From d48bc008f354cf551d82c370013662576179dc2c Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sat, 11 Jul 2026 17:45:51 -0400 Subject: [PATCH 01/38] work on symbol indexer/lookup --- .../com/basic4gl/desktop/BasicEditor.java | 6 +- .../com/basic4gl/desktop/FileManager.java | 32 + .../basic4gl/desktop/IEditorPresenter.java | 2 + .../java/com/basic4gl/desktop/MainWindow.java | 1201 ++++++++++++++++- .../com/basic4gl/desktop/SymbolIndexer.java | 271 ++++ .../main/java/com/basic4gl/desktop/Theme.java | 10 + .../resources/images/colorful/menu_assets.png | Bin 0 -> 639 bytes .../images/colorful/menu_bookmarks.png | Bin 0 -> 307 bytes .../main/resources/images/material/color.txt | 1 + .../images/material/icon_function.png | Bin 0 -> 634 bytes .../resources/images/material/icon_label.png | Bin 0 -> 368 bytes .../images/material/icon_variable.png | Bin 0 -> 571 bytes .../resources/images/material/menu_assets.png | Bin 0 -> 665 bytes .../images/material/menu_bookmarks.png | Bin 0 -> 296 bytes .../material/menu_bookmarks_outline.png | Bin 0 -> 378 bytes .../resources/images/material/menu_debug.png | Bin 0 -> 698 bytes .../resources/images/material/menu_folder.png | Bin 0 -> 284 bytes .../images/material/menu_functions.png | Bin 0 -> 802 bytes .../resources/images/material/menu_help.png | Bin 0 -> 1084 bytes .../basic4gl/compiler/TomBasicCompiler.java | 7 + 20 files changed, 1518 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java create mode 100644 app/src/main/resources/images/colorful/menu_assets.png create mode 100644 app/src/main/resources/images/colorful/menu_bookmarks.png create mode 100644 app/src/main/resources/images/material/color.txt create mode 100644 app/src/main/resources/images/material/icon_function.png create mode 100644 app/src/main/resources/images/material/icon_label.png create mode 100644 app/src/main/resources/images/material/icon_variable.png create mode 100644 app/src/main/resources/images/material/menu_assets.png create mode 100644 app/src/main/resources/images/material/menu_bookmarks.png create mode 100644 app/src/main/resources/images/material/menu_bookmarks_outline.png create mode 100644 app/src/main/resources/images/material/menu_debug.png create mode 100644 app/src/main/resources/images/material/menu_folder.png create mode 100644 app/src/main/resources/images/material/menu_functions.png create mode 100644 app/src/main/resources/images/material/menu_help.png diff --git a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java index d5f11213..2a0e60e6 100644 --- a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java @@ -263,8 +263,10 @@ public void refreshUI() { // Compilation and execution routines public boolean loadProgramIntoCompiler() { - // TODO Get editor assigned as main file - int mainFiledIndex = 0; + int mainFiledIndex = fileManager.getRunnableFileIndex(); + if (mainFiledIndex < 0) { + return false; + } return basic4gl.getPreprocessor() .preprocess(new EditorSourceFile( diff --git a/app/src/main/java/com/basic4gl/desktop/FileManager.java b/app/src/main/java/com/basic4gl/desktop/FileManager.java index 566d7e78..3579677b 100644 --- a/app/src/main/java/com/basic4gl/desktop/FileManager.java +++ b/app/src/main/java/com/basic4gl/desktop/FileManager.java @@ -10,6 +10,7 @@ public class FileManager implements IFileManager { private final Vector fileEditors = new Vector<>(); + private String runnableFilePath; private String currentDirectory; // Current working directory @@ -194,4 +195,35 @@ public void setRunDirectory(String runDirectory) { public Vector getFileEditors() { return fileEditors; } + + public String getRunnableFilePath() { + ensureRunnableFileValid(); + return runnableFilePath; + } + + public void setRunnableFilePath(String runnableFilePath) { + this.runnableFilePath = runnableFilePath; + ensureRunnableFileValid(); + } + + public int getRunnableFileIndex() { + ensureRunnableFileValid(); + if (runnableFilePath == null || runnableFilePath.isBlank()) { + return -1; + } + return getTabIndex(runnableFilePath); + } + + public void ensureRunnableFileValid() { + if (fileEditors.isEmpty()) { + runnableFilePath = null; + return; + } + + if (runnableFilePath != null && !runnableFilePath.isBlank() && getTabIndex(runnableFilePath) != -1) { + return; + } + + runnableFilePath = fileEditors.get(0).getFilePath(); + } } diff --git a/app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java b/app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java index bea35424..965809b7 100644 --- a/app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java +++ b/app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java @@ -9,6 +9,8 @@ interface IEditorPresenter { void onModeChanged(ApMode mode, String statusMsg); + void onCompileSucceeded(); + void refreshDebugDisplays(ApMode mode); void placeCursorAtProcessed(final int line, int col); diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 969f9ac4..4d707af7 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -25,6 +25,8 @@ import java.awt.*; import java.awt.event.*; import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.*; import java.util.List; import java.util.function.BiConsumer; @@ -33,6 +35,9 @@ import javax.swing.border.EmptyBorder; import javax.swing.event.*; import javax.swing.text.BadLocationException; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; import org.fife.ui.rsyntaxtextarea.*; import org.fife.ui.rtextarea.SearchContext; @@ -72,8 +77,76 @@ public void caretUpdate(CaretEvent e) { private final JSplitPane mainPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); private final JSplitPane debugPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); private final JTabbedPane tabControl = new JTabbedPane(); + private final JTabbedPane splitTabControl = new JTabbedPane(); + private final JSplitPane editorSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + private final JPanel primaryTabHost = new JPanel(new BorderLayout()); + private final JButton addTabDropdownButton = new JButton("+"); + private final JSplitPane workspacePane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + private final JSplitPane contentPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + private final JPanel leftSidebarContainer = new JPanel(new BorderLayout()); + private final JPanel leftSidebarContent = new JPanel(new CardLayout()); + private final JToolBar leftSidebarRail = new JToolBar(SwingConstants.VERTICAL); + private final ButtonGroup leftSidebarGroup = new ButtonGroup(); + private final Map leftSidebarButtons = new HashMap<>(); + private final JTabbedPane docsTabs = new JTabbedPane(); + private final JPanel rightDocsContainer = new JPanel(new BorderLayout()); + private final JToolBar rightDocsRail = new JToolBar(SwingConstants.VERTICAL); + private final ButtonGroup rightDocsGroup = new ButtonGroup(); + private final Map rightDocsButtons = new HashMap<>(); + private final JTree fileBrowserTree = new JTree(); + private final DefaultListModel assetsListModel = new DefaultListModel<>(); + private final JList assetsList = new JList<>(assetsListModel); + private final JComboBox runTargetCombo = new JComboBox<>(); + private boolean updatingRunTargetCombo = false; + private final DefaultListModel referenceListModel = new DefaultListModel<>(); + private final JList referenceList = new JList<>(referenceListModel); + private final JTextField referenceSearchField = new JTextField(); + private final JComboBox referenceKindFilter = new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables"}); + private final JComboBox referenceSourceFilter = + new JComboBox<>(new String[] {"All sources", "Builtin", "Libraries", "Program"}); + private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); + private final JTextPane referenceDetailsPane = new JTextPane(); + private final JButton referenceInsertButton = new JButton("Insert"); + private final java.util.List allReferenceItems = new ArrayList<>(); + private final SymbolIndexer symbolIndexer = new SymbolIndexer(this::updateProgramSymbols); + private int expandedLeftSidebarWidth = 260; + private int expandedRightDocsWidth = 320; + private String activeLeftSidebarKey = "files"; + private String activeRightDocsKey = "functions"; private JPanel emptyTabPanel; + private static final class ReferenceItem { + final String kind; + final String name; + final String signature; + final String library; + final String details; + final String insertText; + final int caretOffset; + + ReferenceItem( + String kind, + String name, + String signature, + String library, + String details, + String insertText, + int caretOffset) { + this.kind = kind; + this.name = name; + this.signature = signature; + this.library = library; + this.details = details; + this.insertText = insertText; + this.caretOffset = caretOffset; + } + + @Override + public String toString() { + return signature; + } + } + private final JMenu bookmarkSubMenu = new JMenu("Bookmarks"); private final JMenu breakpointSubMenu = new JMenu("Breakpoints"); private final JMenu helpMenu = new JMenu("Help"); @@ -462,6 +535,11 @@ public void onStepOutRequested() { settingsMenuItem.addActionListener(e -> { showSettings(); }); + + functionListMenuItem.addActionListener(e -> { + selectRightDocsSection("functions"); + }); + aboutMenuItem.addActionListener(e -> showAboutDialog()); if (SystemInfo.isMacOS) { @@ -538,6 +616,7 @@ public void keyReleased(KeyEvent e) { toolBar.add(debugButton); toolBar.addSeparator(); toolBar.add(playButton); + toolBar.add(runTargetCombo); toolBar.add(stepOverButton); toolBar.add(stepInButton); toolBar.add(stepOutButton); @@ -559,6 +638,9 @@ public void keyReleased(KeyEvent e) { exportButton.addActionListener(e -> actionExport()); settingsButton.addActionListener(e -> showSettings()); runButton.setToolTipText("Run the program!"); + runTargetCombo.setToolTipText("Select the runnable source file"); + runTargetCombo.setMaximumSize(new Dimension(260, 30)); + runTargetCombo.addActionListener(e -> onRunTargetSelectionChanged()); toolBar.setAlignmentY(1); toolBar.setFloatable(false); @@ -622,6 +704,8 @@ protected void installDefaults() { // Remove tab tabControl.remove(tabIndex); fileManager.getFileEditors().remove(tabIndex.intValue()); + fileManager.ensureRunnableFileValid(); + refreshRunnableFileControls(); // Refresh controls if no files open if (fileManager.editorCount() == 0) { @@ -631,13 +715,34 @@ protected void installDefaults() { } }); - mainPane.setTopComponent(tabControl); + configurePrimaryTabHost(); + configureSplitTabs(); + configureTabContextMenu(); + configureSidebar(); + configureDocsPane(); + + editorSplitPane.setLeftComponent(primaryTabHost); + editorSplitPane.setRightComponent(splitTabControl); + editorSplitPane.setResizeWeight(0.7); + + mainPane.setTopComponent(primaryTabHost); + debugPane.setLeftComponent(watchListFrame); debugPane.setRightComponent(gosubFrame); + contentPane.setLeftComponent(mainPane); + contentPane.setRightComponent(rightDocsContainer); + contentPane.setResizeWeight(0.74); + + workspacePane.setLeftComponent(leftSidebarContainer); + workspacePane.setRightComponent(contentPane); + workspacePane.setResizeWeight(0.18); + workspacePane.setDividerLocation(expandedLeftSidebarWidth); + contentPane.setDividerLocation(Math.max(200, frame.getPreferredSize().width - expandedRightDocsWidth)); + // Add controls to window frame.add(toolBar, BorderLayout.NORTH); - frame.add(mainPane, BorderLayout.CENTER); + frame.add(workspacePane, BorderLayout.CENTER); frame.add(statusPanel, BorderLayout.SOUTH); frame.setJMenuBar(menuBar); @@ -695,6 +800,9 @@ public void windowDeactivated(WindowEvent e) {} basicEditor.initLibraries(); resetProject(); basicEditor.loadSettings(); + refreshRunnableFileControls(); + populateDocsFromCompiler(); + refreshSidebarContent(); // Warm up the debug server DebugServerFactory.startDebugServer(debugServerBinPath, DebugServerConstants.DEFAULT_DEBUG_SERVER_PORT); @@ -799,6 +907,7 @@ private void tryCloseWindow() { // ShutDownTomWindowsBasicLib(); frame.dispose(); + symbolIndexer.shutdown(); System.exit(0); } @@ -815,6 +924,9 @@ private void resetProject() { // Display the editor tabControl.setSelectedIndex(0); + fileManager.ensureRunnableFileValid(); + refreshRunnableFileControls(); + refreshSidebarContent(); } @Override @@ -864,6 +976,7 @@ void actionNew() { fileManager.getFileEditors().clear(); this.addTab(); + refreshSidebarContent(); } } @@ -903,6 +1016,7 @@ void openEditor(FileEditor editor) { // Display file addTab(editor); + refreshSidebarContent(); } } @@ -999,8 +1113,7 @@ boolean actionSave() { basicEditor.onFileSaving(fileManager.getFileEditors().get(index)); boolean saved = fileManager.getFileEditors().get(index).save(false, fileManager.getCurrentDirectory()); if (saved) { - // TODO Check if index of main file - int main = 0; + int main = fileManager.getRunnableFileIndex(); if (index == main) { fileManager.setFileDirectory( new File(fileManager.getFileEditors().get(index).getFilePath()).getParent()); @@ -1024,8 +1137,7 @@ boolean actionSave(int index) { basicEditor.onFileSaving(fileManager.getFileEditors().get(index)); boolean saved = fileManager.getFileEditors().get(index).save(false, fileManager.getCurrentDirectory()); if (saved) { - // TODO Check if main file - int main = 0; + int main = fileManager.getRunnableFileIndex(); if (index == main) { fileManager.setFileDirectory( new File(fileManager.getFileEditors().get(index).getFilePath()).getParent()); @@ -1051,8 +1163,7 @@ void actionSaveAs() { basicEditor.onFileSaving(fileManager.getFileEditors().get(index)); if (fileManager.getFileEditors().get(index).save(true, fileManager.getCurrentDirectory())) { - // TODO get current main file - int main = 0; + int main = fileManager.getRunnableFileIndex(); if (index == main) { fileManager.setFileDirectory( new File(fileManager.getFileEditors().get(index).getFilePath()).getParent()); @@ -1090,11 +1201,16 @@ public void closeAll() { // Refresh UI refreshActions(basicEditor.getMode()); + refreshRunnableFileControls(); + refreshSidebarContent(); } public void closeTab(int index) { tabControl.remove(index); fileManager.getFileEditors().remove(index); + fileManager.ensureRunnableFileValid(); + refreshRunnableFileControls(); + refreshSidebarContent(); } public void addTab() { @@ -1108,7 +1224,7 @@ public void addTab(FileEditor editor) { fileManager.getFileEditors().add(editor); // replace emptyTabPanel if needed - mainPane.setTopComponent(tabControl); + mainPane.setTopComponent(getActiveEditorHost()); tabControl.addTab(editor.getTitle(), editor.getContentPane()); @@ -1125,7 +1241,7 @@ public void insertUpdate(DocumentEvent e) { int index = getTabIndex(edit.getFilePath()); edit.setModified(); tabControl.setTitleAt(index, edit.getTitle()); - // mTabControl.getTabComponentAt(index).invalidate(); + symbolIndexer.schedule(collectAllSourceText()); } @Override @@ -1133,6 +1249,7 @@ public void removeUpdate(DocumentEvent e) { int index = getTabIndex(edit.getFilePath()); edit.setModified(); tabControl.setTitleAt(index, edit.getTitle()); + symbolIndexer.schedule(collectAllSourceText()); } @Override @@ -1161,6 +1278,10 @@ public void changedUpdate(DocumentEvent e) { if (count == 0) { basicEditor.setMode(ApMode.AP_STOPPED, null); } + + fileManager.ensureRunnableFileValid(); + refreshRunnableFileControls(); + refreshSidebarContent(); } @Override @@ -1259,6 +1380,14 @@ public void setCompilerStatus(String error) { compilerStatusLabel.setText(error); } + @Override + public void onCompileSucceeded() { + populateDocsFromCompiler(); + // Also sync the indexer immediately so the debounced background pass + // reflects the compiled state right away. + symbolIndexer.indexNow(collectAllSourceText()); + } + @Override public void onModeChanged(ApMode mode, String statusMsg) { if (mode != ApMode.AP_CLOSED) { @@ -1353,6 +1482,7 @@ public void refreshActions(ApMode mode) { mainPane.setTopComponent(emptyTabPanel); break; case AP_STOPPED: + mainPane.setTopComponent(getActiveEditorHost()); setClosingTabsEnabled(true); settingsMenuItem.setEnabled(true); settingsButton.setEnabled(true); @@ -1392,6 +1522,7 @@ public void refreshActions(ApMode mode) { case AP_RUNNING: case AP_PAUSED: + mainPane.setTopComponent(getActiveEditorHost()); setClosingTabsEnabled(false); settingsMenuItem.setEnabled(false); @@ -1581,6 +1712,1054 @@ public void onToggleBreakpoint(String filePath, int line) { basicEditor.toggleBreakpt(filePath, line); } + private Component getActiveEditorHost() { + return splitTabControl.getTabCount() == 0 ? primaryTabHost : editorSplitPane; + } + + private void configurePrimaryTabHost() { + addTabDropdownButton.setFocusable(false); + addTabDropdownButton.setToolTipText("Create a new tab or open an asset"); + addTabDropdownButton.setMargin(new Insets(2, 8, 2, 8)); + addTabDropdownButton.addActionListener(e -> showCreateTabMenu(addTabDropdownButton)); + tabControl.putClientProperty(TABBED_PANE_LEADING_COMPONENT, addTabDropdownButton); + primaryTabHost.add(tabControl, BorderLayout.CENTER); + } + + private void configureSplitTabs() { + splitTabControl.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); + splitTabControl.putClientProperty(TABBED_PANE_TAB_CLOSABLE, true); + splitTabControl.putClientProperty( + TABBED_PANE_TAB_CLOSE_CALLBACK, + (BiConsumer) (tabPane, tabIndex) -> { + if (tabIndex >= 0) { + splitTabControl.remove(tabIndex); + if (splitTabControl.getTabCount() == 0 && basicEditor != null && basicEditor.getMode() != ApMode.AP_CLOSED) { + mainPane.setTopComponent(primaryTabHost); + } + } + }); + } + + private void configureTabContextMenu() { + tabControl.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + maybeShowTabPopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowTabPopup(e); + } + }); + } + + private void maybeShowTabPopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + int tabIndex = tabControl.indexAtLocation(e.getX(), e.getY()); + if (tabIndex < 0 || tabIndex >= fileManager.getFileEditors().size()) { + return; + } + + JPopupMenu popup = new JPopupMenu(); + JMenuItem setRunnable = new JMenuItem("Set as runnable file"); + setRunnable.addActionListener(x -> { + fileManager.setRunnableFilePath(fileManager.getFileEditors().get(tabIndex).getFilePath()); + refreshRunnableFileControls(); + }); + popup.add(setRunnable); + + JMenuItem splitPreview = new JMenuItem("Split right"); + splitPreview.addActionListener(x -> openSplitPreview(tabIndex)); + popup.add(splitPreview); + + JMenuItem popOut = new JMenuItem("Pop out tab"); + popOut.addActionListener(x -> popOutTab(tabIndex)); + popup.add(popOut); + + popup.show(tabControl, e.getX(), e.getY()); + } + + private void openSplitPreview(int tabIndex) { + if (tabIndex < 0 || tabIndex >= fileManager.getFileEditors().size()) { + return; + } + File source = fileManager.getFileEditors().get(tabIndex).getFile(); + if (source == null) { + return; + } + + FileEditor preview = FileEditor.open(source, this, fileManager, this, linkGenerator, searchContext); + preview.getEditorPane().setEditable(false); + splitTabControl.addTab(preview.getTitle() + " (split)", preview.getContentPane()); + splitTabControl.setSelectedIndex(splitTabControl.getTabCount() - 1); + mainPane.setTopComponent(getActiveEditorHost()); + SwingUtilities.invokeLater(() -> editorSplitPane.setDividerLocation(0.68)); + } + + private void popOutTab(int tabIndex) { + if (tabIndex < 0 || tabIndex >= fileManager.getFileEditors().size()) { + return; + } + File source = fileManager.getFileEditors().get(tabIndex).getFile(); + if (source == null) { + return; + } + + FileEditor preview = FileEditor.open(source, this, fileManager, this, linkGenerator, searchContext); + preview.getEditorPane().setEditable(false); + + JFrame popout = new JFrame("Pop out: " + preview.getShortFilename()); + popout.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + popout.setLayout(new BorderLayout()); + popout.add(preview.getContentPane(), BorderLayout.CENTER); + popout.setSize(new Dimension(720, 480)); + popout.setLocationRelativeTo(frame); + popout.setVisible(true); + } + + private void showCreateTabMenu(Component anchor) { + JPopupMenu popup = new JPopupMenu(); + + JMenuItem newTabItem = new JMenuItem("New Program Tab"); + newTabItem.addActionListener(e -> addTab()); + popup.add(newTabItem); + + JMenuItem openAssetItem = new JMenuItem("Open Asset or Docs File..."); + openAssetItem.addActionListener(e -> actionOpenAsset()); + popup.add(openAssetItem); + + JMenuItem openReadmeItem = new JMenuItem("Open README.md in docs"); + openReadmeItem.addActionListener(e -> openMarkdownInDocsTab(new File("README.md"))); + popup.add(openReadmeItem); + + popup.show(anchor, 0, anchor.getHeight()); + } + + private void actionOpenAsset() { + JFileChooser chooser = new JFileChooser(); + chooser.setCurrentDirectory(new File(fileManager.getCurrentDirectory())); + int result = chooser.showOpenDialog(frame); + if (result != JFileChooser.APPROVE_OPTION) { + return; + } + + File selected = chooser.getSelectedFile(); + if (selected.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + openMarkdownInDocsTab(selected); + } else { + openTab(selected); + } + } + + private void configureSidebar() { + leftSidebarRail.setFloatable(false); + leftSidebarRail.setRollover(true); + + leftSidebarContent.add(buildFileBrowserPanel(), "files"); + leftSidebarContent.add(buildAssetsPanel(), "assets"); + leftSidebarContent.add(buildBookmarkActionsPanel(), "bookmarks"); + leftSidebarContent.add(buildDebugActionsPanel(), "debug"); + + addLeftSidebarButton("files", createImageIcon(ICON_MENU_FOLDER), "Files"); + addLeftSidebarButton("assets", createImageIcon(ICON_MENU_ASSETS), "Assets"); + addLeftSidebarButton("bookmarks", createImageIcon(ICON_MENU_BOOKMARKS), "Bookmarks"); + leftSidebarRail.add(Box.createVerticalGlue()); + addLeftSidebarButton("debug", createImageIcon(ICON_MENU_DEBUG), "Debug"); + + leftSidebarContainer.add(leftSidebarRail, BorderLayout.WEST); + leftSidebarContainer.add(leftSidebarContent, BorderLayout.CENTER); + selectLeftSidebarSection("files", true); + } + + private JPanel buildFileBrowserPanel() { + JPanel panel = new JPanel(new BorderLayout()); + fileBrowserTree.setRootVisible(true); + fileBrowserTree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() != 2) { + return; + } + TreePath path = fileBrowserTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof File file) || !file.isFile()) { + return; + } + if (file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + openMarkdownInDocsTab(file); + } else { + openTab(file); + } + } + }); + panel.add(new JScrollPane(fileBrowserTree), BorderLayout.CENTER); + return panel; + } + + private JPanel buildAssetsPanel() { + JPanel panel = new JPanel(new BorderLayout()); + assetsList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + assetsList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() != 2) { + return; + } + String selected = assetsList.getSelectedValue(); + if (selected == null) { + return; + } + File file = new File(selected); + if (!file.exists()) { + return; + } + if (selected.toLowerCase(Locale.ROOT).endsWith(".md")) { + openMarkdownInDocsTab(file); + } else { + openTab(file); + } + } + }); + panel.add(new JScrollPane(assetsList), BorderLayout.CENTER); + return panel; + } + + private JPanel buildBookmarkActionsPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + + JButton next = new JButton("Next bookmark"); + next.addActionListener(e -> fileManager.selectNextBookmark(tabControl.getSelectedIndex())); + JButton previous = new JButton("Previous bookmark"); + previous.addActionListener(e -> fileManager.selectPreviousBookmark(tabControl.getSelectedIndex())); + JButton toggle = new JButton("Toggle bookmark"); + toggle.addActionListener(e -> fileManager.toggleBookmark(tabControl.getSelectedIndex())); + + panel.add(next); + panel.add(previous); + panel.add(toggle); + return panel; + } + + private JPanel buildDebugActionsPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + + JButton toggleDebug = new JButton("Toggle debug mode"); + toggleDebug.addActionListener(e -> actionDebugMode()); + JButton playPause = new JButton("Play/Pause"); + playPause.addActionListener(e -> basicEditor.actionPlayPause()); + JButton stepOver = new JButton("Step over"); + stepOver.addActionListener(e -> basicEditor.actionStep()); + JButton stepInto = new JButton("Step into"); + stepInto.addActionListener(e -> basicEditor.actionStepInto()); + JButton stepOut = new JButton("Step out"); + stepOut.addActionListener(e -> basicEditor.actionStepOutOf()); + + panel.add(toggleDebug); + panel.add(playPause); + panel.add(stepOver); + panel.add(stepInto); + panel.add(stepOut); + return panel; + } + + private void configureDocsPane() { + JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); + JPanel lookupHeader = new JPanel(new BorderLayout(6, 6)); + JPanel lookupFilters = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + lookupFilters.add(new JLabel("Type")); + lookupFilters.add(referenceKindFilter); + lookupFilters.add(new JLabel("Source")); + lookupFilters.add(referenceSourceFilter); + lookupFilters.add(new JLabel("Library")); + lookupFilters.add(referenceLibraryFilter); + lookupHeader.add(lookupFilters, BorderLayout.WEST); + lookupHeader.add(referenceSearchField, BorderLayout.CENTER); + referenceInsertButton.setFocusable(false); + referenceInsertButton.setEnabled(false); + lookupHeader.add(referenceInsertButton, BorderLayout.EAST); + + referenceSearchField.setToolTipText("Search by name, signature, or library"); + referenceKindFilter.setToolTipText("Filter by functions or constants"); + referenceSourceFilter.setToolTipText("Filter by builtin tokens or library-provided tokens"); + referenceLibraryFilter.setToolTipText("Filter by library"); + + referenceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + referenceList.setCellRenderer(new DefaultListCellRenderer() { + private final ImageIcon functionIcon = createImageIcon(ICON_FUNCTION); + private final ImageIcon variableIcon = createImageIcon(ICON_VARIABLE); + private final ImageIcon labelIcon = createImageIcon(ICON_LABEL); + + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof ReferenceItem item) { + label.setText(item.signature); + if ("function".equals(item.kind) || "userfunc".equals(item.kind)) { + label.setIcon(functionIcon); + } else if ("label".equals(item.kind)) { + label.setIcon(labelIcon); + } else { + label.setIcon(variableIcon); + } + label.setToolTipText(item.signature + " [" + item.library + "]"); + } + return label; + } + }); + + referenceDetailsPane.setEditable(false); + referenceDetailsPane.setContentType("text/html"); + + JSplitPane lookupSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + lookupSplit.setResizeWeight(0.65); + lookupSplit.setTopComponent(new JScrollPane(referenceList)); + lookupSplit.setBottomComponent(new JScrollPane(referenceDetailsPane)); + + lookupPanel.add(lookupHeader, BorderLayout.NORTH); + lookupPanel.add(lookupSplit, BorderLayout.CENTER); + docsTabs.addTab("Reference", lookupPanel); + + referenceSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + filterReferenceItems(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + filterReferenceItems(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + filterReferenceItems(); + } + }); + referenceList.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + updateReferenceSelectionDetails(); + } + }); + referenceKindFilter.addActionListener(e -> filterReferenceItems()); + referenceSourceFilter.addActionListener(e -> filterReferenceItems()); + referenceLibraryFilter.addActionListener(e -> filterReferenceItems()); + referenceList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + insertSelectedReference(); + } + } + }); + referenceInsertButton.addActionListener(e -> insertSelectedReference()); + + rightDocsRail.setFloatable(false); + rightDocsRail.setRollover(true); + + addRightDocsButton("functions", createImageIcon(ICON_MENU_FUNCTIONS), "Reference lookup"); + addRightDocsButton("docs", createImageIcon(ICON_MENU_HELP), "Markdown docs"); + + rightDocsContainer.add(rightDocsRail, BorderLayout.EAST); + rightDocsContainer.add(docsTabs, BorderLayout.CENTER); + selectRightDocsSection("functions"); + } + + private void addLeftSidebarButton(String key, Icon icon, String tooltip) { + JToggleButton button = createRailButton(icon, tooltip); + button.addActionListener(e -> onLeftSidebarButtonPressed(key)); + leftSidebarGroup.add(button); + leftSidebarButtons.put(key, button); + leftSidebarRail.add(button); + } + + private void addRightDocsButton(String key, Icon icon, String tooltip) { + JToggleButton button = createRailButton(icon, tooltip); + button.addActionListener(e -> onRightDocsButtonPressed(key)); + rightDocsGroup.add(button); + rightDocsButtons.put(key, button); + rightDocsRail.add(button); + } + + private JToggleButton createRailButton(Icon icon, String tooltip) { + JToggleButton button = new JToggleButton(icon); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.setMargin(new Insets(8, 8, 8, 8)); + button.setMaximumSize(new Dimension(38, 38)); + button.setPreferredSize(new Dimension(38, 38)); + return button; + } + + private void onLeftSidebarButtonPressed(String key) { + if (Objects.equals(activeLeftSidebarKey, key) && isLeftSidebarExpanded()) { + collapseLeftSidebar(); + return; + } + selectLeftSidebarSection(key, true); + } + + private void selectLeftSidebarSection(String key, boolean ensureExpanded) { + CardLayout layout = (CardLayout) leftSidebarContent.getLayout(); + layout.show(leftSidebarContent, key); + activeLeftSidebarKey = key; + + JToggleButton button = leftSidebarButtons.get(key); + if (button != null) { + button.setSelected(true); + } + + if (ensureExpanded) { + expandLeftSidebar(); + } + } + + private boolean isLeftSidebarExpanded() { + return workspacePane.getDividerLocation() > leftSidebarRail.getPreferredSize().width + 24; + } + + private void collapseLeftSidebar() { + if (workspacePane.getDividerLocation() > leftSidebarRail.getPreferredSize().width + 24) { + expandedLeftSidebarWidth = workspacePane.getDividerLocation(); + } + workspacePane.setDividerLocation(leftSidebarRail.getPreferredSize().width + 6); + } + + private void expandLeftSidebar() { + int target = Math.max(expandedLeftSidebarWidth, 180); + workspacePane.setDividerLocation(target); + } + + private void onRightDocsButtonPressed(String key) { + if (Objects.equals(activeRightDocsKey, key) && isRightDocsExpanded()) { + collapseRightDocs(); + return; + } + selectRightDocsSection(key); + } + + private void selectRightDocsSection(String key) { + activeRightDocsKey = key; + + JToggleButton button = rightDocsButtons.get(key); + if (button != null) { + button.setSelected(true); + } + + if ("docs".equals(key) && docsTabs.getTabCount() > 1) { + docsTabs.setSelectedIndex(docsTabs.getTabCount() - 1); + } else if (docsTabs.getTabCount() > 0) { + docsTabs.setSelectedIndex(0); + } + + expandRightDocs(); + docsTabs.requestFocusInWindow(); + } + + private boolean isRightDocsExpanded() { + int docsWidth = contentPane.getWidth() - contentPane.getDividerLocation(); + return docsWidth > rightDocsRail.getPreferredSize().width + 28; + } + + private void collapseRightDocs() { + int docsWidth = contentPane.getWidth() - contentPane.getDividerLocation(); + if (docsWidth > rightDocsRail.getPreferredSize().width + 28) { + expandedRightDocsWidth = docsWidth; + } + int collapsedWidth = rightDocsRail.getPreferredSize().width + 8; + contentPane.setDividerLocation(Math.max(120, contentPane.getWidth() - collapsedWidth)); + } + + private void expandRightDocs() { + int targetDocsWidth = Math.max(expandedRightDocsWidth, 220); + int newDivider = Math.max(120, contentPane.getWidth() - targetDocsWidth); + contentPane.setDividerLocation(newDivider); + } + + private void refreshSidebarContent() { + refreshFileBrowserTree(); + refreshAssetsLibrary(); + } + + private void refreshFileBrowserTree() { + File root = new File(fileManager.getCurrentDirectory()); + DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0, 5); + fileBrowserTree.setModel(new DefaultTreeModel(rootNode)); + } + + private DefaultMutableTreeNode buildFileTreeNode(File file, int depth, int maxDepth) { + DefaultMutableTreeNode node = new DefaultMutableTreeNode(file); + if (!file.isDirectory() || depth >= maxDepth) { + return node; + } + + File[] children = file.listFiles(); + if (children == null) { + return node; + } + Arrays.sort(children, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File child : children) { + if (child.getName().startsWith(".")) { + continue; + } + node.add(buildFileTreeNode(child, depth + 1, maxDepth)); + } + return node; + } + + private void refreshAssetsLibrary() { + assetsListModel.clear(); + File root = new File(fileManager.getCurrentDirectory()); + collectAssetFiles(root, 0, 4); + } + + private void collectAssetFiles(File directory, int depth, int maxDepth) { + if (directory == null || !directory.isDirectory() || depth > maxDepth) { + return; + } + File[] files = directory.listFiles(); + if (files == null) { + return; + } + Arrays.sort(files, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File file : files) { + if (file.isDirectory()) { + collectAssetFiles(file, depth + 1, maxDepth); + continue; + } + String name = file.getName().toLowerCase(Locale.ROOT); + if (name.endsWith(".png") + || name.endsWith(".jpg") + || name.endsWith(".jpeg") + || name.endsWith(".gif") + || name.endsWith(".wav") + || name.endsWith(".ogg") + || name.endsWith(".mp3") + || name.endsWith(".txt") + || name.endsWith(".md")) { + assetsListModel.addElement(file.getAbsolutePath()); + } + } + } + + private void refreshRunnableFileControls() { + if (fileManager == null) { + return; + } + + updatingRunTargetCombo = true; + runTargetCombo.removeAllItems(); + + for (FileEditor editor : fileManager.getFileEditors()) { + runTargetCombo.addItem(editor.getShortFilename()); + } + + int runnableIndex = fileManager.getRunnableFileIndex(); + if (runnableIndex >= 0 && runnableIndex < runTargetCombo.getItemCount()) { + runTargetCombo.setSelectedIndex(runnableIndex); + } + + runTargetCombo.setEnabled(runTargetCombo.getItemCount() > 0); + updatingRunTargetCombo = false; + } + + private void onRunTargetSelectionChanged() { + if (updatingRunTargetCombo || fileManager == null) { + return; + } + int index = runTargetCombo.getSelectedIndex(); + if (index >= 0 && index < fileManager.getFileEditors().size()) { + fileManager.setRunnableFilePath(fileManager.getFileEditors().get(index).getFilePath()); + } + } + + // ------------------------------------------------------------------------- + // Symbol indexer support + // ------------------------------------------------------------------------- + + /** + * Concatenates the text of every open editor tab into a single string, separated by newlines. + * This gives the {@link SymbolIndexer} full visibility of all open files for the debounced + * background scan. + */ + private String collectAllSourceText() { + if (fileManager == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (com.basic4gl.desktop.editor.FileEditor fe : fileManager.getFileEditors()) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(fe.getEditorPane().getText()); + } + return sb.toString(); + } + + /** + * Called on the EDT by the {@link SymbolIndexer} callback after each debounce cycle. + * Replaces all "Program" (user-defined) reference items with the freshly scanned symbols and + * refreshes the reference panel. + */ + private void updateProgramSymbols(List symbols) { + // Remove all existing Program-sourced items + allReferenceItems.removeIf(item -> "Program".equals(item.library)); + + // Add newly scanned symbols + for (SymbolIndexer.IndexedSymbol sym : symbols) { + String details; + String insertText; + int caretOffset; + switch (sym.kind) { + case "userfunc" -> { + details = "" + + "

" + escapeHtml(sym.name) + "

" + + "

Type: User Function" + + "
Source: Program

" + + "

" + escapeHtml(sym.signature) + "

" + + ""; + insertText = sym.name + "()"; + caretOffset = sym.name.length() + 1; + } + case "label" -> { + details = "" + + "

" + escapeHtml(sym.name) + "

" + + "

Type: Label" + + "
Usage: gosub " + escapeHtml(sym.name) + "" + + " / goto " + escapeHtml(sym.name) + "

" + + ""; + insertText = sym.name; + caretOffset = sym.name.length(); + } + default -> { // "variable" + details = "" + + "

" + escapeHtml(sym.name) + "

" + + "

Type: Variable" + + "
Source: Program

" + + "

" + escapeHtml(sym.signature) + "

" + + ""; + insertText = sym.name; + caretOffset = sym.name.length(); + } + } + allReferenceItems.add(new ReferenceItem(sym.kind, sym.name, sym.signature, "Program", details, insertText, caretOffset)); + } + + allReferenceItems.sort( + Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(item -> item.kind)); + rebuildLibraryFilterOptions(); + filterReferenceItems(); + } + + private void populateDocsFromCompiler() { + if (basicEditor == null || basicEditor.compiler == null) { + return; + } + Map functionLibraryBySpecIndex = buildFunctionLibraryBySpecIndex(); + Map constantLibraryByName = buildConstantLibraryByName(); + allReferenceItems.clear(); + allReferenceItems.addAll(buildFunctionReferenceItems(basicEditor.compiler, functionLibraryBySpecIndex)); + allReferenceItems.addAll(buildConstantReferenceItems(basicEditor.compiler, constantLibraryByName)); + allReferenceItems.addAll(buildUserFunctionReferenceItems(basicEditor.compiler)); + allReferenceItems.addAll(buildLabelReferenceItems(basicEditor.compiler)); + allReferenceItems.addAll(buildVariableReferenceItems(basicEditor.compiler)); + allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(item -> item.kind)); + rebuildLibraryFilterOptions(); + filterReferenceItems(); + } + + private java.util.List buildFunctionReferenceItems( + TomBasicCompiler comp, Map functionLibraryBySpecIndex) { + java.util.List items = new ArrayList<>(); + for (String key : comp.getFunctionIndex().keySet()) { + for (Integer index : comp.getFunctionIndex().get(key)) { + String name = key; + FunctionSpecification spec = comp.getFunctions().get(index); + String libraryName = functionLibraryBySpecIndex.getOrDefault(index, "Builtin"); + String library = libraryName != null ? libraryName : "Builtin"; + StringBuilder signature = new StringBuilder(); + if (spec.isFunction()) { + signature.append(getTypeString(spec.getReturnType())).append(' '); + } + signature.append(name); + signature.append(spec.hasBrackets() ? "(" : " "); + boolean needComma = false; + Vector params = spec.getParamTypes().getParams(); + StringBuilder argsOnly = new StringBuilder(); + if (params != null) { + for (ValType type : params) { + if (needComma) { + signature.append(", "); + argsOnly.append(", "); + } + String typeName = getTypeString(type); + signature.append(typeName); + argsOnly.append(typeName); + needComma = true; + } + } + if (spec.hasBrackets()) { + signature.append(')'); + } + + String details = "" + + "

" + + escapeHtml(name) + + "

Type: Function
Library: " + + escapeHtml(library) + + "

" + + escapeHtml(signature.toString()) + + "

"; + String insertText = spec.hasBrackets() ? name + "()" : name + " "; + int caretOffset = spec.hasBrackets() ? name.length() + 1 : insertText.length(); + if (spec.hasBrackets() && argsOnly.length() > 0) { + insertText = name + "(" + argsOnly + ")"; + caretOffset = name.length() + 1; + } + items.add(new ReferenceItem("function", name, signature.toString(), library, details, insertText, caretOffset)); + } + } + return items; + } + + private java.util.List buildConstantReferenceItems( + TomBasicCompiler comp, Map constantLibraryByName) { + java.util.List items = new ArrayList<>(); + for (String key : comp.getConstants().keySet()) { + String library = constantLibraryByName.getOrDefault(key.toLowerCase(Locale.ROOT), "Builtin"); + if (library == null) { + library = "Builtin"; + } + String signature = key + " = (" + getTypeString(comp.getConstants().get(key).getType()) + ") " + + comp.getConstants().get(key); + String details = "" + + "

" + + escapeHtml(key) + + "

Type: Constant
Library: " + + escapeHtml(library) + + "

" + + escapeHtml(signature) + + "

"; + items.add(new ReferenceItem("constant", key, signature, library, details, key, key.length())); + } + return items; + } + + private java.util.List buildUserFunctionReferenceItems(TomBasicCompiler comp) { + java.util.List items = new ArrayList<>(); + Map funcIndex = comp.getGlobalUserFunctionIndex(); + java.util.Vector functions = comp.getVM().getUserFunctions(); + java.util.Vector prototypes = comp.getVM().getUserFunctionPrototypes(); + for (Map.Entry entry : funcIndex.entrySet()) { + String name = entry.getKey(); + int funcIdx = entry.getValue(); + com.basic4gl.runtime.stackframe.UserFuncPrototype prototype = null; + if (funcIdx >= 0 && funcIdx < functions.size()) { + int protoIdx = functions.get(funcIdx).prototypeIndex; + if (protoIdx >= 0 && protoIdx < prototypes.size()) { + prototype = prototypes.get(protoIdx); + } + } + StringBuilder signature = new StringBuilder(); + if (prototype != null && prototype.hasReturnVal) { + signature.append(getTypeString(prototype.returnValType)).append(' '); + } + signature.append(name).append('('); + if (prototype != null && prototype.paramCount > 0) { + String[] params = new String[prototype.paramCount]; + for (Map.Entry v : prototype.localVarIndex.entrySet()) { + int idx = v.getValue(); + if (idx < prototype.paramCount && idx < prototype.localVarTypes.size()) { + params[idx] = getTypeString(prototype.localVarTypes.get(idx)) + " " + v.getKey(); + } + } + boolean needComma = false; + for (String param : params) { + if (needComma) signature.append(", "); + signature.append(param != null ? param : "?"); + needComma = true; + } + } + signature.append(')'); + String details = "" + + "

" + escapeHtml(name) + + "

Type: User Function
Source: Program

" + + "

" + escapeHtml(signature.toString()) + "

"; + items.add(new ReferenceItem("userfunc", name, signature.toString(), "Program", details, name + "()", name.length() + 1)); + } + return items; + } + + private java.util.List buildLabelReferenceItems(TomBasicCompiler comp) { + java.util.List items = new ArrayList<>(); + for (String labelName : comp.getLabelNames()) { + String signature = labelName + ":"; + String details = "" + + "

" + escapeHtml(labelName) + + "

Type: Label
Usage: " + + "gosub " + escapeHtml(labelName) + " / goto " + escapeHtml(labelName) + "" + + "

"; + items.add(new ReferenceItem("label", labelName, signature, "Program", details, labelName, labelName.length())); + } + return items; + } + + private java.util.List buildVariableReferenceItems(TomBasicCompiler comp) { + java.util.List items = new ArrayList<>(); + for (com.basic4gl.runtime.VariableCollection.Variable variable : comp.getVM().getVariables().getVariables()) { + if (variable.name == null || variable.name.isEmpty()) continue; + String typeStr = getTypeString(variable.type); + String signature = typeStr + " " + variable.name; + String details = "" + + "

" + escapeHtml(variable.name) + + "

Type: Variable
Data type: " + + escapeHtml(typeStr) + "
Source: Program

"; + items.add(new ReferenceItem("variable", variable.name, signature, "Program", details, variable.name, variable.name.length())); + } + return items; + } + + private Map buildFunctionLibraryBySpecIndex() { + Map functionLibraryBySpecIndex = new HashMap<>(); + if (basicEditor == null || basicEditor.getLibraries() == null) { + return functionLibraryBySpecIndex; + } + + int specCursor = 0; + for (Library library : basicEditor.getLibraries()) { + if (!(library instanceof FunctionLibrary functionLibrary)) { + continue; + } + Map specs = functionLibrary.specs(); + if (specs == null) { + continue; + } + int count = 0; + for (FunctionSpecification[] overloads : specs.values()) { + if (overloads != null) { + count += overloads.length; + } + } + for (int i = 0; i < count; i++) { + String libName = library.name(); + if (libName != null) { + functionLibraryBySpecIndex.put(specCursor + i, library.name()); + } + } + specCursor += count; + } + return functionLibraryBySpecIndex; + } + + private Map buildConstantLibraryByName() { + Map constantLibraryByName = new HashMap<>(); + if (basicEditor == null || basicEditor.getLibraries() == null) { + return constantLibraryByName; + } + + for (Library library : basicEditor.getLibraries()) { + if (!(library instanceof FunctionLibrary functionLibrary)) { + continue; + } + Map constants = functionLibrary.constants(); + if (constants == null) { + continue; + } + for (String name : constants.keySet()) { + String libName = library.name(); + if (libName != null) { + constantLibraryByName.put(name.toLowerCase(Locale.ROOT), library.name()); + } + } + } + return constantLibraryByName; + } + + private void rebuildLibraryFilterOptions() { + String selected = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); + Set libraries = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (ReferenceItem item : allReferenceItems) { + if (item.library != null) { + libraries.add(item.library); + } + } + + referenceLibraryFilter.removeAllItems(); + referenceLibraryFilter.addItem("All libraries"); + for (String library : libraries) { + referenceLibraryFilter.addItem(library); + } + referenceLibraryFilter.setSelectedItem(libraries.contains(selected) ? selected : "All libraries"); + } + + private void filterReferenceItems() { + String query = referenceSearchField.getText(); + String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + String selectedKind = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); + String selectedSource = Objects.toString(referenceSourceFilter.getSelectedItem(), "All sources"); + String selectedLibrary = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); + + referenceListModel.clear(); + for (ReferenceItem item : allReferenceItems) { + boolean kindMatches = "All".equals(selectedKind) + || ("Functions".equals(selectedKind) && ("function".equals(item.kind) || "userfunc".equals(item.kind))) + || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) + || ("Labels".equals(selectedKind) && "label".equals(item.kind)) + || ("Variables".equals(selectedKind) && "variable".equals(item.kind)); + boolean sourceMatches = "All sources".equals(selectedSource) + || ("Builtin".equals(selectedSource) && item.library != null && "Builtin".equalsIgnoreCase(item.library)) + || ("Libraries".equals(selectedSource) && item.library != null && !"Builtin".equalsIgnoreCase(item.library) && !"Program".equalsIgnoreCase(item.library)) + || ("Program".equals(selectedSource) && item.library != null && "Program".equalsIgnoreCase(item.library)); + boolean libraryMatches = "All libraries".equals(selectedLibrary) || (item.library != null && selectedLibrary.equals(item.library)); + if (needle.isEmpty() + || item.name.toLowerCase(Locale.ROOT).contains(needle) + || item.signature.toLowerCase(Locale.ROOT).contains(needle) + || item.kind.toLowerCase(Locale.ROOT).contains(needle) + || (item.library != null && item.library.toLowerCase(Locale.ROOT).contains(needle))) { + if (kindMatches && sourceMatches && libraryMatches) { + referenceListModel.addElement(item); + } + } + } + + if (!referenceListModel.isEmpty()) { + referenceList.setSelectedIndex(0); + } else { + referenceDetailsPane.setText("No matches."); + referenceInsertButton.setEnabled(false); + } + } + + private void updateReferenceSelectionDetails() { + ReferenceItem item = referenceList.getSelectedValue(); + if (item == null) { + referenceDetailsPane.setText("Select an entry."); + referenceInsertButton.setEnabled(false); + return; + } + referenceDetailsPane.setText(item.details); + referenceDetailsPane.setCaretPosition(0); + referenceInsertButton.setEnabled(true); + } + + private void insertSelectedReference() { + ReferenceItem item = referenceList.getSelectedValue(); + if (item == null) { + return; + } + int selectedTab = tabControl.getSelectedIndex(); + if (selectedTab < 0 || selectedTab >= fileManager.getFileEditors().size()) { + return; + } + JTextArea editorPane = fileManager.getFileEditors().get(selectedTab).getEditorPane(); + int insertStart = editorPane.getSelectionStart(); + editorPane.replaceSelection(item.insertText); + editorPane.setCaretPosition(Math.min(insertStart + item.caretOffset, editorPane.getDocument().getLength())); + editorPane.requestFocusInWindow(); + } + + private String getTypeString(ValType type) { + if (type == null) { + return "???"; + } + StringBuilder result = new StringBuilder(); + for (int i = 0; i < type.getVirtualPointerLevel(); i++) { + result.append('&'); + } + result.append(getTypeString(type.basicType)); + for (int i = 0; i < type.arrayLevel; i++) { + result.append("()"); + } + return result.toString(); + } + + private String getTypeString(int type) { + switch (type) { + case BasicValType.VTP_INT: + return "int"; + case BasicValType.VTP_REAL: + return "real"; + case BasicValType.VTP_STRING: + return "string"; + default: + return "???"; + } + } + + private void openMarkdownInDocsTab(File file) { + File resolved = file.isAbsolute() ? file : new File(fileManager.getCurrentDirectory(), file.getPath()); + if (!resolved.exists()) { + resolved = file; + } + if (!resolved.exists()) { + JOptionPane.showMessageDialog(frame, "Markdown file not found: " + file.getPath()); + return; + } + + String tabTitle = resolved.getName(); + for (int i = 0; i < docsTabs.getTabCount(); i++) { + if (tabTitle.equals(docsTabs.getTitleAt(i))) { + docsTabs.setSelectedIndex(i); + selectRightDocsSection("docs"); + return; + } + } + + try { + String markdown = Files.readString(resolved.toPath(), StandardCharsets.UTF_8); + JEditorPane pane = new JEditorPane(); + pane.setEditable(false); + pane.setContentType("text/html"); + pane.setText(markdownToHtml(markdown)); + pane.setCaretPosition(0); + + docsTabs.addTab(tabTitle, new JScrollPane(pane)); + docsTabs.setSelectedIndex(docsTabs.getTabCount() - 1); + selectRightDocsSection("docs"); + } catch (IOException ex) { + JOptionPane.showMessageDialog(frame, "Unable to open markdown file: " + ex.getMessage()); + } + } + + private String markdownToHtml(String markdown) { + StringBuilder html = new StringBuilder(""); + for (String line : markdown.split("\\R", -1)) { + String escaped = escapeHtml(line); + if (escaped.startsWith("### ")) { + html.append("

").append(escaped.substring(4)).append("

"); + } else if (escaped.startsWith("## ")) { + html.append("

").append(escaped.substring(3)).append("

"); + } else if (escaped.startsWith("# ")) { + html.append("

").append(escaped.substring(2)).append("

"); + } else if (escaped.startsWith("- ")) { + html.append("

• ").append(escaped.substring(2)).append("

"); + } else if (escaped.isBlank()) { + html.append("
"); + } else { + html.append("

").append(escaped).append("

"); + } + } + html.append(""); + return html.toString(); + } + + private String escapeHtml(String input) { + if (input == null) { + return ""; + } + return input.replace("&", "&").replace("<", "<").replace(">", ">"); + } + private void setClosingTabsEnabled(boolean enabled) { tabControl.putClientProperty(TABBED_PANE_TAB_CLOSABLE, enabled); // TODO get main file index @@ -1634,6 +2813,8 @@ public void onOpenClick(File file) { @Override public void onCurrentDirectoryChanged(String directory) { basicEditor.onCurrentDirectoryChanged(directory); + // TODO move into editor + refreshSidebarContent(); } @Override diff --git a/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java b/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java new file mode 100644 index 00000000..a369100a --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java @@ -0,0 +1,271 @@ +package com.basic4gl.desktop; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.swing.SwingUtilities; + +/** + * Lightweight debounced symbol indexer. + * + *

Listens for source-text changes and, after a short debounce delay, scans the text for + * user-defined symbols (functions/subs, gosub labels, and dim-declared variables). Results are + * delivered to the supplied {@link Callback} on the Swing EDT, making it safe to update UI + * components directly from the callback. + * + *

Usage: + * + *

{@code
+ * SymbolIndexer indexer = new SymbolIndexer(symbols -> updateReferencePanel(symbols));
+ * // On every document change:
+ * indexer.schedule(getAllEditorText());
+ * // On window close:
+ * indexer.shutdown();
+ * }
+ */ +public class SymbolIndexer { + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** A single symbol discovered in the source. */ + public static final class IndexedSymbol { + /** Kind tag: {@code "userfunc"}, {@code "label"}, or {@code "variable"}. */ + public final String kind; + /** The bare symbol name (no punctuation). */ + public final String name; + /** Human-readable signature shown in the reference panel. */ + public final String signature; + + public IndexedSymbol(String kind, String name, String signature) { + this.kind = kind; + this.name = name; + this.signature = signature; + } + } + + /** Receives indexed symbols on the Swing EDT after each debounce cycle. */ + public interface Callback { + void onIndexed(List symbols); + } + + // ------------------------------------------------------------------------- + // Configuration + // ------------------------------------------------------------------------- + + /** Milliseconds to wait after the last change before running the indexer. */ + private static final long DEBOUNCE_MILLIS = 400; + + // ------------------------------------------------------------------------- + // Patterns (case-insensitive, multiline) + // ------------------------------------------------------------------------- + + // function/sub header: "function Foo(int x, string y)" or "sub Bar()" + private static final Pattern FUNC_PATTERN = + Pattern.compile( + "^[ \\t]*(?:function|sub)[ \\t]+(\\w+)[ \\t]*\\(([^)]*?)\\)", + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE); + + // label declaration: "myLabel:" (at start of a non-blank line, optional leading whitespace) + // Excludes lines that look like "keyword:" to avoid false positives with type annotations. + private static final Pattern LABEL_PATTERN = + Pattern.compile( + "^[ \\t]*(\\w+)[ \\t]*:[ \\t]*(?:$|'|rem[ \\t])", + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE); + + // dim declaration: "dim x", "dim x as integer", "dim x(10)" + // Also handles "dim x as integer()" array types. + private static final Pattern DIM_PATTERN = + Pattern.compile( + "^[ \\t]*dim[ \\t]+(\\w+)(?:[ \\t]*\\([^)]*\\))?(?:[ \\t]+as[ \\t]+(\\w+(?:[ \\t]*\\([ \\t]*\\))?))?", + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE); + + // ------------------------------------------------------------------------- + // State + // ------------------------------------------------------------------------- + + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r, "symbol-indexer"); + t.setDaemon(true); + return t; + }); + + private ScheduledFuture pending; + private final Callback callback; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + public SymbolIndexer(Callback callback) { + this.callback = callback; + } + + // ------------------------------------------------------------------------- + // Public methods + // ------------------------------------------------------------------------- + + /** + * Schedules an indexing pass for the given source text. + * + *

Calling this method again before the debounce window expires cancels the previous + * scheduled pass and restarts the timer — only one indexing pass runs per idle period. + * + *

This method is thread-safe and may be called from any thread. + * + * @param source the full source text to index (may span multiple concatenated files) + */ + public synchronized void schedule(String source) { + if (pending != null && !pending.isDone()) { + pending.cancel(false); + } + pending = + scheduler.schedule( + () -> { + List result = scan(source); + SwingUtilities.invokeLater(() -> callback.onIndexed(result)); + }, + DEBOUNCE_MILLIS, + TimeUnit.MILLISECONDS); + } + + /** + * Triggers an immediate (non-debounced) indexing pass. + * + *

Useful after a successful full compile when up-to-date symbols are already available but + * the indexer should also refresh its last-known symbol set for subsequent incremental updates. + * + * @param source the full source text to index + */ + public synchronized void indexNow(String source) { + if (pending != null && !pending.isDone()) { + pending.cancel(false); + } + pending = + scheduler.schedule( + () -> { + List result = scan(source); + SwingUtilities.invokeLater(() -> callback.onIndexed(result)); + }, + 0, + TimeUnit.MILLISECONDS); + } + + /** + * Shuts down the background scheduler. + * + *

Call this when the owning window is disposed to release the daemon thread. + */ + public void shutdown() { + scheduler.shutdownNow(); + } + + // ------------------------------------------------------------------------- + // Scanning + // ------------------------------------------------------------------------- + + /** Scans {@code source} and returns every discovered symbol. */ + private List scan(String source) { + List symbols = new ArrayList<>(); + if (source == null || source.isEmpty()) { + return symbols; + } + + // --- User functions and subs --- + Matcher m = FUNC_PATTERN.matcher(source); + while (m.find()) { + String name = m.group(1); + String params = m.group(2).trim(); + // Normalise whitespace inside parameter list + params = params.replaceAll("[ \\t]+", " "); + String sig = name + "(" + params + ")"; + symbols.add(new IndexedSymbol("userfunc", name, sig)); + } + + // --- Gosub / goto labels --- + m = LABEL_PATTERN.matcher(source); + while (m.find()) { + String name = m.group(1).trim(); + if (!isReservedWord(name)) { + symbols.add(new IndexedSymbol("label", name, name + ":")); + } + } + + // --- Dim-declared variables --- + m = DIM_PATTERN.matcher(source); + while (m.find()) { + String name = m.group(1); + String type = m.group(2); // may be null + String sig = (type != null && !type.isBlank()) ? type.trim() + " " + name : name; + symbols.add(new IndexedSymbol("variable", name, sig)); + } + + return symbols; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Returns {@code true} if {@code word} is a Basic4GL reserved keyword. + * + *

This is a fast approximation; only common keywords that would otherwise produce false + * label matches are listed here. + */ + private static boolean isReservedWord(String word) { + if (word == null) return false; + return switch (word.toLowerCase()) { + case "dim", + "goto", + "if", + "then", + "else", + "elseif", + "endif", + "end", + "gosub", + "return", + "for", + "to", + "step", + "next", + "while", + "wend", + "run", + "struc", + "endstruc", + "const", + "alloc", + "null", + "data", + "read", + "reset", + "type", + "function", + "sub", + "true", + "false", + "and", + "or", + "not", + "xor", + "mod", + "rem", + "integer", + "single", + "double", + "string" -> true; + default -> false; + }; + } +} + diff --git a/app/src/main/java/com/basic4gl/desktop/Theme.java b/app/src/main/java/com/basic4gl/desktop/Theme.java index 5ee54d1e..1d63d3f5 100644 --- a/app/src/main/java/com/basic4gl/desktop/Theme.java +++ b/app/src/main/java/com/basic4gl/desktop/Theme.java @@ -17,4 +17,14 @@ public class Theme { public static final String ICON_STEP_OUT = THEME_DIRECTORY + "icon_step_out.png"; public static final String ICON_EXPORT = THEME_DIRECTORY + "icon_export.png"; public static final String ICON_SETTINGS = THEME_DIRECTORY + "icon_settings.png"; + public static final String ICON_MENU_FOLDER = THEME_DIRECTORY + "menu_folder.png"; + public static final String ICON_MENU_ASSETS = THEME_DIRECTORY + "menu_assets.png"; + public static final String ICON_MENU_BOOKMARKS = THEME_DIRECTORY + "menu_bookmarks.png"; + public static final String ICON_MENU_BOOKMARKS_OUTLINE = THEME_DIRECTORY + "menu_bookmarks_outline.png"; + public static final String ICON_MENU_FUNCTIONS = THEME_DIRECTORY + "menu_functions.png"; + public static final String ICON_MENU_HELP = THEME_DIRECTORY + "menu_help.png"; + public static final String ICON_MENU_DEBUG = THEME_DIRECTORY + "menu_debug.png"; + public static final String ICON_FUNCTION = THEME_DIRECTORY + "icon_function.png"; + public static final String ICON_VARIABLE = THEME_DIRECTORY + "icon_variable.png"; + public static final String ICON_LABEL = THEME_DIRECTORY + "icon_label.png"; } diff --git a/app/src/main/resources/images/colorful/menu_assets.png b/app/src/main/resources/images/colorful/menu_assets.png new file mode 100644 index 0000000000000000000000000000000000000000..6cf8893b5c9058a14c93795012be7c1d75486fd1 GIT binary patch literal 639 zcmV-_0)YLAP)4e-6#m|>Vi^!LczYU2V+75ur(h+)!a_k*(AxM5G+2v4EbSD;Qb=o|AXrIkCAvAp z6D?BY_D)4a6boba<~i@!ImUgrxkM6&`R;r3=G*t}n}xXmTRi@s!D0}xz%?Xs5=4y! zjE}h7zp&~+3JauIKz!pEmcA!p<($pEv*^9VTHFYtW)fD<$nyeaAW4U?yZT&WVtQbC z!eZ!T;2<&IbwXtjn>GnVa0p+13R{d?Y-El*u`+NQo5jk&o$rYA029k8x9%iQ`CZemBKY;lJGp33F(|)@@a4G|F7Kqvwy3@eu?pil}WIH=x zL4$C_q_d)Ta2i=F14(+=#lf~jF0Z$!?)38O4wZrLo>!^Z%j@ZCz+a^^4=b-xoys+( zqzp=xdgFE->5Dh0>|Lbji8cO3;A=&hGO)tlonI6d*JQFe$z>ttKsamT9Tw`(Nf}tt zy5@U)UKSxS9ToQQ+l!$L27Ciw-;XD-Z>+cA!WF?Z-^=GeeS>0Qw=UyJEH6ookohv0 zp8@p4cJtUUU4b|H23~GM;bC-IF?UYKCLO79dcKZU$1m^)00960v*(lE00006NklTnrdjOGM;+~3m<)6 z%$S|LpQ9&3+(fB{^VIH4x8ilV+msY9Ff-QXH9nmpE*LiF7@Njphlh7>KNUO0WbxiT zMs~s4wd+=_^SIq`?SuV#4N-=DE|w}6*6i{W-SWNCm4lrjCN1*XtRvH`{m$q%rakg# z-o4yrxr#%J#sSkwlV2W@%`bZ?D_`#@lleqZzk#FCa?-;Cowpaiy!k12!}>XKPfj?; z-O~K@^9}kzei4lZY{bIGfbj$TAmMIH@}+9DQWAsWF#1@E$ZZ=AR5i6_}>6o;9ekN4)s?C>o7Bl$m|gIPGK zTw>VG+qq=H+176O*olTFv8Btl-+=fIgG$7~J2$6J$H8Jj+E?cRS_6%JiNNnB=DWIs zUn53hL0}9ruuz%yUp<(?=du@^s!jz@V>pNh5qgR7DX#I)dJAZ9O!d2k#6@Frv4;tL zCQcPPk4xTGTR@hDJv!{ws?5+bXDfM_DWQTsK~>?|5iEl7j$P5L&< z{%%)_EB7Q0H3XUG@8<2ffph2`n80QtO3Ues#Tk4TAL`)(*47+#9|T7VkL5Sh$-Fu% zai}3kvvwHP0b8l}cA%E!(hiF`8{YW&>11_TrrA$gACu*$q>n=C9#P+4FD6 z@~$sY5Qg7L?i95&g@s^cX=Se$P#Zz8$PY*@SmmtOdey>zU@ZzZTKJJ8wVkD%Vj)_J zqL%>s#kZnZFsN-FD||`r%mc{UHUm$tT6Pg$n0ce8Kkvh~2i)wN zi%jQhWjpXwTg~?RSE0DbJ)n5}#YYBw0{{U3|F+LJ2LJ#721!IgR09BjaCsrUUT5S0 O0000u4^P)4f25dLOwcSFQZ4M?yQImMJgLE$ck2o{p`7U@i3kwiE3frWT`8nN>Sh}bB^LWt&E zBO0(!F(6S38!e*5%G{B6mp6XzUG6wR4@4g`JM(=r`!VxgfPXyyRX~bK{IGN}KHP7p zvAJlpkc9U-J>3adn1t#H6Ps_H;_srWMgo~A&J7%L^{xOEiHh8Kb_IO}o}{hk{P8=i zJQO4dh*}xX%J|{xTy;aCLoW9M0?7mN9z92_-XuICifOJknO!q$ z#2t&6H0kXXM0F2Uf8E{YHrQb4>xEsWd+WqBVZW?1X$vTV7=pSZoV#6Z&MpXafq3X? zJsxN5G0m&sog;Qu(64~*bp%~xGrY|hh1xpD$?nrUm zz(JAVb)Yh_SjDD_To#eX$7e9;>QH;0@zrv=^^vi?!gelr1jsO-ZFK4S)`wcfl$WaJ zEqJoD`NrXuc{EN=V?U`WUV4?$o1b9$s#)c;Jn&4PPaMYkI8jzh&7Dr07jPg;TW4#P z=1Z6V$=y_0#hmG_cLjRMzsY|B009608$4qu00006NkloFjxsEFc4#iIvL(vt@ZqJjaf5$j)IHwp!%W|HcmVzYvxcoed+P&_CI zo+Mrc5ib!v2wLpTgW@64WS{TtW|GamCQVD*VZQlh-hBJM`H|f?u)$^X3@*Ak64(t% zKjY>;rl+P-HntX4JCMYJFd5?X!tCs?arf3~j(uW;i&%lH<5@EnKF^IZ;8?rXYw$(} z2+k7#IK&vF9!8=ER;UboFOLEdEIcxQgaK%0!~?entWp`+iS=S-V9$5M4Av{L&7*c8 z=P$5=0k}#44}0m9K`&t^j*9@811<JU8=RCVkILl#7MO((eal;K$yB%;b$-(Zl#d z7zxg0TvJJc-`okA&t#3UCm<{_Wgx*eoRJgKM+~m=MScOG;{-IC1Si=qy5+!g-C=PS zgi(Tfm#&)~rDEZE*~=fayGwGZXiTVYHFjQx%0Pk^1hhS9&<8$(v9xQp+4yQ$8N}Vg zLu;jDFYS_RBPWpe-DKOM?>y3e>+_OWuWdLu8#GG_Z-<5 z4KewNs6_FoZ5t#L+i@>0-#1?+MHByBW`5214S+A2!*~T^rEL(4t*Sd;ytBxQILS!) zo;fpr*DS>o{09I4|NjeTkPQF;00v1!K~w_()iD`BZ=FTz00000NkvXXu0mjfUX>!} literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/menu_bookmarks.png b/app/src/main/resources/images/material/menu_bookmarks.png new file mode 100644 index 0000000000000000000000000000000000000000..f323c8c682c81246710b9090874489f671c438d4 GIT binary patch literal 296 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjcRgJkLn;{G1R3%*ISA~r_P)QM zRd%D^was z@rkC4s~^&~Uh z-FxQ|k|x}-bL^X$w*$+;MYQ5)O#@n&a@LBwV}n-2*j>qqZ`=MAF$obh4OOejsso?d zA3%BlbK3856~JrQHxw!mQ1C5G)B*plu3k~SqWKNDPG=jKXUk8Oz9M&hv*+UOfWNoi z0rcR?N6*yKfb&=mT`bQ$-F#!B3%k+)(1U{pk8!~&4VaR?FzKn?{$=JgB0d7pXPjy9 zSRbq?sLX_k4eLc4yz_W3?O|VD0rcRYnQdYSRw!Vks_n+t$F2>N;hlWuCxC+n54@(2 z42ALr*gxR->ESj_lH{jwBS*jkFYe@|?C1^1lqJ1Z9`FYM0RR8PcDY>u000I_L_t&o Y0F;`EG0$5Ag8%>k07*qoM6N<$f=somcK`qY literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/menu_debug.png b/app/src/main/resources/images/material/menu_debug.png new file mode 100644 index 0000000000000000000000000000000000000000..864aafac8047fe88a642e4542fa736ffe150bcca GIT binary patch literal 698 zcmV;r0!96aP)T z7f)(MP!umk1VKEg_!ktUwe?V&Y<`S0NnYK&x4R0Zmkv8S-(kj-WjRug7u!~KBp46r$vK+(_67(l!LfCHl1`_y$^8zRgQpqGm5%Y8e1 z+We3J@!Tus&v?a~vk!}MiJh@mkVDWC!K2QueRb|(2h9utjR$%t6MAlSE#THFjsS*$ zSI7%?s;6H7;3rnT5eA+v=$u2R@|`1qSm7Ra(994B@DAfAQ&Yb)H@dQ|cS5302hjs6InLHPYu*pBe0q%Dg&pAUt zO-Sd=CN98AVlPKEp^BVS9O2EXk8syz2z0Tuu!MDuLg-})`+6u5NE!la!oKA>`b@=p zsvV*OZYDD#kGOPf4_~^vjd3P7PDcfv1P~v%R`r2qhJfNrrBcnZ;Vc4t1i;gXIP3Vm zOAW`FXH5B1^qxKm^v{*3R`uyIQh*B|-IhNV>purk;6>W-Er8%Z)_ezFSg0=?eq5ZG zgBXhy;J%mB)7A2w8+Xfv{Bf@!2fRXldk5cRj%(ulp8|2s|HG}jz#jkr0RR8yo1AF? g000I_L_t&o06`8BK)O*IyZ`_I07*qoM6N<$f*F=P0RR91 literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/menu_folder.png b/app/src/main/resources/images/material/menu_folder.png new file mode 100644 index 0000000000000000000000000000000000000000..4c01ffb0b534c565e3bcccdfc1684385c38bff09 GIT binary patch literal 284 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj7d>4ZLn;{GObO&VWWdvQ`R<`` z$1AIUyqbMmbsO`IiYCr^3-jltYgA01v29z=+BFGxo7XJPbQ9Y*|KA$TglFp9F1+8w zLeKt;xb{OM+jHRxsUsS?7ap*7bV)PhGTSb5arq=IsDI$YR7KyMD3y|3j%%$=D+*@J zYtUAGzE>g1Tqj*+!L4o&&hFN)>W4iJ)cy!P#}uf(*_C_qip$Ss&rE%gGWXR*9=^km z?(9CPq9PtA)9Gzzopr0N#;!jsO4v literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/menu_functions.png b/app/src/main/resources/images/material/menu_functions.png new file mode 100644 index 0000000000000000000000000000000000000000..e7821ff4e4fcf7d89e1efaec1769a370ec999813 GIT binary patch literal 802 zcmV+-1Ks?IP)8 z&ubGw6#m}USZOU4MHDUg2dETJQbp8SlonC3TC|B$JOmLcNj!Lvq?6<#2uVD6FqeuZ zshUzy57m09h`+!~K@?OxS!?xT4r$dk`+T!*)|N<;f;~CBZ{Lr3`@WqwGizXx$A3iN z34}V`zP^69zweQ1omfQDwIyB!TS8Co#LNLB_abdRaPnzFnFV`z$U-aS|gcE z&ZOdzer;DmMFJ_XRsddNe$jfNsDgmj>!LFat_iIdiV^}(AlNL&kc*Jbbm^i?bNoZc zJpR5h?N^Huf@)u^Hm`&r6^o9g<6+VOQHx#ZsrEHe@o=+_{l0j??bG)QyNzOi=%U9T+@Z}P9D>;#Zp$*ScmgUVM0@2l zTo!+;Bz~A^pm|@_-rHN-l*14c>mT2=w5S7&yOO({Vcb9AQ>|iK;t-gGA%JF>*)4@1 z05~gnL!f=S9b!AO(zR*}1053hZlchhPDF;sS5T+;QUi5N@WCDqf&3}2)ets;=_=%F zgy`hYkFPhU;_AJf>nPo4*`F@3*)|Z5KGGATO3g}s7ZHW)@;Ihr8V-SWuE*5dbV8j@ zC&C9aiO7v?He1l~zaf)QL+M2H$=o@epNc7!Osc73d>%nD_%HRM6Z`@I0RR8j-4Z7N g000I_L_t&o0H~1=K>l*uf&c&j07*qoM6N<$g1uO93jhEB literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/menu_help.png b/app/src/main/resources/images/material/menu_help.png new file mode 100644 index 0000000000000000000000000000000000000000..02a27cf844313d7c26c4cc775bb2945324eb4464 GIT binary patch literal 1084 zcmV-C1jGA@P)=pqD%l5X5F)6aM zSdEDiC9>1@fd|AYPa1#HG(MOJL5(yqQQ}XG#zh}2OXnWX8E1!fyH#EcH|Lx4-E;0e z-<`Yn&N}#?+vOGL>FG(Na)s`6*6hpV2Hs2O%+C=f(z$_RZ?5o=G3H+vxo9$#HLF@~ zymg8|$3VP?fd>G-F2W|+*}MZfK7EcqO>N(^Li$b3X%>}LK1IZ*z_?xV-vhMY!fr@1 zU1J?urkp^Y#D4(LMbe(7VZT%3~PyM?#ez&#?K1fj(O)74YL%QtXWAm z`I+R_$&b2Y)Ekn!Aic=kr9rJuBab-(_yW>wuw<3ErY48I1ij6~`O&hOw|bpWtF?8g z7lgIM&kCdv?bG6y3%*WX zjS2Cg#R7+onQ_QIBGDQ#ztWe@uMvJpCKs!d$k{K)^E@1SSZmcM#sV6sl!_+_yi@p6 zONlA{ObCxnw?|PA=vHR3g0bEUv%6&0S#2Vp@Sl_1t15^@5+{ZD|j)J z+k1DMv|e-v_%E#<0}XEedCvg-jG6lf)&1J^bcrrrECtpT{3d& z>LZ)`{}gCG?b4%v0{{U3|9s<|(f|Me21!IgR09CB-+Dltd?@<>0000 literal 0 HcmV?d00001 diff --git a/compiler/src/main/java/com/basic4gl/compiler/TomBasicCompiler.java b/compiler/src/main/java/com/basic4gl/compiler/TomBasicCompiler.java index 5e5ede15..8f69c3a3 100644 --- a/compiler/src/main/java/com/basic4gl/compiler/TomBasicCompiler.java +++ b/compiler/src/main/java/com/basic4gl/compiler/TomBasicCompiler.java @@ -742,6 +742,13 @@ public boolean isUnaryOperator(String text) { public boolean isOperator(String text) { return isBinaryOperator(text) || isUnaryOperator(text); } + /** + * Returns an unmodifiable view of the label names defined in the compiled program. + * Labels are GoSub/Goto targets (e.g. "myLabel:"). + */ + public Set getLabelNames() { + return Collections.unmodifiableSet(labels.keySet()); + } public long getTokenLine() { return token.getLine(); From 5edfa4ad07f8c92bf8241297fec67d49354576a0 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Fri, 22 May 2026 01:02:07 -0400 Subject: [PATCH 02/38] lexer for symbol lookup and highlighting --- .../basic4gl/desktop/spi/LanguageService.java | 9 + .../spi/language/FunctionDefinition.java | 11 + .../desktop/spi/language/LabelDefinition.java | 3 + .../desktop/spi/language/TypeDefinition.java | 3 + .../spi/language/VariableDefinition.java | 13 + app/build.gradle | 20 + app/src/main/antlr/Basic4GL.g4 | 152 +++++ .../java/com/basic4gl/desktop/MainWindow.java | 379 ++++------- .../desktop/ProjectSettingsDialog.java | 2 +- .../com/basic4gl/desktop/SymbolIndexer.java | 246 ++----- .../desktop/editor/BasicTokenMaker.java | 503 +-------------- .../editor/LanguageSupportTokenMaker.java | 154 +++++ .../language/Basic4GLLanguageSupport.java | 355 ++++++++++ .../desktop/language/HighlightKind.java | 54 ++ .../desktop/language/IndexedSymbol.java | 10 + .../basic4gl/desktop/language/LangToken.java | 29 + .../desktop/language/LanguageSupport.java | 82 +++ .../adapter/Basic4GLLanguageService.java | 222 ++++++- .../adapter/ProjectSettingsDialog.java | 610 ------------------ .../language/adapter/util/LanguageUtil.java | 58 ++ 20 files changed, 1375 insertions(+), 1540 deletions(-) create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/language/FunctionDefinition.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/language/LabelDefinition.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/language/TypeDefinition.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/language/VariableDefinition.java create mode 100644 app/src/main/antlr/Basic4GL.g4 create mode 100644 app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java create mode 100644 app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java create mode 100644 app/src/main/java/com/basic4gl/desktop/language/HighlightKind.java create mode 100644 app/src/main/java/com/basic4gl/desktop/language/IndexedSymbol.java create mode 100644 app/src/main/java/com/basic4gl/desktop/language/LangToken.java create mode 100644 app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java delete mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/ProjectSettingsDialog.java create mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java index 5e8fc8b3..89da8582 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java @@ -2,6 +2,10 @@ import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.types.StackFrame; +import com.basic4gl.desktop.spi.language.FunctionDefinition; +import com.basic4gl.desktop.spi.language.LabelDefinition; +import com.basic4gl.desktop.spi.language.VariableDefinition; + import java.util.ArrayList; import java.util.List; @@ -28,4 +32,9 @@ public interface LanguageService { int getSourceFromMain(String filename, int sourceLine); FileLineNumber getFileLineNumberFromMain(int sourceLine); + + Iterable getVariableDefinitions(); + Iterable getConstantDefinitions(); + Iterable getLabelDefinitions(); + Iterable getFunctionDefinitions(); } diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/FunctionDefinition.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/FunctionDefinition.java new file mode 100644 index 00000000..a3c03419 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/FunctionDefinition.java @@ -0,0 +1,11 @@ +package com.basic4gl.desktop.spi.language; + +public record FunctionDefinition ( + String name, + String signature, + VariableDefinition type, + VariableDefinition[] parameters, + String description, + String packageName, + boolean hasBrackets){ +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LabelDefinition.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LabelDefinition.java new file mode 100644 index 00000000..e8e7609d --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LabelDefinition.java @@ -0,0 +1,3 @@ +package com.basic4gl.desktop.spi.language; + +public record LabelDefinition(String name, String signature, String usage) {} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/TypeDefinition.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/TypeDefinition.java new file mode 100644 index 00000000..fc69820a --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/TypeDefinition.java @@ -0,0 +1,3 @@ +package com.basic4gl.desktop.spi.language; + +public record TypeDefinition(String name, String description, String packageName, String scope) {} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/VariableDefinition.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/VariableDefinition.java new file mode 100644 index 00000000..c543d2e8 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/VariableDefinition.java @@ -0,0 +1,13 @@ +package com.basic4gl.desktop.spi.language; + +public record VariableDefinition( + String name, + String signature, + TypeDefinition type, + String value, + String description, + String packageName, + boolean readOnly, + String scope, + String source) { +} \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 566c02fb..723e61b3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,6 +3,19 @@ plugins { id("com.diffplug.spotless") version "7.0.2" } apply plugin: "java" +apply plugin: "antlr" + +// Generate ANTLR lexer sources into the correct package directory so that +// IDEs resolve the generated class immediately after the first build. +generateGrammarSource { + maxHeapSize = "64m" + arguments += ["-package", "com.basic4gl.desktop.language", "-long-messages"] + // Keep generated sources alongside other build outputs; Spotless ignores build/ + outputDirectory = + file("${project.buildDir}/generated-src/antlr/main/com/basic4gl/desktop/language") +} +// Spotless must not try to format ANTLR-generated sources. +// The spotless java {} block is configured later in the file; we override target here. configurations { release.extendsFrom configurations.default @@ -61,6 +74,10 @@ startScripts { } dependencies { + // ANTLR4: code generator (build-time only) + runtime JAR shipped with the app + antlr "org.antlr:antlr4:4.13.2" + implementation "org.antlr:antlr4-runtime:4.13.2" + implementation fileTree(dir: "libs", include: ["*.jar"]) // TODO figure out a clean way to depend on the JAR output of these projects; @@ -110,6 +127,9 @@ spotless { formatAnnotations() trimTrailingWhitespace() endWithNewline() + + // Exclude ANTLR-generated sources from formatting checks. + targetExclude fileTree("${project.buildDir}/generated-src") } } diff --git a/app/src/main/antlr/Basic4GL.g4 b/app/src/main/antlr/Basic4GL.g4 new file mode 100644 index 00000000..6e23d006 --- /dev/null +++ b/app/src/main/antlr/Basic4GL.g4 @@ -0,0 +1,152 @@ +/** + * Basic4GL lexer grammar. + * + * Single source of truth for the Basic4GL language token vocabulary. The + * generated Basic4GLLexer is consumed by: + * + * 1. LanguageSupportTokenMaker – drives RSyntaxTextArea syntax highlighting + * 2. Basic4GLLanguageSupport – drives SymbolIndexer (user functions, labels, variables) + * + * Adding a new keyword here automatically gives it correct highlighting and + * correct exclusion from label/symbol heuristics – no other file needs touching. + * + * The grammar is intentionally a lexer-only grammar; a full parser grammar can be + * layered on top later when AST-level features (e.g. go-to-definition, refactoring) + * are desired. + */ +lexer grammar Basic4GL; + +// --------------------------------------------------------------------------- +// Preprocessor directive (must precede HASH so '#include' wins max-munch) +// --------------------------------------------------------------------------- + +INCLUDE_DIR : '#' I N C L U D E [ \t]* ; + +// --------------------------------------------------------------------------- +// Comments +// --------------------------------------------------------------------------- + +COMMENT : '\'' ~[\r\n]* ; + +// 'rem' followed by optional whitespace+rest-of-line is a comment. +// Max-munch ensures "remember_var" → IDENTIFIER (longer match wins). +REM_COMMENT : R E M ([ \t] ~[\r\n]*)? ; + +// --------------------------------------------------------------------------- +// Keywords (case-insensitive via letter fragments at the bottom) +// Longer alternatives are listed before shorter ones with the same prefix so +// that ANTLR's max-munch selects the correct rule without ambiguity. +// --------------------------------------------------------------------------- + +ELSEIF_KW : E L S E I F ; +ENDIF_KW : E N D I F ; +ENDSTRUC_KW : E N D S T R U C ; +FUNCTION_KW : F U N C T I O N ; +GOSUB_KW : G O S U B ; +GOTO_KW : G O T O ; +INTEGER_T : I N T E G E R ; +ALLOC_KW : A L L O C ; +CONST_KW : C O N S T ; +DATA_KW : D A T A ; +DOUBLE_T : D O U B L E ; +SINGLE_T : S I N G L E ; +STRING_T : S T R I N G ; +RESET_KW : R E S E T ; +RETURN_KW : R E T U R N ; +STRUC_KW : S T R U C ; +FALSE_KW : F A L S E ; +WHILE_KW : W H I L E ; +WEND_KW : W E N D ; +AS_KW : A S ; +DIM_KW : D I M ; +ELSE_KW : E L S E ; +END_KW : E N D ; +FOR_KW : F O R ; +INT_T : I N T ; +NEXT_KW : N E X T ; +NULL_KW : N U L L ; +READ_KW : R E A D ; +RUN_KW : R U N ; +STEP_KW : S T E P ; +SUB_KW : S U B ; +THEN_KW : T H E N ; +TO_KW : T O ; +TRUE_KW : T R U E ; +TYPE_KW : T Y P E ; +AND_KW : A N D ; +MOD_KW : M O D ; +NOT_KW : N O T ; +XOR_KW : X O R ; +IF_KW : I F ; +OR_KW : O R ; + +// --------------------------------------------------------------------------- +// Literals +// --------------------------------------------------------------------------- + +STRING_LIT : '"' ~["\r\n]* '"' ; +HEX_LIT : '0' X [0-9A-Fa-f]+ ; +FLOAT_LIT : [0-9]+ '.' [0-9]* | '.' [0-9]+ ; +INT_LIT : [0-9]+ ; + +// --------------------------------------------------------------------------- +// Identifiers (catch-all after keywords – order matters) +// --------------------------------------------------------------------------- + +IDENTIFIER : [a-zA-Z_][a-zA-Z_0-9]* ; + +// --------------------------------------------------------------------------- +// Operators and punctuation (multi-char operators before their prefixes) +// --------------------------------------------------------------------------- + +LTE : '<=' ; +GTE : '>=' ; +NEQ : '<>' ; +COLON : ':' ; +LPAREN : '(' ; +RPAREN : ')' ; +LBRACKET : '[' ; +RBRACKET : ']' ; +COMMA : ',' ; +DOT : '.' ; +SEMICOLON : ';' ; +EQ : '=' ; +LT : '<' ; +GT : '>' ; +PLUS : '+' ; +MINUS : '-' ; +STAR : '*' ; +SLASH : '/' ; +BACKSLASH : '\\' ; +CARET : '^' ; +AT : '@' ; +BANG : '!' ; +TILDE : '~' ; +PERCENT : '%' ; +PIPE : '|' ; +HASH : '#' ; + +// --------------------------------------------------------------------------- +// Whitespace +// --------------------------------------------------------------------------- + +NEWLINE : '\r'? '\n' ; +WS : [ \t]+ ; + +// Catch-all so the lexer never hard-errors on unknown characters +UNKNOWN : . ; + +// --------------------------------------------------------------------------- +// Case-insensitive character fragments +// --------------------------------------------------------------------------- + +fragment A : [aA] ; fragment B : [bB] ; fragment C : [cC] ; +fragment D : [dD] ; fragment E : [eE] ; fragment F : [fF] ; +fragment G : [gG] ; fragment H : [hH] ; fragment I : [iI] ; +fragment J : [jJ] ; fragment K : [kK] ; fragment L : [lL] ; +fragment M : [mM] ; fragment N : [nN] ; fragment O : [oO] ; +fragment P : [pP] ; fragment Q : [qQ] ; fragment R : [rR] ; +fragment S : [sS] ; fragment T : [tT] ; fragment U : [uU] ; +fragment V : [vV] ; fragment W : [wW] ; fragment X : [xX] ; +fragment Y : [yY] ; fragment Z : [zZ] ; + diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 4d707af7..a013e23d 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -4,19 +4,25 @@ import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; import static com.formdev.flatlaf.FlatClientProperties.*; +import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; import com.basic4gl.desktop.debugger.DebugServerConstants; import com.basic4gl.desktop.debugger.DebugServerFactory; import com.basic4gl.desktop.editor.*; -import com.basic4gl.desktop.spi.FileLineNumber; -import com.basic4gl.desktop.spi.MenuService; -import com.basic4gl.desktop.spi.ProjectExportPage; -import com.basic4gl.desktop.spi.ProjectSettingsPage; +import com.basic4gl.desktop.spi.*; +import com.basic4gl.desktop.spi.language.FunctionDefinition; +import com.basic4gl.desktop.spi.language.LabelDefinition; +import com.basic4gl.desktop.spi.language.VariableDefinition; import com.basic4gl.desktop.vmview.DebugControlsListener; import com.basic4gl.desktop.vmview.VirtualMachineViewDialog; +import com.basic4gl.language.core.extensions.FunctionLibrary; +import com.basic4gl.language.core.extensions.Library; import com.basic4gl.language.core.internal.Mutable; +import com.basic4gl.language.core.types.BasicValType; +import com.basic4gl.language.core.types.FunctionSpecification; +import com.basic4gl.language.core.types.ValType; import com.formdev.flatlaf.FlatLightLaf; import com.formdev.flatlaf.extras.FlatDesktop; import com.formdev.flatlaf.icons.FlatTabbedPaneCloseIcon; @@ -101,14 +107,18 @@ public void caretUpdate(CaretEvent e) { private final DefaultListModel referenceListModel = new DefaultListModel<>(); private final JList referenceList = new JList<>(referenceListModel); private final JTextField referenceSearchField = new JTextField(); - private final JComboBox referenceKindFilter = new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables"}); + private final JComboBox referenceKindFilter = + new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables"}); private final JComboBox referenceSourceFilter = new JComboBox<>(new String[] {"All sources", "Builtin", "Libraries", "Program"}); private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); private final JTextPane referenceDetailsPane = new JTextPane(); private final JButton referenceInsertButton = new JButton("Insert"); private final java.util.List allReferenceItems = new ArrayList<>(); - private final SymbolIndexer symbolIndexer = new SymbolIndexer(this::updateProgramSymbols); + // Language support is shared between the symbol indexer and (via BasicTokenMaker) the editor. + private final com.basic4gl.desktop.language.LanguageSupport languageSupport = + new com.basic4gl.desktop.language.Basic4GLLanguageSupport(); + private final SymbolIndexer symbolIndexer = new SymbolIndexer(languageSupport, this::updateProgramSymbols); private int expandedLeftSidebarWidth = 260; private int expandedRightDocsWidth = 320; private String activeLeftSidebarKey = "files"; @@ -1729,11 +1739,12 @@ private void configureSplitTabs() { splitTabControl.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); splitTabControl.putClientProperty(TABBED_PANE_TAB_CLOSABLE, true); splitTabControl.putClientProperty( - TABBED_PANE_TAB_CLOSE_CALLBACK, - (BiConsumer) (tabPane, tabIndex) -> { + TABBED_PANE_TAB_CLOSE_CALLBACK, (BiConsumer) (tabPane, tabIndex) -> { if (tabIndex >= 0) { splitTabControl.remove(tabIndex); - if (splitTabControl.getTabCount() == 0 && basicEditor != null && basicEditor.getMode() != ApMode.AP_CLOSED) { + if (splitTabControl.getTabCount() == 0 + && basicEditor != null + && basicEditor.getMode() != ApMode.AP_CLOSED) { mainPane.setTopComponent(primaryTabHost); } } @@ -1766,7 +1777,8 @@ private void maybeShowTabPopup(MouseEvent e) { JPopupMenu popup = new JPopupMenu(); JMenuItem setRunnable = new JMenuItem("Set as runnable file"); setRunnable.addActionListener(x -> { - fileManager.setRunnableFilePath(fileManager.getFileEditors().get(tabIndex).getFilePath()); + fileManager.setRunnableFilePath( + fileManager.getFileEditors().get(tabIndex).getFilePath()); refreshRunnableFileControls(); }); popup.add(setRunnable); @@ -2000,7 +2012,8 @@ private void configureDocsPane() { @Override public Component getListCellRendererComponent( JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { - JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); if (value instanceof ReferenceItem item) { label.setText(item.signature); if ("function".equals(item.kind) || "userfunc".equals(item.kind)) { @@ -2277,7 +2290,8 @@ private void onRunTargetSelectionChanged() { } int index = runTargetCombo.getSelectedIndex(); if (index >= 0 && index < fileManager.getFileEditors().size()) { - fileManager.setRunnableFilePath(fileManager.getFileEditors().get(index).getFilePath()); + fileManager.setRunnableFilePath( + fileManager.getFileEditors().get(index).getFilePath()); } } @@ -2309,69 +2323,66 @@ private String collectAllSourceText() { * Replaces all "Program" (user-defined) reference items with the freshly scanned symbols and * refreshes the reference panel. */ - private void updateProgramSymbols(List symbols) { + private void updateProgramSymbols(List symbols) { // Remove all existing Program-sourced items allReferenceItems.removeIf(item -> "Program".equals(item.library)); // Add newly scanned symbols - for (SymbolIndexer.IndexedSymbol sym : symbols) { + for (com.basic4gl.desktop.language.IndexedSymbol sym : symbols) { String details; String insertText; int caretOffset; - switch (sym.kind) { + switch (sym.kind()) { case "userfunc" -> { details = "" - + "

" + escapeHtml(sym.name) + "

" + + "

" + escapeHtml(sym.name()) + "

" + "

Type: User Function" + "
Source: Program

" - + "

" + escapeHtml(sym.signature) + "

" + + "

" + escapeHtml(sym.signature()) + "

" + ""; - insertText = sym.name + "()"; - caretOffset = sym.name.length() + 1; + insertText = sym.name() + "()"; + caretOffset = sym.name().length() + 1; } case "label" -> { details = "" - + "

" + escapeHtml(sym.name) + "

" + + "

" + escapeHtml(sym.name()) + "

" + "

Type: Label" - + "
Usage: gosub " + escapeHtml(sym.name) + "" - + " / goto " + escapeHtml(sym.name) + "

" + + "
Usage: gosub " + escapeHtml(sym.name()) + "" + + " / goto " + escapeHtml(sym.name()) + "

" + ""; - insertText = sym.name; - caretOffset = sym.name.length(); + insertText = sym.name(); + caretOffset = sym.name().length(); } default -> { // "variable" details = "" - + "

" + escapeHtml(sym.name) + "

" + + "

" + escapeHtml(sym.name()) + "

" + "

Type: Variable" + "
Source: Program

" - + "

" + escapeHtml(sym.signature) + "

" + + "

" + escapeHtml(sym.signature()) + "

" + ""; - insertText = sym.name; - caretOffset = sym.name.length(); + insertText = sym.name(); + caretOffset = sym.name().length(); } } - allReferenceItems.add(new ReferenceItem(sym.kind, sym.name, sym.signature, "Program", details, insertText, caretOffset)); + allReferenceItems.add(new ReferenceItem( + sym.kind(), sym.name(), sym.signature(), "Program", details, insertText, caretOffset)); } - allReferenceItems.sort( - Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) - .thenComparing(item -> item.kind)); + allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(item -> item.kind)); rebuildLibraryFilterOptions(); filterReferenceItems(); } private void populateDocsFromCompiler() { - if (basicEditor == null || basicEditor.compiler == null) { + if (basicEditor == null || basicEditor.getCompiler() == null) { return; } - Map functionLibraryBySpecIndex = buildFunctionLibraryBySpecIndex(); - Map constantLibraryByName = buildConstantLibraryByName(); allReferenceItems.clear(); - allReferenceItems.addAll(buildFunctionReferenceItems(basicEditor.compiler, functionLibraryBySpecIndex)); - allReferenceItems.addAll(buildConstantReferenceItems(basicEditor.compiler, constantLibraryByName)); - allReferenceItems.addAll(buildUserFunctionReferenceItems(basicEditor.compiler)); - allReferenceItems.addAll(buildLabelReferenceItems(basicEditor.compiler)); - allReferenceItems.addAll(buildVariableReferenceItems(basicEditor.compiler)); + allReferenceItems.addAll(buildFunctionReferenceItems(basicEditor.getLanguageService())); + allReferenceItems.addAll(buildConstantReferenceItems(basicEditor.getLanguageService())); + allReferenceItems.addAll(buildLabelReferenceItems(basicEditor.getLanguageService())); + allReferenceItems.addAll(buildVariableReferenceItems(basicEditor.getLanguageService())); allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) .thenComparing(item -> item.kind)); rebuildLibraryFilterOptions(); @@ -2379,218 +2390,104 @@ private void populateDocsFromCompiler() { } private java.util.List buildFunctionReferenceItems( - TomBasicCompiler comp, Map functionLibraryBySpecIndex) { + LanguageService comp) { java.util.List items = new ArrayList<>(); - for (String key : comp.getFunctionIndex().keySet()) { - for (Integer index : comp.getFunctionIndex().get(key)) { - String name = key; - FunctionSpecification spec = comp.getFunctions().get(index); - String libraryName = functionLibraryBySpecIndex.getOrDefault(index, "Builtin"); - String library = libraryName != null ? libraryName : "Builtin"; - StringBuilder signature = new StringBuilder(); - if (spec.isFunction()) { - signature.append(getTypeString(spec.getReturnType())).append(' '); - } - signature.append(name); - signature.append(spec.hasBrackets() ? "(" : " "); - boolean needComma = false; - Vector params = spec.getParamTypes().getParams(); - StringBuilder argsOnly = new StringBuilder(); - if (params != null) { - for (ValType type : params) { - if (needComma) { - signature.append(", "); - argsOnly.append(", "); - } - String typeName = getTypeString(type); - signature.append(typeName); - argsOnly.append(typeName); - needComma = true; + for (FunctionDefinition item : comp.getFunctionDefinitions()) { + if (item == null) { + continue; + } + StringBuilder argsOnly = new StringBuilder(); + if (item.parameters() != null) { + for (VariableDefinition arg : item.parameters()) { + if (argsOnly.length() > 0) { + argsOnly.append(", "); } + argsOnly.append(arg.signature()); } - if (spec.hasBrackets()) { - signature.append(')'); - } - - String details = "" - + "

" - + escapeHtml(name) - + "

Type: Function
Library: " - + escapeHtml(library) - + "

" - + escapeHtml(signature.toString()) - + "

"; - String insertText = spec.hasBrackets() ? name + "()" : name + " "; - int caretOffset = spec.hasBrackets() ? name.length() + 1 : insertText.length(); - if (spec.hasBrackets() && argsOnly.length() > 0) { - insertText = name + "(" + argsOnly + ")"; - caretOffset = name.length() + 1; - } - items.add(new ReferenceItem("function", name, signature.toString(), library, details, insertText, caretOffset)); } + String details = "" + + "

" + + escapeHtml(item.name()) + + "

Type: Function
Library: " + + escapeHtml(item.packageName()) + + "

" + + escapeHtml(item.signature()) + + "

"; + String insertText = item.hasBrackets() ? item.name() + "()" : item.name() + " "; + int caretOffset = item.hasBrackets() ? item.name().length() + 1 : insertText.length(); + if (item.hasBrackets() && argsOnly.length() > 0) { + insertText = item.name() + "(" + argsOnly + ")"; + caretOffset = item.name().length() + 1; + } + items.add(new ReferenceItem( + "function", item.name(), item.signature(), item.packageName(), details, insertText, caretOffset)); } return items; } - private java.util.List buildConstantReferenceItems( - TomBasicCompiler comp, Map constantLibraryByName) { + private java.util.List buildConstantReferenceItems(LanguageService comp) { java.util.List items = new ArrayList<>(); - for (String key : comp.getConstants().keySet()) { - String library = constantLibraryByName.getOrDefault(key.toLowerCase(Locale.ROOT), "Builtin"); - if (library == null) { - library = "Builtin"; + for (VariableDefinition item : comp.getConstantDefinitions()) { + if (item == null) { + continue; } - String signature = key + " = (" + getTypeString(comp.getConstants().get(key).getType()) + ") " - + comp.getConstants().get(key); String details = "" + "

" - + escapeHtml(key) + + escapeHtml(item.name()) + "

Type: Constant
Library: " - + escapeHtml(library) + + escapeHtml(item.packageName()) + "

" - + escapeHtml(signature) + + escapeHtml(item.signature()) + "

"; - items.add(new ReferenceItem("constant", key, signature, library, details, key, key.length())); + items.add(new ReferenceItem("constant", item.name(), item.signature(), item.packageName(), details, item.name(), item.name().length())); } - return items; - } - private java.util.List buildUserFunctionReferenceItems(TomBasicCompiler comp) { - java.util.List items = new ArrayList<>(); - Map funcIndex = comp.getGlobalUserFunctionIndex(); - java.util.Vector functions = comp.getVM().getUserFunctions(); - java.util.Vector prototypes = comp.getVM().getUserFunctionPrototypes(); - for (Map.Entry entry : funcIndex.entrySet()) { - String name = entry.getKey(); - int funcIdx = entry.getValue(); - com.basic4gl.runtime.stackframe.UserFuncPrototype prototype = null; - if (funcIdx >= 0 && funcIdx < functions.size()) { - int protoIdx = functions.get(funcIdx).prototypeIndex; - if (protoIdx >= 0 && protoIdx < prototypes.size()) { - prototype = prototypes.get(protoIdx); - } - } - StringBuilder signature = new StringBuilder(); - if (prototype != null && prototype.hasReturnVal) { - signature.append(getTypeString(prototype.returnValType)).append(' '); - } - signature.append(name).append('('); - if (prototype != null && prototype.paramCount > 0) { - String[] params = new String[prototype.paramCount]; - for (Map.Entry v : prototype.localVarIndex.entrySet()) { - int idx = v.getValue(); - if (idx < prototype.paramCount && idx < prototype.localVarTypes.size()) { - params[idx] = getTypeString(prototype.localVarTypes.get(idx)) + " " + v.getKey(); - } - } - boolean needComma = false; - for (String param : params) { - if (needComma) signature.append(", "); - signature.append(param != null ? param : "?"); - needComma = true; - } - } - signature.append(')'); - String details = "" - + "

" + escapeHtml(name) - + "

Type: User Function
Source: Program

" - + "

" + escapeHtml(signature.toString()) + "

"; - items.add(new ReferenceItem("userfunc", name, signature.toString(), "Program", details, name + "()", name.length() + 1)); - } return items; } - private java.util.List buildLabelReferenceItems(TomBasicCompiler comp) { + private java.util.List buildLabelReferenceItems(LanguageService comp) { java.util.List items = new ArrayList<>(); - for (String labelName : comp.getLabelNames()) { - String signature = labelName + ":"; + for (LabelDefinition label : comp.getLabelDefinitions()) { + if (label == null) { + continue; + } + String signature = label.signature(); String details = "" - + "

" + escapeHtml(labelName) + + "

" + escapeHtml(label.name()) + "

Type: Label
Usage: " - + "gosub " + escapeHtml(labelName) + " / goto " + escapeHtml(labelName) + "" + + "" + escapeHtml(label.usage()) + "" + "

"; - items.add(new ReferenceItem("label", labelName, signature, "Program", details, labelName, labelName.length())); + items.add(new ReferenceItem( + "label", label.name(), signature, "Program", details, label.name(), label.name().length())); } return items; } - private java.util.List buildVariableReferenceItems(TomBasicCompiler comp) { + private java.util.List buildVariableReferenceItems(LanguageService comp) { java.util.List items = new ArrayList<>(); - for (com.basic4gl.runtime.VariableCollection.Variable variable : comp.getVM().getVariables().getVariables()) { - if (variable.name == null || variable.name.isEmpty()) continue; - String typeStr = getTypeString(variable.type); - String signature = typeStr + " " + variable.name; + for (VariableDefinition variable : + comp.getVariableDefinitions()) { + if (variable == null || variable.name() == null || variable.name().isEmpty()) { + continue; + } + String typeStr = variable.type().name(); + String signature = variable.signature(); String details = "" - + "

" + escapeHtml(variable.name) + + "

" + escapeHtml(variable.name()) + "

Type: Variable
Data type: " + escapeHtml(typeStr) + "
Source: Program

"; - items.add(new ReferenceItem("variable", variable.name, signature, "Program", details, variable.name, variable.name.length())); + items.add(new ReferenceItem( + "variable", variable.name(), signature, "Program", details, variable.name(), variable.name().length())); } return items; } - private Map buildFunctionLibraryBySpecIndex() { - Map functionLibraryBySpecIndex = new HashMap<>(); - if (basicEditor == null || basicEditor.getLibraries() == null) { - return functionLibraryBySpecIndex; - } - - int specCursor = 0; - for (Library library : basicEditor.getLibraries()) { - if (!(library instanceof FunctionLibrary functionLibrary)) { - continue; - } - Map specs = functionLibrary.specs(); - if (specs == null) { - continue; - } - int count = 0; - for (FunctionSpecification[] overloads : specs.values()) { - if (overloads != null) { - count += overloads.length; - } - } - for (int i = 0; i < count; i++) { - String libName = library.name(); - if (libName != null) { - functionLibraryBySpecIndex.put(specCursor + i, library.name()); - } - } - specCursor += count; - } - return functionLibraryBySpecIndex; - } - - private Map buildConstantLibraryByName() { - Map constantLibraryByName = new HashMap<>(); - if (basicEditor == null || basicEditor.getLibraries() == null) { - return constantLibraryByName; - } - - for (Library library : basicEditor.getLibraries()) { - if (!(library instanceof FunctionLibrary functionLibrary)) { - continue; - } - Map constants = functionLibrary.constants(); - if (constants == null) { - continue; - } - for (String name : constants.keySet()) { - String libName = library.name(); - if (libName != null) { - constantLibraryByName.put(name.toLowerCase(Locale.ROOT), library.name()); - } - } - } - return constantLibraryByName; - } - private void rebuildLibraryFilterOptions() { String selected = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); Set libraries = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); for (ReferenceItem item : allReferenceItems) { if (item.library != null) { - libraries.add(item.library); + libraries.add(item.library); } } @@ -2612,22 +2509,32 @@ private void filterReferenceItems() { referenceListModel.clear(); for (ReferenceItem item : allReferenceItems) { boolean kindMatches = "All".equals(selectedKind) - || ("Functions".equals(selectedKind) && ("function".equals(item.kind) || "userfunc".equals(item.kind))) + || ("Functions".equals(selectedKind) + && ("function".equals(item.kind) || "userfunc".equals(item.kind))) || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) || ("Labels".equals(selectedKind) && "label".equals(item.kind)) || ("Variables".equals(selectedKind) && "variable".equals(item.kind)); boolean sourceMatches = "All sources".equals(selectedSource) - || ("Builtin".equals(selectedSource) && item.library != null && "Builtin".equalsIgnoreCase(item.library)) - || ("Libraries".equals(selectedSource) && item.library != null && !"Builtin".equalsIgnoreCase(item.library) && !"Program".equalsIgnoreCase(item.library)) - || ("Program".equals(selectedSource) && item.library != null && "Program".equalsIgnoreCase(item.library)); - boolean libraryMatches = "All libraries".equals(selectedLibrary) || (item.library != null && selectedLibrary.equals(item.library)); + || ("Builtin".equals(selectedSource) + && item.library != null + && "Builtin".equalsIgnoreCase(item.library)) + || ("Libraries".equals(selectedSource) + && item.library != null + && !"Builtin".equalsIgnoreCase(item.library) + && !"Program".equalsIgnoreCase(item.library)) + || ("Program".equals(selectedSource) + && item.library != null + && "Program".equalsIgnoreCase(item.library)); + boolean libraryMatches = "All libraries".equals(selectedLibrary) + || (item.library != null && selectedLibrary.equals(item.library)); if (needle.isEmpty() || item.name.toLowerCase(Locale.ROOT).contains(needle) || item.signature.toLowerCase(Locale.ROOT).contains(needle) || item.kind.toLowerCase(Locale.ROOT).contains(needle) - || (item.library != null && item.library.toLowerCase(Locale.ROOT).contains(needle))) { + || (item.library != null + && item.library.toLowerCase(Locale.ROOT).contains(needle))) { if (kindMatches && sourceMatches && libraryMatches) { - referenceListModel.addElement(item); + referenceListModel.addElement(item); } } } @@ -2635,7 +2542,8 @@ private void filterReferenceItems() { if (!referenceListModel.isEmpty()) { referenceList.setSelectedIndex(0); } else { - referenceDetailsPane.setText("No matches."); + referenceDetailsPane.setText( + "No matches."); referenceInsertButton.setEnabled(false); } } @@ -2643,7 +2551,8 @@ private void filterReferenceItems() { private void updateReferenceSelectionDetails() { ReferenceItem item = referenceList.getSelectedValue(); if (item == null) { - referenceDetailsPane.setText("Select an entry."); + referenceDetailsPane.setText( + "Select an entry."); referenceInsertButton.setEnabled(false); return; } @@ -2664,37 +2573,11 @@ private void insertSelectedReference() { JTextArea editorPane = fileManager.getFileEditors().get(selectedTab).getEditorPane(); int insertStart = editorPane.getSelectionStart(); editorPane.replaceSelection(item.insertText); - editorPane.setCaretPosition(Math.min(insertStart + item.caretOffset, editorPane.getDocument().getLength())); + editorPane.setCaretPosition(Math.min( + insertStart + item.caretOffset, editorPane.getDocument().getLength())); editorPane.requestFocusInWindow(); } - private String getTypeString(ValType type) { - if (type == null) { - return "???"; - } - StringBuilder result = new StringBuilder(); - for (int i = 0; i < type.getVirtualPointerLevel(); i++) { - result.append('&'); - } - result.append(getTypeString(type.basicType)); - for (int i = 0; i < type.arrayLevel; i++) { - result.append("()"); - } - return result.toString(); - } - - private String getTypeString(int type) { - switch (type) { - case BasicValType.VTP_INT: - return "int"; - case BasicValType.VTP_REAL: - return "real"; - case BasicValType.VTP_STRING: - return "string"; - default: - return "???"; - } - } private void openMarkdownInDocsTab(File file) { File resolved = file.isAbsolute() ? file : new File(fileManager.getCurrentDirectory(), file.getPath()); diff --git a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java index c7982c5c..d0c5b857 100644 --- a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java @@ -414,4 +414,4 @@ public String toString() { return label; } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java b/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java index a369100a..842631b4 100644 --- a/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java +++ b/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java @@ -1,27 +1,29 @@ package com.basic4gl.desktop; -import java.util.ArrayList; +import com.basic4gl.desktop.language.IndexedSymbol; +import com.basic4gl.desktop.language.LanguageSupport; import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import javax.swing.SwingUtilities; /** * Lightweight debounced symbol indexer. * - *

Listens for source-text changes and, after a short debounce delay, scans the text for - * user-defined symbols (functions/subs, gosub labels, and dim-declared variables). Results are - * delivered to the supplied {@link Callback} on the Swing EDT, making it safe to update UI - * components directly from the callback. + *

Listens for source-text changes and, after a short debounce delay, delegates symbol + * extraction to a {@link LanguageSupport} instance. Results are delivered via a {@link Callback} + * on the Swing EDT. + * + *

The indexer itself contains no language-specific logic; all parsing is + * performed by the supplied {@code LanguageSupport}. Swapping languages is a constructor change. * *

Usage: * *

{@code
- * SymbolIndexer indexer = new SymbolIndexer(symbols -> updateReferencePanel(symbols));
+ * LanguageSupport lang = new Basic4GLLanguageSupport();
+ * SymbolIndexer indexer = new SymbolIndexer(lang, symbols -> updateReferencePanel(symbols));
  * // On every document change:
  * indexer.schedule(getAllEditorText());
  * // On window close:
@@ -30,242 +32,76 @@
  */
 public class SymbolIndexer {
 
-    // -------------------------------------------------------------------------
-    // Public API
-    // -------------------------------------------------------------------------
-
-    /** A single symbol discovered in the source. */
-    public static final class IndexedSymbol {
-        /** Kind tag: {@code "userfunc"}, {@code "label"}, or {@code "variable"}. */
-        public final String kind;
-        /** The bare symbol name (no punctuation). */
-        public final String name;
-        /** Human-readable signature shown in the reference panel. */
-        public final String signature;
-
-        public IndexedSymbol(String kind, String name, String signature) {
-            this.kind = kind;
-            this.name = name;
-            this.signature = signature;
-        }
-    }
-
     /** Receives indexed symbols on the Swing EDT after each debounce cycle. */
     public interface Callback {
         void onIndexed(List symbols);
     }
 
-    // -------------------------------------------------------------------------
-    // Configuration
-    // -------------------------------------------------------------------------
-
-    /** Milliseconds to wait after the last change before running the indexer. */
+    /** Milliseconds to wait after the last change before running extraction. */
     private static final long DEBOUNCE_MILLIS = 400;
 
-    // -------------------------------------------------------------------------
-    // Patterns (case-insensitive, multiline)
-    // -------------------------------------------------------------------------
-
-    // function/sub header: "function Foo(int x, string y)" or "sub Bar()"
-    private static final Pattern FUNC_PATTERN =
-            Pattern.compile(
-                    "^[ \\t]*(?:function|sub)[ \\t]+(\\w+)[ \\t]*\\(([^)]*?)\\)",
-                    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
-
-    // label declaration: "myLabel:" (at start of a non-blank line, optional leading whitespace)
-    // Excludes lines that look like "keyword:" to avoid false positives with type annotations.
-    private static final Pattern LABEL_PATTERN =
-            Pattern.compile(
-                    "^[ \\t]*(\\w+)[ \\t]*:[ \\t]*(?:$|'|rem[ \\t])",
-                    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
-
-    // dim declaration: "dim x", "dim x as integer", "dim x(10)"
-    // Also handles "dim x as integer()" array types.
-    private static final Pattern DIM_PATTERN =
-            Pattern.compile(
-                    "^[ \\t]*dim[ \\t]+(\\w+)(?:[ \\t]*\\([^)]*\\))?(?:[ \\t]+as[ \\t]+(\\w+(?:[ \\t]*\\([ \\t]*\\))?))?",
-                    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
-
-    // -------------------------------------------------------------------------
-    // State
-    // -------------------------------------------------------------------------
+    private final LanguageSupport languageSupport;
+    private final Callback callback;
 
-    private final ScheduledExecutorService scheduler =
-            Executors.newSingleThreadScheduledExecutor(
-                    r -> {
-                        Thread t = new Thread(r, "symbol-indexer");
-                        t.setDaemon(true);
-                        return t;
-                    });
+    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+        Thread t = new Thread(r, "symbol-indexer");
+        t.setDaemon(true);
+        return t;
+    });
 
     private ScheduledFuture pending;
-    private final Callback callback;
-
-    // -------------------------------------------------------------------------
-    // Constructor
-    // -------------------------------------------------------------------------
 
-    public SymbolIndexer(Callback callback) {
+    public SymbolIndexer(LanguageSupport languageSupport, Callback callback) {
+        this.languageSupport = languageSupport;
         this.callback = callback;
     }
 
-    // -------------------------------------------------------------------------
-    // Public methods
-    // -------------------------------------------------------------------------
-
     /**
-     * Schedules an indexing pass for the given source text.
-     *
-     * 

Calling this method again before the debounce window expires cancels the previous - * scheduled pass and restarts the timer — only one indexing pass runs per idle period. + * Schedules an indexing pass after the debounce delay. * - *

This method is thread-safe and may be called from any thread. + *

Calling this again before the window expires cancels the previous pass and restarts the + * timer — only one extraction runs per idle period. Thread-safe. * - * @param source the full source text to index (may span multiple concatenated files) + * @param source full source text (may span multiple concatenated editor files) */ public synchronized void schedule(String source) { - if (pending != null && !pending.isDone()) { - pending.cancel(false); - } - pending = - scheduler.schedule( - () -> { - List result = scan(source); - SwingUtilities.invokeLater(() -> callback.onIndexed(result)); - }, - DEBOUNCE_MILLIS, - TimeUnit.MILLISECONDS); + cancelPending(); + pending = scheduler.schedule(() -> runAndDeliver(source), DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS); } /** - * Triggers an immediate (non-debounced) indexing pass. + * Triggers an immediate (zero-delay) indexing pass. * - *

Useful after a successful full compile when up-to-date symbols are already available but - * the indexer should also refresh its last-known symbol set for subsequent incremental updates. + *

Useful after a successful full compile to sync symbols without waiting for the debounce + * window. * - * @param source the full source text to index + * @param source full source text */ public synchronized void indexNow(String source) { - if (pending != null && !pending.isDone()) { - pending.cancel(false); - } - pending = - scheduler.schedule( - () -> { - List result = scan(source); - SwingUtilities.invokeLater(() -> callback.onIndexed(result)); - }, - 0, - TimeUnit.MILLISECONDS); + cancelPending(); + pending = scheduler.schedule(() -> runAndDeliver(source), 0, TimeUnit.MILLISECONDS); } /** - * Shuts down the background scheduler. - * - *

Call this when the owning window is disposed to release the daemon thread. + * Shuts down the background scheduler. Call when the owning window is disposed to release + * the daemon thread cleanly. */ public void shutdown() { scheduler.shutdownNow(); } // ------------------------------------------------------------------------- - // Scanning + // Private // ------------------------------------------------------------------------- - /** Scans {@code source} and returns every discovered symbol. */ - private List scan(String source) { - List symbols = new ArrayList<>(); - if (source == null || source.isEmpty()) { - return symbols; - } - - // --- User functions and subs --- - Matcher m = FUNC_PATTERN.matcher(source); - while (m.find()) { - String name = m.group(1); - String params = m.group(2).trim(); - // Normalise whitespace inside parameter list - params = params.replaceAll("[ \\t]+", " "); - String sig = name + "(" + params + ")"; - symbols.add(new IndexedSymbol("userfunc", name, sig)); - } - - // --- Gosub / goto labels --- - m = LABEL_PATTERN.matcher(source); - while (m.find()) { - String name = m.group(1).trim(); - if (!isReservedWord(name)) { - symbols.add(new IndexedSymbol("label", name, name + ":")); - } - } - - // --- Dim-declared variables --- - m = DIM_PATTERN.matcher(source); - while (m.find()) { - String name = m.group(1); - String type = m.group(2); // may be null - String sig = (type != null && !type.isBlank()) ? type.trim() + " " + name : name; - symbols.add(new IndexedSymbol("variable", name, sig)); + private void cancelPending() { + if (pending != null && !pending.isDone()) { + pending.cancel(false); } - - return symbols; } - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - /** - * Returns {@code true} if {@code word} is a Basic4GL reserved keyword. - * - *

This is a fast approximation; only common keywords that would otherwise produce false - * label matches are listed here. - */ - private static boolean isReservedWord(String word) { - if (word == null) return false; - return switch (word.toLowerCase()) { - case "dim", - "goto", - "if", - "then", - "else", - "elseif", - "endif", - "end", - "gosub", - "return", - "for", - "to", - "step", - "next", - "while", - "wend", - "run", - "struc", - "endstruc", - "const", - "alloc", - "null", - "data", - "read", - "reset", - "type", - "function", - "sub", - "true", - "false", - "and", - "or", - "not", - "xor", - "mod", - "rem", - "integer", - "single", - "double", - "string" -> true; - default -> false; - }; + private void runAndDeliver(String source) { + List result = languageSupport.extractSymbols(source); + SwingUtilities.invokeLater(() -> callback.onIndexed(result)); } } - diff --git a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java index 08f7e699..5cba67c7 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java @@ -1,508 +1,65 @@ package com.basic4gl.desktop.editor; +import com.basic4gl.desktop.language.Basic4GLLanguageSupport; import java.util.ArrayList; import java.util.List; -import javax.swing.text.Segment; -import org.fife.ui.rsyntaxtextarea.AbstractTokenMaker; -import org.fife.ui.rsyntaxtextarea.RSyntaxUtilities; import org.fife.ui.rsyntaxtextarea.Token; import org.fife.ui.rsyntaxtextarea.TokenMap; /** - * Created by Nate on 1/10/2015. + * RSyntaxTextArea {@code TokenMaker} for the Basic4GL language. + * + *

All tokenisation logic now lives in {@link Basic4GLLanguageSupport} (backed by the + * ANTLR-generated {@code Basic4GL} lexer). This class is retained so that: + * + *

    + *
  • The existing {@code "text/basic4gl"} MIME-type registration in {@code MainWindow} keeps + * working without change (RSyntaxTextArea instantiates this class by name). + *
  • The static keyword lists populated by {@code BasicEditor} at startup continue to produce + * correct highlighting for runtime-registered library functions and constants. + *
*/ -public class BasicTokenMaker extends AbstractTokenMaker { +public class BasicTokenMaker extends LanguageSupportTokenMaker { + private static final String INCLUDE = "include "; private static final String PLUGIN = "#plugin "; private static final char CHAR_COMMENT = '\''; + + // Static lists populated by BasicEditor after the compiler loads its libraries. + // Grammar-level keywords are now handled by the ANTLR lexer; these lists are only + // needed for dynamically registered names (library functions, constants, operators). public static final List reservedWords = new ArrayList<>(); public static final List functions = new ArrayList<>(); public static final List constants = new ArrayList<>(); public static final List operators = new ArrayList<>(); - private static TokenMap tokenMap = new TokenMap(true); - - @Override - public void addToken(Segment segment, int start, int end, int tokenType, int startOffset) { - // This assumes all keywords, etc. were parsed as "identifiers." - if (tokenType == Token.IDENTIFIER) { - int value = wordsToHighlight.get(segment, start, end); - if (value != -1) { - tokenType = value; - } - } - super.addToken(segment, start, end, tokenType, startOffset); + /** No-arg constructor used by RSyntaxTextArea's {@code TokenMakerFactory} via reflection. */ + public BasicTokenMaker() { + super(new Basic4GLLanguageSupport()); } + /** + * Merges the runtime-registered keyword lists into the {@code wordsToHighlight} map that + * {@link LanguageSupportTokenMaker} uses to re-classify {@code IDENTIFIER} tokens. + * + *

Called once by the superclass constructor; the static lists must be populated by + * {@code BasicEditor} before the first editor tab is opened. + */ @Override public TokenMap getWordsToHighlight() { - tokenMap = new TokenMap(true); - - refreshTokenMap(); - - return tokenMap; - } - - public static void refreshTokenMap() { + TokenMap tokenMap = new TokenMap(true); for (String token : reservedWords) { tokenMap.put(token, Token.RESERVED_WORD); } - for (String token : functions) { tokenMap.put(token, Token.FUNCTION); } - for (String token : constants) { tokenMap.put(token, Token.RESERVED_WORD_2); } - for (String token : operators) { tokenMap.put(token, Token.OPERATOR); } - } - - @Override - public Token getTokenList(Segment text, int startTokenType, int startOffset) { - resetTokenList(); - - char[] array = text.array; - int offset = text.offset; - int count = text.count; - int end = offset + count; - String value = String.valueOf(array); - // Token starting offsets are always of the form: - // 'startOffset + (currentTokenStart-offset)', but since startOffset and - // offset are constant, tokens' starting positions become: - // 'newStartOffset+currentTokenStart'. - int newStartOffset = startOffset - offset; - - int currentTokenStart = offset; - int currentTokenType = startTokenType; - if (offset + INCLUDE.length() < end - && value.toLowerCase() - .substring(offset, offset + INCLUDE.length()) - .startsWith(INCLUDE)) { - currentTokenType = Token.PREPROCESSOR; - } - if (offset + PLUGIN.length() < end - && value.toLowerCase() - .substring(offset, offset + PLUGIN.length()) - .startsWith(PLUGIN)) { - currentTokenType = Token.PREPROCESSOR; - } - - for (int i = offset; i < end; i++) { - - char c = array[i]; - - switch (currentTokenType) { - case Token.NULL: - currentTokenStart = i; // Starting a new token here. - - switch (c) { - case ':': - case ' ': - case '\t': - currentTokenType = Token.WHITESPACE; - break; - - case '"': - currentTokenType = Token.LITERAL_STRING_DOUBLE_QUOTE; - break; - - case CHAR_COMMENT: - currentTokenType = Token.COMMENT_EOL; - break; - case '.': - case ',': - case ';': - case '/': - case '\\': - case '|': - case '{': - case '}': - case '[': - case ']': - case '(': - case ')': - case '<': - case '>': - case '-': - case '+': - case '*': - case '%': - case '@': - case '!': - case '~': - case '^': - case '?': - currentTokenType = Token.OPERATOR; - break; - default: - if (RSyntaxUtilities.isDigit(c)) { - currentTokenType = Token.LITERAL_NUMBER_DECIMAL_INT; - break; - } else if (RSyntaxUtilities.isLetter(c) || c == '_') { - currentTokenType = Token.IDENTIFIER; - break; - } - - // Anything not currently handled - mark as an identifier - currentTokenType = Token.IDENTIFIER; - break; - } // End of switch (c). - - break; - - case Token.WHITESPACE: - switch (c) { - case ':': - case ' ': - case '\t': - break; // Still whitespace. - - case '"': - addToken( - text, - currentTokenStart, - i - 1, - Token.WHITESPACE, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.LITERAL_STRING_DOUBLE_QUOTE; - break; - - case CHAR_COMMENT: - addToken( - text, - currentTokenStart, - i - 1, - Token.WHITESPACE, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.COMMENT_EOL; - break; - - case '.': - case ',': - case ';': - case '/': - case '\\': - case '|': - case '{': - case '}': - case '[': - case ']': - case '(': - case ')': - case '<': - case '>': - case '-': - case '+': - case '*': - case '%': - case '@': - case '!': - case '~': - case '^': - case '?': - addToken( - text, - currentTokenStart, - i - 1, - Token.WHITESPACE, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.OPERATOR; - break; - default: // Add the whitespace token and start anew. - addToken( - text, - currentTokenStart, - i - 1, - Token.WHITESPACE, - newStartOffset + currentTokenStart); - currentTokenStart = i; - - if (RSyntaxUtilities.isDigit(c)) { - currentTokenType = Token.LITERAL_NUMBER_DECIMAL_INT; - break; - } else if (RSyntaxUtilities.isLetter(c) || c == '_') { - currentTokenType = Token.IDENTIFIER; - break; - } - - // Anything not currently handled - mark as identifier - currentTokenType = Token.IDENTIFIER; - } // End of switch (c). - - break; - - default: // Should never happen - case Token.IDENTIFIER: - switch (c) { - case ':': - case ' ': - case '\t': - addToken( - text, - currentTokenStart, - i - 1, - Token.IDENTIFIER, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.WHITESPACE; - break; - - case '"': - addToken( - text, - currentTokenStart, - i - 1, - Token.IDENTIFIER, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.LITERAL_STRING_DOUBLE_QUOTE; - break; - case '\'': - addToken( - text, - currentTokenStart, - i - 1, - Token.IDENTIFIER, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.COMMENT_EOL; - break; - case '.': - case ',': - case ';': - case '/': - case '\\': - case '|': - case '{': - case '}': - case '[': - case ']': - case '(': - case ')': - case '<': - case '>': - case '-': - case '+': - case '*': - case '%': - case '@': - case '!': - case '~': - case '^': - case '?': - addToken( - text, - currentTokenStart, - i - 1, - Token.IDENTIFIER, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.OPERATOR; - break; - default: - if (RSyntaxUtilities.isLetterOrDigit(c) || c == '_') { - break; // Still an identifier of some type. - } - // Otherwise, we're still an identifier (?). - - } // End of switch (c). - - break; - - case Token.LITERAL_NUMBER_DECIMAL_INT: - switch (c) { - case ':': - case ' ': - case '\t': - addToken( - text, - currentTokenStart, - i - 1, - Token.LITERAL_NUMBER_DECIMAL_INT, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.WHITESPACE; - break; - - case '"': - addToken( - text, - currentTokenStart, - i - 1, - Token.LITERAL_NUMBER_DECIMAL_INT, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.LITERAL_STRING_DOUBLE_QUOTE; - break; - case '.': - case ',': - case ';': - case '/': - case '\\': - case '|': - case '{': - case '}': - case '[': - case ']': - case '(': - case ')': - case '<': - case '>': - case '-': - case '+': - case '*': - case '%': - case '@': - case '!': - case '~': - case '^': - case '?': - addToken( - text, - currentTokenStart, - i - 1, - Token.LITERAL_NUMBER_DECIMAL_INT, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.OPERATOR; - break; - default: - if (RSyntaxUtilities.isDigit(c)) { - break; // Still a literal number. - } - - // Otherwise, remember this was a number and start over. - addToken( - text, - currentTokenStart, - i - 1, - Token.LITERAL_NUMBER_DECIMAL_INT, - newStartOffset + currentTokenStart); - i--; - currentTokenType = Token.NULL; - } // End of switch (c). - - break; - case Token.OPERATOR: - switch (c) { - case ':': - case ' ': - case '\t': - addToken( - text, currentTokenStart, i - 1, Token.OPERATOR, newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.WHITESPACE; - break; - - case '"': - addToken( - text, currentTokenStart, i - 1, Token.OPERATOR, newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.LITERAL_STRING_DOUBLE_QUOTE; - break; - - case '.': - case ',': - case ';': - case '/': - case '\\': - case '|': - case '{': - case '}': - case '[': - case ']': - case '(': - case ')': - case '<': - case '>': - case '-': - case '+': - case '*': - case '%': - case '@': - case '!': - case '~': - case '^': - case '?': - // Still an operator - break; - default: - if (RSyntaxUtilities.isDigit(c)) { - addToken( - text, - currentTokenStart, - i - 1, - Token.OPERATOR, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.LITERAL_NUMBER_DECIMAL_INT; - break; // A literal number. - } - if (RSyntaxUtilities.isLetter(c) || c == '_') { - addToken( - text, - currentTokenStart, - i - 1, - Token.OPERATOR, - newStartOffset + currentTokenStart); - currentTokenStart = i; - currentTokenType = Token.IDENTIFIER; - break; // An identifier of some type. - } - break; - } - - break; - case Token.PREPROCESSOR: - // Preprocessor goes till EOL - break; - case Token.COMMENT_EOL: - i = end - 1; - addToken(text, currentTokenStart, i, currentTokenType, newStartOffset + currentTokenStart); - // We need to set token type to null so at the bottom we don't add one more token. - currentTokenType = Token.NULL; - break; - - case Token.LITERAL_STRING_DOUBLE_QUOTE: - if (c == '"') { - addToken( - text, - currentTokenStart, - i, - Token.LITERAL_STRING_DOUBLE_QUOTE, - newStartOffset + currentTokenStart); - currentTokenType = Token.NULL; - } - break; - } // End of switch (currentTokenType). - } // End of for (int i=offset; iThis is the only class in the IDE that imports RSyntaxTextArea types and + * bridges them to the language-neutral {@code language} package. Swapping the language is a + * one-line constructor change; no RSyntaxTextArea knowledge leaks into the language definition. + * + *

The {@link #getWordsToHighlight()} method still honours the runtime keyword maps populated + * by {@code BasicEditor} (library function names, constant names, etc.) so that names registered + * after startup are highlighted correctly without a grammar recompile. + */ +public class LanguageSupportTokenMaker extends AbstractTokenMaker { + + private final LanguageSupport languageSupport; + + public LanguageSupportTokenMaker(LanguageSupport languageSupport) { + this.languageSupport = languageSupport; + } + + // ------------------------------------------------------------------------- + // AbstractTokenMaker contract + // ------------------------------------------------------------------------- + + /** + * Returns an empty map by default; subclasses (e.g. {@link BasicTokenMaker}) override this to + * register runtime-discovered names (library functions, constants) that should be highlighted + * even though they are not in the grammar. + */ + @Override + public TokenMap getWordsToHighlight() { + return new TokenMap(true); + } + + @Override + public Token getTokenList(Segment text, int startTokenType, int startOffset) { + resetTokenList(); + + // Handle a string literal that started on the previous line (Basic4GL strings are + // technically single-line, but the editor may carry the state across line repaints). + if (startTokenType == Token.LITERAL_STRING_DOUBLE_QUOTE) { + int eol = text.offset + text.count; + int closeQuote = -1; + for (int i = text.offset; i < eol; i++) { + if (text.array[i] == '"') { + closeQuote = i; + break; + } + } + if (closeQuote >= 0) { + // Emit the closing fragment of the string … + addToken(text, text.offset, closeQuote, Token.LITERAL_STRING_DOUBLE_QUOTE, startOffset); + // … then tokenize whatever follows normally + int remaining = eol - closeQuote - 1; + if (remaining > 0) { + String rest = new String(text.array, closeQuote + 1, remaining); + appendTokens(text, rest, closeQuote + 1, startOffset + closeQuote + 1 - text.offset); + } + } else { + // The entire line is inside an unclosed string + if (text.count > 0) { + addToken( + text, + text.offset, + text.offset + text.count - 1, + Token.LITERAL_STRING_DOUBLE_QUOTE, + startOffset); + } + } + addNullToken(); + return firstToken; + } + + // Normal case: tokenize the line via LanguageSupport + if (text.count == 0) { + addNullToken(); + return firstToken; + } + + String lineText = new String(text.array, text.offset, text.count); + appendTokens(text, lineText, text.offset, startOffset); + addNullToken(); + return firstToken; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Tokenizes {@code lineText} and appends the resulting RSyntaxTextArea tokens to the linked + * list. + * + * @param seg the full line {@link Segment} (needed by {@link #addToken}) + * @param lineText the string to tokenize (may be a sub-range of {@code seg}) + * @param arrayOffset offset into {@code seg.array} where {@code lineText} starts + * @param docOffset document-level character offset of the first character + */ + private void appendTokens(Segment seg, String lineText, int arrayOffset, int docOffset) { + List tokens = languageSupport.tokenizeLine(lineText); + for (LangToken lt : tokens) { + HighlightKind kind = languageSupport.classify(lt); + int rstaType = kindToRstaType(kind); + + int arrayStart = arrayOffset + lt.start(); + int arrayEnd = arrayOffset + lt.end() - 1; // inclusive + + // Re-classify IDENTIFIER tokens that appear in the runtime wordsToHighlight map + // (e.g. library function names, constant names loaded from the compiler at startup). + if (kind == HighlightKind.IDENTIFIER && wordsToHighlight != null) { + int override = wordsToHighlight.get(seg, arrayStart, arrayEnd); + if (override != -1) { + rstaType = override; + } + } + + if (arrayStart <= arrayEnd) { + addToken(seg, arrayStart, arrayEnd, rstaType, docOffset + lt.start()); + } + } + } + + /** + * Maps a {@link HighlightKind} to the corresponding RSyntaxTextArea {@link Token} type + * constant. All RSyntaxTextArea coupling is isolated to this single switch statement. + */ + private static int kindToRstaType(HighlightKind kind) { + return switch (kind) { + case KEYWORD -> Token.RESERVED_WORD; + case KEYWORD_2 -> Token.RESERVED_WORD_2; + case FUNCTION -> Token.FUNCTION; + case CONSTANT -> Token.RESERVED_WORD_2; + case IDENTIFIER -> Token.IDENTIFIER; + case STRING -> Token.LITERAL_STRING_DOUBLE_QUOTE; + case NUMBER -> Token.LITERAL_NUMBER_DECIMAL_INT; + case COMMENT -> Token.COMMENT_EOL; + case PREPROCESSOR -> Token.PREPROCESSOR; + case OPERATOR -> Token.OPERATOR; + case WHITESPACE, NEWLINE -> Token.WHITESPACE; + default -> Token.IDENTIFIER; + }; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java new file mode 100644 index 00000000..4dc9fc65 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java @@ -0,0 +1,355 @@ +package com.basic4gl.desktop.language; + +import java.util.ArrayList; +import java.util.List; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.Token; + +/** + * {@link LanguageSupport} implementation for the Basic4GL language. + * + *

Backed by the ANTLR4-generated {@link Basic4GL} lexer produced from {@code Basic4GL.g4}. + * That single grammar file is the source of truth for: + * + *

    + *
  • Which character sequences are keywords, operators, literals, comments, etc. + *
  • Which identifiers are reserved and must be excluded from label / symbol heuristics. + *
+ * + *

This class contains no RSyntaxTextArea imports. The IDE adapter + * ({@code LanguageSupportTokenMaker}) is the only class that knows about RSyntaxTextArea. + * + *

Thread-safe: each call to {@link #tokenizeLine} and {@link #extractSymbols} creates a fresh + * {@link Basic4GL} lexer instance, so concurrent calls from EDT and background threads are safe. + */ +public class Basic4GLLanguageSupport implements LanguageSupport { + + private static final String SYNTAX_STYLE = "text/basic4gl"; + + // ------------------------------------------------------------------------- + // LanguageSupport – identity + // ------------------------------------------------------------------------- + + @Override + public String syntaxStyle() { + return SYNTAX_STYLE; + } + + // ------------------------------------------------------------------------- + // LanguageSupport – tokenisation + // ------------------------------------------------------------------------- + + @Override + public List tokenizeLine(String line) { + if (line == null || line.isEmpty()) { + return List.of(); + } + Basic4GL lexer = createLexer(line); + List result = new ArrayList<>(); + Token token; + while ((token = lexer.nextToken()).getType() != Token.EOF) { + // Skip NEWLINE tokens – the caller provides one line at a time + if (token.getType() == Basic4GL.NEWLINE) { + continue; + } + result.add(toLangToken(token)); + } + return result; + } + + @Override + public HighlightKind classify(LangToken token) { + return switch (token.type()) { + // Preprocessor + case Basic4GL.INCLUDE_DIR -> HighlightKind.PREPROCESSOR; + + // Comments + case Basic4GL.COMMENT, Basic4GL.REM_COMMENT -> HighlightKind.COMMENT; + + // Primary keywords + case Basic4GL.FUNCTION_KW, + Basic4GL.SUB_KW, + Basic4GL.DIM_KW, + Basic4GL.AS_KW, + Basic4GL.GOTO_KW, + Basic4GL.GOSUB_KW, + Basic4GL.IF_KW, + Basic4GL.THEN_KW, + Basic4GL.ELSE_KW, + Basic4GL.ELSEIF_KW, + Basic4GL.ENDIF_KW, + Basic4GL.END_KW, + Basic4GL.RETURN_KW, + Basic4GL.FOR_KW, + Basic4GL.TO_KW, + Basic4GL.STEP_KW, + Basic4GL.NEXT_KW, + Basic4GL.WHILE_KW, + Basic4GL.WEND_KW, + Basic4GL.RUN_KW, + Basic4GL.STRUC_KW, + Basic4GL.ENDSTRUC_KW, + Basic4GL.CONST_KW, + Basic4GL.ALLOC_KW, + Basic4GL.NULL_KW, + Basic4GL.DATA_KW, + Basic4GL.READ_KW, + Basic4GL.RESET_KW, + Basic4GL.TYPE_KW, + Basic4GL.AND_KW, + Basic4GL.OR_KW, + Basic4GL.NOT_KW, + Basic4GL.XOR_KW, + Basic4GL.MOD_KW -> HighlightKind.KEYWORD; + + // Secondary keywords – type names and boolean literals + case Basic4GL.INTEGER_T, + Basic4GL.INT_T, + Basic4GL.SINGLE_T, + Basic4GL.DOUBLE_T, + Basic4GL.STRING_T, + Basic4GL.TRUE_KW, + Basic4GL.FALSE_KW -> HighlightKind.KEYWORD_2; + + // Literals + case Basic4GL.STRING_LIT -> HighlightKind.STRING; + case Basic4GL.INT_LIT, Basic4GL.FLOAT_LIT, Basic4GL.HEX_LIT -> HighlightKind.NUMBER; + + // Identifiers – the IDE adapter re-classifies these via wordsToHighlight + case Basic4GL.IDENTIFIER -> HighlightKind.IDENTIFIER; + + // Whitespace + case Basic4GL.WS -> HighlightKind.WHITESPACE; + case Basic4GL.NEWLINE -> HighlightKind.NEWLINE; + + // Operators and punctuation + case Basic4GL.COLON, + Basic4GL.LPAREN, + Basic4GL.RPAREN, + Basic4GL.LBRACKET, + Basic4GL.RBRACKET, + Basic4GL.COMMA, + Basic4GL.DOT, + Basic4GL.SEMICOLON, + Basic4GL.EQ, + Basic4GL.NEQ, + Basic4GL.LT, + Basic4GL.GT, + Basic4GL.LTE, + Basic4GL.GTE, + Basic4GL.PLUS, + Basic4GL.MINUS, + Basic4GL.STAR, + Basic4GL.SLASH, + Basic4GL.BACKSLASH, + Basic4GL.CARET, + Basic4GL.AT, + Basic4GL.BANG, + Basic4GL.TILDE, + Basic4GL.PERCENT, + Basic4GL.PIPE, + Basic4GL.HASH -> HighlightKind.OPERATOR; + + // Unknown / unrecognised + default -> HighlightKind.OTHER; + }; + } + + // ------------------------------------------------------------------------- + // LanguageSupport – symbol extraction + // ------------------------------------------------------------------------- + + /** + * Scans the full source text and extracts user-defined symbols by walking the ANTLR token + * stream with a lightweight state machine. + * + *

Recognised patterns: + * + *

    + *
  • {@code function Name(params)} / {@code sub Name(params)} → {@code "userfunc"} + *
  • {@code Name:} (identifier immediately followed by {@code COLON}) → {@code "label"} + *
  • {@code dim Name [as Type]} → {@code "variable"} + *
+ */ + @Override + public List extractSymbols(String source) { + if (source == null || source.isEmpty()) { + return List.of(); + } + + Basic4GL lexer = createLexer(source); + CommonTokenStream stream = new CommonTokenStream(lexer); + stream.fill(); + List tokens = stream.getTokens(); + + List symbols = new ArrayList<>(); + + // State machine + final int NONE = 0; + final int AFTER_FUNC_KW = 1; // saw function/sub – next identifier is the name + final int COLLECT_PARAMS = 2; // collecting signature text inside ( … ) + final int AFTER_DIM_KW = 3; // saw dim – next identifier is the variable name + final int AFTER_DIM_NAME = 4; // saw dim name – look for 'as ' + final int AFTER_AS_KW = 5; // saw 'as' after dim name – next identifier is type + + int state = NONE; + String pendingFuncName = null; + StringBuilder paramBuf = null; + int parenDepth = 0; + String pendingVarName = null; + String pendingVarType = null; + + for (int i = 0; i < tokens.size(); i++) { + Token t = tokens.get(i); + int type = t.getType(); + + // Skip whitespace, newlines, and EOF in the state machine + if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { + // A newline resets after-dim state (one dim per line) + if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME) { + flushVariable(symbols, pendingVarName, null); + state = NONE; + pendingVarName = null; + } + continue; + } + + switch (state) { + case NONE -> { + if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { + state = AFTER_FUNC_KW; + } else if (type == Basic4GL.DIM_KW) { + state = AFTER_DIM_KW; + } else if (type == Basic4GL.IDENTIFIER) { + // Look ahead (skip WS) for a COLON → label declaration + Token next = peekNonWs(tokens, i + 1); + if (next != null && next.getType() == Basic4GL.COLON) { + symbols.add(new IndexedSymbol("label", t.getText(), t.getText() + ":")); + } + } + } + case AFTER_FUNC_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingFuncName = t.getText(); + paramBuf = new StringBuilder(t.getText()).append('('); + parenDepth = 0; + state = COLLECT_PARAMS; + } else { + state = NONE; // unexpected token – reset + } + } + case COLLECT_PARAMS -> { + if (type == Basic4GL.LPAREN) { + parenDepth++; + // don't append – we already opened the sig paren + } else if (type == Basic4GL.RPAREN) { + if (parenDepth == 0) { + // Closing paren of the function signature + String sig = paramBuf.toString().trim(); + // Remove trailing comma if any + if (sig.endsWith(",")) + sig = sig.substring(0, sig.length() - 1).trim(); + symbols.add(new IndexedSymbol("userfunc", pendingFuncName, sig + ")")); + state = NONE; + pendingFuncName = null; + paramBuf = null; + } else { + parenDepth--; + paramBuf.append(t.getText()); + } + } else if (type != Basic4GL.WS && type != Basic4GL.NEWLINE) { + if (paramBuf.length() > 0 + && !paramBuf.toString().endsWith("(") + && !paramBuf.toString().endsWith(",") + && !paramBuf.toString().endsWith(" ")) { + paramBuf.append(' '); + } + paramBuf.append(t.getText()); + } + } + case AFTER_DIM_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingVarName = t.getText(); + pendingVarType = null; + state = AFTER_DIM_NAME; + } else { + state = NONE; + } + } + case AFTER_DIM_NAME -> { + if (type == Basic4GL.AS_KW) { + state = AFTER_AS_KW; + } else if (type == Basic4GL.COLON || type == Basic4GL.COMMA) { + // 'dim x, y' or 'dim x :' – flush current, continue + flushVariable(symbols, pendingVarName, pendingVarType); + pendingVarName = null; + pendingVarType = null; + state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; + } else if (type == Basic4GL.NEWLINE) { + flushVariable(symbols, pendingVarName, pendingVarType); + state = NONE; + } else { + // Any other token (e.g. array size) – stay in AFTER_DIM_NAME + } + } + case AFTER_AS_KW -> { + if (type == Basic4GL.IDENTIFIER + || type == Basic4GL.INTEGER_T + || type == Basic4GL.INT_T + || type == Basic4GL.SINGLE_T + || type == Basic4GL.DOUBLE_T + || type == Basic4GL.STRING_T) { + pendingVarType = t.getText(); + flushVariable(symbols, pendingVarName, pendingVarType); + state = NONE; + } else { + flushVariable(symbols, pendingVarName, null); + state = NONE; + } + } + } + } + + // Flush any dangling state at EOF + if (state == AFTER_DIM_NAME || state == AFTER_AS_KW) { + flushVariable(symbols, pendingVarName, pendingVarType); + } + + return symbols; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Basic4GL createLexer(String input) { + Basic4GL lexer = new Basic4GL(CharStreams.fromString(input)); + lexer.removeErrorListeners(); // suppress console noise on partial / invalid source + return lexer; + } + + private static LangToken toLangToken(Token t) { + int start = t.getStartIndex(); + // getStopIndex() is inclusive; LangToken.end is exclusive + int end = t.getStopIndex() + 1; + return new LangToken(t.getType(), t.getText(), start, end); + } + + /** Returns the first non-whitespace token at or after position {@code from}, or null. */ + private static Token peekNonWs(List tokens, int from) { + for (int i = from; i < tokens.size(); i++) { + int type = tokens.get(i).getType(); + if (type != Basic4GL.WS && type != Token.EOF) { + return tokens.get(i); + } + } + return null; + } + + private static void flushVariable(List out, String name, String type) { + if (name == null || name.isBlank()) return; + String sig = (type != null && !type.isBlank()) ? type + " " + name : name; + out.add(new IndexedSymbol("variable", name, sig)); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/language/HighlightKind.java b/app/src/main/java/com/basic4gl/desktop/language/HighlightKind.java new file mode 100644 index 00000000..b7cdfa58 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/language/HighlightKind.java @@ -0,0 +1,54 @@ +package com.basic4gl.desktop.language; + +/** + * Language-neutral semantic categories used to classify tokens for syntax highlighting. + * + *

A {@link LanguageSupport} implementation maps its internal token types to these categories. + * An IDE adapter (e.g. {@code LanguageSupportTokenMaker} for RSyntaxTextArea) then maps these + * categories to the syntax-highlighting primitives of whichever UI toolkit it targets. + * + *

No RSyntaxTextArea or other UI dependency lives in this enum. + */ +public enum HighlightKind { + /** Language reserved words (if, while, for, function, dim, …). */ + KEYWORD, + + /** + * Secondary reserved words – typically type names (integer, string, single, …) or boolean + * literals (true, false). + */ + KEYWORD_2, + + /** Built-in library function names resolved at runtime (DrawLine, PrintString, …). */ + FUNCTION, + + /** Named constant (resolved via {@code wordsToHighlight} by the IDE adapter). */ + CONSTANT, + + /** User-written identifier not otherwise classified. */ + IDENTIFIER, + + /** String literal. */ + STRING, + + /** Numeric literal (integer, float, hex). */ + NUMBER, + + /** Comment token (single-line comment or rem). */ + COMMENT, + + /** Preprocessor directive (e.g. {@code #include}). */ + PREPROCESSOR, + + /** Operator or punctuation symbol. */ + OPERATOR, + + /** Horizontal / inline whitespace. */ + WHITESPACE, + + /** Newline character(s). */ + NEWLINE, + + /** Anything else – used as a safe fallback so no token is silently dropped. */ + OTHER, +} diff --git a/app/src/main/java/com/basic4gl/desktop/language/IndexedSymbol.java b/app/src/main/java/com/basic4gl/desktop/language/IndexedSymbol.java new file mode 100644 index 00000000..8eabc9d5 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/language/IndexedSymbol.java @@ -0,0 +1,10 @@ +package com.basic4gl.desktop.language; + +/** + * A user-defined symbol discovered by {@link LanguageSupport#extractSymbols}. + * + * @param kind One of {@code "userfunc"}, {@code "label"}, or {@code "variable"}. + * @param name The bare symbol name (no punctuation). + * @param signature Human-readable signature shown in the reference panel. + */ +public record IndexedSymbol(String kind, String name, String signature) {} diff --git a/app/src/main/java/com/basic4gl/desktop/language/LangToken.java b/app/src/main/java/com/basic4gl/desktop/language/LangToken.java new file mode 100644 index 00000000..50182b45 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/language/LangToken.java @@ -0,0 +1,29 @@ +package com.basic4gl.desktop.language; + +/** + * An immutable, language-neutral token produced by {@link LanguageSupport#tokenizeLine}. + * + *

Positions are 0-based character offsets within the line string passed to + * {@code tokenizeLine}: + * + *

    + *
  • {@link #start()} – inclusive start offset + *
  • {@link #end()} – exclusive end offset (i.e. {@code line.substring(start, end)} == text) + *
+ * + *

The {@link #type()} field carries the implementation-specific integer token type (e.g. an + * ANTLR token type constant). It is opaque to callers; use + * {@link LanguageSupport#classify(LangToken)} to obtain the portable {@link HighlightKind}. + */ +public record LangToken(int type, String text, int start, int end) { + + /** Convenience: returns {@code true} when this is the synthetic EOF sentinel. */ + public boolean isEof() { + return type == -1; // matches org.antlr.v4.runtime.Token.EOF + } + + /** Length in characters. */ + public int length() { + return end - start; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java b/app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java new file mode 100644 index 00000000..2429a99b --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java @@ -0,0 +1,82 @@ +package com.basic4gl.desktop.language; + +import java.util.List; + +/** + * Plugin contract for a language definition. + * + *

A single implementation encapsulates everything the IDE needs to know about one language: + * + *

    + *
  • How to tokenize source text (for syntax highlighting) + *
  • How to classify each token into a portable {@link HighlightKind} + *
  • How to extract user-defined symbols from source (for the reference panel / indexer) + *
+ * + *

No RSyntaxTextArea or other UI framework types appear in this interface. + * Adapters that bridge to a specific UI toolkit ({@code LanguageSupportTokenMaker} for + * RSyntaxTextArea, a future LSP adapter, etc.) hold a reference to a {@code LanguageSupport} + * and translate its output into whatever the toolkit requires. + * + *

Implementations are expected to be thread-safe: {@link #tokenizeLine} and + * {@link #extractSymbols} may be called concurrently from both the EDT and background threads. + */ +public interface LanguageSupport { + + // ------------------------------------------------------------------------- + // Identity + // ------------------------------------------------------------------------- + + /** + * The MIME-type style string used to register this language with RSyntaxTextArea's + * {@code TokenMakerFactory} (e.g. {@code "text/basic4gl"}). + * + *

The value is opaque to the core indexer but consumed by the RSyntaxTextArea adapter. + */ + String syntaxStyle(); + + // ------------------------------------------------------------------------- + // Tokenisation + // ------------------------------------------------------------------------- + + /** + * Tokenizes a single line of source text. + * + *

The returned list contains all tokens in left-to-right order. {@link LangToken#start} + * and {@link LangToken#end} are 0-based character offsets within {@code line}. + * + *

Implementations must not return {@code null}; an empty line may return an empty list. + * + * @param line a single line of source (no {@code \n}) + * @return ordered, non-null token list + */ + List tokenizeLine(String line); + + /** + * Maps an implementation-specific {@link LangToken#type()} to a portable + * {@link HighlightKind}. + * + *

This is the only place where the internal token type integers are interpreted. + * All other code works with {@link HighlightKind} values. + * + * @param token a token previously produced by {@link #tokenizeLine} + * @return the semantic highlight category; never {@code null} + */ + HighlightKind classify(LangToken token); + + // ------------------------------------------------------------------------- + // Symbol extraction + // ------------------------------------------------------------------------- + + /** + * Scans the full source text (which may span multiple concatenated files) and returns every + * user-defined symbol it can discover. + * + *

This method is called from a background thread by the {@code SymbolIndexer} after each + * debounce cycle; it must not touch Swing components. + * + * @param source full program source text + * @return discovered symbols; never {@code null} + */ + List extractSymbols(String source); +} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java index 7d44779e..2d288ef7 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java @@ -8,17 +8,24 @@ import com.basic4gl.desktop.spi.FileLineNumber; import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.language.FunctionDefinition; +import com.basic4gl.desktop.spi.language.LabelDefinition; +import com.basic4gl.desktop.spi.language.TypeDefinition; +import com.basic4gl.desktop.spi.language.VariableDefinition; +import com.basic4gl.language.adapter.util.LanguageUtil; import com.basic4gl.language.adapter.util.NumberUtil; import com.basic4gl.language.core.extensions.FunctionLibrary; import com.basic4gl.language.core.extensions.Library; import com.basic4gl.language.core.internal.Mutable; import com.basic4gl.language.core.runtime.IServiceCollection; +import com.basic4gl.language.core.types.BasicValType; +import com.basic4gl.language.core.types.Constant; +import com.basic4gl.language.core.types.FunctionSpecification; +import com.basic4gl.language.core.types.ValType; import com.basic4gl.language.spi.PluginManager; import com.basic4gl.language.spi.PluginLibrary; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; +import java.util.*; import java.util.stream.Stream; public class Basic4GLLanguageService implements LanguageService { @@ -138,4 +145,213 @@ public FileLineNumber getFileLineNumberFromMain(int sourceLine) { return new FileLineNumber(filename.get(), fileRow.get()); } + + @Override + public Iterable getVariableDefinitions() { + ArrayList variableDefinitions = new ArrayList<>(); + for (com.basic4gl.language.core.types.VariableCollection.Variable variable : + compiler.getProgram().getVariables().getVariables()) { + if (variable.name == null || variable.name.isEmpty()) continue; + String typeStr = LanguageUtil.getTypeString(variable.type); + String signature = typeStr + " " + variable.name; + TypeDefinition typeDefinition = LanguageUtil.toTypeDefinition(variable.type); + + VariableDefinition definition = null; // TODO new VariableDefinition(variable.name,) + variableDefinitions.add(definition); + } + return variableDefinitions; + } + + public Iterable getLabelDefinitions() { + ArrayList labelDefinitions = new ArrayList<>(); + return compiler.getLabelNames().stream() + .map(labelName -> { + String usage = "gosub "+ labelName + ": goto" + labelName; + return new LabelDefinition(labelName, labelName + ":", usage); + }) + .toList(); + } + + public Iterable getFunctionDefinitions() { + Map functionLibraryBySpecIndex = buildFunctionLibraryBySpecIndex(); + ArrayList items = new ArrayList<>(); + for (String key : compiler.getFunctionIndex().keySet()) { + for (Integer index : compiler.getFunctionIndex().get(key)) { + String name = key; + FunctionSpecification spec = compiler.getFunctions().get(index); + String libraryName = functionLibraryBySpecIndex.getOrDefault(index, "Builtin"); + String library = libraryName != null ? libraryName : "Builtin"; + StringBuilder signature = new StringBuilder(); + if (spec.isFunction()) { + signature.append(LanguageUtil.getTypeString(spec.getReturnType())).append(' '); + } + signature.append(name); + signature.append(spec.hasBrackets() ? "(" : " "); + boolean needComma = false; + Vector params = spec.getParamTypes().getParams(); + StringBuilder argsOnly = new StringBuilder(); + if (params != null) { + for (ValType type : params) { + if (needComma) { + signature.append(", "); + argsOnly.append(", "); + } + String typeName = LanguageUtil.getTypeString(type); + signature.append(typeName); + argsOnly.append(typeName); + needComma = true; + } + } + if (spec.hasBrackets()) { + signature.append(')'); + } + +// TypeDefinition returnType = spec.isFunction() ? new TypeDefinition(LanguageUtil.getTypeString(spec.getReturnType())) : new TypeDefinition("void"); +// FunctionDefinition definition = new FunctionDefinition( +// name, +// signature.toString(), +// returnType, +// params != null ? params.stream() +// .map(this::getTypeString) +// .map((typeName, i) -> new VariableDefinition(typeName)) +// .toArray(VariableDefinition[]::new) : new VariableDefinition[0], +// spec.getDescription(), +// library +// ); + // TODO + FunctionDefinition definition = null; + items.add(definition); + } + } + + items.addAll(buildUserFunctionReferenceItems()); + + return items; + } + + private ArrayList buildUserFunctionReferenceItems() { + ArrayList items = new ArrayList<>(); + Map funcIndex = compiler.getGlobalUserFunctionIndex(); + java.util.Vector functions = + compiler.getProgram().getUserFunctions(); + java.util.Vector prototypes = + compiler.getProgram().getUserFunctionPrototypes(); + for (Map.Entry entry : funcIndex.entrySet()) { + String name = entry.getKey(); + int funcIdx = entry.getValue(); + com.basic4gl.language.core.stackframe.UserFuncPrototype prototype = null; + if (funcIdx >= 0 && funcIdx < functions.size()) { + int protoIdx = functions.get(funcIdx).prototypeIndex; + if (protoIdx >= 0 && protoIdx < prototypes.size()) { + prototype = prototypes.get(protoIdx); + } + } + StringBuilder signature = new StringBuilder(); + if (prototype != null && prototype.hasReturnVal) { + signature.append(LanguageUtil.getTypeString(prototype.returnValType)).append(' '); + } + signature.append(name).append('('); + if (prototype != null && prototype.paramCount > 0) { + String[] params = new String[prototype.paramCount]; + for (Map.Entry v : prototype.localVarIndex.entrySet()) { + int idx = v.getValue(); + if (idx < prototype.paramCount && idx < prototype.localVarTypes.size()) { + params[idx] = LanguageUtil.getTypeString(prototype.localVarTypes.get(idx)) + " " + v.getKey(); + } + } + boolean needComma = false; + for (String param : params) { + if (needComma) signature.append(", "); + signature.append(param != null ? param : "?"); + needComma = true; + } + } + signature.append(')'); + + FunctionDefinition definition = null; //TODO new FunctionDefinition(name, ...) + items.add(definition); + } + + return items; + } + + private Map buildFunctionLibraryBySpecIndex() { + Map functionLibraryBySpecIndex = new HashMap<>(); + + int specCursor = 0; + for (Library library : compiler.getLibraries()) { + if (!(library instanceof FunctionLibrary functionLibrary)) { + continue; + } + Map specs = functionLibrary.specs(); + if (specs == null) { + continue; + } + int count = 0; + for (FunctionSpecification[] overloads : specs.values()) { + if (overloads != null) { + count += overloads.length; + } + } + for (int i = 0; i < count; i++) { + String libName = library.name(); + if (libName != null) { + functionLibraryBySpecIndex.put(specCursor + i, library.name()); + } + } + specCursor += count; + } + return functionLibraryBySpecIndex; + } + + public Iterable getConstantDefinitions() { + + Map constantLibraryByName = buildConstantLibraryByName(); + java.util.List items = new ArrayList<>(); + for (String key : compiler.getConstants().keySet()) { + String library = constantLibraryByName.getOrDefault(key.toLowerCase(Locale.ROOT), "Builtin"); + if (library == null) { + library = "Builtin"; + } + Constant constant = compiler.getConstants().get(key); + String signature = + key + " = (" + LanguageUtil.getTypeString(constant.getType()) + ") " + + constant; + + + items.add(new VariableDefinition( + key, + signature, + LanguageUtil.toTypeDefinition(constant.getType()), + constant.toString(), + "", + library, + true, + "", + "Builtin" + )); + } + return items; + } + + private Map buildConstantLibraryByName() { + Map constantLibraryByName = new HashMap<>(); + for (Library library : compiler.getLibraries()) { + if (!(library instanceof FunctionLibrary functionLibrary)) { + continue; + } + Map constants = functionLibrary.constants(); + if (constants == null) { + continue; + } + for (String name : constants.keySet()) { + String libName = library.name(); + if (libName != null) { + constantLibraryByName.put(name.toLowerCase(Locale.ROOT), library.name()); + } + } + } + return constantLibraryByName; + } + } diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/ProjectSettingsDialog.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/ProjectSettingsDialog.java deleted file mode 100644 index 8aaaa719..00000000 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/ProjectSettingsDialog.java +++ /dev/null @@ -1,610 +0,0 @@ -package com.basic4gl.language.adapter; - -import com.basic4gl.desktop.spi.Builder; -import com.basic4gl.desktop.spi.Configuration; - -import java.awt.*; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.ResourceBundle; -import javax.swing.*; -import javax.swing.border.EmptyBorder; -import javax.swing.border.MatteBorder; -import javax.swing.event.DocumentEvent; -import javax.swing.event.DocumentListener; - -/** - * Created by Nate on 2/5/2015. - */ -public class ProjectSettingsDialog implements com.basic4gl.desktop.spi.ConfigurationFormPanel.IOnConfigurationChangeListener { - - private static final String BUILD_SETTINGS_CARD = "Build Settings"; - private static final String PROGRAM_ARGUMENTS_CARD = "Program Arguments"; - private static final String RUN_DEBUG_ADVANCED_CARD = "JVM Settings"; - private static final String SAFE_MODE_CARD = "Safe Mode"; - - private final JDialog dialog; - private final JDialog libraryInfoDialog; - - private final JComboBox builderComboBox; - private final JButton libraryInfoButton; - - private final JTextPane infoTextPane; - - // Libraries - private java.util.List builders; - private int currentBuilder; // Index value of target - - private final com.basic4gl.desktop.spi.ConfigurationFormPanel configPane; - - private final com.basic4gl.app.desktop.config.IConfigurableAppSettings appSettings; - - public ProjectSettingsDialog(Frame parent, com.basic4gl.app.desktop.config.IConfigurableAppSettings appSettings) { - - this.appSettings = appSettings; - - Locale locale = new Locale("en", "US"); - ResourceBundle resources = ResourceBundle.getBundle("labels", locale); - - dialog = new JDialog(parent); - - dialog.setTitle("Project Settings"); - dialog.setResizable(true); - dialog.setModal(true); - dialog.setLayout(new BorderLayout()); - - // Library info sub-dialog - libraryInfoDialog = new JDialog(dialog, "Library Info", Dialog.ModalityType.DOCUMENT_MODAL); - libraryInfoDialog.setResizable(true); - libraryInfoDialog.setLayout(new BorderLayout()); - libraryInfoDialog.add(createLibraryInfoHeader("Details for the selected build target."), BorderLayout.NORTH); - - infoTextPane = new JTextPane(); - infoTextPane.setEditable(false); - infoTextPane.setBackground(UIManager.getColor("Panel.background")); - infoTextPane.setBorder(new EmptyBorder(8, 10, 8, 10)); - infoTextPane.setMargin(new Insets(4, 2, 4, 2)); - JScrollPane libraryInfoScrollPane = new JScrollPane(infoTextPane); - libraryInfoScrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); - libraryInfoScrollPane.setBorder(new EmptyBorder(0, 12, 12, 12)); - configureSmoothScrolling(libraryInfoScrollPane); - libraryInfoDialog.add(libraryInfoScrollPane, BorderLayout.CENTER); - - JButton closeLibraryInfoButton = new JButton("Close"); - closeLibraryInfoButton.addActionListener(e -> libraryInfoDialog.setVisible(false)); - JPanel libraryInfoFooter = new JPanel(); - libraryInfoFooter.setLayout(new BoxLayout(libraryInfoFooter, BoxLayout.LINE_AXIS)); - libraryInfoFooter.setBorder(new EmptyBorder(0, 12, 12, 12)); - libraryInfoFooter.add(Box.createHorizontalGlue()); - libraryInfoFooter.add(closeLibraryInfoButton); - libraryInfoDialog.add(libraryInfoFooter, BorderLayout.SOUTH); - libraryInfoDialog.setMinimumSize(new Dimension(420, 300)); - libraryInfoDialog.setSize(new Dimension(460, 320)); - - JPanel contentPane = new JPanel(new BorderLayout()); - dialog.add(contentPane, BorderLayout.CENTER); - - DefaultListModel sections = new DefaultListModel<>(); - sections.addElement(BUILD_SETTINGS_CARD); - sections.addElement(PROGRAM_ARGUMENTS_CARD); - sections.addElement(RUN_DEBUG_ADVANCED_CARD); - sections.addElement(SAFE_MODE_CARD); - JList sectionsList = new JList<>(sections); - sectionsList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - sectionsList.setFixedCellHeight(30); - sectionsList.setBorder(new EmptyBorder(8, 8, 8, 8)); - - JPanel cardsPane = new JPanel(new CardLayout()); - - // Build settings card - JPanel buildSettingsCard = new JPanel(new BorderLayout(0, 12)); - buildSettingsCard.setBorder(new EmptyBorder(12, 12, 12, 12)); - buildSettingsCard.add( - createSectionHeader("Build Settings", "Select a build target and configure its export options."), - BorderLayout.NORTH); - - JPanel buildSettingsBody = new JPanel(new BorderLayout(0, 12)); - - JPanel targetSelectionPane = new JPanel(new GridBagLayout()); - targetSelectionPane.setBorder(new EmptyBorder(6, 8, 6, 8)); - GridBagConstraints targetConstraints = new GridBagConstraints(); - - targetConstraints.gridx = 0; - targetConstraints.gridy = 0; - targetConstraints.anchor = GridBagConstraints.WEST; - targetConstraints.insets = new Insets(0, 0, 0, 10); - targetSelectionPane.add(new JLabel("Target"), targetConstraints); - - targetConstraints.gridx = 1; - targetConstraints.weightx = 1.0; - targetConstraints.fill = GridBagConstraints.HORIZONTAL; - targetConstraints.insets = new Insets(0, 0, 0, 10); - builderComboBox = new JComboBox<>(); - targetSelectionPane.add(builderComboBox, targetConstraints); - - targetConstraints.gridx = 2; - targetConstraints.weightx = 0; - targetConstraints.fill = GridBagConstraints.NONE; - targetConstraints.insets = new Insets(0, 0, 0, 0); - libraryInfoButton = new JButton("Library Info..."); - libraryInfoButton.addActionListener(e -> { - libraryInfoDialog.setLocationRelativeTo(dialog); - libraryInfoDialog.setVisible(true); - }); - targetSelectionPane.add(libraryInfoButton, targetConstraints); - buildSettingsBody.add(targetSelectionPane, BorderLayout.NORTH); - - configPane = new com.basic4gl.desktop.spi.ConfigurationFormPanel(this); - configPane.setBorder(new EmptyBorder(4, 4, 4, 4)); - JScrollPane targetPropertiesScrollPane = new JScrollPane(configPane); - targetPropertiesScrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); - configureSmoothScrolling(targetPropertiesScrollPane); - buildSettingsBody.add(createTitledPanel("Configuration", targetPropertiesScrollPane), BorderLayout.CENTER); - - buildSettingsCard.add(buildSettingsBody, BorderLayout.CENTER); - - // Safe mode settings card - JPanel safeModeSettingsCard = new JPanel(new BorderLayout(0, 12)); - safeModeSettingsCard.setBorder(new EmptyBorder(12, 12, 12, 12)); - safeModeSettingsCard.add( - createSectionHeader("Safe Mode", "Control filesystem restrictions for programs you run in the editor."), - BorderLayout.NORTH); - - JTextPane safeModeDescriptionTextPane = new JTextPane(); - safeModeDescriptionTextPane.setBorder(new EmptyBorder(10, 10, 10, 10)); - safeModeDescriptionTextPane.setEditable(false); - safeModeDescriptionTextPane.setBackground(UIManager.getColor("Panel.background")); - safeModeDescriptionTextPane.setText(resources.getString("safeModeDescription")); - - JScrollPane safeModeSettingsScrollPane = new JScrollPane(safeModeDescriptionTextPane); - safeModeSettingsScrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); - configureSmoothScrolling(safeModeSettingsScrollPane); - safeModeSettingsCard.add(createTitledPanel("Details", safeModeSettingsScrollPane), BorderLayout.CENTER); - - JCheckBox safeModeCheckbox = new JCheckBox(resources.getString("safeModeCheckbox")); - safeModeCheckbox.setSelected(appSettings.isSandboxModeEnabled()); - safeModeCheckbox.setBorder(new EmptyBorder(8, 8, 8, 8)); - JPanel safeModeFooter = new JPanel(new BorderLayout()); - safeModeFooter.add(safeModeCheckbox, BorderLayout.WEST); - safeModeSettingsCard.add(safeModeFooter, BorderLayout.SOUTH); - - // Program arguments card - JPanel programArgumentsCard = new JPanel(new BorderLayout(0, 12)); - programArgumentsCard.setBorder(new EmptyBorder(12, 12, 12, 12)); - programArgumentsCard.add( - createSectionHeader( - "Program Arguments", "Enter one argument per line to pass to programs run from the IDE."), - BorderLayout.NORTH); - - JTextArea argumentsTextArea = new JTextArea(); - argumentsTextArea.setLineWrap(false); - argumentsTextArea.setTabSize(4); - argumentsTextArea.setText(String.join(System.lineSeparator(), appSettings.getProgramArguments())); - - JScrollPane argumentsScrollPane = new JScrollPane(argumentsTextArea); - configureSmoothScrolling(argumentsScrollPane); - programArgumentsCard.add(createTitledPanel("Arguments", argumentsScrollPane), BorderLayout.CENTER); - - // Advanced run/debug card - JPanel advancedRunDebugCard = new JPanel(new BorderLayout(0, 12)); - advancedRunDebugCard.setBorder(new EmptyBorder(12, 12, 12, 12)); - advancedRunDebugCard.add( - createSectionHeader("JVM Settings", "Configure JVM launch options for .jar targets."), - BorderLayout.NORTH); - - JPanel advancedBody = new JPanel(new BorderLayout(0, 12)); - - JTextArea jvmArgumentsTextArea = new JTextArea(); - jvmArgumentsTextArea.setLineWrap(false); - jvmArgumentsTextArea.setTabSize(4); - jvmArgumentsTextArea.setText(String.join(System.lineSeparator(), appSettings.getJvmArguments())); - - JScrollPane jvmArgumentsScrollPane = new JScrollPane(jvmArgumentsTextArea); - configureSmoothScrolling(jvmArgumentsScrollPane); - advancedBody.add(createTitledPanel("JVM Exec Options", jvmArgumentsScrollPane), BorderLayout.CENTER); - - JPanel debugOptionsPanel = new JPanel(new GridBagLayout()); - debugOptionsPanel.setBorder(new EmptyBorder(6, 8, 6, 8)); - GridBagConstraints debugOptionsConstraints = new GridBagConstraints(); - debugOptionsConstraints.gridx = 0; - debugOptionsConstraints.gridy = 0; - debugOptionsConstraints.gridwidth = 2; - debugOptionsConstraints.anchor = GridBagConstraints.WEST; - debugOptionsConstraints.insets = new Insets(0, 0, 8, 0); - - JCheckBox enableJvmDebugCheckbox = new JCheckBox("Enable JDWP debugger"); - enableJvmDebugCheckbox.setSelected(appSettings.isJvmDebuggingEnabled()); - debugOptionsPanel.add(enableJvmDebugCheckbox, debugOptionsConstraints); - - debugOptionsConstraints.gridy++; - JCheckBox waitForAttachCheckbox = new JCheckBox("Suspend until debugger attaches"); - waitForAttachCheckbox.setSelected(appSettings.isJvmDebugSuspendUntilAttach()); - debugOptionsPanel.add(waitForAttachCheckbox, debugOptionsConstraints); - - debugOptionsConstraints.gridy++; - debugOptionsConstraints.gridwidth = 1; - debugOptionsConstraints.insets = new Insets(0, 0, 0, 10); - JLabel debugPortLabel = new JLabel("Debug Port (Optional)"); - debugOptionsPanel.add(debugPortLabel, debugOptionsConstraints); - - debugOptionsConstraints.gridx = 1; - debugOptionsConstraints.weightx = 1.0; - debugOptionsConstraints.fill = GridBagConstraints.HORIZONTAL; - JTextField debugPortField = new JTextField(); - Integer jvmDebugPortOverride = appSettings.getJvmDebugPortOverride(); - debugPortField.setText(jvmDebugPortOverride == null ? "" : Integer.toString(jvmDebugPortOverride)); - debugPortField.setToolTipText("Leave empty to auto-select a free debug port for each run session."); - debugOptionsPanel.add(debugPortField, debugOptionsConstraints); - - debugOptionsConstraints.gridx = 1; - debugOptionsConstraints.gridy++; - debugOptionsConstraints.weightx = 1.0; - debugOptionsConstraints.fill = GridBagConstraints.HORIZONTAL; - debugOptionsConstraints.insets = new Insets(4, 0, 0, 0); - JLabel debugPortHintLabel = new JLabel("Leave blank to auto-select an available port each run."); - debugPortHintLabel.setForeground(UIManager.getColor("Label.disabledForeground")); - debugOptionsPanel.add(debugPortHintLabel, debugOptionsConstraints); - - debugOptionsConstraints.gridy++; - debugOptionsConstraints.insets = new Insets(6, 0, 0, 0); - JLabel debugPortErrorLabel = new JLabel(" "); - debugPortErrorLabel.setForeground(new Color(176, 0, 32)); - debugOptionsPanel.add(debugPortErrorLabel, debugOptionsConstraints); - - debugPortField.getDocument().addDocumentListener(new DocumentListener() { - @Override - public void insertUpdate(DocumentEvent e) { - clearDebugPortError(debugPortErrorLabel); - } - - @Override - public void removeUpdate(DocumentEvent e) { - clearDebugPortError(debugPortErrorLabel); - } - - @Override - public void changedUpdate(DocumentEvent e) { - clearDebugPortError(debugPortErrorLabel); - } - }); - - enableJvmDebugCheckbox.addActionListener(e -> { - if (!enableJvmDebugCheckbox.isSelected()) { - clearDebugPortError(debugPortErrorLabel); - } - updateJvmDebugControlsEnabled( - enableJvmDebugCheckbox, waitForAttachCheckbox, debugPortLabel, debugPortField, debugPortHintLabel); - }); - updateJvmDebugControlsEnabled( - enableJvmDebugCheckbox, waitForAttachCheckbox, debugPortLabel, debugPortField, debugPortHintLabel); - - advancedBody.add(createTitledPanel("Debugger", debugOptionsPanel), BorderLayout.SOUTH); - advancedRunDebugCard.add(advancedBody, BorderLayout.CENTER); - - JButton resetAdvancedDefaultsButton = new JButton("Reset To Defaults"); - resetAdvancedDefaultsButton.addActionListener(e -> resetAdvancedDefaults( - jvmArgumentsTextArea, - enableJvmDebugCheckbox, - waitForAttachCheckbox, - debugPortLabel, - debugPortField, - debugPortHintLabel, - debugPortErrorLabel)); - JPanel advancedFooter = new JPanel(new BorderLayout()); - advancedFooter.add(resetAdvancedDefaultsButton, BorderLayout.WEST); - advancedRunDebugCard.add(advancedFooter, BorderLayout.SOUTH); - - cardsPane.add(buildSettingsCard, BUILD_SETTINGS_CARD); - cardsPane.add(safeModeSettingsCard, SAFE_MODE_CARD); - cardsPane.add(programArgumentsCard, PROGRAM_ARGUMENTS_CARD); - cardsPane.add(advancedRunDebugCard, RUN_DEBUG_ADVANCED_CARD); - - JScrollPane sectionsScrollPane = new JScrollPane(sectionsList); - sectionsScrollPane.setBorder(new MatteBorder(0, 0, 0, 1, UIManager.getColor("Separator.foreground"))); - sectionsScrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); - configureSmoothScrolling(sectionsScrollPane); - - JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, sectionsScrollPane, cardsPane); - splitPane.setDividerLocation(160); - splitPane.setResizeWeight(0); - splitPane.setBorder(null); - contentPane.add(splitPane, BorderLayout.CENTER); - - sectionsList.addListSelectionListener(e -> { - if (e.getValueIsAdjusting()) { - return; - } - String selectedSection = sectionsList.getSelectedValue(); - if (selectedSection == null) { - return; - } - ((CardLayout) cardsPane.getLayout()).show(cardsPane, selectedSection); - }); - sectionsList.setSelectedIndex(0); - - // Buttons - JPanel buttonPane = new JPanel(); - dialog.add(buttonPane, BorderLayout.SOUTH); - JButton applyButton = new JButton("Apply"); - JButton okButton = new JButton("OK"); - JButton cancelButton = new JButton("Cancel"); - - buttonPane.setLayout(new BoxLayout(buttonPane, BoxLayout.LINE_AXIS)); - buttonPane.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); - buttonPane.add(Box.createHorizontalGlue()); - buttonPane.add(applyButton); - buttonPane.add(Box.createRigidArea(new Dimension(10, 0))); - buttonPane.add(okButton); - buttonPane.add(Box.createRigidArea(new Dimension(10, 0))); - buttonPane.add(cancelButton); - - // Action listeners - applyButton.addActionListener(e -> applyChanges( - safeModeCheckbox, - argumentsTextArea, - jvmArgumentsTextArea, - enableJvmDebugCheckbox, - waitForAttachCheckbox, - debugPortField, - debugPortErrorLabel, - false)); - okButton.addActionListener(e -> applyChanges( - safeModeCheckbox, - argumentsTextArea, - jvmArgumentsTextArea, - enableJvmDebugCheckbox, - waitForAttachCheckbox, - debugPortField, - debugPortErrorLabel, - true)); - cancelButton.addActionListener(e -> ProjectSettingsDialog.this.setVisible(false)); - - builderComboBox.addActionListener(e -> { - JComboBox cb = (JComboBox) e.getSource(); - if (cb == null) { - return; - } - selectBuilder(cb.getSelectedIndex()); - }); - - dialog.pack(); - dialog.setMinimumSize(new Dimension(620, 420)); - dialog.setSize(new Dimension(700, 480)); - dialog.setLocationRelativeTo(parent); - } - - private JPanel createSectionHeader(String title, String description) { - JPanel header = new JPanel(); - header.setLayout(new BoxLayout(header, BoxLayout.Y_AXIS)); - - JLabel titleLabel = new JLabel(title); - Font baseFont = titleLabel.getFont(); - titleLabel.setFont(baseFont.deriveFont(Font.BOLD, baseFont.getSize() + 3f)); - titleLabel.setBorder(new EmptyBorder(0, 0, 4, 0)); - - JLabel descriptionLabel = new JLabel(description); - descriptionLabel.setForeground(UIManager.getColor("Label.disabledForeground")); - - header.add(titleLabel); - header.add(descriptionLabel); - return header; - } - - private JPanel createTitledPanel(String title, JComponent content) { - JPanel panel = new JPanel(new BorderLayout(0, 8)); - - JLabel titleLabel = new JLabel(title + ":"); - titleLabel.setBorder(new EmptyBorder(4, 4, 0, 4)); - panel.add(titleLabel, BorderLayout.NORTH); - panel.add(content, BorderLayout.CENTER); - - return panel; - } - - private JPanel createLibraryInfoHeader(String description) { - JPanel header = new JPanel(); - header.setLayout(new BoxLayout(header, BoxLayout.Y_AXIS)); - header.setBorder(new EmptyBorder(12, 12, 8, 12)); - - JLabel titleLabel = new JLabel("About Library"); - Font baseFont = titleLabel.getFont(); - titleLabel.setFont(baseFont.deriveFont(Font.BOLD, baseFont.getSize() + 4f)); - - JLabel descriptionLabel = new JLabel(description); - descriptionLabel.setForeground(UIManager.getColor("Label.disabledForeground")); - descriptionLabel.setBorder(new EmptyBorder(2, 0, 0, 0)); - - header.add(titleLabel); - header.add(descriptionLabel); - return header; - } - - private void configureSmoothScrolling(JScrollPane scrollPane) { - scrollPane.getVerticalScrollBar().setUnitIncrement(16); - scrollPane.getVerticalScrollBar().setBlockIncrement(64); - scrollPane.getViewport().setScrollMode(JViewport.BLIT_SCROLL_MODE); - scrollPane.setWheelScrollingEnabled(true); - } - - private void applyChanges( - JCheckBox safeModeCheckbox, - JTextArea argumentsTextArea, - JTextArea jvmArgumentsTextArea, - JCheckBox enableJvmDebugCheckbox, - JCheckBox waitForAttachCheckbox, - JTextField debugPortField, - JLabel debugPortErrorLabel, - boolean closeDialog) { - Integer parsedDebugPort = appSettings.getJvmDebugPortOverride(); - if (enableJvmDebugCheckbox.isSelected()) { - try { - parsedDebugPort = parseOptionalPortOrThrow(debugPortField.getText()); - } catch (IllegalArgumentException ex) { - Integer previousPort = appSettings.getJvmDebugPortOverride(); - debugPortField.setText(previousPort == null ? "" : Integer.toString(previousPort)); - showDebugPortError(debugPortErrorLabel, ex.getMessage()); - debugPortField.requestFocusInWindow(); - debugPortField.selectAll(); - return; - } - } - clearDebugPortError(debugPortErrorLabel); - - if (currentBuilder >= 0) { - configPane.applyConfig(); - } - - appSettings.setSandboxModeEnabled(safeModeCheckbox.isSelected()); - appSettings.setProgramArguments(parseProgramArguments(argumentsTextArea.getText())); - appSettings.setJvmArguments(parseProgramArguments(jvmArgumentsTextArea.getText())); - appSettings.setJvmDebuggingEnabled(enableJvmDebugCheckbox.isSelected()); - appSettings.setJvmDebugSuspendUntilAttach(waitForAttachCheckbox.isSelected()); - appSettings.setJvmDebugPortOverride(parsedDebugPort); - if (closeDialog) { - setVisible(false); - } - } - - private void resetAdvancedDefaults( - JTextArea jvmArgumentsTextArea, - JCheckBox enableJvmDebugCheckbox, - JCheckBox waitForAttachCheckbox, - JLabel debugPortLabel, - JTextField debugPortField, - JLabel debugPortHintLabel, - JLabel debugPortErrorLabel) { - jvmArgumentsTextArea.setText(""); - enableJvmDebugCheckbox.setSelected(false); - waitForAttachCheckbox.setSelected(false); - debugPortField.setText(""); - clearDebugPortError(debugPortErrorLabel); - updateJvmDebugControlsEnabled( - enableJvmDebugCheckbox, waitForAttachCheckbox, debugPortLabel, debugPortField, debugPortHintLabel); - } - - private List parseProgramArguments(String text) { - List args = new ArrayList<>(); - if (text == null || text.isEmpty()) { - return args; - } - - String[] lines = text.split("\\R", -1); - for (String line : lines) { - if (line == null) { - continue; - } - if (line.trim().isEmpty()) { - continue; - } - args.add(line.trim()); - } - return args; - } - - private Integer parseOptionalPortOrThrow(String text) { - if (text == null || text.trim().isEmpty()) { - return null; - } - - try { - int port = Integer.parseInt(text.trim()); - if (port < 1 || port > 65535) { - throw new IllegalArgumentException("Debug port must be between 1 and 65535, or left blank."); - } - return port; - } catch (NumberFormatException ex) { - throw new IllegalArgumentException("Debug port must be a whole number between 1 and 65535, or left blank."); - } - } - - private void showDebugPortError(JLabel debugPortErrorLabel, String message) { - debugPortErrorLabel.setText(message == null || message.trim().isEmpty() ? "Invalid debug port." : message); - } - - private void clearDebugPortError(JLabel debugPortErrorLabel) { - debugPortErrorLabel.setText(" "); - } - - private void updateJvmDebugControlsEnabled( - JCheckBox enableJvmDebugCheckbox, - JCheckBox waitForAttachCheckbox, - JLabel debugPortLabel, - JTextField debugPortField, - JLabel debugPortHintLabel) { - boolean jvmDebuggingEnabled = enableJvmDebugCheckbox.isSelected(); - waitForAttachCheckbox.setEnabled(jvmDebuggingEnabled); - debugPortLabel.setEnabled(jvmDebuggingEnabled); - debugPortField.setEnabled(jvmDebuggingEnabled); - debugPortHintLabel.setEnabled(jvmDebuggingEnabled); - } - - private void selectBuilder(int builderIndex) { - if (builders == null || builders.isEmpty() || builderIndex < 0 || builderIndex >= builders.size()) { - currentBuilder = -1; - infoTextPane.setText("No build targets available."); - configPane.removeAll(); - configPane.revalidate(); - configPane.repaint(); - setBuildSettingsEnabled(false); - return; - } - - currentBuilder = builderIndex; - Builder target = builders.get(currentBuilder); - - infoTextPane.setText(target.getDescription()); - infoTextPane.setCaretPosition(0); - configPane.setConfiguration(new Configuration(((com.basic4gl.desktop.spi.Builder) target).getConfiguration())); - setBuildSettingsEnabled(true); - } - - private void setBuildSettingsEnabled(boolean enabled) { - builderComboBox.setEnabled(enabled); - libraryInfoButton.setEnabled(enabled); - configPane.setEnabled(enabled); - } - - public void setVisible(boolean visible) { - dialog.setVisible(visible); - } - - public void setBuilders(java.util.List builders, int currentBuilder) { - builderComboBox.removeAllItems(); - this.builders = builders; - - for (Builder builder : this.builders) { - builderComboBox.addItem(builder.getName()); - } - - if (builders.isEmpty()) { - this.currentBuilder = -1; - selectBuilder(-1); - return; - } - - int selectedBuilder = currentBuilder; - if (selectedBuilder < 0 || selectedBuilder >= builders.size()) { - selectedBuilder = 0; - } - - this.currentBuilder = selectedBuilder; - builderComboBox.setSelectedIndex(selectedBuilder); - selectBuilder(selectedBuilder); - } - - public int getCurrentBuilder() { - return currentBuilder; - } - - @Override - public void onConfigurationChanged(Configuration configuration) { - if (currentBuilder < 0 || builders == null || currentBuilder >= builders.size()) { - return; - } - - com.basic4gl.desktop.spi.Builder builder = builders.get(currentBuilder); - - builder.setConfiguration(configuration); - } -} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java new file mode 100644 index 00000000..82a93239 --- /dev/null +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java @@ -0,0 +1,58 @@ +package com.basic4gl.language.adapter.util; + +import com.basic4gl.desktop.spi.LanguageService; +import com.basic4gl.desktop.spi.language.TypeDefinition; +import com.basic4gl.desktop.spi.language.VariableDefinition; +import com.basic4gl.language.core.types.BasicValType; +import com.basic4gl.language.core.types.ValType; + +public final class LanguageUtil { + private LanguageUtil() { } + +// public static VariableDefinition toVariableDefinition() { +// return new VariableDefinition() +// } +// +// +// public static VariableDefinition buildVariableDefinition(String ) { +// +// } + + + public static TypeDefinition toTypeDefinition(ValType type) { + String name = getTypeString(type); + return new TypeDefinition(name, "", "", ""); + } + public static TypeDefinition toTypeDefinition(int type) { + String name = getTypeString(type); + return new TypeDefinition(name, "", "", ""); + } + + public static String getTypeString(ValType type) { + if (type == null) { + return "???"; + } + StringBuilder result = new StringBuilder(); + for (int i = 0; i < type.getVirtualPointerLevel(); i++) { + result.append('&'); + } + result.append(getTypeString(type.basicType)); + for (int i = 0; i < type.arrayLevel; i++) { + result.append("()"); + } + return result.toString(); + } + + public static String getTypeString(int type) { + switch (type) { + case BasicValType.VTP_INT: + return "int"; + case BasicValType.VTP_REAL: + return "real"; + case BasicValType.VTP_STRING: + return "string"; + default: + return "???"; + } + } +} From 67f4fac71bc30e09ee7dbbd23bb48367f534b55e Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Fri, 22 May 2026 01:11:13 -0400 Subject: [PATCH 03/38] function lookup and lexing --- .../java/com/basic4gl/desktop/MainWindow.java | 19 ++- .../com/basic4gl/desktop/SymbolIndexer.java | 53 +++++++-- .../editor/LanguageSupportTokenMaker.java | 22 +++- .../language/Basic4GLLanguageSupport.java | 108 +++++++++++++++--- 4 files changed, 174 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index a013e23d..cfa1a480 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -118,7 +118,9 @@ public void caretUpdate(CaretEvent e) { // Language support is shared between the symbol indexer and (via BasicTokenMaker) the editor. private final com.basic4gl.desktop.language.LanguageSupport languageSupport = new com.basic4gl.desktop.language.Basic4GLLanguageSupport(); - private final SymbolIndexer symbolIndexer = new SymbolIndexer(languageSupport, this::updateProgramSymbols); + private final SymbolIndexer symbolIndexer = + new SymbolIndexer(languageSupport, this::collectAllSourceText, this::updateProgramSymbols); + private int lastProgramSymbolsFingerprint = Integer.MIN_VALUE; private int expandedLeftSidebarWidth = 260; private int expandedRightDocsWidth = 320; private String activeLeftSidebarKey = "files"; @@ -1251,7 +1253,7 @@ public void insertUpdate(DocumentEvent e) { int index = getTabIndex(edit.getFilePath()); edit.setModified(); tabControl.setTitleAt(index, edit.getTitle()); - symbolIndexer.schedule(collectAllSourceText()); + symbolIndexer.schedule(); } @Override @@ -1259,7 +1261,7 @@ public void removeUpdate(DocumentEvent e) { int index = getTabIndex(edit.getFilePath()); edit.setModified(); tabControl.setTitleAt(index, edit.getTitle()); - symbolIndexer.schedule(collectAllSourceText()); + symbolIndexer.schedule(); } @Override @@ -1395,7 +1397,7 @@ public void onCompileSucceeded() { populateDocsFromCompiler(); // Also sync the indexer immediately so the debounced background pass // reflects the compiled state right away. - symbolIndexer.indexNow(collectAllSourceText()); + symbolIndexer.indexNow(); } @Override @@ -2324,6 +2326,15 @@ private String collectAllSourceText() { * refreshes the reference panel. */ private void updateProgramSymbols(List symbols) { + int fingerprint = 1; + for (com.basic4gl.desktop.language.IndexedSymbol symbol : symbols) { + fingerprint = 31 * fingerprint + Objects.hash(symbol.kind(), symbol.name(), symbol.signature()); + } + if (fingerprint == lastProgramSymbolsFingerprint) { + return; + } + lastProgramSymbolsFingerprint = fingerprint; + // Remove all existing Program-sourced items allReferenceItems.removeIf(item -> "Program".equals(item.library)); diff --git a/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java b/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java index 842631b4..b8d796bc 100644 --- a/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java +++ b/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java @@ -7,7 +7,9 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import javax.swing.SwingUtilities; +import java.lang.reflect.InvocationTargetException; /** * Lightweight debounced symbol indexer. @@ -32,6 +34,11 @@ */ public class SymbolIndexer { + /** Supplies a full-source snapshot; typically implemented by MainWindow. */ + public interface SourceProvider { + String getSourceSnapshot(); + } + /** Receives indexed symbols on the Swing EDT after each debounce cycle. */ public interface Callback { void onIndexed(List symbols); @@ -41,6 +48,7 @@ public interface Callback { private static final long DEBOUNCE_MILLIS = 400; private final LanguageSupport languageSupport; + private final SourceProvider sourceProvider; private final Callback callback; private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { @@ -50,9 +58,11 @@ public interface Callback { }); private ScheduledFuture pending; + private long requestedRevision = 0; - public SymbolIndexer(LanguageSupport languageSupport, Callback callback) { + public SymbolIndexer(LanguageSupport languageSupport, SourceProvider sourceProvider, Callback callback) { this.languageSupport = languageSupport; + this.sourceProvider = sourceProvider; this.callback = callback; } @@ -62,11 +72,11 @@ public SymbolIndexer(LanguageSupport languageSupport, Callback callback) { *

Calling this again before the window expires cancels the previous pass and restarts the * timer — only one extraction runs per idle period. Thread-safe. * - * @param source full source text (may span multiple concatenated editor files) */ - public synchronized void schedule(String source) { + public synchronized void schedule() { cancelPending(); - pending = scheduler.schedule(() -> runAndDeliver(source), DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS); + long revision = ++requestedRevision; + pending = scheduler.schedule(() -> runAndDeliver(revision), DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS); } /** @@ -75,11 +85,11 @@ public synchronized void schedule(String source) { *

Useful after a successful full compile to sync symbols without waiting for the debounce * window. * - * @param source full source text */ - public synchronized void indexNow(String source) { + public synchronized void indexNow() { cancelPending(); - pending = scheduler.schedule(() -> runAndDeliver(source), 0, TimeUnit.MILLISECONDS); + long revision = ++requestedRevision; + pending = scheduler.schedule(() -> runAndDeliver(revision), 0, TimeUnit.MILLISECONDS); } /** @@ -100,8 +110,33 @@ private void cancelPending() { } } - private void runAndDeliver(String source) { + private void runAndDeliver(long revision) { + String source = getSourceSnapshotOnEdt(); List result = languageSupport.extractSymbols(source); - SwingUtilities.invokeLater(() -> callback.onIndexed(result)); + SwingUtilities.invokeLater(() -> { + synchronized (SymbolIndexer.this) { + if (revision != requestedRevision) { + return; + } + } + callback.onIndexed(result); + }); + } + + private String getSourceSnapshotOnEdt() { + if (SwingUtilities.isEventDispatchThread()) { + return sourceProvider.getSourceSnapshot(); + } + + AtomicReference source = new AtomicReference<>(""); + try { + SwingUtilities.invokeAndWait(() -> source.set(sourceProvider.getSourceSnapshot())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (InvocationTargetException e) { + // Keep indexing resilient even if the source provider throws. + return ""; + } + return source.get(); } } diff --git a/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java b/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java index f08c412c..99b66f25 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java @@ -3,7 +3,9 @@ import com.basic4gl.desktop.language.HighlightKind; import com.basic4gl.desktop.language.LangToken; import com.basic4gl.desktop.language.LanguageSupport; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import javax.swing.text.Segment; import org.fife.ui.rsyntaxtextarea.AbstractTokenMaker; import org.fife.ui.rsyntaxtextarea.Token; @@ -23,7 +25,15 @@ */ public class LanguageSupportTokenMaker extends AbstractTokenMaker { + private static final int LINE_TOKEN_CACHE_SIZE = 512; + private final LanguageSupport languageSupport; + private final Map> lineTokenCache = new LinkedHashMap<>(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > LINE_TOKEN_CACHE_SIZE; + } + }; public LanguageSupportTokenMaker(LanguageSupport languageSupport) { this.languageSupport = languageSupport; @@ -108,7 +118,17 @@ public Token getTokenList(Segment text, int startTokenType, int startOffset) { * @param docOffset document-level character offset of the first character */ private void appendTokens(Segment seg, String lineText, int arrayOffset, int docOffset) { - List tokens = languageSupport.tokenizeLine(lineText); + List tokens; + synchronized (lineTokenCache) { + tokens = lineTokenCache.get(lineText); + } + if (tokens == null) { + tokens = List.copyOf(languageSupport.tokenizeLine(lineText)); + synchronized (lineTokenCache) { + lineTokenCache.put(lineText, tokens); + } + } + for (LangToken lt : tokens) { HighlightKind kind = languageSupport.classify(lt); int rstaType = kindToRstaType(kind); diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java index 4dc9fc65..19e6ff60 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java +++ b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java @@ -1,7 +1,10 @@ package com.basic4gl.desktop.language; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; import org.antlr.v4.runtime.Token; @@ -183,7 +186,8 @@ public List extractSymbols(String source) { stream.fill(); List tokens = stream.getTokens(); - List symbols = new ArrayList<>(); + Map symbolsByKey = new LinkedHashMap<>(); + Map variableDeclCounts = new LinkedHashMap<>(); // State machine final int NONE = 0; @@ -199,6 +203,7 @@ public List extractSymbols(String source) { int parenDepth = 0; String pendingVarName = null; String pendingVarType = null; + String currentRoutine = null; for (int i = 0; i < tokens.size(); i++) { Token t = tokens.get(i); @@ -208,7 +213,12 @@ public List extractSymbols(String source) { if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { // A newline resets after-dim state (one dim per line) if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME) { - flushVariable(symbols, pendingVarName, null); + flushVariable( + symbolsByKey, + variableDeclCounts, + pendingVarName, + null, + currentRoutine); state = NONE; pendingVarName = null; } @@ -219,13 +229,19 @@ public List extractSymbols(String source) { case NONE -> { if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { state = AFTER_FUNC_KW; + } else if (type == Basic4GL.END_KW) { + Token next = peekNonWs(tokens, i + 1); + if (next != null + && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { + currentRoutine = null; + } } else if (type == Basic4GL.DIM_KW) { state = AFTER_DIM_KW; } else if (type == Basic4GL.IDENTIFIER) { // Look ahead (skip WS) for a COLON → label declaration Token next = peekNonWs(tokens, i + 1); if (next != null && next.getType() == Basic4GL.COLON) { - symbols.add(new IndexedSymbol("label", t.getText(), t.getText() + ":")); + addFirstLabel(symbolsByKey, t.getText()); } } } @@ -250,7 +266,8 @@ public List extractSymbols(String source) { // Remove trailing comma if any if (sig.endsWith(",")) sig = sig.substring(0, sig.length() - 1).trim(); - symbols.add(new IndexedSymbol("userfunc", pendingFuncName, sig + ")")); + addFirstFunction(symbolsByKey, pendingFuncName, sig + ")"); + currentRoutine = pendingFuncName; state = NONE; pendingFuncName = null; paramBuf = null; @@ -282,12 +299,22 @@ public List extractSymbols(String source) { state = AFTER_AS_KW; } else if (type == Basic4GL.COLON || type == Basic4GL.COMMA) { // 'dim x, y' or 'dim x :' – flush current, continue - flushVariable(symbols, pendingVarName, pendingVarType); + flushVariable( + symbolsByKey, + variableDeclCounts, + pendingVarName, + pendingVarType, + currentRoutine); pendingVarName = null; pendingVarType = null; state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; } else if (type == Basic4GL.NEWLINE) { - flushVariable(symbols, pendingVarName, pendingVarType); + flushVariable( + symbolsByKey, + variableDeclCounts, + pendingVarName, + pendingVarType, + currentRoutine); state = NONE; } else { // Any other token (e.g. array size) – stay in AFTER_DIM_NAME @@ -301,10 +328,20 @@ public List extractSymbols(String source) { || type == Basic4GL.DOUBLE_T || type == Basic4GL.STRING_T) { pendingVarType = t.getText(); - flushVariable(symbols, pendingVarName, pendingVarType); + flushVariable( + symbolsByKey, + variableDeclCounts, + pendingVarName, + pendingVarType, + currentRoutine); state = NONE; } else { - flushVariable(symbols, pendingVarName, null); + flushVariable( + symbolsByKey, + variableDeclCounts, + pendingVarName, + null, + currentRoutine); state = NONE; } } @@ -313,10 +350,15 @@ public List extractSymbols(String source) { // Flush any dangling state at EOF if (state == AFTER_DIM_NAME || state == AFTER_AS_KW) { - flushVariable(symbols, pendingVarName, pendingVarType); + flushVariable( + symbolsByKey, + variableDeclCounts, + pendingVarName, + pendingVarType, + currentRoutine); } - return symbols; + return new ArrayList<>(symbolsByKey.values()); } // ------------------------------------------------------------------------- @@ -347,9 +389,47 @@ private static Token peekNonWs(List tokens, int from) { return null; } - private static void flushVariable(List out, String name, String type) { - if (name == null || name.isBlank()) return; - String sig = (type != null && !type.isBlank()) ? type + " " + name : name; - out.add(new IndexedSymbol("variable", name, sig)); + private static void addFirstLabel(Map out, String name) { + if (name == null || name.isBlank()) { + return; + } + String key = symbolKey("label", name, null); + out.putIfAbsent(key, new IndexedSymbol("label", name, name + ":")); + } + + private static void addFirstFunction(Map out, String name, String signature) { + if (name == null || name.isBlank()) { + return; + } + String key = symbolKey("userfunc", name, null); + out.putIfAbsent(key, new IndexedSymbol("userfunc", name, signature)); + } + + private static void flushVariable( + Map out, + Map variableDeclCounts, + String name, + String type, + String currentRoutine) { + if (name == null || name.isBlank()) { + return; + } + + String scope = currentRoutine == null ? "global" : currentRoutine; + String key = symbolKey("variable", name, scope); + int declCount = variableDeclCounts.merge(key, 1, Integer::sum); + + String baseSig = (type != null && !type.isBlank()) ? type + " " + name : name; + String scopedSig = baseSig + " [scope: " + scope + "]"; + String sig = declCount > 1 ? scopedSig + " [re-dim x" + declCount + "]" : scopedSig; + out.put(key, new IndexedSymbol("variable", name, sig)); + } + + private static String symbolKey(String kind, String name, String scope) { + String normalizedName = name == null ? "" : name.toLowerCase(Locale.ROOT); + if (scope == null || scope.isBlank()) { + return kind + "|" + normalizedName; + } + return kind + "|" + scope.toLowerCase(Locale.ROOT) + "|" + normalizedName; } } From f41586e7c96632b789be72ac666cb0cdf31c649f Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Fri, 22 May 2026 01:36:27 -0400 Subject: [PATCH 04/38] work on grammar/scope handling --- .../com/basic4gl/desktop/BasicEditor.java | 1 + .../java/com/basic4gl/desktop/MainWindow.java | 278 +++++++++++++++-- .../language/Basic4GLLanguageSupport.java | 284 +++++++++++++++++- .../desktop/language/LanguageSupport.java | 18 +- .../desktop/language/SymbolDeclaration.java | 24 ++ 5 files changed, 578 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java diff --git a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java index 2a0e60e6..8f9a493b 100644 --- a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java @@ -320,6 +320,7 @@ public boolean compile() { // TODO Reset file directory // SetCurrentDir(mRunDirectory); + presenter.onCompileSucceeded(); return true; } diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index cfa1a480..5a2c8e2b 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -114,6 +114,13 @@ public void caretUpdate(CaretEvent e) { private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); private final JTextPane referenceDetailsPane = new JTextPane(); private final JButton referenceInsertButton = new JButton("Insert"); + private final javax.swing.Timer referenceFilterDebounceTimer = + new javax.swing.Timer(120, e -> filterReferenceItems()); + private boolean updatingReferenceFilters = false; + private static final String REFERENCE_NO_MATCHES_HTML = + "No matches."; + private static final String REFERENCE_SELECT_PROMPT_HTML = + "Select an entry."; private final java.util.List allReferenceItems = new ArrayList<>(); // Language support is shared between the symbol indexer and (via BasicTokenMaker) the editor. private final com.basic4gl.desktop.language.LanguageSupport languageSupport = @@ -181,6 +188,7 @@ public String toString() { private final JMenuItem findMenuItem = new JMenuItem("Find"); private final JMenuItem replaceMenuItem = new JMenuItem("Replace"); + private final JMenuItem goToDeclarationMenuItem = new JMenuItem("Go to Declaration"); private final JCheckBoxMenuItem debugMenuItem = new JCheckBoxMenuItem("Debug Mode"); private final JMenuItem settingsMenuItem = new JMenuItem("Project Settings"); @@ -359,6 +367,7 @@ public MainWindow() { editMenu.add(new JSeparator()); editMenu.add(findMenuItem); editMenu.add(replaceMenuItem); + editMenu.add(goToDeclarationMenuItem); editMenu.add(new JSeparator()); editMenu.add(selectAllMenuItem); @@ -449,6 +458,8 @@ public MainWindow() { replaceMenuItem.addActionListener(e -> { showFindReplaceMenu(true); }); + goToDeclarationMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_B, toolkit.getMenuShortcutKeyMask())); + goToDeclarationMenuItem.addActionListener(e -> actionGoToDeclaration()); selectAllMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_A, toolkit.getMenuShortcutKeyMask())); selectAllMenuItem.addActionListener(e -> { int i = tabControl.getSelectedIndex(); @@ -1200,6 +1211,198 @@ private void actionDebugMode() { refreshDebugDisplays(basicEditor.getMode()); } + private void actionGoToDeclaration() { + int selectedTab = tabControl.getSelectedIndex(); + if (selectedTab < 0 || selectedTab >= fileManager.getFileEditors().size()) { + return; + } + + FileEditor activeEditor = fileManager.getFileEditors().get(selectedTab); + JTextArea editorPane = activeEditor.getEditorPane(); + String symbol = getIdentifierAtCaret(editorPane); + if (symbol == null || symbol.isBlank()) { + setCompilerStatus("No identifier at caret"); + return; + } + + String activeFile = activeEditor.getFilePath(); + int caretLine = 0; + try { + caretLine = editorPane.getLineOfOffset(editorPane.getCaretPosition()); + } catch (BadLocationException ignored) { + } + + java.util.List declarations = + collectOpenFileDeclarations(); + java.util.List matches = declarations.stream() + .filter(d -> ("label".equals(d.kind()) || "variable".equals(d.kind())) + && d.name().equalsIgnoreCase(symbol)) + .toList(); + + if (matches.isEmpty()) { + setCompilerStatus("Declaration not found for: " + symbol); + return; + } + + com.basic4gl.desktop.language.SymbolDeclaration selected = + chooseDeclarationForCaret(matches, activeFile, caretLine); + if (selected == null) { + return; + } + + if (matches.size() > 1) { + selected = promptUserForDeclaration(matches, selected); + if (selected == null) { + return; + } + } + + goToDeclarationLocation(selected); + setCompilerStatus("Declaration: " + selected.signature()); + } + + private java.util.List collectOpenFileDeclarations() { + java.util.List declarations = new ArrayList<>(); + for (FileEditor editor : fileManager.getFileEditors()) { + String fileId = editor.getFilePath(); + if (fileId == null || fileId.isBlank()) { + File file = editor.getFile(); + fileId = file != null ? file.getAbsolutePath() : ""; + } + declarations.addAll(languageSupport.extractDeclarations(editor.getEditorPane().getText(), fileId)); + } + return declarations; + } + + private com.basic4gl.desktop.language.SymbolDeclaration chooseDeclarationForCaret( + java.util.List matches, String activeFile, int caretLine) { + java.util.List sameFile = matches.stream() + .filter(d -> Objects.equals(d.fileId(), activeFile)) + .toList(); + java.util.List candidates = + sameFile.isEmpty() ? matches : sameFile; + + com.basic4gl.desktop.language.SymbolDeclaration best = null; + int bestScore = Integer.MAX_VALUE; + for (com.basic4gl.desktop.language.SymbolDeclaration candidate : candidates) { + int score = declarationScore(candidate, activeFile, caretLine); + if (score < bestScore) { + bestScore = score; + best = candidate; + } + } + return best; + } + + private int declarationScore( + com.basic4gl.desktop.language.SymbolDeclaration declaration, String activeFile, int caretLine) { + int score = 0; + if (!Objects.equals(declaration.fileId(), activeFile)) { + score += 1_000_000; + } + int lineDistance = Math.abs(caretLine - declaration.line()); + score += lineDistance; + if (declaration.line() > caretLine) { + // Prefer declarations above the current caret location. + score += 5_000; + } + // Prefer first declaration over later re-dims when ambiguous. + score += Math.max(0, declaration.declarationIndex() - 1) * 50; + return score; + } + + private com.basic4gl.desktop.language.SymbolDeclaration promptUserForDeclaration( + java.util.List matches, + com.basic4gl.desktop.language.SymbolDeclaration preferred) { + Object[] options = matches.stream().map(this::formatDeclarationChoice).toArray(); + Object initial = preferred != null ? formatDeclarationChoice(preferred) : (options.length > 0 ? options[0] : null); + Object selected = JOptionPane.showInputDialog( + frame, + "Multiple declarations found. Choose destination:", + "Go to Declaration", + JOptionPane.QUESTION_MESSAGE, + null, + options, + initial); + if (selected == null) { + return null; + } + String selectedText = selected.toString(); + for (com.basic4gl.desktop.language.SymbolDeclaration declaration : matches) { + if (formatDeclarationChoice(declaration).equals(selectedText)) { + return declaration; + } + } + return preferred; + } + + private String formatDeclarationChoice(com.basic4gl.desktop.language.SymbolDeclaration declaration) { + String fileLabel = declaration.fileId(); + File f = fileLabel == null ? null : new File(fileLabel); + if (f != null && f.getName() != null && !f.getName().isBlank()) { + fileLabel = f.getName(); + } + return declaration.kind() + " " + declaration.signature() + + " (" + fileLabel + ":" + (declaration.line() + 1) + ")"; + } + + private void goToDeclarationLocation(com.basic4gl.desktop.language.SymbolDeclaration declaration) { + String filePath = declaration.fileId(); + int index = getTabIndex(filePath); + if (index == -1 && filePath != null && !filePath.startsWith("= fileManager.getFileEditors().size()) { + return; + } + + tabControl.setSelectedIndex(index); + JTextArea pane = fileManager.getFileEditors().get(index).getEditorPane(); + int targetOffset; + try { + int lineStart = pane.getLineStartOffset(Math.max(0, declaration.line())); + targetOffset = Math.min(lineStart + Math.max(0, declaration.column()), pane.getDocument().getLength()); + } catch (BadLocationException e) { + targetOffset = Math.min(pane.getDocument().getLength(), pane.getCaretPosition()); + } + pane.requestFocusInWindow(); + pane.setCaretPosition(targetOffset); + } + + private String getIdentifierAtCaret(JTextArea editorPane) { + String text = editorPane.getText(); + if (text == null || text.isEmpty()) { + return null; + } + int caret = editorPane.getCaretPosition(); + caret = Math.max(0, Math.min(caret, text.length())); + + if (caret > 0 && (caret == text.length() || !isIdentifierChar(text.charAt(caret)))) { + caret--; + } + if (caret < 0 || caret >= text.length() || !isIdentifierChar(text.charAt(caret))) { + return null; + } + + int start = caret; + while (start > 0 && isIdentifierChar(text.charAt(start - 1))) { + start--; + } + int end = caret + 1; + while (end < text.length() && isIdentifierChar(text.charAt(end))) { + end++; + } + return text.substring(start, end); + } + + private boolean isIdentifierChar(char c) { + return Character.isLetterOrDigit(c) || c == '_'; + } + public void closeAll() { for (int i = tabControl.getTabCount() - 1; i >= 0; i--) { closeTab(i); @@ -1223,6 +1426,7 @@ public void closeTab(int index) { fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); refreshSidebarContent(); + symbolIndexer.schedule(); } public void addTab() { @@ -1294,6 +1498,7 @@ public void changedUpdate(DocumentEvent e) { fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); refreshSidebarContent(); + symbolIndexer.schedule(); } @Override @@ -1406,6 +1611,7 @@ public void onModeChanged(ApMode mode, String statusMsg) { copyMenuItem.setEnabled(true); findMenuItem.setEnabled(true); replaceMenuItem.setEnabled(true); + goToDeclarationMenuItem.setEnabled(true); selectAllMenuItem.setEnabled(true); stepOverButton.setEnabled(true); @@ -1464,6 +1670,7 @@ public void refreshActions(ApMode mode) { copyMenuItem.setEnabled(false); findMenuItem.setEnabled(false); replaceMenuItem.setEnabled(false); + goToDeclarationMenuItem.setEnabled(false); selectAllMenuItem.setEnabled(false); stepOverButton.setEnabled(false); @@ -2004,8 +2211,12 @@ private void configureDocsPane() { referenceKindFilter.setToolTipText("Filter by functions or constants"); referenceSourceFilter.setToolTipText("Filter by builtin tokens or library-provided tokens"); referenceLibraryFilter.setToolTipText("Filter by library"); + referenceFilterDebounceTimer.setRepeats(false); referenceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + referenceList.setFixedCellHeight(20); + referenceList.setPrototypeCellValue(new ReferenceItem( + "function", "prototype", "prototype(symbol, arg)", "Builtin", "", "", 0)); referenceList.setCellRenderer(new DefaultListCellRenderer() { private final ImageIcon functionIcon = createImageIcon(ICON_FUNCTION); private final ImageIcon variableIcon = createImageIcon(ICON_VARIABLE); @@ -2025,7 +2236,7 @@ public Component getListCellRendererComponent( } else { label.setIcon(variableIcon); } - label.setToolTipText(item.signature + " [" + item.library + "]"); + label.setToolTipText(null); } return label; } @@ -2046,17 +2257,17 @@ public Component getListCellRendererComponent( referenceSearchField.getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent e) { - filterReferenceItems(); + requestFilterReferenceItems(); } @Override public void removeUpdate(DocumentEvent e) { - filterReferenceItems(); + requestFilterReferenceItems(); } @Override public void changedUpdate(DocumentEvent e) { - filterReferenceItems(); + requestFilterReferenceItems(); } }); referenceList.addListSelectionListener(e -> { @@ -2064,9 +2275,21 @@ public void changedUpdate(DocumentEvent e) { updateReferenceSelectionDetails(); } }); - referenceKindFilter.addActionListener(e -> filterReferenceItems()); - referenceSourceFilter.addActionListener(e -> filterReferenceItems()); - referenceLibraryFilter.addActionListener(e -> filterReferenceItems()); + referenceKindFilter.addActionListener(e -> { + if (!updatingReferenceFilters) { + filterReferenceItems(); + } + }); + referenceSourceFilter.addActionListener(e -> { + if (!updatingReferenceFilters) { + filterReferenceItems(); + } + }); + referenceLibraryFilter.addActionListener(e -> { + if (!updatingReferenceFilters) { + filterReferenceItems(); + } + }); referenceList.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { @@ -2502,12 +2725,21 @@ private void rebuildLibraryFilterOptions() { } } - referenceLibraryFilter.removeAllItems(); - referenceLibraryFilter.addItem("All libraries"); - for (String library : libraries) { - referenceLibraryFilter.addItem(library); + updatingReferenceFilters = true; + try { + referenceLibraryFilter.removeAllItems(); + referenceLibraryFilter.addItem("All libraries"); + for (String library : libraries) { + referenceLibraryFilter.addItem(library); + } + referenceLibraryFilter.setSelectedItem(libraries.contains(selected) ? selected : "All libraries"); + } finally { + updatingReferenceFilters = false; } - referenceLibraryFilter.setSelectedItem(libraries.contains(selected) ? selected : "All libraries"); + } + + private void requestFilterReferenceItems() { + referenceFilterDebounceTimer.restart(); } private void filterReferenceItems() { @@ -2517,7 +2749,8 @@ private void filterReferenceItems() { String selectedSource = Objects.toString(referenceSourceFilter.getSelectedItem(), "All sources"); String selectedLibrary = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); - referenceListModel.clear(); + ReferenceItem previousSelection = referenceList.getSelectedValue(); + java.util.List matches = new ArrayList<>(); for (ReferenceItem item : allReferenceItems) { boolean kindMatches = "All".equals(selectedKind) || ("Functions".equals(selectedKind) @@ -2545,16 +2778,24 @@ private void filterReferenceItems() { || (item.library != null && item.library.toLowerCase(Locale.ROOT).contains(needle))) { if (kindMatches && sourceMatches && libraryMatches) { - referenceListModel.addElement(item); + matches.add(item); } } } + referenceListModel.clear(); + for (ReferenceItem match : matches) { + referenceListModel.addElement(match); + } + if (!referenceListModel.isEmpty()) { - referenceList.setSelectedIndex(0); + if (previousSelection != null && matches.contains(previousSelection)) { + referenceList.setSelectedValue(previousSelection, true); + } else { + referenceList.setSelectedIndex(0); + } } else { - referenceDetailsPane.setText( - "No matches."); + referenceDetailsPane.setText(REFERENCE_NO_MATCHES_HTML); referenceInsertButton.setEnabled(false); } } @@ -2562,8 +2803,7 @@ private void filterReferenceItems() { private void updateReferenceSelectionDetails() { ReferenceItem item = referenceList.getSelectedValue(); if (item == null) { - referenceDetailsPane.setText( - "Select an entry."); + referenceDetailsPane.setText(REFERENCE_SELECT_PROMPT_HTML); referenceInsertButton.setEnabled(false); return; } diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java index 19e6ff60..616ff2d0 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java +++ b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java @@ -204,6 +204,10 @@ public List extractSymbols(String source) { String pendingVarName = null; String pendingVarType = null; String currentRoutine = null; + // Struc-scope tracking: dims inside a struc block use "struc:" as scope + // so they never collide with same-named program variables in re-dim counting. + boolean inStruc = false; + String currentStrucName = null; for (int i = 0; i < tokens.size(); i++) { Token t = tokens.get(i); @@ -213,12 +217,15 @@ public List extractSymbols(String source) { if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { // A newline resets after-dim state (one dim per line) if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME) { + String effectiveRoutine = inStruc + ? "struc:" + (currentStrucName != null ? currentStrucName : "") + : currentRoutine; flushVariable( symbolsByKey, variableDeclCounts, pendingVarName, null, - currentRoutine); + effectiveRoutine); state = NONE; pendingVarName = null; } @@ -235,6 +242,16 @@ public List extractSymbols(String source) { && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { currentRoutine = null; } + } else if (type == Basic4GL.STRUC_KW) { + // Entering a struc block – capture the struct name from the next identifier + Token nameToken = peekNonWs(tokens, i + 1); + currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) + ? nameToken.getText() + : null; + inStruc = true; + } else if (type == Basic4GL.ENDSTRUC_KW) { + inStruc = false; + currentStrucName = null; } else if (type == Basic4GL.DIM_KW) { state = AFTER_DIM_KW; } else if (type == Basic4GL.IDENTIFIER) { @@ -295,6 +312,9 @@ public List extractSymbols(String source) { } } case AFTER_DIM_NAME -> { + String effectiveRoutine = inStruc + ? "struc:" + (currentStrucName != null ? currentStrucName : "") + : currentRoutine; if (type == Basic4GL.AS_KW) { state = AFTER_AS_KW; } else if (type == Basic4GL.COLON || type == Basic4GL.COMMA) { @@ -304,7 +324,7 @@ public List extractSymbols(String source) { variableDeclCounts, pendingVarName, pendingVarType, - currentRoutine); + effectiveRoutine); pendingVarName = null; pendingVarType = null; state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; @@ -314,13 +334,16 @@ public List extractSymbols(String source) { variableDeclCounts, pendingVarName, pendingVarType, - currentRoutine); + effectiveRoutine); state = NONE; } else { // Any other token (e.g. array size) – stay in AFTER_DIM_NAME } } case AFTER_AS_KW -> { + String effectiveRoutine = inStruc + ? "struc:" + (currentStrucName != null ? currentStrucName : "") + : currentRoutine; if (type == Basic4GL.IDENTIFIER || type == Basic4GL.INTEGER_T || type == Basic4GL.INT_T @@ -333,7 +356,7 @@ public List extractSymbols(String source) { variableDeclCounts, pendingVarName, pendingVarType, - currentRoutine); + effectiveRoutine); state = NONE; } else { flushVariable( @@ -341,7 +364,7 @@ public List extractSymbols(String source) { variableDeclCounts, pendingVarName, null, - currentRoutine); + effectiveRoutine); state = NONE; } } @@ -350,17 +373,237 @@ public List extractSymbols(String source) { // Flush any dangling state at EOF if (state == AFTER_DIM_NAME || state == AFTER_AS_KW) { + String effectiveRoutine = inStruc + ? "struc:" + (currentStrucName != null ? currentStrucName : "") + : currentRoutine; flushVariable( symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, - currentRoutine); + effectiveRoutine); } return new ArrayList<>(symbolsByKey.values()); } + @Override + public List extractDeclarations(String source, String fileId) { + if (source == null || source.isEmpty()) { + return List.of(); + } + + Basic4GL lexer = createLexer(source); + CommonTokenStream stream = new CommonTokenStream(lexer); + stream.fill(); + List tokens = stream.getTokens(); + + List declarations = new ArrayList<>(); + Map variableDeclCounts = new LinkedHashMap<>(); + + final int NONE = 0; + final int AFTER_FUNC_KW = 1; + final int COLLECT_PARAMS = 2; + final int AFTER_DIM_KW = 3; + final int AFTER_DIM_NAME = 4; + final int AFTER_AS_KW = 5; + + int state = NONE; + Token pendingFuncNameToken = null; + StringBuilder paramBuf = null; + int parenDepth = 0; + Token pendingVarNameToken = null; + String pendingVarType = null; + String currentRoutine = null; + // Struc-scope tracking: dims inside a struc block use "struc:" as scope + // so they never collide with same-named program variables in re-dim counting. + boolean inStruc = false; + String currentStrucName = null; + + for (int i = 0; i < tokens.size(); i++) { + Token t = tokens.get(i); + int type = t.getType(); + + if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { + if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME && pendingVarNameToken != null) { + String effectiveRoutine = inStruc + ? "struc:" + (currentStrucName != null ? currentStrucName : "") + : currentRoutine; + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + pendingVarNameToken = null; + pendingVarType = null; + state = NONE; + } + continue; + } + + switch (state) { + case NONE -> { + if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { + state = AFTER_FUNC_KW; + } else if (type == Basic4GL.END_KW) { + Token next = peekNonWs(tokens, i + 1); + if (next != null + && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { + currentRoutine = null; + } + } else if (type == Basic4GL.STRUC_KW) { + // Entering a struc block – capture the struct name from the next identifier + Token nameToken = peekNonWs(tokens, i + 1); + currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) + ? nameToken.getText() + : null; + inStruc = true; + } else if (type == Basic4GL.ENDSTRUC_KW) { + inStruc = false; + currentStrucName = null; + } else if (type == Basic4GL.DIM_KW) { + state = AFTER_DIM_KW; + } else if (type == Basic4GL.IDENTIFIER) { + Token next = peekNonWs(tokens, i + 1); + if (next != null && next.getType() == Basic4GL.COLON) { + declarations.add(new SymbolDeclaration( + "label", + t.getText(), + t.getText() + ":", + currentRoutine == null ? "global" : currentRoutine, + 1, + fileId, + Math.max(0, t.getLine() - 1), + Math.max(0, t.getCharPositionInLine()))); + } + } + } + case AFTER_FUNC_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingFuncNameToken = t; + paramBuf = new StringBuilder(t.getText()).append('('); + parenDepth = 0; + state = COLLECT_PARAMS; + } else { + state = NONE; + } + } + case COLLECT_PARAMS -> { + if (type == Basic4GL.LPAREN) { + parenDepth++; + } else if (type == Basic4GL.RPAREN) { + if (parenDepth == 0 && pendingFuncNameToken != null) { + String sig = paramBuf.toString().trim(); + if (sig.endsWith(",")) { + sig = sig.substring(0, sig.length() - 1).trim(); + } + declarations.add(new SymbolDeclaration( + "userfunc", + pendingFuncNameToken.getText(), + sig + ")", + "global", + 1, + fileId, + Math.max(0, pendingFuncNameToken.getLine() - 1), + Math.max(0, pendingFuncNameToken.getCharPositionInLine()))); + currentRoutine = pendingFuncNameToken.getText(); + pendingFuncNameToken = null; + paramBuf = null; + state = NONE; + } else { + parenDepth--; + if (paramBuf != null) { + paramBuf.append(t.getText()); + } + } + } else { + if (paramBuf != null + && !paramBuf.toString().endsWith("(") + && !paramBuf.toString().endsWith(",") + && !paramBuf.toString().endsWith(" ")) { + paramBuf.append(' '); + } + if (paramBuf != null) { + paramBuf.append(t.getText()); + } + } + } + case AFTER_DIM_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingVarNameToken = t; + pendingVarType = null; + state = AFTER_DIM_NAME; + } else { + state = NONE; + } + } + case AFTER_DIM_NAME -> { + String effectiveRoutine = inStruc + ? "struc:" + (currentStrucName != null ? currentStrucName : "") + : currentRoutine; + if (type == Basic4GL.AS_KW) { + state = AFTER_AS_KW; + } else if (type == Basic4GL.COLON || type == Basic4GL.COMMA) { + if (pendingVarNameToken != null) { + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + } + pendingVarNameToken = null; + pendingVarType = null; + state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; + } + } + case AFTER_AS_KW -> { + String effectiveRoutine = inStruc + ? "struc:" + (currentStrucName != null ? currentStrucName : "") + : currentRoutine; + if (type == Basic4GL.IDENTIFIER + || type == Basic4GL.INTEGER_T + || type == Basic4GL.INT_T + || type == Basic4GL.SINGLE_T + || type == Basic4GL.DOUBLE_T + || type == Basic4GL.STRING_T) { + pendingVarType = t.getText(); + } + if (pendingVarNameToken != null) { + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + } + pendingVarNameToken = null; + pendingVarType = null; + state = NONE; + } + } + } + + if ((state == AFTER_DIM_NAME || state == AFTER_AS_KW) && pendingVarNameToken != null) { + String effectiveRoutine = inStruc + ? "struc:" + (currentStrucName != null ? currentStrucName : "") + : currentRoutine; + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + } + + return declarations; + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -425,6 +668,35 @@ private static void flushVariable( out.put(key, new IndexedSymbol("variable", name, sig)); } + private static void emitVariableDeclaration( + List declarations, + Map variableDeclCounts, + Token nameToken, + String type, + String currentRoutine, + String fileId) { + String name = nameToken.getText(); + if (name == null || name.isBlank()) { + return; + } + String scope = currentRoutine == null ? "global" : currentRoutine; + String key = symbolKey("variable", name, scope); + int declCount = variableDeclCounts.merge(key, 1, Integer::sum); + String baseSig = (type != null && !type.isBlank()) ? type + " " + name : name; + String scopedSig = baseSig + " [scope: " + scope + "]"; + String sig = declCount > 1 ? scopedSig + " [re-dim x" + declCount + "]" : scopedSig; + + declarations.add(new SymbolDeclaration( + "variable", + name, + sig, + scope, + declCount, + fileId, + Math.max(0, nameToken.getLine() - 1), + Math.max(0, nameToken.getCharPositionInLine()))); + } + private static String symbolKey(String kind, String name, String scope) { String normalizedName = name == null ? "" : name.toLowerCase(Locale.ROOT); if (scope == null || scope.isBlank()) { diff --git a/app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java b/app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java index 2429a99b..3b257734 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java +++ b/app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java @@ -42,8 +42,8 @@ public interface LanguageSupport { /** * Tokenizes a single line of source text. * - *

The returned list contains all tokens in left-to-right order. {@link LangToken#start} - * and {@link LangToken#end} are 0-based character offsets within {@code line}. + *

The returned list contains all tokens in left-to-right order. {@link LangToken#start()} + * and {@link LangToken#end()} are 0-based character offsets within {@code line}. * *

Implementations must not return {@code null}; an empty line may return an empty list. * @@ -79,4 +79,18 @@ public interface LanguageSupport { * @return discovered symbols; never {@code null} */ List extractSymbols(String source); + + /** + * Extracts declaration sites from source for navigation features (e.g. Go To Declaration). + * + *

Default implementation returns an empty list so existing language plugins remain binary + * compatible until they opt into declaration-aware navigation. + * + * @param source full source text + * @param fileId caller-provided source identifier (typically absolute file path) + * @return declaration list; never {@code null} + */ + default List extractDeclarations(String source, String fileId) { + return List.of(); + } } diff --git a/app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java b/app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java new file mode 100644 index 00000000..05d212b6 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java @@ -0,0 +1,24 @@ +package com.basic4gl.desktop.language; + +/** + * A concrete declaration site discovered by a language support implementation. + * + * @param kind Declaration kind, e.g. {@code "label"}, {@code "variable"}, {@code "userfunc"}. + * @param name Bare declared identifier. + * @param signature Human-readable declaration signature. + * @param scope Logical scope label, e.g. {@code "global"} or a routine name. + * @param declarationIndex 1-based ordinal for repeated declarations of the same scoped symbol. + * @param fileId Source file identifier supplied by caller (typically absolute file path). + * @param line 0-based line number in file. + * @param column 0-based column in line. + */ +public record SymbolDeclaration( + String kind, + String name, + String signature, + String scope, + int declarationIndex, + String fileId, + int line, + int column) {} + From 36881b2039e6c944b5aee4494bc9b28c83bcb49c Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Fri, 22 May 2026 01:56:17 -0400 Subject: [PATCH 05/38] scope grammar fixes --- app/src/main/antlr/Basic4GL.g4 | 10 +- .../java/com/basic4gl/desktop/MainWindow.java | 18 ++- .../main/java/com/basic4gl/desktop/Theme.java | 2 + .../language/Basic4GLLanguageSupport.java | 141 +++++++++++++++--- 4 files changed, 146 insertions(+), 25 deletions(-) diff --git a/app/src/main/antlr/Basic4GL.g4 b/app/src/main/antlr/Basic4GL.g4 index 6e23d006..8735b043 100644 --- a/app/src/main/antlr/Basic4GL.g4 +++ b/app/src/main/antlr/Basic4GL.g4 @@ -93,7 +93,14 @@ INT_LIT : [0-9]+ ; // Identifiers (catch-all after keywords – order matters) // --------------------------------------------------------------------------- -IDENTIFIER : [a-zA-Z_][a-zA-Z_0-9]* ; +// Type-suffix characters are part of the identifier: +// # or ! = real (double/float) +// $ = string +// % = integer +// (no suffix) = undefined type +// & is a separate token (pointer/reference prefix). +// These are placed here (after all keywords) so keywords still win on max-munch. +IDENTIFIER : [a-zA-Z_][a-zA-Z_0-9]* [#!$%]? ; // --------------------------------------------------------------------------- // Operators and punctuation (multi-char operators before their prefixes) @@ -125,6 +132,7 @@ TILDE : '~' ; PERCENT : '%' ; PIPE : '|' ; HASH : '#' ; +AMPERSAND : '&' ; // --------------------------------------------------------------------------- // Whitespace diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 5a2c8e2b..a9701f71 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -108,7 +108,7 @@ public void caretUpdate(CaretEvent e) { private final JList referenceList = new JList<>(referenceListModel); private final JTextField referenceSearchField = new JTextField(); private final JComboBox referenceKindFilter = - new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables"}); + new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables", "Structs"}); private final JComboBox referenceSourceFilter = new JComboBox<>(new String[] {"All sources", "Builtin", "Libraries", "Program"}); private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); @@ -2221,6 +2221,7 @@ private void configureDocsPane() { private final ImageIcon functionIcon = createImageIcon(ICON_FUNCTION); private final ImageIcon variableIcon = createImageIcon(ICON_VARIABLE); private final ImageIcon labelIcon = createImageIcon(ICON_LABEL); + private final ImageIcon structIcon = createImageIcon(ICON_STRUCT); @Override public Component getListCellRendererComponent( @@ -2233,6 +2234,8 @@ public Component getListCellRendererComponent( label.setIcon(functionIcon); } else if ("label".equals(item.kind)) { label.setIcon(labelIcon); + } else if ("struc".equals(item.kind)) { + label.setIcon(structIcon); } else { label.setIcon(variableIcon); } @@ -2587,6 +2590,16 @@ private void updateProgramSymbols(List { + details = "" + + "

" + escapeHtml(sym.name()) + "

" + + "

Type: Struct" + + "
Source: Program

" + + "

" + escapeHtml(sym.signature()) + "

" + + ""; + insertText = sym.name(); + caretOffset = sym.name().length(); + } default -> { // "variable" details = "" + "

" + escapeHtml(sym.name()) + "

" @@ -2757,7 +2770,8 @@ private void filterReferenceItems() { && ("function".equals(item.kind) || "userfunc".equals(item.kind))) || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) || ("Labels".equals(selectedKind) && "label".equals(item.kind)) - || ("Variables".equals(selectedKind) && "variable".equals(item.kind)); + || ("Variables".equals(selectedKind) && "variable".equals(item.kind)) + || ("Structs".equals(selectedKind) && "struc".equals(item.kind)); boolean sourceMatches = "All sources".equals(selectedSource) || ("Builtin".equals(selectedSource) && item.library != null diff --git a/app/src/main/java/com/basic4gl/desktop/Theme.java b/app/src/main/java/com/basic4gl/desktop/Theme.java index 1d63d3f5..5d977553 100644 --- a/app/src/main/java/com/basic4gl/desktop/Theme.java +++ b/app/src/main/java/com/basic4gl/desktop/Theme.java @@ -27,4 +27,6 @@ public class Theme { public static final String ICON_FUNCTION = THEME_DIRECTORY + "icon_function.png"; public static final String ICON_VARIABLE = THEME_DIRECTORY + "icon_variable.png"; public static final String ICON_LABEL = THEME_DIRECTORY + "icon_label.png"; + /** Placeholder – replace with a dedicated struct icon when available. */ + public static final String ICON_STRUCT = THEME_DIRECTORY + "icon_variable.png"; } diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java index 616ff2d0..3eb23fcf 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java +++ b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java @@ -152,7 +152,8 @@ public HighlightKind classify(LangToken token) { Basic4GL.TILDE, Basic4GL.PERCENT, Basic4GL.PIPE, - Basic4GL.HASH -> HighlightKind.OPERATOR; + Basic4GL.HASH, + Basic4GL.AMPERSAND -> HighlightKind.OPERATOR; // Unknown / unrecognised default -> HighlightKind.OTHER; @@ -203,6 +204,9 @@ public List extractSymbols(String source) { int parenDepth = 0; String pendingVarName = null; String pendingVarType = null; + // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the + // type-prefix identifier swap when inside an array-size expression. + int dimArrayDepth = 0; String currentRoutine = null; // Struc-scope tracking: dims inside a struc block use "struc:" as scope // so they never collide with same-named program variables in re-dim counting. @@ -224,10 +228,12 @@ public List extractSymbols(String source) { symbolsByKey, variableDeclCounts, pendingVarName, - null, + pendingVarType, effectiveRoutine); state = NONE; pendingVarName = null; + pendingVarType = null; + dimArrayDepth = 0; } continue; } @@ -241,14 +247,22 @@ public List extractSymbols(String source) { if (next != null && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { currentRoutine = null; + } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { + // "end type" – same as endstruc + inStruc = false; + currentStrucName = null; } - } else if (type == Basic4GL.STRUC_KW) { - // Entering a struc block – capture the struct name from the next identifier + } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { + // Entering a struc/type block – capture the struct name from the next identifier Token nameToken = peekNonWs(tokens, i + 1); currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) ? nameToken.getText() : null; inStruc = true; + // Emit the struct type itself as a symbol + if (currentStrucName != null) { + addFirstStruct(symbolsByKey, currentStrucName); + } } else if (type == Basic4GL.ENDSTRUC_KW) { inStruc = false; currentStrucName = null; @@ -305,7 +319,8 @@ public List extractSymbols(String source) { case AFTER_DIM_KW -> { if (type == Basic4GL.IDENTIFIER) { pendingVarName = t.getText(); - pendingVarType = null; + // Infer type from identifier suffix (#, !, $, %) + pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); state = AFTER_DIM_NAME; } else { state = NONE; @@ -315,9 +330,27 @@ public List extractSymbols(String source) { String effectiveRoutine = inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.AS_KW) { + if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { + dimArrayDepth = 0; state = AFTER_AS_KW; - } else if (type == Basic4GL.COLON || type == Basic4GL.COMMA) { + } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { + dimArrayDepth++; + } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { + if (dimArrayDepth > 0) dimArrayDepth--; + } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { + // "dim Type VarName" – the first IDENTIFIER was the type name, + // this IDENTIFIER is the actual variable name. + // Check if we already have a pendingVarType: if it's the inferred + // type from pendingVarName (the first ID), we're in type-prefix mode. + String inferredFromFirstId = inferTypeFromIdentifierSuffix(pendingVarName); + if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { + // Type-prefix case: pendingVarName is the explicit type, new ID is the var name + String newVarUserType = t.getText(); + String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); + pendingVarType = newVarInferredType != null ? newVarInferredType : pendingVarName; + pendingVarName = newVarUserType; + } + } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { // 'dim x, y' or 'dim x :' – flush current, continue flushVariable( symbolsByKey, @@ -327,18 +360,10 @@ public List extractSymbols(String source) { effectiveRoutine); pendingVarName = null; pendingVarType = null; + dimArrayDepth = 0; state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; - } else if (type == Basic4GL.NEWLINE) { - flushVariable( - symbolsByKey, - variableDeclCounts, - pendingVarName, - pendingVarType, - effectiveRoutine); - state = NONE; - } else { - // Any other token (e.g. array size) – stay in AFTER_DIM_NAME } + // else: other tokens (array size expression contents, &, etc.) – stay } case AFTER_AS_KW -> { String effectiveRoutine = inStruc @@ -414,6 +439,9 @@ public List extractDeclarations(String source, String fileId) int parenDepth = 0; Token pendingVarNameToken = null; String pendingVarType = null; + // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the + // type-prefix identifier swap when inside an array-size expression. + int dimArrayDepth = 0; String currentRoutine = null; // Struc-scope tracking: dims inside a struc block use "struc:" as scope // so they never collide with same-named program variables in re-dim counting. @@ -438,6 +466,7 @@ public List extractDeclarations(String source, String fileId) fileId); pendingVarNameToken = null; pendingVarType = null; + dimArrayDepth = 0; state = NONE; } continue; @@ -452,14 +481,30 @@ public List extractDeclarations(String source, String fileId) if (next != null && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { currentRoutine = null; + } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { + // "end type" – same as endstruc + inStruc = false; + currentStrucName = null; } - } else if (type == Basic4GL.STRUC_KW) { - // Entering a struc block – capture the struct name from the next identifier + } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { + // Entering a struc/type block – capture the struct name from the next identifier Token nameToken = peekNonWs(tokens, i + 1); currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) ? nameToken.getText() : null; inStruc = true; + // Emit the struct type definition itself as a declaration + if (currentStrucName != null && nameToken != null) { + declarations.add(new SymbolDeclaration( + "struc", + currentStrucName, + "struc " + currentStrucName, + "global", + 1, + fileId, + Math.max(0, nameToken.getLine() - 1), + Math.max(0, nameToken.getCharPositionInLine()))); + } } else if (type == Basic4GL.ENDSTRUC_KW) { inStruc = false; currentStrucName = null; @@ -533,7 +578,8 @@ public List extractDeclarations(String source, String fileId) case AFTER_DIM_KW -> { if (type == Basic4GL.IDENTIFIER) { pendingVarNameToken = t; - pendingVarType = null; + // Infer type from identifier suffix (#, !, $, %) + pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); state = AFTER_DIM_NAME; } else { state = NONE; @@ -543,9 +589,26 @@ public List extractDeclarations(String source, String fileId) String effectiveRoutine = inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.AS_KW) { + if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { + dimArrayDepth = 0; state = AFTER_AS_KW; - } else if (type == Basic4GL.COLON || type == Basic4GL.COMMA) { + } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { + dimArrayDepth++; + } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { + if (dimArrayDepth > 0) dimArrayDepth--; + } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { + // "dim Type VarName" – the first IDENTIFIER was the type name, + // this IDENTIFIER is the actual variable name. + String firstIdText = pendingVarNameToken != null ? pendingVarNameToken.getText() : null; + String inferredFromFirstId = inferTypeFromIdentifierSuffix(firstIdText); + if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { + // Type-prefix case: first token is the explicit type, new token is the var name + String newVarUserType = t.getText(); + String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); + pendingVarType = newVarInferredType != null ? newVarInferredType : firstIdText; + pendingVarNameToken = t; + } + } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { if (pendingVarNameToken != null) { emitVariableDeclaration( declarations, @@ -557,8 +620,10 @@ public List extractDeclarations(String source, String fileId) } pendingVarNameToken = null; pendingVarType = null; + dimArrayDepth = 0; state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; } + // else: other tokens (array size expression, &, etc.) – stay in AFTER_DIM_NAME } case AFTER_AS_KW -> { String effectiveRoutine = inStruc @@ -648,6 +713,14 @@ private static void addFirstFunction(Map out, String name out.putIfAbsent(key, new IndexedSymbol("userfunc", name, signature)); } + private static void addFirstStruct(Map out, String name) { + if (name == null || name.isBlank()) { + return; + } + String key = symbolKey("struc", name, null); + out.putIfAbsent(key, new IndexedSymbol("struc", name, "struc " + name)); + } + private static void flushVariable( Map out, Map variableDeclCounts, @@ -704,4 +777,28 @@ private static String symbolKey(String kind, String name, String scope) { } return kind + "|" + scope.toLowerCase(Locale.ROOT) + "|" + normalizedName; } + + /** + * Infer the type of a variable from its identifier suffix. + * Returns the inferred type, or null if no suffix. + * + *
    + *
  • {@code #} or {@code !} → "real"
  • + *
  • {@code $} → "string"
  • + *
  • {@code %} → "integer"
  • + *
  • no suffix → null (undefined type)
  • + *
+ */ + private static String inferTypeFromIdentifierSuffix(String identifier) { + if (identifier == null || identifier.isEmpty()) { + return null; + } + char last = identifier.charAt(identifier.length() - 1); + return switch (last) { + case '#', '!' -> "real"; + case '$' -> "string"; + case '%' -> "integer"; + default -> null; + }; + } } From 4b3ee6f88a317af869125a7889764a6cf606a9de Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Fri, 22 May 2026 02:35:59 -0400 Subject: [PATCH 06/38] code folding --- .../basic4gl/desktop/editor/FileEditor.java | 25 +- .../desktop/language/Basic4GLFoldParser.java | 229 ++++++++++++++++++ .../fife/ui/rtextarea/MultiHeaderGutter.java | 102 +++++++- 3 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java diff --git a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java b/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java index dbaf062e..d8a3be85 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java @@ -1,5 +1,6 @@ package com.basic4gl.desktop.editor; +import com.basic4gl.desktop.language.Basic4GLFoldParser; import com.basic4gl.desktop.util.EditorUtil; import com.basic4gl.desktop.util.IFileManager; import com.basic4gl.desktop.util.SwingIconUtil; @@ -16,6 +17,7 @@ import org.fife.rsta.ui.CollapsibleSectionPanel; import org.fife.rsta.ui.search.*; import org.fife.ui.rsyntaxtextarea.*; +import org.fife.ui.rsyntaxtextarea.folding.FoldParserManager; import org.fife.ui.rtextarea.*; public class FileEditor implements SearchListener { @@ -29,6 +31,10 @@ public class FileEditor implements SearchListener { private static final String ICON_BOOKMARK = THEME_DIRECTORY + "bookmark.png"; private static final String ICON_BREAK_PT = THEME_DIRECTORY + "BreakPt.png"; + static { + FoldParserManager.get().addFoldParserMapping("text/basic4gl", new Basic4GLFoldParser()); + } + private final IFileManager fileManager; private final IToggleBreakpointListener toggleBreakpointListener; @@ -66,6 +72,8 @@ public FileEditor( editorPane = new RSyntaxTextArea(20, 60); editorPane.setSyntaxEditingStyle("text/basic4gl"); + // Enable code folding + editorPane.setCodeFoldingEnabled(true); if (linkGenerator != null) { editorPane.setHyperlinksEnabled(true); editorPane.setLinkScanningMask(EditorUtil.getLinkScanningMask()); @@ -192,7 +200,22 @@ public void mouseExited(MouseEvent e) {} scrollPane.setIconRowHeaderEnabled(HEADER_BOOKMARK, false); scrollPane.setIconRowHeaderEnabled(HEADER_BREAK_PT, true); - scrollPane.setFoldIndicatorEnabled(false); + // Enable code folding for Basic4GL syntax + gutter.setFoldIndicatorEnabled(true); + scrollPane.setFoldIndicatorEnabled(true); + // Modern fold visuals: show collapsed markers on hover and subtle armed highlight. + gutter.setFoldIndicatorStyle(FoldIndicatorStyle.MODERN); + gutter.setExpandedFoldRenderStrategy(ExpandedFoldRenderStrategy.ON_HOVER); + gutter.setShowArmedFoldRange(true); + gutter.setShowCollapsedRegionToolTips(true); + gutter.setSpacingBetweenLineNumbersAndFoldIndicator(4); + gutter.setArmedFoldBackground(new Color(232, 244, 255)); + gutter.setFoldIndicatorArmedForeground(new Color(60, 90, 160)); + + // Force gutter visibility update + editorPane.setCodeFoldingEnabled(true); + scrollPane.revalidate(); + scrollPane.repaint(); // Create toolbars and tie their search contexts together also. findToolBar = new FindToolBar(this); diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java new file mode 100644 index 00000000..380d677e --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java @@ -0,0 +1,229 @@ +package com.basic4gl.desktop.language; + +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Locale; +import java.util.ArrayDeque; +import javax.swing.text.Document; +import javax.swing.text.BadLocationException; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.Token; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.folding.Fold; +import org.fife.ui.rsyntaxtextarea.folding.FoldParser; + +/** + * Fold parser for Basic4GL code based on ANTLR tokenization. + * + *

Recognizes all folding structures: + * + *

    + *
  • {@code function Name(...) ... end function}
  • + *
  • {@code sub Name(...) ... end sub}
  • + *
  • {@code if ... else ... elseif ... endif}
  • + *
  • {@code for ... next}
  • + *
  • {@code while ... wend}
  • + *
  • {@code struc Name ... endstruc}
  • + *
  • {@code type Name ... end type}
  • + *
  • {@code label: ... next label:} (labels as implicit scopes)
  • + *
+ * + *

Uses the same ANTLR lexer as {@link Basic4GLLanguageSupport} to ensure consistent + * tokenization, correctly handling comments and strings. + * + */ +public class Basic4GLFoldParser implements FoldParser { + private static final int FOLD_TYPE_CODE = 0; + + private enum ScopeKind { + BLOCK, + LABEL + } + + private static final class Scope { + final ScopeKind kind; + final String blockType; + final Fold fold; + + Scope(ScopeKind kind, String blockType, Fold fold) { + this.kind = kind; + this.blockType = blockType; + this.fold = fold; + } + } + + @Override + public List getFolds(RSyntaxTextArea textArea) { + List folds = new ArrayList<>(); + if (textArea == null || textArea.getDocument().getLength() == 0) { + return folds; + } + + Document doc = textArea.getDocument(); + String docText; + try { + docText = doc.getText(0, doc.getLength()); + } catch (Exception e) { + return folds; + } + + // Use the same lexer as Basic4GLLanguageSupport for consistency + Basic4GL lexer = new Basic4GL(CharStreams.fromString(docText)); + lexer.removeErrorListeners(); // suppress console noise + CommonTokenStream stream = new CommonTokenStream(lexer); + stream.fill(); + List tokens = stream.getTokens(); + + Deque scopes = new ArrayDeque<>(); + int lastContentEndOffset = -1; + + for (int tokenIndex = 0; tokenIndex < tokens.size(); tokenIndex++) { + Token token = tokens.get(tokenIndex); + int type = token.getType(); + int tokenOffset = token.getStartIndex(); + + // Track the end offset of meaningful tokens so EOF fold closure has a target. + if (type != Basic4GL.WS && type != Basic4GL.NEWLINE && type != Token.EOF) { + lastContentEndOffset = token.getStopIndex(); + } + + if (type == Basic4GL.IDENTIFIER) { + Token next = peekNextNonWs(tokens, tokenIndex + 1); + if (next != null && next.getType() == Basic4GL.COLON) { + // Labels fall through, so each new label opens a nested region. + openScope(scopes, folds, textArea, ScopeKind.LABEL, "label", tokenOffset); + continue; + } + } + + if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { + openScope(scopes, folds, textArea, ScopeKind.BLOCK, toLower(token.getText()), tokenOffset); + } else if (type == Basic4GL.IF_KW) { + openScope(scopes, folds, textArea, ScopeKind.BLOCK, "if", tokenOffset); + } else if (type == Basic4GL.FOR_KW) { + openScope(scopes, folds, textArea, ScopeKind.BLOCK, "for", tokenOffset); + } else if (type == Basic4GL.WHILE_KW) { + openScope(scopes, folds, textArea, ScopeKind.BLOCK, "while", tokenOffset); + } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { + openScope(scopes, folds, textArea, ScopeKind.BLOCK, toLower(token.getText()), tokenOffset); + } else if (type == Basic4GL.ENDIF_KW) { + closeToBlock(scopes, "if", tokenOffset); + } else if (type == Basic4GL.NEXT_KW) { + closeToBlock(scopes, "for", tokenOffset); + } else if (type == Basic4GL.WEND_KW) { + closeToBlock(scopes, "while", tokenOffset); + } else if (type == Basic4GL.ENDSTRUC_KW) { + closeToBlock(scopes, "struc", tokenOffset); + } else if (type == Basic4GL.END_KW) { + Token nextToken = peekNextNonWs(tokens, tokenIndex + 1); + if (nextToken != null) { + String endType = toLower(nextToken.getText()); + if ("function".equals(endType) || "sub".equals(endType) || "type".equals(endType)) { + closeToBlock(scopes, endType, tokenOffset); + } + } + } else if (type == Basic4GL.RETURN_KW) { + // Returning from a gosub/function closes fall-through label scopes. + closeWhile(scopes, ScopeKind.LABEL, tokenOffset); + } + } + + if (lastContentEndOffset > -1) { + while (!scopes.isEmpty()) { + closeOne(scopes, lastContentEndOffset); + } + } + + return folds; + } + + /** + * Peek at the next non-whitespace/non-newline token at the given index. + */ + private Token peekNextNonWs(List tokens, int fromIndex) { + for (int i = fromIndex; i < tokens.size(); i++) { + Token t = tokens.get(i); + if (t.getType() != Basic4GL.WS && t.getType() != Basic4GL.NEWLINE && t.getType() != Token.EOF) { + return t; + } + } + return null; + } + + private static String toLower(String text) { + return text == null ? "" : text.toLowerCase(Locale.ROOT); + } + + private static void openScope( + Deque scopes, + List roots, + RSyntaxTextArea textArea, + ScopeKind kind, + String blockType, + int startOffset) { + try { + Fold fold; + Scope parent = scopes.peek(); + if (parent == null) { + fold = new Fold(FOLD_TYPE_CODE, textArea, startOffset); + roots.add(fold); + } else { + fold = parent.fold.createChild(FOLD_TYPE_CODE, startOffset); + } + scopes.push(new Scope(kind, blockType, fold)); + } catch (BadLocationException ignored) { + // Ignore malformed partial source while typing. + } + } + + private static void closeOne(Deque scopes, int endOffset) { + if (scopes.isEmpty()) { + return; + } + Scope scope = scopes.pop(); + try { + scope.fold.setEndOffset(Math.max(scope.fold.getStartOffset(), endOffset)); + if (scope.fold.isOnSingleLine()) { + if (!scope.fold.removeFromParent()) { + // Top-level single-line folds should be discarded from the root list by caller. + } + } + } catch (BadLocationException ignored) { + // Ignore malformed partial source while typing. + } + } + + private static void closeToBlock(Deque scopes, String blockType, int endOffset) { + while (!scopes.isEmpty()) { + Scope top = scopes.peek(); + closeOne(scopes, endOffset); + if (top.kind == ScopeKind.BLOCK && blockType.equals(top.blockType)) { + break; + } + } + } + + private static void closeWhile(Deque scopes, ScopeKind kind, int endOffset) { + while (!scopes.isEmpty() && scopes.peek().kind == kind) { + closeOne(scopes, endOffset); + } + } +} + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java b/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java index 73a4d444..c7452cf5 100644 --- a/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java +++ b/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java @@ -6,6 +6,7 @@ package org.fife.ui.rtextarea; import java.awt.*; +import java.awt.event.MouseAdapter; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.beans.PropertyChangeEvent; @@ -34,6 +35,28 @@ public class MultiHeaderGutter extends JPanel { private final List autoHideIconArea = new ArrayList<>(); private boolean iconRowHeaderInheritsGutterBackground; private FoldIndicator foldIndicator; + private boolean armed; + private int spacingBetweenLineNumbersAndFoldIndicator; + private final MouseAdapter armedListener = new MouseAdapter() { + @Override + public void mouseEntered(java.awt.event.MouseEvent e) { + setArmed(true); + } + + @Override + public void mouseMoved(java.awt.event.MouseEvent e) { + setArmed(true); + } + + @Override + public void mouseExited(java.awt.event.MouseEvent e) { + Component src = (Component) e.getSource(); + Point p = SwingUtilities.convertPoint(src, e.getPoint(), MultiHeaderGutter.this); + if (!MultiHeaderGutter.this.contains(p)) { + setArmed(false); + } + } + }; private final MultiHeaderGutter.TextAreaListener listener = new MultiHeaderGutter.TextAreaListener(); public MultiHeaderGutter(RTextArea textArea) { @@ -41,6 +64,7 @@ public MultiHeaderGutter(RTextArea textArea) { this.lineNumberFont = RTextArea.getDefaultFont(); this.lineNumberingStartIndex = 1; this.iconRowHeaderInheritsGutterBackground = false; + this.spacingBetweenLineNumbersAndFoldIndicator = 0; this.setTextArea(textArea); this.setLayout(new BorderLayout()); if (this.textArea != null) { @@ -62,6 +86,10 @@ public MultiHeaderGutter(RTextArea textArea) { this.headerArea = new JPanel(); this.headerArea.setLayout(new BoxLayout(this.headerArea, BoxLayout.LINE_AXIS)); this.add(this.headerArea, "Before"); + this.addMouseListener(armedListener); + this.addMouseMotionListener(armedListener); + this.headerArea.addMouseListener(armedListener); + this.headerArea.addMouseMotionListener(armedListener); } public GutterIconInfo addLineTrackingIcon(int headerIndex, int line, Icon icon) throws BadLocationException { @@ -167,6 +195,19 @@ public boolean isFoldIndicatorEnabled() { return false; } + public boolean isArmed() { + return armed; + } + + void setArmed(boolean armed) { + if (armed != this.armed) { + this.armed = armed; + if (this.foldIndicator != null) { + this.foldIndicator.gutterArmedUpdate(armed); + } + } + } + public boolean isBookmarkingEnabled(int headerIndex) { return this.iconAreas.get(headerIndex).isBookmarkingEnabled(); } @@ -242,12 +283,17 @@ public void setComponentOrientation(ComponentOrientation o) { public void setFoldIndicatorEnabled(boolean enabled) { if (this.foldIndicator != null) { if (enabled) { - this.add(this.foldIndicator, "After"); + if (this.foldIndicator.getParent() != this) { + this.add(this.foldIndicator, "After"); + } } else { - this.remove(this.foldIndicator); + if (this.foldIndicator.getParent() == this) { + this.remove(this.foldIndicator); + } } this.revalidate(); + this.repaint(); } } @@ -259,6 +305,49 @@ public void setFoldBackground(Color bg) { this.foldIndicator.setFoldIconBackground(bg); } + public void setArmedFoldBackground(Color bg) { + this.foldIndicator.setFoldIconArmedBackground(bg); + } + + public void setFoldIndicatorArmedForeground(Color fg) { + if (fg == null) { + fg = FoldIndicator.DEFAULT_FOREGROUND; + } + this.foldIndicator.setArmedForeground(fg); + } + + public void setFoldIndicatorStyle(FoldIndicatorStyle style) { + if (this.foldIndicator != null) { + this.foldIndicator.setStyle(style); + this.revalidate(); + this.repaint(); + } + } + + public void setExpandedFoldRenderStrategy(ExpandedFoldRenderStrategy strategy) { + this.foldIndicator.setExpandedFoldRenderStrategy(strategy); + } + + public void setShowArmedFoldRange(boolean show) { + this.foldIndicator.setShowArmedFoldRange(show); + } + + public int getSpacingBetweenLineNumbersAndFoldIndicator() { + return spacingBetweenLineNumbersAndFoldIndicator; + } + + public void setSpacingBetweenLineNumbersAndFoldIndicator(int spacing) { + spacing = Math.max(0, spacing); + if (spacing != this.spacingBetweenLineNumbersAndFoldIndicator) { + this.spacingBetweenLineNumbersAndFoldIndicator = spacing; + if (this.lineNumberList != null) { + this.lineNumberList.setBorder(new EmptyBorder(0, 0, 0, spacing)); + } + this.revalidate(); + this.repaint(); + } + } + public void setFoldIndicatorForeground(Color fg) { if (fg == null) { fg = FoldIndicator.DEFAULT_FOREGROUND; @@ -284,6 +373,8 @@ public void addIconRowHeader() { IconRowHeader header = kit.createIconRowHeader(textArea); header.setInheritsGutterBackground(this.getIconRowHeaderInheritsGutterBackground()); this.iconAreas.add(header); + header.addMouseListener(armedListener); + header.addMouseMotionListener(armedListener); this.autoHideIconArea.add(false); setIconRowHeaderEnabled(this.iconAreas.size() - 1, true); } @@ -296,6 +387,8 @@ void addIconRowHeader(RTextArea textArea) { IconRowHeader header = kit.createIconRowHeader(textArea); header.setInheritsGutterBackground(this.getIconRowHeaderInheritsGutterBackground()); this.iconAreas.add(header); + header.addMouseListener(armedListener); + header.addMouseMotionListener(armedListener); this.autoHideIconArea.add(false); setIconRowHeaderEnabled(this.iconAreas.size() - 1, true); } @@ -404,6 +497,9 @@ void setTextArea(RTextArea textArea) { this.lineNumberList.setFont(this.getLineNumberFont()); this.lineNumberList.setForeground(this.getLineNumberColor()); this.lineNumberList.setLineNumberingStartIndex(this.getLineNumberingStartIndex()); + this.lineNumberList.setBorder(new EmptyBorder(0, 0, 0, this.spacingBetweenLineNumbersAndFoldIndicator)); + this.lineNumberList.addMouseListener(armedListener); + this.lineNumberList.addMouseMotionListener(armedListener); } else { this.lineNumberList.setTextArea(textArea); } @@ -418,6 +514,8 @@ void setTextArea(RTextArea textArea) { if (this.foldIndicator == null) { this.foldIndicator = new FoldIndicator(textArea); + this.foldIndicator.addMouseListener(armedListener); + this.foldIndicator.addMouseMotionListener(armedListener); } else { this.foldIndicator.setTextArea(textArea); } From 698f5d17567f8faab83a54169935b0e21f61e97a Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Fri, 22 May 2026 02:47:47 -0400 Subject: [PATCH 07/38] filtering function ref ui --- .../java/com/basic4gl/desktop/MainWindow.java | 95 ++++++++++++++++--- 1 file changed, 84 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index a9701f71..8c5f1f35 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -112,6 +112,8 @@ public void caretUpdate(CaretEvent e) { private final JComboBox referenceSourceFilter = new JComboBox<>(new String[] {"All sources", "Builtin", "Libraries", "Program"}); private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); + private final JButton referenceFiltersButton = new JButton("Filters"); + private final JPopupMenu referenceFiltersPopup = new JPopupMenu(); private final JTextPane referenceDetailsPane = new JTextPane(); private final JButton referenceInsertButton = new JButton("Insert"); private final javax.swing.Timer referenceFilterDebounceTimer = @@ -2194,23 +2196,25 @@ private JPanel buildDebugActionsPanel() { private void configureDocsPane() { JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); JPanel lookupHeader = new JPanel(new BorderLayout(6, 6)); - JPanel lookupFilters = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); - lookupFilters.add(new JLabel("Type")); - lookupFilters.add(referenceKindFilter); - lookupFilters.add(new JLabel("Source")); - lookupFilters.add(referenceSourceFilter); - lookupFilters.add(new JLabel("Library")); - lookupFilters.add(referenceLibraryFilter); - lookupHeader.add(lookupFilters, BorderLayout.WEST); + + JPanel leftHeader = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + referenceFiltersButton.setFocusable(false); + referenceFiltersButton.setToolTipText("Open reference filters"); + leftHeader.add(referenceFiltersButton); + lookupHeader.add(leftHeader, BorderLayout.WEST); + lookupHeader.add(referenceSearchField, BorderLayout.CENTER); referenceInsertButton.setFocusable(false); referenceInsertButton.setEnabled(false); lookupHeader.add(referenceInsertButton, BorderLayout.EAST); referenceSearchField.setToolTipText("Search by name, signature, or library"); - referenceKindFilter.setToolTipText("Filter by functions or constants"); - referenceSourceFilter.setToolTipText("Filter by builtin tokens or library-provided tokens"); - referenceLibraryFilter.setToolTipText("Filter by library"); + referenceKindFilter.setToolTipText("Filter by kind"); + referenceSourceFilter.setToolTipText("Filter by builtin, libraries, or program symbols"); + referenceLibraryFilter.setToolTipText("Filter by library name"); + referenceKindFilter.setPrototypeDisplayValue("Functions"); + rebuildReferenceFiltersPopup(); + referenceFilterDebounceTimer.setRepeats(false); referenceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); @@ -2280,19 +2284,26 @@ public void changedUpdate(DocumentEvent e) { }); referenceKindFilter.addActionListener(e -> { if (!updatingReferenceFilters) { + updateReferenceFiltersButtonTooltip(); filterReferenceItems(); } }); referenceSourceFilter.addActionListener(e -> { if (!updatingReferenceFilters) { + updateReferenceFiltersButtonTooltip(); filterReferenceItems(); } }); referenceLibraryFilter.addActionListener(e -> { if (!updatingReferenceFilters) { + updateReferenceFiltersButtonTooltip(); filterReferenceItems(); } }); + referenceFiltersButton.addActionListener(e -> { + rebuildReferenceFiltersPopup(); + referenceFiltersPopup.show(referenceFiltersButton, 0, referenceFiltersButton.getHeight()); + }); referenceList.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { @@ -2302,6 +2313,7 @@ public void mouseClicked(MouseEvent e) { } }); referenceInsertButton.addActionListener(e -> insertSelectedReference()); + updateReferenceFiltersButtonTooltip(); rightDocsRail.setFloatable(false); rightDocsRail.setRollover(true); @@ -2749,6 +2761,67 @@ private void rebuildLibraryFilterOptions() { } finally { updatingReferenceFilters = false; } + rebuildReferenceFiltersPopup(); + updateReferenceFiltersButtonTooltip(); + } + + private void rebuildReferenceFiltersPopup() { + referenceFiltersPopup.removeAll(); + + JMenu typeMenu = new JMenu("Type"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "All", "All"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Functions", "Functions"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Constants", "Constants"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Labels", "Labels"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Variables", "Variables"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Structs", "Structs"); + + JMenu sourceMenu = new JMenu("Source"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "All sources", "All sources"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Builtin", "Builtin"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Libraries", "Libraries"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Program", "Program"); + + JMenu libraryMenu = new JMenu("Library"); + for (int i = 0; i < referenceLibraryFilter.getItemCount(); i++) { + String item = referenceLibraryFilter.getItemAt(i); + if (item != null) { + addReferenceRadioItems(libraryMenu, referenceLibraryFilter, item, item); + } + } + + JMenuItem resetItem = new JMenuItem("Reset filters"); + resetItem.addActionListener(e -> { + updatingReferenceFilters = true; + try { + referenceKindFilter.setSelectedItem("All"); + referenceSourceFilter.setSelectedItem("All sources"); + referenceLibraryFilter.setSelectedItem("All libraries"); + } finally { + updatingReferenceFilters = false; + } + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + }); + + referenceFiltersPopup.add(typeMenu); + referenceFiltersPopup.add(sourceMenu); + referenceFiltersPopup.add(libraryMenu); + referenceFiltersPopup.addSeparator(); + referenceFiltersPopup.add(resetItem); + } + + private void addReferenceRadioItems(JMenu menu, JComboBox combo, String label, String value) { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(label, Objects.equals(combo.getSelectedItem(), value)); + item.addActionListener(e -> combo.setSelectedItem(value)); + menu.add(item); + } + + private void updateReferenceFiltersButtonTooltip() { + String type = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); + String source = Objects.toString(referenceSourceFilter.getSelectedItem(), "All sources"); + String library = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); + referenceFiltersButton.setToolTipText("Type: " + type + " | Source: " + source + " | Library: " + library); } private void requestFilterReferenceItems() { From 99812a67b3d189a39bdd7ef25988a42679b0398a Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Fri, 22 May 2026 03:03:54 -0400 Subject: [PATCH 08/38] work on file browser --- .../java/com/basic4gl/desktop/MainWindow.java | 405 ++++++++++++++++-- .../desktop/language/Basic4GLFoldParser.java | 1 - .../desktop/language/SymbolDeclaration.java | 1 - 3 files changed, 375 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 8c5f1f35..20ada3b4 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -41,7 +41,9 @@ import javax.swing.border.EmptyBorder; import javax.swing.event.*; import javax.swing.text.BadLocationException; +import javax.swing.filechooser.FileSystemView; import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreePath; import org.fife.ui.rsyntaxtextarea.*; @@ -100,6 +102,8 @@ public void caretUpdate(CaretEvent e) { private final ButtonGroup rightDocsGroup = new ButtonGroup(); private final Map rightDocsButtons = new HashMap<>(); private final JTree fileBrowserTree = new JTree(); + private final JTree assetsTree = new JTree(); + private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); private final DefaultListModel assetsListModel = new DefaultListModel<>(); private final JList assetsList = new JList<>(assetsListModel); private final JComboBox runTargetCombo = new JComboBox<>(); @@ -168,6 +172,29 @@ public String toString() { } } + private static final class AssetItem { + final String title; + final String subtitle; + final File file; + final Icon icon; + + AssetItem(String title, String subtitle, File file, Icon icon) { + this.title = title; + this.subtitle = subtitle; + this.file = file; + this.icon = icon; + } + + boolean isOpenable() { + return file != null && file.isFile(); + } + + @Override + public String toString() { + return title; + } + } + private final JMenu bookmarkSubMenu = new JMenu("Bookmarks"); private final JMenu breakpointSubMenu = new JMenu("Breakpoints"); private final JMenu helpMenu = new JMenu("Help"); @@ -2098,8 +2125,36 @@ private void configureSidebar() { } private JPanel buildFileBrowserPanel() { - JPanel panel = new JPanel(new BorderLayout()); + JPanel panel = new JPanel(new BorderLayout(0, 6)); + JPanel header = new JPanel(new BorderLayout()); + JLabel title = new JLabel("Workspace Browser"); + title.setBorder(new EmptyBorder(4, 8, 0, 8)); + JButton refresh = new JButton("Refresh"); + refresh.setFocusable(false); + refresh.addActionListener(e -> refreshFileBrowserTree()); + header.add(title, BorderLayout.WEST); + header.add(refresh, BorderLayout.EAST); + panel.add(header, BorderLayout.NORTH); + fileBrowserTree.setRootVisible(true); + fileBrowserTree.setShowsRootHandles(true); + fileBrowserTree.setRowHeight(22); + fileBrowserTree.setCellRenderer(new DefaultTreeCellRenderer() { + @Override + public Component getTreeCellRendererComponent( + JTree tree, Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { + JLabel label = (JLabel) super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof File file) { + label.setText(file == null ? "" : fileSystemView.getSystemDisplayName(file)); + if (label.getText() == null || label.getText().isBlank()) { + label.setText(file.getName().isBlank() ? file.getPath() : file.getName()); + } + label.setIcon(fileSystemView.getSystemIcon(file)); + label.setToolTipText(file.getAbsolutePath()); + } + return label; + } + }); fileBrowserTree.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { @@ -2121,35 +2176,69 @@ public void mouseClicked(MouseEvent e) { } } }); - panel.add(new JScrollPane(fileBrowserTree), BorderLayout.CENTER); + JScrollPane scrollPane = new JScrollPane(fileBrowserTree); + configureSmoothScrolling(scrollPane); + panel.add(scrollPane, BorderLayout.CENTER); return panel; } private JPanel buildAssetsPanel() { - JPanel panel = new JPanel(new BorderLayout()); - assetsList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - assetsList.addMouseListener(new MouseAdapter() { + JPanel panel = new JPanel(new BorderLayout(0, 6)); + JPanel header = new JPanel(new BorderLayout()); + JLabel title = new JLabel("Assets"); + title.setBorder(new EmptyBorder(4, 8, 0, 8)); + JButton refresh = new JButton("Refresh"); + refresh.setFocusable(false); + refresh.addActionListener(e -> refreshAssetsLibrary()); + header.add(title, BorderLayout.WEST); + header.add(refresh, BorderLayout.EAST); + panel.add(header, BorderLayout.NORTH); + + assetsTree.setRootVisible(false); + assetsTree.setShowsRootHandles(true); + assetsTree.setRowHeight(24); + assetsTree.setCellRenderer(new DefaultTreeCellRenderer() { + @Override + public Component getTreeCellRendererComponent( + JTree tree, Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { + JLabel label = (JLabel) super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof AssetItem item) { + label.setText(item.subtitle == null || item.subtitle.isBlank() + ? item.title + : "" + escapeHtml(item.title) + "
" + + escapeHtml(item.subtitle) + ""); + label.setIcon(item.icon); + label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); + } + return label; + } + }); + + assetsTree.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() != 2) { return; } - String selected = assetsList.getSelectedValue(); - if (selected == null) { + TreePath path = assetsTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { return; } - File file = new File(selected); - if (!file.exists()) { + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof AssetItem item) || !item.isOpenable()) { return; } - if (selected.toLowerCase(Locale.ROOT).endsWith(".md")) { - openMarkdownInDocsTab(file); + if (item.file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + openMarkdownInDocsTab(item.file); } else { - openTab(file); + openTab(item.file); } } }); - panel.add(new JScrollPane(assetsList), BorderLayout.CENTER); + + JScrollPane scrollPane = new JScrollPane(assetsTree); + configureSmoothScrolling(scrollPane); + panel.add(scrollPane, BorderLayout.CENTER); return panel; } @@ -2352,6 +2441,13 @@ private JToggleButton createRailButton(Icon icon, String tooltip) { return button; } + private void configureSmoothScrolling(JScrollPane scrollPane) { + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + scrollPane.getVerticalScrollBar().setBlockIncrement(64); + scrollPane.getViewport().setScrollMode(JViewport.BLIT_SCROLL_MODE); + scrollPane.setWheelScrollingEnabled(true); + } + private void onLeftSidebarButtonPressed(String key) { if (Objects.equals(activeLeftSidebarKey, key) && isLeftSidebarExpanded()) { collapseLeftSidebar(); @@ -2446,6 +2542,9 @@ private void refreshFileBrowserTree() { File root = new File(fileManager.getCurrentDirectory()); DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0, 5); fileBrowserTree.setModel(new DefaultTreeModel(rootNode)); + if (fileBrowserTree.getRowCount() > 0) { + fileBrowserTree.expandRow(0); + } } private DefaultMutableTreeNode buildFileTreeNode(File file, int depth, int maxDepth) { @@ -2469,40 +2568,285 @@ private DefaultMutableTreeNode buildFileTreeNode(File file, int depth, int maxDe } private void refreshAssetsLibrary() { - assetsListModel.clear(); - File root = new File(fileManager.getCurrentDirectory()); - collectAssetFiles(root, 0, 4); + File rootDir = new File(fileManager.getCurrentDirectory()); + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode( + new AssetItem( + "Assets", + "Workspace resources, libraries, and embedded literals", + null, + createImageIcon(ICON_MENU_ASSETS))); + + DefaultMutableTreeNode workspaceNode = buildMediaTypeSection( + "Workspace Resources", + collectWorkspaceAssets(rootDir, 0, 4), + rootDir, + createImageIcon(ICON_MENU_FOLDER)); + if (workspaceNode != null) { + rootNode.add(workspaceNode); + } + + DefaultMutableTreeNode literalNode = buildMediaTypeSection( + "Embedded Literals", + detectLiteralAssets(rootDir), + rootDir, + createImageIcon(ICON_MENU_ASSETS)); + if (literalNode != null) { + rootNode.add(literalNode); + } + + DefaultMutableTreeNode librariesNode = buildLibraryResourcesNode(rootDir); + if (librariesNode != null) { + rootNode.add(librariesNode); + } + + assetsTree.setModel(new DefaultTreeModel(rootNode)); + for (int i = 0; i < Math.min(4, assetsTree.getRowCount()); i++) { + assetsTree.expandRow(i); + } + } + + private DefaultMutableTreeNode buildMediaTypeSection(String title, java.util.List files, File baseDir, Icon sectionIcon) { + if (files == null || files.isEmpty()) { + return null; + } + Map> byMediaType = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (File file : files) { + String mediaType = getMediaTypeLabel(file); + byMediaType.computeIfAbsent(mediaType, k -> new ArrayList<>()).add(file); + } + DefaultMutableTreeNode section = new DefaultMutableTreeNode( + new AssetItem(title, files.size() + " file(s)", null, sectionIcon)); + for (String mediaType : List.of("Images", "Audio", "Video", "Text", "Documents", "Other")) { + java.util.List bucket = byMediaType.get(mediaType); + if (bucket == null || bucket.isEmpty()) { + continue; + } + DefaultMutableTreeNode typeNode = new DefaultMutableTreeNode( + new AssetItem(mediaType, bucket.size() + " file(s)", null, createImageIcon(ICON_MENU_FOLDER))); + bucket.sort(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File file : bucket) { + typeNode.add(new DefaultMutableTreeNode(createAssetItem(file, baseDir, mediaType))); + } + section.add(typeNode); + } + return section; + } + + private DefaultMutableTreeNode buildLibraryResourcesNode(File baseDir) { + if (basicEditor == null || basicEditor.getLibraries() == null || basicEditor.getLibraries().isEmpty()) { + return null; + } + + DefaultMutableTreeNode librariesNode = new DefaultMutableTreeNode( + new AssetItem("Libraries", "Bookmarked libraries and their resource files", null, createImageIcon(ICON_MENU_FUNCTIONS))); + for (Library library : basicEditor.getLibraries()) { + if (library == null) { + continue; + } + DefaultMutableTreeNode libNode = new DefaultMutableTreeNode( + new AssetItem( + library.name() == null || library.name().isBlank() ? "(unnamed library)" : library.name(), + library.description(), + null, + createImageIcon(ICON_MENU_BOOKMARKS))); + + java.util.List libraryFiles = new ArrayList<>(); + collectResolvedLibraryAssets(library.getDependencies(), baseDir, libraryFiles); + collectResolvedLibraryAssets(library.getClassPathObjects(), baseDir, libraryFiles); + libraryFiles.sort(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File file : libraryFiles) { + libNode.add(new DefaultMutableTreeNode(createAssetItem(file, baseDir, "Library resource"))); + } + + if (libNode.getChildCount() == 0) { + libNode.add(new DefaultMutableTreeNode( + new AssetItem("No local resources", "Library has no resolvable resource files in the workspace", null, createImageIcon(ICON_MENU_HELP)))); + } + librariesNode.add(libNode); + } + return librariesNode; + } + + private void collectResolvedLibraryAssets(java.util.List paths, File baseDir, java.util.List out) { + if (paths == null || paths.isEmpty()) { + return; + } + for (String path : paths) { + if (path == null || path.isBlank()) { + continue; + } + File resolved = resolveAssetReference(path, baseDir, null); + if (resolved == null || !resolved.exists()) { + continue; + } + if (resolved.isDirectory()) { + collectWorkspaceAssets(resolved, 0, 2, out); + } else { + out.add(resolved); + } + } + } + + private java.util.List detectLiteralAssets(File baseDir) { + java.util.LinkedHashSet detected = new java.util.LinkedHashSet<>(); + if (fileManager == null) { + return new ArrayList<>(); + } + + for (com.basic4gl.desktop.editor.FileEditor editor : fileManager.getFileEditors()) { + if (editor == null || editor.getEditorPane() == null) { + continue; + } + String text = editor.getEditorPane().getText(); + if (text == null || text.isBlank()) { + continue; + } + File sourceFile = editor.getFile(); + File sourceParent = sourceFile != null ? sourceFile.getAbsoluteFile().getParentFile() : null; + for (String literal : ExportDialog.extractStringLiterals(text)) { + if (literal == null || literal.isBlank()) { + continue; + } + File resolved = resolveAssetReference(literal, baseDir, sourceParent); + if (resolved != null && resolved.exists() && resolved.isFile()) { + detected.add(resolved); + } + } + } + return new ArrayList<>(detected); + } + + private java.util.List collectWorkspaceAssets(File directory, int depth, int maxDepth) { + java.util.List assets = new ArrayList<>(); + collectWorkspaceAssets(directory, depth, maxDepth, assets); + return assets; } - private void collectAssetFiles(File directory, int depth, int maxDepth) { + private void collectWorkspaceAssets(File directory, int depth, int maxDepth, java.util.List out) { if (directory == null || !directory.isDirectory() || depth > maxDepth) { return; } + if (shouldSkipAssetDirectory(directory)) { + return; + } + File[] files = directory.listFiles(); if (files == null) { return; } Arrays.sort(files, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); for (File file : files) { - if (file.isDirectory()) { - collectAssetFiles(file, depth + 1, maxDepth); + if (file == null || file.getName().startsWith(".")) { continue; } - String name = file.getName().toLowerCase(Locale.ROOT); - if (name.endsWith(".png") - || name.endsWith(".jpg") - || name.endsWith(".jpeg") - || name.endsWith(".gif") - || name.endsWith(".wav") - || name.endsWith(".ogg") - || name.endsWith(".mp3") - || name.endsWith(".txt") - || name.endsWith(".md")) { - assetsListModel.addElement(file.getAbsolutePath()); + if (file.isDirectory()) { + collectWorkspaceAssets(file, depth + 1, maxDepth, out); + } else if (isKnownAssetFile(file)) { + out.add(file); } } } + private boolean shouldSkipAssetDirectory(File directory) { + String name = directory.getName().toLowerCase(Locale.ROOT); + return name.equals("build") + || name.equals("out") + || name.equals("target") + || name.equals("bin") + || name.equals("dist") + || name.equals("node_modules") + || name.equals(".gradle") + || name.equals(".git"); + } + + private AssetItem createAssetItem(File file, File baseDir, String subtitlePrefix) { + String subtitle = subtitlePrefix; + if (file != null) { + String relative = formatRelativePath(file, baseDir); + if (relative != null && !relative.isBlank()) { + subtitle = subtitle == null || subtitle.isBlank() ? relative : subtitle + " • " + relative; + } + } + return new AssetItem(file != null ? file.getName() : "(unknown)", subtitle, file, file != null ? fileSystemView.getSystemIcon(file) : createImageIcon(ICON_MENU_FOLDER)); + } + + private File resolveAssetReference(String literal, File baseDir, File sourceParent) { + if (literal == null || literal.isBlank()) { + return null; + } + String normalized = com.basic4gl.lib.util.FileUtil.separatorsToSystem(literal); + File candidate = new File(normalized); + if (candidate.isAbsolute()) { + return candidate; + } + if (baseDir != null) { + File workspace = new File(baseDir, normalized); + if (workspace.exists()) { + return workspace; + } + } + if (sourceParent != null) { + File sibling = new File(sourceParent, normalized); + if (sibling.exists()) { + return sibling; + } + } + return candidate; + } + + private boolean isKnownAssetFile(File file) { + String name = file.getName().toLowerCase(Locale.ROOT); + return getMediaTypeLabel(file) != null && !"Other".equals(getMediaTypeLabel(file)); + } + + private String getMediaTypeLabel(File file) { + if (file == null) { + return "Other"; + } + String name = file.getName().toLowerCase(Locale.ROOT); + if (name.endsWith(".png") + || name.endsWith(".jpg") + || name.endsWith(".jpeg") + || name.endsWith(".gif") + || name.endsWith(".bmp") + || name.endsWith(".webp") + || name.endsWith(".ico")) { + return "Images"; + } + if (name.endsWith(".wav") || name.endsWith(".ogg") || name.endsWith(".mp3") || name.endsWith(".flac")) { + return "Audio"; + } + if (name.endsWith(".mp4") || name.endsWith(".mov") || name.endsWith(".webm")) { + return "Video"; + } + if (name.endsWith(".txt") || name.endsWith(".md") || name.endsWith(".json") || name.endsWith(".xml") + || name.endsWith(".csv") || name.endsWith(".ini") || name.endsWith(".cfg") || name.endsWith(".properties")) { + return "Text"; + } + if (name.endsWith(".pdf") || name.endsWith(".doc") || name.endsWith(".docx") || name.endsWith(".rtf")) { + return "Documents"; + } + return "Other"; + } + + private String formatRelativePath(File file, File baseDir) { + if (file == null) { + return ""; + } + if (baseDir != null) { + try { + java.nio.file.Path relative = baseDir.getAbsoluteFile().toPath().normalize().relativize(file.getAbsoluteFile().toPath().normalize()); + String text = relative.toString().replace('\\', '/'); + if (!text.startsWith("..")) { + return text; + } + } catch (Exception ignored) { + // Fall back to file name below. + } + } + return file.getName(); + } + private void refreshRunnableFileControls() { if (fileManager == null) { return; @@ -2631,6 +2975,7 @@ private void updateProgramSymbols(List item.kind)); rebuildLibraryFilterOptions(); filterReferenceItems(); + refreshAssetsLibrary(); } private void populateDocsFromCompiler() { diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java index 380d677e..7b97fdc5 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java +++ b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java @@ -226,4 +226,3 @@ private static void closeWhile(Deque scopes, ScopeKind kind, int endOffse - diff --git a/app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java b/app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java index 05d212b6..a7080499 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java +++ b/app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java @@ -21,4 +21,3 @@ public record SymbolDeclaration( String fileId, int line, int column) {} - From 75787b0e94f2dc5fc0250ecea0e4ea188ef2ecd4 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sat, 11 Jul 2026 20:10:02 -0400 Subject: [PATCH 09/38] build fix --- .../java/com/basic4gl/desktop/MainWindow.java | 41 +------------------ 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 20ada3b4..c6520e8c 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -2594,11 +2594,6 @@ private void refreshAssetsLibrary() { rootNode.add(literalNode); } - DefaultMutableTreeNode librariesNode = buildLibraryResourcesNode(rootDir); - if (librariesNode != null) { - rootNode.add(librariesNode); - } - assetsTree.setModel(new DefaultTreeModel(rootNode)); for (int i = 0; i < Math.min(4, assetsTree.getRowCount()); i++) { assetsTree.expandRow(i); @@ -2632,40 +2627,6 @@ private DefaultMutableTreeNode buildMediaTypeSection(String title, java.util.Lis return section; } - private DefaultMutableTreeNode buildLibraryResourcesNode(File baseDir) { - if (basicEditor == null || basicEditor.getLibraries() == null || basicEditor.getLibraries().isEmpty()) { - return null; - } - - DefaultMutableTreeNode librariesNode = new DefaultMutableTreeNode( - new AssetItem("Libraries", "Bookmarked libraries and their resource files", null, createImageIcon(ICON_MENU_FUNCTIONS))); - for (Library library : basicEditor.getLibraries()) { - if (library == null) { - continue; - } - DefaultMutableTreeNode libNode = new DefaultMutableTreeNode( - new AssetItem( - library.name() == null || library.name().isBlank() ? "(unnamed library)" : library.name(), - library.description(), - null, - createImageIcon(ICON_MENU_BOOKMARKS))); - - java.util.List libraryFiles = new ArrayList<>(); - collectResolvedLibraryAssets(library.getDependencies(), baseDir, libraryFiles); - collectResolvedLibraryAssets(library.getClassPathObjects(), baseDir, libraryFiles); - libraryFiles.sort(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); - for (File file : libraryFiles) { - libNode.add(new DefaultMutableTreeNode(createAssetItem(file, baseDir, "Library resource"))); - } - - if (libNode.getChildCount() == 0) { - libNode.add(new DefaultMutableTreeNode( - new AssetItem("No local resources", "Library has no resolvable resource files in the workspace", null, createImageIcon(ICON_MENU_HELP)))); - } - librariesNode.add(libNode); - } - return librariesNode; - } private void collectResolvedLibraryAssets(java.util.List paths, File baseDir, java.util.List out) { if (paths == null || paths.isEmpty()) { @@ -2774,7 +2735,7 @@ private File resolveAssetReference(String literal, File baseDir, File sourcePare if (literal == null || literal.isBlank()) { return null; } - String normalized = com.basic4gl.lib.util.FileUtil.separatorsToSystem(literal); + String normalized = FileUtil.separatorsToSystem(literal); File candidate = new File(normalized); if (candidate.isAbsolute()) { return candidate; From c8e8d2d8956341741ddb90546404423cfa0884ef Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sat, 11 Jul 2026 21:17:06 -0400 Subject: [PATCH 10/38] run spotless --- .../com/basic4gl/desktop/spi/FileOpener.java | 9 +- .../basic4gl/desktop/spi/LanguageService.java | 4 +- .../spi/language/FunctionDefinition.java | 5 +- .../spi/language/VariableDefinition.java | 3 +- .../java/com/basic4gl/desktop/MainWindow.java | 144 +++++++++++------- .../desktop/ProjectSettingsDialog.java | 2 +- .../com/basic4gl/desktop/SymbolIndexer.java | 2 +- .../desktop/language/Basic4GLFoldParser.java | 19 +-- .../language/Basic4GLLanguageSupport.java | 80 +++------- .../fife/ui/rtextarea/MultiHeaderGutter.java | 2 +- .../library/desktopgl/content/FileOpener.java | 10 +- 11 files changed, 131 insertions(+), 149 deletions(-) diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/FileOpener.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/FileOpener.java index 31cfaba1..dc5357e6 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/FileOpener.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/FileOpener.java @@ -14,14 +14,11 @@ */ public class FileOpener extends HasErrorState { private static final String DEFAULT_APP_DATA_FOLDER_NAME = "Basic4GL"; - private static final Pattern SAFE_APP_DATA_FOLDER_NAME = - Pattern.compile("[A-Za-z0-9 _.-]+"); + private static final Pattern SAFE_APP_DATA_FOLDER_NAME = Pattern.compile("[A-Za-z0-9 _.-]+"); private static final Set WINDOWS_RESERVED_NAMES = Set.of( - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" - ); + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", + "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"); public static final String ERROR_DIRECTORY_ALREADY_EXISTS = "Directory already exists"; private String parentDirectory; diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java index 89da8582..0013c6d1 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java @@ -5,7 +5,6 @@ import com.basic4gl.desktop.spi.language.FunctionDefinition; import com.basic4gl.desktop.spi.language.LabelDefinition; import com.basic4gl.desktop.spi.language.VariableDefinition; - import java.util.ArrayList; import java.util.List; @@ -34,7 +33,10 @@ public interface LanguageService { FileLineNumber getFileLineNumberFromMain(int sourceLine); Iterable getVariableDefinitions(); + Iterable getConstantDefinitions(); + Iterable getLabelDefinitions(); + Iterable getFunctionDefinitions(); } diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/FunctionDefinition.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/FunctionDefinition.java index a3c03419..c15f1df0 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/FunctionDefinition.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/FunctionDefinition.java @@ -1,11 +1,10 @@ package com.basic4gl.desktop.spi.language; -public record FunctionDefinition ( +public record FunctionDefinition( String name, String signature, VariableDefinition type, VariableDefinition[] parameters, String description, String packageName, - boolean hasBrackets){ -} + boolean hasBrackets) {} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/VariableDefinition.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/VariableDefinition.java index c543d2e8..9eab5b9c 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/VariableDefinition.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/VariableDefinition.java @@ -9,5 +9,4 @@ public record VariableDefinition( String packageName, boolean readOnly, String scope, - String source) { -} \ No newline at end of file + String source) {} diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index c6520e8c..76bcb65d 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -4,7 +4,6 @@ import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; import static com.formdev.flatlaf.FlatClientProperties.*; -import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; @@ -17,12 +16,7 @@ import com.basic4gl.desktop.spi.language.VariableDefinition; import com.basic4gl.desktop.vmview.DebugControlsListener; import com.basic4gl.desktop.vmview.VirtualMachineViewDialog; -import com.basic4gl.language.core.extensions.FunctionLibrary; -import com.basic4gl.language.core.extensions.Library; import com.basic4gl.language.core.internal.Mutable; -import com.basic4gl.language.core.types.BasicValType; -import com.basic4gl.language.core.types.FunctionSpecification; -import com.basic4gl.language.core.types.ValType; import com.formdev.flatlaf.FlatLightLaf; import com.formdev.flatlaf.extras.FlatDesktop; import com.formdev.flatlaf.icons.FlatTabbedPaneCloseIcon; @@ -40,8 +34,8 @@ import javax.swing.border.BevelBorder; import javax.swing.border.EmptyBorder; import javax.swing.event.*; -import javax.swing.text.BadLocationException; import javax.swing.filechooser.FileSystemView; +import javax.swing.text.BadLocationException; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeModel; @@ -1261,8 +1255,7 @@ private void actionGoToDeclaration() { } catch (BadLocationException ignored) { } - java.util.List declarations = - collectOpenFileDeclarations(); + java.util.List declarations = collectOpenFileDeclarations(); java.util.List matches = declarations.stream() .filter(d -> ("label".equals(d.kind()) || "variable".equals(d.kind())) && d.name().equalsIgnoreCase(symbol)) @@ -1298,7 +1291,8 @@ private java.util.List collectO File file = editor.getFile(); fileId = file != null ? file.getAbsolutePath() : ""; } - declarations.addAll(languageSupport.extractDeclarations(editor.getEditorPane().getText(), fileId)); + declarations.addAll( + languageSupport.extractDeclarations(editor.getEditorPane().getText(), fileId)); } return declarations; } @@ -1344,7 +1338,8 @@ private com.basic4gl.desktop.language.SymbolDeclaration promptUserForDeclaration java.util.List matches, com.basic4gl.desktop.language.SymbolDeclaration preferred) { Object[] options = matches.stream().map(this::formatDeclarationChoice).toArray(); - Object initial = preferred != null ? formatDeclarationChoice(preferred) : (options.length > 0 ? options[0] : null); + Object initial = + preferred != null ? formatDeclarationChoice(preferred) : (options.length > 0 ? options[0] : null); Object selected = JOptionPane.showInputDialog( frame, "Multiple declarations found. Choose destination:", @@ -1371,8 +1366,8 @@ private String formatDeclarationChoice(com.basic4gl.desktop.language.SymbolDecla if (f != null && f.getName() != null && !f.getName().isBlank()) { fileLabel = f.getName(); } - return declaration.kind() + " " + declaration.signature() - + " (" + fileLabel + ":" + (declaration.line() + 1) + ")"; + return declaration.kind() + " " + declaration.signature() + " (" + fileLabel + ":" + (declaration.line() + 1) + + ")"; } private void goToDeclarationLocation(com.basic4gl.desktop.language.SymbolDeclaration declaration) { @@ -1394,7 +1389,9 @@ private void goToDeclarationLocation(com.basic4gl.desktop.language.SymbolDeclara int targetOffset; try { int lineStart = pane.getLineStartOffset(Math.max(0, declaration.line())); - targetOffset = Math.min(lineStart + Math.max(0, declaration.column()), pane.getDocument().getLength()); + targetOffset = Math.min( + lineStart + Math.max(0, declaration.column()), + pane.getDocument().getLength()); } catch (BadLocationException e) { targetOffset = Math.min(pane.getDocument().getLength(), pane.getCaretPosition()); } @@ -2142,8 +2139,15 @@ private JPanel buildFileBrowserPanel() { fileBrowserTree.setCellRenderer(new DefaultTreeCellRenderer() { @Override public Component getTreeCellRendererComponent( - JTree tree, Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { - JLabel label = (JLabel) super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + JTree tree, + Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + JLabel label = (JLabel) + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof File file) { label.setText(file == null ? "" : fileSystemView.getSystemDisplayName(file)); if (label.getText() == null || label.getText().isBlank()) { @@ -2200,13 +2204,21 @@ private JPanel buildAssetsPanel() { assetsTree.setCellRenderer(new DefaultTreeCellRenderer() { @Override public Component getTreeCellRendererComponent( - JTree tree, Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { - JLabel label = (JLabel) super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + JTree tree, + Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + JLabel label = (JLabel) + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof AssetItem item) { - label.setText(item.subtitle == null || item.subtitle.isBlank() - ? item.title - : "" + escapeHtml(item.title) + "
" - + escapeHtml(item.subtitle) + ""); + label.setText( + item.subtitle == null || item.subtitle.isBlank() + ? item.title + : "" + escapeHtml(item.title) + "
" + + escapeHtml(item.subtitle) + ""); label.setIcon(item.icon); label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); } @@ -2308,8 +2320,8 @@ private void configureDocsPane() { referenceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); referenceList.setFixedCellHeight(20); - referenceList.setPrototypeCellValue(new ReferenceItem( - "function", "prototype", "prototype(symbol, arg)", "Builtin", "", "", 0)); + referenceList.setPrototypeCellValue( + new ReferenceItem("function", "prototype", "prototype(symbol, arg)", "Builtin", "", "", 0)); referenceList.setCellRenderer(new DefaultListCellRenderer() { private final ImageIcon functionIcon = createImageIcon(ICON_FUNCTION); private final ImageIcon variableIcon = createImageIcon(ICON_VARIABLE); @@ -2569,12 +2581,11 @@ private DefaultMutableTreeNode buildFileTreeNode(File file, int depth, int maxDe private void refreshAssetsLibrary() { File rootDir = new File(fileManager.getCurrentDirectory()); - DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode( - new AssetItem( - "Assets", - "Workspace resources, libraries, and embedded literals", - null, - createImageIcon(ICON_MENU_ASSETS))); + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(new AssetItem( + "Assets", + "Workspace resources, libraries, and embedded literals", + null, + createImageIcon(ICON_MENU_ASSETS))); DefaultMutableTreeNode workspaceNode = buildMediaTypeSection( "Workspace Resources", @@ -2586,10 +2597,7 @@ private void refreshAssetsLibrary() { } DefaultMutableTreeNode literalNode = buildMediaTypeSection( - "Embedded Literals", - detectLiteralAssets(rootDir), - rootDir, - createImageIcon(ICON_MENU_ASSETS)); + "Embedded Literals", detectLiteralAssets(rootDir), rootDir, createImageIcon(ICON_MENU_ASSETS)); if (literalNode != null) { rootNode.add(literalNode); } @@ -2600,7 +2608,8 @@ private void refreshAssetsLibrary() { } } - private DefaultMutableTreeNode buildMediaTypeSection(String title, java.util.List files, File baseDir, Icon sectionIcon) { + private DefaultMutableTreeNode buildMediaTypeSection( + String title, java.util.List files, File baseDir, Icon sectionIcon) { if (files == null || files.isEmpty()) { return null; } @@ -2609,8 +2618,8 @@ private DefaultMutableTreeNode buildMediaTypeSection(String title, java.util.Lis String mediaType = getMediaTypeLabel(file); byMediaType.computeIfAbsent(mediaType, k -> new ArrayList<>()).add(file); } - DefaultMutableTreeNode section = new DefaultMutableTreeNode( - new AssetItem(title, files.size() + " file(s)", null, sectionIcon)); + DefaultMutableTreeNode section = + new DefaultMutableTreeNode(new AssetItem(title, files.size() + " file(s)", null, sectionIcon)); for (String mediaType : List.of("Images", "Audio", "Video", "Text", "Documents", "Other")) { java.util.List bucket = byMediaType.get(mediaType); if (bucket == null || bucket.isEmpty()) { @@ -2627,7 +2636,6 @@ private DefaultMutableTreeNode buildMediaTypeSection(String title, java.util.Lis return section; } - private void collectResolvedLibraryAssets(java.util.List paths, File baseDir, java.util.List out) { if (paths == null || paths.isEmpty()) { return; @@ -2663,7 +2671,8 @@ private java.util.List detectLiteralAssets(File baseDir) { continue; } File sourceFile = editor.getFile(); - File sourceParent = sourceFile != null ? sourceFile.getAbsoluteFile().getParentFile() : null; + File sourceParent = + sourceFile != null ? sourceFile.getAbsoluteFile().getParentFile() : null; for (String literal : ExportDialog.extractStringLiterals(text)) { if (literal == null || literal.isBlank()) { continue; @@ -2728,7 +2737,11 @@ private AssetItem createAssetItem(File file, File baseDir, String subtitlePrefix subtitle = subtitle == null || subtitle.isBlank() ? relative : subtitle + " • " + relative; } } - return new AssetItem(file != null ? file.getName() : "(unknown)", subtitle, file, file != null ? fileSystemView.getSystemIcon(file) : createImageIcon(ICON_MENU_FOLDER)); + return new AssetItem( + file != null ? file.getName() : "(unknown)", + subtitle, + file, + file != null ? fileSystemView.getSystemIcon(file) : createImageIcon(ICON_MENU_FOLDER)); } private File resolveAssetReference(String literal, File baseDir, File sourceParent) { @@ -2780,8 +2793,14 @@ private String getMediaTypeLabel(File file) { if (name.endsWith(".mp4") || name.endsWith(".mov") || name.endsWith(".webm")) { return "Video"; } - if (name.endsWith(".txt") || name.endsWith(".md") || name.endsWith(".json") || name.endsWith(".xml") - || name.endsWith(".csv") || name.endsWith(".ini") || name.endsWith(".cfg") || name.endsWith(".properties")) { + if (name.endsWith(".txt") + || name.endsWith(".md") + || name.endsWith(".json") + || name.endsWith(".xml") + || name.endsWith(".csv") + || name.endsWith(".ini") + || name.endsWith(".cfg") + || name.endsWith(".properties")) { return "Text"; } if (name.endsWith(".pdf") || name.endsWith(".doc") || name.endsWith(".docx") || name.endsWith(".rtf")) { @@ -2796,7 +2815,10 @@ private String formatRelativePath(File file, File baseDir) { } if (baseDir != null) { try { - java.nio.file.Path relative = baseDir.getAbsoluteFile().toPath().normalize().relativize(file.getAbsoluteFile().toPath().normalize()); + java.nio.file.Path relative = baseDir.getAbsoluteFile() + .toPath() + .normalize() + .relativize(file.getAbsoluteFile().toPath().normalize()); String text = relative.toString().replace('\\', '/'); if (!text.startsWith("..")) { return text; @@ -2954,10 +2976,9 @@ private void populateDocsFromCompiler() { filterReferenceItems(); } - private java.util.List buildFunctionReferenceItems( - LanguageService comp) { + private java.util.List buildFunctionReferenceItems(LanguageService comp) { java.util.List items = new ArrayList<>(); - for (FunctionDefinition item : comp.getFunctionDefinitions()) { + for (FunctionDefinition item : comp.getFunctionDefinitions()) { if (item == null) { continue; } @@ -2992,7 +3013,7 @@ private java.util.List buildFunctionReferenceItems( private java.util.List buildConstantReferenceItems(LanguageService comp) { java.util.List items = new ArrayList<>(); - for (VariableDefinition item : comp.getConstantDefinitions()) { + for (VariableDefinition item : comp.getConstantDefinitions()) { if (item == null) { continue; } @@ -3004,7 +3025,14 @@ private java.util.List buildConstantReferenceItems(LanguageServic + "

" + escapeHtml(item.signature()) + "

"; - items.add(new ReferenceItem("constant", item.name(), item.signature(), item.packageName(), details, item.name(), item.name().length())); + items.add(new ReferenceItem( + "constant", + item.name(), + item.signature(), + item.packageName(), + details, + item.name(), + item.name().length())); } return items; @@ -3023,15 +3051,20 @@ private java.util.List buildLabelReferenceItems(LanguageService c + "" + escapeHtml(label.usage()) + "" + "

"; items.add(new ReferenceItem( - "label", label.name(), signature, "Program", details, label.name(), label.name().length())); + "label", + label.name(), + signature, + "Program", + details, + label.name(), + label.name().length())); } return items; } private java.util.List buildVariableReferenceItems(LanguageService comp) { java.util.List items = new ArrayList<>(); - for (VariableDefinition variable : - comp.getVariableDefinitions()) { + for (VariableDefinition variable : comp.getVariableDefinitions()) { if (variable == null || variable.name() == null || variable.name().isEmpty()) { continue; } @@ -3042,7 +3075,13 @@ private java.util.List buildVariableReferenceItems(LanguageServic + "

Type: Variable
Data type: " + escapeHtml(typeStr) + "
Source: Program

"; items.add(new ReferenceItem( - "variable", variable.name(), signature, "Program", details, variable.name(), variable.name().length())); + "variable", + variable.name(), + signature, + "Program", + details, + variable.name(), + variable.name().length())); } return items; } @@ -3222,7 +3261,6 @@ private void insertSelectedReference() { editorPane.requestFocusInWindow(); } - private void openMarkdownInDocsTab(File file) { File resolved = file.isAbsolute() ? file : new File(fileManager.getCurrentDirectory(), file.getPath()); if (!resolved.exists()) { diff --git a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java index d0c5b857..c7982c5c 100644 --- a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java @@ -414,4 +414,4 @@ public String toString() { return label; } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java b/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java index b8d796bc..37f14704 100644 --- a/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java +++ b/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java @@ -2,6 +2,7 @@ import com.basic4gl.desktop.language.IndexedSymbol; import com.basic4gl.desktop.language.LanguageSupport; +import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -9,7 +10,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import javax.swing.SwingUtilities; -import java.lang.reflect.InvocationTargetException; /** * Lightweight debounced symbol indexer. diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java index 7b97fdc5..604c27dc 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java +++ b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java @@ -1,12 +1,12 @@ package com.basic4gl.desktop.language; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.List; import java.util.Locale; -import java.util.ArrayDeque; -import javax.swing.text.Document; import javax.swing.text.BadLocationException; +import javax.swing.text.Document; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; import org.antlr.v4.runtime.Token; @@ -211,18 +211,3 @@ private static void closeWhile(Deque scopes, ScopeKind kind, int endOffse } } } - - - - - - - - - - - - - - - diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java index 3eb23fcf..04594ef5 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java +++ b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java @@ -221,15 +221,9 @@ public List extractSymbols(String source) { if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { // A newline resets after-dim state (one dim per line) if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME) { - String effectiveRoutine = inStruc - ? "struc:" + (currentStrucName != null ? currentStrucName : "") - : currentRoutine; - flushVariable( - symbolsByKey, - variableDeclCounts, - pendingVarName, - pendingVarType, - effectiveRoutine); + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); state = NONE; pendingVarName = null; pendingVarType = null; @@ -327,9 +321,8 @@ public List extractSymbols(String source) { } } case AFTER_DIM_NAME -> { - String effectiveRoutine = inStruc - ? "struc:" + (currentStrucName != null ? currentStrucName : "") - : currentRoutine; + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { dimArrayDepth = 0; state = AFTER_AS_KW; @@ -353,11 +346,7 @@ public List extractSymbols(String source) { } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { // 'dim x, y' or 'dim x :' – flush current, continue flushVariable( - symbolsByKey, - variableDeclCounts, - pendingVarName, - pendingVarType, - effectiveRoutine); + symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); pendingVarName = null; pendingVarType = null; dimArrayDepth = 0; @@ -366,9 +355,8 @@ public List extractSymbols(String source) { // else: other tokens (array size expression contents, &, etc.) – stay } case AFTER_AS_KW -> { - String effectiveRoutine = inStruc - ? "struc:" + (currentStrucName != null ? currentStrucName : "") - : currentRoutine; + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; if (type == Basic4GL.IDENTIFIER || type == Basic4GL.INTEGER_T || type == Basic4GL.INT_T @@ -377,19 +365,10 @@ public List extractSymbols(String source) { || type == Basic4GL.STRING_T) { pendingVarType = t.getText(); flushVariable( - symbolsByKey, - variableDeclCounts, - pendingVarName, - pendingVarType, - effectiveRoutine); + symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); state = NONE; } else { - flushVariable( - symbolsByKey, - variableDeclCounts, - pendingVarName, - null, - effectiveRoutine); + flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, null, effectiveRoutine); state = NONE; } } @@ -398,15 +377,9 @@ public List extractSymbols(String source) { // Flush any dangling state at EOF if (state == AFTER_DIM_NAME || state == AFTER_AS_KW) { - String effectiveRoutine = inStruc - ? "struc:" + (currentStrucName != null ? currentStrucName : "") - : currentRoutine; - flushVariable( - symbolsByKey, - variableDeclCounts, - pendingVarName, - pendingVarType, - effectiveRoutine); + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); } return new ArrayList<>(symbolsByKey.values()); @@ -454,9 +427,8 @@ public List extractDeclarations(String source, String fileId) if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME && pendingVarNameToken != null) { - String effectiveRoutine = inStruc - ? "struc:" + (currentStrucName != null ? currentStrucName : "") - : currentRoutine; + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; emitVariableDeclaration( declarations, variableDeclCounts, @@ -586,9 +558,8 @@ public List extractDeclarations(String source, String fileId) } } case AFTER_DIM_NAME -> { - String effectiveRoutine = inStruc - ? "struc:" + (currentStrucName != null ? currentStrucName : "") - : currentRoutine; + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { dimArrayDepth = 0; state = AFTER_AS_KW; @@ -626,9 +597,8 @@ public List extractDeclarations(String source, String fileId) // else: other tokens (array size expression, &, etc.) – stay in AFTER_DIM_NAME } case AFTER_AS_KW -> { - String effectiveRoutine = inStruc - ? "struc:" + (currentStrucName != null ? currentStrucName : "") - : currentRoutine; + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; if (type == Basic4GL.IDENTIFIER || type == Basic4GL.INTEGER_T || type == Basic4GL.INT_T @@ -654,16 +624,10 @@ public List extractDeclarations(String source, String fileId) } if ((state == AFTER_DIM_NAME || state == AFTER_AS_KW) && pendingVarNameToken != null) { - String effectiveRoutine = inStruc - ? "struc:" + (currentStrucName != null ? currentStrucName : "") - : currentRoutine; + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; emitVariableDeclaration( - declarations, - variableDeclCounts, - pendingVarNameToken, - pendingVarType, - effectiveRoutine, - fileId); + declarations, variableDeclCounts, pendingVarNameToken, pendingVarType, effectiveRoutine, fileId); } return declarations; diff --git a/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java b/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java index c7452cf5..59866660 100644 --- a/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java +++ b/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java @@ -6,9 +6,9 @@ package org.fife.ui.rtextarea; import java.awt.*; -import java.awt.event.MouseAdapter; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; +import java.awt.event.MouseAdapter; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.util.ArrayList; diff --git a/library/src/main/java/com/basic4gl/library/desktopgl/content/FileOpener.java b/library/src/main/java/com/basic4gl/library/desktopgl/content/FileOpener.java index 16cb4c26..fd74b117 100644 --- a/library/src/main/java/com/basic4gl/library/desktopgl/content/FileOpener.java +++ b/library/src/main/java/com/basic4gl/library/desktopgl/content/FileOpener.java @@ -15,14 +15,11 @@ */ public class FileOpener extends HasErrorState { private static final String DEFAULT_APP_DATA_FOLDER_NAME = "Basic4GL"; - private static final Pattern SAFE_APP_DATA_FOLDER_NAME = - Pattern.compile("[A-Za-z0-9 _.-]+"); + private static final Pattern SAFE_APP_DATA_FOLDER_NAME = Pattern.compile("[A-Za-z0-9 _.-]+"); private static final Set WINDOWS_RESERVED_NAMES = Set.of( - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" - ); + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", + "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"); public static final String ERROR_DIRECTORY_ALREADY_EXISTS = "Directory already exists"; private String parentDirectory; @@ -370,6 +367,7 @@ private static boolean isWindowsReservedName(String name) { return WINDOWS_RESERVED_NAMES.contains(upper); } + public boolean createDirectory(String pathname) { pathname = FileUtil.separatorsToSystem(pathname); From c4f77bd2451b9170e39550269da7f4b4662ffce0 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sat, 11 Jul 2026 21:40:20 -0400 Subject: [PATCH 11/38] work on file viewers --- .../desktop/spi/content/FileViewer.java | 51 ++ .../spi/content/FileViewerException.java | 14 + .../spi/content/FileViewerMetadata.java | 83 ++ app/build.gradle | 2 +- .../com/basic4gl/desktop/BasicEditor.java | 3 + .../com/basic4gl/desktop/EmptyTabPanel.java | 72 +- .../java/com/basic4gl/desktop/MainWindow.java | 847 ++++++++++++++++-- .../desktop/{ => content}/FileManager.java | 94 +- .../desktop/content/FileViewerManager.java | 116 +++ .../desktop/content/FileViewerProvider.java | 25 + .../desktop/content/FileViewerRegistry.java | 186 ++++ .../{ => content}/IFileManagerListener.java | 2 +- .../basic4gl/desktop/{ => editor}/ApMode.java | 2 +- .../desktop/editor/AudioFileViewer.java | 165 ++++ .../basic4gl/desktop/editor/FileEditor.java | 11 +- .../desktop/editor/FileViewerFactory.java | 149 +++ .../desktop/editor/FileViewerWrapper.java | 141 +++ .../desktop/editor/HexFileViewer.java | 151 ++++ .../{ => editor}/IEditorPresenter.java | 4 +- .../basic4gl/desktop/editor/IFileViewer.java | 70 ++ .../desktop/editor/ImageFileViewer.java | 104 +++ .../desktop/editor/TextFileViewer.java | 111 +++ .../desktop/{ => language}/SymbolIndexer.java | 4 +- .../adapter/content/DefaultAudioViewer.java | 283 ++++++ .../content/DefaultAudioViewerProvider.java | 28 + .../adapter/content/DefaultImageViewer.java | 130 +++ .../content/DefaultImageViewerProvider.java | 30 + .../adapter/content/SimpleTextViewer.java | 111 +++ .../content/SimpleTextViewerProvider.java | 33 + ...uage.adapter.fileviewer.FileViewerProvider | 4 + 30 files changed, 2924 insertions(+), 102 deletions(-) create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerException.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerMetadata.java rename app/src/main/java/com/basic4gl/desktop/{ => content}/FileManager.java (65%) create mode 100644 app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/FileViewerProvider.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java rename app/src/main/java/com/basic4gl/desktop/{ => content}/IFileManagerListener.java (71%) rename app/src/main/java/com/basic4gl/desktop/{ => editor}/ApMode.java (71%) create mode 100644 app/src/main/java/com/basic4gl/desktop/editor/AudioFileViewer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/editor/FileViewerFactory.java create mode 100644 app/src/main/java/com/basic4gl/desktop/editor/FileViewerWrapper.java create mode 100644 app/src/main/java/com/basic4gl/desktop/editor/HexFileViewer.java rename app/src/main/java/com/basic4gl/desktop/{ => editor}/IEditorPresenter.java (93%) create mode 100644 app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/editor/ImageFileViewer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/editor/TextFileViewer.java rename app/src/main/java/com/basic4gl/desktop/{ => language}/SymbolIndexer.java (97%) create mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewer.java create mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewerProvider.java create mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewer.java create mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewerProvider.java create mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewer.java create mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewerProvider.java create mode 100644 library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java new file mode 100644 index 00000000..5d1ecfd8 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java @@ -0,0 +1,51 @@ +package com.basic4gl.desktop.spi.content; + +import java.nio.file.Path; +import javax.swing.JComponent; + +/** + * Main interface for file viewers + * + * Implementations should provide a UI component that displays a specific file type. + * Viewers must handle their own resource cleanup. + */ +public interface FileViewer { + + /** + * Load and display a file + * @param path Path to the file to view + * @throws FileViewerException if file cannot be loaded or displayed + */ + void loadFile(Path path) throws FileViewerException; + + /** + * Get the Swing component that displays the file + * @return JComponent to display in UI + */ + JComponent getComponent(); + + /** + * Check if this viewer can handle the given file + * @param filename Filename to check + * @param mimeType Optional MIME type (may be null) + * @return true if this viewer can display this file + */ + boolean canHandle(String filename, String mimeType); + + /** + * Get user-friendly name for this viewer + * @return Viewer name (e.g. "Image Viewer", "Audio Player") + */ + String getName(); + + /** + * Get version of this viewer (for compatibility checking) + * @return Version string + */ + String getVersion(); + + /** + * Clean up resources used by this viewer + */ + void dispose(); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerException.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerException.java new file mode 100644 index 00000000..c043040c --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerException.java @@ -0,0 +1,14 @@ +package com.basic4gl.desktop.spi.content; + +/** + * Exception thrown by file viewer operations + */ +public class FileViewerException extends Exception { + public FileViewerException(String message) { + super(message); + } + + public FileViewerException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerMetadata.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerMetadata.java new file mode 100644 index 00000000..a241fb4e --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerMetadata.java @@ -0,0 +1,83 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Metadata about a file viewer + * + * Contains information for discovery and compatibility checking + */ +public class FileViewerMetadata { + + private final String name; + private final String version; + private final Set supportedExtensions; + private final Set supportedMimeTypes; + private final String description; + + public FileViewerMetadata( + String name, String version, String description, String[] extensions, String[] mimeTypes) { + this.name = name; + this.version = version; + this.description = description; + this.supportedExtensions = Collections.unmodifiableSet( + extensions != null ? new HashSet<>(Arrays.asList(extensions)) : new HashSet<>()); + this.supportedMimeTypes = Collections.unmodifiableSet( + mimeTypes != null ? new HashSet<>(Arrays.asList(mimeTypes)) : new HashSet<>()); + } + + public String getName() { + return name; + } + + public String getVersion() { + return version; + } + + public String getDescription() { + return description; + } + + public Set getSupportedExtensions() { + return supportedExtensions; + } + + public Set getSupportedMimeTypes() { + return supportedMimeTypes; + } + + /** + * Check if this viewer supports the given extension + */ + public boolean supportsExtension(String extension) { + if (extension == null) { + return false; + } + String normalized = extension.toLowerCase(); + if (!normalized.startsWith(".")) { + normalized = "." + normalized; + } + return supportedExtensions.contains(normalized); + } + + /** + * Check if this viewer supports the given MIME type + */ + public boolean supportsMimeType(String mimeType) { + if (mimeType == null) { + return false; + } + return supportedMimeTypes.contains(mimeType.toLowerCase()); + } + + @Override + public String toString() { + return "FileViewerMetadata{" + "name='" + + name + '\'' + ", version='" + + version + '\'' + ", extensions=" + + supportedExtensions + '}'; + } +} diff --git a/app/build.gradle b/app/build.gradle index 723e61b3..5f1b91a9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -173,7 +173,7 @@ task debugAll { run { args rootProject.ext.get("libraryJarPath"), rootProject.ext.get("debugServerJarPath") - debug = true +// debug = true } } } \ No newline at end of file diff --git a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java index 8f9a493b..ffeeaa2d 100644 --- a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java @@ -8,9 +8,12 @@ import com.basic4gl.debug.protocol.callbacks.VariablesCallback; import com.basic4gl.debug.protocol.types.DisassembledInstruction; import com.basic4gl.debug.protocol.types.Variable; +import com.basic4gl.desktop.content.FileManager; import com.basic4gl.desktop.debugger.*; +import com.basic4gl.desktop.editor.ApMode; import com.basic4gl.desktop.editor.BasicTokenMaker; import com.basic4gl.desktop.editor.FileEditor; +import com.basic4gl.desktop.editor.IEditorPresenter; import com.basic4gl.desktop.spi.*; import com.basic4gl.desktop.util.*; import com.basic4gl.language.adapter.Basic4GLEditorPluginAdapter; diff --git a/app/src/main/java/com/basic4gl/desktop/EmptyTabPanel.java b/app/src/main/java/com/basic4gl/desktop/EmptyTabPanel.java index 31497b71..329efdfe 100644 --- a/app/src/main/java/com/basic4gl/desktop/EmptyTabPanel.java +++ b/app/src/main/java/com/basic4gl/desktop/EmptyTabPanel.java @@ -18,7 +18,9 @@ public EmptyTabPanel( IEmptyTabPanelListener listener, KeyStroke newFileKeyStroke, KeyStroke openFileKeyStroke, - List recentFiles) { + KeyStroke openFolderKeyStroke, + List recentFiles, + List recentWorkspaces) { super(); setLayout(new BorderLayout()); @@ -29,17 +31,24 @@ public EmptyTabPanel( JScrollPane scrollPane = new JScrollPane(contents); scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); - contents.add(buildGetStartedPanel(listener, newFileKeyStroke, openFileKeyStroke)); + contents.add(buildGetStartedPanel(listener, newFileKeyStroke, openFileKeyStroke, openFolderKeyStroke)); contents.add(new Box.Filler(new Dimension(16, 16), new Dimension(16, 16), new Dimension(16, 16))); contents.add(buildRecentFilesPanel(listener, recentFiles)); + contents.add(new Box.Filler(new Dimension(16, 16), new Dimension(16, 16), new Dimension(16, 16))); + + contents.add(buildRecentWorkspacesPanel(listener, recentWorkspaces)); + add(scrollPane); } private JPanel buildGetStartedPanel( - IEmptyTabPanelListener listener, KeyStroke newFileKeyStroke, KeyStroke openFileKeyStroke) { + IEmptyTabPanelListener listener, + KeyStroke newFileKeyStroke, + KeyStroke openFileKeyStroke, + KeyStroke openFolderKeyStroke) { JPanel getStartedPanel = new JPanel(); getStartedPanel.setLayout(new BoxLayout(getStartedPanel, BoxLayout.Y_AXIS)); getStartedPanel.setAlignmentX(Component.LEFT_ALIGNMENT); @@ -93,6 +102,22 @@ private JPanel buildGetStartedPanel( addShortCutLabel(wrapper, openFileKeyStroke); getStartedPanel.add(wrapper); + wrapper = new JPanel(new FlowLayout(FlowLayout.LEFT)); + wrapper.setMaximumSize(new Dimension(400, 50)); + FlatButton openFolderButton = new FlatButton(); + openFolderButton.setText("Open Folder..."); + openFolderButton.setIcon(createImageIcon(ICON_MENU_FOLDER)); + openFolderButton.setBackground(null); + openFolderButton.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12)); + openFolderButton.setPreferredSize(new Dimension(160, 30)); + openFolderButton.setAlignmentX(Component.LEFT_ALIGNMENT); + openFolderButton.setHorizontalAlignment(SwingConstants.LEFT); + openFolderButton.addActionListener((x) -> listener.onOpenFolderClick()); + + wrapper.add(openFolderButton); + addShortCutLabel(wrapper, openFolderKeyStroke); + getStartedPanel.add(wrapper); + return getStartedPanel; } @@ -136,6 +161,43 @@ private JPanel buildRecentFilesPanel(IEmptyTabPanelListener listener, List return recentFilePanel; } + private JPanel buildRecentWorkspacesPanel(IEmptyTabPanelListener listener, List recentWorkspaces) { + JPanel recentWorkspacePanel = new JPanel(); + recentWorkspacePanel.setLayout(new BoxLayout(recentWorkspacePanel, BoxLayout.Y_AXIS)); + recentWorkspacePanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + JPanel wrapper = new JPanel(new FlowLayout(FlowLayout.LEFT)); + wrapper.setMaximumSize(new Dimension(400, 50)); + wrapper.setAlignmentX(Component.LEFT_ALIGNMENT); + JLabel recentLabel = new JLabel("Recent Workspaces"); + Font font = recentLabel.getFont(); + recentLabel.setFont(new Font(font.getName(), Font.BOLD, font.getSize() + 2)); + recentLabel.setForeground(new Color(66, 66, 66)); + recentLabel.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12)); + + wrapper.add(recentLabel); + recentWorkspacePanel.add(wrapper); + + for (File folder : recentWorkspaces.stream().limit(5).toList()) { + JPanel recentItem = new JPanel(new FlowLayout(FlowLayout.LEFT)); + recentItem.setAlignmentX(Component.LEFT_ALIGNMENT); + recentItem.setMaximumSize(new Dimension(500, 30)); + recentItem.setBorder(null); + + JButton recentItemButton = new FlatButton(); + recentItemButton.setBackground(null); + recentItemButton.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12)); + recentItemButton.setText(folder.getName().isBlank() ? folder.getAbsolutePath() : folder.getName()); + recentItemButton.setToolTipText(folder.getAbsolutePath()); + recentItemButton.addActionListener((x) -> listener.onOpenWorkspaceClick(folder)); + + recentItem.add(recentItemButton); + recentWorkspacePanel.add(recentItem); + } + + return recentWorkspacePanel; + } + private void addShortCutLabel(JPanel parent, KeyStroke keyStroke) { for (String s : KeyStrokeUtil.getShortcutString(keyStroke).split(" ")) { JLabel l = new JLabel(s); @@ -159,5 +221,9 @@ public static interface IEmptyTabPanelListener { void onOpenClick(); void onOpenClick(File file); + + void onOpenFolderClick(); + + void onOpenWorkspaceClick(File folder); } } diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 76bcb65d..ea38d501 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -7,9 +7,12 @@ import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; +import com.basic4gl.desktop.content.FileManager; +import com.basic4gl.desktop.content.IFileManagerListener; import com.basic4gl.desktop.debugger.DebugServerConstants; import com.basic4gl.desktop.debugger.DebugServerFactory; import com.basic4gl.desktop.editor.*; +import com.basic4gl.desktop.language.SymbolIndexer; import com.basic4gl.desktop.spi.*; import com.basic4gl.desktop.spi.language.FunctionDefinition; import com.basic4gl.desktop.spi.language.LabelDefinition; @@ -23,6 +26,7 @@ import com.formdev.flatlaf.ui.FlatTabbedPaneUI; import com.formdev.flatlaf.util.SystemInfo; import java.awt.*; +import java.awt.datatransfer.StringSelection; import java.awt.event.*; import java.io.*; import java.nio.charset.StandardCharsets; @@ -51,7 +55,7 @@ public class MainWindow ITabProvider, IToggleBreakpointListener, IFileEditorActionListener, - IFileManagerListener, + IFileManagerListener, EmptyTabPanel.IEmptyTabPanelListener, MenuService { @@ -86,6 +90,8 @@ public void caretUpdate(CaretEvent e) { private final JSplitPane workspacePane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); private final JSplitPane contentPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); private final JPanel leftSidebarContainer = new JPanel(new BorderLayout()); + // File viewers: stores IFileViewer instances for each tab (parallel to tabControl) + private final java.util.List fileViewers = new java.util.ArrayList<>(); private final JPanel leftSidebarContent = new JPanel(new CardLayout()); private final JToolBar leftSidebarRail = new JToolBar(SwingConstants.VERTICAL); private final ButtonGroup leftSidebarGroup = new ButtonGroup(); @@ -98,8 +104,12 @@ public void caretUpdate(CaretEvent e) { private final JTree fileBrowserTree = new JTree(); private final JTree assetsTree = new JTree(); private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); - private final DefaultListModel assetsListModel = new DefaultListModel<>(); - private final JList assetsList = new JList<>(assetsListModel); + private boolean showHiddenFiles = false; + private final DefaultListModel assetsListModel = new DefaultListModel<>(); + private final JList assetsGridList = new JList<>(assetsListModel); + private final JPanel assetsContentPanel = new JPanel(new CardLayout()); + private final JComboBox assetsLayoutCombo = new JComboBox<>(new String[] {"Tree", "Grid"}); + private final Map assetThumbnailCache = new HashMap<>(); private final JComboBox runTargetCombo = new JComboBox<>(); private boolean updatingRunTargetCombo = false; private final DefaultListModel referenceListModel = new DefaultListModel<>(); @@ -121,6 +131,7 @@ public void caretUpdate(CaretEvent e) { "No matches."; private static final String REFERENCE_SELECT_PROMPT_HTML = "Select an entry."; + private String referenceDetailsHtml = REFERENCE_SELECT_PROMPT_HTML; private final java.util.List allReferenceItems = new ArrayList<>(); // Language support is shared between the symbol indexer and (via BasicTokenMaker) the editor. private final com.basic4gl.desktop.language.LanguageSupport languageSupport = @@ -133,6 +144,10 @@ public void caretUpdate(CaretEvent e) { private String activeLeftSidebarKey = "files"; private String activeRightDocsKey = "functions"; private JPanel emptyTabPanel; + private final java.util.List recentWorkspaces = new ArrayList<>(); + private static final String RECENT_WORKSPACES_FILE = "recent-workspaces.properties"; + private static final String RECENT_WORKSPACES_KEY = "RECENT_WORKSPACES"; + private static final int MAX_RECENT_WORKSPACES = 10; private static final class ReferenceItem { final String kind; @@ -196,8 +211,10 @@ public String toString() { // Menu Items private final JMenuItem newMenuItem = new JMenuItem("New Program"); private final JMenuItem openMenuItem = new JMenuItem("Open Program..."); + private final JMenuItem openFolderMenuItem = new JMenuItem("Open Folder..."); private final JMenuItem recentSubMenu = new JMenu("Open Recent"); private final JMenuItem clearRecentMenuItem = new JMenuItem("Clear Recently Opened..."); + private final JMenuItem clearRecentWorkspacesMenuItem = new JMenuItem("Clear Recent Workspaces..."); private final JMenuItem saveMenuItem = new JMenuItem("Save"); private final JMenuItem saveAsMenuItem = new JMenuItem("Save As..."); private final JMenuItem exportMenuItem = new JMenuItem("Export..."); @@ -374,6 +391,7 @@ public MainWindow() { fileMenu.add(newMenuItem); fileMenu.add(openMenuItem); + fileMenu.add(openFolderMenuItem); fileMenu.add(recentSubMenu); fileMenu.add(new JSeparator()); fileMenu.add(saveMenuItem); @@ -442,8 +460,12 @@ public MainWindow() { newMenuItem.addActionListener(e -> actionNew()); openMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, toolkit.getMenuShortcutKeyMask())); openMenuItem.addActionListener(e -> actionOpen()); + openFolderMenuItem.setAccelerator( + KeyStroke.getKeyStroke(KeyEvent.VK_O, toolkit.getMenuShortcutKeyMask() | InputEvent.SHIFT_MASK)); + openFolderMenuItem.addActionListener(e -> actionOpenFolder()); saveMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, toolkit.getMenuShortcutKeyMask())); clearRecentMenuItem.addActionListener(e -> actionClearRecent()); + clearRecentWorkspacesMenuItem.addActionListener(e -> actionClearRecentWorkspaces()); saveMenuItem.addActionListener(e -> actionSave()); saveAsMenuItem.addActionListener(e -> actionSaveAs()); exportMenuItem.addActionListener(e -> actionExport()); @@ -740,16 +762,21 @@ protected void installDefaults() { if (fileCheckSaveChanges(tabIndex)) { // Clear file's breakpoints FileEditor editor = fileManager.getFileEditors().get(tabIndex); - List breakpoints = editor.getBreakpoints(); - String file = editor.getFilePath(); + if (editor != null) { + List breakpoints = editor.getBreakpoints(); + String file = editor.getFilePath(); - for (Integer line : breakpoints) { - basicEditor.toggleBreakpt(file, line); + for (Integer line : breakpoints) { + basicEditor.toggleBreakpt(file, line); + } } // Remove tab tabControl.remove(tabIndex); fileManager.getFileEditors().remove(tabIndex.intValue()); + if (tabIndex >= 0 && tabIndex < fileViewers.size()) { + fileViewers.remove(tabIndex); + } fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); @@ -846,6 +873,8 @@ public void windowDeactivated(WindowEvent e) {} basicEditor.initLibraries(); resetProject(); basicEditor.loadSettings(); + loadRecentWorkspaces(); + setRecentItems(basicEditor.getRecentFiles()); refreshRunnableFileControls(); populateDocsFromCompiler(); refreshSidebarContent(); @@ -889,18 +918,51 @@ private void actionExport() { @Override public void setRecentItems(List files) { + List recentFiles = files == null ? Collections.emptyList() : files; recentSubMenu.removeAll(); - for (File file : files) { + + boolean hasRecentFiles = !recentFiles.isEmpty(); + boolean hasRecentWorkspaces = !recentWorkspaces.isEmpty(); + + if (hasRecentFiles) { + JMenuItem filesHeader = new JMenuItem("Recent Files"); + filesHeader.setEnabled(false); + recentSubMenu.add(filesHeader); + } + + for (File file : recentFiles) { JMenuItem fileMenuItem = new JMenuItem(file.getName()); recentSubMenu.add(fileMenuItem); fileMenuItem.addActionListener(e -> { - actionOpen(file); + openFileWithPreferredViewer(file); }); } - recentSubMenu.add(new JSeparator()); + + if (hasRecentFiles || hasRecentWorkspaces) { + recentSubMenu.add(new JSeparator()); + } + + clearRecentMenuItem.setEnabled(hasRecentFiles); recentSubMenu.add(clearRecentMenuItem); - clearRecentMenuItem.setEnabled(!files.isEmpty()); + + if (hasRecentWorkspaces) { + recentSubMenu.add(new JSeparator()); + JMenuItem workspacesHeader = new JMenuItem("Recent Workspaces"); + workspacesHeader.setEnabled(false); + recentSubMenu.add(workspacesHeader); + for (File workspace : recentWorkspaces) { + JMenuItem workspaceItem = new JMenuItem(workspace.getName().isBlank() + ? workspace.getAbsolutePath() + : workspace.getName()); + workspaceItem.setToolTipText(workspace.getAbsolutePath()); + workspaceItem.addActionListener(e -> setWorkspaceDirectory(workspace)); + recentSubMenu.add(workspaceItem); + } + recentSubMenu.add(new JSeparator()); + recentSubMenu.add(clearRecentWorkspacesMenuItem); + } + clearRecentWorkspacesMenuItem.setEnabled(hasRecentWorkspaces); } @Override @@ -964,6 +1026,7 @@ private void resetProject() { // Close existing editors tabControl.removeAll(); fileManager.getFileEditors().clear(); + fileViewers.clear(); // Create a default tab addTab(); @@ -993,6 +1056,11 @@ public void setSelectedTabIndex(int index) { @Override public void openTab(String filename) { File file = new File(fileManager.getCurrentDirectory(), filename); + int existingIndex = findOpenTabIndexByPath(file.getAbsolutePath()); + if (existingIndex >= 0) { + tabControl.setSelectedIndex(existingIndex); + return; + } System.out.println("Open tab: " + filename); System.out.println("Path: " + file.getAbsolutePath()); @@ -1003,12 +1071,33 @@ public void openTab(String filename) { } public void openTab(File file) { + if (file == null) { + return; + } + File absoluteFile = file.getAbsoluteFile(); + int existingIndex = findOpenTabIndexByPath(absoluteFile.getAbsolutePath()); + if (existingIndex >= 0) { + tabControl.setSelectedIndex(existingIndex); + return; + } + System.out.println("Open tab: " + file.getName()); System.out.println("Path: " + file.getAbsolutePath()); - MainWindow.this.addTab(FileEditor.open(file, this, fileManager, this, linkGenerator, searchContext)); + // Use the FileViewerFactory to determine the appropriate viewer + IFileViewer viewer = FileViewerFactory.createViewer( + absoluteFile, + null, // auto-detect viewer type + this, + fileManager, + this, + linkGenerator, + searchContext); + + addTabWithViewer(viewer); tabControl.setSelectedIndex(tabControl.getTabCount() - 1); + registerWorkspace(file.getParentFile()); } void actionNew() { @@ -1020,6 +1109,7 @@ void actionNew() { // Clear file editors this.tabControl.removeAll(); fileManager.getFileEditors().clear(); + fileViewers.clear(); this.addTab(); refreshSidebarContent(); @@ -1032,11 +1122,14 @@ void actionOpen() { void actionOpen(File file) { if (multifileCheckSaveChanges()) { - FileEditor editor = null; if (file != null) { fileManager.setCurrentDirectory(fileManager.getFileDirectory()); - editor = FileEditor.open(file, this, fileManager, this, linkGenerator, searchContext); - } else { + openFileWithPreferredViewer(file); + return; + } + + FileEditor editor; + { fileManager.setCurrentDirectory(fileManager.getFileDirectory()); editor = FileEditor.open(frame, this, fileManager, this, linkGenerator, searchContext); } @@ -1045,6 +1138,30 @@ void actionOpen(File file) { } } + private void actionOpenFolder() { + JFileChooser chooser = new JFileChooser(fileManager.getCurrentDirectory()); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setAcceptAllFileFilterUsed(false); + int result = chooser.showOpenDialog(frame); + if (result != JFileChooser.APPROVE_OPTION) { + return; + } + setWorkspaceDirectory(chooser.getSelectedFile()); + } + + private void openFileWithPreferredViewer(File file) { + if (file == null) { + return; + } + File absoluteFile = file.getAbsoluteFile(); + int existingIndex = findOpenTabIndexByPath(absoluteFile.getAbsolutePath()); + if (existingIndex >= 0) { + tabControl.setSelectedIndex(existingIndex); + return; + } + openTab(absoluteFile); + } + void openEditor(FileEditor editor) { if (editor != null) { // TODO Check if file should open as new tab or project @@ -1059,6 +1176,7 @@ void openEditor(FileEditor editor) { fileManager.setRunDirectory(fileManager.getFileDirectory()); fileManager.setCurrentDirectory(fileManager.getRunDirectory()); + registerWorkspace(new File(fileManager.getRunDirectory())); // Display file addTab(editor); @@ -1079,10 +1197,29 @@ void actionClearRecent() { } } + void actionClearRecentWorkspaces() { + int result = JOptionPane.showConfirmDialog( + frame, + "Clear recently opened workspaces?", + "Confirm", + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE); + + if (result == JOptionPane.YES_OPTION) { + recentWorkspaces.clear(); + saveRecentWorkspaces(); + setRecentItems(basicEditor.getRecentFiles()); + refreshEmptyStateRecentItems(); + } + } + boolean fileCheckSaveChanges(int index) { // Is sub-file modified? FileEditor editor = fileManager.getFileEditors().get(index); + if (editor == null) { + return true; + } if (editor.isModified()) { int result = JOptionPane.showConfirmDialog( frame, @@ -1448,7 +1585,12 @@ public void closeAll() { public void closeTab(int index) { tabControl.remove(index); - fileManager.getFileEditors().remove(index); + if (index >= 0 && index < fileManager.getFileEditors().size()) { + fileManager.getFileEditors().remove(index); + } + if (index >= 0 && index < fileViewers.size()) { + fileViewers.remove(index); + } fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); refreshSidebarContent(); @@ -1464,6 +1606,7 @@ public void addTab(FileEditor editor) { int count = fileManager.editorCount(); fileManager.getFileEditors().add(editor); + fileViewers.add(new FileViewerWrapper(editor)); // replace emptyTabPanel if needed mainPane.setTopComponent(getActiveEditorHost()); @@ -1527,6 +1670,82 @@ public void changedUpdate(DocumentEvent e) { symbolIndexer.schedule(); } + /** + * Adds a file viewer tab (unified method for all viewer types including images and audio) + */ + public void addTabWithViewer(IFileViewer viewer) { + int count = tabControl.getTabCount(); + FileViewerWrapper wrapper = new FileViewerWrapper(viewer); + fileViewers.add(wrapper); + + // For backward compatibility with FileEditor code, also add to fileManager if it's a text editor + if (wrapper.isTextEditor()) { + fileManager.getFileEditors().add(wrapper.getFileEditor()); + } else { + // Add a placeholder to keep indices aligned + fileManager.getFileEditors().add(null); + } + + // replace emptyTabPanel if needed + mainPane.setTopComponent(getActiveEditorHost()); + + tabControl.addTab(viewer.getTitle(), viewer.getContentPane()); + + final FileViewerWrapper wrappedViewer = wrapper; + File file = viewer.getFile(); + if (file != null) { + basicEditor.notifyFileOpened(file); + } + + // Add document listener only for text editors + if (wrapper.isTextEditor()) { + final FileEditor edit = wrapper.getFileEditor(); + JTextArea editorPane = edit.getEditorPane(); + editorPane.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + int index = getTabIndex(edit.getFilePath()); + edit.setModified(); + tabControl.setTitleAt(index, edit.getTitle()); + symbolIndexer.schedule(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + int index = getTabIndex(edit.getFilePath()); + edit.setModified(); + tabControl.setTitleAt(index, edit.getTitle()); + symbolIndexer.schedule(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + int index = getTabIndex(edit.getFilePath()); + edit.setModified(); + tabControl.setTitleAt(index, edit.getTitle()); + } + }); + + // Allow user to see cursor position + editorPane.addCaretListener(TrackCaretPosition); + cursorPositionLabel.setText(0 + ":" + 0); // Reset label + + // Set tab as read-only if App is running or paused + boolean readOnly = basicEditor.getMode() != ApMode.AP_STOPPED; + editorPane.setEditable(!readOnly); + } + + // Refresh interface if there were previously no tabs open + if (count == 0) { + basicEditor.setMode(ApMode.AP_STOPPED, null); + } + + fileManager.ensureRunnableFileValid(); + refreshRunnableFileControls(); + refreshSidebarContent(); + symbolIndexer.schedule(); + } + @Override public void placeCursorAtProcessed(final int row, int col) { lastSourceRow = row; @@ -1723,7 +1942,9 @@ public void refreshActions(ApMode mode) { this, newMenuItem.getAccelerator(), openMenuItem.getAccelerator(), - basicEditor.getRecentFiles()); + openFolderMenuItem.getAccelerator(), + basicEditor.getRecentFiles(), + recentWorkspaces); mainPane.setTopComponent(emptyTabPanel); break; case AP_STOPPED: @@ -2010,12 +2231,17 @@ private void maybeShowTabPopup(MouseEvent e) { } JPopupMenu popup = new JPopupMenu(); + FileEditor contextEditor = fileManager.getFileEditors().get(tabIndex); JMenuItem setRunnable = new JMenuItem("Set as runnable file"); setRunnable.addActionListener(x -> { + if (contextEditor == null) { + return; + } fileManager.setRunnableFilePath( - fileManager.getFileEditors().get(tabIndex).getFilePath()); + contextEditor.getFilePath()); refreshRunnableFileControls(); }); + setRunnable.setEnabled(contextEditor != null); popup.add(setRunnable); JMenuItem splitPreview = new JMenuItem("Split right"); @@ -2033,7 +2259,11 @@ private void openSplitPreview(int tabIndex) { if (tabIndex < 0 || tabIndex >= fileManager.getFileEditors().size()) { return; } - File source = fileManager.getFileEditors().get(tabIndex).getFile(); + FileEditor editor = fileManager.getFileEditors().get(tabIndex); + if (editor == null) { + return; + } + File source = editor.getFile(); if (source == null) { return; } @@ -2050,7 +2280,11 @@ private void popOutTab(int tabIndex) { if (tabIndex < 0 || tabIndex >= fileManager.getFileEditors().size()) { return; } - File source = fileManager.getFileEditors().get(tabIndex).getFile(); + FileEditor editor = fileManager.getFileEditors().get(tabIndex); + if (editor == null) { + return; + } + File source = editor.getFile(); if (source == null) { return; } @@ -2097,7 +2331,7 @@ private void actionOpenAsset() { if (selected.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { openMarkdownInDocsTab(selected); } else { - openTab(selected); + openFileWithPreferredViewer(selected); } } @@ -2124,13 +2358,27 @@ private void configureSidebar() { private JPanel buildFileBrowserPanel() { JPanel panel = new JPanel(new BorderLayout(0, 6)); JPanel header = new JPanel(new BorderLayout()); + JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); JLabel title = new JLabel("Workspace Browser"); title.setBorder(new EmptyBorder(4, 8, 0, 8)); + JButton openFolder = new JButton("Open Folder"); + openFolder.setFocusable(false); + openFolder.addActionListener(e -> actionOpenFolder()); JButton refresh = new JButton("Refresh"); refresh.setFocusable(false); refresh.addActionListener(e -> refreshFileBrowserTree()); + JToggleButton showHiddenToggle = new JToggleButton("Show Hidden"); + showHiddenToggle.setFocusable(false); + showHiddenToggle.setSelected(showHiddenFiles); + showHiddenToggle.addActionListener(e -> { + showHiddenFiles = showHiddenToggle.isSelected(); + refreshFileBrowserTree(); + }); + headerButtons.add(showHiddenToggle); + headerButtons.add(openFolder); + headerButtons.add(refresh); header.add(title, BorderLayout.WEST); - header.add(refresh, BorderLayout.EAST); + header.add(headerButtons, BorderLayout.EAST); panel.add(header, BorderLayout.NORTH); fileBrowserTree.setRootVisible(true); @@ -2155,6 +2403,10 @@ public Component getTreeCellRendererComponent( } label.setIcon(fileSystemView.getSystemIcon(file)); label.setToolTipText(file.getAbsolutePath()); + boolean isHidden = file.getName().startsWith("."); + if (isHidden && !selected) { + label.setForeground(new Color(160, 160, 160)); + } } return label; } @@ -2162,6 +2414,7 @@ public Component getTreeCellRendererComponent( fileBrowserTree.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { + maybeShowWorkspaceBrowserPopup(e); if (e.getClickCount() != 2) { return; } @@ -2176,9 +2429,19 @@ public void mouseClicked(MouseEvent e) { if (file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { openMarkdownInDocsTab(file); } else { - openTab(file); + openFileWithPreferredViewer(file); } } + + @Override + public void mousePressed(MouseEvent e) { + maybeShowWorkspaceBrowserPopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowWorkspaceBrowserPopup(e); + } }); JScrollPane scrollPane = new JScrollPane(fileBrowserTree); configureSmoothScrolling(scrollPane); @@ -2186,21 +2449,110 @@ public void mouseClicked(MouseEvent e) { return panel; } + private void maybeShowWorkspaceBrowserPopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + + TreePath path = fileBrowserTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + fileBrowserTree.setSelectionPath(path); + + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof File selectedFile)) { + return; + } + + JPopupMenu popup = new JPopupMenu(); + + JMenuItem openItem = new JMenuItem(selectedFile.isDirectory() ? "Open Folder" : "Open"); + openItem.addActionListener(evt -> { + if (selectedFile.isDirectory()) { + setWorkspaceDirectory(selectedFile); + } else if (selectedFile.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + openMarkdownInDocsTab(selectedFile); + } else { + openFileWithPreferredViewer(selectedFile); + } + }); + popup.add(openItem); + + JMenuItem revealItem = new JMenuItem("Reveal in Finder"); + revealItem.addActionListener(evt -> revealInFinder(selectedFile)); + popup.add(revealItem); + + JMenuItem openSystemItem = new JMenuItem("Open with Default App"); + openSystemItem.addActionListener(evt -> openWithSystemDefault(selectedFile)); + popup.add(openSystemItem); + + JMenuItem copyPathItem = new JMenuItem("Copy Path"); + copyPathItem.addActionListener(evt -> { + StringSelection selection = new StringSelection(selectedFile.getAbsolutePath()); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + }); + popup.add(copyPathItem); + + popup.addSeparator(); + JMenuItem refreshItem = new JMenuItem("Refresh"); + refreshItem.addActionListener(evt -> refreshFileBrowserTree()); + popup.add(refreshItem); + + popup.show(fileBrowserTree, e.getX(), e.getY()); + } + + private void openWithSystemDefault(File file) { + if (file == null || !file.exists()) { + return; + } + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(file); + } + } catch (IOException ex) { + JOptionPane.showMessageDialog(frame, "Unable to open file: " + ex.getMessage()); + } + } + + private void revealInFinder(File file) { + if (file == null || !file.exists()) { + return; + } + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browseFileDirectory(file); + } + } catch (Exception ex) { + // Fallback when browseFileDirectory is unavailable. + openWithSystemDefault(file.getParentFile()); + } + } + private JPanel buildAssetsPanel() { JPanel panel = new JPanel(new BorderLayout(0, 6)); JPanel header = new JPanel(new BorderLayout()); + JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); JLabel title = new JLabel("Assets"); title.setBorder(new EmptyBorder(4, 8, 0, 8)); + assetsLayoutCombo.setFocusable(false); + assetsLayoutCombo.addActionListener(e -> { + CardLayout layout = (CardLayout) assetsContentPanel.getLayout(); + layout.show(assetsContentPanel, Objects.toString(assetsLayoutCombo.getSelectedItem(), "Tree")); + }); JButton refresh = new JButton("Refresh"); refresh.setFocusable(false); refresh.addActionListener(e -> refreshAssetsLibrary()); + headerButtons.add(assetsLayoutCombo); + headerButtons.add(refresh); header.add(title, BorderLayout.WEST); - header.add(refresh, BorderLayout.EAST); + header.add(headerButtons, BorderLayout.EAST); panel.add(header, BorderLayout.NORTH); assetsTree.setRootVisible(false); assetsTree.setShowsRootHandles(true); - assetsTree.setRowHeight(24); + // Let Swing compute preferred row height so custom/HTML labels do not clip. + assetsTree.setRowHeight(0); assetsTree.setCellRenderer(new DefaultTreeCellRenderer() { @Override public Component getTreeCellRendererComponent( @@ -2214,11 +2566,11 @@ public Component getTreeCellRendererComponent( JLabel label = (JLabel) super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof AssetItem item) { - label.setText( - item.subtitle == null || item.subtitle.isBlank() - ? item.title - : "" + escapeHtml(item.title) + "
" - + escapeHtml(item.subtitle) + ""); + boolean isSection = item.file == null; + label.setIcon(item.icon); + label.setIconTextGap(8); + label.setBorder(new EmptyBorder(3, 0, 3, 0)); + label.setText(formatAssetTreeLabel(item, isSection)); label.setIcon(item.icon); label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); } @@ -2229,6 +2581,7 @@ public Component getTreeCellRendererComponent( assetsTree.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { + maybeShowAssetsTreePopup(e); if (e.getClickCount() != 2) { return; } @@ -2240,20 +2593,172 @@ public void mouseClicked(MouseEvent e) { if (!(userObject instanceof AssetItem item) || !item.isOpenable()) { return; } - if (item.file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - openMarkdownInDocsTab(item.file); - } else { - openTab(item.file); - } + openAssetItem(item); + } + + @Override + public void mousePressed(MouseEvent e) { + maybeShowAssetsTreePopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowAssetsTreePopup(e); } }); JScrollPane scrollPane = new JScrollPane(assetsTree); configureSmoothScrolling(scrollPane); - panel.add(scrollPane, BorderLayout.CENTER); + + assetsGridList.setLayoutOrientation(JList.HORIZONTAL_WRAP); + assetsGridList.setVisibleRowCount(-1); + assetsGridList.setFixedCellHeight(112); + assetsGridList.setFixedCellWidth(120); + assetsGridList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + assetsGridList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof AssetItem item) { + label.setText("
" + escapeHtml(item.title) + "
"); + label.setIcon(getAssetGridIcon(item)); + label.setHorizontalTextPosition(SwingConstants.CENTER); + label.setVerticalTextPosition(SwingConstants.BOTTOM); + label.setHorizontalAlignment(SwingConstants.CENTER); + label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); + } + return label; + } + }); + assetsGridList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + maybeShowAssetsGridPopup(e); + if (e.getClickCount() != 2) { + return; + } + AssetItem item = assetsGridList.getSelectedValue(); + if (item == null || !item.isOpenable()) { + return; + } + openAssetItem(item); + } + + @Override + public void mousePressed(MouseEvent e) { + maybeShowAssetsGridPopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowAssetsGridPopup(e); + } + }); + + JScrollPane gridScrollPane = new JScrollPane(assetsGridList); + configureSmoothScrolling(gridScrollPane); + + assetsContentPanel.add(scrollPane, "Tree"); + assetsContentPanel.add(gridScrollPane, "Grid"); + panel.add(assetsContentPanel, BorderLayout.CENTER); return panel; } + private String formatAssetTreeLabel(AssetItem item, boolean isSection) { + if (item == null) { + return ""; + } + String title = escapeHtml(item.title == null ? "" : item.title); + String subtitle = item.subtitle == null ? "" : item.subtitle.trim(); + if (subtitle.isBlank()) { + return isSection ? "" + title + "" : title; + } + String subtitleHtml = escapeHtml(subtitle); + if (isSection) { + return "" + title + " " + subtitleHtml + ""; + } + return "" + title + " " + subtitleHtml + ""; + } + + private void openAssetItem(AssetItem item) { + if (item == null || !item.isOpenable()) { + return; + } + if (item.file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + openMarkdownInDocsTab(item.file); + } else { + openFileWithPreferredViewer(item.file); + } + } + + private void maybeShowAssetsTreePopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + TreePath path = assetsTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + assetsTree.setSelectionPath(path); + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof AssetItem item)) { + return; + } + showAssetsPopup(item, assetsTree, e.getX(), e.getY()); + } + + private void maybeShowAssetsGridPopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + int index = assetsGridList.locationToIndex(e.getPoint()); + if (index < 0) { + return; + } + assetsGridList.setSelectedIndex(index); + AssetItem item = assetsGridList.getModel().getElementAt(index); + showAssetsPopup(item, assetsGridList, e.getX(), e.getY()); + } + + private void showAssetsPopup(AssetItem item, Component invoker, int x, int y) { + if (item == null) { + return; + } + + JPopupMenu popup = new JPopupMenu(); + + JMenuItem openItem = new JMenuItem("Open"); + openItem.setEnabled(item.isOpenable()); + openItem.addActionListener(evt -> openAssetItem(item)); + popup.add(openItem); + + JMenuItem revealItem = new JMenuItem("Reveal in Finder"); + revealItem.setEnabled(item.file != null); + revealItem.addActionListener(evt -> revealInFinder(item.file)); + popup.add(revealItem); + + JMenuItem systemItem = new JMenuItem("Open with Default App"); + systemItem.setEnabled(item.file != null); + systemItem.addActionListener(evt -> openWithSystemDefault(item.file)); + popup.add(systemItem); + + JMenuItem copyPathItem = new JMenuItem("Copy Path"); + copyPathItem.setEnabled(item.file != null); + copyPathItem.addActionListener(evt -> { + StringSelection selection = new StringSelection(item.file.getAbsolutePath()); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + }); + popup.add(copyPathItem); + + popup.addSeparator(); + JMenuItem refreshItem = new JMenuItem("Refresh Assets"); + refreshItem.addActionListener(evt -> refreshAssetsLibrary()); + popup.add(refreshItem); + + popup.show(invoker, x, y); + } + private JPanel buildBookmarkActionsPanel() { JPanel panel = new JPanel(); panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); @@ -2295,6 +2800,20 @@ private JPanel buildDebugActionsPanel() { } private void configureDocsPane() { + docsTabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); + docsTabs.putClientProperty(TABBED_PANE_TAB_CLOSABLE, true); + docsTabs.putClientProperty( + TABBED_PANE_TAB_CLOSE_CALLBACK, (BiConsumer) (tabPane, tabIndex) -> { + if (tabIndex <= 0 || tabIndex >= docsTabs.getTabCount()) { + return; + } + docsTabs.remove(tabIndex.intValue()); + if (docsTabs.getTabCount() > 0) { + docsTabs.setSelectedIndex(0); + } + selectRightDocsSection("functions"); + }); + JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); JPanel lookupHeader = new JPanel(new BorderLayout(6, 6)); @@ -2352,6 +2871,7 @@ public Component getListCellRendererComponent( referenceDetailsPane.setEditable(false); referenceDetailsPane.setContentType("text/html"); + setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); JSplitPane lookupSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); lookupSplit.setResizeWeight(0.65); @@ -2552,16 +3072,16 @@ private void refreshSidebarContent() { private void refreshFileBrowserTree() { File root = new File(fileManager.getCurrentDirectory()); - DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0, 5); + DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0); fileBrowserTree.setModel(new DefaultTreeModel(rootNode)); if (fileBrowserTree.getRowCount() > 0) { fileBrowserTree.expandRow(0); } } - private DefaultMutableTreeNode buildFileTreeNode(File file, int depth, int maxDepth) { + private DefaultMutableTreeNode buildFileTreeNode(File file, int depth) { DefaultMutableTreeNode node = new DefaultMutableTreeNode(file); - if (!file.isDirectory() || depth >= maxDepth) { + if (!file.isDirectory()) { return node; } @@ -2571,16 +3091,17 @@ private DefaultMutableTreeNode buildFileTreeNode(File file, int depth, int maxDe } Arrays.sort(children, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); for (File child : children) { - if (child.getName().startsWith(".")) { + if (!showHiddenFiles && child.getName().startsWith(".")) { continue; } - node.add(buildFileTreeNode(child, depth + 1, maxDepth)); + node.add(buildFileTreeNode(child, depth + 1)); } return node; } private void refreshAssetsLibrary() { File rootDir = new File(fileManager.getCurrentDirectory()); + assetThumbnailCache.clear(); DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(new AssetItem( "Assets", "Workspace resources, libraries, and embedded literals", @@ -2606,6 +3127,29 @@ private void refreshAssetsLibrary() { for (int i = 0; i < Math.min(4, assetsTree.getRowCount()); i++) { assetsTree.expandRow(i); } + + assetsListModel.clear(); + for (AssetItem item : collectOpenableAssets(rootNode)) { + assetsListModel.addElement(item); + } + } + + private java.util.List collectOpenableAssets(DefaultMutableTreeNode rootNode) { + java.util.List items = new ArrayList<>(); + if (rootNode == null) { + return items; + } + java.util.Enumeration enumeration = rootNode.depthFirstEnumeration(); + while (enumeration.hasMoreElements()) { + Object next = enumeration.nextElement(); + if (!(next instanceof DefaultMutableTreeNode node)) { + continue; + } + if (node.getUserObject() instanceof AssetItem item && item.isOpenable()) { + items.add(item); + } + } + return items; } private DefaultMutableTreeNode buildMediaTypeSection( @@ -2744,6 +3288,45 @@ private AssetItem createAssetItem(File file, File baseDir, String subtitlePrefix file != null ? fileSystemView.getSystemIcon(file) : createImageIcon(ICON_MENU_FOLDER)); } + private Icon getAssetGridIcon(AssetItem item) { + if (item == null || item.file == null) { + return createImageIcon(ICON_MENU_ASSETS); + } + String cacheKey = item.file.getAbsolutePath(); + Icon cached = assetThumbnailCache.get(cacheKey); + if (cached != null) { + return cached; + } + + Icon icon = item.icon; + String lower = item.file.getName().toLowerCase(Locale.ROOT); + if (FileViewerFactory.isImageFile(lower)) { + icon = buildImageThumbnailIcon(item.file, 84, 64); + } + if (icon == null) { + icon = createImageIcon(ICON_MENU_ASSETS); + } + assetThumbnailCache.put(cacheKey, icon); + return icon; + } + + private Icon buildImageThumbnailIcon(File file, int maxWidth, int maxHeight) { + try { + java.awt.image.BufferedImage image = javax.imageio.ImageIO.read(file); + if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) { + return null; + } + double scale = Math.min((double) maxWidth / image.getWidth(), (double) maxHeight / image.getHeight()); + scale = Math.min(1.0d, scale); + int width = Math.max(1, (int) Math.round(image.getWidth() * scale)); + int height = Math.max(1, (int) Math.round(image.getHeight() * scale)); + Image scaled = image.getScaledInstance(width, height, Image.SCALE_SMOOTH); + return new ImageIcon(scaled); + } catch (IOException ex) { + return null; + } + } + private File resolveAssetReference(String literal, File baseDir, File sourceParent) { if (literal == null || literal.isBlank()) { return null; @@ -2838,13 +3421,23 @@ private void refreshRunnableFileControls() { updatingRunTargetCombo = true; runTargetCombo.removeAllItems(); - for (FileEditor editor : fileManager.getFileEditors()) { + java.util.List runnableTabIndices = new ArrayList<>(); + + for (int i = 0; i < fileManager.getFileEditors().size(); i++) { + FileEditor editor = fileManager.getFileEditors().get(i); + if (editor == null) { + continue; + } runTargetCombo.addItem(editor.getShortFilename()); + runnableTabIndices.add(i); } int runnableIndex = fileManager.getRunnableFileIndex(); - if (runnableIndex >= 0 && runnableIndex < runTargetCombo.getItemCount()) { - runTargetCombo.setSelectedIndex(runnableIndex); + if (runnableIndex >= 0) { + int comboIndex = runnableTabIndices.indexOf(runnableIndex); + if (comboIndex >= 0 && comboIndex < runTargetCombo.getItemCount()) { + runTargetCombo.setSelectedIndex(comboIndex); + } } runTargetCombo.setEnabled(runTargetCombo.getItemCount() > 0); @@ -2855,10 +3448,21 @@ private void onRunTargetSelectionChanged() { if (updatingRunTargetCombo || fileManager == null) { return; } - int index = runTargetCombo.getSelectedIndex(); - if (index >= 0 && index < fileManager.getFileEditors().size()) { - fileManager.setRunnableFilePath( - fileManager.getFileEditors().get(index).getFilePath()); + int comboIndex = runTargetCombo.getSelectedIndex(); + if (comboIndex < 0) { + return; + } + + int textEditorOffset = -1; + for (FileEditor editor : fileManager.getFileEditors()) { + if (editor == null) { + continue; + } + textEditorOffset++; + if (textEditorOffset == comboIndex) { + fileManager.setRunnableFilePath(editor.getFilePath()); + return; + } } } @@ -3227,7 +3831,7 @@ private void filterReferenceItems() { referenceList.setSelectedIndex(0); } } else { - referenceDetailsPane.setText(REFERENCE_NO_MATCHES_HTML); + setReferenceDetailsHtml(REFERENCE_NO_MATCHES_HTML); referenceInsertButton.setEnabled(false); } } @@ -3235,15 +3839,24 @@ private void filterReferenceItems() { private void updateReferenceSelectionDetails() { ReferenceItem item = referenceList.getSelectedValue(); if (item == null) { - referenceDetailsPane.setText(REFERENCE_SELECT_PROMPT_HTML); + setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); referenceInsertButton.setEnabled(false); return; } - referenceDetailsPane.setText(item.details); - referenceDetailsPane.setCaretPosition(0); + setReferenceDetailsHtml(item.details); referenceInsertButton.setEnabled(true); } + private void setReferenceDetailsHtml(String html) { + String next = html == null ? REFERENCE_SELECT_PROMPT_HTML : html; + if (Objects.equals(referenceDetailsHtml, next)) { + return; + } + referenceDetailsHtml = next; + referenceDetailsPane.setText(next); + referenceDetailsPane.setCaretPosition(0); + } + private void insertSelectedReference() { ReferenceItem item = referenceList.getSelectedValue(); if (item == null) { @@ -3325,6 +3938,124 @@ private String escapeHtml(String input) { return input.replace("&", "&").replace("<", "<").replace(">", ">"); } + private int findOpenTabIndexByPath(String absolutePath) { + if (absolutePath == null || absolutePath.isBlank()) { + return -1; + } + for (int i = 0; i < fileViewers.size(); i++) { + FileViewerWrapper wrapper = fileViewers.get(i); + if (wrapper == null || wrapper.getFilePath() == null) { + continue; + } + if (absolutePath.equals(wrapper.getFilePath())) { + return i; + } + } + return fileManager.getTabIndex(absolutePath); + } + + private void setWorkspaceDirectory(File folder) { + if (folder == null) { + return; + } + File absoluteFolder = folder.getAbsoluteFile(); + if (!absoluteFolder.exists() || !absoluteFolder.isDirectory()) { + JOptionPane.showMessageDialog(frame, "Folder not found: " + absoluteFolder.getAbsolutePath()); + return; + } + fileManager.setCurrentDirectory(absoluteFolder.getAbsolutePath()); + registerWorkspace(absoluteFolder); + refreshSidebarContent(); + } + + private void registerWorkspace(File folder) { + if (folder == null) { + return; + } + File absolute = folder.getAbsoluteFile(); + if (!absolute.exists() || !absolute.isDirectory()) { + return; + } + recentWorkspaces.removeIf(existing -> existing == null + || !existing.exists() + || existing.getAbsoluteFile().equals(absolute)); + recentWorkspaces.add(0, absolute); + while (recentWorkspaces.size() > MAX_RECENT_WORKSPACES) { + recentWorkspaces.remove(recentWorkspaces.size() - 1); + } + saveRecentWorkspaces(); + setRecentItems(basicEditor.getRecentFiles()); + refreshEmptyStateRecentItems(); + } + + private void loadRecentWorkspaces() { + recentWorkspaces.clear(); + File config = new File(applicationStoragePath, RECENT_WORKSPACES_FILE); + if (!config.exists()) { + return; + } + Properties properties = new Properties(); + try (FileInputStream stream = new FileInputStream(config)) { + properties.load(stream); + String csv = properties.getProperty(RECENT_WORKSPACES_KEY, ""); + for (String entry : csv.split(",")) { + if (entry == null || entry.isBlank()) { + continue; + } + File folder = new File(entry.trim()).getAbsoluteFile(); + if (folder.exists() && folder.isDirectory()) { + recentWorkspaces.add(folder); + } + if (recentWorkspaces.size() >= MAX_RECENT_WORKSPACES) { + break; + } + } + } catch (IOException ignored) { + // Ignore workspace history load errors to avoid interrupting startup. + } + } + + private void saveRecentWorkspaces() { + File config = new File(applicationStoragePath, RECENT_WORKSPACES_FILE); + Properties properties = new Properties(); + String csv = recentWorkspaces.stream() + .filter(Objects::nonNull) + .map(File::getAbsolutePath) + .distinct() + .limit(MAX_RECENT_WORKSPACES) + .reduce((a, b) -> a + "," + b) + .orElse(""); + properties.setProperty(RECENT_WORKSPACES_KEY, csv); + try { + File parent = config.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + try (FileOutputStream out = new FileOutputStream(config)) { + properties.store(out, "Recent workspaces"); + } + } catch (IOException ignored) { + // Ignore workspace history save errors. + } + } + + private void refreshEmptyStateRecentItems() { + if (!(emptyTabPanel instanceof EmptyTabPanel) || basicEditor == null) { + return; + } + if (basicEditor.getMode() != ApMode.AP_CLOSED) { + return; + } + emptyTabPanel = new EmptyTabPanel( + this, + newMenuItem.getAccelerator(), + openMenuItem.getAccelerator(), + openFolderMenuItem.getAccelerator(), + basicEditor.getRecentFiles(), + recentWorkspaces); + mainPane.setTopComponent(emptyTabPanel); + } + private void setClosingTabsEnabled(boolean enabled) { tabControl.putClientProperty(TABBED_PANE_TAB_CLOSABLE, enabled); // TODO get main file index @@ -3372,7 +4103,17 @@ public void onOpenClick() { @Override public void onOpenClick(File file) { - actionOpen(file); + openFileWithPreferredViewer(file); + } + + @Override + public void onOpenFolderClick() { + actionOpenFolder(); + } + + @Override + public void onOpenWorkspaceClick(File folder) { + setWorkspaceDirectory(folder); } @Override diff --git a/app/src/main/java/com/basic4gl/desktop/FileManager.java b/app/src/main/java/com/basic4gl/desktop/content/FileManager.java similarity index 65% rename from app/src/main/java/com/basic4gl/desktop/FileManager.java rename to app/src/main/java/com/basic4gl/desktop/content/FileManager.java index 3579677b..b9e78748 100644 --- a/app/src/main/java/com/basic4gl/desktop/FileManager.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileManager.java @@ -1,4 +1,4 @@ -package com.basic4gl.desktop; +package com.basic4gl.desktop.content; import com.basic4gl.desktop.editor.FileEditor; import com.basic4gl.desktop.util.IFileManager; @@ -38,6 +38,13 @@ public String getFilename(int index) { return fileEditors.get(index).getFilePath(); } + private FileEditor getEditorOrNull(int index) { + if (index < 0 || index >= fileEditors.size()) { + return null; + } + return fileEditors.get(index); + } + public void setCurrentDirectory(String path) { currentDirectory = path; if (listener != null) { @@ -63,7 +70,8 @@ public int getTabIndex(String path) { int i = 0; boolean found = false; for (; i < fileEditors.size(); i++) { - if (fileEditors.get(i).getFilePath().equals(path)) { + FileEditor editor = fileEditors.get(i); + if (editor != null && editor.getFilePath().equals(path)) { found = true; break; } @@ -72,84 +80,95 @@ public int getTabIndex(String path) { } public void selectPreviousBreakpoint(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).gotoNextBreakpoint(false); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.gotoNextBreakpoint(false); } } public void selectNextBreakpoint(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).gotoNextBreakpoint(true); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.gotoNextBreakpoint(true); } } public void toggleBookmark(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).toggleBookmark(); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.toggleBookmark(); } } public void selectPreviousBookmark(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).gotoNextBookmark(false); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.gotoNextBookmark(false); } } public void selectNextBookmark(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).gotoNextBookmark(true); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.gotoNextBookmark(true); } } public void selectAll(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).getEditorPane().selectAll(); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.getEditorPane().selectAll(); } } public void paste(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).getEditorPane().paste(); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.getEditorPane().paste(); } } public void copy(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).getEditorPane().copy(); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.getEditorPane().copy(); } } public void cut(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).getEditorPane().cut(); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.getEditorPane().cut(); } } public void redo(int i) { - if (i > -1 && i < fileEditors.size()) { - if (fileEditors.get(i).canRedo()) { - fileEditors.get(i).redoLastAction(); - } + FileEditor editor = getEditorOrNull(i); + if (editor != null && editor.canRedo()) { + editor.redoLastAction(); } } public void undo(int i) { - if (i > -1 && i < fileEditors.size()) { - if (fileEditors.get(i).canUndo()) { - fileEditors.get(i).undoLastAction(); - } + FileEditor editor = getEditorOrNull(i); + if (editor != null && editor.canUndo()) { + editor.undoLastAction(); } } public void toggleBreakpoint(int i) { - if (i > -1 && i < fileEditors.size()) { - fileEditors.get(i).toggleBreakpoint(); + FileEditor editor = getEditorOrNull(i); + if (editor != null) { + editor.toggleBreakpoint(); } } public void setReadOnly(boolean readOnly) { for (int i = 0; i < fileEditors.size(); i++) { - fileEditors.get(i).getEditorPane().setEditable(!readOnly); + FileEditor editor = fileEditors.get(i); + if (editor != null) { + editor.getEditorPane().setEditable(!readOnly); + } } } @@ -158,12 +177,13 @@ public boolean isMultifileModified(Mutable description) { String desc = ""; for (int i = 0; i < fileEditors.size(); i++) { - if (fileEditors.get(i).isModified()) { + FileEditor editor = fileEditors.get(i); + if (editor != null && editor.isModified()) { result = true; if (!desc.isEmpty()) { desc += ", "; } - String filename = fileEditors.get(i).getShortFilename(); + String filename = editor.getShortFilename(); desc += filename; } @@ -224,6 +244,12 @@ public void ensureRunnableFileValid() { return; } - runnableFilePath = fileEditors.get(0).getFilePath(); + for (FileEditor editor : fileEditors) { + if (editor != null) { + runnableFilePath = editor.getFilePath(); + return; + } + } + runnableFilePath = null; } } diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java new file mode 100644 index 00000000..d5fae577 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java @@ -0,0 +1,116 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewerException; + +import java.nio.file.Path; + +/** + * Convenience facade for using the file viewer system + * + * This class provides a simple API for the most common use cases: + * - Load and display a file + * - Check if a file type is supported + * - List available viewers + */ +public class FileViewerManager { + + private static final FileViewerRegistry registry = new FileViewerRegistry(); + private static boolean initialized = false; + + private FileViewerManager() { + // Static utility class + } + + /** + * Initialize the file viewer system (must be called once before use) + */ + public static void initialize() { + if (!initialized) { + registry.initialize(); + initialized = true; + } + } + + /** + * Load a file with an appropriate viewer + * + * Example usage: + * try { + * FileViewerManager.initialize(); + * FileViewerResult result = FileViewerManager.loadFile(Paths.get("image.png")); + * if (result.isSuccess()) { + * JFrame frame = new JFrame("File Viewer"); + * frame.add(result.getViewer().getComponent()); + * frame.setSize(800, 600); + * frame.setVisible(true); + * } else { + * System.err.println("Error: " + result.getError()); + * } + * } catch (Exception e) { + * e.printStackTrace(); + * } + * + * @param filepath Path to file to view + * @return FileViewerResult containing viewer or error details + */ + public static FileViewerRegistry.FileViewerResult loadFile(Path filepath) { + if (!initialized) { + initialize(); + } + + FileViewerRegistry.FileViewerResult result = registry.findViewer(filepath); + if (result.isSuccess()) { + try { + result.getViewer().loadFile(filepath); + } catch (FileViewerException e) { + return new FileViewerRegistry.FileViewerResult(null, null, e.getMessage()); + } + } + return result; + } + + /** + * Check if a file type is supported by any registered viewer + */ + public static boolean isSupported(String filename) { + if (!initialized) { + initialize(); + } + for (com.basic4gl.desktop.spi.content.FileViewerMetadata viewer : registry.getAvailableViewers()) { + String ext = getFileExtension(filename); + if (ext != null && viewer.supportsExtension(ext)) { + return true; + } + } + return false; + } + + /** + * Get all registered viewers + */ + public static java.util.List getAvailableViewers() { + if (!initialized) { + initialize(); + } + return registry.getAvailableViewers(); + } + + /** + * Get last error message + */ + public static String getLastError() { + return registry.getLastError(); + } + + private static String getFileExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return null; + } + int lastDot = filename.lastIndexOf("."); + if (lastDot > 0 && lastDot < filename.length() - 1) { + return filename.substring(lastDot); + } + return null; + } +} + diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerProvider.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerProvider.java new file mode 100644 index 00000000..9e8b6931 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerProvider.java @@ -0,0 +1,25 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; + +/** + * Provider interface for creating FileViewer instances + * + * Similar to the Basic4GLPluginProvider pattern, this allows discovery via ServiceLoader. + * Implement this interface and register in META-INF/services to make viewers discoverable. + */ +public interface FileViewerProvider { + + /** + * Create a new FileViewer instance + * @return New FileViewer instance + */ + FileViewer createViewer(); + + /** + * Get metadata about this viewer provider + * @return Viewer metadata (name, version, supported file types) + */ + com.basic4gl.desktop.spi.content.FileViewerMetadata getMetadata(); +} + diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java new file mode 100644 index 00000000..0c799d67 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java @@ -0,0 +1,186 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; + +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Registry for discovering and managing file viewers + * + * Handles ServiceLoader-based discovery of FileViewerProviders and + * maintains a registry of available viewers. + */ +public class FileViewerRegistry { + + private final Map providers = new HashMap<>(); + private String lastError = ""; + + /** + * Initialize the registry and discover all available viewers + */ + public void initialize() { + providers.clear(); + lastError = ""; + + try { + ServiceLoader loader = ServiceLoader.load(FileViewerProvider.class); + + for (FileViewerProvider provider : loader) { + com.basic4gl.desktop.spi.content.FileViewerMetadata metadata = provider.getMetadata(); + if (metadata != null && metadata.getName() != null) { + providers.put(metadata.getName(), provider); + } + } + } catch (Exception e) { + lastError = "Failed to discover file viewers: " + e.getMessage(); + } + } + + /** + * Find a suitable viewer for the given file + * @param filepath Path to the file + * @return FileViewerResult with viewer instance or error details + */ + public FileViewerResult findViewer(Path filepath) { + String filename = filepath.getFileName().toString(); + String extension = getFileExtension(filename); + String mimeType = guessMimeType(filename); + + for (FileViewerProvider provider : providers.values()) { + com.basic4gl.desktop.spi.content.FileViewerMetadata metadata = provider.getMetadata(); + if (metadata != null) { + if ((extension != null && metadata.supportsExtension(extension)) + || (mimeType != null && metadata.supportsMimeType(mimeType))) { + try { + FileViewer viewer = provider.createViewer(); + return new FileViewerResult(viewer, metadata); + } catch (Exception e) { + lastError = "Failed to create viewer " + metadata.getName() + ": " + e.getMessage(); + return new FileViewerResult(null, null, lastError); + } + } + } + } + + lastError = "No viewer found for: " + filename; + return new FileViewerResult(null, null, lastError); + } + + /** + * Get all registered viewers + */ + public List getAvailableViewers() { + return providers.values().stream() + .map(FileViewerProvider::getMetadata) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * Get error message from last operation + */ + public String getLastError() { + return lastError; + } + + /** + * Extract file extension from filename + */ + private String getFileExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return null; + } + int lastDot = filename.lastIndexOf("."); + if (lastDot > 0 && lastDot < filename.length() - 1) { + return filename.substring(lastDot); + } + return null; + } + + /** + * Guess MIME type based on file extension (sane defaults) + */ + private String guessMimeType(String filename) { + String ext = getFileExtension(filename); + if (ext == null) { + return null; + } + + return switch (ext.toLowerCase()) { + // Images + case ".jpg", ".jpeg" -> "image/jpeg"; + case ".png" -> "image/png"; + case ".gif" -> "image/gif"; + case ".bmp" -> "image/bmp"; + case ".webp" -> "image/webp"; + case ".svg" -> "image/svg+xml"; + case ".tiff", ".tif" -> "image/tiff"; + case ".ico" -> "image/x-icon"; + case ".pcx" -> "image/x-pcx"; + + // Audio + case ".mp3" -> "audio/mpeg"; + case ".wav" -> "audio/wav"; + case ".flac" -> "audio/flac"; + case ".ogg", ".oga" -> "audio/ogg"; + case ".m4a" -> "audio/mp4"; + case ".aac" -> "audio/aac"; + case ".wma" -> "audio/x-ms-wma"; + case ".aiff", ".aif" -> "audio/aiff"; + + // Video + case ".mp4" -> "video/mp4"; + case ".webm" -> "video/webm"; + case ".mkv" -> "video/x-matroska"; + case ".avi" -> "video/x-msvideo"; + case ".mov" -> "video/quicktime"; + case ".flv" -> "video/x-flv"; + + // Text + case ".txt" -> "text/plain"; + case ".json" -> "application/json"; + case ".xml" -> "application/xml"; + case ".html", ".htm" -> "text/html"; + case ".css" -> "text/css"; + + default -> null; + }; + } + + /** + * Result of finding a viewer + */ + public static class FileViewerResult { + private final FileViewer viewer; + private final com.basic4gl.desktop.spi.content.FileViewerMetadata metadata; + private final String error; + + public FileViewerResult(FileViewer viewer, com.basic4gl.desktop.spi.content.FileViewerMetadata metadata) { + this(viewer, metadata, null); + } + + public FileViewerResult(FileViewer viewer, com.basic4gl.desktop.spi.content.FileViewerMetadata metadata, String error) { + this.viewer = viewer; + this.metadata = metadata; + this.error = error; + } + + public FileViewer getViewer() { + return viewer; + } + + public com.basic4gl.desktop.spi.content.FileViewerMetadata getMetadata() { + return metadata; + } + + public String getError() { + return error; + } + + public boolean isSuccess() { + return viewer != null; + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/IFileManagerListener.java b/app/src/main/java/com/basic4gl/desktop/content/IFileManagerListener.java similarity index 71% rename from app/src/main/java/com/basic4gl/desktop/IFileManagerListener.java rename to app/src/main/java/com/basic4gl/desktop/content/IFileManagerListener.java index d8b6a173..0d2e54a0 100644 --- a/app/src/main/java/com/basic4gl/desktop/IFileManagerListener.java +++ b/app/src/main/java/com/basic4gl/desktop/content/IFileManagerListener.java @@ -1,4 +1,4 @@ -package com.basic4gl.desktop; +package com.basic4gl.desktop.content; public interface IFileManagerListener { void onCurrentDirectoryChanged(String directory); diff --git a/app/src/main/java/com/basic4gl/desktop/ApMode.java b/app/src/main/java/com/basic4gl/desktop/editor/ApMode.java similarity index 71% rename from app/src/main/java/com/basic4gl/desktop/ApMode.java rename to app/src/main/java/com/basic4gl/desktop/editor/ApMode.java index 5374a91e..15bf9534 100644 --- a/app/src/main/java/com/basic4gl/desktop/ApMode.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/ApMode.java @@ -1,4 +1,4 @@ -package com.basic4gl.desktop; +package com.basic4gl.desktop.editor; enum ApMode { AP_CLOSED, diff --git a/app/src/main/java/com/basic4gl/desktop/editor/AudioFileViewer.java b/app/src/main/java/com/basic4gl/desktop/editor/AudioFileViewer.java new file mode 100644 index 00000000..53f386bc --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/editor/AudioFileViewer.java @@ -0,0 +1,165 @@ +package com.basic4gl.desktop.editor; + +import java.awt.*; +import java.io.File; +import javax.swing.*; + +/** + * Audio file viewer for playing WAV, OGG, MP3, FLAC, and other audio formats. + * Provides a player interface with: + * - Play/Pause controls + * - Progress bar + * - Volume control + * - File information display + * + * Note: Audio playback implementation depends on the audio library availability. + * Currently provides a UI framework that can be extended with actual playback functionality. + */ +public class AudioFileViewer implements IFileViewer { + private final File file; + private final JPanel contentPanel; + + public AudioFileViewer(File file) { + this.file = file; + this.contentPanel = createAudioPlayerUI(); + } + + private JPanel createAudioPlayerUI() { + JPanel mainPanel = new JPanel(new BorderLayout()); + mainPanel.setBackground(Color.WHITE); + mainPanel.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20)); + + // Player controls + JPanel controlsPanel = createControlsPanel(); + mainPanel.add(controlsPanel, BorderLayout.CENTER); + + return mainPanel; + } + + private JPanel createControlsPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setAlignmentX(Component.CENTER_ALIGNMENT); + + // File info + if (file != null && file.exists()) { + JLabel fileNameLabel = new JLabel("File: " + file.getName()); + fileNameLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + fileNameLabel.setFont(fileNameLabel.getFont().deriveFont(Font.BOLD, 14f)); + panel.add(fileNameLabel); + + JLabel fileSizeLabel = new JLabel(String.format("Size: %.2f MB", file.length() / (1024.0 * 1024.0))); + fileSizeLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + fileSizeLabel.setFont(fileSizeLabel.getFont().deriveFont(12f)); + panel.add(fileSizeLabel); + + panel.add(Box.createVerticalStrut(20)); + } + + // Control buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 10, 0)); + JButton playButton = new JButton("▶ Play"); + JButton pauseButton = new JButton("⏸ Pause"); + JButton stopButton = new JButton("⏹ Stop"); + + playButton.addActionListener(e -> JOptionPane.showMessageDialog( + contentPanel, + "Audio playback: Please implement audio playback support", + "Feature Not Yet Implemented", + JOptionPane.INFORMATION_MESSAGE)); + pauseButton.setEnabled(false); + stopButton.setEnabled(false); + + buttonPanel.add(playButton); + buttonPanel.add(pauseButton); + buttonPanel.add(stopButton); + panel.add(buttonPanel); + + // Progress bar + panel.add(Box.createVerticalStrut(20)); + JLabel progressLabel = new JLabel("Time: 0:00 / 0:00"); + progressLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + panel.add(progressLabel); + + JProgressBar progressBar = new JProgressBar(0, 100); + progressBar.setValue(0); + progressBar.setAlignmentX(Component.LEFT_ALIGNMENT); + progressBar.setMaximumSize(new Dimension(Integer.MAX_VALUE, progressBar.getPreferredSize().height)); + panel.add(progressBar); + + // Volume control + panel.add(Box.createVerticalStrut(20)); + JPanel volumePanel = new JPanel(); + volumePanel.setLayout(new BoxLayout(volumePanel, BoxLayout.X_AXIS)); + JLabel volumeLabel = new JLabel("Volume: "); + JSlider volumeSlider = new JSlider(0, 100, 80); + volumeSlider.setMaximumSize(new Dimension(300, volumeSlider.getPreferredSize().height)); + volumePanel.add(volumeLabel); + volumePanel.add(volumeSlider); + panel.add(volumePanel); + + // Info panel + panel.add(Box.createVerticalStrut(20)); + JPanel infoPanel = new JPanel(); + infoPanel.setLayout(new BoxLayout(infoPanel, BoxLayout.Y_AXIS)); + infoPanel.setBorder(BorderFactory.createTitledBorder("Info")); + JTextArea infoArea = new JTextArea("Audio player interface ready.\n\n" + "To enable playback, integrate with:\n" + + "- Paulscode SoundSystem (available in this project)\n" + + "- JavaFX MediaPlayer\n" + + "- Tritonus (Java Sound alternative)\n\n" + + "For now, you can open this file with a system audio player\n" + + "using the 'Open With' context menu."); + infoArea.setEditable(false); + infoArea.setLineWrap(true); + infoArea.setWrapStyleWord(true); + infoArea.setMargin(new Insets(10, 10, 10, 10)); + infoPanel.add(new JScrollPane(infoArea)); + panel.add(infoPanel); + + return panel; + } + + @Override + public String getTitle() { + if (file == null) { + return "[Audio]"; + } + return file.getName(); + } + + @Override + public String getFilePath() { + return file != null ? file.getAbsolutePath() : ""; + } + + @Override + public JComponent getContentPane() { + return contentPanel; + } + + @Override + public File getFile() { + return file; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[Audio]"; + } + + @Override + public boolean isModified() { + // Audio files are read-only + return false; + } + + @Override + public void setModified() { + // Audio files are read-only + } + + @Override + public ViewerType getViewerType() { + return ViewerType.AUDIO_VIEWER; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java b/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java index d8a3be85..27d19489 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java @@ -43,14 +43,15 @@ public class FileEditor implements SearchListener { private final ReplaceToolBar replaceToolBar; private final CollapsibleSectionPanel csp; private final RMultiHeaderScrollPane scrollPane; - private final RSyntaxTextArea editorPane; // private Map lineHighlights; //Highlight lines with breakpoints - private String fileName; // Filename without path - private String filePath; // Full path including name - private boolean isModified; - private boolean isSaved; // File exists on system + // Package-protected to allow TextFileViewer access + protected final RSyntaxTextArea editorPane; + protected String fileName; // Filename without path + protected String filePath; // Full path including name + protected boolean isModified; + protected boolean isSaved; // File exists on system public FileEditor( IFileEditorActionListener actionListener, diff --git a/app/src/main/java/com/basic4gl/desktop/editor/FileViewerFactory.java b/app/src/main/java/com/basic4gl/desktop/editor/FileViewerFactory.java new file mode 100644 index 00000000..f1595b15 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/editor/FileViewerFactory.java @@ -0,0 +1,149 @@ +package com.basic4gl.desktop.editor; + +import java.io.File; +import java.util.Locale; +import javax.swing.*; +import org.fife.ui.rsyntaxtextarea.LinkGenerator; +import org.fife.ui.rtextarea.SearchContext; + +/** + * Factory class that determines which viewer to use for different file types. + * This is the central place to configure which file types get which viewers. + */ +public class FileViewerFactory { + private FileViewerFactory() { + // Utility class + } + + /** + * Gets the appropriate viewer type for a given file. + * + * @param file the file to determine viewer type for + * @return the viewer type to use for this file + */ + public static IFileViewer.ViewerType getViewerType(File file) { + if (file == null) { + return IFileViewer.ViewerType.TEXT_EDITOR; + } + + String name = file.getName().toLowerCase(Locale.ROOT); + + // Image files + if (isImageFile(name)) { + return IFileViewer.ViewerType.IMAGE_VIEWER; + } + + // Audio files + if (isAudioFile(name)) { + return IFileViewer.ViewerType.AUDIO_VIEWER; + } + + // Markdown files + if (name.endsWith(".md")) { + return IFileViewer.ViewerType.MARKDOWN_VIEWER; + } + + // Default to text editor + return IFileViewer.ViewerType.TEXT_EDITOR; + } + + /** + * Creates a file viewer for the given file with the specified viewer type preference. + * If the preferred viewer type is not available or the file type doesn't match, falls back + * to a sensible default. + * + * @param file the file to create a viewer for (can be null for new unsaved files) + * @param preferredViewerType the preferred viewer type (can be null to auto-detect) + * @param actionListener listener for file editor actions + * @param fileManager file manager instance + * @param toggleBreakpointListener listener for breakpoint toggles + * @param linkGenerator link generator for hyperlinks + * @param searchContext search context for find/replace + * @return a new IFileViewer instance + */ + public static IFileViewer createViewer( + File file, + IFileViewer.ViewerType preferredViewerType, + IFileEditorActionListener actionListener, + com.basic4gl.desktop.util.IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext) { + + IFileViewer.ViewerType viewerType = preferredViewerType; + + // Auto-detect if not preferred + if (viewerType == null && file != null) { + viewerType = getViewerType(file); + } + + // Create the appropriate viewer + switch (viewerType) { + case IMAGE_VIEWER: + if (file != null && isImageFile(file.getName().toLowerCase(Locale.ROOT))) { + return new ImageFileViewer(file); + } + // Fall through to hex viewer if not an image + case HEX_VIEWER: + return new HexFileViewer(file); + case AUDIO_VIEWER: + if (file != null && isAudioFile(file.getName().toLowerCase(Locale.ROOT))) { + return new AudioFileViewer(file); + } + // Fall through to text editor if not audio + case MARKDOWN_VIEWER: + // Markdown is handled specially (usually in docs tabs), but provide fallback + return new TextFileViewer( + file, actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + case TEXT_EDITOR: + default: + return new TextFileViewer( + file, actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + } + } + + /** + * Determines if a file is an image file + */ + public static boolean isImageFile(String filename) { + String lower = filename.toLowerCase(Locale.ROOT); + return lower.endsWith(".png") + || lower.endsWith(".jpg") + || lower.endsWith(".jpeg") + || lower.endsWith(".gif") + || lower.endsWith(".bmp") + || lower.endsWith(".webp") + || lower.endsWith(".ico") + || lower.endsWith(".svg") + || lower.endsWith(".tiff") + || lower.endsWith(".tif"); + } + + /** + * Determines if a file is an audio file + */ + public static boolean isAudioFile(String filename) { + String lower = filename.toLowerCase(Locale.ROOT); + return lower.endsWith(".wav") + || lower.endsWith(".ogg") + || lower.endsWith(".mp3") + || lower.endsWith(".flac") + || lower.endsWith(".aac") + || lower.endsWith(".m4a") + || lower.endsWith(".wma") + || lower.endsWith(".aiff"); + } + + /** + * Determines if a file should be opened with a hex viewer + */ + public static boolean isBinaryFile(String filename) { + // Files that are binary but might need viewing + String lower = filename.toLowerCase(Locale.ROOT); + return lower.endsWith(".jar") + || lower.endsWith(".zip") + || lower.endsWith(".bin") + || lower.endsWith(".dat") + || lower.endsWith(".class"); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/FileViewerWrapper.java b/app/src/main/java/com/basic4gl/desktop/editor/FileViewerWrapper.java new file mode 100644 index 00000000..6f3b7731 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/editor/FileViewerWrapper.java @@ -0,0 +1,141 @@ +package com.basic4gl.desktop.editor; + +import java.io.File; + +/** + * Wrapper class that bridges IFileViewer with FileEditor for backward compatibility. + * This allows the IDE to store and manage different viewer types while maintaining + * compatibility with existing FileEditor-based code. + */ +public class FileViewerWrapper { + private final IFileViewer viewer; + private final FileEditor textEditor; // Only non-null if viewer is TextFileViewer + + public FileViewerWrapper(IFileViewer viewer) { + this.viewer = viewer; + this.textEditor = (viewer instanceof TextFileViewer) ? ((TextFileViewer) viewer).getFileEditor() : null; + } + + public FileViewerWrapper(FileEditor editor) { + this.viewer = new IFileViewer() { + @Override + public String getTitle() { + return editor.getTitle(); + } + + @Override + public String getFilePath() { + return editor.getFilePath(); + } + + @Override + public javax.swing.JComponent getContentPane() { + return editor.getContentPane(); + } + + @Override + public File getFile() { + return editor.getFile(); + } + + @Override + public String getShortFilename() { + return editor.getShortFilename(); + } + + @Override + public boolean isModified() { + return editor.isModified(); + } + + @Override + public void setModified() { + editor.setModified(); + } + + @Override + public ViewerType getViewerType() { + return ViewerType.TEXT_EDITOR; + } + }; + this.textEditor = editor; + } + + /** + * Gets the viewer interface for general file operations + */ + public IFileViewer getViewer() { + return viewer; + } + + /** + * Gets the FileEditor if this is a text viewer, otherwise null + * For backward compatibility with code expecting FileEditor + */ + public FileEditor getFileEditor() { + return textEditor; + } + + /** + * Returns true if this wrapper contains a text editor + */ + public boolean isTextEditor() { + return textEditor != null; + } + + /** + * Gets the viewer type + */ + public IFileViewer.ViewerType getViewerType() { + return viewer.getViewerType(); + } + + /** + * Gets the title + */ + public String getTitle() { + return viewer.getTitle(); + } + + /** + * Gets the file path + */ + public String getFilePath() { + return viewer.getFilePath(); + } + + /** + * Gets the File object + */ + public File getFile() { + return viewer.getFile(); + } + + /** + * Gets the short filename + */ + public String getShortFilename() { + return viewer.getShortFilename(); + } + + /** + * Gets the content pane + */ + public javax.swing.JComponent getContentPane() { + return viewer.getContentPane(); + } + + /** + * Checks if modified + */ + public boolean isModified() { + return viewer.isModified(); + } + + /** + * Sets modified state + */ + public void setModified() { + viewer.setModified(); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/HexFileViewer.java b/app/src/main/java/com/basic4gl/desktop/editor/HexFileViewer.java new file mode 100644 index 00000000..bc8713af --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/editor/HexFileViewer.java @@ -0,0 +1,151 @@ +package com.basic4gl.desktop.editor; + +import java.awt.*; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import javax.swing.*; + +/** + * Hex file viewer for displaying binary files in hexadecimal format. + * Provides a read-only view showing: + * - Byte offsets on the left + * - Hexadecimal representation in the center + * - ASCII representation on the right + * - File statistics + */ +public class HexFileViewer implements IFileViewer { + private final File file; + private final JPanel contentPanel; + private final JTextArea hexArea; + private static final int BYTES_PER_LINE = 16; + + public HexFileViewer(File file) { + this.file = file; + this.hexArea = new JTextArea(); + this.contentPanel = new JPanel(new BorderLayout()); + + setupUI(); + + if (file != null && file.exists()) { + loadFileAsHex(); + } else { + showError("File not found or is not readable"); + } + } + + private void setupUI() { + hexArea.setEditable(false); + hexArea.setFont(new Font("Monospaced", Font.PLAIN, 12)); + hexArea.setTabSize(4); + + JScrollPane scrollPane = new JScrollPane(hexArea); + contentPanel.add(scrollPane, BorderLayout.CENTER); + + // Info panel at bottom + JPanel infoPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JLabel infoLabel = new JLabel(" File: " + (file != null ? file.getName() : "[Binary]")); + infoPanel.add(infoLabel); + contentPanel.add(infoPanel, BorderLayout.SOUTH); + } + + private void loadFileAsHex() { + try { + StringBuilder hexContent = new StringBuilder(); + try (FileInputStream fis = new FileInputStream(file)) { + byte[] buffer = new byte[BYTES_PER_LINE]; + long offset = 0; + int bytesRead; + + hexContent.append("Offset Hex Bytes ASCII\n"); + hexContent.append("-------- -------------------------------------------------- ----------------\n"); + + while ((bytesRead = fis.read(buffer)) != -1) { + // Offset + hexContent.append(String.format("%08X ", offset)); + + // Hex representation + StringBuilder hexBuilder = new StringBuilder(); + StringBuilder asciiBuilder = new StringBuilder(); + + for (int i = 0; i < BYTES_PER_LINE; i++) { + if (i < bytesRead) { + byte b = buffer[i]; + hexBuilder.append(String.format("%02X ", b & 0xFF)); + + char c = (char) (b & 0xFF); + if (Character.isWhitespace(c) && c != ' ') { + asciiBuilder.append('.'); + } else if (c >= 32 && c < 127) { + asciiBuilder.append(c); + } else { + asciiBuilder.append('.'); + } + } else { + hexBuilder.append(" "); + asciiBuilder.append(" "); + } + } + + hexContent.append(String.format("%-48s ", hexBuilder.toString())); + hexContent.append(asciiBuilder.toString()); + hexContent.append("\n"); + + offset += bytesRead; + } + } + hexArea.setText(hexContent.toString()); + hexArea.setCaretPosition(0); + } catch (IOException e) { + showError("Error reading file: " + e.getMessage()); + } + } + + private void showError(String message) { + hexArea.setText("Error: " + message); + } + + @Override + public String getTitle() { + if (file == null) { + return "[Binary]"; + } + return file.getName() + " (Hex)"; + } + + @Override + public String getFilePath() { + return file != null ? file.getAbsolutePath() : ""; + } + + @Override + public JComponent getContentPane() { + return contentPanel; + } + + @Override + public File getFile() { + return file; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[Binary]"; + } + + @Override + public boolean isModified() { + // Binary files are read-only + return false; + } + + @Override + public void setModified() { + // Binary files are read-only + } + + @Override + public ViewerType getViewerType() { + return ViewerType.HEX_VIEWER; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java similarity index 93% rename from app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java rename to app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java index 965809b7..7b709bb1 100644 --- a/app/src/main/java/com/basic4gl/desktop/IEditorPresenter.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java @@ -1,8 +1,10 @@ -package com.basic4gl.desktop; +package com.basic4gl.desktop.editor; import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; +import com.basic4gl.desktop.ApMode; + import java.io.File; import java.util.List; diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java b/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java new file mode 100644 index 00000000..fff8619a --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java @@ -0,0 +1,70 @@ +package com.basic4gl.desktop.editor; + +import java.io.File; +import javax.swing.*; + +/** + * Base interface for file viewers. All viewers (text editors, image viewers, audio players, hex viewers, etc.) + * implement this interface. + * + * This allows the IDE to support different types of files with different viewing/editing mechanisms while maintaining + * a consistent interface for tab management. + */ +public interface IFileViewer { + /** + * @return the title to display in the tab + */ + String getTitle(); + + /** + * @return the full file path or empty string if unsaved + */ + String getFilePath(); + + /** + * @return the JComponent to display in the tab + */ + JComponent getContentPane(); + + /** + * @return the File object associated with this viewer, or null if unsaved + */ + File getFile(); + + /** + * @return the short filename (just the name, not the full path) + */ + String getShortFilename(); + + /** + * @return true if the file has been modified since opening/saving + */ + boolean isModified(); + + /** + * Mark this viewer's content as modified + */ + void setModified(); + + /** + * @return the viewer type (for identifying which viewer is being used) + */ + ViewerType getViewerType(); + + /** + * Enumeration of supported viewer types + */ + enum ViewerType { + TEXT_EDITOR("Text Editor"), + IMAGE_VIEWER("Image Viewer"), + AUDIO_VIEWER("Audio Viewer"), + HEX_VIEWER("Hex Editor"), + MARKDOWN_VIEWER("Markdown Viewer"); + + public final String display; + + ViewerType(String display) { + this.display = display; + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/ImageFileViewer.java b/app/src/main/java/com/basic4gl/desktop/editor/ImageFileViewer.java new file mode 100644 index 00000000..1a190ee3 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/editor/ImageFileViewer.java @@ -0,0 +1,104 @@ +package com.basic4gl.desktop.editor; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import javax.imageio.ImageIO; +import javax.swing.*; + +/** + * Image file viewer for displaying PNG, JPG, GIF, BMP, and other image formats. + * Provides a read-only view with: + * - Automatic scaling to fit within the window + * - Maintains aspect ratio + * - Shows image dimensions in the title + */ +public class ImageFileViewer implements IFileViewer { + private final File file; + private final JPanel contentPanel; + private BufferedImage image; + private String errorMessage; + + public ImageFileViewer(File file) { + this.file = file; + this.contentPanel = new JPanel(new BorderLayout()); + + if (file != null && file.exists()) { + try { + image = ImageIO.read(file); + if (image != null) { + JLabel imageLabel = new JLabel(new ImageIcon(image)); + JScrollPane scrollPane = new JScrollPane(imageLabel); + contentPanel.add(scrollPane, BorderLayout.CENTER); + } else { + errorMessage = "Unable to read image file: unsupported format"; + showError(); + } + } catch (IOException e) { + errorMessage = "Error reading image: " + e.getMessage(); + showError(); + } + } else { + errorMessage = "File not found or is not readable"; + showError(); + } + } + + private void showError() { + JTextArea errorArea = new JTextArea(errorMessage); + errorArea.setEditable(false); + errorArea.setLineWrap(true); + errorArea.setWrapStyleWord(true); + errorArea.setMargin(new Insets(10, 10, 10, 10)); + contentPanel.add(new JScrollPane(errorArea), BorderLayout.CENTER); + } + + @Override + public String getTitle() { + if (file == null) { + return "[Image]"; + } + String title = file.getName(); + if (image != null) { + title += String.format(" (%dx%d)", image.getWidth(), image.getHeight()); + } + return title; + } + + @Override + public String getFilePath() { + return file != null ? file.getAbsolutePath() : ""; + } + + @Override + public JComponent getContentPane() { + return contentPanel; + } + + @Override + public File getFile() { + return file; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[Image]"; + } + + @Override + public boolean isModified() { + // Images are read-only + return false; + } + + @Override + public void setModified() { + // Images are read-only + } + + @Override + public ViewerType getViewerType() { + return ViewerType.IMAGE_VIEWER; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/TextFileViewer.java b/app/src/main/java/com/basic4gl/desktop/editor/TextFileViewer.java new file mode 100644 index 00000000..0aa45899 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/editor/TextFileViewer.java @@ -0,0 +1,111 @@ +package com.basic4gl.desktop.editor; + +import com.basic4gl.desktop.util.IFileManager; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import javax.swing.*; +import org.fife.ui.rsyntaxtextarea.LinkGenerator; +import org.fife.ui.rtextarea.SearchContext; + +/** + * Text file viewer that wraps the existing FileEditor functionality. + * This is the primary viewer for code files, text files, markdown, and any other text-based content. + */ +public class TextFileViewer implements IFileViewer { + private final FileEditor editor; + + /** + * Creates a new text file viewer for an unsaved file. + */ + public TextFileViewer( + IFileEditorActionListener actionListener, + IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext) { + this.editor = + new FileEditor(actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + } + + /** + * Creates a new text file viewer and loads the specified file. + */ + public TextFileViewer( + File file, + IFileEditorActionListener actionListener, + IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext) { + this.editor = + new FileEditor(actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + + if (file != null && file.exists()) { + try { + FileReader fr = new FileReader(file); + editor.filePath = file.getAbsolutePath(); + editor.fileName = file.getName(); + editor.editorPane.read(fr, null); + fr.close(); + editor.isSaved = true; + } catch (IOException e) { + e.printStackTrace(); + JOptionPane.showMessageDialog( + null, "Could not read file: " + e.getMessage(), "File Error", JOptionPane.ERROR_MESSAGE); + } + editor.editorPane.discardAllEdits(); // Otherwise 'undo' will clear the text area after loading + } else if (file != null) { + editor.filePath = file.getAbsolutePath(); + editor.fileName = file.getName(); + editor.isSaved = false; + } + } + + /** + * Gets the underlying FileEditor instance for compatibility with existing code + */ + public FileEditor getFileEditor() { + return editor; + } + + @Override + public String getTitle() { + return editor.getTitle(); + } + + @Override + public String getFilePath() { + return editor.getFilePath(); + } + + @Override + public JComponent getContentPane() { + return editor.getContentPane(); + } + + @Override + public File getFile() { + return editor.getFile(); + } + + @Override + public String getShortFilename() { + return editor.getShortFilename(); + } + + @Override + public boolean isModified() { + return editor.isModified(); + } + + @Override + public void setModified() { + editor.setModified(); + } + + @Override + public ViewerType getViewerType() { + return ViewerType.TEXT_EDITOR; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java similarity index 97% rename from app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java rename to app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java index 37f14704..2113b3e3 100644 --- a/app/src/main/java/com/basic4gl/desktop/SymbolIndexer.java +++ b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java @@ -1,7 +1,5 @@ -package com.basic4gl.desktop; +package com.basic4gl.desktop.language; -import com.basic4gl.desktop.language.IndexedSymbol; -import com.basic4gl.desktop.language.LanguageSupport; import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.concurrent.Executors; diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewer.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewer.java new file mode 100644 index 00000000..bd1abe54 --- /dev/null +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewer.java @@ -0,0 +1,283 @@ +package com.basic4gl.language.adapter.content; + +import com.basic4gl.library.fileviewer.FileViewer; +import com.basic4gl.library.fileviewer.FileViewerException; +import java.awt.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.sound.sampled.*; +import javax.swing.*; + +/** + * Default audio viewer with basic playback controls + * + * Features: + * - Play/pause/stop controls + * - Progress slider with seek capability + * - Volume control + * - Displays audio information (duration, format, etc.) + * - Supports WAV and AU formats (via Java Sound API) + */ +public class DefaultAudioViewer implements FileViewer { + + private final JPanel panel; + private final JButton playButton; + private final JButton pauseButton; + private final JButton stopButton; + private final JSlider progressSlider; + private final JSlider volumeSlider; + private final JLabel infoLabel; + private final JLabel timeLabel; + + private Clip audioClip; + private Thread updateThread; + private volatile boolean shouldStop = false; + private Path currentPath; + private String errorMessage = ""; + + public DefaultAudioViewer() { + this.panel = createUIPanel(); + this.playButton = new JButton("Play"); + this.pauseButton = new JButton("Pause"); + this.stopButton = new JButton("Stop"); + this.progressSlider = new JSlider(JSlider.HORIZONTAL, 0, 100, 0); + this.volumeSlider = new JSlider(JSlider.HORIZONTAL, 0, 100, 80); + this.infoLabel = new JLabel("No file loaded"); + this.timeLabel = new JLabel("00:00 / 00:00"); + + setupUI(); + setupListeners(); + } + + private JPanel createUIPanel() { + JPanel main = new JPanel(); + main.setLayout(new BorderLayout(10, 10)); + main.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + main.setBackground(Color.DARK_GRAY); + return main; + } + + private void setupUI() { + // Info panel + JPanel infoPanel = new JPanel(new BorderLayout(5, 5)); + infoPanel.setOpaque(false); + infoPanel.add(infoLabel, BorderLayout.CENTER); + infoPanel.add(timeLabel, BorderLayout.EAST); + + // Control buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); + buttonPanel.setOpaque(false); + buttonPanel.add(playButton); + buttonPanel.add(pauseButton); + buttonPanel.add(stopButton); + pauseButton.setEnabled(false); + stopButton.setEnabled(false); + + // Progress slider + progressSlider.setEnabled(false); + JPanel progressPanel = new JPanel(new BorderLayout(5, 0)); + progressPanel.setOpaque(false); + progressPanel.add(new JLabel("Progress:"), BorderLayout.WEST); + progressPanel.add(progressSlider, BorderLayout.CENTER); + + // Volume control + JPanel volumePanel = new JPanel(new BorderLayout(5, 0)); + volumePanel.setOpaque(false); + volumePanel.add(new JLabel("Volume:"), BorderLayout.WEST); + volumePanel.add(volumeSlider, BorderLayout.CENTER); + + // Combine all + JPanel controlPanel = new JPanel(new GridLayout(3, 1, 0, 5)); + controlPanel.setOpaque(false); + controlPanel.add(buttonPanel); + controlPanel.add(progressPanel); + controlPanel.add(volumePanel); + + panel.add(infoPanel, BorderLayout.NORTH); + panel.add(controlPanel, BorderLayout.CENTER); + } + + private void setupListeners() { + playButton.addActionListener(e -> play()); + pauseButton.addActionListener(e -> pause()); + stopButton.addActionListener(e -> stop()); + + volumeSlider.addChangeListener(e -> { + if (audioClip != null) { + float volume = volumeSlider.getValue() / 100.0f; + FloatControl volumeControl = (FloatControl) audioClip.getControl(FloatControl.Type.MASTER_GAIN); + volumeControl.setValue(20.0f * (float) Math.log10(volume)); + } + }); + + progressSlider.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseReleased(java.awt.event.MouseEvent e) { + if (audioClip != null && audioClip.isRunning()) { + long newPosition = (long) (progressSlider.getValue() / 100.0 * audioClip.getMicrosecondLength()); + audioClip.setMicrosecondPosition(newPosition); + } + } + }); + } + + @Override + public void loadFile(Path path) throws FileViewerException { + try { + stop(); + + if (!Files.exists(path)) { + throw new FileViewerException("File not found: " + path); + } + + AudioInputStream audioStream = AudioSystem.getAudioInputStream(path.toFile()); + audioClip = AudioSystem.getClip(); + audioClip.open(audioStream); + + currentPath = path; + errorMessage = ""; + + // Update UI + AudioFormat format = audioStream.getFormat(); + infoLabel.setText(String.format( + "Loaded: %s | Format: %.0f Hz, %d bits, %d channels", + path.getFileName(), format.getSampleRate(), format.getSampleSizeInBits(), format.getChannels())); + + progressSlider.setEnabled(true); + playButton.setEnabled(true); + + updateProgressDisplay(); + + } catch (UnsupportedAudioFileException | IOException e) { + errorMessage = e.getMessage(); + throw new FileViewerException("Failed to load audio file: " + e.getMessage(), e); + } catch (LineUnavailableException e) { + errorMessage = e.getMessage(); + throw new FileViewerException("Audio line not available: " + e.getMessage(), e); + } + } + + private void play() { + if (audioClip != null) { + if (audioClip.isRunning()) { + return; + } + audioClip.start(); + playButton.setEnabled(false); + pauseButton.setEnabled(true); + stopButton.setEnabled(true); + startUpdateThread(); + } + } + + private void pause() { + if (audioClip != null && audioClip.isRunning()) { + audioClip.stop(); + playButton.setEnabled(true); + pauseButton.setEnabled(false); + } + } + + private void stop() { + shouldStop = true; + if (audioClip != null) { + audioClip.stop(); + audioClip.setMicrosecondPosition(0); + } + playButton.setEnabled(true); + pauseButton.setEnabled(false); + stopButton.setEnabled(false); + progressSlider.setValue(0); + timeLabel.setText("00:00 / 00:00"); + } + + private void startUpdateThread() { + if (updateThread == null || !updateThread.isAlive()) { + shouldStop = false; + updateThread = new Thread(this::updateProgress); + updateThread.setDaemon(true); + updateThread.start(); + } + } + + private void updateProgress() { + while (!shouldStop && audioClip != null && audioClip.isRunning()) { + try { + updateProgressDisplay(); + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + updateProgressDisplay(); + } + + private void updateProgressDisplay() { + if (audioClip != null) { + long duration = audioClip.getMicrosecondLength(); + long current = audioClip.getMicrosecondPosition(); + + if (duration > 0) { + int progress = (int) ((current * 100.0) / duration); + progressSlider.setValue(progress); + } + + timeLabel.setText(formatTime(current) + " / " + formatTime(duration)); + } + } + + private String formatTime(long microseconds) { + long seconds = microseconds / 1_000_000; + long minutes = seconds / 60; + long remainingSeconds = seconds % 60; + return String.format("%02d:%02d", minutes, remainingSeconds); + } + + @Override + public JComponent getComponent() { + return panel; + } + + @Override + public boolean canHandle(String filename, String mimeType) { + String lower = (filename != null ? filename.toLowerCase() : ""); + String mime = (mimeType != null ? mimeType.toLowerCase() : ""); + + // Check by extension - Java Sound API natively supports WAV and AU + if (lower.endsWith(".wav") || lower.endsWith(".au")) { + return true; + } + + // Check by MIME type + return mime.equals("audio/wav") || mime.equals("audio/x-wav") || mime.equals("audio/basic"); + } + + @Override + public String getName() { + return "Audio Viewer"; + } + + @Override + public String getVersion() { + return "1.0.0"; + } + + @Override + public void dispose() { + shouldStop = true; + if (audioClip != null) { + audioClip.close(); + audioClip = null; + } + if (updateThread != null) { + try { + updateThread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + currentPath = null; + } +} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewerProvider.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewerProvider.java new file mode 100644 index 00000000..de05a590 --- /dev/null +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewerProvider.java @@ -0,0 +1,28 @@ +package com.basic4gl.language.adapter.content; + +import com.basic4gl.library.fileviewer.FileViewer; +import com.basic4gl.library.fileviewer.FileViewerMetadata; +import com.basic4gl.library.fileviewer.FileViewerProvider; + +/** + * Provider for DefaultAudioViewer + */ +public class DefaultAudioViewerProvider implements FileViewerProvider { + + private static final FileViewerMetadata METADATA = new FileViewerMetadata( + "Audio Viewer", + "1.0.0", + "Default viewer for audio files (WAV, AU)", + new String[] {".wav", ".au"}, + new String[] {"audio/wav", "audio/x-wav", "audio/basic"}); + + @Override + public FileViewer createViewer() { + return new DefaultAudioViewer(); + } + + @Override + public FileViewerMetadata getMetadata() { + return METADATA; + } +} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewer.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewer.java new file mode 100644 index 00000000..68758a01 --- /dev/null +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewer.java @@ -0,0 +1,130 @@ +package com.basic4gl.language.adapter.content; + +import com.basic4gl.library.fileviewer.FileViewer; +import com.basic4gl.library.fileviewer.FileViewerException; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.*; + +/** + * Default image viewer with sane configuration + * + * Features: + * - Supports common image formats (PNG, JPEG, GIF, BMP, etc.) + * - Automatic scaling to fit window + * - Preserves aspect ratio + * - Efficient memory usage + * - Scrollable for large images + */ +public class DefaultImageViewer implements FileViewer { + + private final JPanel panel; + private BufferedImage image; + private Path currentPath; + private String errorMessage = ""; + + public DefaultImageViewer() { + this.panel = new JPanel() { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + if (image != null) { + Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + + int panelWidth = getWidth(); + int panelHeight = getHeight(); + int imgWidth = image.getWidth(); + int imgHeight = image.getHeight(); + + // Calculate scaling to fit panel while preserving aspect ratio + double scale = Math.min((double) panelWidth / imgWidth, (double) panelHeight / imgHeight); + scale = Math.min(scale, 1.0); // Don't upscale + + int scaledWidth = (int) (imgWidth * scale); + int scaledHeight = (int) (imgHeight * scale); + + int x = (panelWidth - scaledWidth) / 2; + int y = (panelHeight - scaledHeight) / 2; + + g2d.drawImage(image, x, y, scaledWidth, scaledHeight, null); + } + } + }; + + panel.setBackground(Color.DARK_GRAY); + panel.setLayout(null); + } + + @Override + public void loadFile(Path path) throws FileViewerException { + try { + if (!Files.exists(path)) { + throw new FileViewerException("File not found: " + path); + } + + byte[] imageBytes = Files.readAllBytes(path); + image = javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(imageBytes)); + + if (image == null) { + throw new FileViewerException("Unable to read image file. Unsupported format?"); + } + + currentPath = path; + errorMessage = ""; + panel.repaint(); + + } catch (Exception e) { + errorMessage = e.getMessage(); + throw new FileViewerException("Failed to load image: " + e.getMessage(), e); + } + } + + @Override + public JComponent getComponent() { + return panel; + } + + @Override + public boolean canHandle(String filename, String mimeType) { + String lower = (filename != null ? filename.toLowerCase() : ""); + String mime = (mimeType != null ? mimeType.toLowerCase() : ""); + + // Check by extension + if (lower.endsWith(".png") + || lower.endsWith(".jpg") + || lower.endsWith(".jpeg") + || lower.endsWith(".gif") + || lower.endsWith(".bmp") + || lower.endsWith(".webp") + || lower.endsWith(".tiff") + || lower.endsWith(".tif") + || lower.endsWith(".ico")) { + return true; + } + + // Check by MIME type + return mime.startsWith("image/"); + } + + @Override + public String getName() { + return "Image Viewer"; + } + + @Override + public String getVersion() { + return "1.0.0"; + } + + @Override + public void dispose() { + if (image != null) { + image.flush(); + image = null; + } + currentPath = null; + } +} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewerProvider.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewerProvider.java new file mode 100644 index 00000000..7856b1e4 --- /dev/null +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewerProvider.java @@ -0,0 +1,30 @@ +package com.basic4gl.language.adapter.content; + +import com.basic4gl.library.fileviewer.FileViewer; +import com.basic4gl.library.fileviewer.FileViewerMetadata; +import com.basic4gl.library.fileviewer.FileViewerProvider; + +/** + * Provider for DefaultImageViewer + */ +public class DefaultImageViewerProvider implements FileViewerProvider { + + private static final FileViewerMetadata METADATA = new FileViewerMetadata( + "Image Viewer", + "1.0.0", + "Default viewer for common image formats", + new String[] {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".ico"}, + new String[] { + "image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp", "image/tiff", "image/x-icon" + }); + + @Override + public FileViewer createViewer() { + return new DefaultImageViewer(); + } + + @Override + public FileViewerMetadata getMetadata() { + return METADATA; + } +} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewer.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewer.java new file mode 100644 index 00000000..75c82bfc --- /dev/null +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewer.java @@ -0,0 +1,111 @@ +package com.basic4gl.language.adapter.content; + +import com.basic4gl.library.fileviewer.FileViewer; +import com.basic4gl.library.fileviewer.FileViewerException; +import java.awt.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.*; + +/** + * Simple text file viewer + * + * Features: + * - Display plain text files + * - Read-only display + * - Syntax-agnostic (works with any text format) + */ +public class SimpleTextViewer implements FileViewer { + + private final JTextArea textArea; + private final JScrollPane scrollPane; + private Path currentPath; + + public SimpleTextViewer() { + this.textArea = new JTextArea(); + this.textArea.setEditable(false); + this.textArea.setFont(new Font("Monospaced", Font.PLAIN, 12)); + this.textArea.setBackground(Color.WHITE); + this.textArea.setForeground(Color.BLACK); + this.textArea.setMargin(new Insets(5, 5, 5, 5)); + this.textArea.setLineWrap(false); + + this.scrollPane = new JScrollPane(textArea); + this.scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS); + } + + @Override + public void loadFile(Path path) throws FileViewerException { + try { + if (!Files.exists(path)) { + throw new FileViewerException("File not found: " + path); + } + + // Limit file size to prevent memory issues (50 MB) + long fileSize = Files.size(path); + if (fileSize > 50 * 1024 * 1024) { + throw new FileViewerException("File too large to display: " + fileSize + " bytes"); + } + + String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + textArea.setText(content); + textArea.setCaretPosition(0); + currentPath = path; + + } catch (FileViewerException e) { + throw e; + } catch (Exception e) { + throw new FileViewerException("Failed to load text file: " + e.getMessage(), e); + } + } + + @Override + public JComponent getComponent() { + return scrollPane; + } + + @Override + public boolean canHandle(String filename, String mimeType) { + String lower = (filename != null ? filename.toLowerCase() : ""); + String mime = (mimeType != null ? mimeType.toLowerCase() : ""); + + // Check by extension + if (lower.endsWith(".txt") + || lower.endsWith(".json") + || lower.endsWith(".xml") + || lower.endsWith(".html") + || lower.endsWith(".htm") + || lower.endsWith(".css") + || lower.endsWith(".java") + || lower.endsWith(".cpp") + || lower.endsWith(".c") + || lower.endsWith(".h") + || lower.endsWith(".py") + || lower.endsWith(".js") + || lower.endsWith(".md") + || lower.endsWith(".config") + || lower.endsWith(".log")) { + return true; + } + + // Check by MIME type + return mime.startsWith("text/"); + } + + @Override + public String getName() { + return "Text Viewer"; + } + + @Override + public String getVersion() { + return "1.0.0"; + } + + @Override + public void dispose() { + textArea.setText(""); + currentPath = null; + } +} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewerProvider.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewerProvider.java new file mode 100644 index 00000000..e252aa61 --- /dev/null +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewerProvider.java @@ -0,0 +1,33 @@ +package com.basic4gl.language.adapter.content; + +import com.basic4gl.library.fileviewer.FileViewer; +import com.basic4gl.library.fileviewer.FileViewerMetadata; +import com.basic4gl.library.fileviewer.FileViewerProvider; + +/** + * Provider for SimpleTextViewer + */ +public class SimpleTextViewerProvider implements FileViewerProvider { + + private static final FileViewerMetadata METADATA = new FileViewerMetadata( + "Text Viewer", + "1.0.0", + "Simple viewer for text-based files", + new String[] { + ".txt", ".json", ".xml", ".html", ".htm", ".css", ".java", ".cpp", ".c", ".h", ".py", ".js", ".md", + ".log" + }, + new String[] { + "text/plain", "text/html", "text/css", "application/json", "application/xml", "text/x-java-source" + }); + + @Override + public FileViewer createViewer() { + return new SimpleTextViewer(); + } + + @Override + public FileViewerMetadata getMetadata() { + return METADATA; + } +} diff --git a/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider b/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider new file mode 100644 index 00000000..1f2bec83 --- /dev/null +++ b/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider @@ -0,0 +1,4 @@ +com.basic4gl.language.adapter.content.DefaultImageViewerProvider +com.basic4gl.language.adapter.content.DefaultAudioViewerProvider +com.basic4gl.language.adapter.content.SimpleTextViewerProvider + From ea542cec8c4ec5ea0fb2b9df8cc6b50e8b6471bc Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sat, 11 Jul 2026 21:55:16 -0400 Subject: [PATCH 12/38] build fixes; refactoring content viewer structure --- .../desktop/spi}/content/DefaultAudioViewer.java | 4 +--- .../spi}/content/DefaultAudioViewerProvider.java | 6 +----- .../desktop/spi}/content/DefaultImageViewer.java | 4 +--- .../spi}/content/DefaultImageViewerProvider.java | 6 +----- .../desktop/spi}/content/FileViewerProvider.java | 3 +-- .../desktop/spi}/content/SimpleTextViewer.java | 4 +--- .../desktop/spi}/content/SimpleTextViewerProvider.java | 6 +----- .../basic4gl/desktop/content/FileViewerRegistry.java | 10 +++++----- .../main/java/com/basic4gl/desktop/editor/ApMode.java | 2 +- .../com/basic4gl/desktop/editor/IEditorPresenter.java | 3 +-- ...c4gl.language.adapter.fileviewer.FileViewerProvider | 6 +++--- 11 files changed, 17 insertions(+), 37 deletions(-) rename {language-adapter/src/main/java/com/basic4gl/language/adapter => app-spi/src/main/java/com/basic4gl/desktop/spi}/content/DefaultAudioViewer.java (98%) rename {language-adapter/src/main/java/com/basic4gl/language/adapter => app-spi/src/main/java/com/basic4gl/desktop/spi}/content/DefaultAudioViewerProvider.java (73%) rename {language-adapter/src/main/java/com/basic4gl/language/adapter => app-spi/src/main/java/com/basic4gl/desktop/spi}/content/DefaultImageViewer.java (96%) rename {language-adapter/src/main/java/com/basic4gl/language/adapter => app-spi/src/main/java/com/basic4gl/desktop/spi}/content/DefaultImageViewerProvider.java (77%) rename {app/src/main/java/com/basic4gl/desktop => app-spi/src/main/java/com/basic4gl/desktop/spi}/content/FileViewerProvider.java (87%) rename {language-adapter/src/main/java/com/basic4gl/language/adapter => app-spi/src/main/java/com/basic4gl/desktop/spi}/content/SimpleTextViewer.java (95%) rename {language-adapter/src/main/java/com/basic4gl/language/adapter => app-spi/src/main/java/com/basic4gl/desktop/spi}/content/SimpleTextViewerProvider.java (79%) diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewer.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewer.java similarity index 98% rename from language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewer.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewer.java index bd1abe54..4036a788 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewer.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewer.java @@ -1,7 +1,5 @@ -package com.basic4gl.language.adapter.content; +package com.basic4gl.desktop.spi.content; -import com.basic4gl.library.fileviewer.FileViewer; -import com.basic4gl.library.fileviewer.FileViewerException; import java.awt.*; import java.io.IOException; import java.nio.file.Files; diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewerProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewerProvider.java similarity index 73% rename from language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewerProvider.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewerProvider.java index de05a590..3632f28d 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultAudioViewerProvider.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewerProvider.java @@ -1,8 +1,4 @@ -package com.basic4gl.language.adapter.content; - -import com.basic4gl.library.fileviewer.FileViewer; -import com.basic4gl.library.fileviewer.FileViewerMetadata; -import com.basic4gl.library.fileviewer.FileViewerProvider; +package com.basic4gl.desktop.spi.content; /** * Provider for DefaultAudioViewer diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewer.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewer.java similarity index 96% rename from language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewer.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewer.java index 68758a01..79e5e636 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewer.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewer.java @@ -1,7 +1,5 @@ -package com.basic4gl.language.adapter.content; +package com.basic4gl.desktop.spi.content; -import com.basic4gl.library.fileviewer.FileViewer; -import com.basic4gl.library.fileviewer.FileViewerException; import java.awt.*; import java.awt.image.BufferedImage; import java.nio.file.Files; diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewerProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewerProvider.java similarity index 77% rename from language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewerProvider.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewerProvider.java index 7856b1e4..febe2adb 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/DefaultImageViewerProvider.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewerProvider.java @@ -1,8 +1,4 @@ -package com.basic4gl.language.adapter.content; - -import com.basic4gl.library.fileviewer.FileViewer; -import com.basic4gl.library.fileviewer.FileViewerMetadata; -import com.basic4gl.library.fileviewer.FileViewerProvider; +package com.basic4gl.desktop.spi.content; /** * Provider for DefaultImageViewer diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java similarity index 87% rename from app/src/main/java/com/basic4gl/desktop/content/FileViewerProvider.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java index 9e8b6931..ddc9710a 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileViewerProvider.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java @@ -1,6 +1,5 @@ -package com.basic4gl.desktop.content; +package com.basic4gl.desktop.spi.content; -import com.basic4gl.desktop.spi.content.FileViewer; /** * Provider interface for creating FileViewer instances diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewer.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewer.java similarity index 95% rename from language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewer.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewer.java index 75c82bfc..681185e4 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewer.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewer.java @@ -1,7 +1,5 @@ -package com.basic4gl.language.adapter.content; +package com.basic4gl.desktop.spi.content; -import com.basic4gl.library.fileviewer.FileViewer; -import com.basic4gl.library.fileviewer.FileViewerException; import java.awt.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewerProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewerProvider.java similarity index 79% rename from language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewerProvider.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewerProvider.java index e252aa61..c0ef9baf 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/content/SimpleTextViewerProvider.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewerProvider.java @@ -1,8 +1,4 @@ -package com.basic4gl.language.adapter.content; - -import com.basic4gl.library.fileviewer.FileViewer; -import com.basic4gl.library.fileviewer.FileViewerMetadata; -import com.basic4gl.library.fileviewer.FileViewerProvider; +package com.basic4gl.desktop.spi.content; /** * Provider for SimpleTextViewer diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java index 0c799d67..af1317b1 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java @@ -14,7 +14,7 @@ */ public class FileViewerRegistry { - private final Map providers = new HashMap<>(); + private final Map providers = new HashMap<>(); private String lastError = ""; /** @@ -25,9 +25,9 @@ public void initialize() { lastError = ""; try { - ServiceLoader loader = ServiceLoader.load(FileViewerProvider.class); + ServiceLoader loader = ServiceLoader.load(com.basic4gl.desktop.spi.content.FileViewerProvider.class); - for (FileViewerProvider provider : loader) { + for (com.basic4gl.desktop.spi.content.FileViewerProvider provider : loader) { com.basic4gl.desktop.spi.content.FileViewerMetadata metadata = provider.getMetadata(); if (metadata != null && metadata.getName() != null) { providers.put(metadata.getName(), provider); @@ -48,7 +48,7 @@ public FileViewerResult findViewer(Path filepath) { String extension = getFileExtension(filename); String mimeType = guessMimeType(filename); - for (FileViewerProvider provider : providers.values()) { + for (com.basic4gl.desktop.spi.content.FileViewerProvider provider : providers.values()) { com.basic4gl.desktop.spi.content.FileViewerMetadata metadata = provider.getMetadata(); if (metadata != null) { if ((extension != null && metadata.supportsExtension(extension)) @@ -73,7 +73,7 @@ public FileViewerResult findViewer(Path filepath) { */ public List getAvailableViewers() { return providers.values().stream() - .map(FileViewerProvider::getMetadata) + .map(com.basic4gl.desktop.spi.content.FileViewerProvider::getMetadata) .filter(Objects::nonNull) .collect(Collectors.toList()); } diff --git a/app/src/main/java/com/basic4gl/desktop/editor/ApMode.java b/app/src/main/java/com/basic4gl/desktop/editor/ApMode.java index 15bf9534..bb01a4d0 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/ApMode.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/ApMode.java @@ -1,6 +1,6 @@ package com.basic4gl.desktop.editor; -enum ApMode { +public enum ApMode { AP_CLOSED, AP_STOPPED, AP_WAITING, diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java index 7b709bb1..fb12a850 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java @@ -3,12 +3,11 @@ import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; -import com.basic4gl.desktop.ApMode; import java.io.File; import java.util.List; -interface IEditorPresenter { +public interface IEditorPresenter { void onModeChanged(ApMode mode, String statusMsg); void onCompileSucceeded(); diff --git a/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider b/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider index 1f2bec83..64dfae9d 100644 --- a/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider +++ b/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider @@ -1,4 +1,4 @@ -com.basic4gl.language.adapter.content.DefaultImageViewerProvider -com.basic4gl.language.adapter.content.DefaultAudioViewerProvider -com.basic4gl.language.adapter.content.SimpleTextViewerProvider +com.basic4gl.desktop.spi.content.DefaultImageViewerProvider +com.basic4gl.desktop.spi.content.DefaultAudioViewerProvider +com.basic4gl.desktop.spi.content.SimpleTextViewerProvider From dcbc47c40d75f3e2c91054c4138e4b0eee78761a Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sat, 11 Jul 2026 23:06:20 -0400 Subject: [PATCH 13/38] moving antlr into the language adapter project - added spotless --- .../spi/content/FileViewerProvider.java | 2 - .../desktop/spi}/language/HighlightKind.java | 4 +- .../desktop/spi}/language/IndexedSymbol.java | 4 +- .../desktop/spi}/language/LangToken.java | 6 +- .../spi}/language/LanguageSupport.java | 2 +- .../spi}/language/SymbolDeclaration.java | 2 +- app/build.gradle | 18 -- .../java/com/basic4gl/desktop/MainWindow.java | 63 +++--- .../desktop/content/FileViewerManager.java | 2 - .../desktop/content/FileViewerRegistry.java | 7 +- .../desktop/editor/BasicTokenMaker.java | 2 +- .../desktop/editor/IEditorPresenter.java | 1 - .../editor/LanguageSupportTokenMaker.java | 6 +- .../desktop/language/Basic4GLFoldParser.java | 3 +- .../desktop/language/SymbolIndexer.java | 10 +- language-adapter/build.gradle | 37 +++- .../src/main/antlr/Basic4GL.g4 | 0 .../adapter/Basic4GLCompilerService.java | 10 +- .../adapter/Basic4GLDebugService.java | 196 +++++++++--------- .../adapter/Basic4GLEditorPluginAdapter.java | 25 ++- .../adapter/Basic4GLLanguageService.java | 71 +++---- .../adapter}/Basic4GLLanguageSupport.java | 10 +- .../adapter/Basic4GLPreprocessorService.java | 5 +- .../language/adapter/BuilderDesktopGL.java | 109 +++++----- .../language/adapter/ConfigurationMapper.java | 43 ++-- .../language/adapter/DesktopTarget.java | 23 +- .../language/adapter/EditorAdapter.java | 3 +- .../language/adapter/EmbeddedFile.java | 3 - .../language/adapter/FileOpenerAdapter.java | 6 - .../language/adapter/FileServiceAdapter.java | 2 + .../adapter/PluginExportProjectPage.java | 13 +- .../PluginManagerProjectSettingsPage.java | 82 ++++---- .../language/adapter/util/LanguageUtil.java | 22 +- .../adapter/ConfigurationMapperTest.java | 3 +- 34 files changed, 393 insertions(+), 402 deletions(-) rename {app/src/main/java/com/basic4gl/desktop => app-spi/src/main/java/com/basic4gl/desktop/spi}/language/HighlightKind.java (89%) rename {app/src/main/java/com/basic4gl/desktop => app-spi/src/main/java/com/basic4gl/desktop/spi}/language/IndexedSymbol.java (65%) rename {app/src/main/java/com/basic4gl/desktop => app-spi/src/main/java/com/basic4gl/desktop/spi}/language/LangToken.java (73%) rename {app/src/main/java/com/basic4gl/desktop => app-spi/src/main/java/com/basic4gl/desktop/spi}/language/LanguageSupport.java (98%) rename {app/src/main/java/com/basic4gl/desktop => app-spi/src/main/java/com/basic4gl/desktop/spi}/language/SymbolDeclaration.java (95%) rename {app => language-adapter}/src/main/antlr/Basic4GL.g4 (100%) rename {app/src/main/java/com/basic4gl/desktop/language => language-adapter/src/main/java/com/basic4gl/language/adapter}/Basic4GLLanguageSupport.java (98%) diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java index ddc9710a..e740a738 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewerProvider.java @@ -1,6 +1,5 @@ package com.basic4gl.desktop.spi.content; - /** * Provider interface for creating FileViewer instances * @@ -21,4 +20,3 @@ public interface FileViewerProvider { */ com.basic4gl.desktop.spi.content.FileViewerMetadata getMetadata(); } - diff --git a/app/src/main/java/com/basic4gl/desktop/language/HighlightKind.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/HighlightKind.java similarity index 89% rename from app/src/main/java/com/basic4gl/desktop/language/HighlightKind.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/language/HighlightKind.java index b7cdfa58..1d659e63 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/HighlightKind.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/HighlightKind.java @@ -1,9 +1,9 @@ -package com.basic4gl.desktop.language; +package com.basic4gl.desktop.spi.language; /** * Language-neutral semantic categories used to classify tokens for syntax highlighting. * - *

A {@link LanguageSupport} implementation maps its internal token types to these categories. + *

A {@link com.basic4gl.desktop.spi.language.LanguageSupport} implementation maps its internal token types to these categories. * An IDE adapter (e.g. {@code LanguageSupportTokenMaker} for RSyntaxTextArea) then maps these * categories to the syntax-highlighting primitives of whichever UI toolkit it targets. * diff --git a/app/src/main/java/com/basic4gl/desktop/language/IndexedSymbol.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/IndexedSymbol.java similarity index 65% rename from app/src/main/java/com/basic4gl/desktop/language/IndexedSymbol.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/language/IndexedSymbol.java index 8eabc9d5..f791a6a8 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/IndexedSymbol.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/IndexedSymbol.java @@ -1,7 +1,7 @@ -package com.basic4gl.desktop.language; +package com.basic4gl.desktop.spi.language; /** - * A user-defined symbol discovered by {@link LanguageSupport#extractSymbols}. + * A user-defined symbol discovered by {@link com.basic4gl.desktop.spi.language.LanguageSupport#extractSymbols}. * * @param kind One of {@code "userfunc"}, {@code "label"}, or {@code "variable"}. * @param name The bare symbol name (no punctuation). diff --git a/app/src/main/java/com/basic4gl/desktop/language/LangToken.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LangToken.java similarity index 73% rename from app/src/main/java/com/basic4gl/desktop/language/LangToken.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/language/LangToken.java index 50182b45..0c398d0b 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/LangToken.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LangToken.java @@ -1,7 +1,7 @@ -package com.basic4gl.desktop.language; +package com.basic4gl.desktop.spi.language; /** - * An immutable, language-neutral token produced by {@link LanguageSupport#tokenizeLine}. + * An immutable, language-neutral token produced by {@link com.basic4gl.desktop.spi.language.LanguageSupport#tokenizeLine}. * *

Positions are 0-based character offsets within the line string passed to * {@code tokenizeLine}: @@ -13,7 +13,7 @@ * *

The {@link #type()} field carries the implementation-specific integer token type (e.g. an * ANTLR token type constant). It is opaque to callers; use - * {@link LanguageSupport#classify(LangToken)} to obtain the portable {@link HighlightKind}. + * {@link com.basic4gl.desktop.spi.language.LanguageSupport#classify(LangToken)} to obtain the portable {@link HighlightKind}. */ public record LangToken(int type, String text, int start, int end) { diff --git a/app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java similarity index 98% rename from app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java index 3b257734..2f1857d8 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/LanguageSupport.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java @@ -1,4 +1,4 @@ -package com.basic4gl.desktop.language; +package com.basic4gl.desktop.spi.language; import java.util.List; diff --git a/app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/SymbolDeclaration.java similarity index 95% rename from app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java rename to app-spi/src/main/java/com/basic4gl/desktop/spi/language/SymbolDeclaration.java index a7080499..ec06821e 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/SymbolDeclaration.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/SymbolDeclaration.java @@ -1,4 +1,4 @@ -package com.basic4gl.desktop.language; +package com.basic4gl.desktop.spi.language; /** * A concrete declaration site discovered by a language support implementation. diff --git a/app/build.gradle b/app/build.gradle index 5f1b91a9..bb42471b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,20 +3,6 @@ plugins { id("com.diffplug.spotless") version "7.0.2" } apply plugin: "java" -apply plugin: "antlr" - -// Generate ANTLR lexer sources into the correct package directory so that -// IDEs resolve the generated class immediately after the first build. -generateGrammarSource { - maxHeapSize = "64m" - arguments += ["-package", "com.basic4gl.desktop.language", "-long-messages"] - // Keep generated sources alongside other build outputs; Spotless ignores build/ - outputDirectory = - file("${project.buildDir}/generated-src/antlr/main/com/basic4gl/desktop/language") -} -// Spotless must not try to format ANTLR-generated sources. -// The spotless java {} block is configured later in the file; we override target here. - configurations { release.extendsFrom configurations.default } @@ -74,10 +60,6 @@ startScripts { } dependencies { - // ANTLR4: code generator (build-time only) + runtime JAR shipped with the app - antlr "org.antlr:antlr4:4.13.2" - implementation "org.antlr:antlr4-runtime:4.13.2" - implementation fileTree(dir: "libs", include: ["*.jar"]) // TODO figure out a clean way to depend on the JAR output of these projects; diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index ea38d501..19aba592 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -15,6 +15,7 @@ import com.basic4gl.desktop.language.SymbolIndexer; import com.basic4gl.desktop.spi.*; import com.basic4gl.desktop.spi.language.FunctionDefinition; +import com.basic4gl.desktop.spi.language.IndexedSymbol; import com.basic4gl.desktop.spi.language.LabelDefinition; import com.basic4gl.desktop.spi.language.VariableDefinition; import com.basic4gl.desktop.vmview.DebugControlsListener; @@ -55,7 +56,7 @@ public class MainWindow ITabProvider, IToggleBreakpointListener, IFileEditorActionListener, - IFileManagerListener, + IFileManagerListener, EmptyTabPanel.IEmptyTabPanelListener, MenuService { @@ -134,8 +135,8 @@ public void caretUpdate(CaretEvent e) { private String referenceDetailsHtml = REFERENCE_SELECT_PROMPT_HTML; private final java.util.List allReferenceItems = new ArrayList<>(); // Language support is shared between the symbol indexer and (via BasicTokenMaker) the editor. - private final com.basic4gl.desktop.language.LanguageSupport languageSupport = - new com.basic4gl.desktop.language.Basic4GLLanguageSupport(); + private final com.basic4gl.desktop.spi.language.LanguageSupport languageSupport = + new com.basic4gl.language.adapter.Basic4GLLanguageSupport(); private final SymbolIndexer symbolIndexer = new SymbolIndexer(languageSupport, this::collectAllSourceText, this::updateProgramSymbols); private int lastProgramSymbolsFingerprint = Integer.MIN_VALUE; @@ -952,9 +953,8 @@ public void setRecentItems(List files) { workspacesHeader.setEnabled(false); recentSubMenu.add(workspacesHeader); for (File workspace : recentWorkspaces) { - JMenuItem workspaceItem = new JMenuItem(workspace.getName().isBlank() - ? workspace.getAbsolutePath() - : workspace.getName()); + JMenuItem workspaceItem = new JMenuItem( + workspace.getName().isBlank() ? workspace.getAbsolutePath() : workspace.getName()); workspaceItem.setToolTipText(workspace.getAbsolutePath()); workspaceItem.addActionListener(e -> setWorkspaceDirectory(workspace)); recentSubMenu.add(workspaceItem); @@ -1392,8 +1392,9 @@ private void actionGoToDeclaration() { } catch (BadLocationException ignored) { } - java.util.List declarations = collectOpenFileDeclarations(); - java.util.List matches = declarations.stream() + java.util.List declarations = + collectOpenFileDeclarations(); + java.util.List matches = declarations.stream() .filter(d -> ("label".equals(d.kind()) || "variable".equals(d.kind())) && d.name().equalsIgnoreCase(symbol)) .toList(); @@ -1403,7 +1404,7 @@ private void actionGoToDeclaration() { return; } - com.basic4gl.desktop.language.SymbolDeclaration selected = + com.basic4gl.desktop.spi.language.SymbolDeclaration selected = chooseDeclarationForCaret(matches, activeFile, caretLine); if (selected == null) { return; @@ -1420,8 +1421,8 @@ private void actionGoToDeclaration() { setCompilerStatus("Declaration: " + selected.signature()); } - private java.util.List collectOpenFileDeclarations() { - java.util.List declarations = new ArrayList<>(); + private java.util.List collectOpenFileDeclarations() { + java.util.List declarations = new ArrayList<>(); for (FileEditor editor : fileManager.getFileEditors()) { String fileId = editor.getFilePath(); if (fileId == null || fileId.isBlank()) { @@ -1434,17 +1435,19 @@ private java.util.List collectO return declarations; } - private com.basic4gl.desktop.language.SymbolDeclaration chooseDeclarationForCaret( - java.util.List matches, String activeFile, int caretLine) { - java.util.List sameFile = matches.stream() + private com.basic4gl.desktop.spi.language.SymbolDeclaration chooseDeclarationForCaret( + java.util.List matches, + String activeFile, + int caretLine) { + java.util.List sameFile = matches.stream() .filter(d -> Objects.equals(d.fileId(), activeFile)) .toList(); - java.util.List candidates = + java.util.List candidates = sameFile.isEmpty() ? matches : sameFile; - com.basic4gl.desktop.language.SymbolDeclaration best = null; + com.basic4gl.desktop.spi.language.SymbolDeclaration best = null; int bestScore = Integer.MAX_VALUE; - for (com.basic4gl.desktop.language.SymbolDeclaration candidate : candidates) { + for (com.basic4gl.desktop.spi.language.SymbolDeclaration candidate : candidates) { int score = declarationScore(candidate, activeFile, caretLine); if (score < bestScore) { bestScore = score; @@ -1455,7 +1458,7 @@ private com.basic4gl.desktop.language.SymbolDeclaration chooseDeclarationForCare } private int declarationScore( - com.basic4gl.desktop.language.SymbolDeclaration declaration, String activeFile, int caretLine) { + com.basic4gl.desktop.spi.language.SymbolDeclaration declaration, String activeFile, int caretLine) { int score = 0; if (!Objects.equals(declaration.fileId(), activeFile)) { score += 1_000_000; @@ -1471,9 +1474,9 @@ private int declarationScore( return score; } - private com.basic4gl.desktop.language.SymbolDeclaration promptUserForDeclaration( - java.util.List matches, - com.basic4gl.desktop.language.SymbolDeclaration preferred) { + private com.basic4gl.desktop.spi.language.SymbolDeclaration promptUserForDeclaration( + java.util.List matches, + com.basic4gl.desktop.spi.language.SymbolDeclaration preferred) { Object[] options = matches.stream().map(this::formatDeclarationChoice).toArray(); Object initial = preferred != null ? formatDeclarationChoice(preferred) : (options.length > 0 ? options[0] : null); @@ -1489,7 +1492,7 @@ private com.basic4gl.desktop.language.SymbolDeclaration promptUserForDeclaration return null; } String selectedText = selected.toString(); - for (com.basic4gl.desktop.language.SymbolDeclaration declaration : matches) { + for (com.basic4gl.desktop.spi.language.SymbolDeclaration declaration : matches) { if (formatDeclarationChoice(declaration).equals(selectedText)) { return declaration; } @@ -1497,7 +1500,7 @@ private com.basic4gl.desktop.language.SymbolDeclaration promptUserForDeclaration return preferred; } - private String formatDeclarationChoice(com.basic4gl.desktop.language.SymbolDeclaration declaration) { + private String formatDeclarationChoice(com.basic4gl.desktop.spi.language.SymbolDeclaration declaration) { String fileLabel = declaration.fileId(); File f = fileLabel == null ? null : new File(fileLabel); if (f != null && f.getName() != null && !f.getName().isBlank()) { @@ -1507,7 +1510,7 @@ private String formatDeclarationChoice(com.basic4gl.desktop.language.SymbolDecla + ")"; } - private void goToDeclarationLocation(com.basic4gl.desktop.language.SymbolDeclaration declaration) { + private void goToDeclarationLocation(com.basic4gl.desktop.spi.language.SymbolDeclaration declaration) { String filePath = declaration.fileId(); int index = getTabIndex(filePath); if (index == -1 && filePath != null && !filePath.startsWith(" list, Object value, int index, boolean isSelected, boolean cellHasFocus) { - JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); if (value instanceof AssetItem item) { label.setText("

" + escapeHtml(item.title) + "
"); label.setIcon(getAssetGridIcon(item)); @@ -3494,9 +3497,9 @@ private String collectAllSourceText() { * Replaces all "Program" (user-defined) reference items with the freshly scanned symbols and * refreshes the reference panel. */ - private void updateProgramSymbols(List symbols) { + private void updateProgramSymbols(List symbols) { int fingerprint = 1; - for (com.basic4gl.desktop.language.IndexedSymbol symbol : symbols) { + for (IndexedSymbol symbol : symbols) { fingerprint = 31 * fingerprint + Objects.hash(symbol.kind(), symbol.name(), symbol.signature()); } if (fingerprint == lastProgramSymbolsFingerprint) { @@ -3508,7 +3511,7 @@ private void updateProgramSymbols(List "Program".equals(item.library)); // Add newly scanned symbols - for (com.basic4gl.desktop.language.IndexedSymbol sym : symbols) { + for (IndexedSymbol sym : symbols) { String details; String insertText; int caretOffset; diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java index d5fae577..a482624c 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java @@ -1,7 +1,6 @@ package com.basic4gl.desktop.content; import com.basic4gl.desktop.spi.content.FileViewerException; - import java.nio.file.Path; /** @@ -113,4 +112,3 @@ private static String getFileExtension(String filename) { return null; } } - diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java index af1317b1..3d9e033c 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerRegistry.java @@ -1,7 +1,6 @@ package com.basic4gl.desktop.content; import com.basic4gl.desktop.spi.content.FileViewer; - import java.nio.file.Path; import java.util.*; import java.util.stream.Collectors; @@ -25,7 +24,8 @@ public void initialize() { lastError = ""; try { - ServiceLoader loader = ServiceLoader.load(com.basic4gl.desktop.spi.content.FileViewerProvider.class); + ServiceLoader loader = + ServiceLoader.load(com.basic4gl.desktop.spi.content.FileViewerProvider.class); for (com.basic4gl.desktop.spi.content.FileViewerProvider provider : loader) { com.basic4gl.desktop.spi.content.FileViewerMetadata metadata = provider.getMetadata(); @@ -161,7 +161,8 @@ public FileViewerResult(FileViewer viewer, com.basic4gl.desktop.spi.content.File this(viewer, metadata, null); } - public FileViewerResult(FileViewer viewer, com.basic4gl.desktop.spi.content.FileViewerMetadata metadata, String error) { + public FileViewerResult( + FileViewer viewer, com.basic4gl.desktop.spi.content.FileViewerMetadata metadata, String error) { this.viewer = viewer; this.metadata = metadata; this.error = error; diff --git a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java index 5cba67c7..9c54baa1 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java @@ -1,6 +1,6 @@ package com.basic4gl.desktop.editor; -import com.basic4gl.desktop.language.Basic4GLLanguageSupport; +import com.basic4gl.language.adapter.Basic4GLLanguageSupport; import java.util.ArrayList; import java.util.List; import org.fife.ui.rsyntaxtextarea.Token; diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java index fb12a850..47e01487 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java @@ -3,7 +3,6 @@ import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; - import java.io.File; import java.util.List; diff --git a/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java b/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java index 99b66f25..6eb38123 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java @@ -1,8 +1,8 @@ package com.basic4gl.desktop.editor; -import com.basic4gl.desktop.language.HighlightKind; -import com.basic4gl.desktop.language.LangToken; -import com.basic4gl.desktop.language.LanguageSupport; +import com.basic4gl.desktop.spi.language.HighlightKind; +import com.basic4gl.desktop.spi.language.LangToken; +import com.basic4gl.desktop.spi.language.LanguageSupport; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java index 604c27dc..2d1b6bc5 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java +++ b/app/src/main/java/com/basic4gl/desktop/language/Basic4GLFoldParser.java @@ -1,5 +1,6 @@ package com.basic4gl.desktop.language; +import com.basic4gl.language.adapter.antlr.Basic4GL; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; @@ -30,7 +31,7 @@ *
  • {@code label: ... next label:} (labels as implicit scopes)
  • * * - *

    Uses the same ANTLR lexer as {@link Basic4GLLanguageSupport} to ensure consistent + *

    Uses the same ANTLR lexer as {@link com.basic4gl.language.adapter.Basic4GLLanguageSupport} to ensure consistent * tokenization, correctly handling comments and strings. * */ diff --git a/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java index 2113b3e3..f9abc5f9 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java +++ b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java @@ -1,5 +1,6 @@ package com.basic4gl.desktop.language; +import com.basic4gl.desktop.spi.language.IndexedSymbol; import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.concurrent.Executors; @@ -13,7 +14,7 @@ * Lightweight debounced symbol indexer. * *

    Listens for source-text changes and, after a short debounce delay, delegates symbol - * extraction to a {@link LanguageSupport} instance. Results are delivered via a {@link Callback} + * extraction to a {@link com.basic4gl.desktop.spi.language.LanguageSupport} instance. Results are delivered via a {@link Callback} * on the Swing EDT. * *

    The indexer itself contains no language-specific logic; all parsing is @@ -45,7 +46,7 @@ public interface Callback { /** Milliseconds to wait after the last change before running extraction. */ private static final long DEBOUNCE_MILLIS = 400; - private final LanguageSupport languageSupport; + private final com.basic4gl.desktop.spi.language.LanguageSupport languageSupport; private final SourceProvider sourceProvider; private final Callback callback; @@ -58,7 +59,10 @@ public interface Callback { private ScheduledFuture pending; private long requestedRevision = 0; - public SymbolIndexer(LanguageSupport languageSupport, SourceProvider sourceProvider, Callback callback) { + public SymbolIndexer( + com.basic4gl.desktop.spi.language.LanguageSupport languageSupport, + SourceProvider sourceProvider, + Callback callback) { this.languageSupport = languageSupport; this.sourceProvider = sourceProvider; this.callback = callback; diff --git a/language-adapter/build.gradle b/language-adapter/build.gradle index 2bd78684..f9985e64 100644 --- a/language-adapter/build.gradle +++ b/language-adapter/build.gradle @@ -3,6 +3,8 @@ plugins { id("com.diffplug.spotless") version "7.0.2" } +apply plugin: "antlr" + group = 'com.basic4gl' version = '1.0-SNAPSHOT' @@ -10,7 +12,21 @@ repositories { mavenCentral() } +// Generate ANTLR lexer sources into the correct package directory so that +// IDEs resolve the generated class immediately after the first build. +generateGrammarSource { + maxHeapSize = "64m" + arguments += ["-package", "com.basic4gl.language.adapter.antlr", "-long-messages"] + // Keep generated sources alongside other build outputs; Spotless ignores build/ + outputDirectory = + file("${project.buildDir}/generated-src/antlr/main/com/basic4gl/language/adapter/antlr") +} + dependencies { + // ANTLR4: code generator (build-time only) + runtime JAR shipped with the app + antlr "org.antlr:antlr4:4.13.2" + implementation "org.antlr:antlr4-runtime:4.13.2" + implementation project(":app-runtime") implementation project(":app-spi") implementation project(":language-core") @@ -30,4 +46,23 @@ dependencies { test { useJUnitPlatform() -} \ No newline at end of file +} + +spotless { + java { + // Use the default importOrder configuration + importOrder() + + removeUnusedImports() + palantirJavaFormat('2.50.0').formatJavadoc(false) + + formatAnnotations() + trimTrailingWhitespace() + endWithNewline() + + // Exclude ANTLR-generated sources from formatting checks. + targetExclude fileTree("${project.buildDir}/generated-src") + } +} + +build.dependsOn spotlessApply \ No newline at end of file diff --git a/app/src/main/antlr/Basic4GL.g4 b/language-adapter/src/main/antlr/Basic4GL.g4 similarity index 100% rename from app/src/main/antlr/Basic4GL.g4 rename to language-adapter/src/main/antlr/Basic4GL.g4 diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLCompilerService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLCompilerService.java index f55d0cae..7e16cd6e 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLCompilerService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLCompilerService.java @@ -1,6 +1,5 @@ package com.basic4gl.language.adapter; -import com.basic4gl.app.desktop.GLTextGridWindow; import com.basic4gl.compiler.Preprocessor; import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.compiler.types.LanguageSyntax; @@ -13,7 +12,6 @@ import com.basic4gl.language.core.runtime.IServiceCollection; import com.basic4gl.library.desktopgl.content.FileOpener; import com.basic4gl.library.desktopgl.content.IFileAccess; - import java.util.ArrayList; import java.util.List; @@ -66,8 +64,8 @@ public void onLoad(PluginContext context) { libraries.add(new com.basic4gl.library.desktopgl.SoundBasicLib()); libraries.add(new com.basic4gl.library.standard.TomCompilerBasicLib()); -// targets.add(GLTextGridWindow.getInstance(compiler)); -// builders.add(BuilderDesktopGL.getInstance(compiler)); + // targets.add(GLTextGridWindow.getInstance(compiler)); + // builders.add(BuilderDesktopGL.getInstance(compiler)); FileOpener fileOpener = new FileOpener(context.files().getParentDirectory()); // TODO Add more libraries @@ -87,9 +85,7 @@ public void onLoad(PluginContext context) { } @Override - public void onUnload() { - - } + public void onUnload() {} @Override public void compile() { diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLDebugService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLDebugService.java index 95452f53..4b28a9da 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLDebugService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLDebugService.java @@ -2,14 +2,12 @@ import com.basic4gl.compiler.Preprocessor; import com.basic4gl.compiler.TomBasicCompiler; -import com.basic4gl.desktop.spi.IProcessExitListener; -import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.spi.Builder; +import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.spi.Target; import com.basic4gl.language.core.extensions.IAppSettings; import com.basic4gl.library.desktopgl.util.ITargetCommandLineOptions; import com.basic4gl.runtime.Debugger; - import java.io.*; import java.net.ServerSocket; import java.nio.file.Path; @@ -38,7 +36,8 @@ public class Basic4GLDebugService implements com.basic4gl.desktop.spi.DebugServi private IAppSettings appSettings; public Debugger debugger; - Basic4GLDebugService(TomBasicCompiler compiler, Preprocessor preprocessor, IAppSettings appSettings, Debugger debugger) { + Basic4GLDebugService( + TomBasicCompiler compiler, Preprocessor preprocessor, IAppSettings appSettings, Debugger debugger) { this.compiler = compiler; this.preprocessor = preprocessor; this.appSettings = appSettings; @@ -58,106 +57,107 @@ public Integer getPermanent() { @Override public com.basic4gl.desktop.spi.DebugLaunchInfo start(Object sender) { try { - Path tempFolder = - Paths.get(System.getProperty("java.io.tmpdir")); // Files.createDirectories(Paths.get("temp")); - File vm = File.createTempFile("basicvm-", "", tempFolder.toFile()); - File config = File.createTempFile("basicconfig-", "", tempFolder.toFile()); - File lineMapping = File.createTempFile("basiclinemapping-", "", tempFolder.toFile()); - - String currentDirectory = context.currentDirectory(); - Builder builder = context.currentBuilder(); - String libraryBinPath = context.getLibraryPath(); - boolean isMacOS = context.isMacOS(); - String defaultDebugPort = context.getDefaultDebuggerPort(); - - try (DataOutputStream outputStream = new DataOutputStream(new FileOutputStream(vm))) { - compiler.streamOut(outputStream); - } catch (Exception e) { - e.printStackTrace(); - } - - try (OutputStream outputStream = new FileOutputStream(config)) { - (context.currentBuilder()).getTarget().saveConfiguration(outputStream); - } catch (Exception e) { - e.printStackTrace(); - } - - try (FileOutputStream outputStream = new FileOutputStream(lineMapping); - ObjectOutputStream oos = new ObjectOutputStream(outputStream)) { - oos.writeObject(preprocessor.getLineNumberMap()); - } catch (Exception e) { - e.printStackTrace(); - } + Path tempFolder = + Paths.get(System.getProperty("java.io.tmpdir")); // Files.createDirectories(Paths.get("temp")); + File vm = File.createTempFile("basicvm-", "", tempFolder.toFile()); + File config = File.createTempFile("basicconfig-", "", tempFolder.toFile()); + File lineMapping = File.createTempFile("basiclinemapping-", "", tempFolder.toFile()); + + String currentDirectory = context.currentDirectory(); + Builder builder = context.currentBuilder(); + String libraryBinPath = context.getLibraryPath(); + boolean isMacOS = context.isMacOS(); + String defaultDebugPort = context.getDefaultDebuggerPort(); + + try (DataOutputStream outputStream = new DataOutputStream(new FileOutputStream(vm))) { + compiler.streamOut(outputStream); + } catch (Exception e) { + e.printStackTrace(); + } - String[] commandArgs = buildCommandArgs( - builder.getTarget(), - appSettings, - currentDirectory, - libraryBinPath, - vm.getAbsolutePath(), - config.getAbsolutePath(), - lineMapping.getAbsolutePath(), - defaultDebugPort, - isMacOS); - String jvmDebugArgs = findJvmDebugArgs(commandArgs); - clearCapturedStderr(); - - // Start output window - final Process process = new ProcessBuilder(commandArgs).start(); - synchronized (launchedProcessLock) { - launchedProcess = process; - } + try (OutputStream outputStream = new FileOutputStream(config)) { + (context.currentBuilder()).getTarget().saveConfiguration(outputStream); + } catch (Exception e) { + e.printStackTrace(); + } - process.onExit().thenAccept(exitedProcess -> { - com.basic4gl.desktop.spi.IProcessExitListener listener = this.processExitListener; - if (listener != null) { - listener.onProcessExited(sender, exitedProcess.exitValue(), getCapturedStderr()); + try (FileOutputStream outputStream = new FileOutputStream(lineMapping); + ObjectOutputStream oos = new ObjectOutputStream(outputStream)) { + oos.writeObject(preprocessor.getLineNumberMap()); + } catch (Exception e) { + e.printStackTrace(); } - }); - - // Automatically close GL window when editor closes - Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { - @Override - public void run() { - System.out.println("Shutdown Hook"); - process.destroy(); + + String[] commandArgs = buildCommandArgs( + builder.getTarget(), + appSettings, + currentDirectory, + libraryBinPath, + vm.getAbsolutePath(), + config.getAbsolutePath(), + lineMapping.getAbsolutePath(), + defaultDebugPort, + isMacOS); + String jvmDebugArgs = findJvmDebugArgs(commandArgs); + clearCapturedStderr(); + + // Start output window + final Process process = new ProcessBuilder(commandArgs).start(); + synchronized (launchedProcessLock) { + launchedProcess = process; } - })); - - // Handle output from GL window - final BufferedReader errinput = new BufferedReader(new InputStreamReader(process.getErrorStream())); - final BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream())); - Thread thread = new Thread(new Runnable() { - @Override - public void run() { - try { - String err; - while ((err = errinput.readLine()) != null) { - captureStderrLine(err); - System.err.println(err); + + process.onExit().thenAccept(exitedProcess -> { + com.basic4gl.desktop.spi.IProcessExitListener listener = this.processExitListener; + if (listener != null) { + listener.onProcessExited(sender, exitedProcess.exitValue(), getCapturedStderr()); + } + }); + + // Automatically close GL window when editor closes + Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { + @Override + public void run() { + System.out.println("Shutdown Hook"); + process.destroy(); + } + })); + + // Handle output from GL window + final BufferedReader errinput = new BufferedReader(new InputStreamReader(process.getErrorStream())); + final BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream())); + Thread thread = new Thread(new Runnable() { + @Override + public void run() { + try { + String err; + while ((err = errinput.readLine()) != null) { + captureStderrLine(err); + System.err.println(err); + } + } catch (IOException e) { + e.printStackTrace(); } - } catch (IOException e) { - e.printStackTrace(); } - } - }); - thread.start(); - thread = new Thread(new Runnable() { - @Override - public void run() { - try { - String err; - while ((err = input.readLine()) != null) { - System.out.println(err); + }); + thread.start(); + thread = new Thread(new Runnable() { + @Override + public void run() { + try { + String err; + while ((err = input.readLine()) != null) { + System.out.println(err); + } + } catch (IOException e) { + e.printStackTrace(); } - } catch (IOException e) { - e.printStackTrace(); } - } - }); - thread.start(); + }); + thread.start(); - return new com.basic4gl.desktop.spi.DebugLaunchInfo(extractJvmDebugPort(jvmDebugArgs), isJvmDebugSuspendEnabled(jvmDebugArgs)); + return new com.basic4gl.desktop.spi.DebugLaunchInfo( + extractJvmDebugPort(jvmDebugArgs), isJvmDebugSuspendEnabled(jvmDebugArgs)); } catch (IOException e) { e.printStackTrace(); @@ -165,7 +165,6 @@ public void run() { } } - public void terminateLaunchedProcess() { Process processToTerminate; synchronized (launchedProcessLock) { @@ -266,10 +265,7 @@ private static String[] buildCommandArgs( addTargetOption(runnerArgs, target.getLineMappingFilePathCommandLineOption(), lineMappingPath); addTargetOption(runnerArgs, target.getLogFilePathCommandLineOption(), logFilePath); addTargetOption(runnerArgs, target.getParentDirectoryCommandLineOption(), currentDirectory); - addTargetOption( - runnerArgs, - target.getDebuggerPortCommandLineOption(), - defaultDebugServerPort); + addTargetOption(runnerArgs, target.getDebuggerPortCommandLineOption(), defaultDebugServerPort); List pluginDirectories = appSettings.getPluginDirectories(); if (pluginDirectories != null && !pluginDirectories.isEmpty()) { diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java index 1558fa48..fc88b6a0 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java @@ -12,9 +12,8 @@ import com.basic4gl.library.plugin.PluginJARManager; import com.basic4gl.runtime.Debugger; import com.basic4gl.runtime.TomVM; - -import java.util.Arrays; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -42,7 +41,12 @@ public class Basic4GLEditorPluginAdapter extends EditorPlugin { public Basic4GLEditorPluginAdapter(PluginContext context) { plugins = new PluginJARManager(false); - Preprocessor preprocessor = new Preprocessor(plugins, 2, Arrays.stream(context.fileServices()).map(FileServiceAdapter::new).toArray(FileServiceAdapter[]::new)); + Preprocessor preprocessor = new Preprocessor( + plugins, + 2, + Arrays.stream(context.fileServices()) + .map(FileServiceAdapter::new) + .toArray(FileServiceAdapter[]::new)); Debugger debugger = new Debugger(preprocessor.getLineNumberMap()); vm = new TomVM(plugins, debugger); compiler = new TomBasicCompiler(vm, plugins); @@ -52,7 +56,6 @@ public Basic4GLEditorPluginAdapter(PluginContext context) { preprocessorService = new Basic4GLPreprocessorService(compiler, preprocessor); } - @Override public String getName() { return "Basic4GLj"; @@ -107,9 +110,7 @@ public Builder[] getBuilders() { Builder builder = BuilderDesktopGL.getInstance(new DesktopTarget(compiler)); builder.init(context.files()); - builders = new Builder[] { - builder - }; + builders = new Builder[] {builder}; return builders; } @@ -145,7 +146,7 @@ public void onLoad(PluginContext context) { this.context = context; this.builders = new Builder[0]; - context.menus().addHelp("Function List",(parent, e) -> { + context.menus().addHelp("Function List", (parent, e) -> { ReferenceWindow window = new ReferenceWindow(parent); window.populate(compiler); window.setVisible(true); @@ -175,12 +176,9 @@ public ProjectSettingsPage[] getProjectSettingsPages() { }; } - @Override public ProjectExportPage[] getProjectExportPages() { - return new ProjectExportPage[] { - new PluginExportProjectPage(plugins, this::getDefaultPluginDirectory) - }; + return new ProjectExportPage[] {new PluginExportProjectPage(plugins, this::getDefaultPluginDirectory)}; } public IConfigurableAppSettings getConfigurableAppSettings() { @@ -486,7 +484,8 @@ private int scorePluginMatch(String declaration, String filename) { return -1; } String lowerFilename = filename.toLowerCase(Locale.ROOT); - if (lowerFilename.equals(normalizedDeclaration) || stripKnownExtension(lowerFilename).equals(normalizedDeclaration)) { + if (lowerFilename.equals(normalizedDeclaration) + || stripKnownExtension(lowerFilename).equals(normalizedDeclaration)) { return 3; } String normalizedFilename = normalizePluginToken(filename); diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java index 2d288ef7..b17a9092 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java @@ -1,6 +1,5 @@ package com.basic4gl.language.adapter; -import com.basic4gl.app.desktop.GLTextGridWindow; import com.basic4gl.compiler.Preprocessor; import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; @@ -17,14 +16,11 @@ import com.basic4gl.language.core.extensions.FunctionLibrary; import com.basic4gl.language.core.extensions.Library; import com.basic4gl.language.core.internal.Mutable; -import com.basic4gl.language.core.runtime.IServiceCollection; -import com.basic4gl.language.core.types.BasicValType; import com.basic4gl.language.core.types.Constant; import com.basic4gl.language.core.types.FunctionSpecification; import com.basic4gl.language.core.types.ValType; -import com.basic4gl.language.spi.PluginManager; import com.basic4gl.language.spi.PluginLibrary; - +import com.basic4gl.language.spi.PluginManager; import java.util.*; import java.util.stream.Stream; @@ -41,14 +37,10 @@ public class Basic4GLLanguageService implements LanguageService { } @Override - public void onLoad(PluginContext context) { - - } + public void onLoad(PluginContext context) {} @Override - public void onUnload() { - - } + public void onUnload() {} @Override public List getReservedWords() { @@ -62,7 +54,8 @@ public List getConstants() { @Override public List getFunctions() { - LinkedHashSet functions = new LinkedHashSet<>(compiler.getFunctionIndex().keySet()); + LinkedHashSet functions = + new LinkedHashSet<>(compiler.getFunctionIndex().keySet()); for (PluginLibrary library : pluginManager.getLoadedLibraries()) { for (int i = 0; i < library.count(); i++) { String functionName = library.getFunctionName(i); @@ -76,10 +69,8 @@ public List getFunctions() { @Override public List getOperators() { - return Stream.concat( - compiler.getBinaryOperators().stream(), - compiler.getUnaryOperators().stream()) - .toList(); + return Stream.concat(compiler.getBinaryOperators().stream(), compiler.getUnaryOperators().stream()) + .toList(); } @Override @@ -153,7 +144,7 @@ public Iterable getVariableDefinitions() { compiler.getProgram().getVariables().getVariables()) { if (variable.name == null || variable.name.isEmpty()) continue; String typeStr = LanguageUtil.getTypeString(variable.type); - String signature = typeStr + " " + variable.name; + String signature = typeStr + " " + variable.name; TypeDefinition typeDefinition = LanguageUtil.toTypeDefinition(variable.type); VariableDefinition definition = null; // TODO new VariableDefinition(variable.name,) @@ -166,7 +157,7 @@ public Iterable getLabelDefinitions() { ArrayList labelDefinitions = new ArrayList<>(); return compiler.getLabelNames().stream() .map(labelName -> { - String usage = "gosub "+ labelName + ": goto" + labelName; + String usage = "gosub " + labelName + ": goto" + labelName; return new LabelDefinition(labelName, labelName + ":", usage); }) .toList(); @@ -183,7 +174,9 @@ public Iterable getFunctionDefinitions() { String library = libraryName != null ? libraryName : "Builtin"; StringBuilder signature = new StringBuilder(); if (spec.isFunction()) { - signature.append(LanguageUtil.getTypeString(spec.getReturnType())).append(' '); + signature + .append(LanguageUtil.getTypeString(spec.getReturnType())) + .append(' '); } signature.append(name); signature.append(spec.hasBrackets() ? "(" : " "); @@ -206,18 +199,19 @@ public Iterable getFunctionDefinitions() { signature.append(')'); } -// TypeDefinition returnType = spec.isFunction() ? new TypeDefinition(LanguageUtil.getTypeString(spec.getReturnType())) : new TypeDefinition("void"); -// FunctionDefinition definition = new FunctionDefinition( -// name, -// signature.toString(), -// returnType, -// params != null ? params.stream() -// .map(this::getTypeString) -// .map((typeName, i) -> new VariableDefinition(typeName)) -// .toArray(VariableDefinition[]::new) : new VariableDefinition[0], -// spec.getDescription(), -// library -// ); + // TypeDefinition returnType = spec.isFunction() ? new + // TypeDefinition(LanguageUtil.getTypeString(spec.getReturnType())) : new TypeDefinition("void"); + // FunctionDefinition definition = new FunctionDefinition( + // name, + // signature.toString(), + // returnType, + // params != null ? params.stream() + // .map(this::getTypeString) + // .map((typeName, i) -> new VariableDefinition(typeName)) + // .toArray(VariableDefinition[]::new) : new VariableDefinition[0], + // spec.getDescription(), + // library + // ); // TODO FunctionDefinition definition = null; items.add(definition); @@ -248,7 +242,9 @@ private ArrayList buildUserFunctionReferenceItems() { } StringBuilder signature = new StringBuilder(); if (prototype != null && prototype.hasReturnVal) { - signature.append(LanguageUtil.getTypeString(prototype.returnValType)).append(' '); + signature + .append(LanguageUtil.getTypeString(prototype.returnValType)) + .append(' '); } signature.append(name).append('('); if (prototype != null && prototype.paramCount > 0) { @@ -268,7 +264,7 @@ private ArrayList buildUserFunctionReferenceItems() { } signature.append(')'); - FunctionDefinition definition = null; //TODO new FunctionDefinition(name, ...) + FunctionDefinition definition = null; // TODO new FunctionDefinition(name, ...) items.add(definition); } @@ -314,10 +310,7 @@ public Iterable getConstantDefinitions() { library = "Builtin"; } Constant constant = compiler.getConstants().get(key); - String signature = - key + " = (" + LanguageUtil.getTypeString(constant.getType()) + ") " - + constant; - + String signature = key + " = (" + LanguageUtil.getTypeString(constant.getType()) + ") " + constant; items.add(new VariableDefinition( key, @@ -328,8 +321,7 @@ public Iterable getConstantDefinitions() { library, true, "", - "Builtin" - )); + "Builtin")); } return items; } @@ -353,5 +345,4 @@ private Map buildConstantLibraryByName() { } return constantLibraryByName; } - } diff --git a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java similarity index 98% rename from app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java rename to language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java index 04594ef5..f65d5437 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/Basic4GLLanguageSupport.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java @@ -1,5 +1,11 @@ -package com.basic4gl.desktop.language; - +package com.basic4gl.language.adapter; + +import com.basic4gl.desktop.spi.language.HighlightKind; +import com.basic4gl.desktop.spi.language.IndexedSymbol; +import com.basic4gl.desktop.spi.language.LangToken; +import com.basic4gl.desktop.spi.language.LanguageSupport; +import com.basic4gl.desktop.spi.language.SymbolDeclaration; +import com.basic4gl.language.adapter.antlr.Basic4GL; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLPreprocessorService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLPreprocessorService.java index b4e21f7a..4b8a85de 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLPreprocessorService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLPreprocessorService.java @@ -12,10 +12,9 @@ public class Basic4GLPreprocessorService implements com.basic4gl.desktop.spi.Pre this.compiler = compiler; this.preprocessor = preprocessor; } - @Override - public void onLoad(com.basic4gl.desktop.spi.PluginContext context) { - } + @Override + public void onLoad(com.basic4gl.desktop.spi.PluginContext context) {} @Override public boolean hasError() { diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/BuilderDesktopGL.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/BuilderDesktopGL.java index a145f2a2..a4c3d23e 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/BuilderDesktopGL.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/BuilderDesktopGL.java @@ -1,19 +1,10 @@ package com.basic4gl.language.adapter; -import com.basic4gl.app.desktop.GLTextGridWindow; import com.basic4gl.app.desktop.config.StandaloneCommandLineOptions; -import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.compiler.util.Exporter; import com.basic4gl.compiler.util.IAssetExportBuilder; import com.basic4gl.compiler.util.IPluginExportBuilder; import com.basic4gl.desktop.spi.*; -import com.basic4gl.language.core.extensions.Basic4GLCompiler; -import com.basic4gl.language.core.extensions.IAppSettings; -import com.basic4gl.language.core.extensions.Library; -import com.basic4gl.language.core.runtime.IServiceCollection; -import com.basic4gl.language.core.runtime.VM; -import com.basic4gl.library.desktopgl.util.ITargetCommandLineOptions; - import java.io.*; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -397,7 +388,6 @@ private File resolvePluginFile(File configuredFile) { return configuredFile; } - @Override public void setExportAssets(List assets) { exportAssets.clear(); @@ -416,7 +406,6 @@ public List getExportAssets() { return new ArrayList<>(exportAssets); } - @Override public void setExportPlugins(List pluginPaths) { exportPlugins.clear(); @@ -435,10 +424,10 @@ public Target getTarget() { return target; } -// @Override -// public IVMDriver getVMDriver() { -// return target; -// } + // @Override + // public IVMDriver getVMDriver() { + // return target; + // } @Override public String getName() { @@ -480,49 +469,49 @@ public void init(FileOpener files) { this.files = files; target.init(new FileOpenerAdapter(files)); } -// -// @Override -// public List getDependencies() { -// return null; -// } -// -// @Override -// public List getClassPathObjects() { -// return null; -// } -// -// @Override -// public String getConfigFilePathCommandLineOption() { -// return target.getConfigFilePathCommandLineOption(); -// } -// -// @Override -// public String getLineMappingFilePathCommandLineOption() { -// return target.getLineMappingFilePathCommandLineOption(); -// } -// -// @Override -// public String getLogFilePathCommandLineOption() { -// return target.getLogFilePathCommandLineOption(); -// } -// -// @Override -// public String getParentDirectoryCommandLineOption() { -// return target.getParentDirectoryCommandLineOption(); -// } -// -// @Override -// public String getProgramFilePathCommandLineOption() { -// return target.getProgramFilePathCommandLineOption(); -// } -// -// @Override -// public String getDebuggerPortCommandLineOption() { -// return target.getDebuggerPortCommandLineOption(); -// } -// -// @Override -// public String getSandboxModeEnabledOption() { -// return target.getSandboxModeEnabledOption(); -// } + // + // @Override + // public List getDependencies() { + // return null; + // } + // + // @Override + // public List getClassPathObjects() { + // return null; + // } + // + // @Override + // public String getConfigFilePathCommandLineOption() { + // return target.getConfigFilePathCommandLineOption(); + // } + // + // @Override + // public String getLineMappingFilePathCommandLineOption() { + // return target.getLineMappingFilePathCommandLineOption(); + // } + // + // @Override + // public String getLogFilePathCommandLineOption() { + // return target.getLogFilePathCommandLineOption(); + // } + // + // @Override + // public String getParentDirectoryCommandLineOption() { + // return target.getParentDirectoryCommandLineOption(); + // } + // + // @Override + // public String getProgramFilePathCommandLineOption() { + // return target.getProgramFilePathCommandLineOption(); + // } + // + // @Override + // public String getDebuggerPortCommandLineOption() { + // return target.getDebuggerPortCommandLineOption(); + // } + // + // @Override + // public String getSandboxModeEnabledOption() { + // return target.getSandboxModeEnabledOption(); + // } } diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/ConfigurationMapper.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/ConfigurationMapper.java index f4e2ee0a..2f7b4f0d 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/ConfigurationMapper.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/ConfigurationMapper.java @@ -24,10 +24,7 @@ public static Configuration toEditorConfiguration(com.basic4gl.language.core.run Configuration mapped = new Configuration(); for (int i = 0; i < config.getSettingCount(); i++) { mapped.addSetting( - config.getField(i).clone(), - config.getParamType(i), - config.getValue(i), - config.getFieldInfoText(i)); + config.getField(i).clone(), config.getParamType(i), config.getValue(i), config.getFieldInfoText(i)); } return mapped; } @@ -39,19 +36,32 @@ public static Configuration toEditorConfiguration(IConfigurableAppSettings confi Configuration settings = new Configuration(); settings.addSetting(new String[] {"Editor"}, Configuration.PARAM_HEADING, ""); - settings.addSetting(new String[] {"Sandbox Mode"}, Configuration.PARAM_BOOL, Boolean.toString(config.isSandboxModeEnabled())); + settings.addSetting( + new String[] {"Sandbox Mode"}, + Configuration.PARAM_BOOL, + Boolean.toString(config.isSandboxModeEnabled())); settings.addSetting(new String[] {"Syntax"}, Configuration.PARAM_INT, Integer.toString(config.getSyntax())); settings.addSetting( - new String[] {"Program Arguments"}, Configuration.PARAM_STRING, serializeArguments(config.getProgramArguments())); - settings.addSetting(new String[] {"JVM Arguments"}, Configuration.PARAM_STRING, serializeArguments(config.getJvmArguments())); + new String[] {"Program Arguments"}, + Configuration.PARAM_STRING, + serializeArguments(config.getProgramArguments())); + settings.addSetting( + new String[] {"JVM Arguments"}, + Configuration.PARAM_STRING, + serializeArguments(config.getJvmArguments())); settings.addSetting( - new String[] {"Enable JVM Debugger"}, Configuration.PARAM_BOOL, Boolean.toString(config.isJvmDebuggingEnabled())); + new String[] {"Enable JVM Debugger"}, + Configuration.PARAM_BOOL, + Boolean.toString(config.isJvmDebuggingEnabled())); settings.addSetting( new String[] {"Suspend Until Attach"}, Configuration.PARAM_BOOL, Boolean.toString(config.isJvmDebugSuspendUntilAttach())); Integer debugPort = config.getJvmDebugPortOverride(); - settings.addSetting(new String[] {"Debug Port Override"}, Configuration.PARAM_STRING, debugPort == null ? "" : debugPort.toString()); + settings.addSetting( + new String[] {"Debug Port Override"}, + Configuration.PARAM_STRING, + debugPort == null ? "" : debugPort.toString()); settings.addSetting( new String[] {"Plugin Directories"}, Configuration.PARAM_STRING, @@ -64,13 +74,11 @@ public static com.basic4gl.language.core.runtime.Configuration toRuntimeConfigur return null; } - com.basic4gl.language.core.runtime.Configuration mapped = new com.basic4gl.language.core.runtime.Configuration(); + com.basic4gl.language.core.runtime.Configuration mapped = + new com.basic4gl.language.core.runtime.Configuration(); for (int i = 0; i < config.getSettingCount(); i++) { mapped.addSetting( - config.getField(i).clone(), - config.getParamType(i), - config.getValue(i), - config.getFieldInfoText(i)); + config.getField(i).clone(), config.getParamType(i), config.getValue(i), config.getFieldInfoText(i)); } return mapped; } @@ -81,14 +89,15 @@ public static IConfigurableAppSettings toAppSettings(Configuration config) { return settings; } - settings.setSandboxModeEnabled(config.getBooleanValueOrDefault(APP_SETTING_SANDBOX_MODE, settings.isSandboxModeEnabled())); + settings.setSandboxModeEnabled( + config.getBooleanValueOrDefault(APP_SETTING_SANDBOX_MODE, settings.isSandboxModeEnabled())); settings.setSyntax(config.getIntValueOrDefault(APP_SETTING_SYNTAX, settings.getSyntax())); settings.setProgramArguments(parseArguments(getValueOrNull(config, APP_SETTING_PROGRAM_ARGUMENTS))); settings.setJvmArguments(parseArguments(getValueOrNull(config, APP_SETTING_JVM_ARGUMENTS))); settings.setJvmDebuggingEnabled( config.getBooleanValueOrDefault(APP_SETTING_JVM_DEBUG_ENABLED, settings.isJvmDebuggingEnabled())); - settings.setJvmDebugSuspendUntilAttach( - config.getBooleanValueOrDefault(APP_SETTING_JVM_DEBUG_SUSPEND, settings.isJvmDebugSuspendUntilAttach())); + settings.setJvmDebugSuspendUntilAttach(config.getBooleanValueOrDefault( + APP_SETTING_JVM_DEBUG_SUSPEND, settings.isJvmDebugSuspendUntilAttach())); String debugPortValue = getValueOrNull(config, APP_SETTING_JVM_DEBUG_PORT); if (debugPortValue != null && !debugPortValue.trim().isEmpty()) { diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/DesktopTarget.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/DesktopTarget.java index 24a80694..66213939 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/DesktopTarget.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/DesktopTarget.java @@ -1,5 +1,7 @@ package com.basic4gl.language.adapter; +import static com.basic4gl.app.desktop.config.StandaloneSettings.*; + import com.basic4gl.app.desktop.GLTextGridWindow; import com.basic4gl.app.desktop.config.IStandaloneSettings; import com.basic4gl.app.desktop.config.StandaloneCommandLineOptionsParser; @@ -8,22 +10,20 @@ import com.basic4gl.desktop.spi.Configuration; import com.basic4gl.desktop.spi.Target; import com.basic4gl.library.desktopgl.util.ITargetCommandLineOptions; - import java.io.*; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import static com.basic4gl.app.desktop.config.StandaloneSettings.*; - public class DesktopTarget implements Target, ITargetCommandLineOptions { private final StandaloneCommandLineOptionsParser cliParser = new StandaloneCommandLineOptionsParser(); private TomBasicCompiler compiler; public DesktopTarget(TomBasicCompiler compiler) { - this.compiler = compiler; + this.compiler = compiler; } + private final IStandaloneSettings settings = new StandaloneSettings(); // settings specific to standalone application private com.basic4gl.language.core.runtime.Configuration configuration; // runtime configuration for this library @@ -86,20 +86,18 @@ public void loadState(InputStream stream) throws Exception { } @Override - public void cleanup() { - - } + public void cleanup() {} public Type getMainClass() { return GLTextGridWindow.class; } - @Override public List getDependencies() { // Get settings - com.basic4gl.language.core.runtime.Configuration config = ConfigurationMapper.toRuntimeConfiguration(getConfiguration()); + com.basic4gl.language.core.runtime.Configuration config = + ConfigurationMapper.toRuntimeConfiguration(getConfiguration()); List list = new ArrayList<>(); @@ -236,12 +234,9 @@ public List getClassPathObjects() { "paulscode-soundsystem-lwjgl3.jar"); } - public void reset() { + public void reset() {} - } - - public void init(FileOpenerAdapter fileOpenerAdapter) { - } + public void init(FileOpenerAdapter fileOpenerAdapter) {} @Override public String getConfigFilePathCommandLineOption() { diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/EditorAdapter.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/EditorAdapter.java index f1edb431..4c4ed051 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/EditorAdapter.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/EditorAdapter.java @@ -1,4 +1,3 @@ package com.basic4gl.language.adapter; -public class EditorAdapter { -} +public class EditorAdapter {} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/EmbeddedFile.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/EmbeddedFile.java index 6985a652..b8efe417 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/EmbeddedFile.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/EmbeddedFile.java @@ -1,9 +1,6 @@ package com.basic4gl.language.adapter; import com.basic4gl.language.core.internal.Assert; - -import static com.basic4gl.language.core.internal.Assert.assertTrue; - import java.io.FileInputStream; import java.nio.ByteBuffer; import java.nio.IntBuffer; diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/FileOpenerAdapter.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/FileOpenerAdapter.java index 8d01209f..55e74bad 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/FileOpenerAdapter.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/FileOpenerAdapter.java @@ -2,13 +2,8 @@ import com.basic4gl.language.core.runtime.HasErrorState; import com.basic4gl.language.core.runtime.IFileOpener; -import com.basic4gl.library.desktopgl.content.FileOpener; - import java.io.*; -import java.net.URL; import java.nio.IntBuffer; -import java.nio.file.Path; -import java.nio.file.Paths; /** * Created by Nate on 11/1/2015. @@ -66,7 +61,6 @@ public FileOutputStream openWrite(String filename, boolean filesFolder) { return parent.openWrite(filename, filesFolder); } - // The following function returns a filename that can be opened in read mode. // If the input filename corresponds to an embedded file, the embedded file // is copied to a temporary file on the drive, and that filename is returned diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/FileServiceAdapter.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/FileServiceAdapter.java index cfc31772..3c8a714e 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/FileServiceAdapter.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/FileServiceAdapter.java @@ -22,9 +22,11 @@ public ISourceFile openSourceFile(String s) { static class SourceFile implements ISourceFile { private final com.basic4gl.desktop.spi.ISourceFile sourceFile; + public SourceFile(com.basic4gl.desktop.spi.ISourceFile sourceFile) { this.sourceFile = sourceFile; } + @Override public String getNextLine() { return sourceFile.getNextLine(); diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/PluginExportProjectPage.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/PluginExportProjectPage.java index 82196693..ed55293e 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/PluginExportProjectPage.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/PluginExportProjectPage.java @@ -16,7 +16,6 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.function.Supplier; -import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.DefaultListCellRenderer; @@ -181,7 +180,8 @@ private void promptAndAddPlugins() { chooser.setMultiSelectionEnabled(true); chooser.setFileFilter(new FileNameExtensionFilter("Plugin JAR Files", "jar")); - String initialDirectory = defaultDirectorySupplier == null ? null : normalizePluginPath(defaultDirectorySupplier.get()); + String initialDirectory = + defaultDirectorySupplier == null ? null : normalizePluginPath(defaultDirectorySupplier.get()); if (initialDirectory != null) { File initialDirFile = new File(initialDirectory); File directory = initialDirFile.isDirectory() ? initialDirFile : initialDirFile.getParentFile(); @@ -232,7 +232,8 @@ private void mergeLoadedPlugins() { if (sourceDirectory == null || sourceDirectory.isBlank() || filename == null || filename.isBlank()) { continue; } - String normalizedPath = normalizePluginPath(Path.of(sourceDirectory, filename).toString()); + String normalizedPath = + normalizePluginPath(Path.of(sourceDirectory, filename).toString()); if (normalizedPath != null) { merged.add(normalizedPath); } @@ -307,11 +308,7 @@ private void updateStatusLabel() { private static class PluginPathRenderer extends DefaultListCellRenderer { @Override public Component getListCellRendererComponent( - JList list, - Object value, - int index, - boolean isSelected, - boolean cellHasFocus) { + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); String path = value == null ? "" : value.toString(); String filename = path; diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/PluginManagerProjectSettingsPage.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/PluginManagerProjectSettingsPage.java index 39385424..11015b5f 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/PluginManagerProjectSettingsPage.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/PluginManagerProjectSettingsPage.java @@ -19,8 +19,8 @@ import java.util.Set; import javax.swing.*; import javax.swing.event.TableModelEvent; -import javax.swing.table.DefaultTableModel; import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.DefaultTableModel; import javax.swing.table.TableCellEditor; import javax.swing.table.TableCellRenderer; @@ -132,20 +132,22 @@ public JComponent createPageComponent() { directoryPanel.add(sourceBodyPanel, BorderLayout.CENTER); - pluginTableModel = new DefaultTableModel(new Object[] {"Enabled", "Name", "Source", "Version", "Description", "Details"}, 0) { - @Override - public Class getColumnClass(int columnIndex) { - if (columnIndex == 0) { - return Boolean.class; - } - return String.class; - } + pluginTableModel = + new DefaultTableModel( + new Object[] {"Enabled", "Name", "Source", "Version", "Description", "Details"}, 0) { + @Override + public Class getColumnClass(int columnIndex) { + if (columnIndex == 0) { + return Boolean.class; + } + return String.class; + } - @Override - public boolean isCellEditable(int row, int column) { - return (column == 0 || column == 5) && !isIncompatibleRow(row); - } - }; + @Override + public boolean isCellEditable(int row, int column) { + return (column == 0 || column == 5) && !isIncompatibleRow(row); + } + }; pluginTable = new JTable(pluginTableModel) { @Override public Component prepareRenderer(javax.swing.table.TableCellRenderer renderer, int row, int column) { @@ -154,14 +156,12 @@ public Component prepareRenderer(javax.swing.table.TableCellRenderer renderer, i if (isCellSelected(row, column)) { component.setBackground(getSelectionBackground()); - component.setForeground(incompatible - ? UIManager.getColor("Label.disabledForeground") - : getSelectionForeground()); + component.setForeground( + incompatible ? UIManager.getColor("Label.disabledForeground") : getSelectionForeground()); } else { component.setBackground(getBackground()); - component.setForeground(incompatible - ? UIManager.getColor("Label.disabledForeground") - : getForeground()); + component.setForeground( + incompatible ? UIManager.getColor("Label.disabledForeground") : getForeground()); } component.setEnabled(true); return component; @@ -313,7 +313,8 @@ private void initializeSourceList() { pluginSourceListModel.addElement(initialDirectory); } } - List recentDirectories = recentDirectoriesSupplier == null ? List.of() : recentDirectoriesSupplier.get(); + List recentDirectories = + recentDirectoriesSupplier == null ? List.of() : recentDirectoriesSupplier.get(); if (recentDirectories != null) { for (String directory : recentDirectories) { addSourceIfMissing(normalizeNullable(directory)); @@ -413,9 +414,9 @@ private void refreshPluginTable() { pluginManager.setDirectories(directories); List jarFiles = new ArrayList<>(pluginManager.getJARFiles()); - jarFiles.sort(Comparator - .comparing((PluginJARFile file) -> sourceSortIndex(file.getSourceDirectory(), directories)) - .thenComparing(PluginJARFile::getFilename, String.CASE_INSENSITIVE_ORDER)); + jarFiles.sort( + Comparator.comparing((PluginJARFile file) -> sourceSortIndex(file.getSourceDirectory(), directories)) + .thenComparing(PluginJARFile::getFilename, String.CASE_INSENSITIVE_ORDER)); pluginDisplayNamesByRowKey.clear(); incompatiblePluginRows.clear(); boolean showUnsupported = showUnsupportedCheckbox == null || showUnsupportedCheckbox.isSelected(); @@ -433,8 +434,10 @@ private void refreshPluginTable() { if (displayName != null) { pluginDisplayNamesByRowKey.put(rowKey, displayName); } - String version = - file.getVersion() == null ? "-" : file.getVersion().getMajorVersion() + "." + file.getVersion().getMinorVersion(); + String version = file.getVersion() == null + ? "-" + : file.getVersion().getMajorVersion() + "." + + file.getVersion().getMinorVersion(); String rowDescription = resolveDescription(file); pluginTableModel.addRow(new Object[] { file.isLoaded() && file.isCompatible(), @@ -471,8 +474,7 @@ private Map snapshotDesiredLoadStates() { String sourceDirectory = (String) pluginTableModel.getValueAt(i, 2); if (filename != null && !filename.isBlank()) { desiredLoadStates.put( - buildRowKey(filename, sourceDirectory), - Boolean.TRUE.equals(pluginTableModel.getValueAt(i, 0))); + buildRowKey(filename, sourceDirectory), Boolean.TRUE.equals(pluginTableModel.getValueAt(i, 0))); } } return desiredLoadStates; @@ -492,9 +494,7 @@ private void restoreDesiredLoadStates(Map desiredLoadStates) { Boolean desired = desiredLoadStates.get(buildRowKey(filename, sourceDirectory)); if (desired != null) { pluginTableModel.setValueAt( - desired && !isIncompatibleRowKey(buildRowKey(filename, sourceDirectory)), - i, - 0); + desired && !isIncompatibleRowKey(buildRowKey(filename, sourceDirectory)), i, 0); } } suppressPluginTableEvents = false; @@ -571,8 +571,10 @@ private void refreshPluginRow(String filename, String sourceDirectory, int row) return; } - String version = - jarFile.getVersion() == null ? "-" : jarFile.getVersion().getMajorVersion() + "." + jarFile.getVersion().getMinorVersion(); + String version = jarFile.getVersion() == null + ? "-" + : jarFile.getVersion().getMajorVersion() + "." + + jarFile.getVersion().getMinorVersion(); String description = resolveDescription(jarFile); String rowKey = buildRowKey(filename, sourceDirectory); if (!jarFile.isCompatible()) { @@ -761,7 +763,8 @@ private void showDetailsDialog(int row) { metadataPanel.add(new JLabel("Summary:"), gbc); gbc.gridx = 1; gbc.weightx = 1.0; - String summaryText = details.getMetadataSummary() == null || details.getMetadataSummary().isBlank() + String summaryText = details.getMetadataSummary() == null + || details.getMetadataSummary().isBlank() ? "-" : details.getMetadataSummary(); JLabel summaryLabel = new JLabel(summaryText); @@ -788,7 +791,8 @@ private void showDetailsDialog(int row) { gbc.gridx = 1; gbc.weightx = 1.0; - String metadataDetailsText = details.getMetadataDetails() == null || details.getMetadataDetails().isBlank() + String metadataDetailsText = details.getMetadataDetails() == null + || details.getMetadataDetails().isBlank() ? "-" : details.getMetadataDetails(); JLabel metadataDetailsLabel = new JLabel(formatMetadataDetailsHtml(metadataDetailsText)); @@ -821,10 +825,7 @@ private String formatMetadataDetailsHtml(String details) { } private String escapeHtml(String value) { - return value - .replace("&", "&") - .replace("<", "<") - .replace(">", ">"); + return value.replace("&", "&").replace("<", "<").replace(">", ">"); } private JScrollPane createListPane(List entries) { @@ -850,7 +851,8 @@ private JScrollPane createTextPane(String text) { private class PluginNameRenderer extends DefaultTableCellRenderer { @Override - public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { + public Component getTableCellRendererComponent( + JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { String filename = value == null ? "" : value.toString(); String display = filename; int modelRow = table.convertRowIndexToModel(row); diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java index 82a93239..6d4bda78 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java @@ -1,28 +1,26 @@ package com.basic4gl.language.adapter.util; -import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.language.TypeDefinition; -import com.basic4gl.desktop.spi.language.VariableDefinition; import com.basic4gl.language.core.types.BasicValType; import com.basic4gl.language.core.types.ValType; public final class LanguageUtil { - private LanguageUtil() { } - -// public static VariableDefinition toVariableDefinition() { -// return new VariableDefinition() -// } -// -// -// public static VariableDefinition buildVariableDefinition(String ) { -// -// } + private LanguageUtil() {} + // public static VariableDefinition toVariableDefinition() { + // return new VariableDefinition() + // } + // + // + // public static VariableDefinition buildVariableDefinition(String ) { + // + // } public static TypeDefinition toTypeDefinition(ValType type) { String name = getTypeString(type); return new TypeDefinition(name, "", "", ""); } + public static TypeDefinition toTypeDefinition(int type) { String name = getTypeString(type); return new TypeDefinition(name, "", "", ""); diff --git a/language-adapter/src/test/java/com/basic4gl/language/adapter/ConfigurationMapperTest.java b/language-adapter/src/test/java/com/basic4gl/language/adapter/ConfigurationMapperTest.java index b2fd05f7..08d6a6f4 100644 --- a/language-adapter/src/test/java/com/basic4gl/language/adapter/ConfigurationMapperTest.java +++ b/language-adapter/src/test/java/com/basic4gl/language/adapter/ConfigurationMapperTest.java @@ -16,7 +16,8 @@ class ConfigurationMapperTest { @Test void toEditorConfiguration_runtimeConfigCopiesAllSettings() { - com.basic4gl.language.core.runtime.Configuration runtime = new com.basic4gl.language.core.runtime.Configuration(); + com.basic4gl.language.core.runtime.Configuration runtime = + new com.basic4gl.language.core.runtime.Configuration(); runtime.addSetting(new String[] {"Header"}, com.basic4gl.language.core.runtime.Configuration.PARAM_HEADING, ""); runtime.addSetting( new String[] {"Toggle"}, From 29d5dff2482aa4ffe91302b9581e17d3b6e5e848 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sun, 12 Jul 2026 02:12:48 -0400 Subject: [PATCH 14/38] refactoring editor panels into separate components from the mainwindow --- .../basic4gl/desktop/spi/DebugController.java | 8 + .../basic4gl/desktop/spi/DialogService.java | 2 +- .../desktop/spi/EditorCommandsService.java | 18 + .../basic4gl/desktop/spi/LanguageService.java | 78 +- .../basic4gl/desktop/spi/PluginContext.java | 7 + .../desktop/spi/language/LanguageSupport.java | 96 - .../com/basic4gl/desktop/BasicEditor.java | 44 +- .../com/basic4gl/desktop/ExportDialog.java | 62 +- .../java/com/basic4gl/desktop/MainWindow.java | 1564 ++--------------- .../desktop/content/AssetService.java | 19 + .../desktop/editor/BasicTokenMaker.java | 9 +- .../editor/LanguageSupportTokenMaker.java | 8 +- .../desktop/language/SymbolIndexer.java | 7 +- .../desktop/panels/AssetsPanelProvider.java | 585 ++++++ .../panels/BookmarksPanelProvider.java | 70 + .../desktop/panels/DebugPanelProvider.java | 74 + .../desktop/panels/DocsPanelProvider.java | 55 + .../basic4gl/desktop/panels/EditorLayout.java | 7 + .../panels/FileBrowserPanelProvider.java | 248 +++ .../desktop/panels/IEditorPanelProvider.java | 27 + .../desktop/panels/SymbolsPanelProvider.java | 648 +++++++ .../desktop/util/BasicDialogService.java | 18 + .../com/basic4gl/desktop/util/FileUtil.java | 92 + .../com/basic4gl/desktop/util/HtmlUtil.java | 37 + .../basic4gl/desktop/util/SwingIconUtil.java | 20 + .../com/basic4gl/desktop/util/SwingUtil.java | 13 + .../adapter/Basic4GLLanguageService.java | 678 ++++++- .../adapter/Basic4GLLanguageSupport.java | 774 -------- .../language/adapter/util/LanguageUtil.java | 140 ++ 29 files changed, 2984 insertions(+), 2424 deletions(-) create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java delete mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/AssetService.java create mode 100644 app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java create mode 100644 app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java create mode 100644 app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java create mode 100644 app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java create mode 100644 app/src/main/java/com/basic4gl/desktop/panels/EditorLayout.java create mode 100644 app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java create mode 100644 app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java create mode 100644 app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java create mode 100644 app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java create mode 100644 app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java create mode 100644 app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java delete mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java new file mode 100644 index 00000000..cedf4b31 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java @@ -0,0 +1,8 @@ +package com.basic4gl.desktop.spi; + +public interface DebugController { + void actionPlayPause(); + void actionStep(); + void actionStepInto(); + void actionStepOutOf(); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java index 01849630..a0690e47 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java @@ -7,5 +7,5 @@ public interface DialogService { // JComponent createContent(DialogContext context); // Boolean getResult(); // default boolean validate() { return true; } - public boolean showDialog(PluginContext context); + public void showDialog(String message); } diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java new file mode 100644 index 00000000..e8613089 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java @@ -0,0 +1,18 @@ +package com.basic4gl.desktop.spi; + +import java.io.File; + +public interface EditorCommandsService { + void openFileWithPreferredViewer(File file); + // TODO this should be cleaned up before 1.0; refactoring + void openMarkdownInDocsTab(File file); + public String collectAllSourceText(); + void actionOpenFolder(); + void selectNextBookmark(); + void selectPreviousBookmark(); + void toggleBookmark(); + + void setWorkspaceDirectory(File selectedFile); + + void insertText(String text, int caretOffset); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java index 0013c6d1..d711dfdd 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java @@ -2,9 +2,8 @@ import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.types.StackFrame; -import com.basic4gl.desktop.spi.language.FunctionDefinition; -import com.basic4gl.desktop.spi.language.LabelDefinition; -import com.basic4gl.desktop.spi.language.VariableDefinition; +import com.basic4gl.desktop.spi.language.*; + import java.util.ArrayList; import java.util.List; @@ -14,6 +13,7 @@ public interface LanguageService { public void onUnload(); + public List extractStringLiterals(String text); public List getReservedWords(); public List getConstants(); @@ -39,4 +39,76 @@ public interface LanguageService { Iterable getLabelDefinitions(); Iterable getFunctionDefinitions(); + + + // ------------------------------------------------------------------------- + // Identity + // ------------------------------------------------------------------------- + + /** + * The MIME-type style string used to register this language with RSyntaxTextArea's + * {@code TokenMakerFactory} (e.g. {@code "text/basic4gl"}). + * + *

    The value is opaque to the core indexer but consumed by the RSyntaxTextArea adapter. + */ + String syntaxStyle(); + + // ------------------------------------------------------------------------- + // Tokenisation + // ------------------------------------------------------------------------- + + /** + * Tokenizes a single line of source text. + * + *

    The returned list contains all tokens in left-to-right order. {@link LangToken#start()} + * and {@link LangToken#end()} are 0-based character offsets within {@code line}. + * + *

    Implementations must not return {@code null}; an empty line may return an empty list. + * + * @param line a single line of source (no {@code \n}) + * @return ordered, non-null token list + */ + List tokenizeLine(String line); + + /** + * Maps an implementation-specific {@link LangToken#type()} to a portable + * {@link HighlightKind}. + * + *

    This is the only place where the internal token type integers are interpreted. + * All other code works with {@link HighlightKind} values. + * + * @param token a token previously produced by {@link #tokenizeLine} + * @return the semantic highlight category; never {@code null} + */ + HighlightKind classify(LangToken token); + + // ------------------------------------------------------------------------- + // Symbol extraction + // ------------------------------------------------------------------------- + + /** + * Scans the full source text (which may span multiple concatenated files) and returns every + * user-defined symbol it can discover. + * + *

    This method is called from a background thread by the {@code SymbolIndexer} after each + * debounce cycle; it must not touch Swing components. + * + * @param source full program source text + * @return discovered symbols; never {@code null} + */ + List extractSymbols(String source); + + /** + * Extracts declaration sites from source for navigation features (e.g. Go To Declaration). + * + *

    Default implementation returns an empty list so existing language plugins remain binary + * compatible until they opt into declaration-aware navigation. + * + * @param source full source text + * @param fileId caller-provided source identifier (typically absolute file path) + * @return declaration list; never {@code null} + */ + default List extractDeclarations(String source, String fileId) { + return List.of(); + } } diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java index f67b5628..f3ccec28 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java @@ -4,6 +4,11 @@ public interface PluginContext { // CommandRegistry commands(); // MenuRegistry menus(); // ToolWindowRegistry toolWindows(); + + EditorCommandsService commands(); + DebugController debugger(); + + DialogService dialogs(); MenuService menus(); @@ -15,6 +20,8 @@ public interface PluginContext { String currentDirectory(); + EditorPlugin currentEditor(); + String getLibraryPath(); SourceFileService[] fileServices(); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java deleted file mode 100644 index 2f1857d8..00000000 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.basic4gl.desktop.spi.language; - -import java.util.List; - -/** - * Plugin contract for a language definition. - * - *

    A single implementation encapsulates everything the IDE needs to know about one language: - * - *

      - *
    • How to tokenize source text (for syntax highlighting) - *
    • How to classify each token into a portable {@link HighlightKind} - *
    • How to extract user-defined symbols from source (for the reference panel / indexer) - *
    - * - *

    No RSyntaxTextArea or other UI framework types appear in this interface. - * Adapters that bridge to a specific UI toolkit ({@code LanguageSupportTokenMaker} for - * RSyntaxTextArea, a future LSP adapter, etc.) hold a reference to a {@code LanguageSupport} - * and translate its output into whatever the toolkit requires. - * - *

    Implementations are expected to be thread-safe: {@link #tokenizeLine} and - * {@link #extractSymbols} may be called concurrently from both the EDT and background threads. - */ -public interface LanguageSupport { - - // ------------------------------------------------------------------------- - // Identity - // ------------------------------------------------------------------------- - - /** - * The MIME-type style string used to register this language with RSyntaxTextArea's - * {@code TokenMakerFactory} (e.g. {@code "text/basic4gl"}). - * - *

    The value is opaque to the core indexer but consumed by the RSyntaxTextArea adapter. - */ - String syntaxStyle(); - - // ------------------------------------------------------------------------- - // Tokenisation - // ------------------------------------------------------------------------- - - /** - * Tokenizes a single line of source text. - * - *

    The returned list contains all tokens in left-to-right order. {@link LangToken#start()} - * and {@link LangToken#end()} are 0-based character offsets within {@code line}. - * - *

    Implementations must not return {@code null}; an empty line may return an empty list. - * - * @param line a single line of source (no {@code \n}) - * @return ordered, non-null token list - */ - List tokenizeLine(String line); - - /** - * Maps an implementation-specific {@link LangToken#type()} to a portable - * {@link HighlightKind}. - * - *

    This is the only place where the internal token type integers are interpreted. - * All other code works with {@link HighlightKind} values. - * - * @param token a token previously produced by {@link #tokenizeLine} - * @return the semantic highlight category; never {@code null} - */ - HighlightKind classify(LangToken token); - - // ------------------------------------------------------------------------- - // Symbol extraction - // ------------------------------------------------------------------------- - - /** - * Scans the full source text (which may span multiple concatenated files) and returns every - * user-defined symbol it can discover. - * - *

    This method is called from a background thread by the {@code SymbolIndexer} after each - * debounce cycle; it must not touch Swing components. - * - * @param source full program source text - * @return discovered symbols; never {@code null} - */ - List extractSymbols(String source); - - /** - * Extracts declaration sites from source for navigation features (e.g. Go To Declaration). - * - *

    Default implementation returns an empty list so existing language plugins remain binary - * compatible until they opt into declaration-aware navigation. - * - * @param source full source text - * @param fileId caller-provided source identifier (typically absolute file path) - * @return declaration list; never {@code null} - */ - default List extractDeclarations(String source, String fileId) { - return List.of(); - } -} diff --git a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java index ffeeaa2d..701afe06 100644 --- a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java @@ -28,7 +28,7 @@ import javax.swing.SwingUtilities; import org.apache.commons.lang3.SystemUtils; -public class BasicEditor implements MainEditor, IApplicationHost, IFileProvider, PluginContext { +public class BasicEditor implements MainEditor, IApplicationHost, IFileProvider, PluginContext, DebugController { private static final int GLOBAL_VARIABLES_PAGE_SIZE = 128; private static final int MEMORY_VARIABLES_PAGE_SIZE = 64; @@ -80,13 +80,17 @@ public class BasicEditor implements MainEditor, IApplicationHost, IFileProvider, private String libraryPath; private final Basic4GLEditorPluginAdapter basic4gl; + private final DialogService dialogService; + private final EditorCommandsService commandsService; public BasicEditor( - String libraryPath, FileManager fileManager, IEditorPresenter presenter, MenuService menuService) { + String libraryPath, FileManager fileManager, IEditorPresenter presenter, DialogService dialogService, MenuService menuService, EditorCommandsService commandsService) { this.libraryPath = libraryPath; this.fileManager = fileManager; this.presenter = presenter; + this.dialogService = dialogService; this.menuService = menuService; + this.commandsService = commandsService; this.basic4gl = new Basic4GLEditorPluginAdapter(this); this.basic4gl.setOnPluginStateChanged(this::refreshSyntaxHighlighting); this.basic4gl.setOnPluginDirectoryHistoryChanged(this::syncPluginDirectorySettings); @@ -110,21 +114,12 @@ public void refreshSyntaxHighlighting() { BasicTokenMaker.functions.clear(); BasicTokenMaker.constants.clear(); BasicTokenMaker.operators.clear(); - for (String s : basic4gl.getLanguage().getReservedWords()) { - BasicTokenMaker.reservedWords.add(s); - } - for (String s : basic4gl.getLanguage().getConstants()) { - BasicTokenMaker.constants.add(s); - } + BasicTokenMaker.reservedWords.addAll(basic4gl.getLanguage().getReservedWords()); + BasicTokenMaker.constants.addAll(basic4gl.getLanguage().getConstants()); + BasicTokenMaker.functions.addAll(basic4gl.getLanguage().getFunctions()); + BasicTokenMaker.operators.addAll(basic4gl.getLanguage().getOperators()); - for (String s : basic4gl.getLanguage().getFunctions()) { - BasicTokenMaker.functions.add(s); - } - - for (String s : basic4gl.getLanguage().getOperators()) { - BasicTokenMaker.operators.add(s); - } presenter.refreshSyntaxHighlighting(); } @@ -1088,10 +1083,18 @@ public void stopOrCancelRunningApplication() { } @Override - public DialogService dialogs() { + public EditorCommandsService commands() { + return commandsService; + } - // TODO implement dialogs for plugins - return null; + @Override + public DebugController debugger() { + return this; + } + + @Override + public DialogService dialogs() { + return dialogService; } @Override @@ -1122,6 +1125,11 @@ public String currentDirectory() { return fileManager.getCurrentDirectory(); } + @Override + public EditorPlugin currentEditor() { + return basic4gl; + } + @Override public String getLibraryPath() { return libraryPath; diff --git a/app/src/main/java/com/basic4gl/desktop/ExportDialog.java b/app/src/main/java/com/basic4gl/desktop/ExportDialog.java index f35cae1e..6fbab002 100644 --- a/app/src/main/java/com/basic4gl/desktop/ExportDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/ExportDialog.java @@ -21,8 +21,11 @@ * Created by Nate on 2/5/2015. */ public class ExportDialog implements com.basic4gl.desktop.spi.ConfigurationFormPanel.IOnConfigurationChangeListener { + private final CompilerService compiler; private final PreprocessorService preprocessor; + private final LanguageService languageService; + private final Vector fileEditors; private final JDialog dialog; @@ -51,12 +54,14 @@ public ExportDialog( Frame parent, CompilerService compiler, PreprocessorService preprocessor, + LanguageService languageService, Vector editors, String exportBaseDirectory, java.util.List contributedExportPages) { this.compiler = compiler; this.preprocessor = preprocessor; + this.languageService = languageService; fileEditors = editors; this.exportBaseDirectory = com.basic4gl.language.adapter.FileUtil.separatorsToSystem(exportBaseDirectory); @@ -530,7 +535,7 @@ private java.util.List detectAssetsFromSourceLiterals() { continue; } - for (String literal : extractStringLiterals(text)) { + for (String literal : languageService.extractStringLiterals(text)) { if (literal == null || literal.isBlank()) { continue; } @@ -555,61 +560,6 @@ private java.util.List detectAssetsFromSourceLiterals() { return new ArrayList<>(detected); } - static java.util.List extractStringLiterals(String text) { - java.util.List literals = new ArrayList<>(); - if (text == null || text.isEmpty()) { - return literals; - } - - int length = text.length(); - int index = 0; - while (index < length) { - char ch = text.charAt(index); - if (ch != '"') { - index++; - continue; - } - - StringBuilder literal = new StringBuilder(); - index++; - boolean escaped = false; - boolean terminated = false; - while (index < length) { - char current = text.charAt(index++); - if (escaped) { - if (current == '"' || current == '\\') { - literal.append(current); - } else { - // Preserve non-quote escape sequences exactly as typed. - literal.append('\\').append(current); - } - escaped = false; - continue; - } - - if (current == '\\') { - escaped = true; - continue; - } - - if (current == '"') { - literals.add(literal.toString()); - terminated = true; - break; - } - - literal.append(current); - } - - // Unterminated string literal: discard and continue scanning. - if (!terminated) { - continue; - } - } - - return literals; - } - private void export() { try { File dest; diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 19aba592..00f5202c 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -1,6 +1,7 @@ package com.basic4gl.desktop; import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.util.HtmlUtil.markdownToHtml; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; import static com.formdev.flatlaf.FlatClientProperties.*; @@ -13,11 +14,9 @@ import com.basic4gl.desktop.debugger.DebugServerFactory; import com.basic4gl.desktop.editor.*; import com.basic4gl.desktop.language.SymbolIndexer; +import com.basic4gl.desktop.panels.*; import com.basic4gl.desktop.spi.*; -import com.basic4gl.desktop.spi.language.FunctionDefinition; -import com.basic4gl.desktop.spi.language.IndexedSymbol; -import com.basic4gl.desktop.spi.language.LabelDefinition; -import com.basic4gl.desktop.spi.language.VariableDefinition; +import com.basic4gl.desktop.util.BasicDialogService; import com.basic4gl.desktop.vmview.DebugControlsListener; import com.basic4gl.desktop.vmview.VirtualMachineViewDialog; import com.basic4gl.language.core.internal.Mutable; @@ -27,7 +26,6 @@ import com.formdev.flatlaf.ui.FlatTabbedPaneUI; import com.formdev.flatlaf.util.SystemInfo; import java.awt.*; -import java.awt.datatransfer.StringSelection; import java.awt.event.*; import java.io.*; import java.nio.charset.StandardCharsets; @@ -39,12 +37,8 @@ import javax.swing.border.BevelBorder; import javax.swing.border.EmptyBorder; import javax.swing.event.*; -import javax.swing.filechooser.FileSystemView; import javax.swing.text.BadLocationException; -import javax.swing.tree.DefaultMutableTreeNode; -import javax.swing.tree.DefaultTreeCellRenderer; -import javax.swing.tree.DefaultTreeModel; -import javax.swing.tree.TreePath; + import org.fife.ui.rsyntaxtextarea.*; import org.fife.ui.rtextarea.SearchContext; @@ -58,7 +52,8 @@ public class MainWindow IFileEditorActionListener, IFileManagerListener, EmptyTabPanel.IEmptyTabPanelListener, - MenuService { + MenuService, + EditorCommandsService { private final CaretListener TrackCaretPosition = new CaretListener() { @Override @@ -97,49 +92,16 @@ public void caretUpdate(CaretEvent e) { private final JToolBar leftSidebarRail = new JToolBar(SwingConstants.VERTICAL); private final ButtonGroup leftSidebarGroup = new ButtonGroup(); private final Map leftSidebarButtons = new HashMap<>(); + private final JTabbedPane docsTabs = new JTabbedPane(); private final JPanel rightDocsContainer = new JPanel(new BorderLayout()); private final JToolBar rightDocsRail = new JToolBar(SwingConstants.VERTICAL); private final ButtonGroup rightDocsGroup = new ButtonGroup(); private final Map rightDocsButtons = new HashMap<>(); - private final JTree fileBrowserTree = new JTree(); - private final JTree assetsTree = new JTree(); - private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); - private boolean showHiddenFiles = false; - private final DefaultListModel assetsListModel = new DefaultListModel<>(); - private final JList assetsGridList = new JList<>(assetsListModel); - private final JPanel assetsContentPanel = new JPanel(new CardLayout()); - private final JComboBox assetsLayoutCombo = new JComboBox<>(new String[] {"Tree", "Grid"}); - private final Map assetThumbnailCache = new HashMap<>(); + private final JComboBox runTargetCombo = new JComboBox<>(); private boolean updatingRunTargetCombo = false; - private final DefaultListModel referenceListModel = new DefaultListModel<>(); - private final JList referenceList = new JList<>(referenceListModel); - private final JTextField referenceSearchField = new JTextField(); - private final JComboBox referenceKindFilter = - new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables", "Structs"}); - private final JComboBox referenceSourceFilter = - new JComboBox<>(new String[] {"All sources", "Builtin", "Libraries", "Program"}); - private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); - private final JButton referenceFiltersButton = new JButton("Filters"); - private final JPopupMenu referenceFiltersPopup = new JPopupMenu(); - private final JTextPane referenceDetailsPane = new JTextPane(); - private final JButton referenceInsertButton = new JButton("Insert"); - private final javax.swing.Timer referenceFilterDebounceTimer = - new javax.swing.Timer(120, e -> filterReferenceItems()); - private boolean updatingReferenceFilters = false; - private static final String REFERENCE_NO_MATCHES_HTML = - "No matches."; - private static final String REFERENCE_SELECT_PROMPT_HTML = - "Select an entry."; - private String referenceDetailsHtml = REFERENCE_SELECT_PROMPT_HTML; - private final java.util.List allReferenceItems = new ArrayList<>(); - // Language support is shared between the symbol indexer and (via BasicTokenMaker) the editor. - private final com.basic4gl.desktop.spi.language.LanguageSupport languageSupport = - new com.basic4gl.language.adapter.Basic4GLLanguageSupport(); - private final SymbolIndexer symbolIndexer = - new SymbolIndexer(languageSupport, this::collectAllSourceText, this::updateProgramSymbols); - private int lastProgramSymbolsFingerprint = Integer.MIN_VALUE; + private int expandedLeftSidebarWidth = 260; private int expandedRightDocsWidth = 320; private String activeLeftSidebarKey = "files"; @@ -150,60 +112,7 @@ public void caretUpdate(CaretEvent e) { private static final String RECENT_WORKSPACES_KEY = "RECENT_WORKSPACES"; private static final int MAX_RECENT_WORKSPACES = 10; - private static final class ReferenceItem { - final String kind; - final String name; - final String signature; - final String library; - final String details; - final String insertText; - final int caretOffset; - - ReferenceItem( - String kind, - String name, - String signature, - String library, - String details, - String insertText, - int caretOffset) { - this.kind = kind; - this.name = name; - this.signature = signature; - this.library = library; - this.details = details; - this.insertText = insertText; - this.caretOffset = caretOffset; - } - @Override - public String toString() { - return signature; - } - } - - private static final class AssetItem { - final String title; - final String subtitle; - final File file; - final Icon icon; - - AssetItem(String title, String subtitle, File file, Icon icon) { - this.title = title; - this.subtitle = subtitle; - this.file = file; - this.icon = icon; - } - - boolean isOpenable() { - return file != null && file.isFile(); - } - - @Override - public String toString() { - return title; - } - } private final JMenu bookmarkSubMenu = new JMenu("Bookmarks"); private final JMenu breakpointSubMenu = new JMenu("Breakpoints"); @@ -267,6 +176,8 @@ public String toString() { private BasicEditor basicEditor; private FileManager fileManager; + IEditorPanelProvider[] panels; + private IncludeLinkGenerator linkGenerator = new IncludeLinkGenerator(this); private SearchContext searchContext; @@ -310,7 +221,7 @@ public static void main(String[] args) { String appHome = System.getenv("APP_HOME"); // APP_HOME is defined in scripts distributed with build if (appHome != null && !appHome.trim().isEmpty()) { File appDirectory = new File(appHome); - File outputBin = new File(appDirectory, "lib/library-1.0-SNAPSHOT.jar"); + File outputBin = new File(appDirectory, "lib/app-runtime-1.0-SNAPSHOT.jar"); File debugServerBin = new File(appDirectory, "lib/debug-server-1.0-SNAPSHOT.jar"); if (outputBin.exists()) { @@ -792,8 +703,8 @@ protected void installDefaults() { configurePrimaryTabHost(); configureSplitTabs(); configureTabContextMenu(); - configureSidebar(); - configureDocsPane(); + configureLeftSidebar(); + configureRightSidebar(); editorSplitPane.setLeftComponent(primaryTabHost); editorSplitPane.setRightComponent(splitTabControl); @@ -850,8 +761,17 @@ public void windowDeactivated(WindowEvent e) {} atmf.putMapping("text/basic4gl", "com.basic4gl.desktop.editor.BasicTokenMaker"); fileManager = new FileManager(this); - - basicEditor = new BasicEditor(outputBinPath, fileManager, this, this); + panels = new IEditorPanelProvider[] { + new AssetsPanelProvider(fileManager), + new FileBrowserPanelProvider(), + new BookmarksPanelProvider(), + new DebugPanelProvider(), + new SymbolsPanelProvider(), + }; + + basicEditor = new BasicEditor(outputBinPath, fileManager, this, + new BasicDialogService(this.frame), + this, this); // TODO Confirm this doesn't break if app is ever signed // getParent @@ -877,7 +797,6 @@ public void windowDeactivated(WindowEvent e) {} loadRecentWorkspaces(); setRecentItems(basicEditor.getRecentFiles()); refreshRunnableFileControls(); - populateDocsFromCompiler(); refreshSidebarContent(); // Warm up the debug server @@ -909,6 +828,7 @@ private void actionExport() { frame, basicEditor.getCompiler(), basicEditor.getPreprocessor(), + basicEditor.getLanguageService(), fileManager.getFileEditors(), fileManager.getCurrentDirectory(), contributedExportPages); @@ -1015,7 +935,9 @@ private void tryCloseWindow() { // ShutDownTomWindowsBasicLib(); frame.dispose(); - symbolIndexer.shutdown(); + for(IEditorPanelProvider panel: panels) { + panel.dispose(); + } System.exit(0); } @@ -1138,7 +1060,8 @@ void actionOpen(File file) { } } - private void actionOpenFolder() { + @Override + public void actionOpenFolder() { JFileChooser chooser = new JFileChooser(fileManager.getCurrentDirectory()); chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); chooser.setAcceptAllFileFilterUsed(false); @@ -1149,7 +1072,8 @@ private void actionOpenFolder() { setWorkspaceDirectory(chooser.getSelectedFile()); } - private void openFileWithPreferredViewer(File file) { + @Override + public void openFileWithPreferredViewer(File file) { if (file == null) { return; } @@ -1429,8 +1353,7 @@ private java.util.List coll File file = editor.getFile(); fileId = file != null ? file.getAbsolutePath() : ""; } - declarations.addAll( - languageSupport.extractDeclarations(editor.getEditorPane().getText(), fileId)); + declarations.addAll(basicEditor.getLanguageService().extractDeclarations(editor.getEditorPane().getText(), fileId)); } return declarations; } @@ -1597,7 +1520,6 @@ public void closeTab(int index) { fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); refreshSidebarContent(); - symbolIndexer.schedule(); } public void addTab() { @@ -1629,7 +1551,9 @@ public void insertUpdate(DocumentEvent e) { int index = getTabIndex(edit.getFilePath()); edit.setModified(); tabControl.setTitleAt(index, edit.getTitle()); - symbolIndexer.schedule(); + for (IEditorPanelProvider panel : panels) { + panel.onFileModified(edit.getFilePath()); + } } @Override @@ -1637,7 +1561,9 @@ public void removeUpdate(DocumentEvent e) { int index = getTabIndex(edit.getFilePath()); edit.setModified(); tabControl.setTitleAt(index, edit.getTitle()); - symbolIndexer.schedule(); + for (IEditorPanelProvider panel : panels) { + panel.onFileModified(edit.getFilePath()); + } } @Override @@ -1670,7 +1596,6 @@ public void changedUpdate(DocumentEvent e) { fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); refreshSidebarContent(); - symbolIndexer.schedule(); } /** @@ -1710,7 +1635,10 @@ public void insertUpdate(DocumentEvent e) { int index = getTabIndex(edit.getFilePath()); edit.setModified(); tabControl.setTitleAt(index, edit.getTitle()); - symbolIndexer.schedule(); + + for (IEditorPanelProvider panel : panels) { + panel.onFileModified(edit.getFilePath()); + } } @Override @@ -1718,7 +1646,10 @@ public void removeUpdate(DocumentEvent e) { int index = getTabIndex(edit.getFilePath()); edit.setModified(); tabControl.setTitleAt(index, edit.getTitle()); - symbolIndexer.schedule(); + + for (IEditorPanelProvider panel : panels) { + panel.onFileModified(edit.getFilePath()); + } } @Override @@ -1746,7 +1677,7 @@ public void changedUpdate(DocumentEvent e) { fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); refreshSidebarContent(); - symbolIndexer.schedule(); + } @Override @@ -1847,10 +1778,10 @@ public void setCompilerStatus(String error) { @Override public void onCompileSucceeded() { - populateDocsFromCompiler(); - // Also sync the indexer immediately so the debounced background pass - // reflects the compiled state right away. - symbolIndexer.indexNow(); + + for (IEditorPanelProvider panel : panels) { + panel.onCompileSucceeded(); + } } @Override @@ -2337,472 +2268,39 @@ private void actionOpenAsset() { } } - private void configureSidebar() { + private void configureLeftSidebar() { leftSidebarRail.setFloatable(false); leftSidebarRail.setRollover(true); - leftSidebarContent.add(buildFileBrowserPanel(), "files"); - leftSidebarContent.add(buildAssetsPanel(), "assets"); - leftSidebarContent.add(buildBookmarkActionsPanel(), "bookmarks"); - leftSidebarContent.add(buildDebugActionsPanel(), "debug"); - addLeftSidebarButton("files", createImageIcon(ICON_MENU_FOLDER), "Files"); - addLeftSidebarButton("assets", createImageIcon(ICON_MENU_ASSETS), "Assets"); - addLeftSidebarButton("bookmarks", createImageIcon(ICON_MENU_BOOKMARKS), "Bookmarks"); + Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) + .forEach(x -> { + leftSidebarContent.add(x.build(this.basicEditor), x.id()); + addLeftSidebarButton(x.id(), createImageIcon(x.getIconPath()), x.getTitle()); + }); + leftSidebarRail.add(Box.createVerticalGlue()); - addLeftSidebarButton("debug", createImageIcon(ICON_MENU_DEBUG), "Debug"); + + Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.SOUTH) + .forEach(x -> { + leftSidebarContent.add(x.build(this.basicEditor), x.id()); + addLeftSidebarButton(x.id(), createImageIcon(x.getIconPath()), x.getTitle()); + }); leftSidebarContainer.add(leftSidebarRail, BorderLayout.WEST); leftSidebarContainer.add(leftSidebarContent, BorderLayout.CENTER); - selectLeftSidebarSection("files", true); - } - - private JPanel buildFileBrowserPanel() { - JPanel panel = new JPanel(new BorderLayout(0, 6)); - JPanel header = new JPanel(new BorderLayout()); - JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); - JLabel title = new JLabel("Workspace Browser"); - title.setBorder(new EmptyBorder(4, 8, 0, 8)); - JButton openFolder = new JButton("Open Folder"); - openFolder.setFocusable(false); - openFolder.addActionListener(e -> actionOpenFolder()); - JButton refresh = new JButton("Refresh"); - refresh.setFocusable(false); - refresh.addActionListener(e -> refreshFileBrowserTree()); - JToggleButton showHiddenToggle = new JToggleButton("Show Hidden"); - showHiddenToggle.setFocusable(false); - showHiddenToggle.setSelected(showHiddenFiles); - showHiddenToggle.addActionListener(e -> { - showHiddenFiles = showHiddenToggle.isSelected(); - refreshFileBrowserTree(); - }); - headerButtons.add(showHiddenToggle); - headerButtons.add(openFolder); - headerButtons.add(refresh); - header.add(title, BorderLayout.WEST); - header.add(headerButtons, BorderLayout.EAST); - panel.add(header, BorderLayout.NORTH); - - fileBrowserTree.setRootVisible(true); - fileBrowserTree.setShowsRootHandles(true); - fileBrowserTree.setRowHeight(22); - fileBrowserTree.setCellRenderer(new DefaultTreeCellRenderer() { - @Override - public Component getTreeCellRendererComponent( - JTree tree, - Object value, - boolean selected, - boolean expanded, - boolean leaf, - int row, - boolean hasFocus) { - JLabel label = (JLabel) - super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); - if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof File file) { - label.setText(file == null ? "" : fileSystemView.getSystemDisplayName(file)); - if (label.getText() == null || label.getText().isBlank()) { - label.setText(file.getName().isBlank() ? file.getPath() : file.getName()); - } - label.setIcon(fileSystemView.getSystemIcon(file)); - label.setToolTipText(file.getAbsolutePath()); - boolean isHidden = file.getName().startsWith("."); - if (isHidden && !selected) { - label.setForeground(new Color(160, 160, 160)); - } - } - return label; - } - }); - fileBrowserTree.addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent e) { - maybeShowWorkspaceBrowserPopup(e); - if (e.getClickCount() != 2) { - return; - } - TreePath path = fileBrowserTree.getPathForLocation(e.getX(), e.getY()); - if (path == null) { - return; - } - Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); - if (!(userObject instanceof File file) || !file.isFile()) { - return; - } - if (file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - openMarkdownInDocsTab(file); - } else { - openFileWithPreferredViewer(file); - } - } - - @Override - public void mousePressed(MouseEvent e) { - maybeShowWorkspaceBrowserPopup(e); - } - - @Override - public void mouseReleased(MouseEvent e) { - maybeShowWorkspaceBrowserPopup(e); - } - }); - JScrollPane scrollPane = new JScrollPane(fileBrowserTree); - configureSmoothScrolling(scrollPane); - panel.add(scrollPane, BorderLayout.CENTER); - return panel; - } - - private void maybeShowWorkspaceBrowserPopup(MouseEvent e) { - if (!e.isPopupTrigger()) { - return; - } - - TreePath path = fileBrowserTree.getPathForLocation(e.getX(), e.getY()); - if (path == null) { - return; - } - fileBrowserTree.setSelectionPath(path); - - Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); - if (!(userObject instanceof File selectedFile)) { - return; - } - - JPopupMenu popup = new JPopupMenu(); - - JMenuItem openItem = new JMenuItem(selectedFile.isDirectory() ? "Open Folder" : "Open"); - openItem.addActionListener(evt -> { - if (selectedFile.isDirectory()) { - setWorkspaceDirectory(selectedFile); - } else if (selectedFile.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - openMarkdownInDocsTab(selectedFile); - } else { - openFileWithPreferredViewer(selectedFile); - } - }); - popup.add(openItem); - - JMenuItem revealItem = new JMenuItem("Reveal in Finder"); - revealItem.addActionListener(evt -> revealInFinder(selectedFile)); - popup.add(revealItem); - - JMenuItem openSystemItem = new JMenuItem("Open with Default App"); - openSystemItem.addActionListener(evt -> openWithSystemDefault(selectedFile)); - popup.add(openSystemItem); - - JMenuItem copyPathItem = new JMenuItem("Copy Path"); - copyPathItem.addActionListener(evt -> { - StringSelection selection = new StringSelection(selectedFile.getAbsolutePath()); - Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); - }); - popup.add(copyPathItem); - - popup.addSeparator(); - JMenuItem refreshItem = new JMenuItem("Refresh"); - refreshItem.addActionListener(evt -> refreshFileBrowserTree()); - popup.add(refreshItem); - - popup.show(fileBrowserTree, e.getX(), e.getY()); - } - - private void openWithSystemDefault(File file) { - if (file == null || !file.exists()) { - return; - } - try { - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(file); - } - } catch (IOException ex) { - JOptionPane.showMessageDialog(frame, "Unable to open file: " + ex.getMessage()); - } - } - - private void revealInFinder(File file) { - if (file == null || !file.exists()) { - return; - } - try { - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().browseFileDirectory(file); - } - } catch (Exception ex) { - // Fallback when browseFileDirectory is unavailable. - openWithSystemDefault(file.getParentFile()); - } - } - - private JPanel buildAssetsPanel() { - JPanel panel = new JPanel(new BorderLayout(0, 6)); - JPanel header = new JPanel(new BorderLayout()); - JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); - JLabel title = new JLabel("Assets"); - title.setBorder(new EmptyBorder(4, 8, 0, 8)); - assetsLayoutCombo.setFocusable(false); - assetsLayoutCombo.addActionListener(e -> { - CardLayout layout = (CardLayout) assetsContentPanel.getLayout(); - layout.show(assetsContentPanel, Objects.toString(assetsLayoutCombo.getSelectedItem(), "Tree")); - }); - JButton refresh = new JButton("Refresh"); - refresh.setFocusable(false); - refresh.addActionListener(e -> refreshAssetsLibrary()); - headerButtons.add(assetsLayoutCombo); - headerButtons.add(refresh); - header.add(title, BorderLayout.WEST); - header.add(headerButtons, BorderLayout.EAST); - panel.add(header, BorderLayout.NORTH); - - assetsTree.setRootVisible(false); - assetsTree.setShowsRootHandles(true); - // Let Swing compute preferred row height so custom/HTML labels do not clip. - assetsTree.setRowHeight(0); - assetsTree.setCellRenderer(new DefaultTreeCellRenderer() { - @Override - public Component getTreeCellRendererComponent( - JTree tree, - Object value, - boolean selected, - boolean expanded, - boolean leaf, - int row, - boolean hasFocus) { - JLabel label = (JLabel) - super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); - if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof AssetItem item) { - boolean isSection = item.file == null; - label.setIcon(item.icon); - label.setIconTextGap(8); - label.setBorder(new EmptyBorder(3, 0, 3, 0)); - label.setText(formatAssetTreeLabel(item, isSection)); - label.setIcon(item.icon); - label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); - } - return label; - } - }); - - assetsTree.addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent e) { - maybeShowAssetsTreePopup(e); - if (e.getClickCount() != 2) { - return; - } - TreePath path = assetsTree.getPathForLocation(e.getX(), e.getY()); - if (path == null) { - return; - } - Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); - if (!(userObject instanceof AssetItem item) || !item.isOpenable()) { - return; - } - openAssetItem(item); - } - - @Override - public void mousePressed(MouseEvent e) { - maybeShowAssetsTreePopup(e); - } - - @Override - public void mouseReleased(MouseEvent e) { - maybeShowAssetsTreePopup(e); - } - }); - - JScrollPane scrollPane = new JScrollPane(assetsTree); - configureSmoothScrolling(scrollPane); - - assetsGridList.setLayoutOrientation(JList.HORIZONTAL_WRAP); - assetsGridList.setVisibleRowCount(-1); - assetsGridList.setFixedCellHeight(112); - assetsGridList.setFixedCellWidth(120); - assetsGridList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - assetsGridList.setCellRenderer(new DefaultListCellRenderer() { - @Override - public Component getListCellRendererComponent( - JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { - JLabel label = - (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); - if (value instanceof AssetItem item) { - label.setText("

    " + escapeHtml(item.title) + "
    "); - label.setIcon(getAssetGridIcon(item)); - label.setHorizontalTextPosition(SwingConstants.CENTER); - label.setVerticalTextPosition(SwingConstants.BOTTOM); - label.setHorizontalAlignment(SwingConstants.CENTER); - label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); - } - return label; - } - }); - assetsGridList.addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent e) { - maybeShowAssetsGridPopup(e); - if (e.getClickCount() != 2) { - return; - } - AssetItem item = assetsGridList.getSelectedValue(); - if (item == null || !item.isOpenable()) { - return; - } - openAssetItem(item); - } - - @Override - public void mousePressed(MouseEvent e) { - maybeShowAssetsGridPopup(e); - } - - @Override - public void mouseReleased(MouseEvent e) { - maybeShowAssetsGridPopup(e); - } - }); - - JScrollPane gridScrollPane = new JScrollPane(assetsGridList); - configureSmoothScrolling(gridScrollPane); - - assetsContentPanel.add(scrollPane, "Tree"); - assetsContentPanel.add(gridScrollPane, "Grid"); - panel.add(assetsContentPanel, BorderLayout.CENTER); - return panel; - } - - private String formatAssetTreeLabel(AssetItem item, boolean isSection) { - if (item == null) { - return ""; - } - String title = escapeHtml(item.title == null ? "" : item.title); - String subtitle = item.subtitle == null ? "" : item.subtitle.trim(); - if (subtitle.isBlank()) { - return isSection ? "" + title + "" : title; - } - String subtitleHtml = escapeHtml(subtitle); - if (isSection) { - return "" + title + " " + subtitleHtml + ""; - } - return "" + title + " " + subtitleHtml + ""; - } - - private void openAssetItem(AssetItem item) { - if (item == null || !item.isOpenable()) { - return; - } - if (item.file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - openMarkdownInDocsTab(item.file); - } else { - openFileWithPreferredViewer(item.file); - } - } - - private void maybeShowAssetsTreePopup(MouseEvent e) { - if (!e.isPopupTrigger()) { - return; - } - TreePath path = assetsTree.getPathForLocation(e.getX(), e.getY()); - if (path == null) { - return; - } - assetsTree.setSelectionPath(path); - Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); - if (!(userObject instanceof AssetItem item)) { - return; - } - showAssetsPopup(item, assetsTree, e.getX(), e.getY()); - } - private void maybeShowAssetsGridPopup(MouseEvent e) { - if (!e.isPopupTrigger()) { - return; - } - int index = assetsGridList.locationToIndex(e.getPoint()); - if (index < 0) { - return; - } - assetsGridList.setSelectedIndex(index); - AssetItem item = assetsGridList.getModel().getElementAt(index); - showAssetsPopup(item, assetsGridList, e.getX(), e.getY()); - } - - private void showAssetsPopup(AssetItem item, Component invoker, int x, int y) { - if (item == null) { - return; - } - - JPopupMenu popup = new JPopupMenu(); - - JMenuItem openItem = new JMenuItem("Open"); - openItem.setEnabled(item.isOpenable()); - openItem.addActionListener(evt -> openAssetItem(item)); - popup.add(openItem); - - JMenuItem revealItem = new JMenuItem("Reveal in Finder"); - revealItem.setEnabled(item.file != null); - revealItem.addActionListener(evt -> revealInFinder(item.file)); - popup.add(revealItem); - - JMenuItem systemItem = new JMenuItem("Open with Default App"); - systemItem.setEnabled(item.file != null); - systemItem.addActionListener(evt -> openWithSystemDefault(item.file)); - popup.add(systemItem); - - JMenuItem copyPathItem = new JMenuItem("Copy Path"); - copyPathItem.setEnabled(item.file != null); - copyPathItem.addActionListener(evt -> { - StringSelection selection = new StringSelection(item.file.getAbsolutePath()); - Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + // Select first panel if available + Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) + .findFirst() + .ifPresent(x -> { + selectLeftSidebarSection(x.id(), true); }); - popup.add(copyPathItem); - - popup.addSeparator(); - JMenuItem refreshItem = new JMenuItem("Refresh Assets"); - refreshItem.addActionListener(evt -> refreshAssetsLibrary()); - popup.add(refreshItem); - - popup.show(invoker, x, y); } - private JPanel buildBookmarkActionsPanel() { - JPanel panel = new JPanel(); - panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); - - JButton next = new JButton("Next bookmark"); - next.addActionListener(e -> fileManager.selectNextBookmark(tabControl.getSelectedIndex())); - JButton previous = new JButton("Previous bookmark"); - previous.addActionListener(e -> fileManager.selectPreviousBookmark(tabControl.getSelectedIndex())); - JButton toggle = new JButton("Toggle bookmark"); - toggle.addActionListener(e -> fileManager.toggleBookmark(tabControl.getSelectedIndex())); - - panel.add(next); - panel.add(previous); - panel.add(toggle); - return panel; - } - private JPanel buildDebugActionsPanel() { - JPanel panel = new JPanel(); - panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); - - JButton toggleDebug = new JButton("Toggle debug mode"); - toggleDebug.addActionListener(e -> actionDebugMode()); - JButton playPause = new JButton("Play/Pause"); - playPause.addActionListener(e -> basicEditor.actionPlayPause()); - JButton stepOver = new JButton("Step over"); - stepOver.addActionListener(e -> basicEditor.actionStep()); - JButton stepInto = new JButton("Step into"); - stepInto.addActionListener(e -> basicEditor.actionStepInto()); - JButton stepOut = new JButton("Step out"); - stepOut.addActionListener(e -> basicEditor.actionStepOutOf()); - - panel.add(toggleDebug); - panel.add(playPause); - panel.add(stepOver); - panel.add(stepInto); - panel.add(stepOut); - return panel; - } + private void configureRightSidebar() { - private void configureDocsPane() { docsTabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); docsTabs.putClientProperty(TABBED_PANE_TAB_CLOSABLE, true); docsTabs.putClientProperty( @@ -2816,140 +2314,9 @@ private void configureDocsPane() { } selectRightDocsSection("functions"); }); - - JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); - JPanel lookupHeader = new JPanel(new BorderLayout(6, 6)); - - JPanel leftHeader = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); - referenceFiltersButton.setFocusable(false); - referenceFiltersButton.setToolTipText("Open reference filters"); - leftHeader.add(referenceFiltersButton); - lookupHeader.add(leftHeader, BorderLayout.WEST); - - lookupHeader.add(referenceSearchField, BorderLayout.CENTER); - referenceInsertButton.setFocusable(false); - referenceInsertButton.setEnabled(false); - lookupHeader.add(referenceInsertButton, BorderLayout.EAST); - - referenceSearchField.setToolTipText("Search by name, signature, or library"); - referenceKindFilter.setToolTipText("Filter by kind"); - referenceSourceFilter.setToolTipText("Filter by builtin, libraries, or program symbols"); - referenceLibraryFilter.setToolTipText("Filter by library name"); - referenceKindFilter.setPrototypeDisplayValue("Functions"); - rebuildReferenceFiltersPopup(); - - referenceFilterDebounceTimer.setRepeats(false); - - referenceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - referenceList.setFixedCellHeight(20); - referenceList.setPrototypeCellValue( - new ReferenceItem("function", "prototype", "prototype(symbol, arg)", "Builtin", "", "", 0)); - referenceList.setCellRenderer(new DefaultListCellRenderer() { - private final ImageIcon functionIcon = createImageIcon(ICON_FUNCTION); - private final ImageIcon variableIcon = createImageIcon(ICON_VARIABLE); - private final ImageIcon labelIcon = createImageIcon(ICON_LABEL); - private final ImageIcon structIcon = createImageIcon(ICON_STRUCT); - - @Override - public Component getListCellRendererComponent( - JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { - JLabel label = - (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); - if (value instanceof ReferenceItem item) { - label.setText(item.signature); - if ("function".equals(item.kind) || "userfunc".equals(item.kind)) { - label.setIcon(functionIcon); - } else if ("label".equals(item.kind)) { - label.setIcon(labelIcon); - } else if ("struc".equals(item.kind)) { - label.setIcon(structIcon); - } else { - label.setIcon(variableIcon); - } - label.setToolTipText(null); - } - return label; - } - }); - - referenceDetailsPane.setEditable(false); - referenceDetailsPane.setContentType("text/html"); - setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); - - JSplitPane lookupSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); - lookupSplit.setResizeWeight(0.65); - lookupSplit.setTopComponent(new JScrollPane(referenceList)); - lookupSplit.setBottomComponent(new JScrollPane(referenceDetailsPane)); - - lookupPanel.add(lookupHeader, BorderLayout.NORTH); - lookupPanel.add(lookupSplit, BorderLayout.CENTER); - docsTabs.addTab("Reference", lookupPanel); - - referenceSearchField.getDocument().addDocumentListener(new DocumentListener() { - @Override - public void insertUpdate(DocumentEvent e) { - requestFilterReferenceItems(); - } - - @Override - public void removeUpdate(DocumentEvent e) { - requestFilterReferenceItems(); - } - - @Override - public void changedUpdate(DocumentEvent e) { - requestFilterReferenceItems(); - } - }); - referenceList.addListSelectionListener(e -> { - if (!e.getValueIsAdjusting()) { - updateReferenceSelectionDetails(); - } - }); - referenceKindFilter.addActionListener(e -> { - if (!updatingReferenceFilters) { - updateReferenceFiltersButtonTooltip(); - filterReferenceItems(); - } - }); - referenceSourceFilter.addActionListener(e -> { - if (!updatingReferenceFilters) { - updateReferenceFiltersButtonTooltip(); - filterReferenceItems(); - } - }); - referenceLibraryFilter.addActionListener(e -> { - if (!updatingReferenceFilters) { - updateReferenceFiltersButtonTooltip(); - filterReferenceItems(); - } - }); - referenceFiltersButton.addActionListener(e -> { - rebuildReferenceFiltersPopup(); - referenceFiltersPopup.show(referenceFiltersButton, 0, referenceFiltersButton.getHeight()); - }); - referenceList.addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent e) { - if (e.getClickCount() == 2) { - insertSelectedReference(); - } - } - }); - referenceInsertButton.addActionListener(e -> insertSelectedReference()); - updateReferenceFiltersButtonTooltip(); - - rightDocsRail.setFloatable(false); - rightDocsRail.setRollover(true); - - addRightDocsButton("functions", createImageIcon(ICON_MENU_FUNCTIONS), "Reference lookup"); - addRightDocsButton("docs", createImageIcon(ICON_MENU_HELP), "Markdown docs"); - - rightDocsContainer.add(rightDocsRail, BorderLayout.EAST); - rightDocsContainer.add(docsTabs, BorderLayout.CENTER); - selectRightDocsSection("functions"); } + private void addLeftSidebarButton(String key, Icon icon, String tooltip) { JToggleButton button = createRailButton(icon, tooltip); button.addActionListener(e -> onLeftSidebarButtonPressed(key)); @@ -2976,12 +2343,6 @@ private JToggleButton createRailButton(Icon icon, String tooltip) { return button; } - private void configureSmoothScrolling(JScrollPane scrollPane) { - scrollPane.getVerticalScrollBar().setUnitIncrement(16); - scrollPane.getVerticalScrollBar().setBlockIncrement(64); - scrollPane.getViewport().setScrollMode(JViewport.BLIT_SCROLL_MODE); - scrollPane.setWheelScrollingEnabled(true); - } private void onLeftSidebarButtonPressed(String key) { if (Objects.equals(activeLeftSidebarKey, key) && isLeftSidebarExpanded()) { @@ -3069,352 +2430,14 @@ private void expandRightDocs() { } private void refreshSidebarContent() { - refreshFileBrowserTree(); - refreshAssetsLibrary(); - } - - private void refreshFileBrowserTree() { - File root = new File(fileManager.getCurrentDirectory()); - DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0); - fileBrowserTree.setModel(new DefaultTreeModel(rootNode)); - if (fileBrowserTree.getRowCount() > 0) { - fileBrowserTree.expandRow(0); - } - } - - private DefaultMutableTreeNode buildFileTreeNode(File file, int depth) { - DefaultMutableTreeNode node = new DefaultMutableTreeNode(file); - if (!file.isDirectory()) { - return node; - } - - File[] children = file.listFiles(); - if (children == null) { - return node; - } - Arrays.sort(children, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); - for (File child : children) { - if (!showHiddenFiles && child.getName().startsWith(".")) { - continue; - } - node.add(buildFileTreeNode(child, depth + 1)); - } - return node; - } - - private void refreshAssetsLibrary() { - File rootDir = new File(fileManager.getCurrentDirectory()); - assetThumbnailCache.clear(); - DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(new AssetItem( - "Assets", - "Workspace resources, libraries, and embedded literals", - null, - createImageIcon(ICON_MENU_ASSETS))); - - DefaultMutableTreeNode workspaceNode = buildMediaTypeSection( - "Workspace Resources", - collectWorkspaceAssets(rootDir, 0, 4), - rootDir, - createImageIcon(ICON_MENU_FOLDER)); - if (workspaceNode != null) { - rootNode.add(workspaceNode); - } - - DefaultMutableTreeNode literalNode = buildMediaTypeSection( - "Embedded Literals", detectLiteralAssets(rootDir), rootDir, createImageIcon(ICON_MENU_ASSETS)); - if (literalNode != null) { - rootNode.add(literalNode); - } - - assetsTree.setModel(new DefaultTreeModel(rootNode)); - for (int i = 0; i < Math.min(4, assetsTree.getRowCount()); i++) { - assetsTree.expandRow(i); - } - - assetsListModel.clear(); - for (AssetItem item : collectOpenableAssets(rootNode)) { - assetsListModel.addElement(item); - } - } - - private java.util.List collectOpenableAssets(DefaultMutableTreeNode rootNode) { - java.util.List items = new ArrayList<>(); - if (rootNode == null) { - return items; - } - java.util.Enumeration enumeration = rootNode.depthFirstEnumeration(); - while (enumeration.hasMoreElements()) { - Object next = enumeration.nextElement(); - if (!(next instanceof DefaultMutableTreeNode node)) { - continue; - } - if (node.getUserObject() instanceof AssetItem item && item.isOpenable()) { - items.add(item); - } - } - return items; - } - - private DefaultMutableTreeNode buildMediaTypeSection( - String title, java.util.List files, File baseDir, Icon sectionIcon) { - if (files == null || files.isEmpty()) { - return null; - } - Map> byMediaType = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - for (File file : files) { - String mediaType = getMediaTypeLabel(file); - byMediaType.computeIfAbsent(mediaType, k -> new ArrayList<>()).add(file); - } - DefaultMutableTreeNode section = - new DefaultMutableTreeNode(new AssetItem(title, files.size() + " file(s)", null, sectionIcon)); - for (String mediaType : List.of("Images", "Audio", "Video", "Text", "Documents", "Other")) { - java.util.List bucket = byMediaType.get(mediaType); - if (bucket == null || bucket.isEmpty()) { - continue; - } - DefaultMutableTreeNode typeNode = new DefaultMutableTreeNode( - new AssetItem(mediaType, bucket.size() + " file(s)", null, createImageIcon(ICON_MENU_FOLDER))); - bucket.sort(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); - for (File file : bucket) { - typeNode.add(new DefaultMutableTreeNode(createAssetItem(file, baseDir, mediaType))); - } - section.add(typeNode); - } - return section; - } - - private void collectResolvedLibraryAssets(java.util.List paths, File baseDir, java.util.List out) { - if (paths == null || paths.isEmpty()) { - return; - } - for (String path : paths) { - if (path == null || path.isBlank()) { - continue; - } - File resolved = resolveAssetReference(path, baseDir, null); - if (resolved == null || !resolved.exists()) { - continue; - } - if (resolved.isDirectory()) { - collectWorkspaceAssets(resolved, 0, 2, out); - } else { - out.add(resolved); - } - } - } - - private java.util.List detectLiteralAssets(File baseDir) { - java.util.LinkedHashSet detected = new java.util.LinkedHashSet<>(); - if (fileManager == null) { - return new ArrayList<>(); - } - - for (com.basic4gl.desktop.editor.FileEditor editor : fileManager.getFileEditors()) { - if (editor == null || editor.getEditorPane() == null) { - continue; - } - String text = editor.getEditorPane().getText(); - if (text == null || text.isBlank()) { - continue; - } - File sourceFile = editor.getFile(); - File sourceParent = - sourceFile != null ? sourceFile.getAbsoluteFile().getParentFile() : null; - for (String literal : ExportDialog.extractStringLiterals(text)) { - if (literal == null || literal.isBlank()) { - continue; - } - File resolved = resolveAssetReference(literal, baseDir, sourceParent); - if (resolved != null && resolved.exists() && resolved.isFile()) { - detected.add(resolved); - } - } + for (IEditorPanelProvider panel : panels) { + panel.refresh(this.basicEditor.getBasic4gl()); } - return new ArrayList<>(detected); } - private java.util.List collectWorkspaceAssets(File directory, int depth, int maxDepth) { - java.util.List assets = new ArrayList<>(); - collectWorkspaceAssets(directory, depth, maxDepth, assets); - return assets; - } - private void collectWorkspaceAssets(File directory, int depth, int maxDepth, java.util.List out) { - if (directory == null || !directory.isDirectory() || depth > maxDepth) { - return; - } - if (shouldSkipAssetDirectory(directory)) { - return; - } - File[] files = directory.listFiles(); - if (files == null) { - return; - } - Arrays.sort(files, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); - for (File file : files) { - if (file == null || file.getName().startsWith(".")) { - continue; - } - if (file.isDirectory()) { - collectWorkspaceAssets(file, depth + 1, maxDepth, out); - } else if (isKnownAssetFile(file)) { - out.add(file); - } - } - } - - private boolean shouldSkipAssetDirectory(File directory) { - String name = directory.getName().toLowerCase(Locale.ROOT); - return name.equals("build") - || name.equals("out") - || name.equals("target") - || name.equals("bin") - || name.equals("dist") - || name.equals("node_modules") - || name.equals(".gradle") - || name.equals(".git"); - } - - private AssetItem createAssetItem(File file, File baseDir, String subtitlePrefix) { - String subtitle = subtitlePrefix; - if (file != null) { - String relative = formatRelativePath(file, baseDir); - if (relative != null && !relative.isBlank()) { - subtitle = subtitle == null || subtitle.isBlank() ? relative : subtitle + " • " + relative; - } - } - return new AssetItem( - file != null ? file.getName() : "(unknown)", - subtitle, - file, - file != null ? fileSystemView.getSystemIcon(file) : createImageIcon(ICON_MENU_FOLDER)); - } - private Icon getAssetGridIcon(AssetItem item) { - if (item == null || item.file == null) { - return createImageIcon(ICON_MENU_ASSETS); - } - String cacheKey = item.file.getAbsolutePath(); - Icon cached = assetThumbnailCache.get(cacheKey); - if (cached != null) { - return cached; - } - - Icon icon = item.icon; - String lower = item.file.getName().toLowerCase(Locale.ROOT); - if (FileViewerFactory.isImageFile(lower)) { - icon = buildImageThumbnailIcon(item.file, 84, 64); - } - if (icon == null) { - icon = createImageIcon(ICON_MENU_ASSETS); - } - assetThumbnailCache.put(cacheKey, icon); - return icon; - } - - private Icon buildImageThumbnailIcon(File file, int maxWidth, int maxHeight) { - try { - java.awt.image.BufferedImage image = javax.imageio.ImageIO.read(file); - if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) { - return null; - } - double scale = Math.min((double) maxWidth / image.getWidth(), (double) maxHeight / image.getHeight()); - scale = Math.min(1.0d, scale); - int width = Math.max(1, (int) Math.round(image.getWidth() * scale)); - int height = Math.max(1, (int) Math.round(image.getHeight() * scale)); - Image scaled = image.getScaledInstance(width, height, Image.SCALE_SMOOTH); - return new ImageIcon(scaled); - } catch (IOException ex) { - return null; - } - } - - private File resolveAssetReference(String literal, File baseDir, File sourceParent) { - if (literal == null || literal.isBlank()) { - return null; - } - String normalized = FileUtil.separatorsToSystem(literal); - File candidate = new File(normalized); - if (candidate.isAbsolute()) { - return candidate; - } - if (baseDir != null) { - File workspace = new File(baseDir, normalized); - if (workspace.exists()) { - return workspace; - } - } - if (sourceParent != null) { - File sibling = new File(sourceParent, normalized); - if (sibling.exists()) { - return sibling; - } - } - return candidate; - } - - private boolean isKnownAssetFile(File file) { - String name = file.getName().toLowerCase(Locale.ROOT); - return getMediaTypeLabel(file) != null && !"Other".equals(getMediaTypeLabel(file)); - } - - private String getMediaTypeLabel(File file) { - if (file == null) { - return "Other"; - } - String name = file.getName().toLowerCase(Locale.ROOT); - if (name.endsWith(".png") - || name.endsWith(".jpg") - || name.endsWith(".jpeg") - || name.endsWith(".gif") - || name.endsWith(".bmp") - || name.endsWith(".webp") - || name.endsWith(".ico")) { - return "Images"; - } - if (name.endsWith(".wav") || name.endsWith(".ogg") || name.endsWith(".mp3") || name.endsWith(".flac")) { - return "Audio"; - } - if (name.endsWith(".mp4") || name.endsWith(".mov") || name.endsWith(".webm")) { - return "Video"; - } - if (name.endsWith(".txt") - || name.endsWith(".md") - || name.endsWith(".json") - || name.endsWith(".xml") - || name.endsWith(".csv") - || name.endsWith(".ini") - || name.endsWith(".cfg") - || name.endsWith(".properties")) { - return "Text"; - } - if (name.endsWith(".pdf") || name.endsWith(".doc") || name.endsWith(".docx") || name.endsWith(".rtf")) { - return "Documents"; - } - return "Other"; - } - - private String formatRelativePath(File file, File baseDir) { - if (file == null) { - return ""; - } - if (baseDir != null) { - try { - java.nio.file.Path relative = baseDir.getAbsoluteFile() - .toPath() - .normalize() - .relativize(file.getAbsoluteFile().toPath().normalize()); - String text = relative.toString().replace('\\', '/'); - if (!text.startsWith("..")) { - return text; - } - } catch (Exception ignored) { - // Fall back to file name below. - } - } - return file.getName(); - } private void refreshRunnableFileControls() { if (fileManager == null) { @@ -3478,7 +2501,7 @@ private void onRunTargetSelectionChanged() { * This gives the {@link SymbolIndexer} full visibility of all open files for the debounced * background scan. */ - private String collectAllSourceText() { + public String collectAllSourceText() { if (fileManager == null) { return ""; } @@ -3492,392 +2515,23 @@ private String collectAllSourceText() { return sb.toString(); } - /** - * Called on the EDT by the {@link SymbolIndexer} callback after each debounce cycle. - * Replaces all "Program" (user-defined) reference items with the freshly scanned symbols and - * refreshes the reference panel. - */ - private void updateProgramSymbols(List symbols) { - int fingerprint = 1; - for (IndexedSymbol symbol : symbols) { - fingerprint = 31 * fingerprint + Objects.hash(symbol.kind(), symbol.name(), symbol.signature()); - } - if (fingerprint == lastProgramSymbolsFingerprint) { - return; - } - lastProgramSymbolsFingerprint = fingerprint; - - // Remove all existing Program-sourced items - allReferenceItems.removeIf(item -> "Program".equals(item.library)); - - // Add newly scanned symbols - for (IndexedSymbol sym : symbols) { - String details; - String insertText; - int caretOffset; - switch (sym.kind()) { - case "userfunc" -> { - details = "" - + "

    " + escapeHtml(sym.name()) + "

    " - + "

    Type: User Function" - + "
    Source: Program

    " - + "

    " + escapeHtml(sym.signature()) + "

    " - + ""; - insertText = sym.name() + "()"; - caretOffset = sym.name().length() + 1; - } - case "label" -> { - details = "" - + "

    " + escapeHtml(sym.name()) + "

    " - + "

    Type: Label" - + "
    Usage: gosub " + escapeHtml(sym.name()) + "" - + " / goto " + escapeHtml(sym.name()) + "

    " - + ""; - insertText = sym.name(); - caretOffset = sym.name().length(); - } - case "struc" -> { - details = "" - + "

    " + escapeHtml(sym.name()) + "

    " - + "

    Type: Struct" - + "
    Source: Program

    " - + "

    " + escapeHtml(sym.signature()) + "

    " - + ""; - insertText = sym.name(); - caretOffset = sym.name().length(); - } - default -> { // "variable" - details = "" - + "

    " + escapeHtml(sym.name()) + "

    " - + "

    Type: Variable" - + "
    Source: Program

    " - + "

    " + escapeHtml(sym.signature()) + "

    " - + ""; - insertText = sym.name(); - caretOffset = sym.name().length(); - } - } - allReferenceItems.add(new ReferenceItem( - sym.kind(), sym.name(), sym.signature(), "Program", details, insertText, caretOffset)); - } - - allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) - .thenComparing(item -> item.kind)); - rebuildLibraryFilterOptions(); - filterReferenceItems(); - refreshAssetsLibrary(); - } - - private void populateDocsFromCompiler() { - if (basicEditor == null || basicEditor.getCompiler() == null) { - return; - } - allReferenceItems.clear(); - allReferenceItems.addAll(buildFunctionReferenceItems(basicEditor.getLanguageService())); - allReferenceItems.addAll(buildConstantReferenceItems(basicEditor.getLanguageService())); - allReferenceItems.addAll(buildLabelReferenceItems(basicEditor.getLanguageService())); - allReferenceItems.addAll(buildVariableReferenceItems(basicEditor.getLanguageService())); - allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) - .thenComparing(item -> item.kind)); - rebuildLibraryFilterOptions(); - filterReferenceItems(); - } - - private java.util.List buildFunctionReferenceItems(LanguageService comp) { - java.util.List items = new ArrayList<>(); - for (FunctionDefinition item : comp.getFunctionDefinitions()) { - if (item == null) { - continue; - } - StringBuilder argsOnly = new StringBuilder(); - if (item.parameters() != null) { - for (VariableDefinition arg : item.parameters()) { - if (argsOnly.length() > 0) { - argsOnly.append(", "); - } - argsOnly.append(arg.signature()); - } - } - String details = "" - + "

    " - + escapeHtml(item.name()) - + "

    Type: Function
    Library: " - + escapeHtml(item.packageName()) - + "

    " - + escapeHtml(item.signature()) - + "

    "; - String insertText = item.hasBrackets() ? item.name() + "()" : item.name() + " "; - int caretOffset = item.hasBrackets() ? item.name().length() + 1 : insertText.length(); - if (item.hasBrackets() && argsOnly.length() > 0) { - insertText = item.name() + "(" + argsOnly + ")"; - caretOffset = item.name().length() + 1; - } - items.add(new ReferenceItem( - "function", item.name(), item.signature(), item.packageName(), details, insertText, caretOffset)); - } - return items; - } - - private java.util.List buildConstantReferenceItems(LanguageService comp) { - java.util.List items = new ArrayList<>(); - for (VariableDefinition item : comp.getConstantDefinitions()) { - if (item == null) { - continue; - } - String details = "" - + "

    " - + escapeHtml(item.name()) - + "

    Type: Constant
    Library: " - + escapeHtml(item.packageName()) - + "

    " - + escapeHtml(item.signature()) - + "

    "; - items.add(new ReferenceItem( - "constant", - item.name(), - item.signature(), - item.packageName(), - details, - item.name(), - item.name().length())); - } - - return items; - } - - private java.util.List buildLabelReferenceItems(LanguageService comp) { - java.util.List items = new ArrayList<>(); - for (LabelDefinition label : comp.getLabelDefinitions()) { - if (label == null) { - continue; - } - String signature = label.signature(); - String details = "" - + "

    " + escapeHtml(label.name()) - + "

    Type: Label
    Usage: " - + "" + escapeHtml(label.usage()) + "" - + "

    "; - items.add(new ReferenceItem( - "label", - label.name(), - signature, - "Program", - details, - label.name(), - label.name().length())); - } - return items; - } - - private java.util.List buildVariableReferenceItems(LanguageService comp) { - java.util.List items = new ArrayList<>(); - for (VariableDefinition variable : comp.getVariableDefinitions()) { - if (variable == null || variable.name() == null || variable.name().isEmpty()) { - continue; - } - String typeStr = variable.type().name(); - String signature = variable.signature(); - String details = "" - + "

    " + escapeHtml(variable.name()) - + "

    Type: Variable
    Data type: " - + escapeHtml(typeStr) + "
    Source: Program

    "; - items.add(new ReferenceItem( - "variable", - variable.name(), - signature, - "Program", - details, - variable.name(), - variable.name().length())); - } - return items; - } - - private void rebuildLibraryFilterOptions() { - String selected = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); - Set libraries = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); - for (ReferenceItem item : allReferenceItems) { - if (item.library != null) { - libraries.add(item.library); - } - } - - updatingReferenceFilters = true; - try { - referenceLibraryFilter.removeAllItems(); - referenceLibraryFilter.addItem("All libraries"); - for (String library : libraries) { - referenceLibraryFilter.addItem(library); - } - referenceLibraryFilter.setSelectedItem(libraries.contains(selected) ? selected : "All libraries"); - } finally { - updatingReferenceFilters = false; - } - rebuildReferenceFiltersPopup(); - updateReferenceFiltersButtonTooltip(); - } - - private void rebuildReferenceFiltersPopup() { - referenceFiltersPopup.removeAll(); - - JMenu typeMenu = new JMenu("Type"); - addReferenceRadioItems(typeMenu, referenceKindFilter, "All", "All"); - addReferenceRadioItems(typeMenu, referenceKindFilter, "Functions", "Functions"); - addReferenceRadioItems(typeMenu, referenceKindFilter, "Constants", "Constants"); - addReferenceRadioItems(typeMenu, referenceKindFilter, "Labels", "Labels"); - addReferenceRadioItems(typeMenu, referenceKindFilter, "Variables", "Variables"); - addReferenceRadioItems(typeMenu, referenceKindFilter, "Structs", "Structs"); - - JMenu sourceMenu = new JMenu("Source"); - addReferenceRadioItems(sourceMenu, referenceSourceFilter, "All sources", "All sources"); - addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Builtin", "Builtin"); - addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Libraries", "Libraries"); - addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Program", "Program"); - - JMenu libraryMenu = new JMenu("Library"); - for (int i = 0; i < referenceLibraryFilter.getItemCount(); i++) { - String item = referenceLibraryFilter.getItemAt(i); - if (item != null) { - addReferenceRadioItems(libraryMenu, referenceLibraryFilter, item, item); - } - } - - JMenuItem resetItem = new JMenuItem("Reset filters"); - resetItem.addActionListener(e -> { - updatingReferenceFilters = true; - try { - referenceKindFilter.setSelectedItem("All"); - referenceSourceFilter.setSelectedItem("All sources"); - referenceLibraryFilter.setSelectedItem("All libraries"); - } finally { - updatingReferenceFilters = false; - } - updateReferenceFiltersButtonTooltip(); - filterReferenceItems(); - }); - - referenceFiltersPopup.add(typeMenu); - referenceFiltersPopup.add(sourceMenu); - referenceFiltersPopup.add(libraryMenu); - referenceFiltersPopup.addSeparator(); - referenceFiltersPopup.add(resetItem); - } - - private void addReferenceRadioItems(JMenu menu, JComboBox combo, String label, String value) { - JRadioButtonMenuItem item = new JRadioButtonMenuItem(label, Objects.equals(combo.getSelectedItem(), value)); - item.addActionListener(e -> combo.setSelectedItem(value)); - menu.add(item); - } - - private void updateReferenceFiltersButtonTooltip() { - String type = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); - String source = Objects.toString(referenceSourceFilter.getSelectedItem(), "All sources"); - String library = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); - referenceFiltersButton.setToolTipText("Type: " + type + " | Source: " + source + " | Library: " + library); - } - - private void requestFilterReferenceItems() { - referenceFilterDebounceTimer.restart(); + @Override + public void selectNextBookmark() { + fileManager.selectNextBookmark(tabControl.getSelectedIndex()); } - private void filterReferenceItems() { - String query = referenceSearchField.getText(); - String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); - String selectedKind = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); - String selectedSource = Objects.toString(referenceSourceFilter.getSelectedItem(), "All sources"); - String selectedLibrary = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); - - ReferenceItem previousSelection = referenceList.getSelectedValue(); - java.util.List matches = new ArrayList<>(); - for (ReferenceItem item : allReferenceItems) { - boolean kindMatches = "All".equals(selectedKind) - || ("Functions".equals(selectedKind) - && ("function".equals(item.kind) || "userfunc".equals(item.kind))) - || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) - || ("Labels".equals(selectedKind) && "label".equals(item.kind)) - || ("Variables".equals(selectedKind) && "variable".equals(item.kind)) - || ("Structs".equals(selectedKind) && "struc".equals(item.kind)); - boolean sourceMatches = "All sources".equals(selectedSource) - || ("Builtin".equals(selectedSource) - && item.library != null - && "Builtin".equalsIgnoreCase(item.library)) - || ("Libraries".equals(selectedSource) - && item.library != null - && !"Builtin".equalsIgnoreCase(item.library) - && !"Program".equalsIgnoreCase(item.library)) - || ("Program".equals(selectedSource) - && item.library != null - && "Program".equalsIgnoreCase(item.library)); - boolean libraryMatches = "All libraries".equals(selectedLibrary) - || (item.library != null && selectedLibrary.equals(item.library)); - if (needle.isEmpty() - || item.name.toLowerCase(Locale.ROOT).contains(needle) - || item.signature.toLowerCase(Locale.ROOT).contains(needle) - || item.kind.toLowerCase(Locale.ROOT).contains(needle) - || (item.library != null - && item.library.toLowerCase(Locale.ROOT).contains(needle))) { - if (kindMatches && sourceMatches && libraryMatches) { - matches.add(item); - } - } - } - - referenceListModel.clear(); - for (ReferenceItem match : matches) { - referenceListModel.addElement(match); - } - - if (!referenceListModel.isEmpty()) { - if (previousSelection != null && matches.contains(previousSelection)) { - referenceList.setSelectedValue(previousSelection, true); - } else { - referenceList.setSelectedIndex(0); - } - } else { - setReferenceDetailsHtml(REFERENCE_NO_MATCHES_HTML); - referenceInsertButton.setEnabled(false); - } + @Override + public void selectPreviousBookmark() { + fileManager.selectPreviousBookmark(tabControl.getSelectedIndex()); } - private void updateReferenceSelectionDetails() { - ReferenceItem item = referenceList.getSelectedValue(); - if (item == null) { - setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); - referenceInsertButton.setEnabled(false); - return; - } - setReferenceDetailsHtml(item.details); - referenceInsertButton.setEnabled(true); + @Override + public void toggleBookmark() { + fileManager.toggleBookmark(tabControl.getSelectedIndex()); } - private void setReferenceDetailsHtml(String html) { - String next = html == null ? REFERENCE_SELECT_PROMPT_HTML : html; - if (Objects.equals(referenceDetailsHtml, next)) { - return; - } - referenceDetailsHtml = next; - referenceDetailsPane.setText(next); - referenceDetailsPane.setCaretPosition(0); - } - private void insertSelectedReference() { - ReferenceItem item = referenceList.getSelectedValue(); - if (item == null) { - return; - } - int selectedTab = tabControl.getSelectedIndex(); - if (selectedTab < 0 || selectedTab >= fileManager.getFileEditors().size()) { - return; - } - JTextArea editorPane = fileManager.getFileEditors().get(selectedTab).getEditorPane(); - int insertStart = editorPane.getSelectionStart(); - editorPane.replaceSelection(item.insertText); - editorPane.setCaretPosition(Math.min( - insertStart + item.caretOffset, editorPane.getDocument().getLength())); - editorPane.requestFocusInWindow(); - } - - private void openMarkdownInDocsTab(File file) { + public void openMarkdownInDocsTab(File file) { File resolved = file.isAbsolute() ? file : new File(fileManager.getCurrentDirectory(), file.getPath()); if (!resolved.exists()) { resolved = file; @@ -3912,35 +2566,6 @@ private void openMarkdownInDocsTab(File file) { } } - private String markdownToHtml(String markdown) { - StringBuilder html = new StringBuilder(""); - for (String line : markdown.split("\\R", -1)) { - String escaped = escapeHtml(line); - if (escaped.startsWith("### ")) { - html.append("

    ").append(escaped.substring(4)).append("

    "); - } else if (escaped.startsWith("## ")) { - html.append("

    ").append(escaped.substring(3)).append("

    "); - } else if (escaped.startsWith("# ")) { - html.append("

    ").append(escaped.substring(2)).append("

    "); - } else if (escaped.startsWith("- ")) { - html.append("

    • ").append(escaped.substring(2)).append("

    "); - } else if (escaped.isBlank()) { - html.append("
    "); - } else { - html.append("

    ").append(escaped).append("

    "); - } - } - html.append(""); - return html.toString(); - } - - private String escapeHtml(String input) { - if (input == null) { - return ""; - } - return input.replace("&", "&").replace("<", "<").replace(">", ">"); - } - private int findOpenTabIndexByPath(String absolutePath) { if (absolutePath == null || absolutePath.isBlank()) { return -1; @@ -3957,7 +2582,7 @@ private int findOpenTabIndexByPath(String absolutePath) { return fileManager.getTabIndex(absolutePath); } - private void setWorkspaceDirectory(File folder) { + public void setWorkspaceDirectory(File folder) { if (folder == null) { return; } @@ -3971,6 +2596,21 @@ private void setWorkspaceDirectory(File folder) { refreshSidebarContent(); } + @Override + public void insertText(String text, int caretOffset) { + + int selectedTab = tabControl.getSelectedIndex(); + if (selectedTab < 0 || selectedTab >= fileManager.getFileEditors().size()) { + return; + } + JTextArea editorPane = fileManager.getFileEditors().get(selectedTab).getEditorPane(); + int insertStart = editorPane.getSelectionStart(); + editorPane.replaceSelection(text); + editorPane.setCaretPosition(Math.min( + insertStart + caretOffset, editorPane.getDocument().getLength())); + editorPane.requestFocusInWindow(); + } + private void registerWorkspace(File folder) { if (folder == null) { return; diff --git a/app/src/main/java/com/basic4gl/desktop/content/AssetService.java b/app/src/main/java/com/basic4gl/desktop/content/AssetService.java new file mode 100644 index 00000000..38497524 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/AssetService.java @@ -0,0 +1,19 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.FileUtil; +import com.basic4gl.desktop.spi.LanguageService; + +import java.io.File; +import java.util.ArrayList; +import java.util.Locale; + +import static com.basic4gl.desktop.util.FileUtil.getMediaTypeLabel; + +public class AssetService { + private final FileManager fileManager; + + public AssetService(FileManager fileManager) { + this.fileManager = fileManager; + } + +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java index 9c54baa1..ca411090 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java @@ -1,15 +1,17 @@ package com.basic4gl.desktop.editor; -import com.basic4gl.language.adapter.Basic4GLLanguageSupport; import java.util.ArrayList; import java.util.List; + +import com.basic4gl.desktop.spi.LanguageService; +import com.basic4gl.language.adapter.Basic4GLLanguageService; import org.fife.ui.rsyntaxtextarea.Token; import org.fife.ui.rsyntaxtextarea.TokenMap; /** * RSyntaxTextArea {@code TokenMaker} for the Basic4GL language. * - *

    All tokenisation logic now lives in {@link Basic4GLLanguageSupport} (backed by the + *

    All tokenisation logic now lives in {@link com.basic4gl.language.adapter.Basic4GLLanguageService} (backed by the * ANTLR-generated {@code Basic4GL} lexer). This class is retained so that: * *

      @@ -35,7 +37,8 @@ public class BasicTokenMaker extends LanguageSupportTokenMaker { /** No-arg constructor used by RSyntaxTextArea's {@code TokenMakerFactory} via reflection. */ public BasicTokenMaker() { - super(new Basic4GLLanguageSupport()); + // TODO... this won't work. + super(new Basic4GLLanguageService()); } /** diff --git a/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java b/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java index 6eb38123..6cef9fe9 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java @@ -1,8 +1,8 @@ package com.basic4gl.desktop.editor; +import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.language.HighlightKind; import com.basic4gl.desktop.spi.language.LangToken; -import com.basic4gl.desktop.spi.language.LanguageSupport; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -13,7 +13,7 @@ /** * Generic RSyntaxTextArea {@link AbstractTokenMaker} that delegates all lexical analysis to a - * pluggable {@link LanguageSupport} instance. + * pluggable {@link LanguageService} instance. * *

      This is the only class in the IDE that imports RSyntaxTextArea types and * bridges them to the language-neutral {@code language} package. Swapping the language is a @@ -27,7 +27,7 @@ public class LanguageSupportTokenMaker extends AbstractTokenMaker { private static final int LINE_TOKEN_CACHE_SIZE = 512; - private final LanguageSupport languageSupport; + private final LanguageService languageSupport; private final Map> lineTokenCache = new LinkedHashMap<>(128, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry> eldest) { @@ -35,7 +35,7 @@ protected boolean removeEldestEntry(Map.Entry> eldest) { } }; - public LanguageSupportTokenMaker(LanguageSupport languageSupport) { + public LanguageSupportTokenMaker(LanguageService languageSupport) { this.languageSupport = languageSupport; } diff --git a/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java index f9abc5f9..e91c313e 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java +++ b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java @@ -1,5 +1,6 @@ package com.basic4gl.desktop.language; +import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.language.IndexedSymbol; import java.lang.reflect.InvocationTargetException; import java.util.List; @@ -14,7 +15,7 @@ * Lightweight debounced symbol indexer. * *

      Listens for source-text changes and, after a short debounce delay, delegates symbol - * extraction to a {@link com.basic4gl.desktop.spi.language.LanguageSupport} instance. Results are delivered via a {@link Callback} + * extraction to a {@link com.basic4gl.desktop.spi.LanguageService} instance. Results are delivered via a {@link Callback} * on the Swing EDT. * *

      The indexer itself contains no language-specific logic; all parsing is @@ -46,7 +47,7 @@ public interface Callback { /** Milliseconds to wait after the last change before running extraction. */ private static final long DEBOUNCE_MILLIS = 400; - private final com.basic4gl.desktop.spi.language.LanguageSupport languageSupport; + private final LanguageService languageSupport; private final SourceProvider sourceProvider; private final Callback callback; @@ -60,7 +61,7 @@ public interface Callback { private long requestedRevision = 0; public SymbolIndexer( - com.basic4gl.desktop.spi.language.LanguageSupport languageSupport, + LanguageService languageSupport, SourceProvider sourceProvider, Callback callback) { this.languageSupport = languageSupport; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java new file mode 100644 index 00000000..c83343d1 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java @@ -0,0 +1,585 @@ +package com.basic4gl.desktop.panels; + +import com.basic4gl.desktop.content.FileManager; +import com.basic4gl.desktop.editor.FileViewerFactory; +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.FileUtil; +import com.basic4gl.desktop.spi.LanguageService; +import com.basic4gl.desktop.spi.PluginContext; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.filechooser.FileSystemView; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.util.*; +import java.util.List; + +import static com.basic4gl.desktop.Theme.ICON_MENU_ASSETS; +import static com.basic4gl.desktop.Theme.ICON_MENU_FOLDER; +import static com.basic4gl.desktop.util.FileUtil.*; +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; +import static com.basic4gl.desktop.util.SwingIconUtil.buildImageThumbnailIcon; +import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; + +public class AssetsPanelProvider implements IEditorPanelProvider { + + private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); + private final JTree assetsTree = new JTree(); + private final DefaultListModel assetsListModel = new DefaultListModel<>(); + private final JList assetsGridList = new JList<>(assetsListModel); + private final JPanel assetsContentPanel = new JPanel(new CardLayout()); + private final JComboBox assetsLayoutCombo = new JComboBox<>(new String[] {"Tree", "Grid"}); + private final Map assetThumbnailCache = new HashMap<>(); + + private FileManager fileManager; + + private PluginContext context; + + private static final class AssetItem { + final String title; + final String subtitle; + final File file; + final Icon icon; + + AssetItem(String title, String subtitle, File file, Icon icon) { + this.title = title; + this.subtitle = subtitle; + this.file = file; + this.icon = icon; + } + + boolean isOpenable() { + return file != null && file.isFile(); + } + + @Override + public String toString() { + return title; + } + } + + public AssetsPanelProvider(FileManager fileManager) { + this.fileManager = fileManager; + } + + @Override + public String id() { + return "assets"; + } + + @Override + public String getTitle() { + return "Assets"; + } + + @Override + public String getIconPath() { + return ICON_MENU_ASSETS; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.WEST; + } + + @Override + public JPanel build(PluginContext context) { + this.context = context; + + JPanel panel = new JPanel(new BorderLayout(0, 6)); + JPanel header = new JPanel(new BorderLayout()); + JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); + JLabel title = new JLabel("Assets"); + title.setBorder(new EmptyBorder(4, 8, 0, 8)); + assetsLayoutCombo.setFocusable(false); + assetsLayoutCombo.addActionListener(e -> { + CardLayout layout = (CardLayout) assetsContentPanel.getLayout(); + layout.show(assetsContentPanel, Objects.toString(assetsLayoutCombo.getSelectedItem(), "Tree")); + }); + JButton refresh = new JButton("Refresh"); + refresh.setFocusable(false); + refresh.addActionListener(e -> refresh(context.currentEditor())); + headerButtons.add(assetsLayoutCombo); + headerButtons.add(refresh); + header.add(title, BorderLayout.WEST); + header.add(headerButtons, BorderLayout.EAST); + panel.add(header, BorderLayout.NORTH); + + assetsTree.setRootVisible(false); + assetsTree.setShowsRootHandles(true); + // Let Swing compute preferred row height so custom/HTML labels do not clip. + assetsTree.setRowHeight(0); + assetsTree.setCellRenderer(new DefaultTreeCellRenderer() { + @Override + public Component getTreeCellRendererComponent( + JTree tree, + Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + JLabel label = (JLabel) + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof AssetItem item) { + boolean isSection = item.file == null; + label.setIcon(item.icon); + label.setIconTextGap(8); + label.setBorder(new EmptyBorder(3, 0, 3, 0)); + label.setText(formatAssetTreeLabel(item, isSection)); + label.setIcon(item.icon); + label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); + } + return label; + } + }); + + assetsTree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + maybeShowAssetsTreePopup(e); + if (e.getClickCount() != 2) { + return; + } + TreePath path = assetsTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof AssetItem item) || !item.isOpenable()) { + return; + } + openAssetItem(item); + } + + @Override + public void mousePressed(MouseEvent e) { + maybeShowAssetsTreePopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowAssetsTreePopup(e); + } + }); + + JScrollPane scrollPane = new JScrollPane(assetsTree); + configureSmoothScrolling(scrollPane); + + assetsGridList.setLayoutOrientation(JList.HORIZONTAL_WRAP); + assetsGridList.setVisibleRowCount(-1); + assetsGridList.setFixedCellHeight(112); + assetsGridList.setFixedCellWidth(120); + assetsGridList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + assetsGridList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof AssetItem item) { + label.setText("

      " + escapeHtml(item.title) + "
      "); + label.setIcon(getAssetGridIcon(item)); + label.setHorizontalTextPosition(SwingConstants.CENTER); + label.setVerticalTextPosition(SwingConstants.BOTTOM); + label.setHorizontalAlignment(SwingConstants.CENTER); + label.setToolTipText(item.file != null ? item.file.getAbsolutePath() : item.subtitle); + } + return label; + } + }); + assetsGridList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + maybeShowAssetsGridPopup(e); + if (e.getClickCount() != 2) { + return; + } + AssetItem item = assetsGridList.getSelectedValue(); + if (item == null || !item.isOpenable()) { + return; + } + openAssetItem(item); + } + + @Override + public void mousePressed(MouseEvent e) { + maybeShowAssetsGridPopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowAssetsGridPopup(e); + } + }); + + JScrollPane gridScrollPane = new JScrollPane(assetsGridList); + configureSmoothScrolling(gridScrollPane); + + assetsContentPanel.add(scrollPane, "Tree"); + assetsContentPanel.add(gridScrollPane, "Grid"); + panel.add(assetsContentPanel, BorderLayout.CENTER); + return panel; + } + + private String formatAssetTreeLabel(AssetItem item, boolean isSection) { + if (item == null) { + return ""; + } + String title = escapeHtml(item.title == null ? "" : item.title); + String subtitle = item.subtitle == null ? "" : item.subtitle.trim(); + if (subtitle.isBlank()) { + return isSection ? "" + title + "" : title; + } + String subtitleHtml = escapeHtml(subtitle); + if (isSection) { + return "" + title + " " + subtitleHtml + ""; + } + return "" + title + " " + subtitleHtml + ""; + } + + private void openAssetItem(AssetItem item) { + if (item == null || !item.isOpenable()) { + return; + } + if (item.file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + // TODO add markdown viewer instead of openMarkdownInDocsTab; md, docs, and assets/browser should be separate concerns + context.commands().openMarkdownInDocsTab(item.file); + } else { + context.commands().openFileWithPreferredViewer(item.file); + } + } + + private void maybeShowAssetsTreePopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + TreePath path = assetsTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + assetsTree.setSelectionPath(path); + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof AssetItem item)) { + return; + } + showAssetsPopup(item, assetsTree, e.getX(), e.getY()); + } + + private void maybeShowAssetsGridPopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + int index = assetsGridList.locationToIndex(e.getPoint()); + if (index < 0) { + return; + } + assetsGridList.setSelectedIndex(index); + AssetItem item = assetsGridList.getModel().getElementAt(index); + showAssetsPopup(item, assetsGridList, e.getX(), e.getY()); + } + + private void showAssetsPopup(AssetItem item, Component invoker, int x, int y) { + if (item == null) { + return; + } + + JPopupMenu popup = new JPopupMenu(); + + JMenuItem openItem = new JMenuItem("Open"); + openItem.setEnabled(item.isOpenable()); + openItem.addActionListener(evt -> openAssetItem(item)); + popup.add(openItem); + + JMenuItem revealItem = new JMenuItem("Reveal in Finder"); + revealItem.setEnabled(item.file != null); + revealItem.addActionListener(evt -> revealInFinder(item.file, context.dialogs())); + popup.add(revealItem); + + JMenuItem systemItem = new JMenuItem("Open with Default App"); + systemItem.setEnabled(item.file != null); + systemItem.addActionListener(evt -> openWithSystemDefault(item.file, context.dialogs())); + popup.add(systemItem); + + JMenuItem copyPathItem = new JMenuItem("Copy Path"); + copyPathItem.setEnabled(item.file != null); + copyPathItem.addActionListener(evt -> { + StringSelection selection = new StringSelection(item.file.getAbsolutePath()); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + }); + popup.add(copyPathItem); + + popup.addSeparator(); + JMenuItem refreshItem = new JMenuItem("Refresh Assets"); + refreshItem.addActionListener(evt -> refresh(context.currentEditor())); + popup.add(refreshItem); + + popup.show(invoker, x, y); + } + + @Override + public void refresh(EditorPlugin languageProvider) { + File rootDir = new File(context.currentDirectory()); + assetThumbnailCache.clear(); + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(new AssetItem( + "Assets", + "Workspace resources, libraries, and embedded literals", + null, + createImageIcon(ICON_MENU_ASSETS))); + + DefaultMutableTreeNode workspaceNode = buildMediaTypeSection( + "Workspace Resources", + collectWorkspaceAssets(rootDir, 0, 4), + rootDir, + createImageIcon(ICON_MENU_FOLDER)); + if (workspaceNode != null) { + rootNode.add(workspaceNode); + } + + DefaultMutableTreeNode literalNode = buildMediaTypeSection( + "Embedded Literals", detectLiteralAssets(rootDir, context.currentEditor().getLanguage()), rootDir, createImageIcon(ICON_MENU_ASSETS)); + if (literalNode != null) { + rootNode.add(literalNode); + } + + assetsTree.setModel(new DefaultTreeModel(rootNode)); + for (int i = 0; i < Math.min(4, assetsTree.getRowCount()); i++) { + assetsTree.expandRow(i); + } + + assetsListModel.clear(); + for (AssetItem item : collectOpenableAssets(rootNode)) { + assetsListModel.addElement(item); + } + } + + @Override + public void onFileModified(String filePath) { + + } + + @Override + public void dispose() { + + } + + @Override + public void onCompileSucceeded() { + + } + + private java.util.List collectOpenableAssets(DefaultMutableTreeNode rootNode) { + java.util.List items = new ArrayList<>(); + if (rootNode == null) { + return items; + } + java.util.Enumeration enumeration = rootNode.depthFirstEnumeration(); + while (enumeration.hasMoreElements()) { + Object next = enumeration.nextElement(); + if (!(next instanceof DefaultMutableTreeNode node)) { + continue; + } + if (node.getUserObject() instanceof AssetItem item && item.isOpenable()) { + items.add(item); + } + } + return items; + } + + private DefaultMutableTreeNode buildMediaTypeSection( + String title, java.util.List files, File baseDir, Icon sectionIcon) { + if (files == null || files.isEmpty()) { + return null; + } + Map> byMediaType = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (File file : files) { + String mediaType = getMediaTypeLabel(file); + byMediaType.computeIfAbsent(mediaType, k -> new ArrayList<>()).add(file); + } + DefaultMutableTreeNode section = + new DefaultMutableTreeNode(new AssetItem(title, files.size() + " file(s)", null, sectionIcon)); + for (String mediaType : List.of("Images", "Audio", "Video", "Text", "Documents", "Other")) { + java.util.List bucket = byMediaType.get(mediaType); + if (bucket == null || bucket.isEmpty()) { + continue; + } + DefaultMutableTreeNode typeNode = new DefaultMutableTreeNode( + new AssetItem(mediaType, bucket.size() + " file(s)", null, createImageIcon(ICON_MENU_FOLDER))); + bucket.sort(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File file : bucket) { + typeNode.add(new DefaultMutableTreeNode(createAssetItem(file, baseDir, mediaType))); + } + section.add(typeNode); + } + return section; + } + + private void collectResolvedLibraryAssets(java.util.List paths, File baseDir, java.util.List out) { + if (paths == null || paths.isEmpty()) { + return; + } + for (String path : paths) { + if (path == null || path.isBlank()) { + continue; + } + File resolved = resolveAssetReference(path, baseDir, null); + if (resolved == null || !resolved.exists()) { + continue; + } + if (resolved.isDirectory()) { + collectWorkspaceAssets(resolved, 0, 2, out); + } else { + out.add(resolved); + } + } + } + + + + private java.util.List collectWorkspaceAssets(File directory, int depth, int maxDepth) { + java.util.List assets = new ArrayList<>(); + collectWorkspaceAssets(directory, depth, maxDepth, assets); + return assets; + } + + private void collectWorkspaceAssets(File directory, int depth, int maxDepth, java.util.List out) { + if (directory == null || !directory.isDirectory() || depth > maxDepth) { + return; + } + if (shouldSkipAssetDirectory(directory)) { + return; + } + + File[] files = directory.listFiles(); + if (files == null) { + return; + } + Arrays.sort(files, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File file : files) { + if (file == null || file.getName().startsWith(".")) { + continue; + } + if (file.isDirectory()) { + collectWorkspaceAssets(file, depth + 1, maxDepth, out); + } else if (isKnownAssetFile(file)) { + out.add(file); + } + } + } + + private boolean shouldSkipAssetDirectory(File directory) { + String name = directory.getName().toLowerCase(Locale.ROOT); + return name.equals("build") + || name.equals("out") + || name.equals("target") + || name.equals("bin") + || name.equals("dist") + || name.equals("node_modules") + || name.equals(".gradle") + || name.equals(".git"); + } + + private AssetItem createAssetItem(File file, File baseDir, String subtitlePrefix) { + String subtitle = subtitlePrefix; + if (file != null) { + String relative = formatRelativePath(file, baseDir); + if (relative != null && !relative.isBlank()) { + subtitle = subtitle == null || subtitle.isBlank() ? relative : subtitle + " • " + relative; + } + } + return new AssetItem( + file != null ? file.getName() : "(unknown)", + subtitle, + file, + file != null ? fileSystemView.getSystemIcon(file) : createImageIcon(ICON_MENU_FOLDER)); + } + + private Icon getAssetGridIcon(AssetItem item) { + if (item == null || item.file == null) { + return createImageIcon(ICON_MENU_ASSETS); + } + String cacheKey = item.file.getAbsolutePath(); + Icon cached = assetThumbnailCache.get(cacheKey); + if (cached != null) { + return cached; + } + + Icon icon = item.icon; + String lower = item.file.getName().toLowerCase(Locale.ROOT); + if (FileViewerFactory.isImageFile(lower)) { + icon = buildImageThumbnailIcon(item.file, 84, 64); + } + if (icon == null) { + icon = createImageIcon(ICON_MENU_ASSETS); + } + assetThumbnailCache.put(cacheKey, icon); + return icon; + } + private java.util.List detectLiteralAssets(File baseDir, LanguageService languageService) { + java.util.LinkedHashSet detected = new java.util.LinkedHashSet<>(); + if (fileManager == null) { + return new ArrayList<>(); + } + + for (com.basic4gl.desktop.editor.FileEditor editor : fileManager.getFileEditors()) { + if (editor == null || editor.getEditorPane() == null) { + continue; + } + String text = editor.getEditorPane().getText(); + if (text == null || text.isBlank()) { + continue; + } + File sourceFile = editor.getFile(); + File sourceParent = + sourceFile != null ? sourceFile.getAbsoluteFile().getParentFile() : null; + for (String literal : languageService.extractStringLiterals(text)) { + if (literal == null || literal.isBlank()) { + continue; + } + File resolved = resolveAssetReference(literal, baseDir, sourceParent); + if (resolved != null && resolved.exists() && resolved.isFile()) { + detected.add(resolved); + } + } + } + return new ArrayList<>(detected); + } + + private File resolveAssetReference(String literal, File baseDir, File sourceParent) { + if (literal == null || literal.isBlank()) { + return null; + } + String normalized = FileUtil.separatorsToSystem(literal); + File candidate = new File(normalized); + if (candidate.isAbsolute()) { + return candidate; + } + if (baseDir != null) { + File workspace = new File(baseDir, normalized); + if (workspace.exists()) { + return workspace; + } + } + if (sourceParent != null) { + File sibling = new File(sourceParent, normalized); + if (sibling.exists()) { + return sibling; + } + } + return candidate; + } + + private boolean isKnownAssetFile(File file) { + String name = file.getName().toLowerCase(Locale.ROOT); + return getMediaTypeLabel(file) != null && !"Other".equals(getMediaTypeLabel(file)); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java new file mode 100644 index 00000000..e878567f --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java @@ -0,0 +1,70 @@ +package com.basic4gl.desktop.panels; + +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; + +import javax.swing.*; + +import static com.basic4gl.desktop.Theme.ICON_MENU_BOOKMARKS; + +public class BookmarksPanelProvider implements IEditorPanelProvider { + @Override + public String id() { + return "bookmarks"; + } + + @Override + public String getTitle() { + return "Bookmarks"; + } + + @Override + public String getIconPath() { + return ICON_MENU_BOOKMARKS; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.WEST; + } + + @Override + public JPanel build(PluginContext context) { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + + + JButton next = new JButton("Next bookmark"); + next.addActionListener(e -> context.commands().selectNextBookmark()); + JButton previous = new JButton("Previous bookmark"); + previous.addActionListener(e -> context.commands().selectPreviousBookmark()); + JButton toggle = new JButton("Toggle bookmark"); + toggle.addActionListener(e -> context.commands().toggleBookmark()); + + panel.add(next); + panel.add(previous); + panel.add(toggle); + return panel; + } + + @Override + public void refresh(EditorPlugin languageProvider) { + + } + + @Override + public void onFileModified(String filePath) { + + } + + @Override + public void dispose() { + + } + + @Override + public void onCompileSucceeded() { + + } + +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java new file mode 100644 index 00000000..88366b28 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java @@ -0,0 +1,74 @@ +package com.basic4gl.desktop.panels; + +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; + +import javax.swing.*; + +import static com.basic4gl.desktop.Theme.ICON_MENU_DEBUG; + +public class DebugPanelProvider implements IEditorPanelProvider { + @Override + public String id() { + return "debug"; + } + + @Override + public String getTitle() { + return "Debug"; + } + + @Override + public String getIconPath() { + return ICON_MENU_DEBUG; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.SOUTH; + } + + @Override + public JPanel build(PluginContext context) { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); +// +// JButton toggleDebug = new JButton("Toggle debug mode"); +// toggleDebug.addActionListener(e -> actionDebugMode()); + JButton playPause = new JButton("Play/Pause"); + playPause.addActionListener(e -> context.debugger().actionPlayPause()); + JButton stepOver = new JButton("Step over"); + stepOver.addActionListener(e -> context.debugger().actionStep()); + JButton stepInto = new JButton("Step into"); + stepInto.addActionListener(e -> context.debugger().actionStepInto()); + JButton stepOut = new JButton("Step out"); + stepOut.addActionListener(e -> context.debugger().actionStepOutOf()); + +// panel.add(toggleDebug); + panel.add(playPause); + panel.add(stepOver); + panel.add(stepInto); + panel.add(stepOut); + return panel; + } + + @Override + public void refresh(EditorPlugin languageProvider) { + + } + + @Override + public void onFileModified(String filePath) { + + } + + @Override + public void dispose() { + + } + + @Override + public void onCompileSucceeded() { + + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java new file mode 100644 index 00000000..cb1a1758 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -0,0 +1,55 @@ +package com.basic4gl.desktop.panels; + +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; + +import javax.swing.*; + +import static com.basic4gl.desktop.Theme.ICON_MENU_HELP; + +public class DocsPanelProvider implements IEditorPanelProvider { + @Override + public String id() { + return "docs"; + } + + @Override + public String getTitle() { + return "Documentation"; + } + + @Override + public String getIconPath() { + return ICON_MENU_HELP; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.WEST; + } + + @Override + public JPanel build(PluginContext context) { + return null; + } + + @Override + public void refresh(EditorPlugin languageProvider) { + + } + + @Override + public void onFileModified(String filePath) { + + } + + @Override + public void dispose() { + + } + + @Override + public void onCompileSucceeded() { + + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/EditorLayout.java b/app/src/main/java/com/basic4gl/desktop/panels/EditorLayout.java new file mode 100644 index 00000000..f1c5e4fb --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/EditorLayout.java @@ -0,0 +1,7 @@ +package com.basic4gl.desktop.panels; + +public enum EditorLayout { + EAST, + WEST, + SOUTH +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java new file mode 100644 index 00000000..7c6d5b78 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java @@ -0,0 +1,248 @@ +package com.basic4gl.desktop.panels; + +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.FileUtil; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.filechooser.FileSystemView; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Locale; + +import static com.basic4gl.desktop.Theme.ICON_MENU_FOLDER; +import static com.basic4gl.desktop.Theme.ICON_MENU_HELP; +import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; + +public class FileBrowserPanelProvider implements IEditorPanelProvider { + + private PluginContext context; + + private final JTree fileBrowserTree = new JTree(); + private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); + private boolean showHiddenFiles = false; + + + @Override + public String id() { + return "files"; + } + + @Override + public String getTitle() { + return "Workspace"; + } + + @Override + public String getIconPath() { + return ICON_MENU_FOLDER; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.WEST; + } + + @Override + public JPanel build(PluginContext context) { + JPanel panel = new JPanel(new BorderLayout(0, 6)); + JPanel header = new JPanel(new BorderLayout()); + JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); + JLabel title = new JLabel("Workspace Browser"); + title.setBorder(new EmptyBorder(4, 8, 0, 8)); + JButton openFolder = new JButton("Open Folder"); + openFolder.setFocusable(false); + openFolder.addActionListener(e -> context.commands().actionOpenFolder()); + JButton refresh = new JButton("Refresh"); + refresh.setFocusable(false); + refresh.addActionListener(e -> refresh(context.currentEditor())); + JToggleButton showHiddenToggle = new JToggleButton("Show Hidden"); + showHiddenToggle.setFocusable(false); + showHiddenToggle.setSelected(showHiddenFiles); + showHiddenToggle.addActionListener(e -> { + showHiddenFiles = showHiddenToggle.isSelected(); + refresh(context.currentEditor()); + }); + headerButtons.add(showHiddenToggle); + headerButtons.add(openFolder); + headerButtons.add(refresh); + header.add(title, BorderLayout.WEST); + header.add(headerButtons, BorderLayout.EAST); + panel.add(header, BorderLayout.NORTH); + + fileBrowserTree.setRootVisible(true); + fileBrowserTree.setShowsRootHandles(true); + fileBrowserTree.setRowHeight(22); + fileBrowserTree.setCellRenderer(new DefaultTreeCellRenderer() { + @Override + public Component getTreeCellRendererComponent( + JTree tree, + Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + JLabel label = (JLabel) + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof File file) { + label.setText(file == null ? "" : fileSystemView.getSystemDisplayName(file)); + if (label.getText() == null || label.getText().isBlank()) { + label.setText(file.getName().isBlank() ? file.getPath() : file.getName()); + } + label.setIcon(fileSystemView.getSystemIcon(file)); + label.setToolTipText(file.getAbsolutePath()); + boolean isHidden = file.getName().startsWith("."); + if (isHidden && !selected) { + label.setForeground(new Color(160, 160, 160)); + } + } + return label; + } + }); + fileBrowserTree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + maybeShowWorkspaceBrowserPopup(e); + if (e.getClickCount() != 2) { + return; + } + TreePath path = fileBrowserTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof File file) || !file.isFile()) { + return; + } + if (file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + context.commands().openMarkdownInDocsTab(file); + } else { + context.commands().openFileWithPreferredViewer(file); + } + } + + @Override + public void mousePressed(MouseEvent e) { + maybeShowWorkspaceBrowserPopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + maybeShowWorkspaceBrowserPopup(e); + } + }); + JScrollPane scrollPane = new JScrollPane(fileBrowserTree); + configureSmoothScrolling(scrollPane); + panel.add(scrollPane, BorderLayout.CENTER); + return panel; + } + + private void maybeShowWorkspaceBrowserPopup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + + TreePath path = fileBrowserTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + fileBrowserTree.setSelectionPath(path); + + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof File selectedFile)) { + return; + } + + JPopupMenu popup = new JPopupMenu(); + + JMenuItem openItem = new JMenuItem(selectedFile.isDirectory() ? "Open Folder" : "Open"); + openItem.addActionListener(evt -> { + if (selectedFile.isDirectory()) { + context.commands().setWorkspaceDirectory(selectedFile); + } else if (selectedFile.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + context.commands().openMarkdownInDocsTab(selectedFile); + } else { + context.commands().openFileWithPreferredViewer(selectedFile); + } + }); + popup.add(openItem); + + JMenuItem revealItem = new JMenuItem("Reveal in Finder"); + revealItem.addActionListener(evt -> FileUtil.revealInFinder(selectedFile, context.dialogs())); + popup.add(revealItem); + + JMenuItem openSystemItem = new JMenuItem("Open with Default App"); + openSystemItem.addActionListener(evt -> FileUtil.openWithSystemDefault(selectedFile, context.dialogs())); + popup.add(openSystemItem); + + JMenuItem copyPathItem = new JMenuItem("Copy Path"); + copyPathItem.addActionListener(evt -> { + StringSelection selection = new StringSelection(selectedFile.getAbsolutePath()); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + }); + popup.add(copyPathItem); + + popup.addSeparator(); + JMenuItem refreshItem = new JMenuItem("Refresh"); + refreshItem.addActionListener(evt -> refresh(context.currentEditor())); + popup.add(refreshItem); + + popup.show(fileBrowserTree, e.getX(), e.getY()); + } + + @Override + public void refresh(EditorPlugin languageProvider) { + File root = new File(context.currentDirectory()); + DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0); + fileBrowserTree.setModel(new DefaultTreeModel(rootNode)); + if (fileBrowserTree.getRowCount() > 0) { + fileBrowserTree.expandRow(0); + } + } + + @Override + public void onFileModified(String filePath) { + + } + + @Override + public void dispose() { + + } + + @Override + public void onCompileSucceeded() { + + } + + private DefaultMutableTreeNode buildFileTreeNode(File file, int depth) { + DefaultMutableTreeNode node = new DefaultMutableTreeNode(file); + if (!file.isDirectory()) { + return node; + } + + File[] children = file.listFiles(); + if (children == null) { + return node; + } + Arrays.sort(children, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File child : children) { + if (!showHiddenFiles && child.getName().startsWith(".")) { + continue; + } + node.add(buildFileTreeNode(child, depth + 1)); + } + return node; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java new file mode 100644 index 00000000..579fd78a --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java @@ -0,0 +1,27 @@ +package com.basic4gl.desktop.panels; + +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.PluginContext; + +import javax.swing.*; + +public interface IEditorPanelProvider { + String id(); + String getTitle(); + String getIconPath(); + EditorLayout getLayoutConstraints(); + JPanel build(PluginContext context); + + + void refresh(EditorPlugin languageProvider); + + void onFileModified(String filePath); + + void dispose(); +// +// void onTabClosed(); + // TODO cleanup hooks; this should be a separate listener that can be registered with PluginContext from build() + void onCompileSucceeded(); +// + +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java new file mode 100644 index 00000000..80d485ee --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -0,0 +1,648 @@ +package com.basic4gl.desktop.panels; + +import com.basic4gl.desktop.language.SymbolIndexer; +import com.basic4gl.desktop.spi.EditorPlugin; +import com.basic4gl.desktop.spi.LanguageService; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.language.FunctionDefinition; +import com.basic4gl.desktop.spi.language.IndexedSymbol; +import com.basic4gl.desktop.spi.language.LabelDefinition; +import com.basic4gl.desktop.spi.language.VariableDefinition; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.*; +import java.util.List; +import java.util.function.BiConsumer; + +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.Theme.ICON_MENU_FUNCTIONS; +import static com.basic4gl.desktop.Theme.ICON_MENU_HELP; +import static com.basic4gl.desktop.Theme.ICON_STRUCT; +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; +import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.formdev.flatlaf.FlatClientProperties.TABBED_PANE_TAB_CLOSABLE; +import static com.formdev.flatlaf.FlatClientProperties.TABBED_PANE_TAB_CLOSE_CALLBACK; + +public class SymbolsPanelProvider implements IEditorPanelProvider { + + private static final String REFERENCE_NO_MATCHES_HTML = + "No matches."; + private static final String REFERENCE_SELECT_PROMPT_HTML = + "Select an entry."; + private String referenceDetailsHtml = REFERENCE_SELECT_PROMPT_HTML; + private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); + private final JButton referenceFiltersButton = new JButton("Filters"); + private final JPopupMenu referenceFiltersPopup = new JPopupMenu(); + private final JTextPane referenceDetailsPane = new JTextPane(); + private final JButton referenceInsertButton = new JButton("Insert"); + private final javax.swing.Timer referenceFilterDebounceTimer = + new javax.swing.Timer(120, e -> filterReferenceItems()); + + private SymbolIndexer symbolIndexer; + private final java.util.List allReferenceItems = new ArrayList<>(); + private final DefaultListModel referenceListModel = new DefaultListModel<>(); + private final JList referenceList = new JList<>(referenceListModel); + private final JTextField referenceSearchField = new JTextField(); + private final JComboBox referenceKindFilter = + new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables", "Structs"}); + private final JComboBox referenceSourceFilter = + new JComboBox<>(new String[] {"All sources", "Builtin", "Libraries", "Program"}); + + + private int lastProgramSymbolsFingerprint = Integer.MIN_VALUE; + private boolean updatingReferenceFilters = false; + + private PluginContext context; + + private static final class ReferenceItem { + final String kind; + final String name; + final String signature; + final String library; + final String details; + final String insertText; + final int caretOffset; + + ReferenceItem( + String kind, + String name, + String signature, + String library, + String details, + String insertText, + int caretOffset) { + this.kind = kind; + this.name = name; + this.signature = signature; + this.library = library; + this.details = details; + this.insertText = insertText; + this.caretOffset = caretOffset; + } + + @Override + public String toString() { + return signature; + } + } + + + @Override + public String id() { + return "symbols"; + } + + @Override + public String getTitle() { + return "View Symbols"; + } + + @Override + public String getIconPath() { + return ICON_MENU_FUNCTIONS; + } + + @Override + public EditorLayout getLayoutConstraints() { + return EditorLayout.WEST; + } + + public JPanel build(PluginContext context) { + this.context = context; + + symbolIndexer = + new SymbolIndexer(context.currentEditor().getLanguage(), context.commands()::collectAllSourceText, this::updateProgramSymbols); + JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); + JPanel lookupHeader = new JPanel(new BorderLayout(6, 6)); + + JPanel leftHeader = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + referenceFiltersButton.setFocusable(false); + referenceFiltersButton.setToolTipText("Open reference filters"); + leftHeader.add(referenceFiltersButton); + lookupHeader.add(leftHeader, BorderLayout.WEST); + + lookupHeader.add(referenceSearchField, BorderLayout.CENTER); + referenceInsertButton.setFocusable(false); + referenceInsertButton.setEnabled(false); + lookupHeader.add(referenceInsertButton, BorderLayout.EAST); + + referenceSearchField.setToolTipText("Search by name, signature, or library"); + referenceKindFilter.setToolTipText("Filter by kind"); + referenceSourceFilter.setToolTipText("Filter by builtin, libraries, or program symbols"); + referenceLibraryFilter.setToolTipText("Filter by library name"); + referenceKindFilter.setPrototypeDisplayValue("Functions"); + rebuildReferenceFiltersPopup(); + + referenceFilterDebounceTimer.setRepeats(false); + + referenceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + referenceList.setFixedCellHeight(20); + referenceList.setPrototypeCellValue( + new SymbolsPanelProvider.ReferenceItem("function", "prototype", "prototype(symbol, arg)", "Builtin", "", "", 0)); + referenceList.setCellRenderer(new DefaultListCellRenderer() { + private final ImageIcon functionIcon = createImageIcon(ICON_FUNCTION); + private final ImageIcon variableIcon = createImageIcon(ICON_VARIABLE); + private final ImageIcon labelIcon = createImageIcon(ICON_LABEL); + private final ImageIcon structIcon = createImageIcon(ICON_STRUCT); + + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof SymbolsPanelProvider.ReferenceItem item) { + label.setText(item.signature); + if ("function".equals(item.kind) || "userfunc".equals(item.kind)) { + label.setIcon(functionIcon); + } else if ("label".equals(item.kind)) { + label.setIcon(labelIcon); + } else if ("struc".equals(item.kind)) { + label.setIcon(structIcon); + } else { + label.setIcon(variableIcon); + } + label.setToolTipText(null); + } + return label; + } + }); + + referenceDetailsPane.setEditable(false); + referenceDetailsPane.setContentType("text/html"); + setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); + + JSplitPane lookupSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + lookupSplit.setResizeWeight(0.65); + lookupSplit.setTopComponent(new JScrollPane(referenceList)); + lookupSplit.setBottomComponent(new JScrollPane(referenceDetailsPane)); + + lookupPanel.add(lookupHeader, BorderLayout.NORTH); + lookupPanel.add(lookupSplit, BorderLayout.CENTER); + + referenceSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + requestFilterReferenceItems(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + requestFilterReferenceItems(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + requestFilterReferenceItems(); + } + }); + referenceList.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + updateReferenceSelectionDetails(); + } + }); + referenceKindFilter.addActionListener(e -> { + if (!updatingReferenceFilters) { + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + } + }); + referenceSourceFilter.addActionListener(e -> { + if (!updatingReferenceFilters) { + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + } + }); + referenceLibraryFilter.addActionListener(e -> { + if (!updatingReferenceFilters) { + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + } + }); + referenceFiltersButton.addActionListener(e -> { + rebuildReferenceFiltersPopup(); + referenceFiltersPopup.show(referenceFiltersButton, 0, referenceFiltersButton.getHeight()); + }); + referenceList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + insertSelectedReference(); + } + } + }); + referenceInsertButton.addActionListener(e -> insertSelectedReference()); + updateReferenceFiltersButtonTooltip(); + + return lookupPanel; + } + + @Override + public void refresh(EditorPlugin languageProvider) { + populateDocsFromCompiler(); + symbolIndexer.schedule(); + } + + @Override + public void onFileModified(String filePath) { + symbolIndexer.schedule(); + } + + @Override + public void dispose() { + symbolIndexer.shutdown(); + } + + @Override + public void onCompileSucceeded() { + + populateDocsFromCompiler(); + // Also sync the indexer immediately so the debounced background pass + // reflects the compiled state right away. + symbolIndexer.indexNow(); + } + + /** + * Called on the EDT by the {@link SymbolIndexer} callback after each debounce cycle. + * Replaces all "Program" (user-defined) reference items with the freshly scanned symbols and + * refreshes the reference panel. + */ + private void updateProgramSymbols(List symbols) { + int fingerprint = 1; + for (IndexedSymbol symbol : symbols) { + fingerprint = 31 * fingerprint + Objects.hash(symbol.kind(), symbol.name(), symbol.signature()); + } + if (fingerprint == lastProgramSymbolsFingerprint) { + return; + } + lastProgramSymbolsFingerprint = fingerprint; + + // Remove all existing Program-sourced items + allReferenceItems.removeIf(item -> "Program".equals(item.library)); + + // Add newly scanned symbols + for (IndexedSymbol sym : symbols) { + String details; + String insertText; + int caretOffset; + switch (sym.kind()) { + case "userfunc" -> { + details = "" + + "

      " + escapeHtml(sym.name()) + "

      " + + "

      Type: User Function" + + "
      Source: Program

      " + + "

      " + escapeHtml(sym.signature()) + "

      " + + ""; + insertText = sym.name() + "()"; + caretOffset = sym.name().length() + 1; + } + case "label" -> { + details = "" + + "

      " + escapeHtml(sym.name()) + "

      " + + "

      Type: Label" + + "
      Usage: gosub " + escapeHtml(sym.name()) + "" + + " / goto " + escapeHtml(sym.name()) + "

      " + + ""; + insertText = sym.name(); + caretOffset = sym.name().length(); + } + case "struc" -> { + details = "" + + "

      " + escapeHtml(sym.name()) + "

      " + + "

      Type: Struct" + + "
      Source: Program

      " + + "

      " + escapeHtml(sym.signature()) + "

      " + + ""; + insertText = sym.name(); + caretOffset = sym.name().length(); + } + default -> { // "variable" + details = "" + + "

      " + escapeHtml(sym.name()) + "

      " + + "

      Type: Variable" + + "
      Source: Program

      " + + "

      " + escapeHtml(sym.signature()) + "

      " + + ""; + insertText = sym.name(); + caretOffset = sym.name().length(); + } + } + allReferenceItems.add(new ReferenceItem( + sym.kind(), sym.name(), sym.signature(), "Program", details, insertText, caretOffset)); + } + + allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(item -> item.kind)); + rebuildLibraryFilterOptions(); + filterReferenceItems(); + + //TODO handle this or not: refreshAssetsLibrary(); + } + + private void populateDocsFromCompiler() { + + if (context.currentEditor() == null || context.currentEditor().getLanguage() == null) { + return; + } + allReferenceItems.clear(); + allReferenceItems.addAll(buildFunctionReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll(buildConstantReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll(buildLabelReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll(buildVariableReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(item -> item.kind)); + rebuildLibraryFilterOptions(); + filterReferenceItems(); + } + + private java.util.List buildFunctionReferenceItems(LanguageService comp) { + java.util.List items = new ArrayList<>(); + for (FunctionDefinition item : comp.getFunctionDefinitions()) { + if (item == null) { + continue; + } + StringBuilder argsOnly = new StringBuilder(); + if (item.parameters() != null) { + for (VariableDefinition arg : item.parameters()) { + if (argsOnly.length() > 0) { + argsOnly.append(", "); + } + argsOnly.append(arg.signature()); + } + } + String details = "" + + "

      " + + escapeHtml(item.name()) + + "

      Type: Function
      Library: " + + escapeHtml(item.packageName()) + + "

      " + + escapeHtml(item.signature()) + + "

      "; + String insertText = item.hasBrackets() ? item.name() + "()" : item.name() + " "; + int caretOffset = item.hasBrackets() ? item.name().length() + 1 : insertText.length(); + if (item.hasBrackets() && argsOnly.length() > 0) { + insertText = item.name() + "(" + argsOnly + ")"; + caretOffset = item.name().length() + 1; + } + items.add(new ReferenceItem( + "function", item.name(), item.signature(), item.packageName(), details, insertText, caretOffset)); + } + return items; + } + + private java.util.List buildConstantReferenceItems(LanguageService comp) { + java.util.List items = new ArrayList<>(); + for (VariableDefinition item : comp.getConstantDefinitions()) { + if (item == null) { + continue; + } + String details = "" + + "

      " + + escapeHtml(item.name()) + + "

      Type: Constant
      Library: " + + escapeHtml(item.packageName()) + + "

      " + + escapeHtml(item.signature()) + + "

      "; + items.add(new ReferenceItem( + "constant", + item.name(), + item.signature(), + item.packageName(), + details, + item.name(), + item.name().length())); + } + + return items; + } + + private java.util.List buildLabelReferenceItems(LanguageService comp) { + java.util.List items = new ArrayList<>(); + for (LabelDefinition label : comp.getLabelDefinitions()) { + if (label == null) { + continue; + } + String signature = label.signature(); + String details = "" + + "

      " + escapeHtml(label.name()) + + "

      Type: Label
      Usage: " + + "" + escapeHtml(label.usage()) + "" + + "

      "; + items.add(new ReferenceItem( + "label", + label.name(), + signature, + "Program", + details, + label.name(), + label.name().length())); + } + return items; + } + + private java.util.List buildVariableReferenceItems(LanguageService comp) { + java.util.List items = new ArrayList<>(); + for (VariableDefinition variable : comp.getVariableDefinitions()) { + if (variable == null || variable.name() == null || variable.name().isEmpty()) { + continue; + } + String typeStr = variable.type().name(); + String signature = variable.signature(); + String details = "" + + "

      " + escapeHtml(variable.name()) + + "

      Type: Variable
      Data type: " + + escapeHtml(typeStr) + "
      Source: Program

      "; + items.add(new ReferenceItem( + "variable", + variable.name(), + signature, + "Program", + details, + variable.name(), + variable.name().length())); + } + return items; + } + + private void rebuildLibraryFilterOptions() { + String selected = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); + Set libraries = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (ReferenceItem item : allReferenceItems) { + if (item.library != null) { + libraries.add(item.library); + } + } + + updatingReferenceFilters = true; + try { + referenceLibraryFilter.removeAllItems(); + referenceLibraryFilter.addItem("All libraries"); + for (String library : libraries) { + referenceLibraryFilter.addItem(library); + } + referenceLibraryFilter.setSelectedItem(libraries.contains(selected) ? selected : "All libraries"); + } finally { + updatingReferenceFilters = false; + } + rebuildReferenceFiltersPopup(); + updateReferenceFiltersButtonTooltip(); + } + + private void rebuildReferenceFiltersPopup() { + referenceFiltersPopup.removeAll(); + + JMenu typeMenu = new JMenu("Type"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "All", "All"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Functions", "Functions"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Constants", "Constants"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Labels", "Labels"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Variables", "Variables"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "Structs", "Structs"); + + JMenu sourceMenu = new JMenu("Source"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "All sources", "All sources"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Builtin", "Builtin"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Libraries", "Libraries"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Program", "Program"); + + JMenu libraryMenu = new JMenu("Library"); + for (int i = 0; i < referenceLibraryFilter.getItemCount(); i++) { + String item = referenceLibraryFilter.getItemAt(i); + if (item != null) { + addReferenceRadioItems(libraryMenu, referenceLibraryFilter, item, item); + } + } + + JMenuItem resetItem = new JMenuItem("Reset filters"); + resetItem.addActionListener(e -> { + updatingReferenceFilters = true; + try { + referenceKindFilter.setSelectedItem("All"); + referenceSourceFilter.setSelectedItem("All sources"); + referenceLibraryFilter.setSelectedItem("All libraries"); + } finally { + updatingReferenceFilters = false; + } + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + }); + + referenceFiltersPopup.add(typeMenu); + referenceFiltersPopup.add(sourceMenu); + referenceFiltersPopup.add(libraryMenu); + referenceFiltersPopup.addSeparator(); + referenceFiltersPopup.add(resetItem); + } + + private void addReferenceRadioItems(JMenu menu, JComboBox combo, String label, String value) { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(label, Objects.equals(combo.getSelectedItem(), value)); + item.addActionListener(e -> combo.setSelectedItem(value)); + menu.add(item); + } + + private void setReferenceDetailsHtml(String html) { + String next = html == null ? REFERENCE_SELECT_PROMPT_HTML : html; + if (Objects.equals(referenceDetailsHtml, next)) { + return; + } + referenceDetailsHtml = next; + referenceDetailsPane.setText(next); + referenceDetailsPane.setCaretPosition(0); + } + + private void updateReferenceFiltersButtonTooltip() { + String type = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); + String source = Objects.toString(referenceSourceFilter.getSelectedItem(), "All sources"); + String library = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); + referenceFiltersButton.setToolTipText("Type: " + type + " | Source: " + source + " | Library: " + library); + } + + private void requestFilterReferenceItems() { + referenceFilterDebounceTimer.restart(); + } + + private void filterReferenceItems() { + String query = referenceSearchField.getText(); + String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + String selectedKind = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); + String selectedSource = Objects.toString(referenceSourceFilter.getSelectedItem(), "All sources"); + String selectedLibrary = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); + + ReferenceItem previousSelection = referenceList.getSelectedValue(); + java.util.List matches = new ArrayList<>(); + for (ReferenceItem item : allReferenceItems) { + boolean kindMatches = "All".equals(selectedKind) + || ("Functions".equals(selectedKind) + && ("function".equals(item.kind) || "userfunc".equals(item.kind))) + || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) + || ("Labels".equals(selectedKind) && "label".equals(item.kind)) + || ("Variables".equals(selectedKind) && "variable".equals(item.kind)) + || ("Structs".equals(selectedKind) && "struc".equals(item.kind)); + boolean sourceMatches = "All sources".equals(selectedSource) + || ("Builtin".equals(selectedSource) + && item.library != null + && "Builtin".equalsIgnoreCase(item.library)) + || ("Libraries".equals(selectedSource) + && item.library != null + && !"Builtin".equalsIgnoreCase(item.library) + && !"Program".equalsIgnoreCase(item.library)) + || ("Program".equals(selectedSource) + && item.library != null + && "Program".equalsIgnoreCase(item.library)); + boolean libraryMatches = "All libraries".equals(selectedLibrary) + || (item.library != null && selectedLibrary.equals(item.library)); + if (needle.isEmpty() + || item.name.toLowerCase(Locale.ROOT).contains(needle) + || item.signature.toLowerCase(Locale.ROOT).contains(needle) + || item.kind.toLowerCase(Locale.ROOT).contains(needle) + || (item.library != null + && item.library.toLowerCase(Locale.ROOT).contains(needle))) { + if (kindMatches && sourceMatches && libraryMatches) { + matches.add(item); + } + } + } + + referenceListModel.clear(); + for (ReferenceItem match : matches) { + referenceListModel.addElement(match); + } + + if (!referenceListModel.isEmpty()) { + if (previousSelection != null && matches.contains(previousSelection)) { + referenceList.setSelectedValue(previousSelection, true); + } else { + referenceList.setSelectedIndex(0); + } + } else { + setReferenceDetailsHtml(REFERENCE_NO_MATCHES_HTML); + referenceInsertButton.setEnabled(false); + } + } + + private void updateReferenceSelectionDetails() { + ReferenceItem item = referenceList.getSelectedValue(); + if (item == null) { + setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); + referenceInsertButton.setEnabled(false); + return; + } + setReferenceDetailsHtml(item.details); + referenceInsertButton.setEnabled(true); + } + + private void insertSelectedReference() { + ReferenceItem item = referenceList.getSelectedValue(); + if (item == null) { + return; + } + context.commands().insertText(item.insertText, item.caretOffset); + } + + +} diff --git a/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java b/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java new file mode 100644 index 00000000..5ed1e92c --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java @@ -0,0 +1,18 @@ +package com.basic4gl.desktop.util; + +import com.basic4gl.desktop.spi.DialogService; + +import javax.swing.*; + +public class BasicDialogService implements DialogService { + private final JFrame frame; + + public BasicDialogService(JFrame frame) { + this.frame = frame; + } + + @Override + public void showDialog(String message) { + JOptionPane.showMessageDialog(frame, message); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java b/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java index cbf73966..e118b46b 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java @@ -1,5 +1,13 @@ package com.basic4gl.desktop.util; +import com.basic4gl.desktop.spi.DialogService; + +import javax.swing.*; +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.util.Locale; + public class FileUtil { public static String fromUserHome(String absolutePath) { String userHome = System.getProperty("user.home"); @@ -9,4 +17,88 @@ public static String fromUserHome(String absolutePath) { } return absolutePath; // Return as-is if not in home directory } + + public static String getMediaTypeLabel(File file) { + if (file == null) { + return "Other"; + } + String name = file.getName().toLowerCase(Locale.ROOT); + if (name.endsWith(".png") + || name.endsWith(".jpg") + || name.endsWith(".jpeg") + || name.endsWith(".gif") + || name.endsWith(".bmp") + || name.endsWith(".webp") + || name.endsWith(".ico")) { + return "Images"; + } + if (name.endsWith(".wav") || name.endsWith(".ogg") || name.endsWith(".mp3") || name.endsWith(".flac")) { + return "Audio"; + } + if (name.endsWith(".mp4") || name.endsWith(".mov") || name.endsWith(".webm")) { + return "Video"; + } + if (name.endsWith(".txt") + || name.endsWith(".md") + || name.endsWith(".json") + || name.endsWith(".xml") + || name.endsWith(".csv") + || name.endsWith(".ini") + || name.endsWith(".cfg") + || name.endsWith(".properties")) { + return "Text"; + } + if (name.endsWith(".pdf") || name.endsWith(".doc") || name.endsWith(".docx") || name.endsWith(".rtf")) { + return "Documents"; + } + return "Other"; + } + + public static String formatRelativePath(File file, File baseDir) { + if (file == null) { + return ""; + } + if (baseDir != null) { + try { + java.nio.file.Path relative = baseDir.getAbsoluteFile() + .toPath() + .normalize() + .relativize(file.getAbsoluteFile().toPath().normalize()); + String text = relative.toString().replace('\\', '/'); + if (!text.startsWith("..")) { + return text; + } + } catch (Exception ignored) { + // Fall back to file name below. + } + } + return file.getName(); + } + + public static void revealInFinder(File file, DialogService dialogService) { + if (file == null || !file.exists()) { + return; + } + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browseFileDirectory(file); + } + } catch (Exception ex) { + // Fallback when browseFileDirectory is unavailable. + openWithSystemDefault(file.getParentFile(), dialogService); + } + } + + public static void openWithSystemDefault(File file, DialogService dialogService) { + if (file == null || !file.exists()) { + return; + } + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(file); + } + } catch (IOException ex) { + dialogService.showDialog("Unable to open file: " + ex.getMessage()); + } + } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java b/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java new file mode 100644 index 00000000..ef1c85d2 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java @@ -0,0 +1,37 @@ +package com.basic4gl.desktop.util; + +public final class HtmlUtil { + private HtmlUtil() { + } + + public static String escapeHtml(String input) { + if (input == null) { + return ""; + } + return input.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + + public static String markdownToHtml(String markdown) { + StringBuilder html = new StringBuilder(""); + for (String line : markdown.split("\\R", -1)) { + String escaped = escapeHtml(line); + if (escaped.startsWith("### ")) { + html.append("

      ").append(escaped.substring(4)).append("

      "); + } else if (escaped.startsWith("## ")) { + html.append("

      ").append(escaped.substring(3)).append("

      "); + } else if (escaped.startsWith("# ")) { + html.append("

      ").append(escaped.substring(2)).append("

      "); + } else if (escaped.startsWith("- ")) { + html.append("

      • ").append(escaped.substring(2)).append("

      "); + } else if (escaped.isBlank()) { + html.append("
      "); + } else { + html.append("

      ").append(escaped).append("

      "); + } + } + html.append(""); + return html.toString(); + } + +} diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java index a2b231ba..afdc6329 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java @@ -1,6 +1,9 @@ package com.basic4gl.desktop.util; import javax.swing.*; +import java.awt.*; +import java.io.File; +import java.io.IOException; public class SwingIconUtil { /** @@ -15,4 +18,21 @@ public static ImageIcon createImageIcon(String path) { return null; } } + + public static Icon buildImageThumbnailIcon(File file, int maxWidth, int maxHeight) { + try { + java.awt.image.BufferedImage image = javax.imageio.ImageIO.read(file); + if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) { + return null; + } + double scale = Math.min((double) maxWidth / image.getWidth(), (double) maxHeight / image.getHeight()); + scale = Math.min(1.0d, scale); + int width = Math.max(1, (int) Math.round(image.getWidth() * scale)); + int height = Math.max(1, (int) Math.round(image.getHeight() * scale)); + Image scaled = image.getScaledInstance(width, height, Image.SCALE_SMOOTH); + return new ImageIcon(scaled); + } catch (IOException ex) { + return null; + } + } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java new file mode 100644 index 00000000..95470cea --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java @@ -0,0 +1,13 @@ +package com.basic4gl.desktop.util; + +import javax.swing.*; + +public class SwingUtil { + + public static void configureSmoothScrolling(JScrollPane scrollPane) { + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + scrollPane.getVerticalScrollBar().setBlockIncrement(64); + scrollPane.getViewport().setScrollMode(JViewport.BLIT_SCROLL_MODE); + scrollPane.setWheelScrollingEnabled(true); + } +} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java index b17a9092..3a095084 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java @@ -7,10 +7,8 @@ import com.basic4gl.desktop.spi.FileLineNumber; import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.PluginContext; -import com.basic4gl.desktop.spi.language.FunctionDefinition; -import com.basic4gl.desktop.spi.language.LabelDefinition; -import com.basic4gl.desktop.spi.language.TypeDefinition; -import com.basic4gl.desktop.spi.language.VariableDefinition; +import com.basic4gl.desktop.spi.language.*; +import com.basic4gl.language.adapter.antlr.Basic4GL; import com.basic4gl.language.adapter.util.LanguageUtil; import com.basic4gl.language.adapter.util.NumberUtil; import com.basic4gl.language.core.extensions.FunctionLibrary; @@ -21,11 +19,19 @@ import com.basic4gl.language.core.types.ValType; import com.basic4gl.language.spi.PluginLibrary; import com.basic4gl.language.spi.PluginManager; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.Token; + import java.util.*; import java.util.stream.Stream; +import static com.basic4gl.language.adapter.util.LanguageUtil.*; + public class Basic4GLLanguageService implements LanguageService { + private static final String SYNTAX_STYLE = "text/basic4gl"; + private final TomBasicCompiler compiler; private final Preprocessor preprocessor; private final PluginManager pluginManager; @@ -42,6 +48,62 @@ public void onLoad(PluginContext context) {} @Override public void onUnload() {} + @Override + public List extractStringLiterals(String text) { + java.util.List literals = new ArrayList<>(); + if (text == null || text.isEmpty()) { + return literals; + } + + int length = text.length(); + int index = 0; + while (index < length) { + char ch = text.charAt(index); + if (ch != '"') { + index++; + continue; + } + + StringBuilder literal = new StringBuilder(); + index++; + boolean escaped = false; + boolean terminated = false; + while (index < length) { + char current = text.charAt(index++); + if (escaped) { + if (current == '"' || current == '\\') { + literal.append(current); + } else { + // Preserve non-quote escape sequences exactly as typed. + literal.append('\\').append(current); + } + escaped = false; + continue; + } + + if (current == '\\') { + escaped = true; + continue; + } + + if (current == '"') { + literals.add(literal.toString()); + terminated = true; + break; + } + + literal.append(current); + } + + // Unterminated string literal: discard and continue scanning. + if (!terminated) { + continue; + } + } + + return literals; + } + @Override public List getReservedWords() { return new ArrayList<>(compiler.getReservedWords()); @@ -345,4 +407,612 @@ private Map buildConstantLibraryByName() { } return constantLibraryByName; } + + // ------------------------------------------------------------------------- + // LanguageSupport – identity + // ------------------------------------------------------------------------- + + @Override + public String syntaxStyle() { + return SYNTAX_STYLE; + } + + // ------------------------------------------------------------------------- + // LanguageSupport – tokenisation + // ------------------------------------------------------------------------- + + @Override + public List tokenizeLine(String line) { + if (line == null || line.isEmpty()) { + return List.of(); + } + Basic4GL lexer = createLexer(line); + List result = new ArrayList<>(); + Token token; + while ((token = lexer.nextToken()).getType() != Token.EOF) { + // Skip NEWLINE tokens – the caller provides one line at a time + if (token.getType() == Basic4GL.NEWLINE) { + continue; + } + result.add(toLangToken(token)); + } + return result; + } + + @Override + public HighlightKind classify(LangToken token) { + return switch (token.type()) { + // Preprocessor + case Basic4GL.INCLUDE_DIR -> HighlightKind.PREPROCESSOR; + + // Comments + case Basic4GL.COMMENT, Basic4GL.REM_COMMENT -> HighlightKind.COMMENT; + + // Primary keywords + case Basic4GL.FUNCTION_KW, + Basic4GL.SUB_KW, + Basic4GL.DIM_KW, + Basic4GL.AS_KW, + Basic4GL.GOTO_KW, + Basic4GL.GOSUB_KW, + Basic4GL.IF_KW, + Basic4GL.THEN_KW, + Basic4GL.ELSE_KW, + Basic4GL.ELSEIF_KW, + Basic4GL.ENDIF_KW, + Basic4GL.END_KW, + Basic4GL.RETURN_KW, + Basic4GL.FOR_KW, + Basic4GL.TO_KW, + Basic4GL.STEP_KW, + Basic4GL.NEXT_KW, + Basic4GL.WHILE_KW, + Basic4GL.WEND_KW, + Basic4GL.RUN_KW, + Basic4GL.STRUC_KW, + Basic4GL.ENDSTRUC_KW, + Basic4GL.CONST_KW, + Basic4GL.ALLOC_KW, + Basic4GL.NULL_KW, + Basic4GL.DATA_KW, + Basic4GL.READ_KW, + Basic4GL.RESET_KW, + Basic4GL.TYPE_KW, + Basic4GL.AND_KW, + Basic4GL.OR_KW, + Basic4GL.NOT_KW, + Basic4GL.XOR_KW, + Basic4GL.MOD_KW -> HighlightKind.KEYWORD; + + // Secondary keywords – type names and boolean literals + case Basic4GL.INTEGER_T, + Basic4GL.INT_T, + Basic4GL.SINGLE_T, + Basic4GL.DOUBLE_T, + Basic4GL.STRING_T, + Basic4GL.TRUE_KW, + Basic4GL.FALSE_KW -> HighlightKind.KEYWORD_2; + + // Literals + case Basic4GL.STRING_LIT -> HighlightKind.STRING; + case Basic4GL.INT_LIT, Basic4GL.FLOAT_LIT, Basic4GL.HEX_LIT -> HighlightKind.NUMBER; + + // Identifiers – the IDE adapter re-classifies these via wordsToHighlight + case Basic4GL.IDENTIFIER -> HighlightKind.IDENTIFIER; + + // Whitespace + case Basic4GL.WS -> HighlightKind.WHITESPACE; + case Basic4GL.NEWLINE -> HighlightKind.NEWLINE; + + // Operators and punctuation + case Basic4GL.COLON, + Basic4GL.LPAREN, + Basic4GL.RPAREN, + Basic4GL.LBRACKET, + Basic4GL.RBRACKET, + Basic4GL.COMMA, + Basic4GL.DOT, + Basic4GL.SEMICOLON, + Basic4GL.EQ, + Basic4GL.NEQ, + Basic4GL.LT, + Basic4GL.GT, + Basic4GL.LTE, + Basic4GL.GTE, + Basic4GL.PLUS, + Basic4GL.MINUS, + Basic4GL.STAR, + Basic4GL.SLASH, + Basic4GL.BACKSLASH, + Basic4GL.CARET, + Basic4GL.AT, + Basic4GL.BANG, + Basic4GL.TILDE, + Basic4GL.PERCENT, + Basic4GL.PIPE, + Basic4GL.HASH, + Basic4GL.AMPERSAND -> HighlightKind.OPERATOR; + + // Unknown / unrecognised + default -> HighlightKind.OTHER; + }; + } + + // ------------------------------------------------------------------------- + // LanguageSupport – symbol extraction + // ------------------------------------------------------------------------- + + /** + * Scans the full source text and extracts user-defined symbols by walking the ANTLR token + * stream with a lightweight state machine. + * + *

      Recognised patterns: + * + *

        + *
      • {@code function Name(params)} / {@code sub Name(params)} → {@code "userfunc"} + *
      • {@code Name:} (identifier immediately followed by {@code COLON}) → {@code "label"} + *
      • {@code dim Name [as Type]} → {@code "variable"} + *
      + */ + @Override + public List extractSymbols(String source) { + if (source == null || source.isEmpty()) { + return List.of(); + } + + Basic4GL lexer = createLexer(source); + CommonTokenStream stream = new CommonTokenStream(lexer); + stream.fill(); + List tokens = stream.getTokens(); + + Map symbolsByKey = new LinkedHashMap<>(); + Map variableDeclCounts = new LinkedHashMap<>(); + + // State machine + final int NONE = 0; + final int AFTER_FUNC_KW = 1; // saw function/sub – next identifier is the name + final int COLLECT_PARAMS = 2; // collecting signature text inside ( … ) + final int AFTER_DIM_KW = 3; // saw dim – next identifier is the variable name + final int AFTER_DIM_NAME = 4; // saw dim name – look for 'as ' + final int AFTER_AS_KW = 5; // saw 'as' after dim name – next identifier is type + + int state = NONE; + String pendingFuncName = null; + StringBuilder paramBuf = null; + int parenDepth = 0; + String pendingVarName = null; + String pendingVarType = null; + // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the + // type-prefix identifier swap when inside an array-size expression. + int dimArrayDepth = 0; + String currentRoutine = null; + // Struc-scope tracking: dims inside a struc block use "struc:" as scope + // so they never collide with same-named program variables in re-dim counting. + boolean inStruc = false; + String currentStrucName = null; + + for (int i = 0; i < tokens.size(); i++) { + Token t = tokens.get(i); + int type = t.getType(); + + // Skip whitespace, newlines, and EOF in the state machine + if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { + // A newline resets after-dim state (one dim per line) + if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME) { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); + state = NONE; + pendingVarName = null; + pendingVarType = null; + dimArrayDepth = 0; + } + continue; + } + + switch (state) { + case NONE -> { + if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { + state = AFTER_FUNC_KW; + } else if (type == Basic4GL.END_KW) { + Token next = peekNonWs(tokens, i + 1); + if (next != null + && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { + currentRoutine = null; + } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { + // "end type" – same as endstruc + inStruc = false; + currentStrucName = null; + } + } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { + // Entering a struc/type block – capture the struct name from the next identifier + Token nameToken = peekNonWs(tokens, i + 1); + currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) + ? nameToken.getText() + : null; + inStruc = true; + // Emit the struct type itself as a symbol + if (currentStrucName != null) { + addFirstStruct(symbolsByKey, currentStrucName); + } + } else if (type == Basic4GL.ENDSTRUC_KW) { + inStruc = false; + currentStrucName = null; + } else if (type == Basic4GL.DIM_KW) { + state = AFTER_DIM_KW; + } else if (type == Basic4GL.IDENTIFIER) { + // Look ahead (skip WS) for a COLON → label declaration + Token next = peekNonWs(tokens, i + 1); + if (next != null && next.getType() == Basic4GL.COLON) { + addFirstLabel(symbolsByKey, t.getText()); + } + } + } + case AFTER_FUNC_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingFuncName = t.getText(); + paramBuf = new StringBuilder(t.getText()).append('('); + parenDepth = 0; + state = COLLECT_PARAMS; + } else { + state = NONE; // unexpected token – reset + } + } + case COLLECT_PARAMS -> { + if (type == Basic4GL.LPAREN) { + parenDepth++; + // don't append – we already opened the sig paren + } else if (type == Basic4GL.RPAREN) { + if (parenDepth == 0) { + // Closing paren of the function signature + String sig = paramBuf.toString().trim(); + // Remove trailing comma if any + if (sig.endsWith(",")) + sig = sig.substring(0, sig.length() - 1).trim(); + addFirstFunction(symbolsByKey, pendingFuncName, sig + ")"); + currentRoutine = pendingFuncName; + state = NONE; + pendingFuncName = null; + paramBuf = null; + } else { + parenDepth--; + paramBuf.append(t.getText()); + } + } else if (type != Basic4GL.WS && type != Basic4GL.NEWLINE) { + if (paramBuf.length() > 0 + && !paramBuf.toString().endsWith("(") + && !paramBuf.toString().endsWith(",") + && !paramBuf.toString().endsWith(" ")) { + paramBuf.append(' '); + } + paramBuf.append(t.getText()); + } + } + case AFTER_DIM_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingVarName = t.getText(); + // Infer type from identifier suffix (#, !, $, %) + pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); + state = AFTER_DIM_NAME; + } else { + state = NONE; + } + } + case AFTER_DIM_NAME -> { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { + dimArrayDepth = 0; + state = AFTER_AS_KW; + } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { + dimArrayDepth++; + } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { + if (dimArrayDepth > 0) dimArrayDepth--; + } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { + // "dim Type VarName" – the first IDENTIFIER was the type name, + // this IDENTIFIER is the actual variable name. + // Check if we already have a pendingVarType: if it's the inferred + // type from pendingVarName (the first ID), we're in type-prefix mode. + String inferredFromFirstId = inferTypeFromIdentifierSuffix(pendingVarName); + if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { + // Type-prefix case: pendingVarName is the explicit type, new ID is the var name + String newVarUserType = t.getText(); + String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); + pendingVarType = newVarInferredType != null ? newVarInferredType : pendingVarName; + pendingVarName = newVarUserType; + } + } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { + // 'dim x, y' or 'dim x :' – flush current, continue + flushVariable( + symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); + pendingVarName = null; + pendingVarType = null; + dimArrayDepth = 0; + state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; + } + // else: other tokens (array size expression contents, &, etc.) – stay + } + case AFTER_AS_KW -> { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + if (type == Basic4GL.IDENTIFIER + || type == Basic4GL.INTEGER_T + || type == Basic4GL.INT_T + || type == Basic4GL.SINGLE_T + || type == Basic4GL.DOUBLE_T + || type == Basic4GL.STRING_T) { + pendingVarType = t.getText(); + flushVariable( + symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); + state = NONE; + } else { + flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, null, effectiveRoutine); + state = NONE; + } + } + } + } + + // Flush any dangling state at EOF + if (state == AFTER_DIM_NAME || state == AFTER_AS_KW) { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); + } + + return new ArrayList<>(symbolsByKey.values()); + } + + @Override + public List extractDeclarations(String source, String fileId) { + if (source == null || source.isEmpty()) { + return List.of(); + } + + Basic4GL lexer = createLexer(source); + CommonTokenStream stream = new CommonTokenStream(lexer); + stream.fill(); + List tokens = stream.getTokens(); + + List declarations = new ArrayList<>(); + Map variableDeclCounts = new LinkedHashMap<>(); + + final int NONE = 0; + final int AFTER_FUNC_KW = 1; + final int COLLECT_PARAMS = 2; + final int AFTER_DIM_KW = 3; + final int AFTER_DIM_NAME = 4; + final int AFTER_AS_KW = 5; + + int state = NONE; + Token pendingFuncNameToken = null; + StringBuilder paramBuf = null; + int parenDepth = 0; + Token pendingVarNameToken = null; + String pendingVarType = null; + // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the + // type-prefix identifier swap when inside an array-size expression. + int dimArrayDepth = 0; + String currentRoutine = null; + // Struc-scope tracking: dims inside a struc block use "struc:" as scope + // so they never collide with same-named program variables in re-dim counting. + boolean inStruc = false; + String currentStrucName = null; + + for (int i = 0; i < tokens.size(); i++) { + Token t = tokens.get(i); + int type = t.getType(); + + if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { + if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME && pendingVarNameToken != null) { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + pendingVarNameToken = null; + pendingVarType = null; + dimArrayDepth = 0; + state = NONE; + } + continue; + } + + switch (state) { + case NONE -> { + if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { + state = AFTER_FUNC_KW; + } else if (type == Basic4GL.END_KW) { + Token next = peekNonWs(tokens, i + 1); + if (next != null + && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { + currentRoutine = null; + } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { + // "end type" – same as endstruc + inStruc = false; + currentStrucName = null; + } + } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { + // Entering a struc/type block – capture the struct name from the next identifier + Token nameToken = peekNonWs(tokens, i + 1); + currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) + ? nameToken.getText() + : null; + inStruc = true; + // Emit the struct type definition itself as a declaration + if (currentStrucName != null && nameToken != null) { + declarations.add(new SymbolDeclaration( + "struc", + currentStrucName, + "struc " + currentStrucName, + "global", + 1, + fileId, + Math.max(0, nameToken.getLine() - 1), + Math.max(0, nameToken.getCharPositionInLine()))); + } + } else if (type == Basic4GL.ENDSTRUC_KW) { + inStruc = false; + currentStrucName = null; + } else if (type == Basic4GL.DIM_KW) { + state = AFTER_DIM_KW; + } else if (type == Basic4GL.IDENTIFIER) { + Token next = peekNonWs(tokens, i + 1); + if (next != null && next.getType() == Basic4GL.COLON) { + declarations.add(new SymbolDeclaration( + "label", + t.getText(), + t.getText() + ":", + currentRoutine == null ? "global" : currentRoutine, + 1, + fileId, + Math.max(0, t.getLine() - 1), + Math.max(0, t.getCharPositionInLine()))); + } + } + } + case AFTER_FUNC_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingFuncNameToken = t; + paramBuf = new StringBuilder(t.getText()).append('('); + parenDepth = 0; + state = COLLECT_PARAMS; + } else { + state = NONE; + } + } + case COLLECT_PARAMS -> { + if (type == Basic4GL.LPAREN) { + parenDepth++; + } else if (type == Basic4GL.RPAREN) { + if (parenDepth == 0 && pendingFuncNameToken != null) { + String sig = paramBuf.toString().trim(); + if (sig.endsWith(",")) { + sig = sig.substring(0, sig.length() - 1).trim(); + } + declarations.add(new SymbolDeclaration( + "userfunc", + pendingFuncNameToken.getText(), + sig + ")", + "global", + 1, + fileId, + Math.max(0, pendingFuncNameToken.getLine() - 1), + Math.max(0, pendingFuncNameToken.getCharPositionInLine()))); + currentRoutine = pendingFuncNameToken.getText(); + pendingFuncNameToken = null; + paramBuf = null; + state = NONE; + } else { + parenDepth--; + if (paramBuf != null) { + paramBuf.append(t.getText()); + } + } + } else { + if (paramBuf != null + && !paramBuf.toString().endsWith("(") + && !paramBuf.toString().endsWith(",") + && !paramBuf.toString().endsWith(" ")) { + paramBuf.append(' '); + } + if (paramBuf != null) { + paramBuf.append(t.getText()); + } + } + } + case AFTER_DIM_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingVarNameToken = t; + // Infer type from identifier suffix (#, !, $, %) + pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); + state = AFTER_DIM_NAME; + } else { + state = NONE; + } + } + case AFTER_DIM_NAME -> { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { + dimArrayDepth = 0; + state = AFTER_AS_KW; + } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { + dimArrayDepth++; + } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { + if (dimArrayDepth > 0) dimArrayDepth--; + } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { + // "dim Type VarName" – the first IDENTIFIER was the type name, + // this IDENTIFIER is the actual variable name. + String firstIdText = pendingVarNameToken != null ? pendingVarNameToken.getText() : null; + String inferredFromFirstId = inferTypeFromIdentifierSuffix(firstIdText); + if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { + // Type-prefix case: first token is the explicit type, new token is the var name + String newVarUserType = t.getText(); + String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); + pendingVarType = newVarInferredType != null ? newVarInferredType : firstIdText; + pendingVarNameToken = t; + } + } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { + if (pendingVarNameToken != null) { + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + } + pendingVarNameToken = null; + pendingVarType = null; + dimArrayDepth = 0; + state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; + } + // else: other tokens (array size expression, &, etc.) – stay in AFTER_DIM_NAME + } + case AFTER_AS_KW -> { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + if (type == Basic4GL.IDENTIFIER + || type == Basic4GL.INTEGER_T + || type == Basic4GL.INT_T + || type == Basic4GL.SINGLE_T + || type == Basic4GL.DOUBLE_T + || type == Basic4GL.STRING_T) { + pendingVarType = t.getText(); + } + if (pendingVarNameToken != null) { + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + } + pendingVarNameToken = null; + pendingVarType = null; + state = NONE; + } + } + } + + if ((state == AFTER_DIM_NAME || state == AFTER_AS_KW) && pendingVarNameToken != null) { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + emitVariableDeclaration( + declarations, variableDeclCounts, pendingVarNameToken, pendingVarType, effectiveRoutine, fileId); + } + + return declarations; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + } diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java deleted file mode 100644 index f65d5437..00000000 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java +++ /dev/null @@ -1,774 +0,0 @@ -package com.basic4gl.language.adapter; - -import com.basic4gl.desktop.spi.language.HighlightKind; -import com.basic4gl.desktop.spi.language.IndexedSymbol; -import com.basic4gl.desktop.spi.language.LangToken; -import com.basic4gl.desktop.spi.language.LanguageSupport; -import com.basic4gl.desktop.spi.language.SymbolDeclaration; -import com.basic4gl.language.adapter.antlr.Basic4GL; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.Token; - -/** - * {@link LanguageSupport} implementation for the Basic4GL language. - * - *

      Backed by the ANTLR4-generated {@link Basic4GL} lexer produced from {@code Basic4GL.g4}. - * That single grammar file is the source of truth for: - * - *

        - *
      • Which character sequences are keywords, operators, literals, comments, etc. - *
      • Which identifiers are reserved and must be excluded from label / symbol heuristics. - *
      - * - *

      This class contains no RSyntaxTextArea imports. The IDE adapter - * ({@code LanguageSupportTokenMaker}) is the only class that knows about RSyntaxTextArea. - * - *

      Thread-safe: each call to {@link #tokenizeLine} and {@link #extractSymbols} creates a fresh - * {@link Basic4GL} lexer instance, so concurrent calls from EDT and background threads are safe. - */ -public class Basic4GLLanguageSupport implements LanguageSupport { - - private static final String SYNTAX_STYLE = "text/basic4gl"; - - // ------------------------------------------------------------------------- - // LanguageSupport – identity - // ------------------------------------------------------------------------- - - @Override - public String syntaxStyle() { - return SYNTAX_STYLE; - } - - // ------------------------------------------------------------------------- - // LanguageSupport – tokenisation - // ------------------------------------------------------------------------- - - @Override - public List tokenizeLine(String line) { - if (line == null || line.isEmpty()) { - return List.of(); - } - Basic4GL lexer = createLexer(line); - List result = new ArrayList<>(); - Token token; - while ((token = lexer.nextToken()).getType() != Token.EOF) { - // Skip NEWLINE tokens – the caller provides one line at a time - if (token.getType() == Basic4GL.NEWLINE) { - continue; - } - result.add(toLangToken(token)); - } - return result; - } - - @Override - public HighlightKind classify(LangToken token) { - return switch (token.type()) { - // Preprocessor - case Basic4GL.INCLUDE_DIR -> HighlightKind.PREPROCESSOR; - - // Comments - case Basic4GL.COMMENT, Basic4GL.REM_COMMENT -> HighlightKind.COMMENT; - - // Primary keywords - case Basic4GL.FUNCTION_KW, - Basic4GL.SUB_KW, - Basic4GL.DIM_KW, - Basic4GL.AS_KW, - Basic4GL.GOTO_KW, - Basic4GL.GOSUB_KW, - Basic4GL.IF_KW, - Basic4GL.THEN_KW, - Basic4GL.ELSE_KW, - Basic4GL.ELSEIF_KW, - Basic4GL.ENDIF_KW, - Basic4GL.END_KW, - Basic4GL.RETURN_KW, - Basic4GL.FOR_KW, - Basic4GL.TO_KW, - Basic4GL.STEP_KW, - Basic4GL.NEXT_KW, - Basic4GL.WHILE_KW, - Basic4GL.WEND_KW, - Basic4GL.RUN_KW, - Basic4GL.STRUC_KW, - Basic4GL.ENDSTRUC_KW, - Basic4GL.CONST_KW, - Basic4GL.ALLOC_KW, - Basic4GL.NULL_KW, - Basic4GL.DATA_KW, - Basic4GL.READ_KW, - Basic4GL.RESET_KW, - Basic4GL.TYPE_KW, - Basic4GL.AND_KW, - Basic4GL.OR_KW, - Basic4GL.NOT_KW, - Basic4GL.XOR_KW, - Basic4GL.MOD_KW -> HighlightKind.KEYWORD; - - // Secondary keywords – type names and boolean literals - case Basic4GL.INTEGER_T, - Basic4GL.INT_T, - Basic4GL.SINGLE_T, - Basic4GL.DOUBLE_T, - Basic4GL.STRING_T, - Basic4GL.TRUE_KW, - Basic4GL.FALSE_KW -> HighlightKind.KEYWORD_2; - - // Literals - case Basic4GL.STRING_LIT -> HighlightKind.STRING; - case Basic4GL.INT_LIT, Basic4GL.FLOAT_LIT, Basic4GL.HEX_LIT -> HighlightKind.NUMBER; - - // Identifiers – the IDE adapter re-classifies these via wordsToHighlight - case Basic4GL.IDENTIFIER -> HighlightKind.IDENTIFIER; - - // Whitespace - case Basic4GL.WS -> HighlightKind.WHITESPACE; - case Basic4GL.NEWLINE -> HighlightKind.NEWLINE; - - // Operators and punctuation - case Basic4GL.COLON, - Basic4GL.LPAREN, - Basic4GL.RPAREN, - Basic4GL.LBRACKET, - Basic4GL.RBRACKET, - Basic4GL.COMMA, - Basic4GL.DOT, - Basic4GL.SEMICOLON, - Basic4GL.EQ, - Basic4GL.NEQ, - Basic4GL.LT, - Basic4GL.GT, - Basic4GL.LTE, - Basic4GL.GTE, - Basic4GL.PLUS, - Basic4GL.MINUS, - Basic4GL.STAR, - Basic4GL.SLASH, - Basic4GL.BACKSLASH, - Basic4GL.CARET, - Basic4GL.AT, - Basic4GL.BANG, - Basic4GL.TILDE, - Basic4GL.PERCENT, - Basic4GL.PIPE, - Basic4GL.HASH, - Basic4GL.AMPERSAND -> HighlightKind.OPERATOR; - - // Unknown / unrecognised - default -> HighlightKind.OTHER; - }; - } - - // ------------------------------------------------------------------------- - // LanguageSupport – symbol extraction - // ------------------------------------------------------------------------- - - /** - * Scans the full source text and extracts user-defined symbols by walking the ANTLR token - * stream with a lightweight state machine. - * - *

      Recognised patterns: - * - *

        - *
      • {@code function Name(params)} / {@code sub Name(params)} → {@code "userfunc"} - *
      • {@code Name:} (identifier immediately followed by {@code COLON}) → {@code "label"} - *
      • {@code dim Name [as Type]} → {@code "variable"} - *
      - */ - @Override - public List extractSymbols(String source) { - if (source == null || source.isEmpty()) { - return List.of(); - } - - Basic4GL lexer = createLexer(source); - CommonTokenStream stream = new CommonTokenStream(lexer); - stream.fill(); - List tokens = stream.getTokens(); - - Map symbolsByKey = new LinkedHashMap<>(); - Map variableDeclCounts = new LinkedHashMap<>(); - - // State machine - final int NONE = 0; - final int AFTER_FUNC_KW = 1; // saw function/sub – next identifier is the name - final int COLLECT_PARAMS = 2; // collecting signature text inside ( … ) - final int AFTER_DIM_KW = 3; // saw dim – next identifier is the variable name - final int AFTER_DIM_NAME = 4; // saw dim name – look for 'as ' - final int AFTER_AS_KW = 5; // saw 'as' after dim name – next identifier is type - - int state = NONE; - String pendingFuncName = null; - StringBuilder paramBuf = null; - int parenDepth = 0; - String pendingVarName = null; - String pendingVarType = null; - // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the - // type-prefix identifier swap when inside an array-size expression. - int dimArrayDepth = 0; - String currentRoutine = null; - // Struc-scope tracking: dims inside a struc block use "struc:" as scope - // so they never collide with same-named program variables in re-dim counting. - boolean inStruc = false; - String currentStrucName = null; - - for (int i = 0; i < tokens.size(); i++) { - Token t = tokens.get(i); - int type = t.getType(); - - // Skip whitespace, newlines, and EOF in the state machine - if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { - // A newline resets after-dim state (one dim per line) - if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME) { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); - state = NONE; - pendingVarName = null; - pendingVarType = null; - dimArrayDepth = 0; - } - continue; - } - - switch (state) { - case NONE -> { - if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { - state = AFTER_FUNC_KW; - } else if (type == Basic4GL.END_KW) { - Token next = peekNonWs(tokens, i + 1); - if (next != null - && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { - currentRoutine = null; - } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { - // "end type" – same as endstruc - inStruc = false; - currentStrucName = null; - } - } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { - // Entering a struc/type block – capture the struct name from the next identifier - Token nameToken = peekNonWs(tokens, i + 1); - currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) - ? nameToken.getText() - : null; - inStruc = true; - // Emit the struct type itself as a symbol - if (currentStrucName != null) { - addFirstStruct(symbolsByKey, currentStrucName); - } - } else if (type == Basic4GL.ENDSTRUC_KW) { - inStruc = false; - currentStrucName = null; - } else if (type == Basic4GL.DIM_KW) { - state = AFTER_DIM_KW; - } else if (type == Basic4GL.IDENTIFIER) { - // Look ahead (skip WS) for a COLON → label declaration - Token next = peekNonWs(tokens, i + 1); - if (next != null && next.getType() == Basic4GL.COLON) { - addFirstLabel(symbolsByKey, t.getText()); - } - } - } - case AFTER_FUNC_KW -> { - if (type == Basic4GL.IDENTIFIER) { - pendingFuncName = t.getText(); - paramBuf = new StringBuilder(t.getText()).append('('); - parenDepth = 0; - state = COLLECT_PARAMS; - } else { - state = NONE; // unexpected token – reset - } - } - case COLLECT_PARAMS -> { - if (type == Basic4GL.LPAREN) { - parenDepth++; - // don't append – we already opened the sig paren - } else if (type == Basic4GL.RPAREN) { - if (parenDepth == 0) { - // Closing paren of the function signature - String sig = paramBuf.toString().trim(); - // Remove trailing comma if any - if (sig.endsWith(",")) - sig = sig.substring(0, sig.length() - 1).trim(); - addFirstFunction(symbolsByKey, pendingFuncName, sig + ")"); - currentRoutine = pendingFuncName; - state = NONE; - pendingFuncName = null; - paramBuf = null; - } else { - parenDepth--; - paramBuf.append(t.getText()); - } - } else if (type != Basic4GL.WS && type != Basic4GL.NEWLINE) { - if (paramBuf.length() > 0 - && !paramBuf.toString().endsWith("(") - && !paramBuf.toString().endsWith(",") - && !paramBuf.toString().endsWith(" ")) { - paramBuf.append(' '); - } - paramBuf.append(t.getText()); - } - } - case AFTER_DIM_KW -> { - if (type == Basic4GL.IDENTIFIER) { - pendingVarName = t.getText(); - // Infer type from identifier suffix (#, !, $, %) - pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); - state = AFTER_DIM_NAME; - } else { - state = NONE; - } - } - case AFTER_DIM_NAME -> { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { - dimArrayDepth = 0; - state = AFTER_AS_KW; - } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { - dimArrayDepth++; - } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { - if (dimArrayDepth > 0) dimArrayDepth--; - } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { - // "dim Type VarName" – the first IDENTIFIER was the type name, - // this IDENTIFIER is the actual variable name. - // Check if we already have a pendingVarType: if it's the inferred - // type from pendingVarName (the first ID), we're in type-prefix mode. - String inferredFromFirstId = inferTypeFromIdentifierSuffix(pendingVarName); - if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { - // Type-prefix case: pendingVarName is the explicit type, new ID is the var name - String newVarUserType = t.getText(); - String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); - pendingVarType = newVarInferredType != null ? newVarInferredType : pendingVarName; - pendingVarName = newVarUserType; - } - } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { - // 'dim x, y' or 'dim x :' – flush current, continue - flushVariable( - symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); - pendingVarName = null; - pendingVarType = null; - dimArrayDepth = 0; - state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; - } - // else: other tokens (array size expression contents, &, etc.) – stay - } - case AFTER_AS_KW -> { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.IDENTIFIER - || type == Basic4GL.INTEGER_T - || type == Basic4GL.INT_T - || type == Basic4GL.SINGLE_T - || type == Basic4GL.DOUBLE_T - || type == Basic4GL.STRING_T) { - pendingVarType = t.getText(); - flushVariable( - symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); - state = NONE; - } else { - flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, null, effectiveRoutine); - state = NONE; - } - } - } - } - - // Flush any dangling state at EOF - if (state == AFTER_DIM_NAME || state == AFTER_AS_KW) { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); - } - - return new ArrayList<>(symbolsByKey.values()); - } - - @Override - public List extractDeclarations(String source, String fileId) { - if (source == null || source.isEmpty()) { - return List.of(); - } - - Basic4GL lexer = createLexer(source); - CommonTokenStream stream = new CommonTokenStream(lexer); - stream.fill(); - List tokens = stream.getTokens(); - - List declarations = new ArrayList<>(); - Map variableDeclCounts = new LinkedHashMap<>(); - - final int NONE = 0; - final int AFTER_FUNC_KW = 1; - final int COLLECT_PARAMS = 2; - final int AFTER_DIM_KW = 3; - final int AFTER_DIM_NAME = 4; - final int AFTER_AS_KW = 5; - - int state = NONE; - Token pendingFuncNameToken = null; - StringBuilder paramBuf = null; - int parenDepth = 0; - Token pendingVarNameToken = null; - String pendingVarType = null; - // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the - // type-prefix identifier swap when inside an array-size expression. - int dimArrayDepth = 0; - String currentRoutine = null; - // Struc-scope tracking: dims inside a struc block use "struc:" as scope - // so they never collide with same-named program variables in re-dim counting. - boolean inStruc = false; - String currentStrucName = null; - - for (int i = 0; i < tokens.size(); i++) { - Token t = tokens.get(i); - int type = t.getType(); - - if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { - if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME && pendingVarNameToken != null) { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - emitVariableDeclaration( - declarations, - variableDeclCounts, - pendingVarNameToken, - pendingVarType, - effectiveRoutine, - fileId); - pendingVarNameToken = null; - pendingVarType = null; - dimArrayDepth = 0; - state = NONE; - } - continue; - } - - switch (state) { - case NONE -> { - if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { - state = AFTER_FUNC_KW; - } else if (type == Basic4GL.END_KW) { - Token next = peekNonWs(tokens, i + 1); - if (next != null - && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { - currentRoutine = null; - } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { - // "end type" – same as endstruc - inStruc = false; - currentStrucName = null; - } - } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { - // Entering a struc/type block – capture the struct name from the next identifier - Token nameToken = peekNonWs(tokens, i + 1); - currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) - ? nameToken.getText() - : null; - inStruc = true; - // Emit the struct type definition itself as a declaration - if (currentStrucName != null && nameToken != null) { - declarations.add(new SymbolDeclaration( - "struc", - currentStrucName, - "struc " + currentStrucName, - "global", - 1, - fileId, - Math.max(0, nameToken.getLine() - 1), - Math.max(0, nameToken.getCharPositionInLine()))); - } - } else if (type == Basic4GL.ENDSTRUC_KW) { - inStruc = false; - currentStrucName = null; - } else if (type == Basic4GL.DIM_KW) { - state = AFTER_DIM_KW; - } else if (type == Basic4GL.IDENTIFIER) { - Token next = peekNonWs(tokens, i + 1); - if (next != null && next.getType() == Basic4GL.COLON) { - declarations.add(new SymbolDeclaration( - "label", - t.getText(), - t.getText() + ":", - currentRoutine == null ? "global" : currentRoutine, - 1, - fileId, - Math.max(0, t.getLine() - 1), - Math.max(0, t.getCharPositionInLine()))); - } - } - } - case AFTER_FUNC_KW -> { - if (type == Basic4GL.IDENTIFIER) { - pendingFuncNameToken = t; - paramBuf = new StringBuilder(t.getText()).append('('); - parenDepth = 0; - state = COLLECT_PARAMS; - } else { - state = NONE; - } - } - case COLLECT_PARAMS -> { - if (type == Basic4GL.LPAREN) { - parenDepth++; - } else if (type == Basic4GL.RPAREN) { - if (parenDepth == 0 && pendingFuncNameToken != null) { - String sig = paramBuf.toString().trim(); - if (sig.endsWith(",")) { - sig = sig.substring(0, sig.length() - 1).trim(); - } - declarations.add(new SymbolDeclaration( - "userfunc", - pendingFuncNameToken.getText(), - sig + ")", - "global", - 1, - fileId, - Math.max(0, pendingFuncNameToken.getLine() - 1), - Math.max(0, pendingFuncNameToken.getCharPositionInLine()))); - currentRoutine = pendingFuncNameToken.getText(); - pendingFuncNameToken = null; - paramBuf = null; - state = NONE; - } else { - parenDepth--; - if (paramBuf != null) { - paramBuf.append(t.getText()); - } - } - } else { - if (paramBuf != null - && !paramBuf.toString().endsWith("(") - && !paramBuf.toString().endsWith(",") - && !paramBuf.toString().endsWith(" ")) { - paramBuf.append(' '); - } - if (paramBuf != null) { - paramBuf.append(t.getText()); - } - } - } - case AFTER_DIM_KW -> { - if (type == Basic4GL.IDENTIFIER) { - pendingVarNameToken = t; - // Infer type from identifier suffix (#, !, $, %) - pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); - state = AFTER_DIM_NAME; - } else { - state = NONE; - } - } - case AFTER_DIM_NAME -> { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { - dimArrayDepth = 0; - state = AFTER_AS_KW; - } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { - dimArrayDepth++; - } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { - if (dimArrayDepth > 0) dimArrayDepth--; - } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { - // "dim Type VarName" – the first IDENTIFIER was the type name, - // this IDENTIFIER is the actual variable name. - String firstIdText = pendingVarNameToken != null ? pendingVarNameToken.getText() : null; - String inferredFromFirstId = inferTypeFromIdentifierSuffix(firstIdText); - if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { - // Type-prefix case: first token is the explicit type, new token is the var name - String newVarUserType = t.getText(); - String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); - pendingVarType = newVarInferredType != null ? newVarInferredType : firstIdText; - pendingVarNameToken = t; - } - } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { - if (pendingVarNameToken != null) { - emitVariableDeclaration( - declarations, - variableDeclCounts, - pendingVarNameToken, - pendingVarType, - effectiveRoutine, - fileId); - } - pendingVarNameToken = null; - pendingVarType = null; - dimArrayDepth = 0; - state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; - } - // else: other tokens (array size expression, &, etc.) – stay in AFTER_DIM_NAME - } - case AFTER_AS_KW -> { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.IDENTIFIER - || type == Basic4GL.INTEGER_T - || type == Basic4GL.INT_T - || type == Basic4GL.SINGLE_T - || type == Basic4GL.DOUBLE_T - || type == Basic4GL.STRING_T) { - pendingVarType = t.getText(); - } - if (pendingVarNameToken != null) { - emitVariableDeclaration( - declarations, - variableDeclCounts, - pendingVarNameToken, - pendingVarType, - effectiveRoutine, - fileId); - } - pendingVarNameToken = null; - pendingVarType = null; - state = NONE; - } - } - } - - if ((state == AFTER_DIM_NAME || state == AFTER_AS_KW) && pendingVarNameToken != null) { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - emitVariableDeclaration( - declarations, variableDeclCounts, pendingVarNameToken, pendingVarType, effectiveRoutine, fileId); - } - - return declarations; - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - private static Basic4GL createLexer(String input) { - Basic4GL lexer = new Basic4GL(CharStreams.fromString(input)); - lexer.removeErrorListeners(); // suppress console noise on partial / invalid source - return lexer; - } - - private static LangToken toLangToken(Token t) { - int start = t.getStartIndex(); - // getStopIndex() is inclusive; LangToken.end is exclusive - int end = t.getStopIndex() + 1; - return new LangToken(t.getType(), t.getText(), start, end); - } - - /** Returns the first non-whitespace token at or after position {@code from}, or null. */ - private static Token peekNonWs(List tokens, int from) { - for (int i = from; i < tokens.size(); i++) { - int type = tokens.get(i).getType(); - if (type != Basic4GL.WS && type != Token.EOF) { - return tokens.get(i); - } - } - return null; - } - - private static void addFirstLabel(Map out, String name) { - if (name == null || name.isBlank()) { - return; - } - String key = symbolKey("label", name, null); - out.putIfAbsent(key, new IndexedSymbol("label", name, name + ":")); - } - - private static void addFirstFunction(Map out, String name, String signature) { - if (name == null || name.isBlank()) { - return; - } - String key = symbolKey("userfunc", name, null); - out.putIfAbsent(key, new IndexedSymbol("userfunc", name, signature)); - } - - private static void addFirstStruct(Map out, String name) { - if (name == null || name.isBlank()) { - return; - } - String key = symbolKey("struc", name, null); - out.putIfAbsent(key, new IndexedSymbol("struc", name, "struc " + name)); - } - - private static void flushVariable( - Map out, - Map variableDeclCounts, - String name, - String type, - String currentRoutine) { - if (name == null || name.isBlank()) { - return; - } - - String scope = currentRoutine == null ? "global" : currentRoutine; - String key = symbolKey("variable", name, scope); - int declCount = variableDeclCounts.merge(key, 1, Integer::sum); - - String baseSig = (type != null && !type.isBlank()) ? type + " " + name : name; - String scopedSig = baseSig + " [scope: " + scope + "]"; - String sig = declCount > 1 ? scopedSig + " [re-dim x" + declCount + "]" : scopedSig; - out.put(key, new IndexedSymbol("variable", name, sig)); - } - - private static void emitVariableDeclaration( - List declarations, - Map variableDeclCounts, - Token nameToken, - String type, - String currentRoutine, - String fileId) { - String name = nameToken.getText(); - if (name == null || name.isBlank()) { - return; - } - String scope = currentRoutine == null ? "global" : currentRoutine; - String key = symbolKey("variable", name, scope); - int declCount = variableDeclCounts.merge(key, 1, Integer::sum); - String baseSig = (type != null && !type.isBlank()) ? type + " " + name : name; - String scopedSig = baseSig + " [scope: " + scope + "]"; - String sig = declCount > 1 ? scopedSig + " [re-dim x" + declCount + "]" : scopedSig; - - declarations.add(new SymbolDeclaration( - "variable", - name, - sig, - scope, - declCount, - fileId, - Math.max(0, nameToken.getLine() - 1), - Math.max(0, nameToken.getCharPositionInLine()))); - } - - private static String symbolKey(String kind, String name, String scope) { - String normalizedName = name == null ? "" : name.toLowerCase(Locale.ROOT); - if (scope == null || scope.isBlank()) { - return kind + "|" + normalizedName; - } - return kind + "|" + scope.toLowerCase(Locale.ROOT) + "|" + normalizedName; - } - - /** - * Infer the type of a variable from its identifier suffix. - * Returns the inferred type, or null if no suffix. - * - *
        - *
      • {@code #} or {@code !} → "real"
      • - *
      • {@code $} → "string"
      • - *
      • {@code %} → "integer"
      • - *
      • no suffix → null (undefined type)
      • - *
      - */ - private static String inferTypeFromIdentifierSuffix(String identifier) { - if (identifier == null || identifier.isEmpty()) { - return null; - } - char last = identifier.charAt(identifier.length() - 1); - return switch (last) { - case '#', '!' -> "real"; - case '$' -> "string"; - case '%' -> "integer"; - default -> null; - }; - } -} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java index 6d4bda78..0be481c6 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java @@ -1,8 +1,18 @@ package com.basic4gl.language.adapter.util; +import com.basic4gl.desktop.spi.language.IndexedSymbol; +import com.basic4gl.desktop.spi.language.LangToken; +import com.basic4gl.desktop.spi.language.SymbolDeclaration; import com.basic4gl.desktop.spi.language.TypeDefinition; +import com.basic4gl.language.adapter.antlr.Basic4GL; import com.basic4gl.language.core.types.BasicValType; import com.basic4gl.language.core.types.ValType; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.Token; + +import java.util.List; +import java.util.Locale; +import java.util.Map; public final class LanguageUtil { private LanguageUtil() {} @@ -53,4 +63,134 @@ public static String getTypeString(int type) { return "???"; } } + + + public static Basic4GL createLexer(String input) { + Basic4GL lexer = new Basic4GL(CharStreams.fromString(input)); + lexer.removeErrorListeners(); // suppress console noise on partial / invalid source + return lexer; + } + + public static LangToken toLangToken(Token t) { + int start = t.getStartIndex(); + // getStopIndex() is inclusive; LangToken.end is exclusive + int end = t.getStopIndex() + 1; + return new LangToken(t.getType(), t.getText(), start, end); + } + + /** Returns the first non-whitespace token at or after position {@code from}, or null. */ + public static Token peekNonWs(List tokens, int from) { + for (int i = from; i < tokens.size(); i++) { + int type = tokens.get(i).getType(); + if (type != Basic4GL.WS && type != Token.EOF) { + return tokens.get(i); + } + } + return null; + } + + public static void addFirstLabel(Map out, String name) { + if (name == null || name.isBlank()) { + return; + } + String key = symbolKey("label", name, null); + out.putIfAbsent(key, new IndexedSymbol("label", name, name + ":")); + } + + public static void addFirstFunction(Map out, String name, String signature) { + if (name == null || name.isBlank()) { + return; + } + String key = symbolKey("userfunc", name, null); + out.putIfAbsent(key, new IndexedSymbol("userfunc", name, signature)); + } + + public static void addFirstStruct(Map out, String name) { + if (name == null || name.isBlank()) { + return; + } + String key = symbolKey("struc", name, null); + out.putIfAbsent(key, new IndexedSymbol("struc", name, "struc " + name)); + } + + public static void flushVariable( + Map out, + Map variableDeclCounts, + String name, + String type, + String currentRoutine) { + if (name == null || name.isBlank()) { + return; + } + + String scope = currentRoutine == null ? "global" : currentRoutine; + String key = symbolKey("variable", name, scope); + int declCount = variableDeclCounts.merge(key, 1, Integer::sum); + + String baseSig = (type != null && !type.isBlank()) ? type + " " + name : name; + String scopedSig = baseSig + " [scope: " + scope + "]"; + String sig = declCount > 1 ? scopedSig + " [re-dim x" + declCount + "]" : scopedSig; + out.put(key, new IndexedSymbol("variable", name, sig)); + } + + public static void emitVariableDeclaration( + List declarations, + Map variableDeclCounts, + Token nameToken, + String type, + String currentRoutine, + String fileId) { + String name = nameToken.getText(); + if (name == null || name.isBlank()) { + return; + } + String scope = currentRoutine == null ? "global" : currentRoutine; + String key = symbolKey("variable", name, scope); + int declCount = variableDeclCounts.merge(key, 1, Integer::sum); + String baseSig = (type != null && !type.isBlank()) ? type + " " + name : name; + String scopedSig = baseSig + " [scope: " + scope + "]"; + String sig = declCount > 1 ? scopedSig + " [re-dim x" + declCount + "]" : scopedSig; + + declarations.add(new SymbolDeclaration( + "variable", + name, + sig, + scope, + declCount, + fileId, + Math.max(0, nameToken.getLine() - 1), + Math.max(0, nameToken.getCharPositionInLine()))); + } + + public static String symbolKey(String kind, String name, String scope) { + String normalizedName = name == null ? "" : name.toLowerCase(Locale.ROOT); + if (scope == null || scope.isBlank()) { + return kind + "|" + normalizedName; + } + return kind + "|" + scope.toLowerCase(Locale.ROOT) + "|" + normalizedName; + } + + /** + * Infer the type of a variable from its identifier suffix. + * Returns the inferred type, or null if no suffix. + * + *
        + *
      • {@code #} or {@code !} → "real"
      • + *
      • {@code $} → "string"
      • + *
      • {@code %} → "integer"
      • + *
      • no suffix → null (undefined type)
      • + *
      + */ + public static String inferTypeFromIdentifierSuffix(String identifier) { + if (identifier == null || identifier.isEmpty()) { + return null; + } + char last = identifier.charAt(identifier.length() - 1); + return switch (last) { + case '#', '!' -> "real"; + case '$' -> "string"; + case '%' -> "integer"; + default -> null; + }; + } } From 6f2d2ec5eec5a53409674f10f095571c5fd1f330 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sun, 12 Jul 2026 02:37:50 -0400 Subject: [PATCH 15/38] project builds --- .../basic4gl/desktop/spi/EditorPlugin.java | 6 +- .../basic4gl/desktop/spi/LanguageService.java | 77 +-- .../desktop/spi/language/LanguageSupport.java | 96 +++ .../com/basic4gl/desktop/BasicEditor.java | 6 + .../java/com/basic4gl/desktop/MainWindow.java | 9 +- .../desktop/debugger/DebugPresenter.java | 4 + .../desktop/editor/BasicTokenMaker.java | 11 +- .../editor/LanguageSupportTokenMaker.java | 8 +- .../desktop/language/SymbolIndexer.java | 6 +- .../desktop/panels/AssetsPanelProvider.java | 3 + .../panels/FileBrowserPanelProvider.java | 4 + .../desktop/panels/SymbolsPanelProvider.java | 5 +- .../adapter/Basic4GLEditorPluginAdapter.java | 6 + .../adapter/Basic4GLLanguageService.java | 608 ----------------- .../adapter/Basic4GLLanguageSupport.java | 643 ++++++++++++++++++ 15 files changed, 792 insertions(+), 700 deletions(-) create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java create mode 100644 app/src/main/java/com/basic4gl/desktop/debugger/DebugPresenter.java create mode 100644 language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorPlugin.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorPlugin.java index 71135108..ca20f717 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorPlugin.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorPlugin.java @@ -1,5 +1,7 @@ package com.basic4gl.desktop.spi; +import com.basic4gl.desktop.spi.language.LanguageSupport; + public abstract class EditorPlugin { public abstract String getName(); @@ -37,8 +39,10 @@ public void onUnload() { public abstract CompilerService getCompiler(); public abstract PreprocessorService getPreprocessor(); - + // TODO this should be renamed public abstract LanguageService getLanguage(); + // TODO this should be renamed + public abstract LanguageSupport getLanguageSupport(); public abstract DebugService getDebug(); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java index d711dfdd..d3eb5670 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java @@ -2,8 +2,9 @@ import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.types.StackFrame; -import com.basic4gl.desktop.spi.language.*; - +import com.basic4gl.desktop.spi.language.FunctionDefinition; +import com.basic4gl.desktop.spi.language.LabelDefinition; +import com.basic4gl.desktop.spi.language.VariableDefinition; import java.util.ArrayList; import java.util.List; @@ -39,76 +40,4 @@ public interface LanguageService { Iterable getLabelDefinitions(); Iterable getFunctionDefinitions(); - - - // ------------------------------------------------------------------------- - // Identity - // ------------------------------------------------------------------------- - - /** - * The MIME-type style string used to register this language with RSyntaxTextArea's - * {@code TokenMakerFactory} (e.g. {@code "text/basic4gl"}). - * - *

      The value is opaque to the core indexer but consumed by the RSyntaxTextArea adapter. - */ - String syntaxStyle(); - - // ------------------------------------------------------------------------- - // Tokenisation - // ------------------------------------------------------------------------- - - /** - * Tokenizes a single line of source text. - * - *

      The returned list contains all tokens in left-to-right order. {@link LangToken#start()} - * and {@link LangToken#end()} are 0-based character offsets within {@code line}. - * - *

      Implementations must not return {@code null}; an empty line may return an empty list. - * - * @param line a single line of source (no {@code \n}) - * @return ordered, non-null token list - */ - List tokenizeLine(String line); - - /** - * Maps an implementation-specific {@link LangToken#type()} to a portable - * {@link HighlightKind}. - * - *

      This is the only place where the internal token type integers are interpreted. - * All other code works with {@link HighlightKind} values. - * - * @param token a token previously produced by {@link #tokenizeLine} - * @return the semantic highlight category; never {@code null} - */ - HighlightKind classify(LangToken token); - - // ------------------------------------------------------------------------- - // Symbol extraction - // ------------------------------------------------------------------------- - - /** - * Scans the full source text (which may span multiple concatenated files) and returns every - * user-defined symbol it can discover. - * - *

      This method is called from a background thread by the {@code SymbolIndexer} after each - * debounce cycle; it must not touch Swing components. - * - * @param source full program source text - * @return discovered symbols; never {@code null} - */ - List extractSymbols(String source); - - /** - * Extracts declaration sites from source for navigation features (e.g. Go To Declaration). - * - *

      Default implementation returns an empty list so existing language plugins remain binary - * compatible until they opt into declaration-aware navigation. - * - * @param source full source text - * @param fileId caller-provided source identifier (typically absolute file path) - * @return declaration list; never {@code null} - */ - default List extractDeclarations(String source, String fileId) { - return List.of(); - } } diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java new file mode 100644 index 00000000..2f1857d8 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/language/LanguageSupport.java @@ -0,0 +1,96 @@ +package com.basic4gl.desktop.spi.language; + +import java.util.List; + +/** + * Plugin contract for a language definition. + * + *

      A single implementation encapsulates everything the IDE needs to know about one language: + * + *

        + *
      • How to tokenize source text (for syntax highlighting) + *
      • How to classify each token into a portable {@link HighlightKind} + *
      • How to extract user-defined symbols from source (for the reference panel / indexer) + *
      + * + *

      No RSyntaxTextArea or other UI framework types appear in this interface. + * Adapters that bridge to a specific UI toolkit ({@code LanguageSupportTokenMaker} for + * RSyntaxTextArea, a future LSP adapter, etc.) hold a reference to a {@code LanguageSupport} + * and translate its output into whatever the toolkit requires. + * + *

      Implementations are expected to be thread-safe: {@link #tokenizeLine} and + * {@link #extractSymbols} may be called concurrently from both the EDT and background threads. + */ +public interface LanguageSupport { + + // ------------------------------------------------------------------------- + // Identity + // ------------------------------------------------------------------------- + + /** + * The MIME-type style string used to register this language with RSyntaxTextArea's + * {@code TokenMakerFactory} (e.g. {@code "text/basic4gl"}). + * + *

      The value is opaque to the core indexer but consumed by the RSyntaxTextArea adapter. + */ + String syntaxStyle(); + + // ------------------------------------------------------------------------- + // Tokenisation + // ------------------------------------------------------------------------- + + /** + * Tokenizes a single line of source text. + * + *

      The returned list contains all tokens in left-to-right order. {@link LangToken#start()} + * and {@link LangToken#end()} are 0-based character offsets within {@code line}. + * + *

      Implementations must not return {@code null}; an empty line may return an empty list. + * + * @param line a single line of source (no {@code \n}) + * @return ordered, non-null token list + */ + List tokenizeLine(String line); + + /** + * Maps an implementation-specific {@link LangToken#type()} to a portable + * {@link HighlightKind}. + * + *

      This is the only place where the internal token type integers are interpreted. + * All other code works with {@link HighlightKind} values. + * + * @param token a token previously produced by {@link #tokenizeLine} + * @return the semantic highlight category; never {@code null} + */ + HighlightKind classify(LangToken token); + + // ------------------------------------------------------------------------- + // Symbol extraction + // ------------------------------------------------------------------------- + + /** + * Scans the full source text (which may span multiple concatenated files) and returns every + * user-defined symbol it can discover. + * + *

      This method is called from a background thread by the {@code SymbolIndexer} after each + * debounce cycle; it must not touch Swing components. + * + * @param source full program source text + * @return discovered symbols; never {@code null} + */ + List extractSymbols(String source); + + /** + * Extracts declaration sites from source for navigation features (e.g. Go To Declaration). + * + *

      Default implementation returns an empty list so existing language plugins remain binary + * compatible until they opt into declaration-aware navigation. + * + * @param source full source text + * @param fileId caller-provided source identifier (typically absolute file path) + * @return declaration list; never {@code null} + */ + default List extractDeclarations(String source, String fileId) { + return List.of(); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java index 701afe06..ad0011ac 100644 --- a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java @@ -15,6 +15,7 @@ import com.basic4gl.desktop.editor.FileEditor; import com.basic4gl.desktop.editor.IEditorPresenter; import com.basic4gl.desktop.spi.*; +import com.basic4gl.desktop.spi.language.LanguageSupport; import com.basic4gl.desktop.util.*; import com.basic4gl.language.adapter.Basic4GLEditorPluginAdapter; import com.basic4gl.language.core.runtime.CallbackMessage; @@ -1170,6 +1171,10 @@ public LanguageService getLanguageService() { return basic4gl.getLanguage(); } + public LanguageSupport getLanguageSupport() { + return basic4gl.getLanguageSupport(); + } + public List getBuilders() { return builders; } @@ -1179,6 +1184,7 @@ public Basic4GLEditorPluginAdapter getBasic4gl() { return basic4gl; } + // TODO Reimplement callbacks public class DebugCallback implements com.basic4gl.language.core.runtime.DebuggerTaskCallback { diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 00f5202c..253c60ce 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -703,8 +703,6 @@ protected void installDefaults() { configurePrimaryTabHost(); configureSplitTabs(); configureTabContextMenu(); - configureLeftSidebar(); - configureRightSidebar(); editorSplitPane.setLeftComponent(primaryTabHost); editorSplitPane.setRightComponent(splitTabControl); @@ -762,8 +760,8 @@ public void windowDeactivated(WindowEvent e) {} fileManager = new FileManager(this); panels = new IEditorPanelProvider[] { - new AssetsPanelProvider(fileManager), new FileBrowserPanelProvider(), + new AssetsPanelProvider(fileManager), new BookmarksPanelProvider(), new DebugPanelProvider(), new SymbolsPanelProvider(), @@ -773,6 +771,9 @@ public void windowDeactivated(WindowEvent e) {} new BasicDialogService(this.frame), this, this); + configureLeftSidebar(); + configureRightSidebar(); + // TODO Confirm this doesn't break if app is ever signed // getParent fileManager.setAppDirectory(new File(".").getAbsolutePath()); @@ -1353,7 +1354,7 @@ private java.util.List coll File file = editor.getFile(); fileId = file != null ? file.getAbsolutePath() : ""; } - declarations.addAll(basicEditor.getLanguageService().extractDeclarations(editor.getEditorPane().getText(), fileId)); + declarations.addAll(basicEditor.getLanguageSupport().extractDeclarations(editor.getEditorPane().getText(), fileId)); } return declarations; } diff --git a/app/src/main/java/com/basic4gl/desktop/debugger/DebugPresenter.java b/app/src/main/java/com/basic4gl/desktop/debugger/DebugPresenter.java new file mode 100644 index 00000000..5f0ab475 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/debugger/DebugPresenter.java @@ -0,0 +1,4 @@ +package com.basic4gl.desktop.debugger; + +public class DebugPresenter { +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java index ca411090..598b791f 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java @@ -1,17 +1,15 @@ package com.basic4gl.desktop.editor; +import com.basic4gl.language.adapter.Basic4GLLanguageSupport; import java.util.ArrayList; import java.util.List; - -import com.basic4gl.desktop.spi.LanguageService; -import com.basic4gl.language.adapter.Basic4GLLanguageService; import org.fife.ui.rsyntaxtextarea.Token; import org.fife.ui.rsyntaxtextarea.TokenMap; /** * RSyntaxTextArea {@code TokenMaker} for the Basic4GL language. * - *

      All tokenisation logic now lives in {@link com.basic4gl.language.adapter.Basic4GLLanguageService} (backed by the + *

      All tokenisation logic now lives in {@link Basic4GLLanguageSupport} (backed by the * ANTLR-generated {@code Basic4GL} lexer). This class is retained so that: * *

        @@ -24,6 +22,7 @@ public class BasicTokenMaker extends LanguageSupportTokenMaker { private static final String INCLUDE = "include "; + // TODO "#plugin " should be added to the antlr config private static final String PLUGIN = "#plugin "; private static final char CHAR_COMMENT = '\''; @@ -37,8 +36,8 @@ public class BasicTokenMaker extends LanguageSupportTokenMaker { /** No-arg constructor used by RSyntaxTextArea's {@code TokenMakerFactory} via reflection. */ public BasicTokenMaker() { - // TODO... this won't work. - super(new Basic4GLLanguageService()); + // TODO this should be injected somehow.. + super(new Basic4GLLanguageSupport()); } /** diff --git a/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java b/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java index 6cef9fe9..6eb38123 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/LanguageSupportTokenMaker.java @@ -1,8 +1,8 @@ package com.basic4gl.desktop.editor; -import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.language.HighlightKind; import com.basic4gl.desktop.spi.language.LangToken; +import com.basic4gl.desktop.spi.language.LanguageSupport; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -13,7 +13,7 @@ /** * Generic RSyntaxTextArea {@link AbstractTokenMaker} that delegates all lexical analysis to a - * pluggable {@link LanguageService} instance. + * pluggable {@link LanguageSupport} instance. * *

        This is the only class in the IDE that imports RSyntaxTextArea types and * bridges them to the language-neutral {@code language} package. Swapping the language is a @@ -27,7 +27,7 @@ public class LanguageSupportTokenMaker extends AbstractTokenMaker { private static final int LINE_TOKEN_CACHE_SIZE = 512; - private final LanguageService languageSupport; + private final LanguageSupport languageSupport; private final Map> lineTokenCache = new LinkedHashMap<>(128, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry> eldest) { @@ -35,7 +35,7 @@ protected boolean removeEldestEntry(Map.Entry> eldest) { } }; - public LanguageSupportTokenMaker(LanguageService languageSupport) { + public LanguageSupportTokenMaker(LanguageSupport languageSupport) { this.languageSupport = languageSupport; } diff --git a/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java index e91c313e..f473cfdf 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java +++ b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java @@ -2,6 +2,8 @@ import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.language.IndexedSymbol; +import com.basic4gl.desktop.spi.language.LanguageSupport; + import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.concurrent.Executors; @@ -47,7 +49,7 @@ public interface Callback { /** Milliseconds to wait after the last change before running extraction. */ private static final long DEBOUNCE_MILLIS = 400; - private final LanguageService languageSupport; + private final LanguageSupport languageSupport; private final SourceProvider sourceProvider; private final Callback callback; @@ -61,7 +63,7 @@ public interface Callback { private long requestedRevision = 0; public SymbolIndexer( - LanguageService languageSupport, + LanguageSupport languageSupport, SourceProvider sourceProvider, Callback callback) { this.languageSupport = languageSupport; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java index c83343d1..387efaa1 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java @@ -328,6 +328,9 @@ private void showAssetsPopup(AssetItem item, Component invoker, int x, int y) { @Override public void refresh(EditorPlugin languageProvider) { + if (context == null) { + return; + } File rootDir = new File(context.currentDirectory()); assetThumbnailCache.clear(); DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(new AssetItem( diff --git a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java index 7c6d5b78..e75a0083 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java @@ -55,6 +55,7 @@ public EditorLayout getLayoutConstraints() { @Override public JPanel build(PluginContext context) { + this.context = context; JPanel panel = new JPanel(new BorderLayout(0, 6)); JPanel header = new JPanel(new BorderLayout()); JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); @@ -203,6 +204,9 @@ private void maybeShowWorkspaceBrowserPopup(MouseEvent e) { @Override public void refresh(EditorPlugin languageProvider) { + if (context == null) { + return; + } File root = new File(context.currentDirectory()); DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0); fileBrowserTree.setModel(new DefaultTreeModel(rootNode)); diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java index 80d485ee..e37b39d5 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -116,7 +116,7 @@ public JPanel build(PluginContext context) { this.context = context; symbolIndexer = - new SymbolIndexer(context.currentEditor().getLanguage(), context.commands()::collectAllSourceText, this::updateProgramSymbols); + new SymbolIndexer(context.currentEditor().getLanguageSupport(), context.commands()::collectAllSourceText, this::updateProgramSymbols); JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); JPanel lookupHeader = new JPanel(new BorderLayout(6, 6)); @@ -243,6 +243,9 @@ public void mouseClicked(MouseEvent e) { @Override public void refresh(EditorPlugin languageProvider) { + if (context == null) { + return; + } populateDocsFromCompiler(); symbolIndexer.schedule(); } diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java index fc88b6a0..f085ff89 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java @@ -5,6 +5,7 @@ import com.basic4gl.compiler.Preprocessor; import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.desktop.spi.*; +import com.basic4gl.desktop.spi.language.LanguageSupport; import com.basic4gl.language.adapter.menu.ReferenceWindow; import com.basic4gl.library.plugin.PluginJAR; import com.basic4gl.library.plugin.PluginJARDetails; @@ -27,6 +28,7 @@ public class Basic4GLEditorPluginAdapter extends EditorPlugin { private final TomVM vm; private final TomBasicCompiler compiler; private final LanguageService languageService; + private final LanguageSupport languageSupport = new Basic4GLLanguageSupport(); private final CompilerService compilerService; private final DebugService debugService; private final PreprocessorService preprocessorService; @@ -90,6 +92,10 @@ public PreprocessorService getPreprocessor() { public LanguageService getLanguage() { return languageService; } + @Override + public LanguageSupport getLanguageSupport() { + return languageSupport; + } @Override public DebugService getDebug() { diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java index 3a095084..782b8045 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java @@ -407,612 +407,4 @@ private Map buildConstantLibraryByName() { } return constantLibraryByName; } - - // ------------------------------------------------------------------------- - // LanguageSupport – identity - // ------------------------------------------------------------------------- - - @Override - public String syntaxStyle() { - return SYNTAX_STYLE; - } - - // ------------------------------------------------------------------------- - // LanguageSupport – tokenisation - // ------------------------------------------------------------------------- - - @Override - public List tokenizeLine(String line) { - if (line == null || line.isEmpty()) { - return List.of(); - } - Basic4GL lexer = createLexer(line); - List result = new ArrayList<>(); - Token token; - while ((token = lexer.nextToken()).getType() != Token.EOF) { - // Skip NEWLINE tokens – the caller provides one line at a time - if (token.getType() == Basic4GL.NEWLINE) { - continue; - } - result.add(toLangToken(token)); - } - return result; - } - - @Override - public HighlightKind classify(LangToken token) { - return switch (token.type()) { - // Preprocessor - case Basic4GL.INCLUDE_DIR -> HighlightKind.PREPROCESSOR; - - // Comments - case Basic4GL.COMMENT, Basic4GL.REM_COMMENT -> HighlightKind.COMMENT; - - // Primary keywords - case Basic4GL.FUNCTION_KW, - Basic4GL.SUB_KW, - Basic4GL.DIM_KW, - Basic4GL.AS_KW, - Basic4GL.GOTO_KW, - Basic4GL.GOSUB_KW, - Basic4GL.IF_KW, - Basic4GL.THEN_KW, - Basic4GL.ELSE_KW, - Basic4GL.ELSEIF_KW, - Basic4GL.ENDIF_KW, - Basic4GL.END_KW, - Basic4GL.RETURN_KW, - Basic4GL.FOR_KW, - Basic4GL.TO_KW, - Basic4GL.STEP_KW, - Basic4GL.NEXT_KW, - Basic4GL.WHILE_KW, - Basic4GL.WEND_KW, - Basic4GL.RUN_KW, - Basic4GL.STRUC_KW, - Basic4GL.ENDSTRUC_KW, - Basic4GL.CONST_KW, - Basic4GL.ALLOC_KW, - Basic4GL.NULL_KW, - Basic4GL.DATA_KW, - Basic4GL.READ_KW, - Basic4GL.RESET_KW, - Basic4GL.TYPE_KW, - Basic4GL.AND_KW, - Basic4GL.OR_KW, - Basic4GL.NOT_KW, - Basic4GL.XOR_KW, - Basic4GL.MOD_KW -> HighlightKind.KEYWORD; - - // Secondary keywords – type names and boolean literals - case Basic4GL.INTEGER_T, - Basic4GL.INT_T, - Basic4GL.SINGLE_T, - Basic4GL.DOUBLE_T, - Basic4GL.STRING_T, - Basic4GL.TRUE_KW, - Basic4GL.FALSE_KW -> HighlightKind.KEYWORD_2; - - // Literals - case Basic4GL.STRING_LIT -> HighlightKind.STRING; - case Basic4GL.INT_LIT, Basic4GL.FLOAT_LIT, Basic4GL.HEX_LIT -> HighlightKind.NUMBER; - - // Identifiers – the IDE adapter re-classifies these via wordsToHighlight - case Basic4GL.IDENTIFIER -> HighlightKind.IDENTIFIER; - - // Whitespace - case Basic4GL.WS -> HighlightKind.WHITESPACE; - case Basic4GL.NEWLINE -> HighlightKind.NEWLINE; - - // Operators and punctuation - case Basic4GL.COLON, - Basic4GL.LPAREN, - Basic4GL.RPAREN, - Basic4GL.LBRACKET, - Basic4GL.RBRACKET, - Basic4GL.COMMA, - Basic4GL.DOT, - Basic4GL.SEMICOLON, - Basic4GL.EQ, - Basic4GL.NEQ, - Basic4GL.LT, - Basic4GL.GT, - Basic4GL.LTE, - Basic4GL.GTE, - Basic4GL.PLUS, - Basic4GL.MINUS, - Basic4GL.STAR, - Basic4GL.SLASH, - Basic4GL.BACKSLASH, - Basic4GL.CARET, - Basic4GL.AT, - Basic4GL.BANG, - Basic4GL.TILDE, - Basic4GL.PERCENT, - Basic4GL.PIPE, - Basic4GL.HASH, - Basic4GL.AMPERSAND -> HighlightKind.OPERATOR; - - // Unknown / unrecognised - default -> HighlightKind.OTHER; - }; - } - - // ------------------------------------------------------------------------- - // LanguageSupport – symbol extraction - // ------------------------------------------------------------------------- - - /** - * Scans the full source text and extracts user-defined symbols by walking the ANTLR token - * stream with a lightweight state machine. - * - *

        Recognised patterns: - * - *

          - *
        • {@code function Name(params)} / {@code sub Name(params)} → {@code "userfunc"} - *
        • {@code Name:} (identifier immediately followed by {@code COLON}) → {@code "label"} - *
        • {@code dim Name [as Type]} → {@code "variable"} - *
        - */ - @Override - public List extractSymbols(String source) { - if (source == null || source.isEmpty()) { - return List.of(); - } - - Basic4GL lexer = createLexer(source); - CommonTokenStream stream = new CommonTokenStream(lexer); - stream.fill(); - List tokens = stream.getTokens(); - - Map symbolsByKey = new LinkedHashMap<>(); - Map variableDeclCounts = new LinkedHashMap<>(); - - // State machine - final int NONE = 0; - final int AFTER_FUNC_KW = 1; // saw function/sub – next identifier is the name - final int COLLECT_PARAMS = 2; // collecting signature text inside ( … ) - final int AFTER_DIM_KW = 3; // saw dim – next identifier is the variable name - final int AFTER_DIM_NAME = 4; // saw dim name – look for 'as ' - final int AFTER_AS_KW = 5; // saw 'as' after dim name – next identifier is type - - int state = NONE; - String pendingFuncName = null; - StringBuilder paramBuf = null; - int parenDepth = 0; - String pendingVarName = null; - String pendingVarType = null; - // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the - // type-prefix identifier swap when inside an array-size expression. - int dimArrayDepth = 0; - String currentRoutine = null; - // Struc-scope tracking: dims inside a struc block use "struc:" as scope - // so they never collide with same-named program variables in re-dim counting. - boolean inStruc = false; - String currentStrucName = null; - - for (int i = 0; i < tokens.size(); i++) { - Token t = tokens.get(i); - int type = t.getType(); - - // Skip whitespace, newlines, and EOF in the state machine - if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { - // A newline resets after-dim state (one dim per line) - if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME) { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); - state = NONE; - pendingVarName = null; - pendingVarType = null; - dimArrayDepth = 0; - } - continue; - } - - switch (state) { - case NONE -> { - if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { - state = AFTER_FUNC_KW; - } else if (type == Basic4GL.END_KW) { - Token next = peekNonWs(tokens, i + 1); - if (next != null - && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { - currentRoutine = null; - } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { - // "end type" – same as endstruc - inStruc = false; - currentStrucName = null; - } - } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { - // Entering a struc/type block – capture the struct name from the next identifier - Token nameToken = peekNonWs(tokens, i + 1); - currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) - ? nameToken.getText() - : null; - inStruc = true; - // Emit the struct type itself as a symbol - if (currentStrucName != null) { - addFirstStruct(symbolsByKey, currentStrucName); - } - } else if (type == Basic4GL.ENDSTRUC_KW) { - inStruc = false; - currentStrucName = null; - } else if (type == Basic4GL.DIM_KW) { - state = AFTER_DIM_KW; - } else if (type == Basic4GL.IDENTIFIER) { - // Look ahead (skip WS) for a COLON → label declaration - Token next = peekNonWs(tokens, i + 1); - if (next != null && next.getType() == Basic4GL.COLON) { - addFirstLabel(symbolsByKey, t.getText()); - } - } - } - case AFTER_FUNC_KW -> { - if (type == Basic4GL.IDENTIFIER) { - pendingFuncName = t.getText(); - paramBuf = new StringBuilder(t.getText()).append('('); - parenDepth = 0; - state = COLLECT_PARAMS; - } else { - state = NONE; // unexpected token – reset - } - } - case COLLECT_PARAMS -> { - if (type == Basic4GL.LPAREN) { - parenDepth++; - // don't append – we already opened the sig paren - } else if (type == Basic4GL.RPAREN) { - if (parenDepth == 0) { - // Closing paren of the function signature - String sig = paramBuf.toString().trim(); - // Remove trailing comma if any - if (sig.endsWith(",")) - sig = sig.substring(0, sig.length() - 1).trim(); - addFirstFunction(symbolsByKey, pendingFuncName, sig + ")"); - currentRoutine = pendingFuncName; - state = NONE; - pendingFuncName = null; - paramBuf = null; - } else { - parenDepth--; - paramBuf.append(t.getText()); - } - } else if (type != Basic4GL.WS && type != Basic4GL.NEWLINE) { - if (paramBuf.length() > 0 - && !paramBuf.toString().endsWith("(") - && !paramBuf.toString().endsWith(",") - && !paramBuf.toString().endsWith(" ")) { - paramBuf.append(' '); - } - paramBuf.append(t.getText()); - } - } - case AFTER_DIM_KW -> { - if (type == Basic4GL.IDENTIFIER) { - pendingVarName = t.getText(); - // Infer type from identifier suffix (#, !, $, %) - pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); - state = AFTER_DIM_NAME; - } else { - state = NONE; - } - } - case AFTER_DIM_NAME -> { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { - dimArrayDepth = 0; - state = AFTER_AS_KW; - } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { - dimArrayDepth++; - } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { - if (dimArrayDepth > 0) dimArrayDepth--; - } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { - // "dim Type VarName" – the first IDENTIFIER was the type name, - // this IDENTIFIER is the actual variable name. - // Check if we already have a pendingVarType: if it's the inferred - // type from pendingVarName (the first ID), we're in type-prefix mode. - String inferredFromFirstId = inferTypeFromIdentifierSuffix(pendingVarName); - if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { - // Type-prefix case: pendingVarName is the explicit type, new ID is the var name - String newVarUserType = t.getText(); - String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); - pendingVarType = newVarInferredType != null ? newVarInferredType : pendingVarName; - pendingVarName = newVarUserType; - } - } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { - // 'dim x, y' or 'dim x :' – flush current, continue - flushVariable( - symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); - pendingVarName = null; - pendingVarType = null; - dimArrayDepth = 0; - state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; - } - // else: other tokens (array size expression contents, &, etc.) – stay - } - case AFTER_AS_KW -> { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.IDENTIFIER - || type == Basic4GL.INTEGER_T - || type == Basic4GL.INT_T - || type == Basic4GL.SINGLE_T - || type == Basic4GL.DOUBLE_T - || type == Basic4GL.STRING_T) { - pendingVarType = t.getText(); - flushVariable( - symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); - state = NONE; - } else { - flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, null, effectiveRoutine); - state = NONE; - } - } - } - } - - // Flush any dangling state at EOF - if (state == AFTER_DIM_NAME || state == AFTER_AS_KW) { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); - } - - return new ArrayList<>(symbolsByKey.values()); - } - - @Override - public List extractDeclarations(String source, String fileId) { - if (source == null || source.isEmpty()) { - return List.of(); - } - - Basic4GL lexer = createLexer(source); - CommonTokenStream stream = new CommonTokenStream(lexer); - stream.fill(); - List tokens = stream.getTokens(); - - List declarations = new ArrayList<>(); - Map variableDeclCounts = new LinkedHashMap<>(); - - final int NONE = 0; - final int AFTER_FUNC_KW = 1; - final int COLLECT_PARAMS = 2; - final int AFTER_DIM_KW = 3; - final int AFTER_DIM_NAME = 4; - final int AFTER_AS_KW = 5; - - int state = NONE; - Token pendingFuncNameToken = null; - StringBuilder paramBuf = null; - int parenDepth = 0; - Token pendingVarNameToken = null; - String pendingVarType = null; - // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the - // type-prefix identifier swap when inside an array-size expression. - int dimArrayDepth = 0; - String currentRoutine = null; - // Struc-scope tracking: dims inside a struc block use "struc:" as scope - // so they never collide with same-named program variables in re-dim counting. - boolean inStruc = false; - String currentStrucName = null; - - for (int i = 0; i < tokens.size(); i++) { - Token t = tokens.get(i); - int type = t.getType(); - - if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { - if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME && pendingVarNameToken != null) { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - emitVariableDeclaration( - declarations, - variableDeclCounts, - pendingVarNameToken, - pendingVarType, - effectiveRoutine, - fileId); - pendingVarNameToken = null; - pendingVarType = null; - dimArrayDepth = 0; - state = NONE; - } - continue; - } - - switch (state) { - case NONE -> { - if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { - state = AFTER_FUNC_KW; - } else if (type == Basic4GL.END_KW) { - Token next = peekNonWs(tokens, i + 1); - if (next != null - && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { - currentRoutine = null; - } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { - // "end type" – same as endstruc - inStruc = false; - currentStrucName = null; - } - } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { - // Entering a struc/type block – capture the struct name from the next identifier - Token nameToken = peekNonWs(tokens, i + 1); - currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) - ? nameToken.getText() - : null; - inStruc = true; - // Emit the struct type definition itself as a declaration - if (currentStrucName != null && nameToken != null) { - declarations.add(new SymbolDeclaration( - "struc", - currentStrucName, - "struc " + currentStrucName, - "global", - 1, - fileId, - Math.max(0, nameToken.getLine() - 1), - Math.max(0, nameToken.getCharPositionInLine()))); - } - } else if (type == Basic4GL.ENDSTRUC_KW) { - inStruc = false; - currentStrucName = null; - } else if (type == Basic4GL.DIM_KW) { - state = AFTER_DIM_KW; - } else if (type == Basic4GL.IDENTIFIER) { - Token next = peekNonWs(tokens, i + 1); - if (next != null && next.getType() == Basic4GL.COLON) { - declarations.add(new SymbolDeclaration( - "label", - t.getText(), - t.getText() + ":", - currentRoutine == null ? "global" : currentRoutine, - 1, - fileId, - Math.max(0, t.getLine() - 1), - Math.max(0, t.getCharPositionInLine()))); - } - } - } - case AFTER_FUNC_KW -> { - if (type == Basic4GL.IDENTIFIER) { - pendingFuncNameToken = t; - paramBuf = new StringBuilder(t.getText()).append('('); - parenDepth = 0; - state = COLLECT_PARAMS; - } else { - state = NONE; - } - } - case COLLECT_PARAMS -> { - if (type == Basic4GL.LPAREN) { - parenDepth++; - } else if (type == Basic4GL.RPAREN) { - if (parenDepth == 0 && pendingFuncNameToken != null) { - String sig = paramBuf.toString().trim(); - if (sig.endsWith(",")) { - sig = sig.substring(0, sig.length() - 1).trim(); - } - declarations.add(new SymbolDeclaration( - "userfunc", - pendingFuncNameToken.getText(), - sig + ")", - "global", - 1, - fileId, - Math.max(0, pendingFuncNameToken.getLine() - 1), - Math.max(0, pendingFuncNameToken.getCharPositionInLine()))); - currentRoutine = pendingFuncNameToken.getText(); - pendingFuncNameToken = null; - paramBuf = null; - state = NONE; - } else { - parenDepth--; - if (paramBuf != null) { - paramBuf.append(t.getText()); - } - } - } else { - if (paramBuf != null - && !paramBuf.toString().endsWith("(") - && !paramBuf.toString().endsWith(",") - && !paramBuf.toString().endsWith(" ")) { - paramBuf.append(' '); - } - if (paramBuf != null) { - paramBuf.append(t.getText()); - } - } - } - case AFTER_DIM_KW -> { - if (type == Basic4GL.IDENTIFIER) { - pendingVarNameToken = t; - // Infer type from identifier suffix (#, !, $, %) - pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); - state = AFTER_DIM_NAME; - } else { - state = NONE; - } - } - case AFTER_DIM_NAME -> { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { - dimArrayDepth = 0; - state = AFTER_AS_KW; - } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { - dimArrayDepth++; - } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { - if (dimArrayDepth > 0) dimArrayDepth--; - } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { - // "dim Type VarName" – the first IDENTIFIER was the type name, - // this IDENTIFIER is the actual variable name. - String firstIdText = pendingVarNameToken != null ? pendingVarNameToken.getText() : null; - String inferredFromFirstId = inferTypeFromIdentifierSuffix(firstIdText); - if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { - // Type-prefix case: first token is the explicit type, new token is the var name - String newVarUserType = t.getText(); - String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); - pendingVarType = newVarInferredType != null ? newVarInferredType : firstIdText; - pendingVarNameToken = t; - } - } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { - if (pendingVarNameToken != null) { - emitVariableDeclaration( - declarations, - variableDeclCounts, - pendingVarNameToken, - pendingVarType, - effectiveRoutine, - fileId); - } - pendingVarNameToken = null; - pendingVarType = null; - dimArrayDepth = 0; - state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; - } - // else: other tokens (array size expression, &, etc.) – stay in AFTER_DIM_NAME - } - case AFTER_AS_KW -> { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - if (type == Basic4GL.IDENTIFIER - || type == Basic4GL.INTEGER_T - || type == Basic4GL.INT_T - || type == Basic4GL.SINGLE_T - || type == Basic4GL.DOUBLE_T - || type == Basic4GL.STRING_T) { - pendingVarType = t.getText(); - } - if (pendingVarNameToken != null) { - emitVariableDeclaration( - declarations, - variableDeclCounts, - pendingVarNameToken, - pendingVarType, - effectiveRoutine, - fileId); - } - pendingVarNameToken = null; - pendingVarType = null; - state = NONE; - } - } - } - - if ((state == AFTER_DIM_NAME || state == AFTER_AS_KW) && pendingVarNameToken != null) { - String effectiveRoutine = - inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; - emitVariableDeclaration( - declarations, variableDeclCounts, pendingVarNameToken, pendingVarType, effectiveRoutine, fileId); - } - - return declarations; - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - } diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java new file mode 100644 index 00000000..1bb6f9fa --- /dev/null +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java @@ -0,0 +1,643 @@ +package com.basic4gl.language.adapter; + +import com.basic4gl.desktop.spi.language.HighlightKind; +import com.basic4gl.desktop.spi.language.IndexedSymbol; +import com.basic4gl.desktop.spi.language.LangToken; +import com.basic4gl.desktop.spi.language.LanguageSupport; +import com.basic4gl.desktop.spi.language.SymbolDeclaration; +import com.basic4gl.language.adapter.antlr.Basic4GL; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.Token; + +import static com.basic4gl.language.adapter.util.LanguageUtil.*; + +/** + * {@link LanguageSupport} implementation for the Basic4GL language. + * + *

        Backed by the ANTLR4-generated {@link Basic4GL} lexer produced from {@code Basic4GL.g4}. + * That single grammar file is the source of truth for: + * + *

          + *
        • Which character sequences are keywords, operators, literals, comments, etc. + *
        • Which identifiers are reserved and must be excluded from label / symbol heuristics. + *
        + * + *

        This class contains no RSyntaxTextArea imports. The IDE adapter + * ({@code LanguageSupportTokenMaker}) is the only class that knows about RSyntaxTextArea. + * + *

        Thread-safe: each call to {@link #tokenizeLine} and {@link #extractSymbols} creates a fresh + * {@link Basic4GL} lexer instance, so concurrent calls from EDT and background threads are safe. + */ +public class Basic4GLLanguageSupport implements LanguageSupport { + + private static final String SYNTAX_STYLE = "text/basic4gl"; + + // ------------------------------------------------------------------------- + // LanguageSupport – identity + // ------------------------------------------------------------------------- + + @Override + public String syntaxStyle() { + return SYNTAX_STYLE; + } + + // ------------------------------------------------------------------------- + // LanguageSupport – tokenisation + // ------------------------------------------------------------------------- + + @Override + public List tokenizeLine(String line) { + if (line == null || line.isEmpty()) { + return List.of(); + } + Basic4GL lexer = createLexer(line); + List result = new ArrayList<>(); + Token token; + while ((token = lexer.nextToken()).getType() != Token.EOF) { + // Skip NEWLINE tokens – the caller provides one line at a time + if (token.getType() == Basic4GL.NEWLINE) { + continue; + } + result.add(toLangToken(token)); + } + return result; + } + + @Override + public HighlightKind classify(LangToken token) { + return switch (token.type()) { + // Preprocessor + case Basic4GL.INCLUDE_DIR -> HighlightKind.PREPROCESSOR; + + // Comments + case Basic4GL.COMMENT, Basic4GL.REM_COMMENT -> HighlightKind.COMMENT; + + // Primary keywords + case Basic4GL.FUNCTION_KW, + Basic4GL.SUB_KW, + Basic4GL.DIM_KW, + Basic4GL.AS_KW, + Basic4GL.GOTO_KW, + Basic4GL.GOSUB_KW, + Basic4GL.IF_KW, + Basic4GL.THEN_KW, + Basic4GL.ELSE_KW, + Basic4GL.ELSEIF_KW, + Basic4GL.ENDIF_KW, + Basic4GL.END_KW, + Basic4GL.RETURN_KW, + Basic4GL.FOR_KW, + Basic4GL.TO_KW, + Basic4GL.STEP_KW, + Basic4GL.NEXT_KW, + Basic4GL.WHILE_KW, + Basic4GL.WEND_KW, + Basic4GL.RUN_KW, + Basic4GL.STRUC_KW, + Basic4GL.ENDSTRUC_KW, + Basic4GL.CONST_KW, + Basic4GL.ALLOC_KW, + Basic4GL.NULL_KW, + Basic4GL.DATA_KW, + Basic4GL.READ_KW, + Basic4GL.RESET_KW, + Basic4GL.TYPE_KW, + Basic4GL.AND_KW, + Basic4GL.OR_KW, + Basic4GL.NOT_KW, + Basic4GL.XOR_KW, + Basic4GL.MOD_KW -> HighlightKind.KEYWORD; + + // Secondary keywords – type names and boolean literals + case Basic4GL.INTEGER_T, + Basic4GL.INT_T, + Basic4GL.SINGLE_T, + Basic4GL.DOUBLE_T, + Basic4GL.STRING_T, + Basic4GL.TRUE_KW, + Basic4GL.FALSE_KW -> HighlightKind.KEYWORD_2; + + // Literals + case Basic4GL.STRING_LIT -> HighlightKind.STRING; + case Basic4GL.INT_LIT, Basic4GL.FLOAT_LIT, Basic4GL.HEX_LIT -> HighlightKind.NUMBER; + + // Identifiers – the IDE adapter re-classifies these via wordsToHighlight + case Basic4GL.IDENTIFIER -> HighlightKind.IDENTIFIER; + + // Whitespace + case Basic4GL.WS -> HighlightKind.WHITESPACE; + case Basic4GL.NEWLINE -> HighlightKind.NEWLINE; + + // Operators and punctuation + case Basic4GL.COLON, + Basic4GL.LPAREN, + Basic4GL.RPAREN, + Basic4GL.LBRACKET, + Basic4GL.RBRACKET, + Basic4GL.COMMA, + Basic4GL.DOT, + Basic4GL.SEMICOLON, + Basic4GL.EQ, + Basic4GL.NEQ, + Basic4GL.LT, + Basic4GL.GT, + Basic4GL.LTE, + Basic4GL.GTE, + Basic4GL.PLUS, + Basic4GL.MINUS, + Basic4GL.STAR, + Basic4GL.SLASH, + Basic4GL.BACKSLASH, + Basic4GL.CARET, + Basic4GL.AT, + Basic4GL.BANG, + Basic4GL.TILDE, + Basic4GL.PERCENT, + Basic4GL.PIPE, + Basic4GL.HASH, + Basic4GL.AMPERSAND -> HighlightKind.OPERATOR; + + // Unknown / unrecognised + default -> HighlightKind.OTHER; + }; + } + + // ------------------------------------------------------------------------- + // LanguageSupport – symbol extraction + // ------------------------------------------------------------------------- + + /** + * Scans the full source text and extracts user-defined symbols by walking the ANTLR token + * stream with a lightweight state machine. + * + *

        Recognised patterns: + * + *

          + *
        • {@code function Name(params)} / {@code sub Name(params)} → {@code "userfunc"} + *
        • {@code Name:} (identifier immediately followed by {@code COLON}) → {@code "label"} + *
        • {@code dim Name [as Type]} → {@code "variable"} + *
        + */ + @Override + public List extractSymbols(String source) { + if (source == null || source.isEmpty()) { + return List.of(); + } + + Basic4GL lexer = createLexer(source); + CommonTokenStream stream = new CommonTokenStream(lexer); + stream.fill(); + List tokens = stream.getTokens(); + + Map symbolsByKey = new LinkedHashMap<>(); + Map variableDeclCounts = new LinkedHashMap<>(); + + // State machine + final int NONE = 0; + final int AFTER_FUNC_KW = 1; // saw function/sub – next identifier is the name + final int COLLECT_PARAMS = 2; // collecting signature text inside ( … ) + final int AFTER_DIM_KW = 3; // saw dim – next identifier is the variable name + final int AFTER_DIM_NAME = 4; // saw dim name – look for 'as ' + final int AFTER_AS_KW = 5; // saw 'as' after dim name – next identifier is type + + int state = NONE; + String pendingFuncName = null; + StringBuilder paramBuf = null; + int parenDepth = 0; + String pendingVarName = null; + String pendingVarType = null; + // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the + // type-prefix identifier swap when inside an array-size expression. + int dimArrayDepth = 0; + String currentRoutine = null; + // Struc-scope tracking: dims inside a struc block use "struc:" as scope + // so they never collide with same-named program variables in re-dim counting. + boolean inStruc = false; + String currentStrucName = null; + + for (int i = 0; i < tokens.size(); i++) { + Token t = tokens.get(i); + int type = t.getType(); + + // Skip whitespace, newlines, and EOF in the state machine + if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { + // A newline resets after-dim state (one dim per line) + if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME) { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); + state = NONE; + pendingVarName = null; + pendingVarType = null; + dimArrayDepth = 0; + } + continue; + } + + switch (state) { + case NONE -> { + if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { + state = AFTER_FUNC_KW; + } else if (type == Basic4GL.END_KW) { + Token next = peekNonWs(tokens, i + 1); + if (next != null + && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { + currentRoutine = null; + } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { + // "end type" – same as endstruc + inStruc = false; + currentStrucName = null; + } + } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { + // Entering a struc/type block – capture the struct name from the next identifier + Token nameToken = peekNonWs(tokens, i + 1); + currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) + ? nameToken.getText() + : null; + inStruc = true; + // Emit the struct type itself as a symbol + if (currentStrucName != null) { + addFirstStruct(symbolsByKey, currentStrucName); + } + } else if (type == Basic4GL.ENDSTRUC_KW) { + inStruc = false; + currentStrucName = null; + } else if (type == Basic4GL.DIM_KW) { + state = AFTER_DIM_KW; + } else if (type == Basic4GL.IDENTIFIER) { + // Look ahead (skip WS) for a COLON → label declaration + Token next = peekNonWs(tokens, i + 1); + if (next != null && next.getType() == Basic4GL.COLON) { + addFirstLabel(symbolsByKey, t.getText()); + } + } + } + case AFTER_FUNC_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingFuncName = t.getText(); + paramBuf = new StringBuilder(t.getText()).append('('); + parenDepth = 0; + state = COLLECT_PARAMS; + } else { + state = NONE; // unexpected token – reset + } + } + case COLLECT_PARAMS -> { + if (type == Basic4GL.LPAREN) { + parenDepth++; + // don't append – we already opened the sig paren + } else if (type == Basic4GL.RPAREN) { + if (parenDepth == 0) { + // Closing paren of the function signature + String sig = paramBuf.toString().trim(); + // Remove trailing comma if any + if (sig.endsWith(",")) + sig = sig.substring(0, sig.length() - 1).trim(); + addFirstFunction(symbolsByKey, pendingFuncName, sig + ")"); + currentRoutine = pendingFuncName; + state = NONE; + pendingFuncName = null; + paramBuf = null; + } else { + parenDepth--; + paramBuf.append(t.getText()); + } + } else if (type != Basic4GL.WS && type != Basic4GL.NEWLINE) { + if (paramBuf.length() > 0 + && !paramBuf.toString().endsWith("(") + && !paramBuf.toString().endsWith(",") + && !paramBuf.toString().endsWith(" ")) { + paramBuf.append(' '); + } + paramBuf.append(t.getText()); + } + } + case AFTER_DIM_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingVarName = t.getText(); + // Infer type from identifier suffix (#, !, $, %) + pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); + state = AFTER_DIM_NAME; + } else { + state = NONE; + } + } + case AFTER_DIM_NAME -> { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { + dimArrayDepth = 0; + state = AFTER_AS_KW; + } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { + dimArrayDepth++; + } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { + if (dimArrayDepth > 0) dimArrayDepth--; + } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { + // "dim Type VarName" – the first IDENTIFIER was the type name, + // this IDENTIFIER is the actual variable name. + // Check if we already have a pendingVarType: if it's the inferred + // type from pendingVarName (the first ID), we're in type-prefix mode. + String inferredFromFirstId = inferTypeFromIdentifierSuffix(pendingVarName); + if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { + // Type-prefix case: pendingVarName is the explicit type, new ID is the var name + String newVarUserType = t.getText(); + String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); + pendingVarType = newVarInferredType != null ? newVarInferredType : pendingVarName; + pendingVarName = newVarUserType; + } + } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { + // 'dim x, y' or 'dim x :' – flush current, continue + flushVariable( + symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); + pendingVarName = null; + pendingVarType = null; + dimArrayDepth = 0; + state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; + } + // else: other tokens (array size expression contents, &, etc.) – stay + } + case AFTER_AS_KW -> { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + if (type == Basic4GL.IDENTIFIER + || type == Basic4GL.INTEGER_T + || type == Basic4GL.INT_T + || type == Basic4GL.SINGLE_T + || type == Basic4GL.DOUBLE_T + || type == Basic4GL.STRING_T) { + pendingVarType = t.getText(); + flushVariable( + symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); + state = NONE; + } else { + flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, null, effectiveRoutine); + state = NONE; + } + } + } + } + + // Flush any dangling state at EOF + if (state == AFTER_DIM_NAME || state == AFTER_AS_KW) { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + flushVariable(symbolsByKey, variableDeclCounts, pendingVarName, pendingVarType, effectiveRoutine); + } + + return new ArrayList<>(symbolsByKey.values()); + } + + @Override + public List extractDeclarations(String source, String fileId) { + if (source == null || source.isEmpty()) { + return List.of(); + } + + Basic4GL lexer = createLexer(source); + CommonTokenStream stream = new CommonTokenStream(lexer); + stream.fill(); + List tokens = stream.getTokens(); + + List declarations = new ArrayList<>(); + Map variableDeclCounts = new LinkedHashMap<>(); + + final int NONE = 0; + final int AFTER_FUNC_KW = 1; + final int COLLECT_PARAMS = 2; + final int AFTER_DIM_KW = 3; + final int AFTER_DIM_NAME = 4; + final int AFTER_AS_KW = 5; + + int state = NONE; + Token pendingFuncNameToken = null; + StringBuilder paramBuf = null; + int parenDepth = 0; + Token pendingVarNameToken = null; + String pendingVarType = null; + // Depth of ( or [ seen while in AFTER_DIM_NAME – used to suppress the + // type-prefix identifier swap when inside an array-size expression. + int dimArrayDepth = 0; + String currentRoutine = null; + // Struc-scope tracking: dims inside a struc block use "struc:" as scope + // so they never collide with same-named program variables in re-dim counting. + boolean inStruc = false; + String currentStrucName = null; + + for (int i = 0; i < tokens.size(); i++) { + Token t = tokens.get(i); + int type = t.getType(); + + if (type == Token.EOF || type == Basic4GL.WS || type == Basic4GL.NEWLINE) { + if (type == Basic4GL.NEWLINE && state == AFTER_DIM_NAME && pendingVarNameToken != null) { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + pendingVarNameToken = null; + pendingVarType = null; + dimArrayDepth = 0; + state = NONE; + } + continue; + } + + switch (state) { + case NONE -> { + if (type == Basic4GL.FUNCTION_KW || type == Basic4GL.SUB_KW) { + state = AFTER_FUNC_KW; + } else if (type == Basic4GL.END_KW) { + Token next = peekNonWs(tokens, i + 1); + if (next != null + && (next.getType() == Basic4GL.FUNCTION_KW || next.getType() == Basic4GL.SUB_KW)) { + currentRoutine = null; + } else if (next != null && next.getType() == Basic4GL.TYPE_KW) { + // "end type" – same as endstruc + inStruc = false; + currentStrucName = null; + } + } else if (type == Basic4GL.STRUC_KW || type == Basic4GL.TYPE_KW) { + // Entering a struc/type block – capture the struct name from the next identifier + Token nameToken = peekNonWs(tokens, i + 1); + currentStrucName = (nameToken != null && nameToken.getType() == Basic4GL.IDENTIFIER) + ? nameToken.getText() + : null; + inStruc = true; + // Emit the struct type definition itself as a declaration + if (currentStrucName != null && nameToken != null) { + declarations.add(new SymbolDeclaration( + "struc", + currentStrucName, + "struc " + currentStrucName, + "global", + 1, + fileId, + Math.max(0, nameToken.getLine() - 1), + Math.max(0, nameToken.getCharPositionInLine()))); + } + } else if (type == Basic4GL.ENDSTRUC_KW) { + inStruc = false; + currentStrucName = null; + } else if (type == Basic4GL.DIM_KW) { + state = AFTER_DIM_KW; + } else if (type == Basic4GL.IDENTIFIER) { + Token next = peekNonWs(tokens, i + 1); + if (next != null && next.getType() == Basic4GL.COLON) { + declarations.add(new SymbolDeclaration( + "label", + t.getText(), + t.getText() + ":", + currentRoutine == null ? "global" : currentRoutine, + 1, + fileId, + Math.max(0, t.getLine() - 1), + Math.max(0, t.getCharPositionInLine()))); + } + } + } + case AFTER_FUNC_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingFuncNameToken = t; + paramBuf = new StringBuilder(t.getText()).append('('); + parenDepth = 0; + state = COLLECT_PARAMS; + } else { + state = NONE; + } + } + case COLLECT_PARAMS -> { + if (type == Basic4GL.LPAREN) { + parenDepth++; + } else if (type == Basic4GL.RPAREN) { + if (parenDepth == 0 && pendingFuncNameToken != null) { + String sig = paramBuf.toString().trim(); + if (sig.endsWith(",")) { + sig = sig.substring(0, sig.length() - 1).trim(); + } + declarations.add(new SymbolDeclaration( + "userfunc", + pendingFuncNameToken.getText(), + sig + ")", + "global", + 1, + fileId, + Math.max(0, pendingFuncNameToken.getLine() - 1), + Math.max(0, pendingFuncNameToken.getCharPositionInLine()))); + currentRoutine = pendingFuncNameToken.getText(); + pendingFuncNameToken = null; + paramBuf = null; + state = NONE; + } else { + parenDepth--; + if (paramBuf != null) { + paramBuf.append(t.getText()); + } + } + } else { + if (paramBuf != null + && !paramBuf.toString().endsWith("(") + && !paramBuf.toString().endsWith(",") + && !paramBuf.toString().endsWith(" ")) { + paramBuf.append(' '); + } + if (paramBuf != null) { + paramBuf.append(t.getText()); + } + } + } + case AFTER_DIM_KW -> { + if (type == Basic4GL.IDENTIFIER) { + pendingVarNameToken = t; + // Infer type from identifier suffix (#, !, $, %) + pendingVarType = inferTypeFromIdentifierSuffix(t.getText()); + state = AFTER_DIM_NAME; + } else { + state = NONE; + } + } + case AFTER_DIM_NAME -> { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + if (type == Basic4GL.AS_KW && dimArrayDepth == 0) { + dimArrayDepth = 0; + state = AFTER_AS_KW; + } else if (type == Basic4GL.LPAREN || type == Basic4GL.LBRACKET) { + dimArrayDepth++; + } else if (type == Basic4GL.RPAREN || type == Basic4GL.RBRACKET) { + if (dimArrayDepth > 0) dimArrayDepth--; + } else if (type == Basic4GL.IDENTIFIER && dimArrayDepth == 0) { + // "dim Type VarName" – the first IDENTIFIER was the type name, + // this IDENTIFIER is the actual variable name. + String firstIdText = pendingVarNameToken != null ? pendingVarNameToken.getText() : null; + String inferredFromFirstId = inferTypeFromIdentifierSuffix(firstIdText); + if (pendingVarType == null || pendingVarType.equals(inferredFromFirstId)) { + // Type-prefix case: first token is the explicit type, new token is the var name + String newVarUserType = t.getText(); + String newVarInferredType = inferTypeFromIdentifierSuffix(newVarUserType); + pendingVarType = newVarInferredType != null ? newVarInferredType : firstIdText; + pendingVarNameToken = t; + } + } else if ((type == Basic4GL.COLON || type == Basic4GL.COMMA) && dimArrayDepth == 0) { + if (pendingVarNameToken != null) { + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + } + pendingVarNameToken = null; + pendingVarType = null; + dimArrayDepth = 0; + state = (type == Basic4GL.COMMA) ? AFTER_DIM_KW : NONE; + } + // else: other tokens (array size expression, &, etc.) – stay in AFTER_DIM_NAME + } + case AFTER_AS_KW -> { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + if (type == Basic4GL.IDENTIFIER + || type == Basic4GL.INTEGER_T + || type == Basic4GL.INT_T + || type == Basic4GL.SINGLE_T + || type == Basic4GL.DOUBLE_T + || type == Basic4GL.STRING_T) { + pendingVarType = t.getText(); + } + if (pendingVarNameToken != null) { + emitVariableDeclaration( + declarations, + variableDeclCounts, + pendingVarNameToken, + pendingVarType, + effectiveRoutine, + fileId); + } + pendingVarNameToken = null; + pendingVarType = null; + state = NONE; + } + } + } + + if ((state == AFTER_DIM_NAME || state == AFTER_AS_KW) && pendingVarNameToken != null) { + String effectiveRoutine = + inStruc ? "struc:" + (currentStrucName != null ? currentStrucName : "") : currentRoutine; + emitVariableDeclaration( + declarations, variableDeclCounts, pendingVarNameToken, pendingVarType, effectiveRoutine, fileId); + } + + return declarations; + } +} From 21e350385b7acb3c133efe1c35f68e09974e27ae Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sun, 12 Jul 2026 03:53:47 -0400 Subject: [PATCH 16/38] adjusting splitpanes around new tabs --- .../basic4gl/desktop/spi/DialogService.java | 2 + .../java/com/basic4gl/desktop/MainWindow.java | 436 ++++++++---------- .../desktop/debugger/DebugPresenter.java | 4 - .../desktop/debugger/IDebugPresenter.java | 18 + .../desktop/panels/DebugPanelProvider.java | 247 +++++++++- .../desktop/panels/DocsPanelProvider.java | 2 +- .../desktop/panels/SymbolsPanelProvider.java | 2 +- .../desktop/util/BasicDialogService.java | 6 + 8 files changed, 451 insertions(+), 266 deletions(-) delete mode 100644 app/src/main/java/com/basic4gl/desktop/debugger/DebugPresenter.java create mode 100644 app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java index a0690e47..1b809e81 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/DialogService.java @@ -8,4 +8,6 @@ public interface DialogService { // Boolean getResult(); // default boolean validate() { return true; } public void showDialog(String message); + + String showInputDialog(String message, String title, String initialValue); } diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 253c60ce..2d69802d 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -12,6 +12,7 @@ import com.basic4gl.desktop.content.IFileManagerListener; import com.basic4gl.desktop.debugger.DebugServerConstants; import com.basic4gl.desktop.debugger.DebugServerFactory; +import com.basic4gl.desktop.debugger.IDebugPresenter; import com.basic4gl.desktop.editor.*; import com.basic4gl.desktop.language.SymbolIndexer; import com.basic4gl.desktop.panels.*; @@ -77,24 +78,31 @@ public void caretUpdate(CaretEvent e) { // Window private final JFrame frame = new JFrame(BuildInfo.APPLICATION_NAME); private final JSplitPane mainPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); - private final JSplitPane debugPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); private final JTabbedPane tabControl = new JTabbedPane(); private final JTabbedPane splitTabControl = new JTabbedPane(); private final JSplitPane editorSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); private final JPanel primaryTabHost = new JPanel(new BorderLayout()); private final JButton addTabDropdownButton = new JButton("+"); + private final JPanel centerPaneHost = new JPanel(new BorderLayout()); + private final JPanel topPaneHost = new JPanel(new BorderLayout()); + private final JPanel leftRailsHost = new JPanel(); private final JSplitPane workspacePane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); private final JSplitPane contentPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); - private final JPanel leftSidebarContainer = new JPanel(new BorderLayout()); + private final JPanel bottomBarContainer = new JPanel(new BorderLayout()); // File viewers: stores IFileViewer instances for each tab (parallel to tabControl) private final java.util.List fileViewers = new java.util.ArrayList<>(); private final JPanel leftSidebarContent = new JPanel(new CardLayout()); private final JToolBar leftSidebarRail = new JToolBar(SwingConstants.VERTICAL); private final ButtonGroup leftSidebarGroup = new ButtonGroup(); private final Map leftSidebarButtons = new HashMap<>(); + private final JPanel bottomBarContent = new JPanel(new CardLayout()); + private final JToolBar bottomBarRail = new JToolBar(SwingConstants.VERTICAL); + private final ButtonGroup bottomBarGroup = new ButtonGroup(); + private final Map bottomBarButtons = new HashMap<>(); private final JTabbedPane docsTabs = new JTabbedPane(); private final JPanel rightDocsContainer = new JPanel(new BorderLayout()); + private final JPanel rightDocsContent = new JPanel(new CardLayout()); private final JToolBar rightDocsRail = new JToolBar(SwingConstants.VERTICAL); private final ButtonGroup rightDocsGroup = new ButtonGroup(); private final Map rightDocsButtons = new HashMap<>(); @@ -104,8 +112,10 @@ public void caretUpdate(CaretEvent e) { private int expandedLeftSidebarWidth = 260; private int expandedRightDocsWidth = 320; + private int expandedBottomBarHeight = 220; private String activeLeftSidebarKey = "files"; - private String activeRightDocsKey = "functions"; + private String activeRightDocsKey; + private String activeBottomBarKey; private JPanel emptyTabPanel; private final java.util.List recentWorkspaces = new ArrayList<>(); private static final String RECENT_WORKSPACES_FILE = "recent-workspaces.properties"; @@ -155,22 +165,12 @@ public void caretUpdate(CaretEvent e) { private final JButton saveButton = new JButton(createImageIcon(ICON_SAVE)); private final JButton runButton = new JButton(createImageIcon(ICON_RUN_APP)); - private final JToggleButton debugButton = new JToggleButton(createImageIcon(ICON_DEBUG)); - private final JButton playButton = new JButton(createImageIcon(ICON_PLAY)); - private final JButton stepOverButton = new JButton(createImageIcon(ICON_STEP_OVER)); - private final JButton stepInButton = new JButton(createImageIcon(ICON_STEP_IN)); - private final JButton stepOutButton = new JButton(createImageIcon(ICON_STEP_OUT)); private final JButton exportButton = new JButton(createImageIcon(ICON_EXPORT)); private final JButton settingsButton = new JButton(createImageIcon(ICON_SETTINGS)); - private final JSeparator debugSeparator = new JSeparator(JSeparator.VERTICAL); // Labels private final JLabel compilerStatusLabel = new JLabel(""); // Compiler/VM Status private final JLabel cursorPositionLabel = new JLabel("0:0"); // Cursor Position - // Debugging - private final DefaultListModel watchListModel = new DefaultListModel<>(); - private final JList watchListBox = new JList<>(watchListModel); - private final DefaultListModel gosubListModel = new DefaultListModel<>(); // Editors private BasicEditor basicEditor; @@ -183,8 +183,8 @@ public void caretUpdate(CaretEvent e) { private SearchContext searchContext; // Debugging - private boolean isDebugMode = false; private VirtualMachineViewDialog virtualMachineViewDialog; + private IDebugPresenter debugPresenter; private int lastSourceRow = -1; private int lastSourceColumn = -1; @@ -517,7 +517,7 @@ public void onStepOutRequested() { }); functionListMenuItem.addActionListener(e -> { - selectRightDocsSection("functions"); + selectRightDocsSection("symbols"); }); aboutMenuItem.addActionListener(e -> showAboutDialog()); @@ -529,61 +529,7 @@ public void onStepOutRequested() { // TODO mExitMenuItem.setVisible(false); } - // Debugger - JPanel watchListFrame = new JPanel(); - watchListFrame.setLayout(new BorderLayout()); - JLabel watchlistLabel = new JLabel("Watchlist"); - watchlistLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); - watchListFrame.add(watchlistLabel, BorderLayout.NORTH); - JScrollPane watchListScrollPane = new JScrollPane(watchListBox); - watchListFrame.add(watchListScrollPane, BorderLayout.CENTER); - - watchListBox.addMouseListener(new MouseAdapter() { - public void mouseClicked(MouseEvent evt) { - JList list = (JList) evt.getSource(); - if (evt.getClickCount() == 2) { - // Double-click detected - int index = list.locationToIndex(evt.getPoint()); - editWatch(); - } - } - }); - - watchListBox.addKeyListener(new KeyListener() { - @Override - public void keyTyped(KeyEvent e) {} - - @Override - public void keyPressed(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_ENTER) { - editWatch(); - } - } - - @Override - public void keyReleased(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_DELETE) { - deleteWatch(); - } else if (e.getKeyCode() == KeyEvent.VK_INSERT) { - watchListBox.setSelectedIndex(basicEditor.getWatchListSize()); - editWatch(); - } - } - }); - - watchListBox.addListSelectionListener(e -> updateWatchHint()); - JPanel gosubFrame = new JPanel(); - gosubFrame.setLayout(new BorderLayout()); - JLabel callstackLabel = new JLabel("Callstack"); - callstackLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); - gosubFrame.add(callstackLabel, BorderLayout.NORTH); - JList gosubListBox = new JList<>(gosubListModel); - JScrollPane gosubListScrollPane = new JScrollPane(gosubListBox); - gosubFrame.add(gosubListScrollPane, BorderLayout.CENTER); - - debugPane.setLeftComponent(watchListFrame); - debugPane.setRightComponent(gosubFrame); // Toolbar JToolBar toolBar = new JToolBar(); @@ -593,14 +539,7 @@ public void keyReleased(KeyEvent e) { toolBar.addSeparator(); toolBar.add(runButton); toolBar.addSeparator(); - toolBar.add(debugButton); - toolBar.addSeparator(); - toolBar.add(playButton); toolBar.add(runTargetCombo); - toolBar.add(stepOverButton); - toolBar.add(stepInButton); - toolBar.add(stepOutButton); - toolBar.add(debugSeparator); toolBar.add(Box.createHorizontalGlue()); toolBar.add(exportButton); toolBar.add(settingsButton); @@ -610,11 +549,6 @@ public void keyReleased(KeyEvent e) { saveButton.addActionListener(e -> actionSave()); runButton.addActionListener(e -> basicEditor.actionRun()); - debugButton.addActionListener(e -> actionDebugMode()); - playButton.addActionListener(e -> basicEditor.actionPlayPause()); - stepOverButton.addActionListener(e -> basicEditor.actionStep()); - stepInButton.addActionListener(e -> basicEditor.actionStepInto()); - stepOutButton.addActionListener(e -> basicEditor.actionStepOutOf()); exportButton.addActionListener(e -> actionExport()); settingsButton.addActionListener(e -> showSettings()); runButton.setToolTipText("Run the program!"); @@ -708,24 +642,34 @@ protected void installDefaults() { editorSplitPane.setRightComponent(splitTabControl); editorSplitPane.setResizeWeight(0.7); - mainPane.setTopComponent(primaryTabHost); - - debugPane.setLeftComponent(watchListFrame); - debugPane.setRightComponent(gosubFrame); - - contentPane.setLeftComponent(mainPane); + contentPane.setLeftComponent(primaryTabHost); contentPane.setRightComponent(rightDocsContainer); contentPane.setResizeWeight(0.74); - workspacePane.setLeftComponent(leftSidebarContainer); + workspacePane.setLeftComponent(leftSidebarContent); workspacePane.setRightComponent(contentPane); workspacePane.setResizeWeight(0.18); workspacePane.setDividerLocation(expandedLeftSidebarWidth); contentPane.setDividerLocation(Math.max(200, frame.getPreferredSize().width - expandedRightDocsWidth)); + topPaneHost.add(workspacePane, BorderLayout.CENTER); + + mainPane.setTopComponent(topPaneHost); + mainPane.setBottomComponent(bottomBarContainer); + mainPane.setResizeWeight(1.0); + + leftRailsHost.setLayout(new BoxLayout(leftRailsHost, BoxLayout.Y_AXIS)); + leftRailsHost.add(leftSidebarRail); + leftRailsHost.add(Box.createVerticalGlue()); + leftRailsHost.add(bottomBarRail); + + centerPaneHost.add(leftRailsHost, BorderLayout.WEST); + centerPaneHost.add(mainPane, BorderLayout.CENTER); + centerPaneHost.add(rightDocsRail, BorderLayout.EAST); + // Add controls to window frame.add(toolBar, BorderLayout.NORTH); - frame.add(workspacePane, BorderLayout.CENTER); + frame.add(centerPaneHost, BorderLayout.CENTER); frame.add(statusPanel, BorderLayout.SOUTH); frame.setJMenuBar(menuBar); @@ -759,18 +703,22 @@ public void windowDeactivated(WindowEvent e) {} atmf.putMapping("text/basic4gl", "com.basic4gl.desktop.editor.BasicTokenMaker"); fileManager = new FileManager(this); + + basicEditor = new BasicEditor(outputBinPath, fileManager, this, + new BasicDialogService(this.frame), + this, this); + + debugPresenter = new DebugPanelProvider(basicEditor); + panels = new IEditorPanelProvider[] { new FileBrowserPanelProvider(), new AssetsPanelProvider(fileManager), new BookmarksPanelProvider(), - new DebugPanelProvider(), + (IEditorPanelProvider) debugPresenter, new SymbolsPanelProvider(), + new DocsPanelProvider(), }; - basicEditor = new BasicEditor(outputBinPath, fileManager, this, - new BasicDialogService(this.frame), - this, this); - configureLeftSidebar(); configureRightSidebar(); @@ -807,6 +755,10 @@ public void windowDeactivated(WindowEvent e) {} frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); + SwingUtilities.invokeLater(() -> { + collapseBottomBar(); + collapseRightDocs(); + }); } private void actionExport() { @@ -1288,12 +1240,11 @@ void actionSaveAs() { } private void actionDebugMode() { - // Toggle debug mode - isDebugMode = !isDebugMode; - debugMenuItem.setSelected(isDebugMode); - debugButton.setSelected(isDebugMode); - - refreshDebugDisplays(basicEditor.getMode()); + if (Objects.equals(activeBottomBarKey, "debug") && isBottomBarExpanded()) { + collapseBottomBar(); + return; + } + selectBottomBarSection("debug", true); } private void actionGoToDeclaration() { @@ -1535,7 +1486,7 @@ public void addTab(FileEditor editor) { fileViewers.add(new FileViewerWrapper(editor)); // replace emptyTabPanel if needed - mainPane.setTopComponent(getActiveEditorHost()); + setEditorContent(getActiveEditorHost()); tabControl.addTab(editor.getTitle(), editor.getContentPane()); @@ -1616,7 +1567,7 @@ public void addTabWithViewer(IFileViewer viewer) { } // replace emptyTabPanel if needed - mainPane.setTopComponent(getActiveEditorHost()); + setEditorContent(getActiveEditorHost()); tabControl.addTab(viewer.getTitle(), viewer.getContentPane()); @@ -1748,10 +1699,8 @@ public void onPause() { basicEditor.setMode(ApMode.AP_PAUSED, null); refreshActions(basicEditor.getMode()); - // Place editor into debug mode - isDebugMode = true; - debugMenuItem.setSelected(true); - debugButton.setSelected(true); + // Open debug panel when execution pauses. + selectBottomBarSection("debug", true); refreshDebugDisplays(basicEditor.getMode()); // TODO Add VMViewer @@ -1777,6 +1726,11 @@ public void setCompilerStatus(String error) { compilerStatusLabel.setText(error); } + @Override + public void updateCallStack(StackTraceCallback message) { + debugPresenter.updateCallStack(message); + } + @Override public void onCompileSucceeded() { @@ -1794,13 +1748,9 @@ public void onModeChanged(ApMode mode, String statusMsg) { goToDeclarationMenuItem.setEnabled(true); selectAllMenuItem.setEnabled(true); - stepOverButton.setEnabled(true); - stepInButton.setEnabled(true); - stepOutButton.setEnabled(true); stepOverMenuItem.setEnabled(true); stepIntoMenuItem.setEnabled(true); stepOutOfMenuItem.setEnabled(true); - playButton.setEnabled(true); playPauseMenuItem.setEnabled(true); runMenuItem.setEnabled(true); runButton.setEnabled(true); @@ -1853,13 +1803,9 @@ public void refreshActions(ApMode mode) { goToDeclarationMenuItem.setEnabled(false); selectAllMenuItem.setEnabled(false); - stepOverButton.setEnabled(false); - stepInButton.setEnabled(false); - stepOutButton.setEnabled(false); stepOverMenuItem.setEnabled(false); stepIntoMenuItem.setEnabled(false); stepOutOfMenuItem.setEnabled(false); - playButton.setEnabled(false); playPauseMenuItem.setEnabled(false); runMenuItem.setEnabled(false); runButton.setEnabled(false); @@ -1880,10 +1826,10 @@ public void refreshActions(ApMode mode) { openFolderMenuItem.getAccelerator(), basicEditor.getRecentFiles(), recentWorkspaces); - mainPane.setTopComponent(emptyTabPanel); + setEditorContent(emptyTabPanel); break; case AP_STOPPED: - mainPane.setTopComponent(getActiveEditorHost()); + setEditorContent(getActiveEditorHost()); setClosingTabsEnabled(true); settingsMenuItem.setEnabled(true); settingsButton.setEnabled(true); @@ -1923,7 +1869,7 @@ public void refreshActions(ApMode mode) { case AP_RUNNING: case AP_PAUSED: - mainPane.setTopComponent(getActiveEditorHost()); + setEditorContent(getActiveEditorHost()); setClosingTabsEnabled(false); settingsMenuItem.setEnabled(false); @@ -1951,62 +1897,21 @@ public void refreshActions(ApMode mode) { @Override public void refreshDebugDisplays(ApMode mode) { + debugPresenter.refreshDebugControls(mode); + playPauseMenuItem.setEnabled(mode != ApMode.AP_WAITING && mode != ApMode.AP_CLOSED); - // Show/hide debug controls - playButton.setVisible(isDebugMode); - stepOverButton.setVisible(isDebugMode); - stepInButton.setVisible(isDebugMode); - stepOutButton.setVisible(isDebugMode); - debugSeparator.setVisible(isDebugMode); - - // TODO Show/hide debug pane - if (isDebugMode) { - mainPane.setResizeWeight(0.7); - // mDebugPane.setEnabled(true); - mainPane.setEnabled(true); - mainPane.setBottomComponent(debugPane); - SwingUtilities.invokeLater(() -> debugPane.setDividerLocation(0.7)); - } else { - mainPane.remove(debugPane); - // mDebugPane.setEnabled(false); - mainPane.setEnabled(false); - } - - if (mode != ApMode.AP_CLOSED) { - playButton.setIcon(mode == ApMode.AP_RUNNING ? createImageIcon(ICON_PAUSE) : createImageIcon(ICON_PLAY)); - playButton.setEnabled(mode != ApMode.AP_WAITING); - playPauseMenuItem.setEnabled(mode != ApMode.AP_WAITING); - stepOverButton.setEnabled(mode != ApMode.AP_RUNNING && mode != ApMode.AP_WAITING); - stepInButton.setEnabled(mode != ApMode.AP_RUNNING && mode != ApMode.AP_WAITING); - - // TODO 12/2022 determine appropriate state for mStepOutButton; - // does the editor even need to care about UserCallStack size with remote debugger protocol - // setup? - stepOutButton.setEnabled(mode == ApMode.AP_PAUSED); - // TODO old mStepOutButton.setEnabled(mode == ApMode.AP_PAUSED && - // (mEditor.mVM.UserCallStack().size() > 0)); - } - if (!isDebugMode) { - return; + if (mode == ApMode.AP_CLOSED && isBottomBarExpanded()) { + collapseBottomBar(); } if (mode != ApMode.AP_PAUSED) { - // Clear debug controls - gosubListModel.clear(); + debugPresenter.clearCallStack(); } - } - - @Override - public void updateCallStack(StackTraceCallback stackTraceCallback) { - - // Clear debug controls - gosubListModel.clear(); - for (String label : basicEditor.getLanguageService().buildFriendlyCallStackLabels(stackTraceCallback)) { - gosubListModel.addElement(label); - } + syncDebugMenuSelection(); } + @Override public void updateVmViewCallStack(StackTraceCallback stackTraceCallback) { if (virtualMachineViewDialog != null && virtualMachineViewDialog.isDisplayable()) { @@ -2031,15 +1936,10 @@ public void updateVmViewVariables(VariablesCallback variablesCallback) { @Override public void updateEvaluateWatch(String evaluatedWatch, String result) { - int index = 0; - for (String watch : basicEditor.getWatches()) { - if (Objects.equals(watch, evaluatedWatch)) { - watchListModel.setElementAt(watch + ": " + result, index); - } - index++; - } + debugPresenter.updateEvaluateWatch(evaluatedWatch, result); } + @Override public void updateVmViewVariableValue(String expression, String result) { if (virtualMachineViewDialog != null && virtualMachineViewDialog.isDisplayable()) { @@ -2056,57 +1956,9 @@ public void updateVmViewError(String scope, String message) { @Override public void refreshWatchList() { - // Clear debug controls - watchListModel.clear(); - - for (String watch : basicEditor.getWatches()) { - - watchListModel.addElement(watch + ": " + "???"); - } - watchListModel.addElement(" "); // Last line is blank, and can be clicked on to add new watch - } - - private void editWatch() { - String newWatch, oldWatch; - - // Find watch - int index = watchListBox.getSelectedIndex(); - int saveIndex = index; - - // Extract watch text - oldWatch = basicEditor.getWatchOrDefault(index); - - // Prompt for new text - newWatch = (String) JOptionPane.showInputDialog( - frame, "Enter variable/expression:", "Watch variable", JOptionPane.PLAIN_MESSAGE, null, null, oldWatch); - - basicEditor.updateWatch(newWatch, index); - - watchListBox.setSelectedIndex(saveIndex); - updateWatchHint(); - } - - void deleteWatch() { - - // Find watch - int index = watchListBox.getSelectedIndex(); - int saveIndex = index; - - // Delete watch - basicEditor.removeWatchAt(index); - - watchListBox.setSelectedIndex(saveIndex); - updateWatchHint(); + debugPresenter.refreshWatchList(); } - private void updateWatchHint() { - int index = watchListBox.getSelectedIndex(); - if (index > -1 && index < basicEditor.getWatchListSize()) { - watchListBox.setToolTipText((String) watchListModel.get(index)); - } else { - watchListBox.setToolTipText(""); - } - } @Override public void onToggleBreakpoint(String filePath, int line) { @@ -2117,6 +1969,10 @@ private Component getActiveEditorHost() { return splitTabControl.getTabCount() == 0 ? primaryTabHost : editorSplitPane; } + private void setEditorContent(Component component) { + contentPane.setLeftComponent(component); + } + private void configurePrimaryTabHost() { addTabDropdownButton.setFocusable(false); addTabDropdownButton.setToolTipText("Create a new tab or open an asset"); @@ -2136,7 +1992,7 @@ private void configureSplitTabs() { if (splitTabControl.getTabCount() == 0 && basicEditor != null && basicEditor.getMode() != ApMode.AP_CLOSED) { - mainPane.setTopComponent(primaryTabHost); + setEditorContent(primaryTabHost); } } }); @@ -2206,7 +2062,7 @@ private void openSplitPreview(int tabIndex) { preview.getEditorPane().setEditable(false); splitTabControl.addTab(preview.getTitle() + " (split)", preview.getContentPane()); splitTabControl.setSelectedIndex(splitTabControl.getTabCount() - 1); - mainPane.setTopComponent(getActiveEditorHost()); + setEditorContent(getActiveEditorHost()); SwingUtilities.invokeLater(() -> editorSplitPane.setDividerLocation(0.68)); } @@ -2272,7 +2128,8 @@ private void actionOpenAsset() { private void configureLeftSidebar() { leftSidebarRail.setFloatable(false); leftSidebarRail.setRollover(true); - + bottomBarRail.setFloatable(false); + bottomBarRail.setRollover(true); Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) .forEach(x -> { @@ -2280,16 +2137,13 @@ private void configureLeftSidebar() { addLeftSidebarButton(x.id(), createImageIcon(x.getIconPath()), x.getTitle()); }); - leftSidebarRail.add(Box.createVerticalGlue()); - Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.SOUTH) .forEach(x -> { - leftSidebarContent.add(x.build(this.basicEditor), x.id()); - addLeftSidebarButton(x.id(), createImageIcon(x.getIconPath()), x.getTitle()); + bottomBarContent.add(x.build(this.basicEditor), x.id()); + addBottomBarButton(x.id(), createImageIcon(x.getIconPath()), x.getTitle()); }); - leftSidebarContainer.add(leftSidebarRail, BorderLayout.WEST); - leftSidebarContainer.add(leftSidebarContent, BorderLayout.CENTER); + bottomBarContainer.add(bottomBarContent, BorderLayout.CENTER); // Select first panel if available Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) @@ -2301,6 +2155,18 @@ private void configureLeftSidebar() { private void configureRightSidebar() { + rightDocsRail.setFloatable(false); + rightDocsRail.setRollover(true); + rightDocsContainer.add(rightDocsContent, BorderLayout.CENTER); + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.EAST) + .forEach(x -> { + addRightDocsButton(x.id(), createImageIcon(x.getIconPath()), x.getTitle()); + JComponent content = "docs".equals(x.id()) ? docsTabs : x.build(this.basicEditor); + if (content != null) { + rightDocsContent.add(content, x.id()); + } + }); docsTabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); docsTabs.putClientProperty(TABBED_PANE_TAB_CLOSABLE, true); @@ -2313,8 +2179,13 @@ private void configureRightSidebar() { if (docsTabs.getTabCount() > 0) { docsTabs.setSelectedIndex(0); } - selectRightDocsSection("functions"); + selectRightDocsSection("symbols"); }); + + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.EAST) + .findFirst() + .ifPresent(x -> selectRightDocsSection(x.id())); } @@ -2334,6 +2205,14 @@ private void addRightDocsButton(String key, Icon icon, String tooltip) { rightDocsRail.add(button); } + private void addBottomBarButton(String key, Icon icon, String tooltip) { + JToggleButton button = createRailButton(icon, tooltip); + button.addActionListener(e -> onBottomBarButtonPressed(key)); + bottomBarGroup.add(button); + bottomBarButtons.put(key, button); + bottomBarRail.add(button); + } + private JToggleButton createRailButton(Icon icon, String tooltip) { JToggleButton button = new JToggleButton(icon); button.setToolTipText(tooltip); @@ -2353,6 +2232,14 @@ private void onLeftSidebarButtonPressed(String key) { selectLeftSidebarSection(key, true); } + private void onBottomBarButtonPressed(String key) { + if (Objects.equals(activeBottomBarKey, key) && isBottomBarExpanded()) { + collapseBottomBar(); + return; + } + selectBottomBarSection(key, true); + } + private void selectLeftSidebarSection(String key, boolean ensureExpanded) { CardLayout layout = (CardLayout) leftSidebarContent.getLayout(); layout.show(leftSidebarContent, key); @@ -2368,15 +2255,35 @@ private void selectLeftSidebarSection(String key, boolean ensureExpanded) { } } + private void selectBottomBarSection(String key, boolean ensureExpanded) { + if (!bottomBarButtons.containsKey(key)) { + return; + } + activeBottomBarKey = key; + CardLayout layout = (CardLayout) bottomBarContent.getLayout(); + layout.show(bottomBarContent, key); + JToggleButton button = bottomBarButtons.get(key); + if (button != null) { + button.setSelected(true); + } + + if (ensureExpanded) { + expandBottomBar(); + } + syncDebugMenuSelection(); + } + private boolean isLeftSidebarExpanded() { - return workspacePane.getDividerLocation() > leftSidebarRail.getPreferredSize().width + 24; + return workspacePane.getDividerLocation() > 12; } private void collapseLeftSidebar() { - if (workspacePane.getDividerLocation() > leftSidebarRail.getPreferredSize().width + 24) { + if (isLeftSidebarExpanded()) { expandedLeftSidebarWidth = workspacePane.getDividerLocation(); } - workspacePane.setDividerLocation(leftSidebarRail.getPreferredSize().width + 6); + activeLeftSidebarKey = null; + leftSidebarGroup.clearSelection(); + workspacePane.setDividerLocation(0); } private void expandLeftSidebar() { @@ -2384,6 +2291,47 @@ private void expandLeftSidebar() { workspacePane.setDividerLocation(target); } + private boolean isBottomBarExpanded() { + if (mainPane.getBottomComponent() != bottomBarContainer || mainPane.getHeight() <= 0) { + return false; + } + int bottomHeight = mainPane.getHeight() - mainPane.getDividerLocation(); + return bottomHeight > 12; + } + + private void collapseBottomBar() { + if (mainPane.getBottomComponent() != bottomBarContainer) { + mainPane.setBottomComponent(bottomBarContainer); + } + if (isBottomBarExpanded() && mainPane.getHeight() > 0) { + expandedBottomBarHeight = Math.max(120, mainPane.getHeight() - mainPane.getDividerLocation()); + } + if (mainPane.getHeight() > 0) { + mainPane.setDividerLocation(mainPane.getHeight()); + } + activeBottomBarKey = null; + bottomBarGroup.clearSelection(); + syncDebugMenuSelection(); + } + + private void expandBottomBar() { + if (mainPane.getBottomComponent() != bottomBarContainer) { + mainPane.setBottomComponent(bottomBarContainer); + } + mainPane.setResizeWeight(1.0); + + int targetBottomHeight = Math.max(expandedBottomBarHeight, 140); + if (mainPane.getHeight() > 0) { + int newDivider = Math.max(120, mainPane.getHeight() - targetBottomHeight); + mainPane.setDividerLocation(newDivider); + } + syncDebugMenuSelection(); + } + + private void syncDebugMenuSelection() { + debugMenuItem.setSelected(isBottomBarExpanded() && Objects.equals(activeBottomBarKey, "debug")); + } + private void onRightDocsButtonPressed(String key) { if (Objects.equals(activeRightDocsKey, key) && isRightDocsExpanded()) { collapseRightDocs(); @@ -2393,7 +2341,12 @@ private void onRightDocsButtonPressed(String key) { } private void selectRightDocsSection(String key) { + if (!rightDocsButtons.containsKey(key)) { + return; + } activeRightDocsKey = key; + CardLayout layout = (CardLayout) rightDocsContent.getLayout(); + layout.show(rightDocsContent, key); JToggleButton button = rightDocsButtons.get(key); if (button != null) { @@ -2407,21 +2360,24 @@ private void selectRightDocsSection(String key) { } expandRightDocs(); - docsTabs.requestFocusInWindow(); + if ("docs".equals(key)) { + docsTabs.requestFocusInWindow(); + } } private boolean isRightDocsExpanded() { int docsWidth = contentPane.getWidth() - contentPane.getDividerLocation(); - return docsWidth > rightDocsRail.getPreferredSize().width + 28; + return docsWidth > 12; } private void collapseRightDocs() { int docsWidth = contentPane.getWidth() - contentPane.getDividerLocation(); - if (docsWidth > rightDocsRail.getPreferredSize().width + 28) { + if (docsWidth > 12) { expandedRightDocsWidth = docsWidth; } - int collapsedWidth = rightDocsRail.getPreferredSize().width + 8; - contentPane.setDividerLocation(Math.max(120, contentPane.getWidth() - collapsedWidth)); + activeRightDocsKey = null; + rightDocsGroup.clearSelection(); + contentPane.setDividerLocation(contentPane.getWidth()); } private void expandRightDocs() { @@ -2697,7 +2653,7 @@ private void refreshEmptyStateRecentItems() { openFolderMenuItem.getAccelerator(), basicEditor.getRecentFiles(), recentWorkspaces); - mainPane.setTopComponent(emptyTabPanel); + setEditorContent(emptyTabPanel); } private void setClosingTabsEnabled(boolean enabled) { diff --git a/app/src/main/java/com/basic4gl/desktop/debugger/DebugPresenter.java b/app/src/main/java/com/basic4gl/desktop/debugger/DebugPresenter.java deleted file mode 100644 index 5f0ab475..00000000 --- a/app/src/main/java/com/basic4gl/desktop/debugger/DebugPresenter.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.basic4gl.desktop.debugger; - -public class DebugPresenter { -} diff --git a/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java b/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java new file mode 100644 index 00000000..e5f7f1be --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java @@ -0,0 +1,18 @@ +package com.basic4gl.desktop.debugger; + +import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; +import com.basic4gl.desktop.editor.ApMode; + +public interface IDebugPresenter { + + + void updateCallStack(StackTraceCallback message); + + void refreshWatchList(); + + void updateEvaluateWatch(String evaluatedWatch, String result); + + void clearCallStack(); + + void refreshDebugControls(ApMode mode); +} diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java index 88366b28..99461177 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java @@ -1,13 +1,51 @@ package com.basic4gl.desktop.panels; +import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; +import com.basic4gl.desktop.BasicEditor; +import com.basic4gl.desktop.debugger.IDebugPresenter; +import com.basic4gl.desktop.editor.ApMode; import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.PluginContext; import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.Objects; + +import static com.basic4gl.desktop.Theme.ICON_PAUSE; +import static com.basic4gl.desktop.Theme.ICON_PLAY; +import static com.basic4gl.desktop.Theme.ICON_STEP_IN; +import static com.basic4gl.desktop.Theme.ICON_STEP_OUT; +import static com.basic4gl.desktop.Theme.ICON_STEP_OVER; import static com.basic4gl.desktop.Theme.ICON_MENU_DEBUG; +import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; + +public class DebugPanelProvider implements IEditorPanelProvider, IDebugPresenter { + + private final JSplitPane debugPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + private final JButton playButton = new JButton(createImageIcon(ICON_PLAY)); + private final JButton stepOverButton = new JButton(createImageIcon(ICON_STEP_OVER)); + private final JButton stepInButton = new JButton(createImageIcon(ICON_STEP_IN)); + private final JButton stepOutButton = new JButton(createImageIcon(ICON_STEP_OUT)); + // Debugging + private final DefaultListModel watchListModel = new DefaultListModel<>(); + private final JList watchListBox = new JList<>(watchListModel); + private final DefaultListModel gosubListModel = new DefaultListModel<>(); + + private final BasicEditor basicEditor; + private PluginContext context; + + + public DebugPanelProvider(BasicEditor basicEditor) { + //TODO this is working up to a circular dependency.. can't init BasicEditor with this as IDebugPresenter + this.basicEditor = basicEditor; + } -public class DebugPanelProvider implements IEditorPanelProvider { @Override public String id() { return "debug"; @@ -30,28 +68,197 @@ public EditorLayout getLayoutConstraints() { @Override public JPanel build(PluginContext context) { - JPanel panel = new JPanel(); - panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); -// -// JButton toggleDebug = new JButton("Toggle debug mode"); -// toggleDebug.addActionListener(e -> actionDebugMode()); - JButton playPause = new JButton("Play/Pause"); - playPause.addActionListener(e -> context.debugger().actionPlayPause()); - JButton stepOver = new JButton("Step over"); - stepOver.addActionListener(e -> context.debugger().actionStep()); - JButton stepInto = new JButton("Step into"); - stepInto.addActionListener(e -> context.debugger().actionStepInto()); - JButton stepOut = new JButton("Step out"); - stepOut.addActionListener(e -> context.debugger().actionStepOutOf()); - -// panel.add(toggleDebug); - panel.add(playPause); - panel.add(stepOver); - panel.add(stepInto); - panel.add(stepOut); + this.context = context; + + JPanel panel = new JPanel(new BorderLayout()); + + JToolBar debugToolBar = new JToolBar(); + debugToolBar.setFloatable(false); + debugToolBar.add(playButton); + debugToolBar.add(stepOverButton); + debugToolBar.add(stepInButton); + debugToolBar.add(stepOutButton); + + playButton.setToolTipText("Play/Pause"); + stepOverButton.setToolTipText("Step Over"); + stepInButton.setToolTipText("Step In"); + stepOutButton.setToolTipText("Step Out"); + + playButton.addActionListener(e -> context.debugger().actionPlayPause()); + stepOverButton.addActionListener(e -> context.debugger().actionStep()); + stepInButton.addActionListener(e -> context.debugger().actionStepInto()); + stepOutButton.addActionListener(e -> context.debugger().actionStepOutOf()); + + panel.add(debugToolBar, BorderLayout.NORTH); + panel.add(buildDebugPanel(), BorderLayout.CENTER); return panel; } + private JPanel buildDebugPanel() { + // Debugger + JPanel watchListFrame = new JPanel(); + watchListFrame.setLayout(new BorderLayout()); + JLabel watchlistLabel = new JLabel("Watchlist"); + watchlistLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); + watchListFrame.add(watchlistLabel, BorderLayout.NORTH); + JScrollPane watchListScrollPane = new JScrollPane(watchListBox); + watchListFrame.add(watchListScrollPane, BorderLayout.CENTER); + + watchListBox.addMouseListener(new MouseAdapter() { + public void mouseClicked(MouseEvent evt) { + JList list = (JList) evt.getSource(); + if (evt.getClickCount() == 2) { + // Double-click detected + int index = list.locationToIndex(evt.getPoint()); + editWatch(); + } + } + }); + + watchListBox.addKeyListener(new KeyListener() { + @Override + public void keyTyped(KeyEvent e) {} + + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER) { + editWatch(); + } + } + + @Override + public void keyReleased(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_DELETE) { + deleteWatch(); + } else if (e.getKeyCode() == KeyEvent.VK_INSERT) { + watchListBox.setSelectedIndex(basicEditor.getWatchListSize()); + editWatch(); + } + } + }); + + watchListBox.addListSelectionListener(e -> updateWatchHint()); + + JPanel gosubFrame = new JPanel(); + gosubFrame.setLayout(new BorderLayout()); + JLabel callstackLabel = new JLabel("Callstack"); + callstackLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); + gosubFrame.add(callstackLabel, BorderLayout.NORTH); + JList gosubListBox = new JList<>(gosubListModel); + JScrollPane gosubListScrollPane = new JScrollPane(gosubListBox); + gosubFrame.add(gosubListScrollPane, BorderLayout.CENTER); + + debugPane.setLeftComponent(watchListFrame); + debugPane.setRightComponent(gosubFrame); + + JPanel debugPanel = new JPanel(); + debugPanel.setLayout(new BorderLayout()); + debugPanel.add(debugPane, BorderLayout.CENTER); + + return debugPanel; + } + + @Override + public void updateCallStack(StackTraceCallback stackTraceCallback) { + + // Clear debug controls + gosubListModel.clear(); + + for (String label : basicEditor.getLanguageService().buildFriendlyCallStackLabels(stackTraceCallback)) { + gosubListModel.addElement(label); + } + } + + @Override + public void refreshWatchList() { + // Clear debug controls + watchListModel.clear(); + + for (String watch : basicEditor.getWatches()) { + + watchListModel.addElement(watch + ": " + "???"); + } + watchListModel.addElement(" "); // Last line is blank, and can be clicked on to add new watch + } + + private void editWatch() { + if (context == null) { + return; + } + + String newWatch, oldWatch; + + // Find watch + int index = watchListBox.getSelectedIndex(); + int saveIndex = index; + + // Extract watch text + oldWatch = basicEditor.getWatchOrDefault(index); + + // Prompt for new text + newWatch = context.dialogs().showInputDialog("Enter variable/expression:", "Watch variable", oldWatch); + + basicEditor.updateWatch(newWatch, index); + + watchListBox.setSelectedIndex(saveIndex); + updateWatchHint(); + } + + void deleteWatch() { + + // Find watch + int index = watchListBox.getSelectedIndex(); + int saveIndex = index; + + // Delete watch + basicEditor.removeWatchAt(index); + + watchListBox.setSelectedIndex(saveIndex); + updateWatchHint(); + } + + private void updateWatchHint() { + int index = watchListBox.getSelectedIndex(); + if (index > -1 && index < basicEditor.getWatchListSize()) { + watchListBox.setToolTipText((String) watchListModel.get(index)); + } else { + watchListBox.setToolTipText(""); + } + } + + @Override + public void updateEvaluateWatch(String evaluatedWatch, String result) { + int index = 0; + for (String watch : basicEditor.getWatches()) { + if (Objects.equals(watch, evaluatedWatch)) { + watchListModel.setElementAt(watch + ": " + result, index); + } + index++; + } + } + + @Override + public void clearCallStack() { + gosubListModel.clear(); + } + + @Override + public void refreshDebugControls(ApMode mode) { + if (mode != ApMode.AP_CLOSED) { + playButton.setIcon(mode == ApMode.AP_RUNNING ? createImageIcon(ICON_PAUSE) : createImageIcon(ICON_PLAY)); + playButton.setEnabled(mode != ApMode.AP_WAITING); + stepOverButton.setEnabled(mode != ApMode.AP_RUNNING && mode != ApMode.AP_WAITING); + stepInButton.setEnabled(mode != ApMode.AP_RUNNING && mode != ApMode.AP_WAITING); + stepOutButton.setEnabled(mode == ApMode.AP_PAUSED); + return; + } + + playButton.setEnabled(false); + stepOverButton.setEnabled(false); + stepInButton.setEnabled(false); + stepOutButton.setEnabled(false); + } + @Override public void refresh(EditorPlugin languageProvider) { diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java index cb1a1758..82cbc440 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -25,7 +25,7 @@ public String getIconPath() { @Override public EditorLayout getLayoutConstraints() { - return EditorLayout.WEST; + return EditorLayout.EAST; } @Override diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java index e37b39d5..87036d83 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -109,7 +109,7 @@ public String getIconPath() { @Override public EditorLayout getLayoutConstraints() { - return EditorLayout.WEST; + return EditorLayout.EAST; } public JPanel build(PluginContext context) { diff --git a/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java b/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java index 5ed1e92c..aa9b5407 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java +++ b/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java @@ -15,4 +15,10 @@ public BasicDialogService(JFrame frame) { public void showDialog(String message) { JOptionPane.showMessageDialog(frame, message); } + + @Override + public String showInputDialog(String message, String title, String initialValue) { + return (String) JOptionPane.showInputDialog( + frame, message, title, JOptionPane.PLAIN_MESSAGE, null, null, initialValue); + } } From 195c749d8801d4861e896f48d72d96449829fcb8 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Tue, 14 Jul 2026 00:11:24 -0400 Subject: [PATCH 17/38] resolve language service todos --- .../adapter/Basic4GLLanguageService.java | 128 +++++++++++++++--- 1 file changed, 110 insertions(+), 18 deletions(-) diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java index 782b8045..3320320a 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java @@ -208,8 +208,16 @@ public Iterable getVariableDefinitions() { String typeStr = LanguageUtil.getTypeString(variable.type); String signature = typeStr + " " + variable.name; TypeDefinition typeDefinition = LanguageUtil.toTypeDefinition(variable.type); - - VariableDefinition definition = null; // TODO new VariableDefinition(variable.name,) + VariableDefinition definition = new VariableDefinition( + variable.name, + signature, + typeDefinition, + "", + "", + "Program", + false, + "global", + "Program"); variableDefinitions.add(definition); } return variableDefinitions; @@ -261,21 +269,38 @@ public Iterable getFunctionDefinitions() { signature.append(')'); } - // TypeDefinition returnType = spec.isFunction() ? new - // TypeDefinition(LanguageUtil.getTypeString(spec.getReturnType())) : new TypeDefinition("void"); - // FunctionDefinition definition = new FunctionDefinition( - // name, - // signature.toString(), - // returnType, - // params != null ? params.stream() - // .map(this::getTypeString) - // .map((typeName, i) -> new VariableDefinition(typeName)) - // .toArray(VariableDefinition[]::new) : new VariableDefinition[0], - // spec.getDescription(), - // library - // ); - // TODO - FunctionDefinition definition = null; + VariableDefinition returnValue = spec.isFunction() + ? new VariableDefinition( + "return", + LanguageUtil.getTypeString(spec.getReturnType()), + LanguageUtil.toTypeDefinition(spec.getReturnType()), + "", + "", + library, + true, + "", + "Builtin") + : new VariableDefinition( + "return", + "void", + new TypeDefinition("void", "", "", ""), + "", + "", + library, + true, + "", + "Builtin"); + VariableDefinition[] parameters = params != null + ? buildFunctionParameterDefinitions(params, library) + : new VariableDefinition[0]; + FunctionDefinition definition = new FunctionDefinition( + name, + signature.toString(), + returnValue, + parameters, + "", + library, + spec.hasBrackets()); items.add(definition); } } @@ -326,13 +351,80 @@ private ArrayList buildUserFunctionReferenceItems() { } signature.append(')'); - FunctionDefinition definition = null; // TODO new FunctionDefinition(name, ...) + VariableDefinition[] parameters = new VariableDefinition[prototype != null ? prototype.paramCount : 0]; + if (prototype != null && prototype.paramCount > 0) { + for (int i = 0; i < prototype.paramCount; i++) { + String paramName = prototype.getLocalVarName(i); + String typeName = i < prototype.localVarTypes.size() + ? LanguageUtil.getTypeString(prototype.localVarTypes.get(i)) + : "?"; + parameters[i] = new VariableDefinition( + paramName, + typeName + " " + paramName, + i < prototype.localVarTypes.size() + ? LanguageUtil.toTypeDefinition(prototype.localVarTypes.get(i)) + : new TypeDefinition("?", "", "", ""), + "", + "", + "Program", + false, + name, + "Program"); + } + } + VariableDefinition returnValue = prototype != null && prototype.hasReturnVal + ? new VariableDefinition( + "return", + LanguageUtil.getTypeString(prototype.returnValType), + LanguageUtil.toTypeDefinition(prototype.returnValType), + "", + "", + "Program", + true, + name, + "Program") + : new VariableDefinition( + "return", + "void", + new TypeDefinition("void", "", "", ""), + "", + "", + "Program", + true, + name, + "Program"); + FunctionDefinition definition = new FunctionDefinition( + name, + signature.toString(), + returnValue, + parameters, + "", + "Program", + true); items.add(definition); } return items; } + private VariableDefinition[] buildFunctionParameterDefinitions(Vector params, String library) { + VariableDefinition[] definitions = new VariableDefinition[params.size()]; + for (int i = 0; i < params.size(); i++) { + String typeName = LanguageUtil.getTypeString(params.get(i)); + definitions[i] = new VariableDefinition( + "arg" + (i + 1), + typeName, + LanguageUtil.toTypeDefinition(params.get(i)), + "", + "", + library, + true, + "", + "Builtin"); + } + return definitions; + } + private Map buildFunctionLibraryBySpecIndex() { Map functionLibraryBySpecIndex = new HashMap<>(); From 624dfa98b78e32774c9e81d7caa2556c6defd461 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Tue, 14 Jul 2026 00:12:24 -0400 Subject: [PATCH 18/38] work on card panel layouts and buttons --- .../java/com/basic4gl/desktop/MainWindow.java | 32 +- .../desktop/ProjectSettingsDialog.java | 3 + .../main/java/com/basic4gl/desktop/Theme.java | 6 + .../desktop/panels/AssetsPanelProvider.java | 205 +++++++++++-- .../desktop/panels/DebugPanelProvider.java | 67 ++++- .../panels/FileBrowserPanelProvider.java | 186 ++++++++++-- .../desktop/panels/SymbolsPanelProvider.java | 273 +++++++++++++++--- .../desktop/util/RoundedCardPanel.java | 32 ++ .../basic4gl/desktop/util/SwingIconUtil.java | 10 + .../com/basic4gl/desktop/util/SwingUtil.java | 26 ++ .../vmview/VirtualMachineViewDialog.java | 2 + .../images/material/icon_chevron_down.png | Bin 0 -> 336 bytes .../images/material/icon_chevron_up.png | Bin 0 -> 330 bytes .../images/material/icon_dots_vertical.png | Bin 0 -> 218 bytes .../images/material/icon_refresh.png | Bin 0 -> 729 bytes .../resources/images/material/icon_search.png | Bin 0 -> 694 bytes .../images/material/icon_view_grid.png | Bin 0 -> 232 bytes .../images/material/icon_view_list.png | Bin 0 -> 288 bytes 18 files changed, 740 insertions(+), 102 deletions(-) create mode 100644 app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java create mode 100644 app/src/main/resources/images/material/icon_chevron_down.png create mode 100644 app/src/main/resources/images/material/icon_chevron_up.png create mode 100644 app/src/main/resources/images/material/icon_dots_vertical.png create mode 100644 app/src/main/resources/images/material/icon_refresh.png create mode 100644 app/src/main/resources/images/material/icon_search.png create mode 100644 app/src/main/resources/images/material/icon_view_grid.png create mode 100644 app/src/main/resources/images/material/icon_view_list.png diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 2d69802d..1308b402 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -3,6 +3,7 @@ import static com.basic4gl.desktop.Theme.*; import static com.basic4gl.desktop.util.HtmlUtil.markdownToHtml; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; import static com.formdev.flatlaf.FlatClientProperties.*; import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; @@ -77,17 +78,17 @@ public void caretUpdate(CaretEvent e) { // Window private final JFrame frame = new JFrame(BuildInfo.APPLICATION_NAME); - private final JSplitPane mainPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + private final JSplitPane mainPane; private final JTabbedPane tabControl = new JTabbedPane(); private final JTabbedPane splitTabControl = new JTabbedPane(); - private final JSplitPane editorSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + private final JSplitPane editorSplitPane; private final JPanel primaryTabHost = new JPanel(new BorderLayout()); private final JButton addTabDropdownButton = new JButton("+"); private final JPanel centerPaneHost = new JPanel(new BorderLayout()); private final JPanel topPaneHost = new JPanel(new BorderLayout()); private final JPanel leftRailsHost = new JPanel(); - private final JSplitPane workspacePane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); - private final JSplitPane contentPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + private final JSplitPane workspacePane; + private final JSplitPane contentPane; private final JPanel bottomBarContainer = new JPanel(new BorderLayout()); // File viewers: stores IFileViewer instances for each tab (parallel to tabControl) private final java.util.List fileViewers = new java.util.ArrayList<>(); @@ -287,6 +288,12 @@ public MainWindow() { frame.setPreferredSize(new Dimension(696, 480)); frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + + mainPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + editorSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + workspacePane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + contentPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + JMenuBar menuBar = new JMenuBar(); JMenu fileMenu = new JMenu("File"); menuBar.add(fileMenu); @@ -588,6 +595,7 @@ public void onStepOutRequested() { UIManager.put("TabbedPane.closeIcon", new FlatTabbedPaneCloseIcon()); UIManager.put("TabbedPane.selectedBackground", Color.white); + UIManager.put("SplitPaneDivider.gripColor", new Color(0, 0, 0, 0)); SwingUtilities.updateComponentTreeUI(tabControl); tabControl.setUI(new FlatTabbedPaneUI() { @@ -641,15 +649,28 @@ protected void installDefaults() { editorSplitPane.setLeftComponent(primaryTabHost); editorSplitPane.setRightComponent(splitTabControl); editorSplitPane.setResizeWeight(0.7); + hideSplitPaneHandle(editorSplitPane); + + editorSplitPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); + editorSplitPane.putClientProperty("JSplitPane.style", "plain"); contentPane.setLeftComponent(primaryTabHost); contentPane.setRightComponent(rightDocsContainer); contentPane.setResizeWeight(0.74); + hideSplitPaneHandle(contentPane); + + contentPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); + contentPane.putClientProperty("JSplitPane.style", "plain"); workspacePane.setLeftComponent(leftSidebarContent); workspacePane.setRightComponent(contentPane); workspacePane.setResizeWeight(0.18); workspacePane.setDividerLocation(expandedLeftSidebarWidth); + hideSplitPaneHandle(workspacePane); + + workspacePane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); + workspacePane.putClientProperty("JSplitPane.style", "plain"); + contentPane.setDividerLocation(Math.max(200, frame.getPreferredSize().width - expandedRightDocsWidth)); topPaneHost.add(workspacePane, BorderLayout.CENTER); @@ -657,6 +678,7 @@ protected void installDefaults() { mainPane.setTopComponent(topPaneHost); mainPane.setBottomComponent(bottomBarContainer); mainPane.setResizeWeight(1.0); + hideSplitPaneHandle(mainPane); leftRailsHost.setLayout(new BoxLayout(leftRailsHost, BoxLayout.Y_AXIS)); leftRailsHost.add(leftSidebarRail); @@ -2302,6 +2324,7 @@ private boolean isBottomBarExpanded() { private void collapseBottomBar() { if (mainPane.getBottomComponent() != bottomBarContainer) { mainPane.setBottomComponent(bottomBarContainer); + hideSplitPaneHandle(mainPane); } if (isBottomBarExpanded() && mainPane.getHeight() > 0) { expandedBottomBarHeight = Math.max(120, mainPane.getHeight() - mainPane.getDividerLocation()); @@ -2317,6 +2340,7 @@ private void collapseBottomBar() { private void expandBottomBar() { if (mainPane.getBottomComponent() != bottomBarContainer) { mainPane.setBottomComponent(bottomBarContainer); + hideSplitPaneHandle(mainPane); } mainPane.setResizeWeight(1.0); diff --git a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java index c7982c5c..092423d3 100644 --- a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java @@ -13,6 +13,8 @@ import javax.swing.border.EmptyBorder; import javax.swing.border.MatteBorder; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; + public class ProjectSettingsDialog implements com.basic4gl.desktop.spi.ConfigurationFormPanel.IOnConfigurationChangeListener { @@ -112,6 +114,7 @@ public ProjectSettingsDialog( splitPane.setDividerLocation(160); splitPane.setResizeWeight(0); splitPane.setBorder(null); + hideSplitPaneHandle(splitPane); contentPane.add(splitPane, BorderLayout.CENTER); sectionsList.addListSelectionListener(e -> { diff --git a/app/src/main/java/com/basic4gl/desktop/Theme.java b/app/src/main/java/com/basic4gl/desktop/Theme.java index 5d977553..8f4e7eb0 100644 --- a/app/src/main/java/com/basic4gl/desktop/Theme.java +++ b/app/src/main/java/com/basic4gl/desktop/Theme.java @@ -17,6 +17,12 @@ public class Theme { public static final String ICON_STEP_OUT = THEME_DIRECTORY + "icon_step_out.png"; public static final String ICON_EXPORT = THEME_DIRECTORY + "icon_export.png"; public static final String ICON_SETTINGS = THEME_DIRECTORY + "icon_settings.png"; + public static final String ICON_REFRESH = THEME_DIRECTORY + "icon_refresh.png"; + public static final String ICON_DOTS_VERTICAL = THEME_DIRECTORY + "icon_dots_vertical.png"; + public static final String ICON_VIEW_GRID = THEME_DIRECTORY + "icon_view_grid.png"; + public static final String ICON_VIEW_LIST = THEME_DIRECTORY + "icon_view_list.png"; + public static final String ICON_CHEVRON_DOWN = THEME_DIRECTORY + "icon_chevron_down.png"; + public static final String ICON_SEARCH = THEME_DIRECTORY + "icon_search.png"; public static final String ICON_MENU_FOLDER = THEME_DIRECTORY + "menu_folder.png"; public static final String ICON_MENU_ASSETS = THEME_DIRECTORY + "menu_assets.png"; public static final String ICON_MENU_BOOKMARKS = THEME_DIRECTORY + "menu_bookmarks.png"; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java index 387efaa1..ef1204b4 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java @@ -6,9 +6,12 @@ import com.basic4gl.desktop.spi.FileUtil; import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.RoundedCardPanel; import javax.swing.*; import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; import javax.swing.filechooser.FileSystemView; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; @@ -22,13 +25,17 @@ import java.util.*; import java.util.List; +import static com.basic4gl.desktop.Theme.ICON_REFRESH; +import static com.basic4gl.desktop.Theme.ICON_SEARCH; +import static com.basic4gl.desktop.Theme.ICON_VIEW_GRID; +import static com.basic4gl.desktop.Theme.ICON_VIEW_LIST; import static com.basic4gl.desktop.Theme.ICON_MENU_ASSETS; import static com.basic4gl.desktop.Theme.ICON_MENU_FOLDER; import static com.basic4gl.desktop.util.FileUtil.*; import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; -import static com.basic4gl.desktop.util.SwingIconUtil.buildImageThumbnailIcon; -import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingIconUtil.*; import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; public class AssetsPanelProvider implements IEditorPanelProvider { @@ -36,9 +43,13 @@ public class AssetsPanelProvider implements IEditorPanelProvider { private final JTree assetsTree = new JTree(); private final DefaultListModel assetsListModel = new DefaultListModel<>(); private final JList assetsGridList = new JList<>(assetsListModel); + private final JTextField assetsSearchField = new JTextField(); private final JPanel assetsContentPanel = new JPanel(new CardLayout()); - private final JComboBox assetsLayoutCombo = new JComboBox<>(new String[] {"Tree", "Grid"}); private final Map assetThumbnailCache = new HashMap<>(); + private static final String LAYOUT_TREE = "Tree"; + private static final String LAYOUT_GRID = "Grid"; + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final Dimension HEADER_LAYOUT_BUTTON_SIZE = new Dimension(34, 30); private FileManager fileManager; @@ -95,25 +106,49 @@ public EditorLayout getLayoutConstraints() { public JPanel build(PluginContext context) { this.context = context; + JPanel panelCardHost = new JPanel(new CardLayout()); JPanel panel = new JPanel(new BorderLayout(0, 6)); + Color panelBackground = createLighterPanelBackground(); + panel.setBackground(panelBackground); JPanel header = new JPanel(new BorderLayout()); + header.setBackground(panelBackground); JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); + headerButtons.setOpaque(false); JLabel title = new JLabel("Assets"); - title.setBorder(new EmptyBorder(4, 8, 0, 8)); - assetsLayoutCombo.setFocusable(false); - assetsLayoutCombo.addActionListener(e -> { - CardLayout layout = (CardLayout) assetsContentPanel.getLayout(); - layout.show(assetsContentPanel, Objects.toString(assetsLayoutCombo.getSelectedItem(), "Tree")); - }); - JButton refresh = new JButton("Refresh"); - refresh.setFocusable(false); + Font baseFont = title.getFont(); + title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + title.setForeground(new Color(0x424242)); + title.setBorder(new EmptyBorder(0, 8, 0, 8)); + JPanel layoutTabs = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); + layoutTabs.setOpaque(false); + ButtonGroup layoutButtons = new ButtonGroup(); + JToggleButton treeLayoutButton = createAssetsLayoutButton( + "List View", + ICON_VIEW_LIST, + LAYOUT_TREE, + "first"); + JToggleButton gridLayoutButton = createAssetsLayoutButton( + "Grid View", + ICON_VIEW_GRID, + LAYOUT_GRID, + "last"); + layoutButtons.add(treeLayoutButton); + layoutButtons.add(gridLayoutButton); + treeLayoutButton.setSelected(true); + layoutTabs.add(treeLayoutButton); + layoutTabs.add(gridLayoutButton); + JToggleButton searchToggle = createHeaderSearchToggleButton(); + JButton refresh = createHeaderIconButton(ICON_REFRESH, "Refresh Assets"); refresh.addActionListener(e -> refresh(context.currentEditor())); - headerButtons.add(assetsLayoutCombo); + headerButtons.add(layoutTabs); + headerButtons.add(searchToggle); headerButtons.add(refresh); header.add(title, BorderLayout.WEST); header.add(headerButtons, BorderLayout.EAST); panel.add(header, BorderLayout.NORTH); + assetsTree.setBackground(panelBackground); + assetsTree.setBorder(null); assetsTree.setRootVisible(false); assetsTree.setShowsRootHandles(true); // Let Swing compute preferred row height so custom/HTML labels do not clip. @@ -173,8 +208,11 @@ public void mouseReleased(MouseEvent e) { }); JScrollPane scrollPane = new JScrollPane(assetsTree); + scrollPane.setBorder(null); configureSmoothScrolling(scrollPane); + assetsGridList.setBorder(null); + assetsGridList.setBackground(panelBackground); assetsGridList.setLayoutOrientation(JList.HORIZONTAL_WRAP); assetsGridList.setVisibleRowCount(-1); assetsGridList.setFixedCellHeight(112); @@ -223,12 +261,113 @@ public void mouseReleased(MouseEvent e) { }); JScrollPane gridScrollPane = new JScrollPane(assetsGridList); + gridScrollPane.setBorder(null); configureSmoothScrolling(gridScrollPane); - assetsContentPanel.add(scrollPane, "Tree"); - assetsContentPanel.add(gridScrollPane, "Grid"); - panel.add(assetsContentPanel, BorderLayout.CENTER); - return panel; + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + assetsSearchField.setToolTipText("Search assets"); + searchBar.add(assetsSearchField, BorderLayout.CENTER); + searchBar.setVisible(false); + assetsSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + + @Override + public void removeUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + + @Override + public void changedUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + }); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + assetsSearchField.requestFocusInWindow(); + } + panel.revalidate(); + panel.repaint(); + }); + + assetsContentPanel.add(scrollPane, LAYOUT_TREE); + assetsContentPanel.add(gridScrollPane, LAYOUT_GRID); + showAssetsLayout(LAYOUT_TREE); + JPanel content = new JPanel(new BorderLayout(0, 6)); + content.setBackground(panelBackground); + content.add(searchBar, BorderLayout.NORTH); + content.add(assetsContentPanel, BorderLayout.CENTER); + panel.add(content, BorderLayout.CENTER); + panelCardHost.add(createRoundedCardHost(panel, panelBackground, "assets-main"), "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; + } + + private JToggleButton createAssetsLayoutButton( + String tooltip, String iconPath, String layoutKey, String segmentPosition) { + JToggleButton button = new JToggleButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "segmented"); + button.putClientProperty("JButton.segmentPosition", segmentPosition); + button.setOpaque(false); + button.setMargin(new Insets(6, 8, 6, 8)); + button.setPreferredSize(HEADER_LAYOUT_BUTTON_SIZE); + button.setMinimumSize(HEADER_LAYOUT_BUTTON_SIZE); + button.setMaximumSize(HEADER_LAYOUT_BUTTON_SIZE); + button.addActionListener(e -> showAssetsLayout(layoutKey)); + return button; + } + + private JButton createHeaderIconButton(String iconPath, String tooltip) { + JButton button = new JButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } + + private void showAssetsLayout(String layoutKey) { + CardLayout layout = (CardLayout) assetsContentPanel.getLayout(); + layout.show(assetsContentPanel, layoutKey); } private String formatAssetTreeLabel(AssetItem item, boolean isSection) { @@ -332,6 +471,9 @@ public void refresh(EditorPlugin languageProvider) { return; } File rootDir = new File(context.currentDirectory()); + String searchNeedle = assetsSearchField.getText() == null + ? "" + : assetsSearchField.getText().trim().toLowerCase(Locale.ROOT); assetThumbnailCache.clear(); DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(new AssetItem( "Assets", @@ -339,17 +481,21 @@ public void refresh(EditorPlugin languageProvider) { null, createImageIcon(ICON_MENU_ASSETS))); + java.util.List workspaceAssets = collectWorkspaceAssets(rootDir, 0, 4); + workspaceAssets = filterAssetFiles(workspaceAssets, rootDir, searchNeedle); DefaultMutableTreeNode workspaceNode = buildMediaTypeSection( "Workspace Resources", - collectWorkspaceAssets(rootDir, 0, 4), + workspaceAssets, rootDir, createImageIcon(ICON_MENU_FOLDER)); if (workspaceNode != null) { rootNode.add(workspaceNode); } + java.util.List literalAssets = detectLiteralAssets(rootDir, context.currentEditor().getLanguage()); + literalAssets = filterAssetFiles(literalAssets, rootDir, searchNeedle); DefaultMutableTreeNode literalNode = buildMediaTypeSection( - "Embedded Literals", detectLiteralAssets(rootDir, context.currentEditor().getLanguage()), rootDir, createImageIcon(ICON_MENU_ASSETS)); + "Embedded Literals", literalAssets, rootDir, createImageIcon(ICON_MENU_ASSETS)); if (literalNode != null) { rootNode.add(literalNode); } @@ -365,6 +511,29 @@ public void refresh(EditorPlugin languageProvider) { } } + private java.util.List filterAssetFiles(java.util.List files, File baseDir, String searchNeedle) { + if (files == null || files.isEmpty()) { + return Collections.emptyList(); + } + if (searchNeedle == null || searchNeedle.isBlank()) { + return files; + } + java.util.List filtered = new ArrayList<>(); + for (File file : files) { + if (file == null) { + continue; + } + String name = file.getName().toLowerCase(Locale.ROOT); + String absolute = file.getAbsolutePath().toLowerCase(Locale.ROOT); + String relative = formatRelativePath(file, baseDir); + String relativeLower = relative == null ? "" : relative.toLowerCase(Locale.ROOT); + if (name.contains(searchNeedle) || absolute.contains(searchNeedle) || relativeLower.contains(searchNeedle)) { + filtered.add(file); + } + } + return filtered; + } + @Override public void onFileModified(String filePath) { diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java index 99461177..d009a1bb 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java @@ -6,6 +6,8 @@ import com.basic4gl.desktop.editor.ApMode; import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.RoundedCardPanel; +import com.basic4gl.desktop.util.SwingUtil; import javax.swing.*; import javax.swing.border.EmptyBorder; @@ -24,6 +26,7 @@ import static com.basic4gl.desktop.Theme.ICON_STEP_OVER; import static com.basic4gl.desktop.Theme.ICON_MENU_DEBUG; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; public class DebugPanelProvider implements IEditorPanelProvider, IDebugPresenter { @@ -70,10 +73,21 @@ public EditorLayout getLayoutConstraints() { public JPanel build(PluginContext context) { this.context = context; + JPanel panelCardHost = new JPanel(new CardLayout()); JPanel panel = new JPanel(new BorderLayout()); + Color panelBackground = createLighterPanelBackground(); + panel.setBackground(panelBackground); + + JLabel title = new JLabel("Debug"); + Font baseFont = title.getFont(); + title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + title.setForeground(new Color(0x424242)); + title.setBorder(new EmptyBorder(0, 6, 0, 6)); JToolBar debugToolBar = new JToolBar(); debugToolBar.setFloatable(false); + debugToolBar.setOpaque(false); + debugToolBar.add(title); debugToolBar.add(playButton); debugToolBar.add(stepOverButton); debugToolBar.add(stepInButton); @@ -90,15 +104,24 @@ public JPanel build(PluginContext context) { stepOutButton.addActionListener(e -> context.debugger().actionStepOutOf()); panel.add(debugToolBar, BorderLayout.NORTH); - panel.add(buildDebugPanel(), BorderLayout.CENTER); - return panel; + panel.add(buildDebugPanel(panelBackground), BorderLayout.CENTER); + panelCardHost.add(createRoundedCardHost(panel, panelBackground, "debug-main"), "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; } - private JPanel buildDebugPanel() { + private JPanel buildDebugPanel(Color panelBackground) { // Debugger JPanel watchListFrame = new JPanel(); watchListFrame.setLayout(new BorderLayout()); + watchListFrame.setBackground(panelBackground); JLabel watchlistLabel = new JLabel("Watchlist"); + + Font font = watchlistLabel.getFont(); + watchlistLabel.setFont(new Font(font.getName(), Font.BOLD, font.getSize())); + watchlistLabel.setForeground(new Color(0x000000)); + watchlistLabel.setBorder(new EmptyBorder(0, 8, 0, 8)); + watchlistLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); watchListFrame.add(watchlistLabel, BorderLayout.NORTH); JScrollPane watchListScrollPane = new JScrollPane(watchListBox); @@ -141,23 +164,61 @@ public void keyReleased(KeyEvent e) { JPanel gosubFrame = new JPanel(); gosubFrame.setLayout(new BorderLayout()); + gosubFrame.setBackground(panelBackground); JLabel callstackLabel = new JLabel("Callstack"); + + font = callstackLabel.getFont(); + callstackLabel.setFont(new Font(font.getName(), Font.BOLD, font.getSize())); + callstackLabel.setForeground(new Color(0x000000)); + callstackLabel.setBorder(new EmptyBorder(0, 8, 0, 8)); + callstackLabel.setBorder(new EmptyBorder(4, 8, 4, 8)); + gosubFrame.add(callstackLabel, BorderLayout.NORTH); JList gosubListBox = new JList<>(gosubListModel); JScrollPane gosubListScrollPane = new JScrollPane(gosubListBox); gosubFrame.add(gosubListScrollPane, BorderLayout.CENTER); + hideSplitPaneHandle(debugPane); + + debugPane.setBackground(panelBackground); debugPane.setLeftComponent(watchListFrame); debugPane.setRightComponent(gosubFrame); JPanel debugPanel = new JPanel(); debugPanel.setLayout(new BorderLayout()); + debugPanel.setBackground(panelBackground); debugPanel.add(debugPane, BorderLayout.CENTER); return debugPanel; } + private Color createLighterPanelBackground() { + Color base = UIManager.getColor("Panel.background"); + if (base == null) { + base = new Color(238, 238, 238); + } + return new Color( + Math.min(255, base.getRed() + 8), + Math.min(255, base.getGreen() + 8), + Math.min(255, base.getBlue() + 8)); + } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = SwingUtil.createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } + @Override public void updateCallStack(StackTraceCallback stackTraceCallback) { diff --git a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java index e75a0083..d0fb0a8d 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java @@ -3,9 +3,13 @@ import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.util.FileUtil; +import com.basic4gl.desktop.util.RoundedCardPanel; +import com.basic4gl.desktop.util.SwingUtil; import javax.swing.*; import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; import javax.swing.filechooser.FileSystemView; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; @@ -20,18 +24,23 @@ import java.util.Comparator; import java.util.Locale; +import static com.basic4gl.desktop.Theme.ICON_DOTS_VERTICAL; import static com.basic4gl.desktop.Theme.ICON_MENU_FOLDER; -import static com.basic4gl.desktop.Theme.ICON_MENU_HELP; +import static com.basic4gl.desktop.Theme.ICON_REFRESH; +import static com.basic4gl.desktop.Theme.ICON_SEARCH; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; public class FileBrowserPanelProvider implements IEditorPanelProvider { private PluginContext context; private final JTree fileBrowserTree = new JTree(); + private final JTextField fileSearchField = new JTextField(); private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); private boolean showHiddenFiles = false; - + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); @Override public String id() { @@ -56,31 +65,82 @@ public EditorLayout getLayoutConstraints() { @Override public JPanel build(PluginContext context) { this.context = context; + JPanel panelCardHost = new JPanel(new CardLayout()); JPanel panel = new JPanel(new BorderLayout(0, 6)); - JPanel header = new JPanel(new BorderLayout()); - JPanel headerButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); - JLabel title = new JLabel("Workspace Browser"); - title.setBorder(new EmptyBorder(4, 8, 0, 8)); - JButton openFolder = new JButton("Open Folder"); - openFolder.setFocusable(false); - openFolder.addActionListener(e -> context.commands().actionOpenFolder()); - JButton refresh = new JButton("Refresh"); - refresh.setFocusable(false); + Color panelBackground = createLighterPanelBackground(); + panel.setBackground(panelBackground); + panel.setOpaque(true); + JPanel header = new JPanel(); + header.setLayout(new BoxLayout(header, BoxLayout.X_AXIS)); + header.setBackground(panelBackground); + header.setOpaque(true); + JLabel title = new JLabel("Workspace"); + Font baseFont = title.getFont(); + title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + title.setForeground(new Color(0x424242)); + title.setBorder(new EmptyBorder(0, 8, 0, 8)); + JButton refresh = createHeaderIconButton(ICON_REFRESH, "Refresh Workspace"); refresh.addActionListener(e -> refresh(context.currentEditor())); - JToggleButton showHiddenToggle = new JToggleButton("Show Hidden"); - showHiddenToggle.setFocusable(false); - showHiddenToggle.setSelected(showHiddenFiles); - showHiddenToggle.addActionListener(e -> { - showHiddenFiles = showHiddenToggle.isSelected(); + JToggleButton searchToggle = createHeaderSearchToggleButton(); + + JPopupMenu overflowMenu = new JPopupMenu(); + JMenuItem openFolderItem = new JMenuItem("Open Folder"); + openFolderItem.addActionListener(e -> context.commands().actionOpenFolder()); + overflowMenu.add(openFolderItem); + + JCheckBoxMenuItem showHiddenItem = new JCheckBoxMenuItem("Show Hidden Files", showHiddenFiles); + showHiddenItem.addActionListener(e -> { + showHiddenFiles = showHiddenItem.isSelected(); refresh(context.currentEditor()); }); - headerButtons.add(showHiddenToggle); - headerButtons.add(openFolder); - headerButtons.add(refresh); - header.add(title, BorderLayout.WEST); - header.add(headerButtons, BorderLayout.EAST); + overflowMenu.add(showHiddenItem); + + JButton overflowButton = createHeaderIconButton(ICON_DOTS_VERTICAL, "More Actions"); + overflowButton.addActionListener(e -> { + showHiddenItem.setSelected(showHiddenFiles); + overflowMenu.show(overflowButton, 0, overflowButton.getHeight()); + }); + + header.add(title); + header.add(Box.createHorizontalGlue()); + header.add(searchToggle); + header.add(refresh); + header.add(overflowButton); panel.add(header, BorderLayout.NORTH); + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + fileSearchField.setToolTipText("Search workspace files"); + searchBar.add(fileSearchField, BorderLayout.CENTER); + searchBar.setVisible(false); + fileSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + + @Override + public void removeUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + + @Override + public void changedUpdate(DocumentEvent e) { + refresh(context.currentEditor()); + } + }); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + fileSearchField.requestFocusInWindow(); + } + panel.revalidate(); + panel.repaint(); + }); + + fileBrowserTree.setBackground(panelBackground); fileBrowserTree.setRootVisible(true); fileBrowserTree.setShowsRootHandles(true); fileBrowserTree.setRowHeight(22); @@ -144,9 +204,62 @@ public void mouseReleased(MouseEvent e) { } }); JScrollPane scrollPane = new JScrollPane(fileBrowserTree); + scrollPane.setBorder(null); + scrollPane.setBackground(panelBackground); + configureSmoothScrolling(scrollPane); - panel.add(scrollPane, BorderLayout.CENTER); - return panel; + JPanel content = new JPanel(new BorderLayout(0, 6)); + content.setBackground(panelBackground); + content.add(searchBar, BorderLayout.NORTH); + content.add(scrollPane, BorderLayout.CENTER); + panel.add(content, BorderLayout.CENTER); + panelCardHost.add(createRoundedCardHost(panel, panelBackground, "workspace-main"), "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; + } + + private JButton createHeaderIconButton(String iconPath, String tooltip) { + JButton button = new JButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setBackground(panelBackground); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; } private void maybeShowWorkspaceBrowserPopup(MouseEvent e) { @@ -208,7 +321,13 @@ public void refresh(EditorPlugin languageProvider) { return; } File root = new File(context.currentDirectory()); - DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0); + String searchNeedle = fileSearchField.getText() == null + ? "" + : fileSearchField.getText().trim().toLowerCase(Locale.ROOT); + DefaultMutableTreeNode rootNode = buildFileTreeNode(root, 0, searchNeedle); + if (rootNode == null) { + rootNode = new DefaultMutableTreeNode(root); + } fileBrowserTree.setModel(new DefaultTreeModel(rootNode)); if (fileBrowserTree.getRowCount() > 0) { fileBrowserTree.expandRow(0); @@ -230,23 +349,30 @@ public void onCompileSucceeded() { } - private DefaultMutableTreeNode buildFileTreeNode(File file, int depth) { - DefaultMutableTreeNode node = new DefaultMutableTreeNode(file); + private DefaultMutableTreeNode buildFileTreeNode(File file, int depth, String searchNeedle) { + boolean hasSearch = searchNeedle != null && !searchNeedle.isBlank(); + boolean nameMatches = !hasSearch || file.getName().toLowerCase(Locale.ROOT).contains(searchNeedle); + boolean pathMatches = !hasSearch || file.getAbsolutePath().toLowerCase(Locale.ROOT).contains(searchNeedle); + boolean matches = nameMatches || pathMatches; if (!file.isDirectory()) { - return node; + return matches ? new DefaultMutableTreeNode(file) : null; } + DefaultMutableTreeNode node = new DefaultMutableTreeNode(file); File[] children = file.listFiles(); if (children == null) { - return node; + return (depth == 0 || matches) ? node : null; } Arrays.sort(children, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); for (File child : children) { if (!showHiddenFiles && child.getName().startsWith(".")) { continue; } - node.add(buildFileTreeNode(child, depth + 1)); + DefaultMutableTreeNode childNode = buildFileTreeNode(child, depth + 1, searchNeedle); + if (childNode != null) { + node.add(childNode); + } } - return node; + return (depth == 0 || matches || node.getChildCount() > 0) ? node : null; } } diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java index 87036d83..4cd80ea9 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -8,11 +8,16 @@ import com.basic4gl.desktop.spi.language.IndexedSymbol; import com.basic4gl.desktop.spi.language.LabelDefinition; import com.basic4gl.desktop.spi.language.VariableDefinition; +import com.basic4gl.desktop.util.RoundedCardPanel; import javax.swing.*; +import javax.swing.border.EmptyBorder; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.*; @@ -25,6 +30,9 @@ import static com.basic4gl.desktop.Theme.ICON_STRUCT; import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; import static com.formdev.flatlaf.FlatClientProperties.TABBED_PANE_TAB_CLOSABLE; import static com.formdev.flatlaf.FlatClientProperties.TABBED_PANE_TAB_CLOSE_CALLBACK; @@ -36,9 +44,12 @@ public class SymbolsPanelProvider implements IEditorPanelProvider { "Select an entry."; private String referenceDetailsHtml = REFERENCE_SELECT_PROMPT_HTML; private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); - private final JButton referenceFiltersButton = new JButton("Filters"); + private final JButton referenceFiltersButton = new JButton("All Symbols"); private final JPopupMenu referenceFiltersPopup = new JPopupMenu(); + private final JLabel referenceSelectionNameLabel = new JLabel("Select an entry."); + private String referenceSelectionName = "Select an entry."; private final JTextPane referenceDetailsPane = new JTextPane(); + private final JButton referenceCopyButton = new JButton("Copy"); private final JButton referenceInsertButton = new JButton("Insert"); private final javax.swing.Timer referenceFilterDebounceTimer = new javax.swing.Timer(120, e -> filterReferenceItems()); @@ -49,9 +60,11 @@ public class SymbolsPanelProvider implements IEditorPanelProvider { private final JList referenceList = new JList<>(referenceListModel); private final JTextField referenceSearchField = new JTextField(); private final JComboBox referenceKindFilter = - new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables", "Structs"}); + new JComboBox<>(new String[] {"All Symbols", "Functions", "Constants", "Labels", "Variables", "Structs"}); private final JComboBox referenceSourceFilter = - new JComboBox<>(new String[] {"All sources", "Builtin", "Libraries", "Program"}); + new JComboBox<>(new String[] {"All Sources", "Builtin", "Libraries", "Program"}); + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final int CARD_ARC = 14; private int lastProgramSymbolsFingerprint = Integer.MIN_VALUE; @@ -117,19 +130,41 @@ public JPanel build(PluginContext context) { symbolIndexer = new SymbolIndexer(context.currentEditor().getLanguageSupport(), context.commands()::collectAllSourceText, this::updateProgramSymbols); + JPanel panelCardHost = new JPanel(new CardLayout()); JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); - JPanel lookupHeader = new JPanel(new BorderLayout(6, 6)); + Color panelBackground = createLighterPanelBackground(); + lookupPanel.setBackground(panelBackground); + JPanel lookupHeader = new JPanel(); + lookupHeader.setBackground(panelBackground); + lookupHeader.setLayout(new BoxLayout(lookupHeader, BoxLayout.X_AXIS)); - JPanel leftHeader = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); referenceFiltersButton.setFocusable(false); + referenceFiltersButton.setIcon(createScaledIcon(ICON_CHEVRON_DOWN, 18)); + referenceFiltersButton.setHorizontalTextPosition(SwingConstants.LEFT); + referenceFiltersButton.setIconTextGap(6); + referenceFiltersButton.putClientProperty("JButton.buttonType", "toolBarButton"); + referenceFiltersButton.setOpaque(false); + referenceFiltersButton.setMargin(new Insets(5, 8, 5, 8)); + Font baseFont = referenceFiltersButton.getFont(); + referenceFiltersButton.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + referenceFiltersButton.setForeground(new Color(0x5B717F)); referenceFiltersButton.setToolTipText("Open reference filters"); - leftHeader.add(referenceFiltersButton); - lookupHeader.add(leftHeader, BorderLayout.WEST); - - lookupHeader.add(referenceSearchField, BorderLayout.CENTER); + lookupHeader.add(referenceFiltersButton); + lookupHeader.add(Box.createHorizontalGlue()); + JToggleButton searchToggle = createHeaderSearchToggleButton(); + lookupHeader.add(searchToggle); + + referenceCopyButton.setFocusable(false); + referenceCopyButton.setMargin(new Insets(4, 4, 4, 4)); + Font actionButtonFont = referenceCopyButton.getFont(); + referenceCopyButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, actionButtonFont.getSize())); + referenceCopyButton.setForeground(new Color(0x5B717F)); + referenceCopyButton.setEnabled(false); referenceInsertButton.setFocusable(false); + referenceInsertButton.setMargin(new Insets(4, 4, 4, 4)); + referenceInsertButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, actionButtonFont.getSize())); + referenceInsertButton.setForeground(new Color(0x5B717F)); referenceInsertButton.setEnabled(false); - lookupHeader.add(referenceInsertButton, BorderLayout.EAST); referenceSearchField.setToolTipText("Search by name, signature, or library"); referenceKindFilter.setToolTipText("Filter by kind"); @@ -140,6 +175,7 @@ public JPanel build(PluginContext context) { referenceFilterDebounceTimer.setRepeats(false); + referenceList.setBackground(panelBackground); referenceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); referenceList.setFixedCellHeight(20); referenceList.setPrototypeCellValue( @@ -174,14 +210,72 @@ public Component getListCellRendererComponent( referenceDetailsPane.setEditable(false); referenceDetailsPane.setContentType("text/html"); + referenceDetailsPane.setBorder(null); + referenceDetailsPane.setBackground(panelBackground); setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); + Font nameFont = referenceSelectionNameLabel.getFont(); + referenceSelectionNameLabel.setFont(new Font(nameFont.getName(), Font.BOLD, nameFont.getSize())); + referenceSelectionNameLabel.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + updateReferenceSelectionNameLabel(); + } + }); + setReferenceSelectionName("Select an entry."); JSplitPane lookupSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); lookupSplit.setResizeWeight(0.65); - lookupSplit.setTopComponent(new JScrollPane(referenceList)); - lookupSplit.setBottomComponent(new JScrollPane(referenceDetailsPane)); + hideSplitPaneHandle(lookupSplit); + lookupSplit.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); + lookupSplit.putClientProperty("JSplitPane.style", "plain"); + JScrollPane listScrollPane = new JScrollPane(referenceList); + listScrollPane.setBorder(null); + JPanel detailsPanel = new JPanel(new BorderLayout(0, 0)); + JToolBar detailsHeader = new JToolBar(); + detailsHeader.setFloatable(false); + detailsHeader.setRollover(true); + detailsHeader.setBackground(panelBackground); + detailsHeader.setOpaque(true); + detailsHeader.setBorder(new EmptyBorder(6, 8, 4, 8)); + JPanel detailsTitleHost = new JPanel(new BorderLayout()); + detailsTitleHost.setOpaque(false); + detailsTitleHost.add(referenceSelectionNameLabel, BorderLayout.CENTER); + detailsTitleHost.setMaximumSize(new Dimension(Integer.MAX_VALUE, referenceSelectionNameLabel.getPreferredSize().height + 4)); + detailsHeader.add(detailsTitleHost); + detailsHeader.add(Box.createHorizontalGlue()); + detailsHeader.add(referenceCopyButton); + detailsHeader.add(referenceInsertButton); + detailsPanel.add(detailsHeader, BorderLayout.NORTH); + detailsPanel.add(referenceDetailsPane, BorderLayout.CENTER); + JScrollPane detailsScrollPane = new JScrollPane(detailsPanel); + detailsScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + detailsScrollPane.setBorder(null); + lookupSplit.setBottomComponent(createRoundedCardHost(detailsScrollPane, panelBackground, "symbols-details")); + + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + searchBar.add(referenceSearchField, BorderLayout.CENTER); + searchBar.setVisible(false); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + referenceSearchField.requestFocusInWindow(); + } + lookupPanel.revalidate(); + lookupPanel.repaint(); + }); + JPanel symbolsListPanel = new JPanel(new BorderLayout(0, 6)); + symbolsListPanel.setBackground(panelBackground); + JPanel symbolsListHeader = new JPanel(new BorderLayout(0, 6)); + symbolsListHeader.setOpaque(false); + symbolsListHeader.add(lookupHeader, BorderLayout.NORTH); + symbolsListHeader.add(searchBar, BorderLayout.SOUTH); + symbolsListPanel.add(symbolsListHeader, BorderLayout.NORTH); + symbolsListPanel.add(listScrollPane, BorderLayout.CENTER); + lookupSplit.setTopComponent(createRoundedCardHost(symbolsListPanel, panelBackground, "symbols-list")); - lookupPanel.add(lookupHeader, BorderLayout.NORTH); lookupPanel.add(lookupSplit, BorderLayout.CENTER); referenceSearchField.getDocument().addDocumentListener(new DocumentListener() { @@ -236,11 +330,50 @@ public void mouseClicked(MouseEvent e) { } }); referenceInsertButton.addActionListener(e -> insertSelectedReference()); + referenceCopyButton.addActionListener(e -> copySelectedSymbolName()); updateReferenceFiltersButtonTooltip(); - return lookupPanel; + panelCardHost.add(lookupPanel, "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); +// new Color( +// Math.min(255, panelBackground.getRed() + 10), +// Math.min(255, panelBackground.getGreen() + 10), +// Math.min(255, panelBackground.getBlue() + 10)); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } + + + + @Override public void refresh(EditorPlugin languageProvider) { if (context == null) { @@ -295,7 +428,6 @@ private void updateProgramSymbols(List symbols) { switch (sym.kind()) { case "userfunc" -> { details = "" - + "

        " + escapeHtml(sym.name()) + "

        " + "

        Type: User Function" + "
        Source: Program

        " + "

        " + escapeHtml(sym.signature()) + "

        " @@ -305,7 +437,6 @@ private void updateProgramSymbols(List symbols) { } case "label" -> { details = "" - + "

        " + escapeHtml(sym.name()) + "

        " + "

        Type: Label" + "
        Usage: gosub " + escapeHtml(sym.name()) + "" + " / goto " + escapeHtml(sym.name()) + "

        " @@ -315,7 +446,6 @@ private void updateProgramSymbols(List symbols) { } case "struc" -> { details = "" - + "

        " + escapeHtml(sym.name()) + "

        " + "

        Type: Struct" + "
        Source: Program

        " + "

        " + escapeHtml(sym.signature()) + "

        " @@ -325,7 +455,6 @@ private void updateProgramSymbols(List symbols) { } default -> { // "variable" details = "" - + "

        " + escapeHtml(sym.name()) + "

        " + "

        Type: Variable" + "
        Source: Program

        " + "

        " + escapeHtml(sym.signature()) + "

        " @@ -377,10 +506,8 @@ private java.util.List buildFunctionReferenceItems(LanguageServic argsOnly.append(arg.signature()); } } - String details = "" - + "

        " - + escapeHtml(item.name()) - + "

        Type: Function
        Library: " + String details = "" + + "

        Type: Function
        Library: " + escapeHtml(item.packageName()) + "

        " + escapeHtml(item.signature()) @@ -403,10 +530,8 @@ private java.util.List buildConstantReferenceItems(LanguageServic if (item == null) { continue; } - String details = "" - + "

        " - + escapeHtml(item.name()) - + "

        Type: Constant
        Library: " + String details = "" + + "

        Type: Constant
        Library: " + escapeHtml(item.packageName()) + "

        " + escapeHtml(item.signature()) @@ -431,9 +556,8 @@ private java.util.List buildLabelReferenceItems(LanguageService c continue; } String signature = label.signature(); - String details = "" - + "

        " + escapeHtml(label.name()) - + "

        Type: Label
        Usage: " + String details = "" + + "

        Type: Label
        Usage: " + "" + escapeHtml(label.usage()) + "" + "

        "; items.add(new ReferenceItem( @@ -456,9 +580,8 @@ private java.util.List buildVariableReferenceItems(LanguageServic } String typeStr = variable.type().name(); String signature = variable.signature(); - String details = "" - + "

        " + escapeHtml(variable.name()) - + "

        Type: Variable
        Data type: " + String details = "" + + "

        Type: Variable
        Data type: " + escapeHtml(typeStr) + "
        Source: Program

        "; items.add(new ReferenceItem( "variable", @@ -473,7 +596,7 @@ private java.util.List buildVariableReferenceItems(LanguageServic } private void rebuildLibraryFilterOptions() { - String selected = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); + String selected = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All Libraries"); Set libraries = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); for (ReferenceItem item : allReferenceItems) { if (item.library != null) { @@ -484,11 +607,11 @@ private void rebuildLibraryFilterOptions() { updatingReferenceFilters = true; try { referenceLibraryFilter.removeAllItems(); - referenceLibraryFilter.addItem("All libraries"); + referenceLibraryFilter.addItem("All Libraries"); for (String library : libraries) { referenceLibraryFilter.addItem(library); } - referenceLibraryFilter.setSelectedItem(libraries.contains(selected) ? selected : "All libraries"); + referenceLibraryFilter.setSelectedItem(libraries.contains(selected) ? selected : "All Libraries"); } finally { updatingReferenceFilters = false; } @@ -500,7 +623,7 @@ private void rebuildReferenceFiltersPopup() { referenceFiltersPopup.removeAll(); JMenu typeMenu = new JMenu("Type"); - addReferenceRadioItems(typeMenu, referenceKindFilter, "All", "All"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "All Symbols", "All Symbols"); addReferenceRadioItems(typeMenu, referenceKindFilter, "Functions", "Functions"); addReferenceRadioItems(typeMenu, referenceKindFilter, "Constants", "Constants"); addReferenceRadioItems(typeMenu, referenceKindFilter, "Labels", "Labels"); @@ -508,7 +631,7 @@ private void rebuildReferenceFiltersPopup() { addReferenceRadioItems(typeMenu, referenceKindFilter, "Structs", "Structs"); JMenu sourceMenu = new JMenu("Source"); - addReferenceRadioItems(sourceMenu, referenceSourceFilter, "All sources", "All sources"); + addReferenceRadioItems(sourceMenu, referenceSourceFilter, "All Sources", "All Sources"); addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Builtin", "Builtin"); addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Libraries", "Libraries"); addReferenceRadioItems(sourceMenu, referenceSourceFilter, "Program", "Program"); @@ -525,9 +648,9 @@ private void rebuildReferenceFiltersPopup() { resetItem.addActionListener(e -> { updatingReferenceFilters = true; try { - referenceKindFilter.setSelectedItem("All"); - referenceSourceFilter.setSelectedItem("All sources"); - referenceLibraryFilter.setSelectedItem("All libraries"); + referenceKindFilter.setSelectedItem("All Symbols"); + referenceSourceFilter.setSelectedItem("All Sources"); + referenceLibraryFilter.setSelectedItem("All Libraries"); } finally { updatingReferenceFilters = false; } @@ -559,9 +682,10 @@ private void setReferenceDetailsHtml(String html) { } private void updateReferenceFiltersButtonTooltip() { - String type = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); - String source = Objects.toString(referenceSourceFilter.getSelectedItem(), "All sources"); - String library = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); + String type = Objects.toString(referenceKindFilter.getSelectedItem(), "All Symbols"); + String source = Objects.toString(referenceSourceFilter.getSelectedItem(), "All Sources"); + String library = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All Libraries"); + referenceFiltersButton.setText(type); referenceFiltersButton.setToolTipText("Type: " + type + " | Source: " + source + " | Library: " + library); } @@ -573,20 +697,20 @@ private void filterReferenceItems() { String query = referenceSearchField.getText(); String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); String selectedKind = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); - String selectedSource = Objects.toString(referenceSourceFilter.getSelectedItem(), "All sources"); + String selectedSource = Objects.toString(referenceSourceFilter.getSelectedItem(), "All Sources"); String selectedLibrary = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All libraries"); ReferenceItem previousSelection = referenceList.getSelectedValue(); java.util.List matches = new ArrayList<>(); for (ReferenceItem item : allReferenceItems) { - boolean kindMatches = "All".equals(selectedKind) + boolean kindMatches = "All Symbols".equals(selectedKind) || ("Functions".equals(selectedKind) && ("function".equals(item.kind) || "userfunc".equals(item.kind))) || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) || ("Labels".equals(selectedKind) && "label".equals(item.kind)) || ("Variables".equals(selectedKind) && "variable".equals(item.kind)) || ("Structs".equals(selectedKind) && "struc".equals(item.kind)); - boolean sourceMatches = "All sources".equals(selectedSource) + boolean sourceMatches = "All Sources".equals(selectedSource) || ("Builtin".equals(selectedSource) && item.library != null && "Builtin".equalsIgnoreCase(item.library)) @@ -597,7 +721,7 @@ private void filterReferenceItems() { || ("Program".equals(selectedSource) && item.library != null && "Program".equalsIgnoreCase(item.library)); - boolean libraryMatches = "All libraries".equals(selectedLibrary) + boolean libraryMatches = "All Libraries".equals(selectedLibrary) || (item.library != null && selectedLibrary.equals(item.library)); if (needle.isEmpty() || item.name.toLowerCase(Locale.ROOT).contains(needle) @@ -623,7 +747,9 @@ private void filterReferenceItems() { referenceList.setSelectedIndex(0); } } else { + setReferenceSelectionName("No selection"); setReferenceDetailsHtml(REFERENCE_NO_MATCHES_HTML); + referenceCopyButton.setEnabled(false); referenceInsertButton.setEnabled(false); } } @@ -631,11 +757,15 @@ private void filterReferenceItems() { private void updateReferenceSelectionDetails() { ReferenceItem item = referenceList.getSelectedValue(); if (item == null) { + setReferenceSelectionName("Select an entry."); setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); + referenceCopyButton.setEnabled(false); referenceInsertButton.setEnabled(false); return; } + setReferenceSelectionName(item.name); setReferenceDetailsHtml(item.details); + referenceCopyButton.setEnabled(true); referenceInsertButton.setEnabled(true); } @@ -647,5 +777,54 @@ private void insertSelectedReference() { context.commands().insertText(item.insertText, item.caretOffset); } + private void copySelectedSymbolName() { + ReferenceItem item = referenceList.getSelectedValue(); + if (item == null || item.name == null || item.name.isBlank()) { + return; + } + StringSelection selection = new StringSelection(item.name); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + } + + private void setReferenceSelectionName(String name) { + referenceSelectionName = (name == null || name.isBlank()) ? "Select an entry." : name; + updateReferenceSelectionNameLabel(); + } + + private void updateReferenceSelectionNameLabel() { + String full = referenceSelectionName == null ? "" : referenceSelectionName; + referenceSelectionNameLabel.setToolTipText(full.isBlank() ? null : full); + int availableWidth = referenceSelectionNameLabel.getWidth(); + if (availableWidth <= 0) { + referenceSelectionNameLabel.setText(full); + return; + } + FontMetrics metrics = referenceSelectionNameLabel.getFontMetrics(referenceSelectionNameLabel.getFont()); + referenceSelectionNameLabel.setText(ellipsizeText(full, metrics, availableWidth)); + } + + private String ellipsizeText(String text, FontMetrics metrics, int maxWidth) { + if (text == null || text.isEmpty() || metrics.stringWidth(text) <= maxWidth) { + return text == null ? "" : text; + } + String ellipsis = "..."; + int ellipsisWidth = metrics.stringWidth(ellipsis); + if (ellipsisWidth >= maxWidth) { + return ellipsis; + } + int low = 0; + int high = text.length(); + while (low < high) { + int mid = (low + high + 1) / 2; + String candidate = text.substring(0, mid) + ellipsis; + if (metrics.stringWidth(candidate) <= maxWidth) { + low = mid; + } else { + high = mid - 1; + } + } + return text.substring(0, Math.max(0, low)) + ellipsis; + } + } diff --git a/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java b/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java new file mode 100644 index 00000000..9493e72a --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java @@ -0,0 +1,32 @@ +package com.basic4gl.desktop.util; + +import javax.swing.*; +import java.awt.*; + +public class RoundedCardPanel extends JPanel { + public static final int DEFAULT_ARC = 14; + + private final int arc; + + public RoundedCardPanel() { + this(DEFAULT_ARC); + } + + public RoundedCardPanel(int arc) { + this.arc = arc; + setOpaque(false); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(getBackground()); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc); + } finally { + g2.dispose(); + } + super.paintComponent(g); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java index afdc6329..612bc17b 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java @@ -35,4 +35,14 @@ public static Icon buildImageThumbnailIcon(File file, int maxWidth, int maxHeigh return null; } } + + + public static Icon createScaledIcon(String iconPath, int size) { + ImageIcon icon = createImageIcon(iconPath); + if (icon == null) { + return null; + } + Image scaled = icon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH); + return new ImageIcon(scaled); + } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java index 95470cea..36f1ab07 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java @@ -1,6 +1,9 @@ package com.basic4gl.desktop.util; +import com.formdev.flatlaf.FlatClientProperties; + import javax.swing.*; +import java.awt.*; public class SwingUtil { @@ -10,4 +13,27 @@ public static void configureSmoothScrolling(JScrollPane scrollPane) { scrollPane.getViewport().setScrollMode(JViewport.BLIT_SCROLL_MODE); scrollPane.setWheelScrollingEnabled(true); } + + public static void hideSplitPaneHandle(JSplitPane splitPane) { + if (splitPane == null) { + return; + } + + splitPane.putClientProperty( + FlatClientProperties.STYLE, + "style: plain"); + + splitPane.setOneTouchExpandable(false); + } + + public static Color createLighterPanelBackground() { + Color base = UIManager.getColor("Panel.background"); + if (base == null) { + base = new Color(238, 238, 238); + } + return new Color( + Math.min(255, base.getRed() + 8), + Math.min(255, base.getGreen() + 8), + Math.min(255, base.getBlue() + 8)); + } } diff --git a/app/src/main/java/com/basic4gl/desktop/vmview/VirtualMachineViewDialog.java b/app/src/main/java/com/basic4gl/desktop/vmview/VirtualMachineViewDialog.java index b75fcea6..d8d1392b 100644 --- a/app/src/main/java/com/basic4gl/desktop/vmview/VirtualMachineViewDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/vmview/VirtualMachineViewDialog.java @@ -2,6 +2,7 @@ import static com.basic4gl.desktop.Theme.*; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; @@ -303,6 +304,7 @@ private JPanel createVariablesGroup() { JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, tableScrollPane, detailPanel); splitPane.setResizeWeight(0.7); splitPane.setOneTouchExpandable(true); + hideSplitPaneHandle(splitPane); panel.add(splitPane, BorderLayout.CENTER); panel.add(variablesStatusLabel, BorderLayout.SOUTH); diff --git a/app/src/main/resources/images/material/icon_chevron_down.png b/app/src/main/resources/images/material/icon_chevron_down.png new file mode 100644 index 0000000000000000000000000000000000000000..ec618eb19dfefd841d317889db51d02e1abd739e GIT binary patch literal 336 zcmV-W0k8gvP)|0 zRlU7}C!gBj?t<$Tz4(gS|GkfUBW8c`^A^Yb$?ZIj6^&h%_kTSB_JUyB%Hzo6xCRjH z0S$KMtxo^vT7Wh73Xb4#-9O)5gT*w)5HK;KKxJuP^li9^0$a^4LJqlxE5kJ)dodk! zDFA3ggRZhp{9+Qb#vVE3dU?};k4^$Z!%b1FgJJ`qX*oRg4EiB{>(W`y20c!z(07~s zjhM9y!$(O5e9mbp%p_(lC6f&(Mqws#YCdF;Dq7~%2IXy3;(A4uYbh6x00030|E{@< i9smFU21!IgR09AO>`XCk`bydW0000j{6r8$0WY%VZvGl?Y}`^BuoWzim8}2 zv-6G28pLUXOjLjNf^Q}kRI{S&imm_v0RR6-P|!XA000I_ cL_t&o0KmOmF-m#*NdN!<07*qoM6N<$g7^`QkpKVy literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_dots_vertical.png b/app/src/main/resources/images/material/icon_dots_vertical.png new file mode 100644 index 0000000000000000000000000000000000000000..2168c134f3c8f3e47492de135e8872f5505f642e GIT binary patch literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj{hlt4Ar*{oLJsmaDDdd$C6~z6 z>ns%J5Pl)_=Z^cfgI*6kqk8{&Y3yBcHnwQzg@fnaStFW{iru{FTUKZ&o-}QzP6z7& z75)joVkE`cmYrmkSadByZ^D@_6~Wgmhd6#}i*f9>KJ<)z&BtRlrnw!g8GnI77eqcj z3o0^k&0P+}`LA4pL+1Y2)os6f`yp|li~j$Qa|$pAGT1;)V`lKR@-sYn5P)WpCupeBts#OtGU{Un5kaT9=>ANG+IPXp)NQXAU{t4TTJLyBa85Yr7;ti&7CJy-Y$+h3F{s zRvcIX)Cozm5D(_4>Rrrn#}0RPF2jdhBbM{fKV$+)JBfseC0E zi8yB~bB!!~9s5}!IyjT5c}pTEu?~rmpy9i?B-BJiQsHfoKbLN%~&j z6WB(2=pVutGu8?&=PJ%+tp5On-Fz+_prtH^FE>B5wHJM z-Y%Fz1ll+I;idkmldxCgF+I>F(8*d)tCcN|s<+#Ensg6h;(LgRu+T*9H0$?+13*-xl?AKesK zh4tA{PraFsE2Ya4lsdTB{ePd}7XSbN|NpX?cqae=00v1!K~w_(g|zT7>(H7k00000 LNkvXXu0mjfeSk#O literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_search.png b/app/src/main/resources/images/material/icon_search.png new file mode 100644 index 0000000000000000000000000000000000000000..3920baa2ad5ddb7498dda711213c9a7bc6bddf37 GIT binary patch literal 694 zcmV;n0!jUeP) z3ts#SM8tv!(IO&f588u&fZ`#N?v67}#jMGu)wY)o@0*#tnfE@sZ|50ovTb32>kS-r zv-Ym*4HZN`M1@q=-brQp5Bk87I+QtEO@; z0+2=Rnly%=s*h91^h9$EKB>N*q5#GbEl%ukKB|vX=_fv{nQ@5og95j2>WEgHe>M0%?_qhp51>ooo>*UbiKh|7-vrMy3W^*lDO{B!bpMi z`VGBc(ov4wsl0T`LUW?INRA`lhi=Mzl7VNksLS<+KD%kVTjJPJ1<*>pU?!k8fbPjT z6aXxVNu|U-D0wm{ZLH-*6v9&vTN?t<&1R?U7nR19+Au)nrj_xg1AYMj0RR73RF@(E000I_ cL_t&o09N?mG3;~dt^fc407*qoM6N<$f>HuMIRF3v literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_view_grid.png b/app/src/main/resources/images/material/icon_view_grid.png new file mode 100644 index 0000000000000000000000000000000000000000..6dc4b50f641c10bddf449a0af8f3adea93328f4f GIT binary patch literal 232 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gjb3I)gLn;{WObX;`aNuxRon6-D zCop$GaLKtxwLB*t{Sut*ba261{*oi9zkd9VY%ab1-s4uuGqx0 z^Y|5u4{DXa!yY$yZ+PXKFIm)oy7Yjm%cT}UXI~k^>x|VsM_e?LRvh%$$#-R{ZPd%X zz0(x)U;nvovEZ}DRf`8gYpU{eoZl7hzopr00H)1KmY&$ literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_view_list.png b/app/src/main/resources/images/material/icon_view_list.png new file mode 100644 index 0000000000000000000000000000000000000000..e551a6748615f3757f448dc016ae873506220571 GIT binary patch literal 288 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjS3O-ELn;{GOfuvWCRkKcz5o-CsvstA>uF93mWAbI+DN}Cw zu}+vXXOX}67156#O#*d#nhj4TD73IUDCsN;U(zr~Dbn-*n>8!`o1FjupE(ZbumArY ic3zwSWUzre$jsm{gVo{c-TZSv5e83JKbLh*2~7aR)o|qi literal 0 HcmV?d00001 From 222f7a6f1715df118934e8883c99e90a69ad24e0 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Tue, 14 Jul 2026 00:53:21 -0400 Subject: [PATCH 19/38] reference panel cleanup --- .../java/com/basic4gl/desktop/MainWindow.java | 5 +- .../desktop/panels/SymbolsPanelProvider.java | 70 ++++--------------- 2 files changed, 16 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 1308b402..62351861 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -543,11 +543,10 @@ public void onStepOutRequested() { toolBar.add(newButton); toolBar.add(openButton); toolBar.add(saveButton); - toolBar.addSeparator(); + toolBar.add(Box.createHorizontalGlue()); + toolBar.add(runTargetCombo); toolBar.add(runButton); toolBar.addSeparator(); - toolBar.add(runTargetCombo); - toolBar.add(Box.createHorizontalGlue()); toolBar.add(exportButton); toolBar.add(settingsButton); diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java index 4cd80ea9..92752130 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -16,8 +16,6 @@ import javax.swing.event.DocumentListener; import java.awt.*; import java.awt.datatransfer.StringSelection; -import java.awt.event.ComponentAdapter; -import java.awt.event.ComponentEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.*; @@ -157,12 +155,12 @@ public JPanel build(PluginContext context) { referenceCopyButton.setFocusable(false); referenceCopyButton.setMargin(new Insets(4, 4, 4, 4)); Font actionButtonFont = referenceCopyButton.getFont(); - referenceCopyButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, actionButtonFont.getSize())); +// referenceCopyButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, actionButtonFont.getSize())); referenceCopyButton.setForeground(new Color(0x5B717F)); referenceCopyButton.setEnabled(false); referenceInsertButton.setFocusable(false); referenceInsertButton.setMargin(new Insets(4, 4, 4, 4)); - referenceInsertButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, actionButtonFont.getSize())); +// referenceInsertButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, actionButtonFont.getSize())); referenceInsertButton.setForeground(new Color(0x5B717F)); referenceInsertButton.setEnabled(false); @@ -215,12 +213,9 @@ public Component getListCellRendererComponent( setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); Font nameFont = referenceSelectionNameLabel.getFont(); referenceSelectionNameLabel.setFont(new Font(nameFont.getName(), Font.BOLD, nameFont.getSize())); - referenceSelectionNameLabel.addComponentListener(new ComponentAdapter() { - @Override - public void componentResized(ComponentEvent e) { - updateReferenceSelectionNameLabel(); - } - }); + int titleHeight = referenceSelectionNameLabel.getPreferredSize().height; + referenceSelectionNameLabel.setMinimumSize(new Dimension(0, titleHeight)); + referenceSelectionNameLabel.setPreferredSize(new Dimension(0, titleHeight)); setReferenceSelectionName("Select an entry."); JSplitPane lookupSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); @@ -231,20 +226,16 @@ public void componentResized(ComponentEvent e) { JScrollPane listScrollPane = new JScrollPane(referenceList); listScrollPane.setBorder(null); JPanel detailsPanel = new JPanel(new BorderLayout(0, 0)); - JToolBar detailsHeader = new JToolBar(); - detailsHeader.setFloatable(false); - detailsHeader.setRollover(true); + JPanel detailsHeader = new JPanel(new BorderLayout(8, 0)); detailsHeader.setBackground(panelBackground); detailsHeader.setOpaque(true); detailsHeader.setBorder(new EmptyBorder(6, 8, 4, 8)); - JPanel detailsTitleHost = new JPanel(new BorderLayout()); - detailsTitleHost.setOpaque(false); - detailsTitleHost.add(referenceSelectionNameLabel, BorderLayout.CENTER); - detailsTitleHost.setMaximumSize(new Dimension(Integer.MAX_VALUE, referenceSelectionNameLabel.getPreferredSize().height + 4)); - detailsHeader.add(detailsTitleHost); - detailsHeader.add(Box.createHorizontalGlue()); - detailsHeader.add(referenceCopyButton); - detailsHeader.add(referenceInsertButton); + detailsHeader.add(referenceSelectionNameLabel, BorderLayout.CENTER); + JPanel detailsActions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + detailsActions.setOpaque(false); + detailsActions.add(referenceCopyButton); + detailsActions.add(referenceInsertButton); + detailsHeader.add(detailsActions, BorderLayout.EAST); detailsPanel.add(detailsHeader, BorderLayout.NORTH); detailsPanel.add(referenceDetailsPane, BorderLayout.CENTER); JScrollPane detailsScrollPane = new JScrollPane(detailsPanel); @@ -788,43 +779,10 @@ private void copySelectedSymbolName() { private void setReferenceSelectionName(String name) { referenceSelectionName = (name == null || name.isBlank()) ? "Select an entry." : name; - updateReferenceSelectionNameLabel(); - } - - private void updateReferenceSelectionNameLabel() { - String full = referenceSelectionName == null ? "" : referenceSelectionName; - referenceSelectionNameLabel.setToolTipText(full.isBlank() ? null : full); - int availableWidth = referenceSelectionNameLabel.getWidth(); - if (availableWidth <= 0) { - referenceSelectionNameLabel.setText(full); - return; - } - FontMetrics metrics = referenceSelectionNameLabel.getFontMetrics(referenceSelectionNameLabel.getFont()); - referenceSelectionNameLabel.setText(ellipsizeText(full, metrics, availableWidth)); + referenceSelectionNameLabel.setText(referenceSelectionName); + referenceSelectionNameLabel.setToolTipText(referenceSelectionName.isBlank() ? null : referenceSelectionName); } - private String ellipsizeText(String text, FontMetrics metrics, int maxWidth) { - if (text == null || text.isEmpty() || metrics.stringWidth(text) <= maxWidth) { - return text == null ? "" : text; - } - String ellipsis = "..."; - int ellipsisWidth = metrics.stringWidth(ellipsis); - if (ellipsisWidth >= maxWidth) { - return ellipsis; - } - int low = 0; - int high = text.length(); - while (low < high) { - int mid = (low + high + 1) / 2; - String candidate = text.substring(0, mid) + ellipsis; - if (metrics.stringWidth(candidate) <= maxWidth) { - low = mid; - } else { - high = mid - 1; - } - } - return text.substring(0, Math.max(0, low)) + ellipsis; - } } From 00c0a5bddea02eaeb1290acf382bc2841a6ea451 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Tue, 14 Jul 2026 01:23:57 -0400 Subject: [PATCH 20/38] implement bookmarks panel --- .../basic4gl/desktop/spi/BookmarkInfo.java | 3 + .../desktop/spi/EditorCommandsService.java | 3 + .../java/com/basic4gl/desktop/MainWindow.java | 46 +++ .../main/java/com/basic4gl/desktop/Theme.java | 3 + .../basic4gl/desktop/editor/FileEditor.java | 62 +++- .../editor/IFileEditorActionListener.java | 1 + .../panels/BookmarksPanelProvider.java | 270 ++++++++++++++++-- .../images/material/icon_arrow_down.png | Bin 0 -> 374 bytes .../images/material/icon_arrow_up.png | Bin 0 -> 375 bytes .../images/material/icon_bookmark_add.png | Bin 0 -> 426 bytes .../images/material/menu_help_solid.png | Bin 0 -> 819 bytes 11 files changed, 369 insertions(+), 19 deletions(-) create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/BookmarkInfo.java create mode 100644 app/src/main/resources/images/material/icon_arrow_down.png create mode 100644 app/src/main/resources/images/material/icon_arrow_up.png create mode 100644 app/src/main/resources/images/material/icon_bookmark_add.png create mode 100644 app/src/main/resources/images/material/menu_help_solid.png diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/BookmarkInfo.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/BookmarkInfo.java new file mode 100644 index 00000000..61bd8984 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/BookmarkInfo.java @@ -0,0 +1,3 @@ +package com.basic4gl.desktop.spi; + +public record BookmarkInfo(String filePath, String fileName, int lineNumber, String lineText) {} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java index e8613089..5602e014 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java @@ -1,6 +1,7 @@ package com.basic4gl.desktop.spi; import java.io.File; +import java.util.List; public interface EditorCommandsService { void openFileWithPreferredViewer(File file); @@ -11,6 +12,8 @@ public interface EditorCommandsService { void selectNextBookmark(); void selectPreviousBookmark(); void toggleBookmark(); + List listBookmarks(); + void goToBookmark(String filePath, int lineNumber); void setWorkspaceDirectory(File selectedFile); diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 62351861..652402c7 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -632,6 +632,7 @@ protected void installDefaults() { } fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); + refreshSidebarContent(); // Refresh controls if no files open if (fileManager.editorCount() == 0) { @@ -2510,6 +2511,44 @@ public void toggleBookmark() { fileManager.toggleBookmark(tabControl.getSelectedIndex()); } + @Override + public List listBookmarks() { + List bookmarks = new ArrayList<>(); + for (FileEditor editor : fileManager.getFileEditors()) { + if (editor == null) { + continue; + } + String filePath = editor.getFilePath(); + String fileName = editor.getShortFilename(); + for (FileEditor.BookmarkLine bookmark : editor.getBookmarks()) { + bookmarks.add(new BookmarkInfo(filePath, fileName, bookmark.lineNumber(), bookmark.lineText())); + } + } + bookmarks.sort(Comparator.comparing(BookmarkInfo::fileName, String.CASE_INSENSITIVE_ORDER) + .thenComparingInt(BookmarkInfo::lineNumber)); + return bookmarks; + } + + @Override + public void goToBookmark(String filePath, int lineNumber) { + int index = getTabIndex(filePath); + if (index == -1 && filePath != null && !filePath.isBlank()) { + File file = new File(filePath); + if (file.exists()) { + addTab(FileEditor.open(file, this, fileManager, this, linkGenerator, searchContext)); + index = getTabIndex(filePath); + } + } + if (index < 0 || index >= fileManager.getFileEditors().size()) { + return; + } + tabControl.setSelectedIndex(index); + FileEditor editor = fileManager.getFileEditors().get(index); + if (editor != null) { + editor.goToLine(lineNumber); + } + } + public void openMarkdownInDocsTab(File file) { File resolved = file.isAbsolute() ? file : new File(fileManager.getCurrentDirectory(), file.getPath()); @@ -2714,6 +2753,13 @@ public void onSearchResult(String message) { setCompilerStatus(message); } + @Override + public void onBookmarksChanged(String filePath) { + for (IEditorPanelProvider panel : panels) { + panel.onFileModified(filePath); + } + } + @Override public void onNewClick() { actionNew(); diff --git a/app/src/main/java/com/basic4gl/desktop/Theme.java b/app/src/main/java/com/basic4gl/desktop/Theme.java index 8f4e7eb0..4304ffeb 100644 --- a/app/src/main/java/com/basic4gl/desktop/Theme.java +++ b/app/src/main/java/com/basic4gl/desktop/Theme.java @@ -23,6 +23,9 @@ public class Theme { public static final String ICON_VIEW_LIST = THEME_DIRECTORY + "icon_view_list.png"; public static final String ICON_CHEVRON_DOWN = THEME_DIRECTORY + "icon_chevron_down.png"; public static final String ICON_SEARCH = THEME_DIRECTORY + "icon_search.png"; + public static final String ICON_ARROW_DOWN = THEME_DIRECTORY + "icon_arrow_down.png"; + public static final String ICON_ARROW_UP = THEME_DIRECTORY + "icon_arrow_up.png"; + public static final String ICON_BOOKMARK_ADD = THEME_DIRECTORY + "icon_bookmark_add.png"; public static final String ICON_MENU_FOLDER = THEME_DIRECTORY + "menu_folder.png"; public static final String ICON_MENU_ASSETS = THEME_DIRECTORY + "menu_assets.png"; public static final String ICON_MENU_BOOKMARKS = THEME_DIRECTORY + "menu_bookmarks.png"; diff --git a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java b/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java index 27d19489..45c298c8 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java @@ -11,6 +11,7 @@ import java.io.FileWriter; import java.io.IOException; import java.util.ArrayList; +import java.util.List; import javax.swing.*; import javax.swing.filechooser.FileNameExtensionFilter; import javax.swing.text.BadLocationException; @@ -53,6 +54,8 @@ public class FileEditor implements SearchListener { protected boolean isModified; protected boolean isSaved; // File exists on system + public record BookmarkLine(int lineNumber, String lineText) {} + public FileEditor( IFileEditorActionListener actionListener, IFileManager fileManager, @@ -93,7 +96,7 @@ public FileEditor( RTextAreaEditorKit.rtaPrevBookmarkAction); inputMap.put( KeyStroke.getKeyStroke(KeyEvent.VK_F2, toolkit.getMenuShortcutKeyMask()), - RTextAreaEditorKit.rtaToggleBookmarkAction); + "B4GL.ToggleBookmarkAction"); inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0), "RTA.NextBreakpointAction"); inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.SHIFT_MASK), "RTA.PrevBreakpointAction"); @@ -114,6 +117,14 @@ public FileEditor( RTextAreaEditorKit.rtaToggleBookmarkAction, new MultiHeaderBookmarkActions.MultiHeaderToggleBookmarkAction( RTextAreaEditorKit.rtaToggleBookmarkAction, HEADER_BOOKMARK)); + actionMap.put( + "B4GL.ToggleBookmarkAction", + new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + FileEditor.this.toggleBookmark(); + } + }); actionMap.put( "RTA.NextBreakpointAction", @@ -146,6 +157,7 @@ public void mouseReleased(MouseEvent e) { if (gutter.getBookmarks(HEADER_BOOKMARK).length == 0) { scrollPane.setIconRowHeaderEnabled(HEADER_BOOKMARK, false); } + actionListener.onBookmarksChanged(getFilePath()); } @Override @@ -562,6 +574,54 @@ public void toggleBookmark() { ex.printStackTrace(); System.out.println(editorPane.getCaretPosition()); } + actionListener.onBookmarksChanged(getFilePath()); + } + + public List getBookmarks() { + ArrayList points = new ArrayList<>(); + MultiHeaderGutter gutter = scrollPane.getGutter(); + if (gutter == null) { + return points; + } + GutterIconInfo[] bookmarks = gutter.getBookmarks(HEADER_BOOKMARK); + for (GutterIconInfo info : bookmarks) { + try { + int line = editorPane.getLineOfOffset(info.getMarkedOffset()); + String lineText = getLineText(line); + points.add(new BookmarkLine(line, lineText)); + } catch (BadLocationException ex) { + ex.printStackTrace(); + } + } + return points; + } + + public void goToLine(int lineNumber) { + int safeLine = Math.max(0, lineNumber); + try { + int maxLine = Math.max(0, editorPane.getLineCount() - 1); + safeLine = Math.min(safeLine, maxLine); + int offset = editorPane.getLineStartOffset(safeLine); + if (editorPane.isCodeFoldingEnabled()) { + editorPane.getFoldManager().ensureOffsetNotInClosedFold(offset); + } + editorPane.requestFocusInWindow(); + editorPane.setCaretPosition(offset); + } catch (BadLocationException ble) { + UIManager.getLookAndFeel().provideErrorFeedback(editorPane); + ble.printStackTrace(); + } + } + + private String getLineText(int line) { + try { + int start = editorPane.getLineStartOffset(line); + int end = editorPane.getLineEndOffset(line); + String text = editorPane.getText(start, Math.max(0, end - start)); + return text == null ? "" : text.strip(); + } catch (BadLocationException ex) { + return ""; + } } public void gotoNextBreakpoint(boolean forward) { diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java b/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java index 8e202597..53abecb7 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java @@ -2,4 +2,5 @@ public interface IFileEditorActionListener { void onSearchResult(String message); + void onBookmarksChanged(String filePath); } diff --git a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java index e878567f..d50417a4 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java @@ -1,13 +1,33 @@ package com.basic4gl.desktop.panels; +import com.basic4gl.desktop.spi.BookmarkInfo; import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.RoundedCardPanel; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; -import static com.basic4gl.desktop.Theme.ICON_MENU_BOOKMARKS; +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; public class BookmarksPanelProvider implements IEditorPanelProvider { + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + + private final List allBookmarks = new ArrayList<>(); + private final DefaultListModel bookmarkListModel = new DefaultListModel<>(); + private final JList bookmarkList = new JList<>(bookmarkListModel); + private final JTextField bookmarkSearchField = new JTextField(); + private PluginContext context; + @Override public String id() { return "bookmarks"; @@ -30,41 +50,255 @@ public EditorLayout getLayoutConstraints() { @Override public JPanel build(PluginContext context) { - JPanel panel = new JPanel(); - panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + this.context = context; + + JPanel panelCardHost = new JPanel(new CardLayout()); + JPanel panel = new JPanel(new BorderLayout(0, 6)); + Color panelBackground = createLighterPanelBackground(); + panel.setBackground(panelBackground); + panel.setOpaque(true); + + JPanel header = new JPanel(); + header.setLayout(new BoxLayout(header, BoxLayout.X_AXIS)); + header.setBackground(panelBackground); + header.setOpaque(true); + + JLabel title = new JLabel("Bookmarks"); + Font baseFont = title.getFont(); + title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + title.setForeground(new Color(0x424242)); + title.setBorder(new EmptyBorder(0, 8, 0, 8)); + + JButton toggleBookmarkButton = createHeaderIconButton(ICON_BOOKMARK_ADD, "Toggle bookmark"); + toggleBookmarkButton.addActionListener(e -> { + if (this.context != null && this.context.commands() != null) { + this.context.commands().toggleBookmark(); + reloadBookmarks(); + } + }); + + JButton nextBookmarkButton = createHeaderIconButton(ICON_ARROW_DOWN, "Next bookmark"); + nextBookmarkButton.addActionListener(e -> { + if (this.context != null && this.context.commands() != null) { + this.context.commands().selectNextBookmark(); + reloadBookmarks(); + } + }); + + JButton previousBookmarkButton = createHeaderIconButton(ICON_ARROW_UP, "Previous bookmark"); + previousBookmarkButton.addActionListener(e -> { + if (this.context != null && this.context.commands() != null) { + this.context.commands().selectPreviousBookmark(); + reloadBookmarks(); + } + }); + + JToggleButton searchToggle = createHeaderSearchToggleButton(); + header.add(title); + header.add(Box.createHorizontalGlue()); + header.add(toggleBookmarkButton); + header.add(Box.createHorizontalStrut(4)); + header.add(nextBookmarkButton); + header.add(Box.createHorizontalStrut(4)); + header.add(previousBookmarkButton); + header.add(Box.createHorizontalStrut(4)); + header.add(searchToggle); + panel.add(header, BorderLayout.NORTH); + + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + bookmarkSearchField.setToolTipText("Search bookmarks"); + searchBar.add(bookmarkSearchField, BorderLayout.CENTER); + searchBar.setVisible(false); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + bookmarkSearchField.requestFocusInWindow(); + } + panel.revalidate(); + panel.repaint(); + }); + bookmarkSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + filterBookmarks(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + filterBookmarks(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + filterBookmarks(); + } + }); + + bookmarkList.setBackground(panelBackground); + bookmarkList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + bookmarkList.setFixedCellHeight(22); + bookmarkList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof BookmarkInfo bookmark) { + String preview = bookmark.lineText() == null ? "" : bookmark.lineText(); + String text = bookmark.fileName() + ":" + (bookmark.lineNumber() + 1); + if (!preview.isBlank()) { + text += " " + preview; + } + label.setText(text); + label.setToolTipText(bookmark.filePath()); + } + return label; + } + }); + bookmarkList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + goToSelectedBookmark(); + } + } + }); + bookmarkList.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke("ENTER"), "openBookmark"); + bookmarkList.getActionMap().put("openBookmark", new AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + goToSelectedBookmark(); + } + }); + + JPanel content = new JPanel(new BorderLayout(0, 6)); + content.setBackground(panelBackground); + content.add(searchBar, BorderLayout.NORTH); + + JScrollPane scrollPane = new JScrollPane(bookmarkList); + scrollPane.setBackground(panelBackground); + scrollPane.setBorder(null); + content.add(scrollPane, BorderLayout.CENTER); - JButton next = new JButton("Next bookmark"); - next.addActionListener(e -> context.commands().selectNextBookmark()); - JButton previous = new JButton("Previous bookmark"); - previous.addActionListener(e -> context.commands().selectPreviousBookmark()); - JButton toggle = new JButton("Toggle bookmark"); - toggle.addActionListener(e -> context.commands().toggleBookmark()); + panel.add(content, BorderLayout.CENTER); + panelCardHost.add(createRoundedCardHost(panel, panelBackground, "bookmarks-main"), "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); - panel.add(next); - panel.add(previous); - panel.add(toggle); - return panel; + reloadBookmarks(); + return panelCardHost; } @Override public void refresh(EditorPlugin languageProvider) { - + reloadBookmarks(); } @Override public void onFileModified(String filePath) { - + reloadBookmarks(); } @Override - public void dispose() { + public void dispose() {} + + @Override + public void onCompileSucceeded() {} + private void reloadBookmarks() { + if (context == null || context.commands() == null) { + return; + } + allBookmarks.clear(); + allBookmarks.addAll(context.commands().listBookmarks()); + filterBookmarks(); } - @Override - public void onCompileSucceeded() { + private void filterBookmarks() { + String needle = bookmarkSearchField.getText() == null + ? "" + : bookmarkSearchField.getText().trim().toLowerCase(Locale.ROOT); + BookmarkInfo previousSelection = bookmarkList.getSelectedValue(); + bookmarkListModel.clear(); + for (BookmarkInfo item : allBookmarks) { + String haystack = (item.fileName() + " " + item.lineText()).toLowerCase(Locale.ROOT); + if (needle.isEmpty() || haystack.contains(needle)) { + bookmarkListModel.addElement(item); + } + } + + if (!bookmarkListModel.isEmpty()) { + if (previousSelection != null) { + bookmarkList.setSelectedValue(previousSelection, true); + } else { + bookmarkList.setSelectedIndex(0); + } + } + } + private void goToSelectedBookmark() { + if (context == null || context.commands() == null) { + return; + } + BookmarkInfo item = bookmarkList.getSelectedValue(); + if (item == null) { + return; + } + context.commands().goToBookmark(item.filePath(), item.lineNumber()); } + private JButton createHeaderIconButton(String iconPath, String tooltip) { + JButton button = new JButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private Color createLighterPanelBackground() { + Color base = UIManager.getColor("Panel.background"); + if (base == null) { + base = new Color(238, 238, 238); + } + return new Color( + Math.min(255, base.getRed() + 8), + Math.min(255, base.getGreen() + 8), + Math.min(255, base.getBlue() + 8)); + } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } } diff --git a/app/src/main/resources/images/material/icon_arrow_down.png b/app/src/main/resources/images/material/icon_arrow_down.png new file mode 100644 index 0000000000000000000000000000000000000000..7178fd6a84dd30ee878b8f1f3211d781becbdc40 GIT binary patch literal 374 zcmV-+0g3*JP)jX#+c+`kymUk<9W~I3bMFW6}{QyKobe#jCMnnuf5ZfYH13+|^oDg5KoJcz0@6w_E-$>L=CCzSh^#1pdS|rLu5SqG?)~-M zH-McwO@DlJ`w>7#y{(=_^|I@`z;1U}!!y2fkMTVTpmo2iUKj|-CyPDLkg>7=%;^Kw zUZ}O_eb2*;$_6m2-c_%M`|6E-ZjA!uhWfhy5BLHA0RR8}9N(Y-000I_L_t&o0HSSv UG4XA-(EtDd07*qoM6N<$g0#e)o&W#< literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_arrow_up.png b/app/src/main/resources/images/material/icon_arrow_up.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f1bdce76c46208a487f0e7a4738818843264dd GIT binary patch literal 375 zcmV--0f_#IP)*p%cHOb|Yp;CuNcFt$} zz2~!|Gk;fs@Crx`Ps8mOS#9eeZ^v8cde?cbQF(UsgV z(`WOw0jbf{mG{V5O?$>f1_eMaLO)8ttmJi7eo*H_4mImh5nwtkcI^q|>*75{Zb1rfSv#vnrgGhImcs| zL4i5MsotwVYD08cEBf7*3{Q?7G+fpTg25(-XMe!~00960sYOnP00006Nkl{Z}hi20UT=3Kr8j0U?RK* z^1mNG-CdVOQT$DGI0Q7%qQ@y!TdzS?nbU3I215V<0RR7$LN`PJ000I_L_t&o0O@F; UF{B7XmjD0&07*qoM6N<$g0%OrZ2$lO literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/menu_help_solid.png b/app/src/main/resources/images/material/menu_help_solid.png new file mode 100644 index 0000000000000000000000000000000000000000..37bfa1fb5a70d8889d2c9ee4aba2d156109565a6 GIT binary patch literal 819 zcmV-31I+x1P)Ec_Z5h?t3=)Jz-w-@%eSh8q%DN>BA)DSE1?UM z3IX2w8AQ6x`9_BA1doAoEkVMS0vQ9~7!*w-KtpPalszgDklTU)X**?K5%7s=^tPHt z-}sQBW&|=eb#)K`pFxSyK&(UU_EN5ZQcC07k|Cga?*c-&B6EJwNB9B16GS@{KM!UR zQhfEg21J8$I2_Uyn4YorFj=C`bI1mNyqCxiTAGD<97du6jzFRLDT!s+R=+O1-{D*V zK&)ski$d83K9)ViV`b)rN zP1Z%2E)>i(|C78=F zJ^|oI#xVbzoem&2nR@TO(W~kURI#c8&ic&t{wXt^y2Fi*Gd7sI+Q>MOG1T3AX;t&o x)f8}q790Nn00960V4U*g00006Nkl7o|pgt002ovPDHLkV1iu1dwT!? literal 0 HcmV?d00001 From a70544b2d51efe675fff3838de04afd96a6b1ea4 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Tue, 14 Jul 2026 01:47:26 -0400 Subject: [PATCH 21/38] fix panel collapsing state --- .../java/com/basic4gl/desktop/MainWindow.java | 186 +++++++++++++----- 1 file changed, 133 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 652402c7..5c470f2a 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -49,12 +49,12 @@ */ public class MainWindow implements IEditorPresenter, - ITabProvider, - IToggleBreakpointListener, - IFileEditorActionListener, - IFileManagerListener, - EmptyTabPanel.IEmptyTabPanelListener, - MenuService, + ITabProvider, + IToggleBreakpointListener, + IFileEditorActionListener, + IFileManagerListener, + EmptyTabPanel.IEmptyTabPanelListener, + MenuService, EditorCommandsService { private final CaretListener TrackCaretPosition = new CaretListener() { @@ -114,6 +114,12 @@ public void caretUpdate(CaretEvent e) { private int expandedLeftSidebarWidth = 260; private int expandedRightDocsWidth = 320; private int expandedBottomBarHeight = 220; + private int workspacePaneDividerSize; + private int contentPaneDividerSize; + private int mainPaneDividerSize; + private boolean leftSidebarCollapsed; + private boolean rightDocsCollapsed; + private boolean bottomBarCollapsed; private String activeLeftSidebarKey = "files"; private String activeRightDocsKey; private String activeBottomBarKey; @@ -247,6 +253,7 @@ public static void main(String[] args) { System.setProperty("com.apple.mrj.application.apple.menu.about.name", "Basic4GLj"); FlatLightLaf.setup(); + UIManager.put("SplitPaneDivider.style", "plain"); PrintStream out = null; try { @@ -594,7 +601,6 @@ public void onStepOutRequested() { UIManager.put("TabbedPane.closeIcon", new FlatTabbedPaneCloseIcon()); UIManager.put("TabbedPane.selectedBackground", Color.white); - UIManager.put("SplitPaneDivider.gripColor", new Color(0, 0, 0, 0)); SwingUtilities.updateComponentTreeUI(tabControl); tabControl.setUI(new FlatTabbedPaneUI() { @@ -632,7 +638,6 @@ protected void installDefaults() { } fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); - refreshSidebarContent(); // Refresh controls if no files open if (fileManager.editorCount() == 0) { @@ -651,25 +656,18 @@ protected void installDefaults() { editorSplitPane.setResizeWeight(0.7); hideSplitPaneHandle(editorSplitPane); - editorSplitPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); - editorSplitPane.putClientProperty("JSplitPane.style", "plain"); - contentPane.setLeftComponent(primaryTabHost); contentPane.setRightComponent(rightDocsContainer); contentPane.setResizeWeight(0.74); hideSplitPaneHandle(contentPane); - - contentPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); - contentPane.putClientProperty("JSplitPane.style", "plain"); + contentPaneDividerSize = contentPane.getDividerSize(); workspacePane.setLeftComponent(leftSidebarContent); workspacePane.setRightComponent(contentPane); workspacePane.setResizeWeight(0.18); workspacePane.setDividerLocation(expandedLeftSidebarWidth); hideSplitPaneHandle(workspacePane); - - workspacePane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); - workspacePane.putClientProperty("JSplitPane.style", "plain"); + workspacePaneDividerSize = workspacePane.getDividerSize(); contentPane.setDividerLocation(Math.max(200, frame.getPreferredSize().width - expandedRightDocsWidth)); @@ -679,6 +677,7 @@ protected void installDefaults() { mainPane.setBottomComponent(bottomBarContainer); mainPane.setResizeWeight(1.0); hideSplitPaneHandle(mainPane); + mainPaneDividerSize = mainPane.getDividerSize(); leftRailsHost.setLayout(new BoxLayout(leftRailsHost, BoxLayout.Y_AXIS)); leftRailsHost.add(leftSidebarRail); @@ -1589,7 +1588,7 @@ public void addTabWithViewer(IFileViewer viewer) { } // replace emptyTabPanel if needed - setEditorContent(getActiveEditorHost()); + setEditorContent(getActiveEditorHost()); tabControl.addTab(viewer.getTitle(), viewer.getContentPane()); @@ -2171,8 +2170,8 @@ private void configureLeftSidebar() { Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) .findFirst() .ifPresent(x -> { - selectLeftSidebarSection(x.id(), true); - }); + selectLeftSidebarSection(x.id(), true); + }); } @@ -2296,59 +2295,100 @@ private void selectBottomBarSection(String key, boolean ensureExpanded) { } private boolean isLeftSidebarExpanded() { - return workspacePane.getDividerLocation() > 12; + return !leftSidebarCollapsed + && workspacePane.getLeftComponent() == leftSidebarContent; } private void collapseLeftSidebar() { - if (isLeftSidebarExpanded()) { - expandedLeftSidebarWidth = workspacePane.getDividerLocation(); + if (workspacePane.getLeftComponent() == leftSidebarContent) { + int currentWidth = leftSidebarContent.getWidth(); + if (currentWidth <= 12) { + currentWidth = workspacePane.getDividerLocation(); + } + if (currentWidth > 12) { + expandedLeftSidebarWidth = currentWidth; + } } + + leftSidebarCollapsed = true; activeLeftSidebarKey = null; leftSidebarGroup.clearSelection(); - workspacePane.setDividerLocation(0); + + workspacePane.setResizeWeight(0.0); + workspacePane.setDividerSize(0); + workspacePane.setLeftComponent(null); + workspacePane.revalidate(); + workspacePane.repaint(); } private void expandLeftSidebar() { + leftSidebarCollapsed = false; + if (workspacePane.getLeftComponent() != leftSidebarContent) { + workspacePane.setLeftComponent(leftSidebarContent); + } + workspacePane.setDividerSize(workspacePaneDividerSize); + workspacePane.setResizeWeight(0.18); + workspacePane.revalidate(); + int target = Math.max(expandedLeftSidebarWidth, 180); - workspacePane.setDividerLocation(target); + SwingUtilities.invokeLater(() -> { + if (!leftSidebarCollapsed + && workspacePane.getLeftComponent() == leftSidebarContent) { + setDividerLocationClamped(workspacePane, target); + } + }); } private boolean isBottomBarExpanded() { - if (mainPane.getBottomComponent() != bottomBarContainer || mainPane.getHeight() <= 0) { - return false; - } - int bottomHeight = mainPane.getHeight() - mainPane.getDividerLocation(); - return bottomHeight > 12; + return !bottomBarCollapsed + && mainPane.getBottomComponent() == bottomBarContainer; } private void collapseBottomBar() { - if (mainPane.getBottomComponent() != bottomBarContainer) { - mainPane.setBottomComponent(bottomBarContainer); - hideSplitPaneHandle(mainPane); - } - if (isBottomBarExpanded() && mainPane.getHeight() > 0) { - expandedBottomBarHeight = Math.max(120, mainPane.getHeight() - mainPane.getDividerLocation()); - } - if (mainPane.getHeight() > 0) { - mainPane.setDividerLocation(mainPane.getHeight()); + if (mainPane.getBottomComponent() == bottomBarContainer) { + int currentHeight = bottomBarContainer.getHeight(); + if (currentHeight <= 12 && mainPane.getHeight() > 0) { + currentHeight = mainPane.getHeight() + - mainPane.getDividerLocation() + - mainPane.getDividerSize(); + } + if (currentHeight > 12) { + expandedBottomBarHeight = Math.max(120, currentHeight); + } } + + bottomBarCollapsed = true; activeBottomBarKey = null; bottomBarGroup.clearSelection(); + + mainPane.setResizeWeight(1.0); + mainPane.setDividerSize(0); + mainPane.setBottomComponent(null); + mainPane.revalidate(); + mainPane.repaint(); syncDebugMenuSelection(); } private void expandBottomBar() { + bottomBarCollapsed = false; if (mainPane.getBottomComponent() != bottomBarContainer) { mainPane.setBottomComponent(bottomBarContainer); - hideSplitPaneHandle(mainPane); } + mainPane.setDividerSize(mainPaneDividerSize); mainPane.setResizeWeight(1.0); + mainPane.revalidate(); int targetBottomHeight = Math.max(expandedBottomBarHeight, 140); - if (mainPane.getHeight() > 0) { - int newDivider = Math.max(120, mainPane.getHeight() - targetBottomHeight); - mainPane.setDividerLocation(newDivider); - } + SwingUtilities.invokeLater(() -> { + if (!bottomBarCollapsed + && mainPane.getBottomComponent() == bottomBarContainer + && mainPane.getHeight() > 0) { + int newDivider = mainPane.getHeight() + - targetBottomHeight + - mainPane.getDividerSize(); + setDividerLocationClamped(mainPane, newDivider); + } + }); syncDebugMenuSelection(); } @@ -2390,24 +2430,64 @@ private void selectRightDocsSection(String key) { } private boolean isRightDocsExpanded() { - int docsWidth = contentPane.getWidth() - contentPane.getDividerLocation(); - return docsWidth > 12; + return !rightDocsCollapsed + && contentPane.getRightComponent() == rightDocsContainer; } private void collapseRightDocs() { - int docsWidth = contentPane.getWidth() - contentPane.getDividerLocation(); - if (docsWidth > 12) { - expandedRightDocsWidth = docsWidth; + if (contentPane.getRightComponent() == rightDocsContainer) { + int currentWidth = rightDocsContainer.getWidth(); + if (currentWidth <= 12 && contentPane.getWidth() > 0) { + currentWidth = contentPane.getWidth() + - contentPane.getDividerLocation() + - contentPane.getDividerSize(); + } + if (currentWidth > 12) { + expandedRightDocsWidth = currentWidth; + } } + + rightDocsCollapsed = true; activeRightDocsKey = null; rightDocsGroup.clearSelection(); - contentPane.setDividerLocation(contentPane.getWidth()); + + contentPane.setResizeWeight(1.0); + contentPane.setDividerSize(0); + contentPane.setRightComponent(null); + contentPane.revalidate(); + contentPane.repaint(); } private void expandRightDocs() { + rightDocsCollapsed = false; + if (contentPane.getRightComponent() != rightDocsContainer) { + contentPane.setRightComponent(rightDocsContainer); + } + contentPane.setDividerSize(contentPaneDividerSize); + contentPane.setResizeWeight(0.74); + contentPane.revalidate(); + int targetDocsWidth = Math.max(expandedRightDocsWidth, 220); - int newDivider = Math.max(120, contentPane.getWidth() - targetDocsWidth); - contentPane.setDividerLocation(newDivider); + SwingUtilities.invokeLater(() -> { + if (!rightDocsCollapsed + && contentPane.getRightComponent() == rightDocsContainer + && contentPane.getWidth() > 0) { + int newDivider = contentPane.getWidth() + - targetDocsWidth + - contentPane.getDividerSize(); + setDividerLocationClamped(contentPane, newDivider); + } + }); + } + + private void setDividerLocationClamped(JSplitPane splitPane, int requestedLocation) { + int minimum = splitPane.getMinimumDividerLocation(); + int maximum = splitPane.getMaximumDividerLocation(); + if (maximum < minimum) { + splitPane.setDividerLocation(requestedLocation); + return; + } + splitPane.setDividerLocation(Math.max(minimum, Math.min(requestedLocation, maximum))); } private void refreshSidebarContent() { @@ -2798,4 +2878,4 @@ public void addHelp(String label, com.basic4gl.desktop.spi.MenuActionListener li helpMenuItem.addActionListener(e -> listener.actionPerformed(frame, e)); helpMenu.add(helpMenuItem); } -} +} \ No newline at end of file From 24c980a06a2b274f874ce5659b4873ed11516e3f Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Tue, 14 Jul 2026 02:23:56 -0400 Subject: [PATCH 22/38] update icons --- .../java/com/basic4gl/desktop/MainWindow.java | 33 ++++++++++++------ .../main/java/com/basic4gl/desktop/Theme.java | 10 +++--- .../desktop/panels/AssetsPanelProvider.java | 12 ++++++- .../panels/BookmarksPanelProvider.java | 12 ++++++- .../desktop/panels/DebugPanelProvider.java | 19 ++++++---- .../desktop/panels/DocsPanelProvider.java | 14 +++++++- .../panels/FileBrowserPanelProvider.java | 17 ++++++--- .../desktop/panels/IEditorPanelProvider.java | 5 ++- .../desktop/panels/SymbolsPanelProvider.java | 12 ++++++- .../basic4gl/desktop/util/SwingIconUtil.java | 32 +++++++++++++++++ .../images/material/icon_new_outline.png | Bin 0 -> 282 bytes .../images/material/icon_open_outline.png | Bin 0 -> 474 bytes .../images/material/icon_save_outline.png | Bin 0 -> 434 bytes .../images/material/icon_settings_outline.png | Bin 0 -> 1061 bytes .../images/material/menu_folder_solid.png | Bin 0 -> 267 bytes 15 files changed, 135 insertions(+), 31 deletions(-) create mode 100644 app/src/main/resources/images/material/icon_new_outline.png create mode 100644 app/src/main/resources/images/material/icon_open_outline.png create mode 100644 app/src/main/resources/images/material/icon_save_outline.png create mode 100644 app/src/main/resources/images/material/icon_settings_outline.png create mode 100644 app/src/main/resources/images/material/menu_folder_solid.png diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 5c470f2a..356caaf7 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -2155,13 +2155,21 @@ private void configureLeftSidebar() { Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) .forEach(x -> { leftSidebarContent.add(x.build(this.basicEditor), x.id()); - addLeftSidebarButton(x.id(), createImageIcon(x.getIconPath()), x.getTitle()); + addLeftSidebarButton( + x.id(), + createImageIcon(x.getActiveIconPath(), x.getActiveIconTint()), + createImageIcon(x.getInactiveIconPath()), + x.getTitle()); }); Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.SOUTH) .forEach(x -> { bottomBarContent.add(x.build(this.basicEditor), x.id()); - addBottomBarButton(x.id(), createImageIcon(x.getIconPath()), x.getTitle()); + addBottomBarButton( + x.id(), + createImageIcon(x.getActiveIconPath(), x.getActiveIconTint()), + createImageIcon(x.getInactiveIconPath()), + x.getTitle()); }); bottomBarContainer.add(bottomBarContent, BorderLayout.CENTER); @@ -2182,7 +2190,11 @@ private void configureRightSidebar() { Arrays.stream(panels) .filter(x -> x.getLayoutConstraints() == EditorLayout.EAST) .forEach(x -> { - addRightDocsButton(x.id(), createImageIcon(x.getIconPath()), x.getTitle()); + addRightDocsButton( + x.id(), + createImageIcon(x.getActiveIconPath(), x.getActiveIconTint()), + createImageIcon(x.getInactiveIconPath()), + x.getTitle()); JComponent content = "docs".equals(x.id()) ? docsTabs : x.build(this.basicEditor); if (content != null) { rightDocsContent.add(content, x.id()); @@ -2210,32 +2222,33 @@ private void configureRightSidebar() { } - private void addLeftSidebarButton(String key, Icon icon, String tooltip) { - JToggleButton button = createRailButton(icon, tooltip); + private void addLeftSidebarButton(String key, Icon selectedIcon, Icon icon, String tooltip) { + JToggleButton button = createRailButton(selectedIcon, icon, tooltip); button.addActionListener(e -> onLeftSidebarButtonPressed(key)); leftSidebarGroup.add(button); leftSidebarButtons.put(key, button); leftSidebarRail.add(button); } - private void addRightDocsButton(String key, Icon icon, String tooltip) { - JToggleButton button = createRailButton(icon, tooltip); + private void addRightDocsButton(String key, Icon selectedIcon, Icon icon, String tooltip) { + JToggleButton button = createRailButton(selectedIcon, icon, tooltip); button.addActionListener(e -> onRightDocsButtonPressed(key)); rightDocsGroup.add(button); rightDocsButtons.put(key, button); rightDocsRail.add(button); } - private void addBottomBarButton(String key, Icon icon, String tooltip) { - JToggleButton button = createRailButton(icon, tooltip); + private void addBottomBarButton(String key, Icon selectedIcon, Icon icon, String tooltip) { + JToggleButton button = createRailButton(selectedIcon, icon, tooltip); button.addActionListener(e -> onBottomBarButtonPressed(key)); bottomBarGroup.add(button); bottomBarButtons.put(key, button); bottomBarRail.add(button); } - private JToggleButton createRailButton(Icon icon, String tooltip) { + private JToggleButton createRailButton(Icon selectedIcon, Icon icon, String tooltip) { JToggleButton button = new JToggleButton(icon); + button.setSelectedIcon(selectedIcon != null ? selectedIcon : icon); button.setToolTipText(tooltip); button.setFocusable(false); button.setMargin(new Insets(8, 8, 8, 8)); diff --git a/app/src/main/java/com/basic4gl/desktop/Theme.java b/app/src/main/java/com/basic4gl/desktop/Theme.java index 4304ffeb..0b76ed6d 100644 --- a/app/src/main/java/com/basic4gl/desktop/Theme.java +++ b/app/src/main/java/com/basic4gl/desktop/Theme.java @@ -6,9 +6,9 @@ public class Theme { public static final String THEME_DIRECTORY = IMAGE_DIRECTORY + "material/"; public static final String ICON_RUN_APP = THEME_DIRECTORY + "icon_run.png"; public static final String ICON_STOP_APP = THEME_DIRECTORY + "icon_stop.png"; - public static final String ICON_NEW = THEME_DIRECTORY + "icon_new.png"; - public static final String ICON_OPEN = THEME_DIRECTORY + "icon_open.png"; - public static final String ICON_SAVE = THEME_DIRECTORY + "icon_save.png"; + public static final String ICON_NEW = THEME_DIRECTORY + "icon_new_outline.png"; + public static final String ICON_OPEN = THEME_DIRECTORY + "icon_open_outline.png"; + public static final String ICON_SAVE = THEME_DIRECTORY + "icon_save_outline.png"; public static final String ICON_DEBUG = THEME_DIRECTORY + "icon_debug.png"; public static final String ICON_PLAY = THEME_DIRECTORY + "icon_play.png"; public static final String ICON_PAUSE = THEME_DIRECTORY + "icon_pause.png"; @@ -16,7 +16,7 @@ public class Theme { public static final String ICON_STEP_IN = THEME_DIRECTORY + "icon_step_in.png"; public static final String ICON_STEP_OUT = THEME_DIRECTORY + "icon_step_out.png"; public static final String ICON_EXPORT = THEME_DIRECTORY + "icon_export.png"; - public static final String ICON_SETTINGS = THEME_DIRECTORY + "icon_settings.png"; + public static final String ICON_SETTINGS = THEME_DIRECTORY + "icon_settings_outline.png"; public static final String ICON_REFRESH = THEME_DIRECTORY + "icon_refresh.png"; public static final String ICON_DOTS_VERTICAL = THEME_DIRECTORY + "icon_dots_vertical.png"; public static final String ICON_VIEW_GRID = THEME_DIRECTORY + "icon_view_grid.png"; @@ -27,11 +27,13 @@ public class Theme { public static final String ICON_ARROW_UP = THEME_DIRECTORY + "icon_arrow_up.png"; public static final String ICON_BOOKMARK_ADD = THEME_DIRECTORY + "icon_bookmark_add.png"; public static final String ICON_MENU_FOLDER = THEME_DIRECTORY + "menu_folder.png"; + public static final String ICON_MENU_FOLDER_SOLID = THEME_DIRECTORY + "menu_folder_solid.png"; public static final String ICON_MENU_ASSETS = THEME_DIRECTORY + "menu_assets.png"; public static final String ICON_MENU_BOOKMARKS = THEME_DIRECTORY + "menu_bookmarks.png"; public static final String ICON_MENU_BOOKMARKS_OUTLINE = THEME_DIRECTORY + "menu_bookmarks_outline.png"; public static final String ICON_MENU_FUNCTIONS = THEME_DIRECTORY + "menu_functions.png"; public static final String ICON_MENU_HELP = THEME_DIRECTORY + "menu_help.png"; + public static final String ICON_MENU_HELP_SOLID = THEME_DIRECTORY + "menu_help_solid.png"; public static final String ICON_MENU_DEBUG = THEME_DIRECTORY + "menu_debug.png"; public static final String ICON_FUNCTION = THEME_DIRECTORY + "icon_function.png"; public static final String ICON_VARIABLE = THEME_DIRECTORY + "icon_variable.png"; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java index ef1204b4..c8e59a7f 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java @@ -93,10 +93,20 @@ public String getTitle() { } @Override - public String getIconPath() { + public String getActiveIconPath() { return ICON_MENU_ASSETS; } + @Override + public String getInactiveIconPath() { + return ICON_MENU_ASSETS; + } + + @Override + public Color getActiveIconTint() { + return null; + } + @Override public EditorLayout getLayoutConstraints() { return EditorLayout.WEST; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java index d50417a4..a904dd90 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java @@ -39,10 +39,20 @@ public String getTitle() { } @Override - public String getIconPath() { + public String getActiveIconPath() { return ICON_MENU_BOOKMARKS; } + @Override + public String getInactiveIconPath() { + return ICON_MENU_BOOKMARKS_OUTLINE; + } + + @Override + public Color getActiveIconTint() { + return null; + } + @Override public EditorLayout getLayoutConstraints() { return EditorLayout.WEST; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java index d009a1bb..d16a8303 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java @@ -19,12 +19,7 @@ import java.awt.event.MouseEvent; import java.util.Objects; -import static com.basic4gl.desktop.Theme.ICON_PAUSE; -import static com.basic4gl.desktop.Theme.ICON_PLAY; -import static com.basic4gl.desktop.Theme.ICON_STEP_IN; -import static com.basic4gl.desktop.Theme.ICON_STEP_OUT; -import static com.basic4gl.desktop.Theme.ICON_STEP_OVER; -import static com.basic4gl.desktop.Theme.ICON_MENU_DEBUG; +import static com.basic4gl.desktop.Theme.*; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; @@ -60,10 +55,20 @@ public String getTitle() { } @Override - public String getIconPath() { + public String getActiveIconPath() { + return ICON_DEBUG; + } + + @Override + public String getInactiveIconPath() { return ICON_MENU_DEBUG; } + @Override + public Color getActiveIconTint() { + return null; + } + @Override public EditorLayout getLayoutConstraints() { return EditorLayout.SOUTH; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java index 82cbc440..080d0da0 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -4,8 +4,10 @@ import com.basic4gl.desktop.spi.PluginContext; import javax.swing.*; +import java.awt.*; import static com.basic4gl.desktop.Theme.ICON_MENU_HELP; +import static com.basic4gl.desktop.Theme.ICON_MENU_HELP_SOLID; public class DocsPanelProvider implements IEditorPanelProvider { @Override @@ -19,10 +21,20 @@ public String getTitle() { } @Override - public String getIconPath() { + public String getActiveIconPath() { + return ICON_MENU_HELP_SOLID; + } + + @Override + public String getInactiveIconPath() { return ICON_MENU_HELP; } + @Override + public Color getActiveIconTint() { + return null; + } + @Override public EditorLayout getLayoutConstraints() { return EditorLayout.EAST; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java index d0fb0a8d..45a832ec 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java @@ -24,10 +24,7 @@ import java.util.Comparator; import java.util.Locale; -import static com.basic4gl.desktop.Theme.ICON_DOTS_VERTICAL; -import static com.basic4gl.desktop.Theme.ICON_MENU_FOLDER; -import static com.basic4gl.desktop.Theme.ICON_REFRESH; -import static com.basic4gl.desktop.Theme.ICON_SEARCH; +import static com.basic4gl.desktop.Theme.*; import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; @@ -53,10 +50,20 @@ public String getTitle() { } @Override - public String getIconPath() { + public String getActiveIconPath() { + return ICON_MENU_FOLDER_SOLID; + } + + @Override + public String getInactiveIconPath() { return ICON_MENU_FOLDER; } + @Override + public Color getActiveIconTint() { + return null; + } + @Override public EditorLayout getLayoutConstraints() { return EditorLayout.WEST; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java index 579fd78a..122e118b 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java @@ -4,11 +4,14 @@ import com.basic4gl.desktop.spi.PluginContext; import javax.swing.*; +import java.awt.*; public interface IEditorPanelProvider { String id(); String getTitle(); - String getIconPath(); + String getActiveIconPath(); + String getInactiveIconPath(); + Color getActiveIconTint(); EditorLayout getLayoutConstraints(); JPanel build(PluginContext context); diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java index 92752130..224bf3ee 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -114,10 +114,20 @@ public String getTitle() { } @Override - public String getIconPath() { + public String getActiveIconPath() { return ICON_MENU_FUNCTIONS; } + @Override + public String getInactiveIconPath() { + return ICON_MENU_FUNCTIONS; + } + + @Override + public Color getActiveIconTint() { + return null; + } + @Override public EditorLayout getLayoutConstraints() { return EditorLayout.EAST; diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java index 612bc17b..243ebd13 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java @@ -2,6 +2,7 @@ import javax.swing.*; import java.awt.*; +import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; @@ -19,6 +20,14 @@ public static ImageIcon createImageIcon(String path) { } } + public static ImageIcon createImageIcon(String path, Color tint) { + ImageIcon baseIcon = createImageIcon(path); + if (baseIcon == null || tint == null) { + return baseIcon; + } + return new ImageIcon(tintImage(baseIcon.getImage(), tint)); + } + public static Icon buildImageThumbnailIcon(File file, int maxWidth, int maxHeight) { try { java.awt.image.BufferedImage image = javax.imageio.ImageIO.read(file); @@ -45,4 +54,27 @@ public static Icon createScaledIcon(String iconPath, int size) { Image scaled = icon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH); return new ImageIcon(scaled); } + + private static BufferedImage tintImage(Image source, Color tint) { + int width = source.getWidth(null); + int height = source.getHeight(null); + BufferedImage sourceImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D sourceGraphics = sourceImage.createGraphics(); + sourceGraphics.drawImage(source, 0, 0, null); + sourceGraphics.dispose(); + + BufferedImage tinted = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + int tintRgb = tint.getRGB() & 0x00FFFFFF; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int argb = sourceImage.getRGB(x, y); + int alpha = (argb >>> 24) & 0xFF; + if (alpha == 0) { + continue; + } + tinted.setRGB(x, y, (alpha << 24) | tintRgb); + } + } + return tinted; + } } diff --git a/app/src/main/resources/images/material/icon_new_outline.png b/app/src/main/resources/images/material/icon_new_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..cdd54a466e1b82440f6f8cfdcbeaf9a8c305f697 GIT binary patch literal 282 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj=RI85SQa;^Rwc`^2y2w$%1(R=f-mohz*?%vENJTe??s3o&<;Z_zn%ouP8Fhzhp? z>q)i>$0huxoC3dpBwi@6;S^xoaEvt}mBXvctLqHU4DO`*i;*!evzD!%TM@yp|NZ?k z#wStp_HMMxdB^3Se||Z`T|xhv8D}G#MRo1ky+3|i^tqSm!{#+_`4|}f|5sYXJ$Edq)#c)I$ztaD0e0sv2eYH$Dm literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_open_outline.png b/app/src/main/resources/images/material/icon_open_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..01327a9d1ea8758210aa078035ec9f038816272b GIT binary patch literal 474 zcmV<00VV#4P)_kv2-#`?70UM1%-oQixf=wo^MZ8B4@j4&CKhQ?75K9peEG%q; zU=_3D?0VcS++yx8ongP7VRmM|?03U@Fyh}o!BBxCiE0hns5R-i)@-v2+j~SqyEzgf z0zmEn@SG#ZgqXRVha@Hdm^YpN*ffdjc5zG;5GCvuHh~?_>5BqJiMTvL5FB-|AQs~G z00{#7ungZxu+wTyh^~EsbE4A4N!1%P@3fRXDiPbqRva-GgahSd_!wsZWn$Q?I&cF3C%RuaD~3 zo!I0QdER&^4_@emEa-b`J51|P5SNbZVofkVh3rz|OKXRL(m6#Q51)JnLvC;>z5-Xbn!AJ08oE+Yf zEZK7+xyE6K-JPA8ADR6~4-1xm1_+{fa}b)RK{(_#8brqUDqSlPbMFe^zR%fbXXc>~ zQUgfxl`=_T6Vj^0Y)H(958?pC^`~h{RJs;6y(8vzfZm%RUI1E1ewp4gK_K!yQZ7`a z6F`}7l$nRJceOKsn|M0+0yLXEO){_d0_Fv^S(djhmkd*RHK;t^&xBJp}PFf!+Wb z6Zk+bRt$xx0FsA`EIrGuSt_hq&24%vHdS)~G%lkw4_%W_87ulCI%_om(bpaW&$HE* z0|FkIXtx?x1LRqHJF-c?Xz@R&`sOxSlf9`udNn|m+|_vDfNuZ*0RR79BYUC%000I_ cL_t&o00Xq~F*^A|iU0rr07*qoM6N<$g37kAB>(^b literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_settings_outline.png b/app/src/main/resources/images/material/icon_settings_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..8d688f2e6da8459ab4796f0c72e4d3effc25eeb2 GIT binary patch literal 1061 zcmV+=1ls$FP)^?@k@iLRvN`xrJ{*~UrI&Wkl4)Ku6=tQW)e$+AhAV|(XNLFuCiR(W0~B*XY@C$ z27+uZzw=@<-K3(gNSK^pb_R4RSr>eP^%RfjX%X zba%UEs5}$JRGy|DCjh}$s#sa^-Mxq<@ZGN2)18UTC0JEx-aS#2WkXUEqIL0 zmeW5Glw5z{7j9CO4o%J6t`e@v<_1QI-(rj=s>xNlWM;EMw^%M~_5$*g^*GlYK8jds z1dwo)zVk$TCgfHwDQaXR&v+Foqa(t5ct+OfbWC*o4#-(zR7LI# zlNitB@^@GE?>}1^ENlfZVLies(uk!-(0mThLh&>GD>A*V$Y*yFkded%{W2j)1-?rM zexWt+-Q~n)LAaE$Qvx|=xmE+-z~mKW{Z4jpn+oJaH|MYW%vTmh7000000NkvXXu0mjfZ}
        )%?ApB7rI^8Zp2I=olIN9OP34m5%SuEzH6=C&1bter%KLs|{LC}{f<9tvL{0Pd zuZ`YuZSC;@DRu)-ZkYhaq*61s>5aylT08y;cXDyq2?wQYP$}8HeQ(|K1mmPQmd9bA zs&fTb_BKv-KJdhn-%pQmv#P6t=@GY;8rS;%{9fNxwexa$+opM<3%?%O7c4Y=@61M3 z8PO8;9VN#PHzi$W(oW-?+s5_6`t+~bgUk#J|NnO|{do%X3meEU%nTL#QViSE=FJ0& OFnGH9xvX Date: Tue, 14 Jul 2026 03:01:40 -0400 Subject: [PATCH 23/38] work on docs panel --- app/build.gradle | 14 +++ .../java/com/basic4gl/desktop/MainWindow.java | 77 ++++++++++++- .../com/basic4gl/desktop/util/HtmlUtil.java | 30 ++--- app/src/main/resources/css/docs-markdown.css | 109 ++++++++++++++++++ docs/compatibility-notes.md | 43 +++++++ docs/index.md | 24 ++++ docs/programmers-guide-command-line.md | 84 ++++++++++++++ 7 files changed, 356 insertions(+), 25 deletions(-) create mode 100644 app/src/main/resources/css/docs-markdown.css create mode 100644 docs/compatibility-notes.md create mode 100644 docs/index.md create mode 100644 docs/programmers-guide-command-line.md diff --git a/app/build.gradle b/app/build.gradle index bb42471b..6026335f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -87,6 +87,20 @@ dependencies { // for FlatDesktop Java 8 backports implementation 'com.formdev:flatlaf-extras:3.5.4' + implementation 'org.commonmark:commonmark:0.29.0' + + def osName = System.getProperty("os.name").toLowerCase() + def osArch = System.getProperty("os.arch").toLowerCase() + def javafxPlatform = osName.contains("mac") + ? (osArch.contains("aarch64") || osArch.contains("arm64") ? "mac-aarch64" : "mac") + : (osName.contains("win") ? "win" : "linux") + implementation "org.openjfx:javafx-base:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-graphics:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-controls:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-media:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-swing:21.0.4:${javafxPlatform}" + implementation "org.openjfx:javafx-web:21.0.4:${javafxPlatform}" + // for IS_OS_MAC flag for OS specific behavior implementation("org.apache.commons:commons-lang3:3.14.0") diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 356caaf7..ececd952 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -27,6 +27,10 @@ import com.formdev.flatlaf.icons.FlatTabbedPaneCloseIcon; import com.formdev.flatlaf.ui.FlatTabbedPaneUI; import com.formdev.flatlaf.util.SystemInfo; +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; +import javafx.scene.Scene; +import javafx.scene.web.WebView; import java.awt.*; import java.awt.event.*; import java.io.*; @@ -35,6 +39,7 @@ import java.util.*; import java.util.List; import java.util.function.BiConsumer; +import java.util.concurrent.atomic.AtomicBoolean; import javax.swing.*; import javax.swing.border.BevelBorder; import javax.swing.border.EmptyBorder; @@ -178,6 +183,9 @@ public void caretUpdate(CaretEvent e) { private final JLabel compilerStatusLabel = new JLabel(""); // Compiler/VM Status private final JLabel cursorPositionLabel = new JLabel("0:0"); // Cursor Position + private static final String DOCS_MARKDOWN_STYLESHEET_RESOURCE = "/css/docs-markdown.css"; + private static final AtomicBoolean JAVAFX_INITIALIZED = new AtomicBoolean(false); + // Editors private BasicEditor basicEditor; @@ -2664,17 +2672,74 @@ public void openMarkdownInDocsTab(File file) { try { String markdown = Files.readString(resolved.toPath(), StandardCharsets.UTF_8); - JEditorPane pane = new JEditorPane(); - pane.setEditable(false); - pane.setContentType("text/html"); - pane.setText(markdownToHtml(markdown)); - pane.setCaretPosition(0); + JFXPanel panel = new JFXPanel(); + String html = buildMarkdownDocumentHtml(markdownToHtml(markdown)); + ensureJavaFxInitialized(); + Platform.runLater(() -> { + try { + WebView webView = new WebView(); + webView.getEngine().loadContent(html, "text/html"); + panel.setScene(new Scene(webView)); + } catch (Throwable ex) { + showDocsFallback(panel, html, ex); + } + }); - docsTabs.addTab(tabTitle, new JScrollPane(pane)); + docsTabs.addTab(tabTitle, panel); docsTabs.setSelectedIndex(docsTabs.getTabCount() - 1); selectRightDocsSection("docs"); } catch (IOException ex) { JOptionPane.showMessageDialog(frame, "Unable to open markdown file: " + ex.getMessage()); + } catch (Throwable ex) { + JOptionPane.showMessageDialog(frame, "Unable to render markdown file: " + ex.getMessage()); + } + } + + private void ensureJavaFxInitialized() { + if (!JAVAFX_INITIALIZED.compareAndSet(false, true)) { + return; + } + try { + Platform.startup(() -> {}); + } catch (IllegalStateException ignored) { + // Toolkit already initialized by JFXPanel startup. + } + Platform.setImplicitExit(false); + } + + private void showDocsFallback(JFXPanel panel, String html, Throwable ex) { + System.err.println("Unable to initialize JavaFX WebView: " + ex.getMessage()); + SwingUtilities.invokeLater(() -> { + JEditorPane fallbackPane = new JEditorPane(); + fallbackPane.setEditable(false); + fallbackPane.setContentType("text/html"); + fallbackPane.setText(html); + fallbackPane.setCaretPosition(0); + panel.setLayout(new BorderLayout()); + panel.add(new JScrollPane(fallbackPane), BorderLayout.CENTER); + panel.revalidate(); + panel.repaint(); + }); + } + + private String buildMarkdownDocumentHtml(String bodyHtml) { + String stylesheetText = readTextResource(DOCS_MARKDOWN_STYLESHEET_RESOURCE); + return "" + + bodyHtml + + ""; + } + + private String readTextResource(String resourcePath) { + try (InputStream input = MainWindow.class.getResourceAsStream(resourcePath)) { + if (input == null) { + return ""; + } + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException ex) { + System.err.println("Unable to load resource " + resourcePath + ": " + ex.getMessage()); + return ""; } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java b/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java index ef1c85d2..f2e6e7c5 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java @@ -1,6 +1,13 @@ package com.basic4gl.desktop.util; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; + public final class HtmlUtil { + private static final Parser MARKDOWN_PARSER = Parser.builder().build(); + private static final HtmlRenderer MARKDOWN_RENDERER = HtmlRenderer.builder().build(); + private HtmlUtil() { } @@ -13,25 +20,10 @@ public static String escapeHtml(String input) { public static String markdownToHtml(String markdown) { - StringBuilder html = new StringBuilder(""); - for (String line : markdown.split("\\R", -1)) { - String escaped = escapeHtml(line); - if (escaped.startsWith("### ")) { - html.append("

        ").append(escaped.substring(4)).append("

        "); - } else if (escaped.startsWith("## ")) { - html.append("

        ").append(escaped.substring(3)).append("

        "); - } else if (escaped.startsWith("# ")) { - html.append("

        ").append(escaped.substring(2)).append("

        "); - } else if (escaped.startsWith("- ")) { - html.append("

        • ").append(escaped.substring(2)).append("

        "); - } else if (escaped.isBlank()) { - html.append("
        "); - } else { - html.append("

        ").append(escaped).append("

        "); - } - } - html.append(""); - return html.toString(); + String input = markdown == null ? "" : markdown; + Node document = MARKDOWN_PARSER.parse(input); + String htmlBody = MARKDOWN_RENDERER.render(document); + return "" + htmlBody + ""; } } diff --git a/app/src/main/resources/css/docs-markdown.css b/app/src/main/resources/css/docs-markdown.css new file mode 100644 index 00000000..0305b01f --- /dev/null +++ b/app/src/main/resources/css/docs-markdown.css @@ -0,0 +1,109 @@ +.markdown-body { + font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + font-size: 13px; + line-height: 1.55; + color: #1f2933; + background-color: #ffffff; + margin: 10px 14px; +} + +.markdown-body h1 { + font-size: 1.65em; + font-weight: 700; + margin: 0.75em 0 0.45em; + padding-bottom: 0.3em; + border-bottom: 1px solid #dde3ea; +} + +.markdown-body h2 { + font-size: 1.35em; + font-weight: 700; + margin: 0.75em 0 0.45em; + padding-bottom: 0.25em; + border-bottom: 1px solid #e5eaf0; +} + +.markdown-body h3 { + font-size: 1.15em; + font-weight: 700; + margin: 0.7em 0 0.35em; +} + +.markdown-body p { + margin: 0.2em 0 0.75em; +} + +.markdown-body ul, +.markdown-body ol { + margin: 0.2em 0 0.8em 1.5em; + padding: 0; +} + +.markdown-body li { + margin: 0.2em 0; +} + +.markdown-body a { + color: #0b66d0; + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body code { + font-family: "JetBrains Mono", "Menlo", "Consolas", monospace; + font-size: 12px; + background-color: #f2f4f8; + border: 1px solid #e0e6ed; + border-radius: 3px; + padding: 0 3px; +} + +.markdown-body pre { + font-family: "JetBrains Mono", "Menlo", "Consolas", monospace; + font-size: 12px; + line-height: 1.45; + background-color: #f6f8fa; + border: 1px solid #e0e6ed; + border-radius: 6px; + padding: 10px; + margin: 0.2em 0 0.9em; +} + +.markdown-body blockquote { + color: #5b6876; + border-left: 4px solid #d0d8e2; + margin: 0.3em 0 0.8em; + padding: 0.2em 0 0.2em 0.8em; +} + +html { + scrollbar-color: #c3c9d1 #f3f5f7; + scrollbar-width: thin; +} + +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-track { + background: #f3f5f7; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: #c3c9d1; + border: 2px solid #f3f5f7; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: #aeb6c1; +} + +::-webkit-scrollbar-corner { + background: #f3f5f7; +} diff --git a/docs/compatibility-notes.md b/docs/compatibility-notes.md new file mode 100644 index 00000000..c0ba74c6 --- /dev/null +++ b/docs/compatibility-notes.md @@ -0,0 +1,43 @@ + +Basic4GL was originally developed for Windows with OpenGL 1.1, which is considered a legacy version of OpenGL and may not be fully supported by modern systems. + +This Java port of Basic4GL attempts to support all functions provided by the original Windows version, but some functions have not been ported yet and some may not be possible with Basic4GLj's current OpenGL implementation + +Additionally, recent work on Basic4GLj (as of 2022!) has been done on a Mac with Intel - I can't guarantee everything works as expected on Windows, and support for Apple Silicon based Macs is experimental. + +Please report any compatibility or stability issues to the Issues page of this project. + +### Sample Program Compatibility + +Sample programs located in the `/samples` folder are copied from the Windows version of Basic4GL and are not guaranteed to work due to the in-progress nature of Basic4GLj and some Basic4GL features being otherwise unsupported. + +Network functions are currently unsupported are not currently supported. + +Some sample programs may require `glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_BLEND)` to be added their original source inorder to display output; this is considered a bug since it inverts texture colors and will hopefully become unecessary in future versions of the Basic4GLj IDE - the applications will function normally with or without the added `glTexEnvi` in earlier versions of the Windows Basic4GL. + +### Keyboard Input Compatibility + +The following virtualkey constants available in the Windows version of Basic4GL are not currently supported: + + VK_LBUTTON, VK_RBUTTON, VK_CANCEL, VK_MBUTTON, VK_CLEAR, VK_RETURN, VK_SHIFT, VK_CONTROL, VK_MENU, VK_PAUSE, + VK_KANA, VK_HANGEUL, VK_HANGUL, VK_JUNJA, VK_FINAL, VK_HANJA, VK_KANJI, VK_CONVERT, VK_NONCONVERT, VK_ACCEPT, + VK_MODECHANGE, VK_SELECT, VK_PRINT, VK_EXECUTE, VK_SNAPSHOT, VK_HELP, VK_LWIN, VK_RWIN, VK_APPS, VK_SEPARATOR, + VK_LMENU, VK_RMENU, VK_PROCESSKEY, VK_ATTN, VK_CRSEL, VK_EXSEL, VK_EREOF, VK_PLAY, VK_ZOOM + +_Integer values of the virtualkey constants may differ from the Windows version; the supported virtualkeys have been mapped to the key constants provided by GLFW where possible. In addition, all documented GLFW key constants are available for use in Basic4GLj. +see: http://www.glfw.org/docs/latest/group__keys.html_ + +`VK_RETURN` has not been mapped to a GLFW constant because GLFW has separate constants for the enter key and the keypad/numpad enter key, where `VK_RETURN` would recognize either. +`GLFW_KEY_ENTER` and `GLFW_KEY_KP_ENTER` are available for usage in place of `VK_RETURN`. + +### OpenGL GLU Compatibility + +OpenGL GLU constants are unavailable, they are unsupported by the current version of LWJGL that is used by Basic4GLj. GLU functions available in previous versions of Basic4GL are available with modifications. +- `gluOrtho2D` is mapped to `glOrtho` +- `gluPerspective` uses `glFrustrum` implementation +- `gluLookAt` will currently throw an `UnsupportedOperationException` if called + + +### Misc. + +- `glColor3ub` and `glColor4ub` are mapped to `glColor3ubv` and `glColor4ubv` to resolve crash on macOS \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..1d8ba738 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,24 @@ +Welcome to the Basic4GLj wiki! + +## Getting Started: +* [Get the Latest Release](https://github.com/NateIsStalling/Basic4GLj/releases) +* [Language Syntax Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Language-Syntax-Guide) +* [Sample Programs](https://github.com/NateIsStalling/Basic4GLj/tree/main/app/src/main/dist/samples/Programs) + +## Programmer's Guide: +* [Text Output Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Text-Output-Guide) +* [Standard Function Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Standard-Function-Guide) +* [Keyboard, Mouse, and Joystick Input Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Keyboard,-Mouse,-and-Joystick-Input-Guide) +* [File IO Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/File-IO-Guide) +* [Sound Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Sound-Guide) +* [Sprite Library Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Sprite-Library-Guide) +* [OpenGL Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/OpenGL-Guide) +* [Trigonometry Function Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Trigonometry-Function-Guide) +* [Command Line Function Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Command-Line-Function-Guide) +* [Runtime Compilation Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Runtime-Compilation-Guide) +* [Network Engine Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Network-Engine-Guide) + + +## Miscellaneous: +* [Compatibility Notes](https://github.com/NateIsStalling/Basic4GLj/wiki/Compatibility-Notes) +* [OpCode Reference](https://github.com/NateIsStalling/Basic4GLj/wiki/OpCode-Reference) \ No newline at end of file diff --git a/docs/programmers-guide-command-line.md b/docs/programmers-guide-command-line.md new file mode 100644 index 00000000..f7b4d712 --- /dev/null +++ b/docs/programmers-guide-command-line.md @@ -0,0 +1,84 @@ +# Programmer's Guide: Command Line Functions + +Basic4GL standalone programs can accept commands from the command line. +Command line arguments are entered after the program name when a program is run from the command line. + +For example, if we built a standalone Java app called "CmdTest.jar", and ran it in a command prompt window with the command: + +> java -XstartOnFirstThread -jar "CmdTest.jar" 1 banana 2 cucumber 3 "Tomato sandwich" + +Then we have passed it 6 parameters: + +1. `1` +2. `banana` +3. `2` +4. `cucumber` +5. `3` +6. `Tomato sandwich` + +We can access these parameters with the `ArgCount` and `Arg` functions. + + +> [!IMPORTANT] +> +> `-XstartOnFirstThread` is required by LWJGL for the program window to display on Mac OS +> when running standalone apps from the command prompt. +> +> If your program does not start when run from the command prompt, +> try adding `-XstartOnFirstThread` to the Java arguments. + +### ArgCount +`ArgCount()` returns the number of command line arguments. + +### Arg +`Arg(index)` returns parameter number index as a text string, where `index` is `0` to return the first parameter. + +`index` should be between `0` and `ArgCount() - 1`, otherwise `Arg(index)` returns a blank string. + +## Setting command line arguments within Basic4GL + +> [!IMPORTANT] +> +> Setting command line arguments within Basic4GLj is upcoming functionality for v0.7.0 and is not available in older versions + +To set command line arguments for a program run inside Basic4GLj, open **Application** > **Project Settings**, select the **Program Arguments** tab, and enter one argument per line. + +## Some examples + +### Display all arguments: +``` +dim i +printr ArgCount(); " argument(s) found" +for i = 0 to ArgCount() - 1 +printr Arg(i) +next +``` + +### Compile and run another program: +``` +dim prog +if ArgCount() = 0 then +printr "No program name!" +end +endif +prog = CompileFile(Arg(0), "__") +if CompilerError() <> "" then +printr CompilerError() +end +endif +Execute(prog) +if CompilerError() <> "" then +print CompilerError() +end +endif +``` + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +_Programmer's guide_ + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen \ No newline at end of file From 833e444ebbfb16a6a0b9e2bc7065b6d6bdb60104 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Tue, 14 Jul 2026 03:08:30 -0400 Subject: [PATCH 24/38] docs icons --- app/src/main/java/com/basic4gl/desktop/Theme.java | 2 ++ .../desktop/panels/DocsPanelProvider.java | 7 +++---- .../main/resources/images/material/icon_book.png | Bin 0 -> 754 bytes .../images/material/icon_book_outline.png | Bin 0 -> 805 bytes 4 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 app/src/main/resources/images/material/icon_book.png create mode 100644 app/src/main/resources/images/material/icon_book_outline.png diff --git a/app/src/main/java/com/basic4gl/desktop/Theme.java b/app/src/main/java/com/basic4gl/desktop/Theme.java index 0b76ed6d..4a778603 100644 --- a/app/src/main/java/com/basic4gl/desktop/Theme.java +++ b/app/src/main/java/com/basic4gl/desktop/Theme.java @@ -32,6 +32,8 @@ public class Theme { public static final String ICON_MENU_BOOKMARKS = THEME_DIRECTORY + "menu_bookmarks.png"; public static final String ICON_MENU_BOOKMARKS_OUTLINE = THEME_DIRECTORY + "menu_bookmarks_outline.png"; public static final String ICON_MENU_FUNCTIONS = THEME_DIRECTORY + "menu_functions.png"; + public static final String ICON_MENU_DOCS = THEME_DIRECTORY + "icon_book_outline.png"; + public static final String ICON_MENU_DOCS_SOLID = THEME_DIRECTORY + "icon_book.png"; public static final String ICON_MENU_HELP = THEME_DIRECTORY + "menu_help.png"; public static final String ICON_MENU_HELP_SOLID = THEME_DIRECTORY + "menu_help_solid.png"; public static final String ICON_MENU_DEBUG = THEME_DIRECTORY + "menu_debug.png"; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java index 080d0da0..2e8380f3 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -6,8 +6,7 @@ import javax.swing.*; import java.awt.*; -import static com.basic4gl.desktop.Theme.ICON_MENU_HELP; -import static com.basic4gl.desktop.Theme.ICON_MENU_HELP_SOLID; +import static com.basic4gl.desktop.Theme.*; public class DocsPanelProvider implements IEditorPanelProvider { @Override @@ -22,12 +21,12 @@ public String getTitle() { @Override public String getActiveIconPath() { - return ICON_MENU_HELP_SOLID; + return ICON_MENU_DOCS_SOLID; } @Override public String getInactiveIconPath() { - return ICON_MENU_HELP; + return ICON_MENU_DOCS; } @Override diff --git a/app/src/main/resources/images/material/icon_book.png b/app/src/main/resources/images/material/icon_book.png new file mode 100644 index 0000000000000000000000000000000000000000..3a1e29d5cf7f8746a056bce4c56b689da79643e5 GIT binary patch literal 754 zcmVrK3Ps8~crtmwgmw1^%Q1rJ5hOKX#;mugYOHW7QVZD!5IP|T)+f*>e1 z8ec{A_L_hsMA8%6)Q%?tc9 z!Lw2n>*-$;N$BITgw`G+iNPb0xNZqD?e7_AjmGs+FV2i+Z^z00RsPtLK%`giK=4CR zp#qBxi%3$+VBrf)@)-V?o$7pRZ;C5VYp9`qF4R{l!mI$*x(z5JeL@7VS-5j>MCbIFAoN5fI?H z@%vGi0Evtc^Cp}@0r|fOqyP)4d+H}Qc6|aMcWm7}OIeM( zX>~RWbs-&74YcT6QRIb?y01vxbBppR$WWI6NOt2Ij^oO9vG6tzBHRt%tX`ME529wU zOfadn#M14u3-i>Rn7T@Y8F5h z-wsLm4KbyeXrrGD_F}xMQH(R)6L^Q<>u??*#p{p)1+W3RZi@(Ii1m~hCi7YAgk$R_Y}_l%ZLpUE zea2S~GlN{SQ_t|SUd3bB4*xQJ0{m3l!WcYL0)Cb#-$)k3r<{VbA59W_Fp z0JA&=xkZTP;T&dW3wD5*Mq#Iz_y^+kiukdR+=s7@h`$rjRDmxgV zWJG_<4PJNrSkBfH#^;aA*$oM-Hvta*0nX(s&O_GVZID~B01mzllf5S5G3@sZa|ZY! z<|xoA7H}0e;()KP&?w`u^OB$DYPuYlzx0rU!E$5e$`=xOE-cdqiqwpom(RhRidP`S z56#v9T_W7h!be~)Y?=VRYzAA@#7v!vDT!62;cMD70bID-nC!R^)dAH^j788X3N(lY zJR2OUJRe%wPFoyX?*&@3wr+#n6-=*KU~E{=O=`UooG>^p5s1dqeY>=dctpF;H1G#o17*1vuj041hAK95v&2sgP80_k!t}guBtMH(D(_z z=sQ8Rh2O-$DgN#?@JBpS;e|=O-XarCAlONY4>-1^05%}kZ5E*n5r3j$sln97UmP^?*p!x zm`7TD$++GB237T5c2IX9zQ?#d0n^z41c{Gw8T|y@;s>B#_W2uuvJ?F-_zM64|NqD# jc69&%00v1!K~w_(UW-&f*xNo{00000NkvXXu0mjfvk!DB literal 0 HcmV?d00001 From 7d1f1d2a82efbcebe9e87a8f62186ba023e9105f Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Wed, 15 Jul 2026 23:08:36 -0400 Subject: [PATCH 25/38] work on cleaning up file openers + docs panel + markdown support --- .../basic4gl/desktop/spi/DebugController.java | 3 + .../desktop/spi/EditorCommandsService.java | 9 +- .../basic4gl/desktop/spi/LanguageService.java | 1 + .../basic4gl/desktop/spi/PluginContext.java | 2 +- .../basic4gl/desktop/spi/content/Content.java | 34 +++ .../desktop/spi/content/ContentMetadata.java | 61 +++++ .../desktop/spi/content/ContentService.java | 7 + .../desktop/spi/content/FileViewer.java | 3 +- .../desktop/spi/content/Template.java | 42 +++ app/build.gradle | 2 +- ...4gl.desktop.spi.content.FileViewerProvider | 5 + .../com/basic4gl/desktop/BasicEditor.java | 14 +- .../com/basic4gl/desktop/ExportDialog.java | 2 +- .../java/com/basic4gl/desktop/MainWindow.java | 248 +++--------------- .../desktop/ProjectSettingsDialog.java | 4 +- .../desktop/content/AssetService.java | 10 - .../{editor => content}/AudioFileViewer.java | 16 +- .../desktop}/content/DefaultAudioViewer.java | 7 +- .../content/DefaultAudioViewerProvider.java | 6 +- .../desktop}/content/DefaultImageViewer.java | 7 +- .../content/DefaultImageViewerProvider.java | 6 +- .../{editor => content}/FileEditor.java | 21 +- .../basic4gl/desktop/content/FileManager.java | 1 - .../FileViewerFactory.java | 25 +- .../desktop/content/FileViewerManager.java | 5 +- .../FileViewerWrapper.java | 16 +- .../{editor => content}/HexFileViewer.java | 16 +- .../basic4gl/desktop/content/HtmlViewer.java | 204 ++++++++++++++ .../{editor => content}/ImageFileViewer.java | 16 +- .../desktop/content/MarkdownViewer.java | 86 ++++++ .../content/MarkdownViewerProvider.java | 23 ++ .../desktop}/content/SimpleTextViewer.java | 7 +- .../content/SimpleTextViewerProvider.java | 6 +- .../{editor => content}/TextFileViewer.java | 18 +- .../desktop/debugger/IDebugPresenter.java | 1 - .../editor/IFileEditorActionListener.java | 1 + .../basic4gl/desktop/editor/IFileViewer.java | 16 +- .../desktop/language/SymbolIndexer.java | 7 +- .../desktop/panels/AssetsPanelProvider.java | 98 +++---- .../panels/BookmarksPanelProvider.java | 7 +- .../desktop/panels/DebugPanelProvider.java | 35 +-- .../desktop/panels/DocsPanelProvider.java | 218 +++++++++++++-- .../panels/FileBrowserPanelProvider.java | 57 ++-- .../desktop/panels/IEditorPanelProvider.java | 16 +- .../desktop/panels/SymbolsPanelProvider.java | 112 ++++---- .../desktop/util/BasicDialogService.java | 5 +- .../com/basic4gl/desktop/util/FileUtil.java | 3 +- .../com/basic4gl/desktop/util/HtmlUtil.java | 5 +- .../basic4gl/desktop/util/KeyStrokeUtil.java | 24 +- .../desktop/util/RoundedCardPanel.java | 4 +- .../basic4gl/desktop/util/SwingIconUtil.java | 3 +- .../com/basic4gl/desktop/util/SwingUtil.java | 7 +- app/src/main/resources/css/docs-markdown.css | 16 +- .../basic4gl/desktop/ExportDialogTest.java | 46 ---- language-adapter/build.gradle | 3 + .../adapter/Basic4GLEditorPluginAdapter.java | 1 + .../adapter/Basic4GLLanguageService.java | 42 +-- .../adapter/Basic4GLLanguageSupport.java | 160 ++++++----- .../language/adapter/util/LanguageUtil.java | 6 +- .../language/adapter/LanguageServiceTest.java | 69 +++++ ...uage.adapter.fileviewer.FileViewerProvider | 4 - 61 files changed, 1226 insertions(+), 673 deletions(-) create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java create mode 100644 app/src/META-INF/services/com.basic4gl.desktop.spi.content.FileViewerProvider rename app/src/main/java/com/basic4gl/desktop/{editor => content}/AudioFileViewer.java (94%) rename {app-spi/src/main/java/com/basic4gl/desktop/spi => app/src/main/java/com/basic4gl/desktop}/content/DefaultAudioViewer.java (96%) rename {app-spi/src/main/java/com/basic4gl/desktop/spi => app/src/main/java/com/basic4gl/desktop}/content/DefaultAudioViewerProvider.java (74%) rename {app-spi/src/main/java/com/basic4gl/desktop/spi => app/src/main/java/com/basic4gl/desktop}/content/DefaultImageViewer.java (93%) rename {app-spi/src/main/java/com/basic4gl/desktop/spi => app/src/main/java/com/basic4gl/desktop}/content/DefaultImageViewerProvider.java (78%) rename app/src/main/java/com/basic4gl/desktop/{editor => content}/FileEditor.java (98%) rename app/src/main/java/com/basic4gl/desktop/{editor => content}/FileViewerFactory.java (84%) rename app/src/main/java/com/basic4gl/desktop/{editor => content}/FileViewerWrapper.java (89%) rename app/src/main/java/com/basic4gl/desktop/{editor => content}/HexFileViewer.java (93%) create mode 100644 app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java rename app/src/main/java/com/basic4gl/desktop/{editor => content}/ImageFileViewer.java (89%) create mode 100644 app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/MarkdownViewerProvider.java rename {app-spi/src/main/java/com/basic4gl/desktop/spi => app/src/main/java/com/basic4gl/desktop}/content/SimpleTextViewer.java (91%) rename {app-spi/src/main/java/com/basic4gl/desktop/spi => app/src/main/java/com/basic4gl/desktop}/content/SimpleTextViewerProvider.java (79%) rename app/src/main/java/com/basic4gl/desktop/{editor => content}/TextFileViewer.java (88%) delete mode 100644 app/src/test/java/com/basic4gl/desktop/ExportDialogTest.java create mode 100644 language-adapter/src/test/java/com/basic4gl/language/adapter/LanguageServiceTest.java delete mode 100644 library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java index cedf4b31..ff666e15 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/DebugController.java @@ -2,7 +2,10 @@ public interface DebugController { void actionPlayPause(); + void actionStep(); + void actionStepInto(); + void actionStepOutOf(); } diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java index 5602e014..c2ffc194 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorCommandsService.java @@ -5,14 +5,19 @@ public interface EditorCommandsService { void openFileWithPreferredViewer(File file); - // TODO this should be cleaned up before 1.0; refactoring - void openMarkdownInDocsTab(File file); + public String collectAllSourceText(); + void actionOpenFolder(); + void selectNextBookmark(); + void selectPreviousBookmark(); + void toggleBookmark(); + List listBookmarks(); + void goToBookmark(String filePath, int lineNumber); void setWorkspaceDirectory(File selectedFile); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java index d3eb5670..964d88d2 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/LanguageService.java @@ -15,6 +15,7 @@ public interface LanguageService { public void onUnload(); public List extractStringLiterals(String text); + public List getReservedWords(); public List getConstants(); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java index f3ccec28..850cdee6 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java @@ -6,8 +6,8 @@ public interface PluginContext { // ToolWindowRegistry toolWindows(); EditorCommandsService commands(); - DebugController debugger(); + DebugController debugger(); DialogService dialogs(); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java new file mode 100644 index 00000000..2b03b8ef --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java @@ -0,0 +1,34 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.File; + +public class Content { + private String name; + private ContentMetadata metadata; + + private File file; + private boolean readonly; + + public Content(String name, ContentMetadata metadata, File file, boolean readonly) { + this.name = name; + this.metadata = metadata != null ? metadata : new ContentMetadata(); + this.file = file; + this.readonly = readonly; + } + + public String getName() { + return name; + } + + public ContentMetadata getMetadata() { + return metadata; + } + + public File getFile() { + return file; + } + + public boolean isReadonly() { + return readonly; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java new file mode 100644 index 00000000..50128c79 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java @@ -0,0 +1,61 @@ +package com.basic4gl.desktop.spi.content; + +public class ContentMetadata { + private String category; + private String description; + private String[] tags; + private String author; + private String version; + + public ContentMetadata() { + tags = new String[0]; + } + + public ContentMetadata(String category, String description, String[] tags, String author, String version) { + this.category = category; + this.description = description; + this.tags = tags; + this.author = author; + this.version = version; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String[] getTags() { + return tags; + } + + public void setTags(String[] tags) { + this.tags = tags; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java new file mode 100644 index 00000000..640b6808 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java @@ -0,0 +1,7 @@ +package com.basic4gl.desktop.spi.content; + +public interface ContentService { + void registerTemplate(Template template); + + void registerDocument(Content content); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java index 5d1ecfd8..36f98e29 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/FileViewer.java @@ -1,5 +1,6 @@ package com.basic4gl.desktop.spi.content; +import com.basic4gl.desktop.spi.PluginContext; import java.nio.file.Path; import javax.swing.JComponent; @@ -16,7 +17,7 @@ public interface FileViewer { * @param path Path to the file to view * @throws FileViewerException if file cannot be loaded or displayed */ - void loadFile(Path path) throws FileViewerException; + void loadFile(PluginContext context, Path path) throws FileViewerException; /** * Get the Swing component that displays the file diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java new file mode 100644 index 00000000..1527b430 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java @@ -0,0 +1,42 @@ +package com.basic4gl.desktop.spi.content; + +public class Template { + + private String name; + private ContentMetadata metadata; + private Content[] content; + + public Template(String name, ContentMetadata metadata, Content[] content) { + this.name = name; + this.metadata = metadata != null ? metadata : new ContentMetadata(); + this.content = content; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public ContentMetadata getMetadata() { + return metadata; + } + + public void setMetadata(ContentMetadata metadata) { + this.metadata = metadata; + } + + public Content[] getContent() { + return content; + } + + public void setContent(Content[] content) { + this.content = content; + } + + public String getCategory() { + return metadata.getCategory(); + } +} diff --git a/app/build.gradle b/app/build.gradle index 6026335f..a340bcd9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -109,7 +109,7 @@ dependencies { implementation ('javax.websocket:javax.websocket-api:1.0') implementation ('org.eclipse.jetty.websocket:javax-websocket-client-impl:9.4.49.v20220914') - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13.1' } spotless { diff --git a/app/src/META-INF/services/com.basic4gl.desktop.spi.content.FileViewerProvider b/app/src/META-INF/services/com.basic4gl.desktop.spi.content.FileViewerProvider new file mode 100644 index 00000000..4a315206 --- /dev/null +++ b/app/src/META-INF/services/com.basic4gl.desktop.spi.content.FileViewerProvider @@ -0,0 +1,5 @@ +com.basic4gl.desktop.content.DefaultImageViewerProvider +com.basic4gl.desktop.content.DefaultAudioViewerProvider +com.basic4gl.desktop.content.MarkdownViewerProvider +com.basic4gl.desktop.content.SimpleTextViewerProvider + diff --git a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java index ad0011ac..26f32cf1 100644 --- a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java @@ -8,11 +8,11 @@ import com.basic4gl.debug.protocol.callbacks.VariablesCallback; import com.basic4gl.debug.protocol.types.DisassembledInstruction; import com.basic4gl.debug.protocol.types.Variable; +import com.basic4gl.desktop.content.FileEditor; import com.basic4gl.desktop.content.FileManager; import com.basic4gl.desktop.debugger.*; import com.basic4gl.desktop.editor.ApMode; import com.basic4gl.desktop.editor.BasicTokenMaker; -import com.basic4gl.desktop.editor.FileEditor; import com.basic4gl.desktop.editor.IEditorPresenter; import com.basic4gl.desktop.spi.*; import com.basic4gl.desktop.spi.language.LanguageSupport; @@ -85,7 +85,12 @@ public class BasicEditor implements MainEditor, IApplicationHost, IFileProvider, private final EditorCommandsService commandsService; public BasicEditor( - String libraryPath, FileManager fileManager, IEditorPresenter presenter, DialogService dialogService, MenuService menuService, EditorCommandsService commandsService) { + String libraryPath, + FileManager fileManager, + IEditorPresenter presenter, + DialogService dialogService, + MenuService menuService, + EditorCommandsService commandsService) { this.libraryPath = libraryPath; this.fileManager = fileManager; this.presenter = presenter; @@ -1184,7 +1189,6 @@ public Basic4GLEditorPluginAdapter getBasic4gl() { return basic4gl; } - // TODO Reimplement callbacks public class DebugCallback implements com.basic4gl.language.core.runtime.DebuggerTaskCallback { @@ -1455,7 +1459,7 @@ public void loadSettings() { syncPluginDirectorySettings(); } - public void onFileOpened(com.basic4gl.desktop.editor.FileEditor editor) { + public void onFileOpened(FileEditor editor) { if (editor == null || editor.getEditorPane() == null) { return; } @@ -1463,7 +1467,7 @@ public void onFileOpened(com.basic4gl.desktop.editor.FileEditor editor) { refreshSyntaxHighlighting(); } - public void onFileSaving(com.basic4gl.desktop.editor.FileEditor editor) { + public void onFileSaving(FileEditor editor) { if (editor == null || editor.getEditorPane() == null) { return; } diff --git a/app/src/main/java/com/basic4gl/desktop/ExportDialog.java b/app/src/main/java/com/basic4gl/desktop/ExportDialog.java index 6fbab002..a579a43e 100644 --- a/app/src/main/java/com/basic4gl/desktop/ExportDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/ExportDialog.java @@ -1,7 +1,7 @@ package com.basic4gl.desktop; import com.basic4gl.compiler.util.IAssetExportBuilder; -import com.basic4gl.desktop.editor.FileEditor; +import com.basic4gl.desktop.content.FileEditor; import com.basic4gl.desktop.spi.*; import com.basic4gl.desktop.util.EditorSourceFile; import com.formdev.flatlaf.ui.FlatTabbedPaneUI; diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index ececd952..68f9f8dc 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -1,7 +1,6 @@ package com.basic4gl.desktop; import static com.basic4gl.desktop.Theme.*; -import static com.basic4gl.desktop.util.HtmlUtil.markdownToHtml; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; import static com.formdev.flatlaf.FlatClientProperties.*; @@ -9,8 +8,7 @@ import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; -import com.basic4gl.desktop.content.FileManager; -import com.basic4gl.desktop.content.IFileManagerListener; +import com.basic4gl.desktop.content.*; import com.basic4gl.desktop.debugger.DebugServerConstants; import com.basic4gl.desktop.debugger.DebugServerFactory; import com.basic4gl.desktop.debugger.IDebugPresenter; @@ -27,25 +25,17 @@ import com.formdev.flatlaf.icons.FlatTabbedPaneCloseIcon; import com.formdev.flatlaf.ui.FlatTabbedPaneUI; import com.formdev.flatlaf.util.SystemInfo; -import javafx.application.Platform; -import javafx.embed.swing.JFXPanel; -import javafx.scene.Scene; -import javafx.scene.web.WebView; import java.awt.*; import java.awt.event.*; import java.io.*; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.*; import java.util.List; import java.util.function.BiConsumer; -import java.util.concurrent.atomic.AtomicBoolean; import javax.swing.*; import javax.swing.border.BevelBorder; import javax.swing.border.EmptyBorder; import javax.swing.event.*; import javax.swing.text.BadLocationException; - import org.fife.ui.rsyntaxtextarea.*; import org.fife.ui.rtextarea.SearchContext; @@ -54,13 +44,13 @@ */ public class MainWindow implements IEditorPresenter, - ITabProvider, - IToggleBreakpointListener, - IFileEditorActionListener, - IFileManagerListener, - EmptyTabPanel.IEmptyTabPanelListener, - MenuService, - EditorCommandsService { + ITabProvider, + IToggleBreakpointListener, + IFileEditorActionListener, + IFileManagerListener, + EmptyTabPanel.IEmptyTabPanelListener, + MenuService, + EditorCommandsService { private final CaretListener TrackCaretPosition = new CaretListener() { @Override @@ -106,7 +96,6 @@ public void caretUpdate(CaretEvent e) { private final ButtonGroup bottomBarGroup = new ButtonGroup(); private final Map bottomBarButtons = new HashMap<>(); - private final JTabbedPane docsTabs = new JTabbedPane(); private final JPanel rightDocsContainer = new JPanel(new BorderLayout()); private final JPanel rightDocsContent = new JPanel(new CardLayout()); private final JToolBar rightDocsRail = new JToolBar(SwingConstants.VERTICAL); @@ -134,8 +123,6 @@ public void caretUpdate(CaretEvent e) { private static final String RECENT_WORKSPACES_KEY = "RECENT_WORKSPACES"; private static final int MAX_RECENT_WORKSPACES = 10; - - private final JMenu bookmarkSubMenu = new JMenu("Bookmarks"); private final JMenu breakpointSubMenu = new JMenu("Breakpoints"); private final JMenu helpMenu = new JMenu("Help"); @@ -183,10 +170,6 @@ public void caretUpdate(CaretEvent e) { private final JLabel compilerStatusLabel = new JLabel(""); // Compiler/VM Status private final JLabel cursorPositionLabel = new JLabel("0:0"); // Cursor Position - private static final String DOCS_MARKDOWN_STYLESHEET_RESOURCE = "/css/docs-markdown.css"; - private static final AtomicBoolean JAVAFX_INITIALIZED = new AtomicBoolean(false); - - // Editors private BasicEditor basicEditor; private FileManager fileManager; @@ -199,7 +182,7 @@ public void caretUpdate(CaretEvent e) { // Debugging private VirtualMachineViewDialog virtualMachineViewDialog; - private IDebugPresenter debugPresenter; + private IDebugPresenter debugPresenter; private int lastSourceRow = -1; private int lastSourceColumn = -1; @@ -303,7 +286,6 @@ public MainWindow() { frame.setPreferredSize(new Dimension(696, 480)); frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); - mainPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); editorSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); workspacePane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); @@ -551,8 +533,6 @@ public void onStepOutRequested() { // TODO mExitMenuItem.setVisible(false); } - - // Toolbar JToolBar toolBar = new JToolBar(); toolBar.add(newButton); @@ -733,19 +713,17 @@ public void windowDeactivated(WindowEvent e) {} fileManager = new FileManager(this); - basicEditor = new BasicEditor(outputBinPath, fileManager, this, - new BasicDialogService(this.frame), - this, this); + basicEditor = new BasicEditor(outputBinPath, fileManager, this, new BasicDialogService(this.frame), this, this); debugPresenter = new DebugPanelProvider(basicEditor); panels = new IEditorPanelProvider[] { - new FileBrowserPanelProvider(), - new AssetsPanelProvider(fileManager), - new BookmarksPanelProvider(), - (IEditorPanelProvider) debugPresenter, - new SymbolsPanelProvider(), - new DocsPanelProvider(), + new FileBrowserPanelProvider(), + new AssetsPanelProvider(fileManager), + new BookmarksPanelProvider(), + (IEditorPanelProvider) debugPresenter, + new SymbolsPanelProvider(), + new DocsPanelProvider(fileManager), }; configureLeftSidebar(); @@ -917,7 +895,7 @@ private void tryCloseWindow() { // ShutDownTomWindowsBasicLib(); frame.dispose(); - for(IEditorPanelProvider panel: panels) { + for (IEditorPanelProvider panel : panels) { panel.dispose(); } System.exit(0); @@ -996,7 +974,8 @@ public void openTab(File file) { fileManager, this, linkGenerator, - searchContext); + searchContext, + basicEditor); addTabWithViewer(viewer); @@ -1334,7 +1313,9 @@ private java.util.List coll File file = editor.getFile(); fileId = file != null ? file.getAbsolutePath() : ""; } - declarations.addAll(basicEditor.getLanguageSupport().extractDeclarations(editor.getEditorPane().getText(), fileId)); + declarations.addAll(basicEditor + .getLanguageSupport() + .extractDeclarations(editor.getEditorPane().getText(), fileId)); } return declarations; } @@ -1658,7 +1639,6 @@ public void changedUpdate(DocumentEvent e) { fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); refreshSidebarContent(); - } @Override @@ -1940,7 +1920,6 @@ public void refreshDebugDisplays(ApMode mode) { syncDebugMenuSelection(); } - @Override public void updateVmViewCallStack(StackTraceCallback stackTraceCallback) { if (virtualMachineViewDialog != null && virtualMachineViewDialog.isDisplayable()) { @@ -1968,7 +1947,6 @@ public void updateEvaluateWatch(String evaluatedWatch, String result) { debugPresenter.updateEvaluateWatch(evaluatedWatch, result); } - @Override public void updateVmViewVariableValue(String expression, String result) { if (virtualMachineViewDialog != null && virtualMachineViewDialog.isDisplayable()) { @@ -1988,7 +1966,6 @@ public void refreshWatchList() { debugPresenter.refreshWatchList(); } - @Override public void onToggleBreakpoint(String filePath, int line) { basicEditor.toggleBreakpt(filePath, line); @@ -2131,10 +2108,6 @@ private void showCreateTabMenu(Component anchor) { openAssetItem.addActionListener(e -> actionOpenAsset()); popup.add(openAssetItem); - JMenuItem openReadmeItem = new JMenuItem("Open README.md in docs"); - openReadmeItem.addActionListener(e -> openMarkdownInDocsTab(new File("README.md"))); - popup.add(openReadmeItem); - popup.show(anchor, 0, anchor.getHeight()); } @@ -2147,11 +2120,8 @@ private void actionOpenAsset() { } File selected = chooser.getSelectedFile(); - if (selected.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - openMarkdownInDocsTab(selected); - } else { - openFileWithPreferredViewer(selected); - } + + openFileWithPreferredViewer(selected); } private void configureLeftSidebar() { @@ -2160,7 +2130,8 @@ private void configureLeftSidebar() { bottomBarRail.setFloatable(false); bottomBarRail.setRollover(true); - Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) .forEach(x -> { leftSidebarContent.add(x.build(this.basicEditor), x.id()); addLeftSidebarButton( @@ -2170,7 +2141,8 @@ private void configureLeftSidebar() { x.getTitle()); }); - Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.SOUTH) + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.SOUTH) .forEach(x -> { bottomBarContent.add(x.build(this.basicEditor), x.id()); addBottomBarButton( @@ -2183,14 +2155,14 @@ private void configureLeftSidebar() { bottomBarContainer.add(bottomBarContent, BorderLayout.CENTER); // Select first panel if available - Arrays.stream(panels).filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) .findFirst() .ifPresent(x -> { selectLeftSidebarSection(x.id(), true); }); } - private void configureRightSidebar() { rightDocsRail.setFloatable(false); rightDocsRail.setRollover(true); @@ -2203,33 +2175,18 @@ private void configureRightSidebar() { createImageIcon(x.getActiveIconPath(), x.getActiveIconTint()), createImageIcon(x.getInactiveIconPath()), x.getTitle()); - JComponent content = "docs".equals(x.id()) ? docsTabs : x.build(this.basicEditor); + JComponent content = x.build(this.basicEditor); if (content != null) { rightDocsContent.add(content, x.id()); } }); - docsTabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); - docsTabs.putClientProperty(TABBED_PANE_TAB_CLOSABLE, true); - docsTabs.putClientProperty( - TABBED_PANE_TAB_CLOSE_CALLBACK, (BiConsumer) (tabPane, tabIndex) -> { - if (tabIndex <= 0 || tabIndex >= docsTabs.getTabCount()) { - return; - } - docsTabs.remove(tabIndex.intValue()); - if (docsTabs.getTabCount() > 0) { - docsTabs.setSelectedIndex(0); - } - selectRightDocsSection("symbols"); - }); - Arrays.stream(panels) .filter(x -> x.getLayoutConstraints() == EditorLayout.EAST) .findFirst() .ifPresent(x -> selectRightDocsSection(x.id())); } - private void addLeftSidebarButton(String key, Icon selectedIcon, Icon icon, String tooltip) { JToggleButton button = createRailButton(selectedIcon, icon, tooltip); button.addActionListener(e -> onLeftSidebarButtonPressed(key)); @@ -2265,7 +2222,6 @@ private JToggleButton createRailButton(Icon selectedIcon, Icon icon, String tool return button; } - private void onLeftSidebarButtonPressed(String key) { if (Objects.equals(activeLeftSidebarKey, key) && isLeftSidebarExpanded()) { collapseLeftSidebar(); @@ -2316,8 +2272,7 @@ private void selectBottomBarSection(String key, boolean ensureExpanded) { } private boolean isLeftSidebarExpanded() { - return !leftSidebarCollapsed - && workspacePane.getLeftComponent() == leftSidebarContent; + return !leftSidebarCollapsed && workspacePane.getLeftComponent() == leftSidebarContent; } private void collapseLeftSidebar() { @@ -2353,25 +2308,21 @@ private void expandLeftSidebar() { int target = Math.max(expandedLeftSidebarWidth, 180); SwingUtilities.invokeLater(() -> { - if (!leftSidebarCollapsed - && workspacePane.getLeftComponent() == leftSidebarContent) { + if (!leftSidebarCollapsed && workspacePane.getLeftComponent() == leftSidebarContent) { setDividerLocationClamped(workspacePane, target); } }); } private boolean isBottomBarExpanded() { - return !bottomBarCollapsed - && mainPane.getBottomComponent() == bottomBarContainer; + return !bottomBarCollapsed && mainPane.getBottomComponent() == bottomBarContainer; } private void collapseBottomBar() { if (mainPane.getBottomComponent() == bottomBarContainer) { int currentHeight = bottomBarContainer.getHeight(); if (currentHeight <= 12 && mainPane.getHeight() > 0) { - currentHeight = mainPane.getHeight() - - mainPane.getDividerLocation() - - mainPane.getDividerSize(); + currentHeight = mainPane.getHeight() - mainPane.getDividerLocation() - mainPane.getDividerSize(); } if (currentHeight > 12) { expandedBottomBarHeight = Math.max(120, currentHeight); @@ -2404,9 +2355,7 @@ private void expandBottomBar() { if (!bottomBarCollapsed && mainPane.getBottomComponent() == bottomBarContainer && mainPane.getHeight() > 0) { - int newDivider = mainPane.getHeight() - - targetBottomHeight - - mainPane.getDividerSize(); + int newDivider = mainPane.getHeight() - targetBottomHeight - mainPane.getDividerSize(); setDividerLocationClamped(mainPane, newDivider); } }); @@ -2438,30 +2387,18 @@ private void selectRightDocsSection(String key) { button.setSelected(true); } - if ("docs".equals(key) && docsTabs.getTabCount() > 1) { - docsTabs.setSelectedIndex(docsTabs.getTabCount() - 1); - } else if (docsTabs.getTabCount() > 0) { - docsTabs.setSelectedIndex(0); - } - expandRightDocs(); - if ("docs".equals(key)) { - docsTabs.requestFocusInWindow(); - } } private boolean isRightDocsExpanded() { - return !rightDocsCollapsed - && contentPane.getRightComponent() == rightDocsContainer; + return !rightDocsCollapsed && contentPane.getRightComponent() == rightDocsContainer; } private void collapseRightDocs() { if (contentPane.getRightComponent() == rightDocsContainer) { int currentWidth = rightDocsContainer.getWidth(); if (currentWidth <= 12 && contentPane.getWidth() > 0) { - currentWidth = contentPane.getWidth() - - contentPane.getDividerLocation() - - contentPane.getDividerSize(); + currentWidth = contentPane.getWidth() - contentPane.getDividerLocation() - contentPane.getDividerSize(); } if (currentWidth > 12) { expandedRightDocsWidth = currentWidth; @@ -2493,9 +2430,7 @@ private void expandRightDocs() { if (!rightDocsCollapsed && contentPane.getRightComponent() == rightDocsContainer && contentPane.getWidth() > 0) { - int newDivider = contentPane.getWidth() - - targetDocsWidth - - contentPane.getDividerSize(); + int newDivider = contentPane.getWidth() - targetDocsWidth - contentPane.getDividerSize(); setDividerLocationClamped(contentPane, newDivider); } }); @@ -2517,10 +2452,6 @@ private void refreshSidebarContent() { } } - - - - private void refreshRunnableFileControls() { if (fileManager == null) { return; @@ -2588,7 +2519,7 @@ public String collectAllSourceText() { return ""; } StringBuilder sb = new StringBuilder(); - for (com.basic4gl.desktop.editor.FileEditor fe : fileManager.getFileEditors()) { + for (FileEditor fe : fileManager.getFileEditors()) { if (sb.length() > 0) { sb.append('\n'); } @@ -2650,99 +2581,6 @@ public void goToBookmark(String filePath, int lineNumber) { } } - - public void openMarkdownInDocsTab(File file) { - File resolved = file.isAbsolute() ? file : new File(fileManager.getCurrentDirectory(), file.getPath()); - if (!resolved.exists()) { - resolved = file; - } - if (!resolved.exists()) { - JOptionPane.showMessageDialog(frame, "Markdown file not found: " + file.getPath()); - return; - } - - String tabTitle = resolved.getName(); - for (int i = 0; i < docsTabs.getTabCount(); i++) { - if (tabTitle.equals(docsTabs.getTitleAt(i))) { - docsTabs.setSelectedIndex(i); - selectRightDocsSection("docs"); - return; - } - } - - try { - String markdown = Files.readString(resolved.toPath(), StandardCharsets.UTF_8); - JFXPanel panel = new JFXPanel(); - String html = buildMarkdownDocumentHtml(markdownToHtml(markdown)); - ensureJavaFxInitialized(); - Platform.runLater(() -> { - try { - WebView webView = new WebView(); - webView.getEngine().loadContent(html, "text/html"); - panel.setScene(new Scene(webView)); - } catch (Throwable ex) { - showDocsFallback(panel, html, ex); - } - }); - - docsTabs.addTab(tabTitle, panel); - docsTabs.setSelectedIndex(docsTabs.getTabCount() - 1); - selectRightDocsSection("docs"); - } catch (IOException ex) { - JOptionPane.showMessageDialog(frame, "Unable to open markdown file: " + ex.getMessage()); - } catch (Throwable ex) { - JOptionPane.showMessageDialog(frame, "Unable to render markdown file: " + ex.getMessage()); - } - } - - private void ensureJavaFxInitialized() { - if (!JAVAFX_INITIALIZED.compareAndSet(false, true)) { - return; - } - try { - Platform.startup(() -> {}); - } catch (IllegalStateException ignored) { - // Toolkit already initialized by JFXPanel startup. - } - Platform.setImplicitExit(false); - } - - private void showDocsFallback(JFXPanel panel, String html, Throwable ex) { - System.err.println("Unable to initialize JavaFX WebView: " + ex.getMessage()); - SwingUtilities.invokeLater(() -> { - JEditorPane fallbackPane = new JEditorPane(); - fallbackPane.setEditable(false); - fallbackPane.setContentType("text/html"); - fallbackPane.setText(html); - fallbackPane.setCaretPosition(0); - panel.setLayout(new BorderLayout()); - panel.add(new JScrollPane(fallbackPane), BorderLayout.CENTER); - panel.revalidate(); - panel.repaint(); - }); - } - - private String buildMarkdownDocumentHtml(String bodyHtml) { - String stylesheetText = readTextResource(DOCS_MARKDOWN_STYLESHEET_RESOURCE); - return "" - + bodyHtml - + ""; - } - - private String readTextResource(String resourcePath) { - try (InputStream input = MainWindow.class.getResourceAsStream(resourcePath)) { - if (input == null) { - return ""; - } - return new String(input.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException ex) { - System.err.println("Unable to load resource " + resourcePath + ": " + ex.getMessage()); - return ""; - } - } - private int findOpenTabIndexByPath(String absolutePath) { if (absolutePath == null || absolutePath.isBlank()) { return -1; @@ -2783,8 +2621,8 @@ public void insertText(String text, int caretOffset) { JTextArea editorPane = fileManager.getFileEditors().get(selectedTab).getEditorPane(); int insertStart = editorPane.getSelectionStart(); editorPane.replaceSelection(text); - editorPane.setCaretPosition(Math.min( - insertStart + caretOffset, editorPane.getDocument().getLength())); + editorPane.setCaretPosition( + Math.min(insertStart + caretOffset, editorPane.getDocument().getLength())); editorPane.requestFocusInWindow(); } @@ -2956,4 +2794,4 @@ public void addHelp(String label, com.basic4gl.desktop.spi.MenuActionListener li helpMenuItem.addActionListener(e -> listener.actionPerformed(frame, e)); helpMenu.add(helpMenuItem); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java index 092423d3..d7978cb1 100644 --- a/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java +++ b/app/src/main/java/com/basic4gl/desktop/ProjectSettingsDialog.java @@ -1,5 +1,7 @@ package com.basic4gl.desktop; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; + import com.basic4gl.app.desktop.config.IConfigurableAppSettings; import com.basic4gl.desktop.spi.Builder; import com.basic4gl.desktop.spi.Configuration; @@ -13,8 +15,6 @@ import javax.swing.border.EmptyBorder; import javax.swing.border.MatteBorder; -import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; - public class ProjectSettingsDialog implements com.basic4gl.desktop.spi.ConfigurationFormPanel.IOnConfigurationChangeListener { diff --git a/app/src/main/java/com/basic4gl/desktop/content/AssetService.java b/app/src/main/java/com/basic4gl/desktop/content/AssetService.java index 38497524..6b4ece80 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/AssetService.java +++ b/app/src/main/java/com/basic4gl/desktop/content/AssetService.java @@ -1,19 +1,9 @@ package com.basic4gl.desktop.content; -import com.basic4gl.desktop.spi.FileUtil; -import com.basic4gl.desktop.spi.LanguageService; - -import java.io.File; -import java.util.ArrayList; -import java.util.Locale; - -import static com.basic4gl.desktop.util.FileUtil.getMediaTypeLabel; - public class AssetService { private final FileManager fileManager; public AssetService(FileManager fileManager) { this.fileManager = fileManager; } - } diff --git a/app/src/main/java/com/basic4gl/desktop/editor/AudioFileViewer.java b/app/src/main/java/com/basic4gl/desktop/content/AudioFileViewer.java similarity index 94% rename from app/src/main/java/com/basic4gl/desktop/editor/AudioFileViewer.java rename to app/src/main/java/com/basic4gl/desktop/content/AudioFileViewer.java index 53f386bc..2a5c45f3 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/AudioFileViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/AudioFileViewer.java @@ -1,5 +1,6 @@ -package com.basic4gl.desktop.editor; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.editor.IFileViewer; import java.awt.*; import java.io.File; import javax.swing.*; @@ -162,4 +163,17 @@ public void setModified() { public ViewerType getViewerType() { return ViewerType.AUDIO_VIEWER; } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.DEFAULT; + } } diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewer.java b/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewer.java similarity index 96% rename from app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewer.java rename to app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewer.java index 4036a788..4878e76e 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewer.java @@ -1,5 +1,8 @@ -package com.basic4gl.desktop.spi.content; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerException; import java.awt.*; import java.io.IOException; import java.nio.file.Files; @@ -121,7 +124,7 @@ public void mouseReleased(java.awt.event.MouseEvent e) { } @Override - public void loadFile(Path path) throws FileViewerException { + public void loadFile(PluginContext context, Path path) throws FileViewerException { try { stop(); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewerProvider.java b/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewerProvider.java similarity index 74% rename from app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewerProvider.java rename to app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewerProvider.java index 3632f28d..ed940813 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultAudioViewerProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/content/DefaultAudioViewerProvider.java @@ -1,4 +1,8 @@ -package com.basic4gl.desktop.spi.content; +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerMetadata; +import com.basic4gl.desktop.spi.content.FileViewerProvider; /** * Provider for DefaultAudioViewer diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewer.java b/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewer.java similarity index 93% rename from app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewer.java rename to app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewer.java index 79e5e636..104b1025 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewer.java @@ -1,5 +1,8 @@ -package com.basic4gl.desktop.spi.content; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerException; import java.awt.*; import java.awt.image.BufferedImage; import java.nio.file.Files; @@ -57,7 +60,7 @@ protected void paintComponent(Graphics g) { } @Override - public void loadFile(Path path) throws FileViewerException { + public void loadFile(PluginContext context, Path path) throws FileViewerException { try { if (!Files.exists(path)) { throw new FileViewerException("File not found: " + path); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewerProvider.java b/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewerProvider.java similarity index 78% rename from app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewerProvider.java rename to app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewerProvider.java index febe2adb..0ab89398 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DefaultImageViewerProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/content/DefaultImageViewerProvider.java @@ -1,4 +1,8 @@ -package com.basic4gl.desktop.spi.content; +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerMetadata; +import com.basic4gl.desktop.spi.content.FileViewerProvider; /** * Provider for DefaultImageViewer diff --git a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java b/app/src/main/java/com/basic4gl/desktop/content/FileEditor.java similarity index 98% rename from app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java rename to app/src/main/java/com/basic4gl/desktop/content/FileEditor.java index 45c298c8..f52196fb 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/FileEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileEditor.java @@ -1,5 +1,7 @@ -package com.basic4gl.desktop.editor; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; import com.basic4gl.desktop.language.Basic4GLFoldParser; import com.basic4gl.desktop.util.EditorUtil; import com.basic4gl.desktop.util.IFileManager; @@ -95,8 +97,7 @@ public FileEditor( KeyStroke.getKeyStroke(KeyEvent.VK_F2, InputEvent.SHIFT_MASK), RTextAreaEditorKit.rtaPrevBookmarkAction); inputMap.put( - KeyStroke.getKeyStroke(KeyEvent.VK_F2, toolkit.getMenuShortcutKeyMask()), - "B4GL.ToggleBookmarkAction"); + KeyStroke.getKeyStroke(KeyEvent.VK_F2, toolkit.getMenuShortcutKeyMask()), "B4GL.ToggleBookmarkAction"); inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0), "RTA.NextBreakpointAction"); inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.SHIFT_MASK), "RTA.PrevBreakpointAction"); @@ -117,14 +118,12 @@ public FileEditor( RTextAreaEditorKit.rtaToggleBookmarkAction, new MultiHeaderBookmarkActions.MultiHeaderToggleBookmarkAction( RTextAreaEditorKit.rtaToggleBookmarkAction, HEADER_BOOKMARK)); - actionMap.put( - "B4GL.ToggleBookmarkAction", - new AbstractAction() { - @Override - public void actionPerformed(ActionEvent e) { - FileEditor.this.toggleBookmark(); - } - }); + actionMap.put("B4GL.ToggleBookmarkAction", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + FileEditor.this.toggleBookmark(); + } + }); actionMap.put( "RTA.NextBreakpointAction", diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileManager.java b/app/src/main/java/com/basic4gl/desktop/content/FileManager.java index b9e78748..85dc914e 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileManager.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileManager.java @@ -1,6 +1,5 @@ package com.basic4gl.desktop.content; -import com.basic4gl.desktop.editor.FileEditor; import com.basic4gl.desktop.util.IFileManager; import com.basic4gl.language.core.internal.Mutable; import java.io.File; diff --git a/app/src/main/java/com/basic4gl/desktop/editor/FileViewerFactory.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java similarity index 84% rename from app/src/main/java/com/basic4gl/desktop/editor/FileViewerFactory.java rename to app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java index f1595b15..990183a3 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/FileViewerFactory.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java @@ -1,8 +1,11 @@ -package com.basic4gl.desktop.editor; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; +import com.basic4gl.desktop.spi.PluginContext; import java.io.File; import java.util.Locale; -import javax.swing.*; import org.fife.ui.rsyntaxtextarea.LinkGenerator; import org.fife.ui.rtextarea.SearchContext; @@ -59,6 +62,7 @@ public static IFileViewer.ViewerType getViewerType(File file) { * @param toggleBreakpointListener listener for breakpoint toggles * @param linkGenerator link generator for hyperlinks * @param searchContext search context for find/replace + * @param pluginContext plugin context for accessing IDE services * @return a new IFileViewer instance */ public static IFileViewer createViewer( @@ -68,7 +72,8 @@ public static IFileViewer createViewer( com.basic4gl.desktop.util.IFileManager fileManager, IToggleBreakpointListener toggleBreakpointListener, LinkGenerator linkGenerator, - SearchContext searchContext) { + SearchContext searchContext, + PluginContext pluginContext) { IFileViewer.ViewerType viewerType = preferredViewerType; @@ -83,18 +88,22 @@ public static IFileViewer createViewer( if (file != null && isImageFile(file.getName().toLowerCase(Locale.ROOT))) { return new ImageFileViewer(file); } - // Fall through to hex viewer if not an image case HEX_VIEWER: return new HexFileViewer(file); case AUDIO_VIEWER: if (file != null && isAudioFile(file.getName().toLowerCase(Locale.ROOT))) { return new AudioFileViewer(file); } - // Fall through to text editor if not audio case MARKDOWN_VIEWER: - // Markdown is handled specially (usually in docs tabs), but provide fallback - return new TextFileViewer( - file, actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + if (file != null && file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + return new MarkdownViewer(pluginContext, file); + } + case HTML_VIEWER: + if (file != null + && (file.getName().toLowerCase(Locale.ROOT).endsWith(".html") + || file.getName().toLowerCase(Locale.ROOT).endsWith(".htm"))) { + return new HtmlViewer(pluginContext, file); + } case TEXT_EDITOR: default: return new TextFileViewer( diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java index a482624c..2dddb825 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerManager.java @@ -1,5 +1,6 @@ package com.basic4gl.desktop.content; +import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.spi.content.FileViewerException; import java.nio.file.Path; @@ -52,7 +53,7 @@ public static void initialize() { * @param filepath Path to file to view * @return FileViewerResult containing viewer or error details */ - public static FileViewerRegistry.FileViewerResult loadFile(Path filepath) { + public static FileViewerRegistry.FileViewerResult loadFile(PluginContext context, Path filepath) { if (!initialized) { initialize(); } @@ -60,7 +61,7 @@ public static FileViewerRegistry.FileViewerResult loadFile(Path filepath) { FileViewerRegistry.FileViewerResult result = registry.findViewer(filepath); if (result.isSuccess()) { try { - result.getViewer().loadFile(filepath); + result.getViewer().loadFile(context, filepath); } catch (FileViewerException e) { return new FileViewerRegistry.FileViewerResult(null, null, e.getMessage()); } diff --git a/app/src/main/java/com/basic4gl/desktop/editor/FileViewerWrapper.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java similarity index 89% rename from app/src/main/java/com/basic4gl/desktop/editor/FileViewerWrapper.java rename to app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java index 6f3b7731..2f894dad 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/FileViewerWrapper.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java @@ -1,5 +1,6 @@ -package com.basic4gl.desktop.editor; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.editor.IFileViewer; import java.io.File; /** @@ -57,6 +58,19 @@ public void setModified() { public ViewerType getViewerType() { return ViewerType.TEXT_EDITOR; } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.DEFAULT; + } }; this.textEditor = editor; } diff --git a/app/src/main/java/com/basic4gl/desktop/editor/HexFileViewer.java b/app/src/main/java/com/basic4gl/desktop/content/HexFileViewer.java similarity index 93% rename from app/src/main/java/com/basic4gl/desktop/editor/HexFileViewer.java rename to app/src/main/java/com/basic4gl/desktop/content/HexFileViewer.java index bc8713af..f42630e4 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/HexFileViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/HexFileViewer.java @@ -1,5 +1,6 @@ -package com.basic4gl.desktop.editor; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.editor.IFileViewer; import java.awt.*; import java.io.File; import java.io.FileInputStream; @@ -148,4 +149,17 @@ public void setModified() { public ViewerType getViewerType() { return ViewerType.HEX_VIEWER; } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.DEFAULT; + } } diff --git a/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java b/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java new file mode 100644 index 00000000..212bc68e --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java @@ -0,0 +1,204 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerException; +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; +import javafx.scene.Scene; +import javafx.scene.web.WebView; +import javax.swing.*; + +public class HtmlViewer implements FileViewer, IFileViewer { + private static final AtomicBoolean JAVAFX_INITIALIZED = new AtomicBoolean(false); + + private final JFXPanel panel; + + private WebView webView; + + protected File file; + + protected String source; + protected boolean readOnly = false; + + protected ViewMode viewMode = ViewMode.PREVIEW; + + public HtmlViewer() { + panel = new JFXPanel(); + + ensureJavaFxInitialized(); + Platform.runLater(() -> { + try { + webView = new WebView(); + panel.setScene(new Scene(webView)); + } catch (Throwable ex) { + showDocsFallback(panel, "", ex); + } + }); + } + + public HtmlViewer(PluginContext pluginContext, File file) { + this(); + try { + loadFile(pluginContext, file.toPath()); + } catch (FileViewerException ex) { + pluginContext.dialogs().showDialog("Unable to load HTML file: " + ex.getMessage()); + } + } + + @Override + public void loadFile(PluginContext context, Path path) throws FileViewerException { + file = path.toFile(); + + try { + + String html = Files.readString(path, StandardCharsets.UTF_8); + + panel.putClientProperty("docs.path", path); + + loadHtmlContent(html); + + } catch (IOException ex) { + context.dialogs().showDialog("Unable to load file: " + ex.getMessage()); + } catch (Throwable ex) { + context.dialogs().showDialog("Unable to render html file: " + ex.getMessage()); + } + } + + protected void loadHtmlContent(String html) { + + ensureJavaFxInitialized(); + + Platform.runLater(() -> { + try { + webView.getEngine().loadContent(html, "text/html"); + } catch (Throwable ex) { + showDocsFallback(panel, "", ex); + } + }); + } + + @Override + public JComponent getComponent() { + return panel; + } + + @Override + public boolean canHandle(String filename, String mimeType) { + if (filename.toLowerCase().endsWith(".html") || filename.toLowerCase().endsWith(".htm")) { + return true; + } + + return mimeType.equals("text/html"); + } + + @Override + public String getName() { + return "HTML Viewer"; + } + + @Override + public String getVersion() { + return "1.0.0"; + } + + @Override + public void dispose() { + // No resources to dispose + } + + private void ensureJavaFxInitialized() { + if (!JAVAFX_INITIALIZED.compareAndSet(false, true)) { + return; + } + try { + Platform.startup(() -> {}); + } catch (IllegalStateException ignored) { + // Toolkit already initialized by JFXPanel startup. + } + Platform.setImplicitExit(false); + } + + private void showDocsFallback(JFXPanel panel, String html, Throwable ex) { + System.err.println("Unable to initialize JavaFX WebView: " + ex.getMessage()); + SwingUtilities.invokeLater(() -> { + JEditorPane fallbackPane = new JEditorPane(); + fallbackPane.setEditable(false); + fallbackPane.setContentType("text/html"); + fallbackPane.setText(html); + fallbackPane.setCaretPosition(0); + panel.setLayout(new BorderLayout()); + panel.add(new JScrollPane(fallbackPane), BorderLayout.CENTER); + panel.revalidate(); + panel.repaint(); + }); + } + + @Override + public String getTitle() { + return file != null ? file.getName() : "[HTML]"; + } + + @Override + public String getFilePath() { + return file != null ? file.getAbsolutePath() : ""; + } + + @Override + public JComponent getContentPane() { + return panel; + } + + @Override + public File getFile() { + return file; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[HTML]"; + } + + @Override + public boolean isModified() { + // TODO implement switching between preview and edit mode, wrapping TextFileViewer + return false; + } + + @Override + public void setModified() { + // TODO implement switching between preview and edit mode, wrapping TextFileViewer + } + + @Override + public ViewerType getViewerType() { + return ViewerType.HTML_VIEWER; + } + + @Override + public boolean hasPreview() { + return true; + } + + @Override + public void setViewMode(ViewMode viewMode) { + if (viewMode == ViewMode.DEFAULT) { + this.viewMode = ViewMode.PREVIEW; + } else { + this.viewMode = viewMode; + } + } + + @Override + public ViewMode getViewMode() { + return viewMode; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/ImageFileViewer.java b/app/src/main/java/com/basic4gl/desktop/content/ImageFileViewer.java similarity index 89% rename from app/src/main/java/com/basic4gl/desktop/editor/ImageFileViewer.java rename to app/src/main/java/com/basic4gl/desktop/content/ImageFileViewer.java index 1a190ee3..0607d776 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/ImageFileViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/ImageFileViewer.java @@ -1,5 +1,6 @@ -package com.basic4gl.desktop.editor; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.editor.IFileViewer; import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; @@ -101,4 +102,17 @@ public void setModified() { public ViewerType getViewerType() { return ViewerType.IMAGE_VIEWER; } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.DEFAULT; + } } diff --git a/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java new file mode 100644 index 00000000..d5bd1422 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java @@ -0,0 +1,86 @@ +package com.basic4gl.desktop.content; + +import static com.basic4gl.desktop.util.HtmlUtil.markdownToHtml; + +import com.basic4gl.desktop.MainWindow; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewerException; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.*; + +public class MarkdownViewer extends HtmlViewer { + + private static final String DOCS_MARKDOWN_STYLESHEET_RESOURCE = "/css/docs-markdown.css"; + private static final String DOCS_EXPLORER_TAB_TITLE = "Explorer"; + + public MarkdownViewer() { + super(); + } + + public MarkdownViewer(PluginContext pluginContext, File file) { + super(); + try { + loadFile(pluginContext, file.toPath()); + } catch (FileViewerException ex) { + pluginContext.dialogs().showDialog("Unable to load markdown file: " + ex.getMessage()); + } + } + + @Override + public void loadFile(PluginContext context, Path path) throws FileViewerException { + file = path.toFile(); + + try { + String markdown = Files.readString(path, StandardCharsets.UTF_8); + String html = buildMarkdownDocumentHtml(markdownToHtml(markdown)); + + loadHtmlContent(html); + + } catch (IOException ex) { + context.dialogs().showDialog("Unable to open markdown file: " + ex.getMessage()); + } catch (Throwable ex) { + context.dialogs().showDialog("Unable to render markdown file: " + ex.getMessage()); + } + } + + @Override + public String getTitle() { + return file != null ? file.getName() : "[Markdown]"; + } + + @Override + public String getShortFilename() { + return file != null ? file.getName() : "[Markdown]"; + } + + @Override + public ViewerType getViewerType() { + return ViewerType.MARKDOWN_VIEWER; + } + + private String readTextResource(String resourcePath) { + try (InputStream input = MainWindow.class.getResourceAsStream(resourcePath)) { + if (input == null) { + return ""; + } + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException ex) { + System.err.println("Unable to load resource " + resourcePath + ": " + ex.getMessage()); + return ""; + } + } + + private String buildMarkdownDocumentHtml(String bodyHtml) { + String stylesheetText = readTextResource(DOCS_MARKDOWN_STYLESHEET_RESOURCE); + return "" + + bodyHtml + + ""; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewerProvider.java b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewerProvider.java new file mode 100644 index 00000000..876dfbe2 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewerProvider.java @@ -0,0 +1,23 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerMetadata; +import com.basic4gl.desktop.spi.content.FileViewerProvider; + +public class MarkdownViewerProvider implements FileViewerProvider { + + private static final FileViewerMetadata METADATA = new FileViewerMetadata( + "Markdown Viewer", "1.0.0", "Simple viewer for Markdown files", new String[] {".md"}, new String[] { + "text/markdown" + }); + + @Override + public FileViewer createViewer() { + return new SimpleTextViewer(); + } + + @Override + public FileViewerMetadata getMetadata() { + return METADATA; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewer.java b/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewer.java similarity index 91% rename from app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewer.java rename to app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewer.java index 681185e4..62d6841c 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewer.java @@ -1,5 +1,8 @@ -package com.basic4gl.desktop.spi.content; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerException; import java.awt.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -34,7 +37,7 @@ public SimpleTextViewer() { } @Override - public void loadFile(Path path) throws FileViewerException { + public void loadFile(PluginContext context, Path path) throws FileViewerException { try { if (!Files.exists(path)) { throw new FileViewerException("File not found: " + path); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewerProvider.java b/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewerProvider.java similarity index 79% rename from app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewerProvider.java rename to app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewerProvider.java index c0ef9baf..e538844c 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/SimpleTextViewerProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/content/SimpleTextViewerProvider.java @@ -1,4 +1,8 @@ -package com.basic4gl.desktop.spi.content; +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.FileViewer; +import com.basic4gl.desktop.spi.content.FileViewerMetadata; +import com.basic4gl.desktop.spi.content.FileViewerProvider; /** * Provider for SimpleTextViewer diff --git a/app/src/main/java/com/basic4gl/desktop/editor/TextFileViewer.java b/app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java similarity index 88% rename from app/src/main/java/com/basic4gl/desktop/editor/TextFileViewer.java rename to app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java index 0aa45899..0609168c 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/TextFileViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java @@ -1,5 +1,8 @@ -package com.basic4gl.desktop.editor; +package com.basic4gl.desktop.content; +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; import com.basic4gl.desktop.util.IFileManager; import java.io.File; import java.io.FileReader; @@ -108,4 +111,17 @@ public void setModified() { public ViewerType getViewerType() { return ViewerType.TEXT_EDITOR; } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.DEFAULT; + } } diff --git a/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java b/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java index e5f7f1be..088d8388 100644 --- a/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java +++ b/app/src/main/java/com/basic4gl/desktop/debugger/IDebugPresenter.java @@ -5,7 +5,6 @@ public interface IDebugPresenter { - void updateCallStack(StackTraceCallback message); void refreshWatchList(); diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java b/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java index 53abecb7..4b868b1c 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IFileEditorActionListener.java @@ -2,5 +2,6 @@ public interface IFileEditorActionListener { void onSearchResult(String message); + void onBookmarksChanged(String filePath); } diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java b/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java index fff8619a..8ee7f257 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java @@ -51,6 +51,19 @@ public interface IFileViewer { */ ViewerType getViewerType(); + boolean hasPreview(); + + void setViewMode(ViewMode viewMode); + + ViewMode getViewMode(); + + enum ViewMode { + DEFAULT, + EDITOR, + EDITOR_AND_PREVIEW, + PREVIEW, + } + /** * Enumeration of supported viewer types */ @@ -59,7 +72,8 @@ enum ViewerType { IMAGE_VIEWER("Image Viewer"), AUDIO_VIEWER("Audio Viewer"), HEX_VIEWER("Hex Editor"), - MARKDOWN_VIEWER("Markdown Viewer"); + MARKDOWN_VIEWER("Markdown Viewer"), + HTML_VIEWER("HTML Viewer"); public final String display; diff --git a/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java index f473cfdf..cb0b8aca 100644 --- a/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java +++ b/app/src/main/java/com/basic4gl/desktop/language/SymbolIndexer.java @@ -1,9 +1,7 @@ package com.basic4gl.desktop.language; -import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.language.IndexedSymbol; import com.basic4gl.desktop.spi.language.LanguageSupport; - import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.concurrent.Executors; @@ -62,10 +60,7 @@ public interface Callback { private ScheduledFuture pending; private long requestedRevision = 0; - public SymbolIndexer( - LanguageSupport languageSupport, - SourceProvider sourceProvider, - Callback callback) { + public SymbolIndexer(LanguageSupport languageSupport, SourceProvider sourceProvider, Callback callback) { this.languageSupport = languageSupport; this.sourceProvider = sourceProvider; this.callback = callback; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java index c8e59a7f..f9f31d1a 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java @@ -1,13 +1,32 @@ package com.basic4gl.desktop.panels; +import static com.basic4gl.desktop.Theme.ICON_MENU_ASSETS; +import static com.basic4gl.desktop.Theme.ICON_MENU_FOLDER; +import static com.basic4gl.desktop.Theme.ICON_REFRESH; +import static com.basic4gl.desktop.Theme.ICON_SEARCH; +import static com.basic4gl.desktop.Theme.ICON_VIEW_GRID; +import static com.basic4gl.desktop.Theme.ICON_VIEW_LIST; +import static com.basic4gl.desktop.util.FileUtil.*; +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; +import static com.basic4gl.desktop.util.SwingIconUtil.*; +import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; + +import com.basic4gl.desktop.content.FileEditor; import com.basic4gl.desktop.content.FileManager; -import com.basic4gl.desktop.editor.FileViewerFactory; +import com.basic4gl.desktop.content.FileViewerFactory; import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.FileUtil; import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.util.RoundedCardPanel; - +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.util.*; +import java.util.List; import javax.swing.*; import javax.swing.border.EmptyBorder; import javax.swing.event.DocumentEvent; @@ -17,25 +36,6 @@ import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreePath; -import java.awt.*; -import java.awt.datatransfer.StringSelection; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; -import java.io.File; -import java.util.*; -import java.util.List; - -import static com.basic4gl.desktop.Theme.ICON_REFRESH; -import static com.basic4gl.desktop.Theme.ICON_SEARCH; -import static com.basic4gl.desktop.Theme.ICON_VIEW_GRID; -import static com.basic4gl.desktop.Theme.ICON_VIEW_LIST; -import static com.basic4gl.desktop.Theme.ICON_MENU_ASSETS; -import static com.basic4gl.desktop.Theme.ICON_MENU_FOLDER; -import static com.basic4gl.desktop.util.FileUtil.*; -import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; -import static com.basic4gl.desktop.util.SwingIconUtil.*; -import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; -import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; public class AssetsPanelProvider implements IEditorPanelProvider { @@ -79,7 +79,7 @@ public String toString() { } public AssetsPanelProvider(FileManager fileManager) { - this.fileManager = fileManager; + this.fileManager = fileManager; } @Override @@ -132,16 +132,8 @@ public JPanel build(PluginContext context) { JPanel layoutTabs = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); layoutTabs.setOpaque(false); ButtonGroup layoutButtons = new ButtonGroup(); - JToggleButton treeLayoutButton = createAssetsLayoutButton( - "List View", - ICON_VIEW_LIST, - LAYOUT_TREE, - "first"); - JToggleButton gridLayoutButton = createAssetsLayoutButton( - "Grid View", - ICON_VIEW_GRID, - LAYOUT_GRID, - "last"); + JToggleButton treeLayoutButton = createAssetsLayoutButton("List View", ICON_VIEW_LIST, LAYOUT_TREE, "first"); + JToggleButton gridLayoutButton = createAssetsLayoutButton("Grid View", ICON_VIEW_GRID, LAYOUT_GRID, "last"); layoutButtons.add(treeLayoutButton); layoutButtons.add(gridLayoutButton); treeLayoutButton.setSelected(true); @@ -360,6 +352,7 @@ private JToggleButton createHeaderSearchToggleButton() { button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); return button; } + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { Color cardBackground = createLighterPanelBackground(); JPanel card = new RoundedCardPanel(); @@ -400,12 +393,8 @@ private void openAssetItem(AssetItem item) { if (item == null || !item.isOpenable()) { return; } - if (item.file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - // TODO add markdown viewer instead of openMarkdownInDocsTab; md, docs, and assets/browser should be separate concerns - context.commands().openMarkdownInDocsTab(item.file); - } else { - context.commands().openFileWithPreferredViewer(item.file); - } + + context.commands().openFileWithPreferredViewer(item.file); } private void maybeShowAssetsTreePopup(MouseEvent e) { @@ -494,18 +483,16 @@ public void refresh(EditorPlugin languageProvider) { java.util.List workspaceAssets = collectWorkspaceAssets(rootDir, 0, 4); workspaceAssets = filterAssetFiles(workspaceAssets, rootDir, searchNeedle); DefaultMutableTreeNode workspaceNode = buildMediaTypeSection( - "Workspace Resources", - workspaceAssets, - rootDir, - createImageIcon(ICON_MENU_FOLDER)); + "Workspace Resources", workspaceAssets, rootDir, createImageIcon(ICON_MENU_FOLDER)); if (workspaceNode != null) { rootNode.add(workspaceNode); } - java.util.List literalAssets = detectLiteralAssets(rootDir, context.currentEditor().getLanguage()); + java.util.List literalAssets = + detectLiteralAssets(rootDir, context.currentEditor().getLanguage()); literalAssets = filterAssetFiles(literalAssets, rootDir, searchNeedle); - DefaultMutableTreeNode literalNode = buildMediaTypeSection( - "Embedded Literals", literalAssets, rootDir, createImageIcon(ICON_MENU_ASSETS)); + DefaultMutableTreeNode literalNode = + buildMediaTypeSection("Embedded Literals", literalAssets, rootDir, createImageIcon(ICON_MENU_ASSETS)); if (literalNode != null) { rootNode.add(literalNode); } @@ -537,7 +524,9 @@ private java.util.List filterAssetFiles(java.util.List files, File b String absolute = file.getAbsolutePath().toLowerCase(Locale.ROOT); String relative = formatRelativePath(file, baseDir); String relativeLower = relative == null ? "" : relative.toLowerCase(Locale.ROOT); - if (name.contains(searchNeedle) || absolute.contains(searchNeedle) || relativeLower.contains(searchNeedle)) { + if (name.contains(searchNeedle) + || absolute.contains(searchNeedle) + || relativeLower.contains(searchNeedle)) { filtered.add(file); } } @@ -545,19 +534,13 @@ private java.util.List filterAssetFiles(java.util.List files, File b } @Override - public void onFileModified(String filePath) { - - } + public void onFileModified(String filePath) {} @Override - public void dispose() { - - } + public void dispose() {} @Override - public void onCompileSucceeded() { - - } + public void onCompileSucceeded() {} private java.util.List collectOpenableAssets(DefaultMutableTreeNode rootNode) { java.util.List items = new ArrayList<>(); @@ -625,8 +608,6 @@ private void collectResolvedLibraryAssets(java.util.List paths, File bas } } - - private java.util.List collectWorkspaceAssets(File directory, int depth, int maxDepth) { java.util.List assets = new ArrayList<>(); collectWorkspaceAssets(directory, depth, maxDepth, assets); @@ -706,13 +687,14 @@ private Icon getAssetGridIcon(AssetItem item) { assetThumbnailCache.put(cacheKey, icon); return icon; } + private java.util.List detectLiteralAssets(File baseDir, LanguageService languageService) { java.util.LinkedHashSet detected = new java.util.LinkedHashSet<>(); if (fileManager == null) { return new ArrayList<>(); } - for (com.basic4gl.desktop.editor.FileEditor editor : fileManager.getFileEditors()) { + for (FileEditor editor : fileManager.getFileEditors()) { if (editor == null || editor.getEditorPane() == null) { continue; } diff --git a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java index a904dd90..66ddb5e1 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java @@ -1,10 +1,12 @@ package com.basic4gl.desktop.panels; +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; + import com.basic4gl.desktop.spi.BookmarkInfo; import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.util.RoundedCardPanel; - import java.awt.*; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; @@ -16,9 +18,6 @@ import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; -import static com.basic4gl.desktop.Theme.*; -import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; - public class BookmarksPanelProvider implements IEditorPanelProvider { private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java index d16a8303..b405842a 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DebugPanelProvider.java @@ -1,5 +1,9 @@ package com.basic4gl.desktop.panels; +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; + import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.desktop.BasicEditor; import com.basic4gl.desktop.debugger.IDebugPresenter; @@ -8,20 +12,14 @@ import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.util.RoundedCardPanel; import com.basic4gl.desktop.util.SwingUtil; - -import javax.swing.*; -import javax.swing.border.EmptyBorder; - import java.awt.*; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.Objects; - -import static com.basic4gl.desktop.Theme.*; -import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; -import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; +import javax.swing.*; +import javax.swing.border.EmptyBorder; public class DebugPanelProvider implements IEditorPanelProvider, IDebugPresenter { @@ -38,9 +36,8 @@ public class DebugPanelProvider implements IEditorPanelProvider, IDebugPresenter private final BasicEditor basicEditor; private PluginContext context; - public DebugPanelProvider(BasicEditor basicEditor) { - //TODO this is working up to a circular dependency.. can't init BasicEditor with this as IDebugPresenter + // TODO this is working up to a circular dependency.. can't init BasicEditor with this as IDebugPresenter this.basicEditor = basicEditor; } @@ -170,7 +167,7 @@ public void keyReleased(KeyEvent e) { JPanel gosubFrame = new JPanel(); gosubFrame.setLayout(new BorderLayout()); gosubFrame.setBackground(panelBackground); - JLabel callstackLabel = new JLabel("Callstack"); + JLabel callstackLabel = new JLabel("Call Stack"); font = callstackLabel.getFont(); callstackLabel.setFont(new Font(font.getName(), Font.BOLD, font.getSize())); @@ -326,22 +323,14 @@ public void refreshDebugControls(ApMode mode) { } @Override - public void refresh(EditorPlugin languageProvider) { - - } + public void refresh(EditorPlugin languageProvider) {} @Override - public void onFileModified(String filePath) { - - } + public void onFileModified(String filePath) {} @Override - public void dispose() { - - } + public void dispose() {} @Override - public void onCompileSucceeded() { - - } + public void onCompileSucceeded() {} } diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java index 2e8380f3..bf98cc5b 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -1,14 +1,41 @@ package com.basic4gl.desktop.panels; +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; + +import com.basic4gl.desktop.content.FileManager; import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.PluginContext; - -import javax.swing.*; import java.awt.*; - -import static com.basic4gl.desktop.Theme.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Locale; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.filechooser.FileSystemView; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; public class DocsPanelProvider implements IEditorPanelProvider { + + private final JTabbedPane docsTabs = new JTabbedPane(); + private final JTree docsExplorerTree = new JTree(); + private final JTextField docsExplorerSearchField = new JTextField(); + private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); + + private final FileManager fileManager; + + public DocsPanelProvider(FileManager fileManager) { + this.fileManager = fileManager; + } + @Override public String id() { return "docs"; @@ -41,26 +68,187 @@ public EditorLayout getLayoutConstraints() { @Override public JPanel build(PluginContext context) { - return null; + JPanel panel = new JPanel(new BorderLayout(0, 6)); + Color panelBackground = com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground(); + panel.setBackground(panelBackground); + panel.setOpaque(true); + + JPanel header = new JPanel(); + header.setLayout(new BoxLayout(header, BoxLayout.X_AXIS)); + header.setBackground(panelBackground); + JLabel title = new JLabel("Docs Explorer"); + Font baseFont = title.getFont(); + title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + title.setForeground(new Color(0x424242)); + title.setBorder(new EmptyBorder(0, 8, 0, 8)); + + JButton refresh = new JButton(createImageIcon(ICON_REFRESH)); + refresh.setToolTipText("Refresh Docs Explorer"); + refresh.setFocusable(false); + refresh.putClientProperty("JButton.buttonType", "toolBarButton"); + refresh.setOpaque(false); + refresh.addActionListener(e -> refreshDocsExplorerTree()); + + JToggleButton searchToggle = new JToggleButton(createImageIcon(ICON_SEARCH)); + searchToggle.setToolTipText("Show search"); + searchToggle.setFocusable(false); + searchToggle.putClientProperty("JButton.buttonType", "toolBarButton"); + searchToggle.setOpaque(false); + + header.add(title); + header.add(Box.createHorizontalGlue()); + header.add(searchToggle); + header.add(refresh); + panel.add(header, BorderLayout.NORTH); + + JPanel searchBar = new JPanel(new BorderLayout(6, 0)); + searchBar.setBackground(panelBackground); + searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); + docsExplorerSearchField.setToolTipText("Search markdown files"); + searchBar.add(docsExplorerSearchField, BorderLayout.CENTER); + searchBar.setVisible(false); + searchToggle.addActionListener(e -> { + boolean visible = searchToggle.isSelected(); + searchBar.setVisible(visible); + if (visible) { + docsExplorerSearchField.requestFocusInWindow(); + } + panel.revalidate(); + panel.repaint(); + }); + docsExplorerSearchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + refreshDocsExplorerTree(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + refreshDocsExplorerTree(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + refreshDocsExplorerTree(); + } + }); + + docsExplorerTree.setBackground(panelBackground); + docsExplorerTree.setRootVisible(true); + docsExplorerTree.setShowsRootHandles(true); + docsExplorerTree.setRowHeight(22); + docsExplorerTree.setCellRenderer(new DefaultTreeCellRenderer() { + @Override + public Component getTreeCellRendererComponent( + JTree tree, + Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + JLabel label = (JLabel) + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); + if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof File file) { + label.setText(fileSystemView.getSystemDisplayName(file)); + if (label.getText() == null || label.getText().isBlank()) { + label.setText(file.getName().isBlank() ? file.getPath() : file.getName()); + } + label.setIcon(fileSystemView.getSystemIcon(file)); + label.setToolTipText(file.getAbsolutePath()); + } + return label; + } + }); + docsExplorerTree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() != 2) { + return; + } + TreePath path = docsExplorerTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(userObject instanceof File selectedFile) || !selectedFile.isFile()) { + return; + } + if (selectedFile.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { + context.commands().openFileWithPreferredViewer(selectedFile); + } + } + }); + + JScrollPane scrollPane = new JScrollPane(docsExplorerTree); + scrollPane.setBorder(null); + scrollPane.setBackground(panelBackground); + com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling(scrollPane); + + JPanel content = new JPanel(new BorderLayout(0, 6)); + content.setBackground(panelBackground); + content.add(searchBar, BorderLayout.NORTH); + content.add(scrollPane, BorderLayout.CENTER); + panel.add(content, BorderLayout.CENTER); + return panel; } @Override - public void refresh(EditorPlugin languageProvider) { - - } + public void refresh(EditorPlugin languageProvider) {} @Override - public void onFileModified(String filePath) { - - } + public void onFileModified(String filePath) {} @Override - public void dispose() { - - } + public void dispose() {} @Override - public void onCompileSucceeded() { + public void onCompileSucceeded() {} + + private void refreshDocsExplorerTree() { + if (fileManager == null) { + return; + } + File root = new File(fileManager.getCurrentDirectory()); + String searchNeedle = docsExplorerSearchField.getText() == null + ? "" + : docsExplorerSearchField.getText().trim().toLowerCase(Locale.ROOT); + DefaultMutableTreeNode rootNode = buildDocsTreeNode(root, 0, searchNeedle); + if (rootNode == null) { + rootNode = new DefaultMutableTreeNode(root); + } + docsExplorerTree.setModel(new DefaultTreeModel(rootNode)); + if (docsExplorerTree.getRowCount() > 0) { + docsExplorerTree.expandRow(0); + } + } + private DefaultMutableTreeNode buildDocsTreeNode(File file, int depth, String searchNeedle) { + boolean hasSearch = searchNeedle != null && !searchNeedle.isBlank(); + String fileName = file.getName().toLowerCase(Locale.ROOT); + String absolutePath = file.getAbsolutePath().toLowerCase(Locale.ROOT); + boolean matchesSearch = !hasSearch || fileName.contains(searchNeedle) || absolutePath.contains(searchNeedle); + + if (!file.isDirectory()) { + boolean isMarkdown = fileName.endsWith(".md"); + return (isMarkdown && matchesSearch) ? new DefaultMutableTreeNode(file) : null; + } + + DefaultMutableTreeNode node = new DefaultMutableTreeNode(file); + File[] children = file.listFiles(); + if (children == null) { + return (depth == 0 || matchesSearch) ? node : null; + } + Arrays.sort(children, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + for (File child : children) { + if (child.getName().startsWith(".")) { + continue; + } + DefaultMutableTreeNode childNode = buildDocsTreeNode(child, depth + 1, searchNeedle); + if (childNode != null) { + node.add(childNode); + } + } + return (depth == 0 || node.getChildCount() > 0 || matchesSearch) ? node : null; } } diff --git a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java index 45a832ec..efc53fd8 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java @@ -1,11 +1,22 @@ package com.basic4gl.desktop.panels; +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; +import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; + import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.util.FileUtil; import com.basic4gl.desktop.util.RoundedCardPanel; -import com.basic4gl.desktop.util.SwingUtil; - +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Locale; import javax.swing.*; import javax.swing.border.EmptyBorder; import javax.swing.event.DocumentEvent; @@ -15,19 +26,6 @@ import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreePath; -import java.awt.*; -import java.awt.datatransfer.StringSelection; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; -import java.io.File; -import java.util.Arrays; -import java.util.Comparator; -import java.util.Locale; - -import static com.basic4gl.desktop.Theme.*; -import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; -import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; -import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; public class FileBrowserPanelProvider implements IEditorPanelProvider { @@ -193,11 +191,8 @@ public void mouseClicked(MouseEvent e) { if (!(userObject instanceof File file) || !file.isFile()) { return; } - if (file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - context.commands().openMarkdownInDocsTab(file); - } else { - context.commands().openFileWithPreferredViewer(file); - } + + context.commands().openFileWithPreferredViewer(file); } @Override @@ -251,8 +246,6 @@ private JToggleButton createHeaderSearchToggleButton() { return button; } - - private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { Color cardBackground = createLighterPanelBackground(); JPanel card = new RoundedCardPanel(); @@ -291,8 +284,6 @@ private void maybeShowWorkspaceBrowserPopup(MouseEvent e) { openItem.addActionListener(evt -> { if (selectedFile.isDirectory()) { context.commands().setWorkspaceDirectory(selectedFile); - } else if (selectedFile.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - context.commands().openMarkdownInDocsTab(selectedFile); } else { context.commands().openFileWithPreferredViewer(selectedFile); } @@ -342,24 +333,20 @@ public void refresh(EditorPlugin languageProvider) { } @Override - public void onFileModified(String filePath) { - - } + public void onFileModified(String filePath) {} @Override - public void dispose() { - - } + public void dispose() {} @Override - public void onCompileSucceeded() { - - } + public void onCompileSucceeded() {} private DefaultMutableTreeNode buildFileTreeNode(File file, int depth, String searchNeedle) { boolean hasSearch = searchNeedle != null && !searchNeedle.isBlank(); - boolean nameMatches = !hasSearch || file.getName().toLowerCase(Locale.ROOT).contains(searchNeedle); - boolean pathMatches = !hasSearch || file.getAbsolutePath().toLowerCase(Locale.ROOT).contains(searchNeedle); + boolean nameMatches = + !hasSearch || file.getName().toLowerCase(Locale.ROOT).contains(searchNeedle); + boolean pathMatches = + !hasSearch || file.getAbsolutePath().toLowerCase(Locale.ROOT).contains(searchNeedle); boolean matches = nameMatches || pathMatches; if (!file.isDirectory()) { return matches ? new DefaultMutableTreeNode(file) : null; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java index 122e118b..5c09a8d9 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/IEditorPanelProvider.java @@ -2,29 +2,33 @@ import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.PluginContext; - -import javax.swing.*; import java.awt.*; +import javax.swing.*; public interface IEditorPanelProvider { String id(); + String getTitle(); + String getActiveIconPath(); + String getInactiveIconPath(); + Color getActiveIconTint(); + EditorLayout getLayoutConstraints(); - JPanel build(PluginContext context); + JPanel build(PluginContext context); void refresh(EditorPlugin languageProvider); void onFileModified(String filePath); void dispose(); -// -// void onTabClosed(); + // + // void onTabClosed(); // TODO cleanup hooks; this should be a separate listener that can be registered with PluginContext from build() void onCompileSucceeded(); -// + // } diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java index 224bf3ee..6ddf4fdd 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -1,5 +1,14 @@ package com.basic4gl.desktop.panels; +import static com.basic4gl.desktop.Theme.*; +import static com.basic4gl.desktop.Theme.ICON_MENU_FUNCTIONS; +import static com.basic4gl.desktop.Theme.ICON_STRUCT; +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; +import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; + import com.basic4gl.desktop.language.SymbolIndexer; import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.LanguageService; @@ -9,30 +18,16 @@ import com.basic4gl.desktop.spi.language.LabelDefinition; import com.basic4gl.desktop.spi.language.VariableDefinition; import com.basic4gl.desktop.util.RoundedCardPanel; - -import javax.swing.*; -import javax.swing.border.EmptyBorder; -import javax.swing.event.DocumentEvent; -import javax.swing.event.DocumentListener; import java.awt.*; import java.awt.datatransfer.StringSelection; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.*; import java.util.List; -import java.util.function.BiConsumer; - -import static com.basic4gl.desktop.Theme.*; -import static com.basic4gl.desktop.Theme.ICON_MENU_FUNCTIONS; -import static com.basic4gl.desktop.Theme.ICON_MENU_HELP; -import static com.basic4gl.desktop.Theme.ICON_STRUCT; -import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; -import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; -import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; -import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; -import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; -import static com.formdev.flatlaf.FlatClientProperties.TABBED_PANE_TAB_CLOSABLE; -import static com.formdev.flatlaf.FlatClientProperties.TABBED_PANE_TAB_CLOSE_CALLBACK; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; public class SymbolsPanelProvider implements IEditorPanelProvider { @@ -64,7 +59,6 @@ public class SymbolsPanelProvider implements IEditorPanelProvider { private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); private static final int CARD_ARC = 14; - private int lastProgramSymbolsFingerprint = Integer.MIN_VALUE; private boolean updatingReferenceFilters = false; @@ -102,7 +96,6 @@ public String toString() { } } - @Override public String id() { return "symbols"; @@ -136,8 +129,10 @@ public EditorLayout getLayoutConstraints() { public JPanel build(PluginContext context) { this.context = context; - symbolIndexer = - new SymbolIndexer(context.currentEditor().getLanguageSupport(), context.commands()::collectAllSourceText, this::updateProgramSymbols); + symbolIndexer = new SymbolIndexer( + context.currentEditor().getLanguageSupport(), + context.commands()::collectAllSourceText, + this::updateProgramSymbols); JPanel panelCardHost = new JPanel(new CardLayout()); JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); Color panelBackground = createLighterPanelBackground(); @@ -165,12 +160,14 @@ public JPanel build(PluginContext context) { referenceCopyButton.setFocusable(false); referenceCopyButton.setMargin(new Insets(4, 4, 4, 4)); Font actionButtonFont = referenceCopyButton.getFont(); -// referenceCopyButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, actionButtonFont.getSize())); + // referenceCopyButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, + // actionButtonFont.getSize())); referenceCopyButton.setForeground(new Color(0x5B717F)); referenceCopyButton.setEnabled(false); referenceInsertButton.setFocusable(false); referenceInsertButton.setMargin(new Insets(4, 4, 4, 4)); -// referenceInsertButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, actionButtonFont.getSize())); + // referenceInsertButton.setFont(new Font(actionButtonFont.getName(), Font.BOLD, + // actionButtonFont.getSize())); referenceInsertButton.setForeground(new Color(0x5B717F)); referenceInsertButton.setEnabled(false); @@ -186,8 +183,8 @@ public JPanel build(PluginContext context) { referenceList.setBackground(panelBackground); referenceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); referenceList.setFixedCellHeight(20); - referenceList.setPrototypeCellValue( - new SymbolsPanelProvider.ReferenceItem("function", "prototype", "prototype(symbol, arg)", "Builtin", "", "", 0)); + referenceList.setPrototypeCellValue(new SymbolsPanelProvider.ReferenceItem( + "function", "prototype", "prototype(symbol, arg)", "Builtin", "", "", 0)); referenceList.setCellRenderer(new DefaultListCellRenderer() { private final ImageIcon functionIcon = createImageIcon(ICON_FUNCTION); private final ImageIcon variableIcon = createImageIcon(ICON_VARIABLE); @@ -352,13 +349,12 @@ private JToggleButton createHeaderSearchToggleButton() { return button; } - private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { Color cardBackground = createLighterPanelBackground(); -// new Color( -// Math.min(255, panelBackground.getRed() + 10), -// Math.min(255, panelBackground.getGreen() + 10), -// Math.min(255, panelBackground.getBlue() + 10)); + // new Color( + // Math.min(255, panelBackground.getRed() + 10), + // Math.min(255, panelBackground.getGreen() + 10), + // Math.min(255, panelBackground.getBlue() + 10)); JPanel card = new RoundedCardPanel(); card.setLayout(new BorderLayout()); card.setBackground(cardBackground); @@ -372,9 +368,6 @@ private JComponent createRoundedCardHost(JComponent content, Color panelBackgrou return host; } - - - @Override public void refresh(EditorPlugin languageProvider) { if (context == null) { @@ -473,7 +466,7 @@ private void updateProgramSymbols(List symbols) { rebuildLibraryFilterOptions(); filterReferenceItems(); - //TODO handle this or not: refreshAssetsLibrary(); + // TODO handle this or not: refreshAssetsLibrary(); } private void populateDocsFromCompiler() { @@ -482,10 +475,14 @@ private void populateDocsFromCompiler() { return; } allReferenceItems.clear(); - allReferenceItems.addAll(buildFunctionReferenceItems(context.currentEditor().getLanguage())); - allReferenceItems.addAll(buildConstantReferenceItems(context.currentEditor().getLanguage())); - allReferenceItems.addAll(buildLabelReferenceItems(context.currentEditor().getLanguage())); - allReferenceItems.addAll(buildVariableReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll( + buildFunctionReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll( + buildConstantReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll( + buildLabelReferenceItems(context.currentEditor().getLanguage())); + allReferenceItems.addAll( + buildVariableReferenceItems(context.currentEditor().getLanguage())); allReferenceItems.sort(Comparator.comparing((ReferenceItem item) -> item.name, String.CASE_INSENSITIVE_ORDER) .thenComparing(item -> item.kind)); rebuildLibraryFilterOptions(); @@ -507,8 +504,8 @@ private java.util.List buildFunctionReferenceItems(LanguageServic argsOnly.append(arg.signature()); } } - String details = "" + - "

        Type: Function
        Library: " + String details = "" + + "

        Type: Function
        Library: " + escapeHtml(item.packageName()) + "

        " + escapeHtml(item.signature()) @@ -531,8 +528,8 @@ private java.util.List buildConstantReferenceItems(LanguageServic if (item == null) { continue; } - String details = "" + - "

        Type: Constant
        Library: " + String details = "" + + "

        Type: Constant
        Library: " + escapeHtml(item.packageName()) + "

        " + escapeHtml(item.signature()) @@ -557,8 +554,8 @@ private java.util.List buildLabelReferenceItems(LanguageService c continue; } String signature = label.signature(); - String details = "" + - "

        Type: Label
        Usage: " + String details = "" + + "

        Type: Label
        Usage: " + "" + escapeHtml(label.usage()) + "" + "

        "; items.add(new ReferenceItem( @@ -581,8 +578,8 @@ private java.util.List buildVariableReferenceItems(LanguageServic } String typeStr = variable.type().name(); String signature = variable.signature(); - String details = "" + - "

        Type: Variable
        Data type: " + String details = "" + + "

        Type: Variable
        Data type: " + escapeHtml(typeStr) + "
        Source: Program

        "; items.add(new ReferenceItem( "variable", @@ -706,22 +703,22 @@ private void filterReferenceItems() { for (ReferenceItem item : allReferenceItems) { boolean kindMatches = "All Symbols".equals(selectedKind) || ("Functions".equals(selectedKind) - && ("function".equals(item.kind) || "userfunc".equals(item.kind))) + && ("function".equals(item.kind) || "userfunc".equals(item.kind))) || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) || ("Labels".equals(selectedKind) && "label".equals(item.kind)) || ("Variables".equals(selectedKind) && "variable".equals(item.kind)) || ("Structs".equals(selectedKind) && "struc".equals(item.kind)); boolean sourceMatches = "All Sources".equals(selectedSource) || ("Builtin".equals(selectedSource) - && item.library != null - && "Builtin".equalsIgnoreCase(item.library)) + && item.library != null + && "Builtin".equalsIgnoreCase(item.library)) || ("Libraries".equals(selectedSource) - && item.library != null - && !"Builtin".equalsIgnoreCase(item.library) - && !"Program".equalsIgnoreCase(item.library)) + && item.library != null + && !"Builtin".equalsIgnoreCase(item.library) + && !"Program".equalsIgnoreCase(item.library)) || ("Program".equals(selectedSource) - && item.library != null - && "Program".equalsIgnoreCase(item.library)); + && item.library != null + && "Program".equalsIgnoreCase(item.library)); boolean libraryMatches = "All Libraries".equals(selectedLibrary) || (item.library != null && selectedLibrary.equals(item.library)); if (needle.isEmpty() @@ -729,7 +726,7 @@ private void filterReferenceItems() { || item.signature.toLowerCase(Locale.ROOT).contains(needle) || item.kind.toLowerCase(Locale.ROOT).contains(needle) || (item.library != null - && item.library.toLowerCase(Locale.ROOT).contains(needle))) { + && item.library.toLowerCase(Locale.ROOT).contains(needle))) { if (kindMatches && sourceMatches && libraryMatches) { matches.add(item); } @@ -792,7 +789,4 @@ private void setReferenceSelectionName(String name) { referenceSelectionNameLabel.setText(referenceSelectionName); referenceSelectionNameLabel.setToolTipText(referenceSelectionName.isBlank() ? null : referenceSelectionName); } - - - } diff --git a/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java b/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java index aa9b5407..035fcc92 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java +++ b/app/src/main/java/com/basic4gl/desktop/util/BasicDialogService.java @@ -1,7 +1,6 @@ package com.basic4gl.desktop.util; import com.basic4gl.desktop.spi.DialogService; - import javax.swing.*; public class BasicDialogService implements DialogService { @@ -18,7 +17,7 @@ public void showDialog(String message) { @Override public String showInputDialog(String message, String title, String initialValue) { - return (String) JOptionPane.showInputDialog( - frame, message, title, JOptionPane.PLAIN_MESSAGE, null, null, initialValue); + return (String) + JOptionPane.showInputDialog(frame, message, title, JOptionPane.PLAIN_MESSAGE, null, null, initialValue); } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java b/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java index e118b46b..3417c12e 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/FileUtil.java @@ -1,12 +1,11 @@ package com.basic4gl.desktop.util; import com.basic4gl.desktop.spi.DialogService; - -import javax.swing.*; import java.awt.*; import java.io.File; import java.io.IOException; import java.util.Locale; +import javax.swing.*; public class FileUtil { public static String fromUserHome(String absolutePath) { diff --git a/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java b/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java index f2e6e7c5..329b3cbc 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/HtmlUtil.java @@ -8,8 +8,7 @@ public final class HtmlUtil { private static final Parser MARKDOWN_PARSER = Parser.builder().build(); private static final HtmlRenderer MARKDOWN_RENDERER = HtmlRenderer.builder().build(); - private HtmlUtil() { - } + private HtmlUtil() {} public static String escapeHtml(String input) { if (input == null) { @@ -18,12 +17,10 @@ public static String escapeHtml(String input) { return input.replace("&", "&").replace("<", "<").replace(">", ">"); } - public static String markdownToHtml(String markdown) { String input = markdown == null ? "" : markdown; Node document = MARKDOWN_PARSER.parse(input); String htmlBody = MARKDOWN_RENDERER.render(document); return "" + htmlBody + ""; } - } diff --git a/app/src/main/java/com/basic4gl/desktop/util/KeyStrokeUtil.java b/app/src/main/java/com/basic4gl/desktop/util/KeyStrokeUtil.java index c69b39ba..2db6fed6 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/KeyStrokeUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/KeyStrokeUtil.java @@ -1,6 +1,8 @@ package com.basic4gl.desktop.util; +import java.awt.*; import java.awt.event.KeyEvent; +import java.util.ArrayList; import javax.swing.*; public final class KeyStrokeUtil { @@ -13,13 +15,23 @@ public static String getShortcutString(KeyStroke keyStroke) { int modifiers = keyStroke.getModifiers(); int keyCode = keyStroke.getKeyCode(); - String modifierSymbol; - if (System.getProperty("os.name").toLowerCase().contains("mac")) { - modifierSymbol = "⌘"; // Command symbol for macOS - } else { - modifierSymbol = "Ctrl"; // Control for Windows/Linux + ArrayList modifierSymbol = new ArrayList<>(); + + Toolkit toolkit = Toolkit.getDefaultToolkit(); + + if ((modifiers & KeyEvent.SHIFT_DOWN_MASK) != 0) { + modifierSymbol.add("Shift"); + } + + if ((modifiers & toolkit.getMenuShortcutKeyMask()) != 0 + || (modifiers & toolkit.getMenuShortcutKeyMaskEx()) == 0) { + if (System.getProperty("os.name").toLowerCase().contains("mac")) { + modifierSymbol.add("⌘"); // Command symbol for macOS + } else { + modifierSymbol.add("Ctrl"); // Control for Windows/Linux + } } - return modifierSymbol + " " + KeyEvent.getKeyText(keyCode); + return String.join(" + ", modifierSymbol) + " " + KeyEvent.getKeyText(keyCode); } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java b/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java index 9493e72a..bd9961cc 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java +++ b/app/src/main/java/com/basic4gl/desktop/util/RoundedCardPanel.java @@ -1,7 +1,7 @@ package com.basic4gl.desktop.util; -import javax.swing.*; import java.awt.*; +import javax.swing.*; public class RoundedCardPanel extends JPanel { public static final int DEFAULT_ARC = 14; @@ -29,4 +29,4 @@ protected void paintComponent(Graphics g) { } super.paintComponent(g); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java index 243ebd13..de0a9f9e 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java @@ -1,10 +1,10 @@ package com.basic4gl.desktop.util; -import javax.swing.*; import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import javax.swing.*; public class SwingIconUtil { /** @@ -45,7 +45,6 @@ public static Icon buildImageThumbnailIcon(File file, int maxWidth, int maxHeigh } } - public static Icon createScaledIcon(String iconPath, int size) { ImageIcon icon = createImageIcon(iconPath); if (icon == null) { diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java index 36f1ab07..d8ef0c87 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingUtil.java @@ -1,9 +1,8 @@ package com.basic4gl.desktop.util; import com.formdev.flatlaf.FlatClientProperties; - -import javax.swing.*; import java.awt.*; +import javax.swing.*; public class SwingUtil { @@ -19,9 +18,7 @@ public static void hideSplitPaneHandle(JSplitPane splitPane) { return; } - splitPane.putClientProperty( - FlatClientProperties.STYLE, - "style: plain"); + splitPane.putClientProperty(FlatClientProperties.STYLE, "style: plain"); splitPane.setOneTouchExpandable(false); } diff --git a/app/src/main/resources/css/docs-markdown.css b/app/src/main/resources/css/docs-markdown.css index 0305b01f..074f06d1 100644 --- a/app/src/main/resources/css/docs-markdown.css +++ b/app/src/main/resources/css/docs-markdown.css @@ -1,14 +1,14 @@ .markdown-body { - font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; - font-size: 13px; - line-height: 1.55; + font-family: sans-serif; + font-size: 12px; + line-height: 1.5; color: #1f2933; background-color: #ffffff; margin: 10px 14px; } .markdown-body h1 { - font-size: 1.65em; + font-size: 1.45em; font-weight: 700; margin: 0.75em 0 0.45em; padding-bottom: 0.3em; @@ -16,7 +16,7 @@ } .markdown-body h2 { - font-size: 1.35em; + font-size: 1.25em; font-weight: 700; margin: 0.75em 0 0.45em; padding-bottom: 0.25em; @@ -24,7 +24,7 @@ } .markdown-body h3 { - font-size: 1.15em; + font-size: 1.1em; font-weight: 700; margin: 0.7em 0 0.35em; } @@ -53,7 +53,7 @@ } .markdown-body code { - font-family: "JetBrains Mono", "Menlo", "Consolas", monospace; + font-family: monospace; font-size: 12px; background-color: #f2f4f8; border: 1px solid #e0e6ed; @@ -62,7 +62,7 @@ } .markdown-body pre { - font-family: "JetBrains Mono", "Menlo", "Consolas", monospace; + font-family: monospace; font-size: 12px; line-height: 1.45; background-color: #f6f8fa; diff --git a/app/src/test/java/com/basic4gl/desktop/ExportDialogTest.java b/app/src/test/java/com/basic4gl/desktop/ExportDialogTest.java deleted file mode 100644 index 9cdf1af2..00000000 --- a/app/src/test/java/com/basic4gl/desktop/ExportDialogTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.basic4gl.desktop; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import org.junit.Test; - -public class ExportDialogTest { - - @Test - public void extractStringLiterals_returnsDecodedValues() { - String source = "print \"assets\\\\image.png\"\nprint \"He said: \\\"ok\\\"\"\nprint \"line\\nfeed\""; - - List literals = ExportDialog.extractStringLiterals(source); - - assertEquals(Arrays.asList("assets\\image.png", "He said: \"ok\"", "line\\nfeed"), literals); - } - - @Test - public void extractStringLiterals_ignoresUnterminatedLiteral() { - String source = "print \"complete\"\nprint \"unterminated"; - - List literals = ExportDialog.extractStringLiterals(source); - - assertEquals(Collections.singletonList("complete"), literals); - } - - @Test - public void extractStringLiterals_handlesLargeEscapedInputWithoutRecursion() { - StringBuilder source = new StringBuilder("print "); - source.append('"'); - for (int i = 0; i < 200000; i++) { - source.append("\\\\"); - } - source.append("asset.dat"); - source.append('"'); - - List literals = ExportDialog.extractStringLiterals(source.toString()); - - assertEquals(1, literals.size()); - assertTrue(literals.get(0).endsWith("asset.dat")); - } -} diff --git a/language-adapter/build.gradle b/language-adapter/build.gradle index f9985e64..c2352017 100644 --- a/language-adapter/build.gradle +++ b/language-adapter/build.gradle @@ -40,7 +40,10 @@ dependencies { implementation 'com.formdev:flatlaf-extras:3.5.4' testImplementation platform('org.junit:junit-bom:6.0.0') + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java index f085ff89..2358d6b7 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java @@ -92,6 +92,7 @@ public PreprocessorService getPreprocessor() { public LanguageService getLanguage() { return languageService; } + @Override public LanguageSupport getLanguageSupport() { return languageSupport; diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java index 3320320a..e52c6393 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java @@ -1,5 +1,7 @@ package com.basic4gl.language.adapter; +import static com.basic4gl.language.adapter.util.LanguageUtil.*; + import com.basic4gl.compiler.Preprocessor; import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; @@ -8,7 +10,6 @@ import com.basic4gl.desktop.spi.LanguageService; import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.spi.language.*; -import com.basic4gl.language.adapter.antlr.Basic4GL; import com.basic4gl.language.adapter.util.LanguageUtil; import com.basic4gl.language.adapter.util.NumberUtil; import com.basic4gl.language.core.extensions.FunctionLibrary; @@ -19,15 +20,9 @@ import com.basic4gl.language.core.types.ValType; import com.basic4gl.language.spi.PluginLibrary; import com.basic4gl.language.spi.PluginManager; -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.Token; - import java.util.*; import java.util.stream.Stream; -import static com.basic4gl.language.adapter.util.LanguageUtil.*; - public class Basic4GLLanguageService implements LanguageService { private static final String SYNTAX_STYLE = "text/basic4gl"; @@ -209,15 +204,7 @@ public Iterable getVariableDefinitions() { String signature = typeStr + " " + variable.name; TypeDefinition typeDefinition = LanguageUtil.toTypeDefinition(variable.type); VariableDefinition definition = new VariableDefinition( - variable.name, - signature, - typeDefinition, - "", - "", - "Program", - false, - "global", - "Program"); + variable.name, signature, typeDefinition, "", "", "Program", false, "global", "Program"); variableDefinitions.add(definition); } return variableDefinitions; @@ -290,17 +277,10 @@ public Iterable getFunctionDefinitions() { true, "", "Builtin"); - VariableDefinition[] parameters = params != null - ? buildFunctionParameterDefinitions(params, library) - : new VariableDefinition[0]; + VariableDefinition[] parameters = + params != null ? buildFunctionParameterDefinitions(params, library) : new VariableDefinition[0]; FunctionDefinition definition = new FunctionDefinition( - name, - signature.toString(), - returnValue, - parameters, - "", - library, - spec.hasBrackets()); + name, signature.toString(), returnValue, parameters, "", library, spec.hasBrackets()); items.add(definition); } } @@ -393,14 +373,8 @@ private ArrayList buildUserFunctionReferenceItems() { true, name, "Program"); - FunctionDefinition definition = new FunctionDefinition( - name, - signature.toString(), - returnValue, - parameters, - "", - "Program", - true); + FunctionDefinition definition = + new FunctionDefinition(name, signature.toString(), returnValue, parameters, "", "Program", true); items.add(definition); } diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java index 1bb6f9fa..1f137d23 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageSupport.java @@ -1,5 +1,7 @@ package com.basic4gl.language.adapter; +import static com.basic4gl.language.adapter.util.LanguageUtil.*; + import com.basic4gl.desktop.spi.language.HighlightKind; import com.basic4gl.desktop.spi.language.IndexedSymbol; import com.basic4gl.desktop.spi.language.LangToken; @@ -9,14 +11,10 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; -import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; import org.antlr.v4.runtime.Token; -import static com.basic4gl.language.adapter.util.LanguageUtil.*; - /** * {@link LanguageSupport} implementation for the Basic4GL language. * @@ -72,98 +70,98 @@ public List tokenizeLine(String line) { @Override public HighlightKind classify(LangToken token) { return switch (token.type()) { - // Preprocessor + // Preprocessor case Basic4GL.INCLUDE_DIR -> HighlightKind.PREPROCESSOR; - // Comments + // Comments case Basic4GL.COMMENT, Basic4GL.REM_COMMENT -> HighlightKind.COMMENT; - // Primary keywords + // Primary keywords case Basic4GL.FUNCTION_KW, - Basic4GL.SUB_KW, - Basic4GL.DIM_KW, - Basic4GL.AS_KW, - Basic4GL.GOTO_KW, - Basic4GL.GOSUB_KW, - Basic4GL.IF_KW, - Basic4GL.THEN_KW, - Basic4GL.ELSE_KW, - Basic4GL.ELSEIF_KW, - Basic4GL.ENDIF_KW, - Basic4GL.END_KW, - Basic4GL.RETURN_KW, - Basic4GL.FOR_KW, - Basic4GL.TO_KW, - Basic4GL.STEP_KW, - Basic4GL.NEXT_KW, - Basic4GL.WHILE_KW, - Basic4GL.WEND_KW, - Basic4GL.RUN_KW, - Basic4GL.STRUC_KW, - Basic4GL.ENDSTRUC_KW, - Basic4GL.CONST_KW, - Basic4GL.ALLOC_KW, - Basic4GL.NULL_KW, - Basic4GL.DATA_KW, - Basic4GL.READ_KW, - Basic4GL.RESET_KW, - Basic4GL.TYPE_KW, - Basic4GL.AND_KW, - Basic4GL.OR_KW, - Basic4GL.NOT_KW, - Basic4GL.XOR_KW, - Basic4GL.MOD_KW -> HighlightKind.KEYWORD; - - // Secondary keywords – type names and boolean literals + Basic4GL.SUB_KW, + Basic4GL.DIM_KW, + Basic4GL.AS_KW, + Basic4GL.GOTO_KW, + Basic4GL.GOSUB_KW, + Basic4GL.IF_KW, + Basic4GL.THEN_KW, + Basic4GL.ELSE_KW, + Basic4GL.ELSEIF_KW, + Basic4GL.ENDIF_KW, + Basic4GL.END_KW, + Basic4GL.RETURN_KW, + Basic4GL.FOR_KW, + Basic4GL.TO_KW, + Basic4GL.STEP_KW, + Basic4GL.NEXT_KW, + Basic4GL.WHILE_KW, + Basic4GL.WEND_KW, + Basic4GL.RUN_KW, + Basic4GL.STRUC_KW, + Basic4GL.ENDSTRUC_KW, + Basic4GL.CONST_KW, + Basic4GL.ALLOC_KW, + Basic4GL.NULL_KW, + Basic4GL.DATA_KW, + Basic4GL.READ_KW, + Basic4GL.RESET_KW, + Basic4GL.TYPE_KW, + Basic4GL.AND_KW, + Basic4GL.OR_KW, + Basic4GL.NOT_KW, + Basic4GL.XOR_KW, + Basic4GL.MOD_KW -> HighlightKind.KEYWORD; + + // Secondary keywords – type names and boolean literals case Basic4GL.INTEGER_T, - Basic4GL.INT_T, - Basic4GL.SINGLE_T, - Basic4GL.DOUBLE_T, - Basic4GL.STRING_T, - Basic4GL.TRUE_KW, - Basic4GL.FALSE_KW -> HighlightKind.KEYWORD_2; - - // Literals + Basic4GL.INT_T, + Basic4GL.SINGLE_T, + Basic4GL.DOUBLE_T, + Basic4GL.STRING_T, + Basic4GL.TRUE_KW, + Basic4GL.FALSE_KW -> HighlightKind.KEYWORD_2; + + // Literals case Basic4GL.STRING_LIT -> HighlightKind.STRING; case Basic4GL.INT_LIT, Basic4GL.FLOAT_LIT, Basic4GL.HEX_LIT -> HighlightKind.NUMBER; - // Identifiers – the IDE adapter re-classifies these via wordsToHighlight + // Identifiers – the IDE adapter re-classifies these via wordsToHighlight case Basic4GL.IDENTIFIER -> HighlightKind.IDENTIFIER; - // Whitespace + // Whitespace case Basic4GL.WS -> HighlightKind.WHITESPACE; case Basic4GL.NEWLINE -> HighlightKind.NEWLINE; - // Operators and punctuation + // Operators and punctuation case Basic4GL.COLON, - Basic4GL.LPAREN, - Basic4GL.RPAREN, - Basic4GL.LBRACKET, - Basic4GL.RBRACKET, - Basic4GL.COMMA, - Basic4GL.DOT, - Basic4GL.SEMICOLON, - Basic4GL.EQ, - Basic4GL.NEQ, - Basic4GL.LT, - Basic4GL.GT, - Basic4GL.LTE, - Basic4GL.GTE, - Basic4GL.PLUS, - Basic4GL.MINUS, - Basic4GL.STAR, - Basic4GL.SLASH, - Basic4GL.BACKSLASH, - Basic4GL.CARET, - Basic4GL.AT, - Basic4GL.BANG, - Basic4GL.TILDE, - Basic4GL.PERCENT, - Basic4GL.PIPE, - Basic4GL.HASH, - Basic4GL.AMPERSAND -> HighlightKind.OPERATOR; - - // Unknown / unrecognised + Basic4GL.LPAREN, + Basic4GL.RPAREN, + Basic4GL.LBRACKET, + Basic4GL.RBRACKET, + Basic4GL.COMMA, + Basic4GL.DOT, + Basic4GL.SEMICOLON, + Basic4GL.EQ, + Basic4GL.NEQ, + Basic4GL.LT, + Basic4GL.GT, + Basic4GL.LTE, + Basic4GL.GTE, + Basic4GL.PLUS, + Basic4GL.MINUS, + Basic4GL.STAR, + Basic4GL.SLASH, + Basic4GL.BACKSLASH, + Basic4GL.CARET, + Basic4GL.AT, + Basic4GL.BANG, + Basic4GL.TILDE, + Basic4GL.PERCENT, + Basic4GL.PIPE, + Basic4GL.HASH, + Basic4GL.AMPERSAND -> HighlightKind.OPERATOR; + + // Unknown / unrecognised default -> HighlightKind.OTHER; }; } diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java index 0be481c6..8aa33848 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/util/LanguageUtil.java @@ -7,12 +7,11 @@ import com.basic4gl.language.adapter.antlr.Basic4GL; import com.basic4gl.language.core.types.BasicValType; import com.basic4gl.language.core.types.ValType; -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.Token; - import java.util.List; import java.util.Locale; import java.util.Map; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.Token; public final class LanguageUtil { private LanguageUtil() {} @@ -64,7 +63,6 @@ public static String getTypeString(int type) { } } - public static Basic4GL createLexer(String input) { Basic4GL lexer = new Basic4GL(CharStreams.fromString(input)); lexer.removeErrorListeners(); // suppress console noise on partial / invalid source diff --git a/language-adapter/src/test/java/com/basic4gl/language/adapter/LanguageServiceTest.java b/language-adapter/src/test/java/com/basic4gl/language/adapter/LanguageServiceTest.java new file mode 100644 index 00000000..ece9bf4e --- /dev/null +++ b/language-adapter/src/test/java/com/basic4gl/language/adapter/LanguageServiceTest.java @@ -0,0 +1,69 @@ +package com.basic4gl.language.adapter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.basic4gl.compiler.Preprocessor; +import com.basic4gl.compiler.TomBasicCompiler; +import com.basic4gl.desktop.spi.LanguageService; +import com.basic4gl.language.spi.PluginManager; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class LanguageServiceTest { + + @Mock + TomBasicCompiler compiler; + + @Mock + Preprocessor preprocessor; + + @Mock + PluginManager pluginManager; + + @Test + public void extractStringLiterals_returnsDecodedValues() { + String source = "print \"assets\\\\image.png\"\nprint \"He said: \\\"ok\\\"\"\nprint \"line\\nfeed\""; + + LanguageService languageService = new Basic4GLLanguageService(compiler, preprocessor, pluginManager); + + List literals = languageService.extractStringLiterals(source); + + assertEquals(Arrays.asList("assets\\image.png", "He said: \"ok\"", "line\\nfeed"), literals); + } + + @Test + public void extractStringLiterals_ignoresUnterminatedLiteral() { + String source = "print \"complete\"\nprint \"unterminated"; + + LanguageService languageService = new Basic4GLLanguageService(compiler, preprocessor, pluginManager); + + List literals = languageService.extractStringLiterals(source); + + assertEquals(Collections.singletonList("complete"), literals); + } + + @Test + public void extractStringLiterals_handlesLargeEscapedInputWithoutRecursion() { + StringBuilder source = new StringBuilder("print "); + source.append('"'); + for (int i = 0; i < 200000; i++) { + source.append("\\\\"); + } + source.append("asset.dat"); + source.append('"'); + + LanguageService languageService = new Basic4GLLanguageService(compiler, preprocessor, pluginManager); + + List literals = languageService.extractStringLiterals(source.toString()); + + assertEquals(1, literals.size()); + assertTrue(literals.get(0).endsWith("asset.dat")); + } +} diff --git a/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider b/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider deleted file mode 100644 index 64dfae9d..00000000 --- a/library/src/main/resources/META-INF/services/com.basic4gl.language.adapter.fileviewer.FileViewerProvider +++ /dev/null @@ -1,4 +0,0 @@ -com.basic4gl.desktop.spi.content.DefaultImageViewerProvider -com.basic4gl.desktop.spi.content.DefaultAudioViewerProvider -com.basic4gl.desktop.spi.content.SimpleTextViewerProvider - From 04e8b241161ae60b5e79f86f5047318ea710901c Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Thu, 16 Jul 2026 00:25:51 -0400 Subject: [PATCH 26/38] implement markdown editor options --- .../com/basic4gl/desktop/BasicEditor.java | 3 + .../java/com/basic4gl/desktop/MainWindow.java | 225 +++++++++++------- .../main/java/com/basic4gl/desktop/Theme.java | 4 + .../desktop/content/FileViewerFactory.java | 23 +- .../desktop/content/FileViewerWrapper.java | 64 +---- .../basic4gl/desktop/content/HtmlViewer.java | 109 ++++++++- .../desktop/content/MarkdownViewer.java | 24 +- .../desktop/content/TextFileViewer.java | 4 + .../desktop/panels/AssetsPanelProvider.java | 17 +- .../resources/images/material/icon_add.png | Bin 0 -> 195 bytes .../resources/images/material/icon_edit.png | Bin 0 -> 486 bytes .../images/material/icon_edit_outline.png | Bin 0 -> 557 bytes .../images/material/icon_edit_preview.png | Bin 0 -> 606 bytes .../material/icon_edit_preview_outline.png | Bin 0 -> 618 bytes .../images/material/icon_preview.png | Bin 0 -> 429 bytes .../images/material/icon_preview_outline.png | Bin 0 -> 430 bytes .../adapter/Basic4GLLanguageService.java | 2 - 17 files changed, 316 insertions(+), 159 deletions(-) create mode 100644 app/src/main/resources/images/material/icon_add.png create mode 100644 app/src/main/resources/images/material/icon_edit.png create mode 100644 app/src/main/resources/images/material/icon_edit_outline.png create mode 100644 app/src/main/resources/images/material/icon_edit_preview.png create mode 100644 app/src/main/resources/images/material/icon_edit_preview_outline.png create mode 100644 app/src/main/resources/images/material/icon_preview.png create mode 100644 app/src/main/resources/images/material/icon_preview_outline.png diff --git a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java index 26f32cf1..aded1014 100644 --- a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java @@ -235,6 +235,9 @@ public int isBreakpt(String filename, int line) { @Override public boolean toggleBreakpt(String filename, int line) { + if (vmWorker == null) { + return false; + } return vmWorker.toggleBreakpoint(filename, line); } diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 68f9f8dc..0036c3ba 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -2,6 +2,7 @@ import static com.basic4gl.desktop.Theme.*; import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; import static com.formdev.flatlaf.FlatClientProperties.*; @@ -17,6 +18,7 @@ import com.basic4gl.desktop.panels.*; import com.basic4gl.desktop.spi.*; import com.basic4gl.desktop.util.BasicDialogService; +import com.basic4gl.desktop.util.RoundedCardPanel; import com.basic4gl.desktop.vmview.DebugControlsListener; import com.basic4gl.desktop.vmview.VirtualMachineViewDialog; import com.basic4gl.language.core.internal.Mutable; @@ -78,15 +80,21 @@ public void caretUpdate(CaretEvent e) { private final JTabbedPane splitTabControl = new JTabbedPane(); private final JSplitPane editorSplitPane; private final JPanel primaryTabHost = new JPanel(new BorderLayout()); - private final JButton addTabDropdownButton = new JButton("+"); + private final JButton addTabDropdownButton = new JButton(createScaledIcon(ICON_ADD, 18)); + private final JPanel fileViewModeTabs = createSegmentedButtonStrip(); + private final JToggleButton editViewButton = + createFileViewModeButton("Editor", ICON_EDIT, IFileViewer.ViewMode.EDITOR, "first"); + private final JToggleButton editPreviewViewButton = createFileViewModeButton( + "Editor and Preview", ICON_EDIT_PREVIEW, IFileViewer.ViewMode.EDITOR_AND_PREVIEW, "middle"); + private final JToggleButton previewViewButton = + createFileViewModeButton("Preview", ICON_PREVIEW, IFileViewer.ViewMode.PREVIEW, "last"); + private final JPanel fileViewModeTabsHost = createSegmentedButtonStripHost(fileViewModeTabs); private final JPanel centerPaneHost = new JPanel(new BorderLayout()); private final JPanel topPaneHost = new JPanel(new BorderLayout()); private final JPanel leftRailsHost = new JPanel(); private final JSplitPane workspacePane; private final JSplitPane contentPane; private final JPanel bottomBarContainer = new JPanel(new BorderLayout()); - // File viewers: stores IFileViewer instances for each tab (parallel to tabControl) - private final java.util.List fileViewers = new java.util.ArrayList<>(); private final JPanel leftSidebarContent = new JPanel(new CardLayout()); private final JToolBar leftSidebarRail = new JToolBar(SwingConstants.VERTICAL); private final ButtonGroup leftSidebarGroup = new ButtonGroup(); @@ -122,6 +130,14 @@ public void caretUpdate(CaretEvent e) { private static final String RECENT_WORKSPACES_FILE = "recent-workspaces.properties"; private static final String RECENT_WORKSPACES_KEY = "RECENT_WORKSPACES"; private static final int MAX_RECENT_WORKSPACES = 10; + private static final Dimension TAB_HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final Dimension TAB_VIEW_MODE_BUTTON_SIZE = new Dimension(34, 30); + private static final String TAB_FILE_VIEWER_PROPERTY = "basic4gl.fileViewer"; + private static final Color SEGMENTED_BACKGROUND = new Color(0xE0E0E0); + private static final String SEGMENTED_BUTTON_STYLE = + "arc: 14; borderWidth: 0; focusWidth: 0; innerFocusWidth: 0;" + + " margin: 6,8,6,8; background: #E0E0E0;" + + " hoverBackground: #D6D6D6; selectedBackground: #FFFFFF"; private final JMenu bookmarkSubMenu = new JMenu("Bookmarks"); private final JMenu breakpointSubMenu = new JMenu("Breakpoints"); @@ -621,9 +637,7 @@ protected void installDefaults() { // Remove tab tabControl.remove(tabIndex); fileManager.getFileEditors().remove(tabIndex.intValue()); - if (tabIndex >= 0 && tabIndex < fileViewers.size()) { - fileViewers.remove(tabIndex); - } + refreshFileViewModeButtons(); fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); @@ -908,7 +922,7 @@ private void resetProject() { // Close existing editors tabControl.removeAll(); fileManager.getFileEditors().clear(); - fileViewers.clear(); + refreshFileViewModeButtons(); // Create a default tab addTab(); @@ -977,7 +991,7 @@ public void openTab(File file) { searchContext, basicEditor); - addTabWithViewer(viewer); + addTab(viewer); tabControl.setSelectedIndex(tabControl.getTabCount() - 1); registerWorkspace(file.getParentFile()); @@ -992,7 +1006,7 @@ void actionNew() { // Clear file editors this.tabControl.removeAll(); fileManager.getFileEditors().clear(); - fileViewers.clear(); + refreshFileViewModeButtons(); this.addTab(); refreshSidebarContent(); @@ -1476,9 +1490,7 @@ public void closeTab(int index) { if (index >= 0 && index < fileManager.getFileEditors().size()) { fileManager.getFileEditors().remove(index); } - if (index >= 0 && index < fileViewers.size()) { - fileViewers.remove(index); - } + refreshFileViewModeButtons(); fileManager.ensureRunnableFileValid(); refreshRunnableFileControls(); refreshSidebarContent(); @@ -1490,83 +1502,15 @@ public void addTab() { } public void addTab(FileEditor editor) { - - int count = fileManager.editorCount(); - fileManager.getFileEditors().add(editor); - fileViewers.add(new FileViewerWrapper(editor)); - - // replace emptyTabPanel if needed - setEditorContent(getActiveEditorHost()); - - tabControl.addTab(editor.getTitle(), editor.getContentPane()); - - final FileEditor edit = editor; - File file = edit.getFile(); - if (file != null) { - basicEditor.notifyFileOpened(file); - basicEditor.onFileOpened(edit); - } - - edit.getEditorPane().getDocument().addDocumentListener(new DocumentListener() { - @Override - public void insertUpdate(DocumentEvent e) { - int index = getTabIndex(edit.getFilePath()); - edit.setModified(); - tabControl.setTitleAt(index, edit.getTitle()); - for (IEditorPanelProvider panel : panels) { - panel.onFileModified(edit.getFilePath()); - } - } - - @Override - public void removeUpdate(DocumentEvent e) { - int index = getTabIndex(edit.getFilePath()); - edit.setModified(); - tabControl.setTitleAt(index, edit.getTitle()); - for (IEditorPanelProvider panel : panels) { - panel.onFileModified(edit.getFilePath()); - } - } - - @Override - public void changedUpdate(DocumentEvent e) { - if (e.getLength() == 0) { - // ignore empty changes - eg: syntax highlighting refreshed - return; - } - int index = getTabIndex(edit.getFilePath()); - edit.setModified(); - tabControl.setTitleAt(index, edit.getTitle()); - } - }); - - // Allow user to see cursor position - editor.getEditorPane().addCaretListener(TrackCaretPosition); - cursorPositionLabel.setText(0 + ":" + 0); // Reset label - - // Set tab as read-only if App is running or paused - boolean readOnly = basicEditor.getMode() != ApMode.AP_STOPPED; - editor.getEditorPane().setEditable(!readOnly); - - // TODO set syntax highlight colors - - // Refresh interface if there was previously no tabs open - if (count == 0) { - basicEditor.setMode(ApMode.AP_STOPPED, null); - } - - fileManager.ensureRunnableFileValid(); - refreshRunnableFileControls(); - refreshSidebarContent(); + addTab(new TextFileViewer(editor)); } /** - * Adds a file viewer tab (unified method for all viewer types including images and audio) + * Adds a file viewer tab for all viewer types, including text editors, images, audio, and docs. */ - public void addTabWithViewer(IFileViewer viewer) { + public void addTab(IFileViewer viewer) { int count = tabControl.getTabCount(); FileViewerWrapper wrapper = new FileViewerWrapper(viewer); - fileViewers.add(wrapper); // For backward compatibility with FileEditor code, also add to fileManager if it's a text editor if (wrapper.isTextEditor()) { @@ -1579,12 +1523,16 @@ public void addTabWithViewer(IFileViewer viewer) { // replace emptyTabPanel if needed setEditorContent(getActiveEditorHost()); - tabControl.addTab(viewer.getTitle(), viewer.getContentPane()); + JComponent contentPane = viewer.getContentPane(); + contentPane.putClientProperty(TAB_FILE_VIEWER_PROPERTY, viewer); + tabControl.addTab(viewer.getTitle(), contentPane); - final FileViewerWrapper wrappedViewer = wrapper; File file = viewer.getFile(); if (file != null) { basicEditor.notifyFileOpened(file); + if (wrapper.isTextEditor()) { + basicEditor.onFileOpened(wrapper.getFileEditor()); + } } // Add document listener only for text editors @@ -1616,6 +1564,10 @@ public void removeUpdate(DocumentEvent e) { @Override public void changedUpdate(DocumentEvent e) { + if (e.getLength() == 0) { + // ignore empty changes - eg: syntax highlighting refreshed + return; + } int index = getTabIndex(edit.getFilePath()); edit.setModified(); tabControl.setTitleAt(index, edit.getTitle()); @@ -1982,12 +1934,105 @@ private void setEditorContent(Component component) { private void configurePrimaryTabHost() { addTabDropdownButton.setFocusable(false); addTabDropdownButton.setToolTipText("Create a new tab or open an asset"); - addTabDropdownButton.setMargin(new Insets(2, 8, 2, 8)); + addTabDropdownButton.putClientProperty("JButton.buttonType", "toolBarButton"); + addTabDropdownButton.putClientProperty( + "FlatLaf.style", "arc: 14; focusWidth: 0; innerFocusWidth: 0; margin: 6,6,6,6"); + addTabDropdownButton.setOpaque(false); + addTabDropdownButton.setMargin(new Insets(6, 6, 6, 6)); + addTabDropdownButton.setPreferredSize(TAB_HEADER_ICON_BUTTON_SIZE); + addTabDropdownButton.setMinimumSize(TAB_HEADER_ICON_BUTTON_SIZE); + addTabDropdownButton.setMaximumSize(TAB_HEADER_ICON_BUTTON_SIZE); addTabDropdownButton.addActionListener(e -> showCreateTabMenu(addTabDropdownButton)); tabControl.putClientProperty(TABBED_PANE_LEADING_COMPONENT, addTabDropdownButton); + ButtonGroup viewModeButtons = new ButtonGroup(); + viewModeButtons.add(editViewButton); + viewModeButtons.add(editPreviewViewButton); + viewModeButtons.add(previewViewButton); + fileViewModeTabs.add(editViewButton); + fileViewModeTabs.add(editPreviewViewButton); + fileViewModeTabs.add(previewViewButton); + fileViewModeTabsHost.setVisible(false); + tabControl.putClientProperty(TABBED_PANE_TRAILING_COMPONENT, fileViewModeTabsHost); + tabControl.addChangeListener(e -> refreshFileViewModeButtons()); primaryTabHost.add(tabControl, BorderLayout.CENTER); } + private JToggleButton createFileViewModeButton( + String tooltip, String iconPath, IFileViewer.ViewMode viewMode, String segmentPosition) { + JToggleButton button = new JToggleButton(createScaledIcon(iconPath, 18)); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "segmented"); + button.putClientProperty("JButton.segmentPosition", segmentPosition); + button.putClientProperty("FlatLaf.style", SEGMENTED_BUTTON_STYLE); + button.setOpaque(false); + button.setMargin(new Insets(6, 8, 6, 8)); + button.setPreferredSize(TAB_VIEW_MODE_BUTTON_SIZE); + button.setMinimumSize(TAB_VIEW_MODE_BUTTON_SIZE); + button.setMaximumSize(TAB_VIEW_MODE_BUTTON_SIZE); + button.addActionListener(e -> setSelectedFileViewMode(viewMode)); + return button; + } + + private JPanel createSegmentedButtonStrip() { + JPanel panel = new RoundedCardPanel(RoundedCardPanel.DEFAULT_ARC); + panel.setLayout(new BoxLayout(panel, BoxLayout.LINE_AXIS)); + panel.setBackground(SEGMENTED_BACKGROUND); + panel.setBorder(new EmptyBorder(1, 1, 1, 1)); + return panel; + } + + private JPanel createSegmentedButtonStripHost(JPanel strip) { + JPanel host = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); + host.setOpaque(false); + host.setBorder(new EmptyBorder(2, 4, 2, 0)); + host.add(strip); + return host; + } + + private void setSelectedFileViewMode(IFileViewer.ViewMode viewMode) { + IFileViewer viewer = getSelectedFileViewer(); + if (viewer == null || !viewer.hasPreview()) { + refreshFileViewModeButtons(); + return; + } + viewer.setViewMode(viewMode); + viewer.getContentPane().revalidate(); + viewer.getContentPane().repaint(); + refreshFileViewModeButtons(); + } + + private void refreshFileViewModeButtons() { + IFileViewer viewer = getSelectedFileViewer(); + boolean hasPreview = viewer != null && viewer.hasPreview(); + fileViewModeTabsHost.setVisible(hasPreview); + if (!hasPreview) { + return; + } + + IFileViewer.ViewMode viewMode = viewer.getViewMode(); + editViewButton.setSelected(viewMode == IFileViewer.ViewMode.EDITOR); + editPreviewViewButton.setSelected(viewMode == IFileViewer.ViewMode.EDITOR_AND_PREVIEW); + previewViewButton.setSelected( + viewMode == IFileViewer.ViewMode.PREVIEW || viewMode == IFileViewer.ViewMode.DEFAULT); + } + + private IFileViewer getSelectedFileViewer() { + return getFileViewerAt(tabControl.getSelectedIndex()); + } + + private IFileViewer getFileViewerAt(int index) { + if (index < 0 || index >= tabControl.getTabCount()) { + return null; + } + Component component = tabControl.getComponentAt(index); + if (component instanceof JComponent tabContent + && tabContent.getClientProperty(TAB_FILE_VIEWER_PROPERTY) instanceof IFileViewer viewer) { + return viewer; + } + return null; + } + private void configureSplitTabs() { splitTabControl.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); splitTabControl.putClientProperty(TABBED_PANE_TAB_CLOSABLE, true); @@ -2585,12 +2630,12 @@ private int findOpenTabIndexByPath(String absolutePath) { if (absolutePath == null || absolutePath.isBlank()) { return -1; } - for (int i = 0; i < fileViewers.size(); i++) { - FileViewerWrapper wrapper = fileViewers.get(i); - if (wrapper == null || wrapper.getFilePath() == null) { + for (int i = 0; i < tabControl.getTabCount(); i++) { + IFileViewer viewer = getFileViewerAt(i); + if (viewer == null || viewer.getFilePath() == null) { continue; } - if (absolutePath.equals(wrapper.getFilePath())) { + if (absolutePath.equals(viewer.getFilePath())) { return i; } } diff --git a/app/src/main/java/com/basic4gl/desktop/Theme.java b/app/src/main/java/com/basic4gl/desktop/Theme.java index 4a778603..be2565bf 100644 --- a/app/src/main/java/com/basic4gl/desktop/Theme.java +++ b/app/src/main/java/com/basic4gl/desktop/Theme.java @@ -19,8 +19,12 @@ public class Theme { public static final String ICON_SETTINGS = THEME_DIRECTORY + "icon_settings_outline.png"; public static final String ICON_REFRESH = THEME_DIRECTORY + "icon_refresh.png"; public static final String ICON_DOTS_VERTICAL = THEME_DIRECTORY + "icon_dots_vertical.png"; + public static final String ICON_ADD = THEME_DIRECTORY + "icon_add.png"; public static final String ICON_VIEW_GRID = THEME_DIRECTORY + "icon_view_grid.png"; public static final String ICON_VIEW_LIST = THEME_DIRECTORY + "icon_view_list.png"; + public static final String ICON_EDIT = THEME_DIRECTORY + "icon_edit_outline.png"; + public static final String ICON_EDIT_PREVIEW = THEME_DIRECTORY + "icon_edit_preview_outline.png"; + public static final String ICON_PREVIEW = THEME_DIRECTORY + "icon_preview_outline.png"; public static final String ICON_CHEVRON_DOWN = THEME_DIRECTORY + "icon_chevron_down.png"; public static final String ICON_SEARCH = THEME_DIRECTORY + "icon_search.png"; public static final String ICON_ARROW_DOWN = THEME_DIRECTORY + "icon_arrow_down.png"; diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java index 990183a3..50dab91a 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerFactory.java @@ -46,6 +46,11 @@ public static IFileViewer.ViewerType getViewerType(File file) { return IFileViewer.ViewerType.MARKDOWN_VIEWER; } + // HTML files + if (name.endsWith(".html") || name.endsWith(".htm")) { + return IFileViewer.ViewerType.HTML_VIEWER; + } + // Default to text editor return IFileViewer.ViewerType.TEXT_EDITOR; } @@ -96,13 +101,27 @@ public static IFileViewer createViewer( } case MARKDOWN_VIEWER: if (file != null && file.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - return new MarkdownViewer(pluginContext, file); + return new MarkdownViewer( + pluginContext, + file, + actionListener, + fileManager, + toggleBreakpointListener, + linkGenerator, + searchContext); } case HTML_VIEWER: if (file != null && (file.getName().toLowerCase(Locale.ROOT).endsWith(".html") || file.getName().toLowerCase(Locale.ROOT).endsWith(".htm"))) { - return new HtmlViewer(pluginContext, file); + return new HtmlViewer( + pluginContext, + file, + actionListener, + fileManager, + toggleBreakpointListener, + linkGenerator, + searchContext); } case TEXT_EDITOR: default: diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java b/app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java index 2f894dad..b9f825ee 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileViewerWrapper.java @@ -14,65 +14,17 @@ public class FileViewerWrapper { public FileViewerWrapper(IFileViewer viewer) { this.viewer = viewer; - this.textEditor = (viewer instanceof TextFileViewer) ? ((TextFileViewer) viewer).getFileEditor() : null; + if (viewer instanceof TextFileViewer textFileViewer) { + this.textEditor = textFileViewer.getFileEditor(); + } else if (viewer instanceof HtmlViewer htmlViewer) { + this.textEditor = htmlViewer.getFileEditor(); + } else { + this.textEditor = null; + } } public FileViewerWrapper(FileEditor editor) { - this.viewer = new IFileViewer() { - @Override - public String getTitle() { - return editor.getTitle(); - } - - @Override - public String getFilePath() { - return editor.getFilePath(); - } - - @Override - public javax.swing.JComponent getContentPane() { - return editor.getContentPane(); - } - - @Override - public File getFile() { - return editor.getFile(); - } - - @Override - public String getShortFilename() { - return editor.getShortFilename(); - } - - @Override - public boolean isModified() { - return editor.isModified(); - } - - @Override - public void setModified() { - editor.setModified(); - } - - @Override - public ViewerType getViewerType() { - return ViewerType.TEXT_EDITOR; - } - - @Override - public boolean hasPreview() { - return false; - } - - @Override - public void setViewMode(ViewMode viewMode) {} - - @Override - public ViewMode getViewMode() { - return ViewMode.DEFAULT; - } - }; - this.textEditor = editor; + this(new TextFileViewer(editor)); } /** diff --git a/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java b/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java index 212bc68e..b2db27cc 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/HtmlViewer.java @@ -1,7 +1,10 @@ package com.basic4gl.desktop.content; import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; import com.basic4gl.desktop.spi.PluginContext; +import com.basic4gl.desktop.util.IFileManager; import com.basic4gl.desktop.spi.content.FileViewer; import com.basic4gl.desktop.spi.content.FileViewerException; import java.awt.*; @@ -16,14 +19,21 @@ import javafx.scene.Scene; import javafx.scene.web.WebView; import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import org.fife.ui.rsyntaxtextarea.LinkGenerator; +import org.fife.ui.rtextarea.SearchContext; public class HtmlViewer implements FileViewer, IFileViewer { private static final AtomicBoolean JAVAFX_INITIALIZED = new AtomicBoolean(false); + private final JPanel contentPane = new JPanel(new BorderLayout()); private final JFXPanel panel; private WebView webView; + private TextFileViewer textViewer; + protected File file; protected String source; @@ -33,6 +43,7 @@ public class HtmlViewer implements FileViewer, IFileViewer { public HtmlViewer() { panel = new JFXPanel(); + showViewMode(); ensureJavaFxInitialized(); Platform.runLater(() -> { @@ -54,6 +65,41 @@ public HtmlViewer(PluginContext pluginContext, File file) { } } + public HtmlViewer( + PluginContext pluginContext, + File file, + IFileEditorActionListener actionListener, + IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext) { + this(); + textViewer = new TextFileViewer( + file, actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + textViewer.getFileEditor().getEditorPane().getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + onEditorDocumentChanged(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + onEditorDocumentChanged(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + onEditorDocumentChanged(); + } + }); + try { + loadFile(pluginContext, file.toPath()); + } catch (FileViewerException ex) { + pluginContext.dialogs().showDialog("Unable to load HTML file: " + ex.getMessage()); + } + showViewMode(); + } + @Override public void loadFile(PluginContext context, Path path) throws FileViewerException { file = path.toFile(); @@ -61,10 +107,11 @@ public void loadFile(PluginContext context, Path path) throws FileViewerExceptio try { String html = Files.readString(path, StandardCharsets.UTF_8); + source = html; panel.putClientProperty("docs.path", path); - loadHtmlContent(html); + loadHtmlContent(renderPreviewHtml(html)); } catch (IOException ex) { context.dialogs().showDialog("Unable to load file: " + ex.getMessage()); @@ -73,6 +120,10 @@ public void loadFile(PluginContext context, Path path) throws FileViewerExceptio } } + protected String renderPreviewHtml(String source) { + return source == null ? "" : source; + } + protected void loadHtmlContent(String html) { ensureJavaFxInitialized(); @@ -88,7 +139,7 @@ protected void loadHtmlContent(String html) { @Override public JComponent getComponent() { - return panel; + return contentPane; } @Override @@ -154,7 +205,7 @@ public String getFilePath() { @Override public JComponent getContentPane() { - return panel; + return contentPane; } @Override @@ -169,13 +220,14 @@ public String getShortFilename() { @Override public boolean isModified() { - // TODO implement switching between preview and edit mode, wrapping TextFileViewer - return false; + return textViewer != null && textViewer.isModified(); } @Override public void setModified() { - // TODO implement switching between preview and edit mode, wrapping TextFileViewer + if (textViewer != null) { + textViewer.setModified(); + } } @Override @@ -195,10 +247,55 @@ public void setViewMode(ViewMode viewMode) { } else { this.viewMode = viewMode; } + showViewMode(); } @Override public ViewMode getViewMode() { return viewMode; } + + public FileEditor getFileEditor() { + return textViewer != null ? textViewer.getFileEditor() : null; + } + + private void onEditorDocumentChanged() { + source = textViewer.getFileEditor().getEditorPane().getText(); + if (viewMode == ViewMode.PREVIEW || viewMode == ViewMode.EDITOR_AND_PREVIEW) { + loadHtmlContent(renderPreviewHtml(source)); + } + } + + private void showViewMode() { + contentPane.removeAll(); + removeFromParent(panel); + JComponent editorContent = textViewer != null ? textViewer.getContentPane() : null; + if (editorContent != null) { + removeFromParent(editorContent); + } + + if (viewMode == ViewMode.EDITOR && editorContent != null) { + contentPane.add(editorContent, BorderLayout.CENTER); + } else if (viewMode == ViewMode.EDITOR_AND_PREVIEW && editorContent != null) { + loadHtmlContent(renderPreviewHtml(textViewer.getFileEditor().getEditorPane().getText())); + JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, editorContent, panel); + splitPane.setResizeWeight(0.5); + contentPane.add(splitPane, BorderLayout.CENTER); + } else { + if (textViewer != null) { + loadHtmlContent(renderPreviewHtml(textViewer.getFileEditor().getEditorPane().getText())); + } + contentPane.add(panel, BorderLayout.CENTER); + } + + contentPane.revalidate(); + contentPane.repaint(); + } + + private void removeFromParent(Component component) { + Container parent = component.getParent(); + if (parent != null) { + parent.remove(component); + } + } } diff --git a/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java index d5bd1422..6d0d4ba0 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java @@ -3,8 +3,11 @@ import static com.basic4gl.desktop.util.HtmlUtil.markdownToHtml; import com.basic4gl.desktop.MainWindow; +import com.basic4gl.desktop.editor.IFileEditorActionListener; +import com.basic4gl.desktop.editor.IToggleBreakpointListener; import com.basic4gl.desktop.spi.PluginContext; import com.basic4gl.desktop.spi.content.FileViewerException; +import com.basic4gl.desktop.util.IFileManager; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -12,6 +15,8 @@ import java.nio.file.Files; import java.nio.file.Path; import javax.swing.*; +import org.fife.ui.rsyntaxtextarea.LinkGenerator; +import org.fife.ui.rtextarea.SearchContext; public class MarkdownViewer extends HtmlViewer { @@ -31,13 +36,25 @@ public MarkdownViewer(PluginContext pluginContext, File file) { } } + public MarkdownViewer( + PluginContext pluginContext, + File file, + IFileEditorActionListener actionListener, + IFileManager fileManager, + IToggleBreakpointListener toggleBreakpointListener, + LinkGenerator linkGenerator, + SearchContext searchContext) { + super(pluginContext, file, actionListener, fileManager, toggleBreakpointListener, linkGenerator, searchContext); + } + @Override public void loadFile(PluginContext context, Path path) throws FileViewerException { file = path.toFile(); try { String markdown = Files.readString(path, StandardCharsets.UTF_8); - String html = buildMarkdownDocumentHtml(markdownToHtml(markdown)); + source = markdown; + String html = renderPreviewHtml(markdown); loadHtmlContent(html); @@ -63,6 +80,11 @@ public ViewerType getViewerType() { return ViewerType.MARKDOWN_VIEWER; } + @Override + protected String renderPreviewHtml(String source) { + return buildMarkdownDocumentHtml(markdownToHtml(source == null ? "" : source)); + } + private String readTextResource(String resourcePath) { try (InputStream input = MainWindow.class.getResourceAsStream(resourcePath)) { if (input == null) { diff --git a/app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java b/app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java index 0609168c..f529bb0b 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/TextFileViewer.java @@ -18,6 +18,10 @@ public class TextFileViewer implements IFileViewer { private final FileEditor editor; + public TextFileViewer(FileEditor editor) { + this.editor = editor; + } + /** * Creates a new text file viewer for an unsaved file. */ diff --git a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java index f9f31d1a..f2bfbca8 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/AssetsPanelProvider.java @@ -50,6 +50,11 @@ public class AssetsPanelProvider implements IEditorPanelProvider { private static final String LAYOUT_GRID = "Grid"; private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); private static final Dimension HEADER_LAYOUT_BUTTON_SIZE = new Dimension(34, 30); + private static final Color SEGMENTED_BACKGROUND = new Color(0xE0E0E0); + private static final String SEGMENTED_BUTTON_STYLE = + "arc: 14; borderWidth: 0; focusWidth: 0; innerFocusWidth: 0;" + + " margin: 6,8,6,8; background: #E0E0E0;" + + " hoverBackground: #D6D6D6; selectedBackground: #FFFFFF"; private FileManager fileManager; @@ -129,8 +134,7 @@ public JPanel build(PluginContext context) { title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); title.setForeground(new Color(0x424242)); title.setBorder(new EmptyBorder(0, 8, 0, 8)); - JPanel layoutTabs = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); - layoutTabs.setOpaque(false); + JPanel layoutTabs = createSegmentedButtonStrip(); ButtonGroup layoutButtons = new ButtonGroup(); JToggleButton treeLayoutButton = createAssetsLayoutButton("List View", ICON_VIEW_LIST, LAYOUT_TREE, "first"); JToggleButton gridLayoutButton = createAssetsLayoutButton("Grid View", ICON_VIEW_GRID, LAYOUT_GRID, "last"); @@ -318,6 +322,7 @@ private JToggleButton createAssetsLayoutButton( button.setFocusable(false); button.putClientProperty("JButton.buttonType", "segmented"); button.putClientProperty("JButton.segmentPosition", segmentPosition); + button.putClientProperty("FlatLaf.style", SEGMENTED_BUTTON_STYLE); button.setOpaque(false); button.setMargin(new Insets(6, 8, 6, 8)); button.setPreferredSize(HEADER_LAYOUT_BUTTON_SIZE); @@ -327,6 +332,14 @@ private JToggleButton createAssetsLayoutButton( return button; } + private JPanel createSegmentedButtonStrip() { + JPanel panel = new RoundedCardPanel(RoundedCardPanel.DEFAULT_ARC); + panel.setLayout(new BoxLayout(panel, BoxLayout.LINE_AXIS)); + panel.setBackground(SEGMENTED_BACKGROUND); + panel.setBorder(new EmptyBorder(1, 1, 1, 1)); + return panel; + } + private JButton createHeaderIconButton(String iconPath, String tooltip) { JButton button = new JButton(createScaledIcon(iconPath, 18)); button.setToolTipText(tooltip); diff --git a/app/src/main/resources/images/material/icon_add.png b/app/src/main/resources/images/material/icon_add.png new file mode 100644 index 0000000000000000000000000000000000000000..6cd12b467cd0e3753a6b927d7fc433d5424f3207 GIT binary patch literal 195 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj6`n4RAr*{of^Ks*81RH#FX2)b zP%q$mzU7Yl13SKT0bH6(1cWACsGg8K`w!OvsX3@P)PPa4#oc=Xp+FN(`9s@hLk}ywbJX)@|AGX9DA=Hp?Gir$s`7Bwb z1w1(#@G3i3N7|FTeEJ7OaT-bbRnFz}zp zX>tV&2jp0OGaqIY!nt9rK*0%!|Lgp2dY6K8L#7}fj^9PGFj(yoK_T3Tck(hZcQ-S%y60c z1d}V)Sb_X1_HcsC6g3s;PY%!iN$nsJBz%^VlAL8V=ZKVQTBhWeNA36eDIXOWqu2ZLy*t{AhbA@IrC&(^5 v)cDZ_R{#J2|Nqzd(0~8{00v1!K~w_()=}Lty=!vB00000NkvXXu0mjfVXOP% literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_edit_preview.png b/app/src/main/resources/images/material/icon_edit_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..7f1a6b99a0f51f571799b5afe45c4647dfe4aa86 GIT binary patch literal 606 zcmV-k0-^nhP)s1bwys;02 z7Q}nEZ1$(8o*9wAnFKIE6y3W;^-j7uBLL{mbh4noE8W*3kWQ_Sza`K%M-{o64zBNr z6t$>oDjOp}^04N6X>#iqyaQQmKo64OH(~efgq$Dft&(S6?i(5Dvx%+(3cRs}87NN7 z2IJ2ua+L9@Fz_$Z;`k-AgLW9u!Tz59h(5XryodsSuc*Yf*g_T6ux6fEf0iO7KW}}v zTp4QAbrOyY0`i@B1+ISx$S3Vva#^EG^6Mn+TR>hfY?sSuS*5Z$Ch9z9_y`)f)^M;V z49pa-kteV$io?;w#1C5(2f<>!R}S>{V0hk`KQ@-2G-^&m^%2J+N$l%3{KV?&D4@`D zGd05#uU65_-7pIeZklg&*Y*mzV0tY8dAj`N-OzmM5FG^sGR?Q4{g+)HyPlA7ETa&; zmz-RHIZGbLaXqQAwpHM>4dx-N`YZEyJ#&R)37U2Ca3=8GR&txq2q^RdARp0X3AFh; z5ZvbjJ;fF!+IB`D3dWzYcpwaX>4dg!$X(vm8OA%3GoJev*<3D~;^r)ia>c&0RR8q)V07*qoM6N<$g4}2kC;$Ke literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_edit_preview_outline.png b/app/src/main/resources/images/material/icon_edit_preview_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..56fc7995283474eeb9fde265f7ac694f50f698b8 GIT binary patch literal 618 zcmV-w0+s!VP)BWj@#vwOX7OeQm%nJjEJO~~b*<-GfK-rW7Nu)?;=1Z=l**l|bR z3AjbPwM*(yeCvr75v&OSJ%Uz!7cpOtHzx!D&Y7gIJ!%D}SFl|Wd4oU{ZyQ+?NL%H{ zc(AQ2V&L@*Xy1wn{Kc>RfmYv_Ent@_2l7LzH$KSlKiNOJ!SyEAbVW)j z>ts>wF~tU_+zUBkdRXh=NBMGjqlwKUUshX%9|O3ryR*A098Cp2{N&3_{PKi*ad)9m zSXV>mV`L~70pHe2X;_IuN|yTbPh z#A(tWAH5+2cCq3RThqdiqRwM=3%$NpH|2~>f#c_fcK|en$T-K$jyW=8N(P(}&f##f z?#Wu@p(FyLuU+_oIm;B7zH;f4ue~n8qTi+JxOZCm*(s_4N)CTA^W{zdE2U;7A9|)h zm|fzY5SiUGk+t<=UcqjrjS0lOf0g(ty}#?s2pO0+rylf2M8fdHk z8RsRv6C_%iKr6y%(y!8E+;!}EFOh@eP9IEN5WUBH3bh(Fp@0(z_@?%BpD4hpgB=P#5EfLf`$Pfxn!WEn*Yb+)>%=Oy1wvK$fj2lz0mh0Dr~WXh zWf=;`nKVXvYnjRpe4Fm07KIA?OK5`p7!507k0RR7D9Rx7|000I_L_t&o Y0F*w@F}DdGyZ`_I07*qoM6N<$f>tEH6aWAK literal 0 HcmV?d00001 diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java index e52c6393..e9a09722 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLLanguageService.java @@ -1,7 +1,5 @@ package com.basic4gl.language.adapter; -import static com.basic4gl.language.adapter.util.LanguageUtil.*; - import com.basic4gl.compiler.Preprocessor; import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; From 25d59f207f6e6519d73ad41b6c18a343dedb0d84 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Thu, 16 Jul 2026 00:32:39 -0400 Subject: [PATCH 27/38] fix exception with tabs --- .../java/com/basic4gl/desktop/MainWindow.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 0036c3ba..17c36f78 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -1202,8 +1202,7 @@ boolean actionSave() { fileManager.setRunDirectory(fileManager.getFileDirectory()); fileManager.setCurrentDirectory(fileManager.getRunDirectory()); } - tabControl.setTitleAt(index, fileManager.getFileEditors().get(index).getTitle()); - tabControl.getTabComponentAt(index).invalidate(); + refreshTabTitle(index); } return saved; } @@ -1226,8 +1225,7 @@ boolean actionSave(int index) { fileManager.setRunDirectory(fileManager.getFileDirectory()); fileManager.setCurrentDirectory(fileManager.getRunDirectory()); } - tabControl.setTitleAt(index, fileManager.getFileEditors().get(index).getTitle()); - tabControl.getTabComponentAt(index).invalidate(); + refreshTabTitle(index); } return saved; } @@ -1257,8 +1255,18 @@ void actionSaveAs() { // Restore Current directory fileManager.setCurrentDirectory(fileManager.getRunDirectory()); } + refreshTabTitle(index); + } + + private void refreshTabTitle(int index) { tabControl.setTitleAt(index, fileManager.getFileEditors().get(index).getTitle()); - tabControl.getTabComponentAt(index).invalidate(); + + Component tabComponent = tabControl.getTabComponentAt(index); + if (tabComponent != null) { + tabComponent.invalidate(); + } + tabControl.revalidate(); + tabControl.repaint(); } private void actionDebugMode() { From 52681c8037e538ebca42108a70673c352e039f2c Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Thu, 16 Jul 2026 01:06:46 -0400 Subject: [PATCH 28/38] Add Programmer's Guide to docs --- docs/command-line-function-guide.md | 84 ++ docs/file-io-guide.md | 274 ++++ docs/keyboard-mouse-joystick-guide.md | 405 ++++++ docs/language-syntax-guide.md | 1906 +++++++++++++++++++++++++ docs/network-engine-guide.md | 505 +++++++ docs/opcode-reference.md | 115 ++ docs/opengl-guide.md | 246 ++++ docs/runtime-compilation-guide.md | 167 +++ docs/sound-guide.md | 227 +++ docs/sprite-library-guide.md | 1265 ++++++++++++++++ docs/standard-function-guide.md | 291 ++++ docs/text-output-guide.md | 478 +++++++ docs/trigonometry-function-guide.md | 272 ++++ 13 files changed, 6235 insertions(+) create mode 100644 docs/command-line-function-guide.md create mode 100644 docs/file-io-guide.md create mode 100644 docs/keyboard-mouse-joystick-guide.md create mode 100644 docs/language-syntax-guide.md create mode 100644 docs/network-engine-guide.md create mode 100644 docs/opcode-reference.md create mode 100644 docs/opengl-guide.md create mode 100644 docs/runtime-compilation-guide.md create mode 100644 docs/sound-guide.md create mode 100644 docs/sprite-library-guide.md create mode 100644 docs/standard-function-guide.md create mode 100644 docs/text-output-guide.md create mode 100644 docs/trigonometry-function-guide.md diff --git a/docs/command-line-function-guide.md b/docs/command-line-function-guide.md new file mode 100644 index 00000000..f7b4d712 --- /dev/null +++ b/docs/command-line-function-guide.md @@ -0,0 +1,84 @@ +# Programmer's Guide: Command Line Functions + +Basic4GL standalone programs can accept commands from the command line. +Command line arguments are entered after the program name when a program is run from the command line. + +For example, if we built a standalone Java app called "CmdTest.jar", and ran it in a command prompt window with the command: + +> java -XstartOnFirstThread -jar "CmdTest.jar" 1 banana 2 cucumber 3 "Tomato sandwich" + +Then we have passed it 6 parameters: + +1. `1` +2. `banana` +3. `2` +4. `cucumber` +5. `3` +6. `Tomato sandwich` + +We can access these parameters with the `ArgCount` and `Arg` functions. + + +> [!IMPORTANT] +> +> `-XstartOnFirstThread` is required by LWJGL for the program window to display on Mac OS +> when running standalone apps from the command prompt. +> +> If your program does not start when run from the command prompt, +> try adding `-XstartOnFirstThread` to the Java arguments. + +### ArgCount +`ArgCount()` returns the number of command line arguments. + +### Arg +`Arg(index)` returns parameter number index as a text string, where `index` is `0` to return the first parameter. + +`index` should be between `0` and `ArgCount() - 1`, otherwise `Arg(index)` returns a blank string. + +## Setting command line arguments within Basic4GL + +> [!IMPORTANT] +> +> Setting command line arguments within Basic4GLj is upcoming functionality for v0.7.0 and is not available in older versions + +To set command line arguments for a program run inside Basic4GLj, open **Application** > **Project Settings**, select the **Program Arguments** tab, and enter one argument per line. + +## Some examples + +### Display all arguments: +``` +dim i +printr ArgCount(); " argument(s) found" +for i = 0 to ArgCount() - 1 +printr Arg(i) +next +``` + +### Compile and run another program: +``` +dim prog +if ArgCount() = 0 then +printr "No program name!" +end +endif +prog = CompileFile(Arg(0), "__") +if CompilerError() <> "" then +printr CompilerError() +end +endif +Execute(prog) +if CompilerError() <> "" then +print CompilerError() +end +endif +``` + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +_Programmer's guide_ + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen \ No newline at end of file diff --git a/docs/file-io-guide.md b/docs/file-io-guide.md new file mode 100644 index 00000000..bde0f510 --- /dev/null +++ b/docs/file-io-guide.md @@ -0,0 +1,274 @@ +# Programmer's Guide: File I/O + +## A note on security +Basic4GL programs can only read and write files from the directory where the Basic4GL program was saved +(or any subdirectory thereof). + +This is for security, and is intended to protect people new to programming +when trying out example programs from the internet and other sources. +For all we know, the person who wrote the program might think that overwriting files in the Windows system directory is a hilarious practical joke. +This way the potential damage is restricted to a small subfolder, and the people can download and run Basic4GL programs with confidence. + +Obviously this means that if you distribute Basic4GL programs that use File I/O, +you will have to ensure that the files read/written to end up in the appropriate directory so that they can be reached. + +> [!NOTE] +> +> This security restriction applies to the general purpose File I/O routines described below. +> +> Other functions that load data from disk are not subject to these restrictions, +> in particular the image and texture loading functions can load any file they want. + +> [!IMPORTANT] +> +> You can switch off these safety features by unchecking _"Safe mode"_ in the settings screen. +> +> If you do this, then you will need to make sure any program you run in Basic4GLj will not damage your computer. +> +> Also, standalone programs created with Basic4GLj ALWAYS run with _"safe mode"_ switched _OFF_. + +## Opening files +`OpenFileRead` and `OpenFileWrite` + +Files are opened like so: + +(For writing): +``` +dim file +... +file = OpenFileWrite ("Files/filename.ext") + +(For reading): + +dim file +... +file = OpenFileRead ("Files/filename.ext") +``` + +Where `filename.ext` is the filename and extension that is to be opened. + +`file` is an integer variable that will store the file handle. + +This is a number that Basic4GL generates to identify the file that was just opened, +and will be passed to other file routines to read data from or write data to the file. + +> [!IMPORTANT] +> +> If a file is opened for writing, it replaces any file that was there previously. +> +> If no file exists, one is created. + +## Error handling + +### FileError +If a file I/O routine fails, the Basic4GL program simply keeps running, +without performing the particular file operation that it attempted. + +You can test whether the operation succeeded by calling the `FileError ()` function. +This is updated after every file operation. +If the operation succeeded, it will be set to an error message, describing what went wrong. + +For example: +``` +dim file +file = OpenFileRead ("c:\autoexec.bat") +if FileError () <> "" then print FileError (): end endif +' Carry on... +``` + +## Closing the file + +### CloseFile +It is good practice to close the file once you've finished with it as follows: +``` +CloseFile (file) +``` + +> [!NOTE] +> +> If you forget, or your program stops for any reason before it can close the file, +> Basic4GL will close it automatically, the next time you run a Basic4GL program or when you close down Basic4GL. + +## File reading routines +> [!IMPORTANT] +> +> The file must have been opened with `OpenFileRead` for these routines to work correctly. + +### ReadLine +`ReadLine(file)` reads a line from a text `file` and returns it as a string. + +The lines are separated by carriage return and/or newline characters. + +### ReadText +`ReadText(file, skipEOL)` skips over whitespace (_spaces_, _tabs_ e.t.c) until it finds some text. +It then returns all the consecutive text at that point until a whitespace character has been reached, as a string. + +`SkipEOL` is a boolean (`true`/`false`) parameter. + +If `SkipEOL` is `true`, then `ReadText` will skip over any end-of-line characters it finds in the file. +If `SkipEOL` is `false`, it will stop at the end-of-line and return a blank string. + +This can be used to break up a text files into words. + +### ReadChar +`ReadChar(file)` reads a single character from the `file` and returns it as a string. + +### ReadByte +`ReadByte(file)` reads a single binary byte from the `file` and returns it as an integer. + +### ReadWord +`ReadWord(file)` reads a two byte "word" from the `file` and returns it as an integer. + +### ReadInt +`ReadInt(file)` reads a four byte integer from the `file` and returns it as an integer. + +### ReadFloat +`ReadFloat(file)` reads four bytes as a four byte floating point number and returns it as a real. + +### ReadDouble +`ReadDouble(file)` reads eight bytes as an eight byte floating point number and returns it as a real. + +### ReadReal +`ReadReal(file)` is a synonym for `ReadFloat(file)` in the current version of Basic4GL and Basic4GLj. + +> [!NOTE] +> +> Basic4GL's "real" type is equivalent to a "float" in C. + +## File writing routines +> [!IMPORTANT] +> +> The file must have been opened with `OpenFileWrite` for these routines to work correctly. + +### WriteLine +`WriteLine (file, text)` writes text to the file and automatically appends a carriage return/newline pair. + +`text` is a string value. + +### WriteString +`WriteString (file, text)` writes text to the file. + +> [!IMPORTANT] +> +> `WriteString` does not append a carriage return or linefeed to text output. +> A zero byte string terminator is NOT appended either.. + +`text` is a string value. + +### WriteChar +`WriteChar (file, text)` writes the first character of `text` to the `file` as a single character. + +`text` is a string value. + +### WriteByte +`WriteByte (file, intval)` writes `intval` to the `file` as a single byte value. + +`intval` is an integer value. + +### WriteWord +`WriteWord (file, intval)` writes `intval` to the `file` as a two byte "word" value. + +`intval` is an integer value. + +### WriteInt +`WriteInt (file, intval)` writes `intval` to the `file` as a four byte integer value. + +`intval` is an integer value. + +### WriteFloat +`WriteFloat (file, realval)` writes `realval` to the `file` as a four byte floating point value. + +`realval` is a real value. + +### WriteDouble +`WriteDouble (file, realval)` writes `realval` to the file as an eight byte floating point value. + +`realval` is a real value. + +### WriteReal +`WriteReal (file, realval)` is a synonym for `WriteFloat (file, realval)` + +## Other file I/O routines + +### EndOfFile +`EndOfFile (file)` applies to files opened for reading, and returns `true` if we have reached the end of the file. + +### Seek +`Seek (file, offset)` applies to files opened for reading, +and attempts to reposition the reading position to offset `bytes` from the beginning of the `file`. + +## Deleting a file + +### DeleteFile +`DeleteFile(filename)` will delete a file. + +> [!IMPORTANT] +> +> This routine is only available when _"Safe mode"_ is switched OFF. + +If the delete routine succeeds, `DeleteFile()` returns `true`. + +Otherwise, `DeleteFile()` returns `false`, and `FileError()` can be used to retrieve the text of the error. + +## Directory listing routines + +### FindFirstFile +`FindFirstFile(mask)` returns the filename of the first file that matches the text string `mask`. + +Example: +``` +dim filename$ +filename$ = FindFirstFile("*.gb") +print filename$ +``` + +Example 2: +``` +print FindFirstFile("files\*.*") +``` + +> [!IMPORTANT] +> +> Directory listing is subject to the same restrictions as general file access. +> +> That is, the directory must be the same directory as where the Basic4GL program is saved, or a subdirectory. + +If no matching file is found, `FindFirstFile` returns an empty string (`""`). + +### FindNextFile +`FindNextFile()` returns the filename of the next matching file in the directory. + +This function uses the same mask as was passed to `FindFirstFile`, +and therefore will only work after a successful `FindFirstFile` call. + +`FindNextFile` will keep returning the next filename until there are no more matching files, +at which point it returns an empty string (`""`). + +Example: +``` +dim filename$ +filename$ = FindFirstFile("*.gb") +while filename$ <> "" +printr filename$ +filename$ = FindNextFile() +wend +FindClose() +``` + +### FindClose +`FindClose()` will free resources after a `FindFirstFile..FindNextFile` directory search. + +> [!NOTE] +> +> It is not strictly required as Basic4GL will do this for you automatically when the program finishes. +> However, it is good practice. + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +_Programmer's guide_ + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen diff --git a/docs/keyboard-mouse-joystick-guide.md b/docs/keyboard-mouse-joystick-guide.md new file mode 100644 index 00000000..2e411494 --- /dev/null +++ b/docs/keyboard-mouse-joystick-guide.md @@ -0,0 +1,405 @@ +# Programmer's Guide: Keyboard, Mouse, and Joystick Input + +## Keyboard input + +### Input +Reads a text string from the keyboard. + +Format: +``` +Input variable +Input "prompt"; variable +Input "prompt", variable +``` + +`Input` will pause the program and wait until the user types in some text and hits enter. The text will be displayed on the screen as the user types. +If a prompt is given, it will be displayed on the screen. The first format (with the semicolon) automatically displays a question mark after the prompt. + +The second format (with the comma) simply displays the prompt and nothing else. + +Once the user has hit enter, the program will continue, and variable will contain the resulting text or number that the user entered. + +Examples: + +``` +dim name$ +input "What is your name"; name$ +print "Hello " + name$ +dim number +input "Please enter a number: ", number +print "The square root of " + number + " is " + sqrt (number) +``` +> [!NOTE] +> +> Be aware that Basic4GL's implementation of "input" is not as complete as other BASICs. +> Basic4GL does not support inputting multiple variables with the same input command. +> Also, Basic4GL will not prompt the user to "Redo from start" +> if the text he/she entered cannot be converted into the destination variable type. +> Instead it will simply set the destination variable to 0. + +> [!NOTE] +> +> There is an older `Input$()` function that has the syntax: +> +> `variable = Input$()` +> +> This is an old syntax, and kept only for backwards compatibility with older Basic4GL programs. + +## Key state + +### KeyDown and ScanKeyDown +Determines whether a key is currently pressed or released. + +Format: +``` +KeyDown(character) +``` +``` +ScanKeyDown(scan-code) +``` +`KeyDown` takes the first character of the string argument passed to it. + +`ScanKeyDown` takes a numeric virtual key code, often a `VK_x` constant (such as `VK_UP` e.t.c.) + +> [!TIP] +> +> Click "Help|Functions and Constants list..." then the "Constants" tab for a list. + +Both functions return `true` (`-1`) if the key is being pressed or `false` (`0`) if otherwise. + +> [!NOTE] +> +> KeyDown("") will always return false. + +#### Example 1: +``` +ResizeText (5, 1) +while true +locate 0, 0 +if KeyDown ("A") then print "Down" +else print " Up " +endif +wend +``` + +#### Example 2: +``` +dim a# +while true +glClear (GL_DEPTH_BUFFER_BIT or GL_COLOR_BUFFER_BIT) +glLoadIdentity () +glTranslatef (0, 0, -5) +glRotatef (a#, 0, 0, 1) +glBegin (GL_TRIANGLES) +glVertex2f ( 0, 1.5) +glVertex2f (-1,-1) +glVertex2f ( 1,-1) +glEnd () +SwapBuffers () +while SyncTimer (10) +if ScanKeyDown (VK_LEFT) then a# = a# + 3: endif +if ScanKeyDown (VK_RIGHT) then a# = a# - 3: endif +wend +wend +``` + +## Buffered input + +### Inkey$ and InScanKey +Format: +``` +Inkey$ () +InScanKey () +``` + +Basic4GL buffers characters and raw scan codes typed into the output window. + +`Inkey$ ()` returns characters typed as single character strings. +If no characters are buffered, `Inkey$ ()` will return an empty string. + +`InScanKey ()` returns scan codes as integers. If no scan codes are buffered, `InScanKey ()` returns `0`. + +Example: +``` +while true: print Inkey$ (): wend +``` + +### ClearKeys +Format: +``` +ClearKeys () +``` + +`ClearKeys ()` clears the keyboard buffer, +throwing away any keypresses that have yet to be handled by `Inkey$ ()` or `InScanKey ()`. + +`ClearKeys ()` is equivalent to the following code: + +``` +While Inkey$() <> "": wend +While InScanKey() <> 0: wend +``` + +## Mouse input +The following functions can be used to read the mouse. + +### Mouse_X, Mouse_Y +These functions return the position of the mouse in relation to the OpenGL window (if in windowed mode), +or the screen (fullscreen mode). + +`Mouse_X()` returns the `X` (horizontal) position. +`Mouse_Y()` returns the `Y` (vertical) position. + +Both functions return a real value between `0` (far left, or top) and `1` (far right, or bottom). + +#### Example 1: +``` +print Mouse_X () + ", " + Mouse_Y (): run +``` + +#### Example 2: +``` +ResizeText (80, 50) +dim x, y, char$ +while true +if not Mouse_Button (MOUSE_LBUTTON) then +locate x, y: print char$ +endif +x = Mouse_X () * TextCols () +y = Mouse_Y () * TextRows () +char$ = CharAt$ (x, y) +locate x, y: print "X" +wend +``` + +### Mouse_Button +`Mouse_Button (index)` returns `true` if button `index` is being pressed, or `false` if it isn't. + +The left mouse button is index `0`, the right is index `1` and the middle is index `2`. + +Alternatively you can use the following constants: + +| Button | Constant | +|---------------------|----------------| +| Left mouse button | MOUSE_LBUTTON | +| Right mouse button | MOUSE_RBUTTON | +| Middle mouse button | MOUSE_MBUTTON | + +Example: +``` +dim i +print "Press the mouse buttons!" +while true + locate 0, 2 + for i = 0 to 2: printr Mouse_Button (i) + " ": next +wend +``` + +### Mouse_Wheel +`Mouse_Wheel()` returns how many notches the mouse wheel has turned since the last time `Mouse_Wheel()` was called +(or the program started). + +For example: +``` +dim i +print "Turn the mouse wheel!" +while true +i = i + Mouse_Wheel () +locate 0, 2: print i + " " +wend +``` + +### Mouse_XD(), Mouse_YD() +These functions return how far the mouse has moved since the last time `Mouse_XD()` or `Mouse_YD()` was called +(respectively). + +`Mouse_XD()` returns the `X` (horizontal) distance. + +`Mouse_YD()` returns the `Y` (vertical) distance. + +These functions are useful for first-person shooter type movement, where the mouse is used to turn the player, +instead of controlling a pointer on the screen. + +> [!WARNING] +> +> `Mouse_XD()` and `Mouse_YD()` work internally by positioning the mouse pointer in the middle of the window +> and measuring how far the mouse moves from that position. +> +> This means that using `Mouse_X()` or `Mouse_Y()` will produce unexpected results, +> and it is recommended you stick to one method or the other. + +Example: +``` +dim x#, z# +while true + glClear (GL_DEPTH_BUFFER_BIT or GL_COLOR_BUFFER_BIT) + glLoadIdentity () + glTranslatef (0, 0, -4) + glRotatef (z#, 0, 0, 1) + glRotatef (x#, 1, 0, 0) + glBegin (GL_TRIANGLES) + glVertex2f (0, 1) + glVertex2f (-.5, -1) + glVertex2f ( .5, -1) + glEnd () + SwapBuffers () + z# = z# - Mouse_XD () * 100 + x# = x# + Mouse_YD () * 100 +wend +``` + +## Joystick input +> [!NOTE] +> +> A big thanks to Tyler Bingham for implementing the joystick support for the original Basic4GL! + +Basic4GL supports input from a single joystick. If more than one joystick is attached to a PC, +Basic4GL will use whatever one the operating system says is first. + +The following functions can be used to read the joystick. + +### Joy_Keys +`Joy_Keys()` takes a snapshot of the joystick and generates appropriate keypresses. + +Arrow keys are generated for stick movement, and space bar and control (_Ctrl_) keypresses are generated for joystick buttons `0` and `1` respectively. + +The keypresses can then be detected with the keyboard input functions: +- `InScanKey ()` +- `KeyDown (...)` +- `ScanKeyDown ()` + +> [!NOTE] +> +> Inkey$() is not affected by Joy_Keys(). + +This effectively provides a simple and easy way of incorporating joystick and keyboard support into a program. + +Example: +``` +dim x, y +x = TextCols () / 2 +y = TextRows () / 2 +while true + Joy_Keys () + if not ScanKeyDown (VK_SPACE) then + locate x, y: print " " + endif + if ScanKeyDown (VK_LEFT) and x > 0 then x = x - 1 endif + if ScanKeyDown (VK_RIGHT) and x < TextCols () - 1 then x = x + 1 endif + if ScanKeyDown (VK_UP) and y > 0 then y = y - 1 endif + if ScanKeyDown (VK_DOWN) and y < TextRows () - 1 then y = y + 1 endif + locate x, y: print "X" + Sleep (30) +wend +``` + +### Joy_X, Joy_Y +`Joy_X()` returns the `X` (horizontal) position. + +`Joy_Y()` returns the `Y` (vertical) position. + +Both functions return a value from `-32768` (far left, or top) to `32767` (far right or bottom). + +`0` is the centre of each axis. (If you have a stable, properly calibrated digital joystick.) + +Example: +``` +print Joy_X () + ", " + Joy_Y (): run +``` + +### Joy_Button +`Joy_Button(index)` returns `true` if button index is currently being pressed, or `false` if isn't. + +The first joystick button is index `0`. The second is index `1` e.t.c + +Example: +``` +dim i +for i = 0 to 9 + if joy_button (i) then print i: else print " ": endif +next +run +``` + +### Joy_Left, Joy_Right, Joy_Up, Joy_Down + +`Joy_Left ()` returns `true` if the joystick is more than `100` units to the left. (This is equivalent to: `Joy_X () < -100`) + +`Joy_Right ()` returns `true` if the joystick is more than `100` units to the right. (This is equivalent to: `Joy_X () > 100`) + +`Joy_Up ()` returns `true` if the joystick is more than `100` units upwards. (This is equivalent to: `Joy_Y () < -100`) + +`Joy_Down ()` returns `true` if the joystick is more than `100` units downwards. (This is equivalent to: `Joy_Y () > 100`) + +### Joy_0, ..., Joy_9 +There are also explicit functions for each joystick button from `0` through to `9`. + +`Joy_0()` returns true if the first joystick button is being pressed. (This is equivalent to: `Joy_Button(0)`). + +... + +`Joy_9()` returns true if the 10th joystick button is being pressed. (This is equivalent to: `Joy_Button(9)`). + +## Joystick polling +To "poll" the joystick means to take a snapshot of its current state, including the readings of the `X` and `Y` axis +and whether each button is up or down at the time of the poll. + +Basic4GL automatically polls the joystick whenever one of the joystick functions is called, +so you don't have to tell it to explicitly. + +For example: +``` +while true: printr Joy_X() + " " + Joy_Y () + " " + Joy_0() + " " + Joy_1 (): wend +``` + +> [!TIP] +> +> Polling takes time (at least on older analogue joysticks). +> +> You may want to explicitly tell Basic4GL when to poll the joystick, in order to make the program run faster. +> +> It is more efficient to poll the joystick once, and then act on the `X` axis, `Y` axis and button data captured in that poll +> than to poll the joystick for each axis and button that you read. + +### UpdateJoystick +`UpdateJoystick ()` polls the joystick and takes a snapshot of the `X` and `Y` axis and the state of all the buttons. + +Any `Joy_?` calls will now return the data captured at the time of the `UpdateJoystick()` call. + +For example: +``` +while true: UpdateJoystick (): printr Joy_X() + " " + Joy_Y () + " " + Joy_0() + " " + Joy_1 (): wend +``` + +Now instead of reading the joystick 4 times each time around the loop, we are only reading it once. +This runs significantly faster than the previous example on my PC +(although my PC has an older analogue joystick attached to it.. I can't comment on digital joysticks.) + +As soon as you call `UpdateJoystick()`, Basic4GL switches to explicit joystick updates, +and stays that way until your program finishes executing. + +Therefore, you must keep calling `UpdateJoystick()` at the appropriate times to ensure the joystick data is up-to-date. + +If you don't, the joystick will appear frozen, for example: +``` +UpdateJoystick () +while true: printr Joy_X() + " " + Joy_Y () + " " + Joy_0() + " " + Joy_1 (): wend +``` + +Here we have moved the `UpdateJoystick()` call out of the main loop, so it is only called once at the start of the program. + +Because we don't ever call it again, each joystick functions will simply return the same value each time, +i.e. the state of the joystick at the start of the program when `UpdateJoystick()` was called. + +So manual polling can be faster, but you must do it right! + + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +_Programmer's guide_ + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen \ No newline at end of file diff --git a/docs/language-syntax-guide.md b/docs/language-syntax-guide.md new file mode 100644 index 00000000..51d79277 --- /dev/null +++ b/docs/language-syntax-guide.md @@ -0,0 +1,1906 @@ +# Basic4GL Language Guide +This document is aimed at experienced programmers and describes the basic syntax of Basic4GL programs. + +This document focuses on the language itself, and as such does not go into the individual functions and constants, +or how they are intended to be used. + +## Basic4GL Overview +Basic4GL is designed to combine a simple, safe and easy to understand programming language based on traditional BASIC with the OpenGL graphics library, +so that programmers can experiment and learn OpenGL and beginning programmers can learn about programming in general. + +The downside is that Basic4GL cannot compete with programs compiled to native machine code (e.g. from a C++ compiler). +But this was never the intention. + +Basic4GL compiles programs to byte code, which it runs on a virtual machine. +This makes Basic4GL a safe language to experiment in as the virtual machine protects the programs from writing +to invalid addresses or jumping to uninitialised code, +and handles cleaning up resources such as OpenGL textures automatically. + +In addition, the Basic4GL virtual machine automatically handles certain setup tasks +such as creating an OpenGL capable window (and initialising OpenGL state), handling windows messages, +and buffering keyboard input. + +Basic4GL programs do not need to initialise OpenGL windows, link to libraries, include header files or declare function prototypes. +This means you can cut through all the paperwork and get straight to the code that does the actual work. + +The following examples are complete Basic4GL programs. + +#### Example 1, A "Hello world" program: + +``` +print "Hello world!" +``` + +#### Example 2, drawing a square in OpenGL: + +``` +glTranslatef (0, 0, -5) +glBegin (GL_QUADS) +glVertex2f ( 1, 1): glVertex2f (-1, 1): glVertex2f (-1,-1): glVertex2f ( 1,-1) +glEnd () +SwapBuffers () +``` + +## BASIC Language Syntax +As of Basic4GL language version 2.3.2, Basic4GL supports a new "traditional BASIC" syntax. + +> [!NOTE] +> Basic4GLj is based on Basic4GL 2.5 + +This syntax is intended to be more compatible with other BASIC compilers, +to make porting code between them and Basic4GL a little bit easier, +and to make programming in Basic4GL a little easier for people who are used to other BASIC compilers. + +The new syntax must be explicitly enabled, otherwise Basic4GL will simply use the standard Basic4GL syntax. +You do this by placing the following command at the top of your program: + +``` +language traditional +``` + +Basic4GL also accepts: + +``` +language basic4gl +``` + +Which will switch the compiler to the standard Basic4GL syntax. (Although it's not really necessary, as this is the default syntax anyway.) + +And also: +``` +language traditional_print +``` + +Which is a tradeoff between the standard Basic4GL syntax, except with a more traditional `print` command syntax. + +Syntax differences +The differences between the "Traditional BASIC" and old "Basic4GL" syntax are listed below: + +"Traditional" BASIC Basic4GL +Functions have round brackets only if they return a value. +Examples: + +``` +a = rnd()%5 +sleep 1000 +locate 10, 12 +glVertex3f -5, 12, 2 +print sqrt(2) +``` + +All functions have round brackets except for `cls`, `print`, `printr` and `locate` + +Examples: + +``` +a = rnd()%5 +sleep(1000) +locate 10, 12 +glVertex3f(-5, 12, 2) +print sqrt(2) +``` + +If a `print` command ends with a semicolon (`;`) the cursor will remain on the same line. +Otherwise, the cursor will move to the next line. + +Example: + +``` +print "============" +print " Tom's game" +print "============" +print +print "Please enter your name:"; +``` + +The cursor always remains on the same line after `print`. +To have the cursor move to the next line, use the `printr` command instead. + +Example: + +``` +printr "============" +printr " Tom's game" +printr "============" +printr +print "Please enter your name:" +``` + +When dividing two integers, they will automatically be converted to floating point first. + +Examples: + +``` +print "5 goes into 12"; int(5/12); "times" +print "10/8 = "; 10/8 +a = 3: b = 4: c# = a / b +``` + +When dividing two integers, integer division is used, and the remainder is discarded. + +Examples: + +``` +printr "5 goes into 12 "; 5/12; " times" +printr "10/8 = "; 10.0 / 8 +a = 3: b = 4: c# = (a * 1.0) / b +``` + +## Syntax documentation +The syntax documented in these help files is the standard Basic4GL syntax. + +When other syntaxes differ, the differences will be described in their corresponding **Compatibility with other BASICs** section. + +## "Include" files + +Basic4GL supports a very simple `include` mechanism. +You can include a file in your main program with: +``` +include filename.ext +``` + +Where `filename.ext` is the filename and extension of the file you wish to include. + +> [!IMPORTANT] +> +> "include" must be on its own line, with no leading spaces before the `include` keyword. + +When keyed in correctly the line will become highlighted, and `filename.ext` will be displayed as an underlined hyperlink (which you can click to open up the include file). + +Basic4GL will compile your file as if all the lines of `filename.ext` had been cut and pasted in at the point of the include. + +> [!IMPORTANT] +> +> "include" is not supported by the runtime compilation functions ("Compile()" and "CompileFile()"). + +## Basic language features + +### Comments + +Comments are designated with a single quote. + +All text from the quote to the end of the line are ignored by the compiler. +``` +' Program starts here +dim a 'Declare a variable +a = 5 'Initialise to a value +print a 'Print it to screen +``` + +Is equivalent to: + +``` +dim a +a = 5 +print a +``` + +### Case insensitivity + +Basic4GL is a case insensitive language. This applies to all keywords and variable names, and infact anything except the contents of string constants. + +The following lines are all equivalent: + +``` +GLVERTEX2F (X, Y) +glVertex2f (x, y) +glvertex2f(X, Y) +``` + +The following lines are not equivalent: + +``` +print "HELLO WORLD" +print "Hello World" +print "hello world" +``` + +(because the "Hello world"s are quoted strings). + +### Separating instructions +Instructions are separated by colons `:` or new-lines. + +The following code sample: + +``` +dim a$: a$ = "Hello": print a$ +``` + +Is equivalent to: + +``` +dim a$ +a$ = "Hello" +print a$ +``` + +## Variables and data types + +Basic4GL supports only 3 basic data types (although they can be combined into structures which are described further on). + +| Data Type | Description | +|-----------|--------------------------------| +| Integer | A 32 bit signed integer. | +| Real | A 32 bit floating point value. | +| String | A character string. | + +Variables are declared and allocated explicitly with the `Dim` instruction. +Attempting to use a variable without declaring it with `Dim` will result in a compiler error. + +A naming convention is used to designate the type of each variable, as follows: + +String variables are post-fixed with a `$` character, for example: + +``` +Dim a$ +a$ = "Hello world" +``` + +Real variables are post-fixed with a `#` character, for example: + +``` +Dim value# +value = 1.2345 +``` + +Integer variables are not post-fixed, for example: + +``` +Dim index +index = 10 +``` + +### Declaring variables (with Dim) + +All variables must be declared with `Dim` before use. + +The format is: + +``` +Dim variable [, variable [, ...]] +``` + +For example: + +``` +Dim a +Dim name$ +Dim a, b, c +Dim xOffset#, yOffset# +Dim ages(20) +Dim a, b, c, name$, xOffset#, yOffset#, ages(20) +``` + +`Dim` is both a declaration to the compiler that the keyword is to be treated as a variable, and an executed instruction. +Therefore, the `Dim` instruction must appear before the variable is used. + +This program: +``` +a = 5 +Dim a +``` +Results in a compiler error, because the compiler encounters `a` in an expression before it is declared with `Dim`. + +This program: + +``` +goto Skip +Dim a +Skip: +a = 5 +``` + +Compiles successfully but results in a run time error, as it attempts to write to `a` before the `Dim` instruction has executed, +and therefore no storage space has yet been allocated for it. + +The correct example is (of course): + +``` +Dim a +a = 5 +``` + +#### Compatibility with other BASICs +Basic4GL also supports the syntax: + +``` +Dim variable as type +``` + +Where type can be one of: + +- `integer` +- `string` +- `single` +- `double` + +> [!NOTE] +> +> Basic4GL has only one floating point type which is a single precision float (ie a `single`). The `double` keyword is still accepted for compatibility, but Basic4GL still allocates a single precision floating point number. + +### Allocating variable storage +Storage space is allocated when the `Dim` instruction has been executed. +In addition, Basic4GL automatically initialises the data as follows: + +- Integers and reals are initialised to `0`. +- Strings are initialised to the empty string `""`. + +### Re-Dimming a variable +Attempting to `Dim` the same variable twice results in a runtime error. +There is currently no way to re-dim a variable. However, this may be included in a future version of Basic4GL. + +### Array variables +Basic4GL supports single and multi-dimensional arrays. These are "Dim"med by specifying the array variable name, followed by a number in round brackets. + +Basic4GL will allocate elements from indices `0`, through to and including the value specified in the brackets. + +Examples: + +``` +Dim a$(10) +Dim size#(12) +const MaxThings = 12 +Dim ThingHeight# (MaxThings), ThingWidth#(MaxThings) +dim count: count = 10 +Dim array(count), bigArray (count * 10) +``` + +For arrays of more than one dimension, each dimension is specified in its own pair of brackets. + +Examples: + +``` +Dim matrix#(3)(3) +matrix#(2)(3) = 1 +const width = 20, height = 15 +Dim grid(width)(height) +``` + +Is mentioned, Basic4GL allocates elements from indices `0`, through to and including the value specified in the brackets. + +For example: +``` +Dim a(3) +``` + +Will allocate four integers, named `a(0)`, `a(1)`, `a(2)` and `a(3)`, and set their values to `0`. + +Basic4GL arrays are sized at runtime. You can use any (expression that can be cast to an integer) to specify the number of elements. + +> [!CAUTION] +> +> However, keep in mind that Basic4GL will stop with a runtime error if you attempt to allocate array: +>- With an array size of less than 0, OR +>- That uses more memory than the Basic4GL memory limit. + +Basic4GL arrays can be copied by specifying the array name without any brackets or indices. The target array must be the same size as the copied array, otherwise a runtime error will result. + +Examples: +``` +Dim a$(4), b$(4) +... +b$ = a$ ' Copy entire array from a$ to b$ +``` + +Likewise, some functions accept arrays as parameters, or return them as results: +``` +Dim matrix#(3)(3) +matrix# = MatrixTranslate (-.5, -.5, -2) +glLoadMatrixf (matrix#) +glBegin (GL_TRIANGLES) +glVertex2f (0, 0): glVertex2f (1, 0): glVertex2f (0, 1) +glEnd () +SwapBuffers () +``` + +If you specify just one dimension of a 2D array, the result is a 1D array, +which can be assigned to/from variables or passed to to/functions like any other 1D array of the same type. + +Example: +``` +dim vectors# (12)(3), temp#(3) +temp# = vectors# (4) +``` + +Likewise, specifying N dimensions of an M dimension array results in a (M - N) dimension array. + +#### Compatibility with other BASICs + +Basic4GL also supports the syntax: + +``` +Dim variable(dimension [,dimension [...]]) +``` + +For multidimension arrays. + +E.g. +``` +dim grid(20, 10) +grid (3, 7) = 12 +``` + +Is exactly equivalent to: +``` +dim grid(20)(10) +grid (3)(7) = 12 +``` + +**Why not automatically allocate variables?** + +Early designs of Basic4GL were intended to allocate variables automatically the first time they were encountered. +However, Basic4GL is case-insensitive, and OpenGL uses long constants for bitmasks and flags. + +Therefore, mistyping (or miss-spelling) a constant in an OpenGL function call such as: + +``` +glClear (GL_DEPTH_BUFER_BIT) ' Missing an "F" in "BUFFER" +``` + +Would have resulted in a code that still compiles, but instead of passing the value of `GL_DEPTH_BUFFER_BIT` into the function, Basic4GL would have created a new variable called `GL_DEPTH_BUFER_BIT`, initialised the value to `0`, and then passed `0` into the function. +This type of error can be very confusing and frustrating, especially when learning a library such as OpenGL. + +Therefore, variables must be explicitly declared with `Dim`. + +### Converting between data types +You can convert a variable, or an expression value to a different type, simply by assigning it to a variable of that type, providing the conversion type is one of the ones below: + +- Integer -> Real +- Real -> Integer +- Integer -> String +- Real -> String + +Certain expression operators such as `+`, `-`, `*`, `/` can also result in an automatic conversion of either the left or right operand to match the other, using the following rules: + +- If one operand is a string, the other operand is converted to a string before the operation is performed. +- If one operand is a real and the other is an integer, the integer is converted to a real before the operation is performed. + +### Literal constants +To use a literal integer in a Basic4GL program, simply specify the integer value. + +Examples: + +``` +Dim a: a = 5 +Dim a: a = -5 +``` + +Likewise, to use a literal real: + +``` +Dim a#: a# = 3.14159265 +``` + +Literal integers can also be specified in hexadecimal using the `0x` prefix. + +Examples: + +``` +Dim a: a = 0xff +Dim a: a = -0xff +``` + +To use a literal string, simply encase the string in double quotes. + +For example: + +``` +Dim helloString$: helloString$ = "Hello world!" +``` + +Basic4GL does not support literal prefix notations, such as `\n` for newline in C/C++. +You can however use the Chr$() function to achieve the same effect, for example: + +``` +Dim a$: a$ = "Bob says " + Chr$(34) + "Hello!" + Chr$ (34) +Print a$ +``` + +Will output: + +> Bob says "Hello!" + +### Named constants +Basic4GL also has a number of named constants, such as `M_PI` and `GL_CULL_FACE`. + +> [!TIP] +> +> For a complete list, click "Help > Function and Constant list..." and click the "Constants" tab. + +> [!NOTE] +> +> Two commonly used constants are `true` and `false`, which evaluate to `-1` and `0` respectively. + +You can add constants using the `Const` instruction. + +The format is: +``` +Const name = value [, name = value [, ...]] +``` + +Where: + +`name` is the name of the constant, and follows the same naming conventions as standard variables, (including `#` and `$` suffixes for real and string constants respectively). +`value` is a literal constant, another named constant, or a constant expression (defined below) +For example: + +``` +const Things = 20 +const Max = 100, Min = 1 +const StepCount = 360, StepSize# = 2 * m_pi / StepCount +const major = 3, minor = 7, version$ = major + "." + minor +``` + +### Constant expressions +Certain instructions require constant expressions, such as the `const` instruction (described above), and the `step` part of the `for..next` instruction. +These expressions must always evaluate to the same value and Basic4GL must be able to calculate this value at the time the program is compiled. + +An expression must satisfy these criteria to be considered "constant" by Basic4GL: + +- The expression must contain only literal constants or named constants. +- These constants can only be combined with the standard operators: + `+`, `-`, `*`, `/`, `%`, `=`, `<>`, `>`, `>=`, `<`, `<=`, `or`, `and`, `not`. + +Examples: + +``` +-12 +22.4 +m_pi +m_pi / 180 +true and not false +"banana" +"banana " + "split" +"Pi = " + m_pi +``` + +Are all valid constant expressions + +Expressions are not considered constant if they contain variables or functions. This holds even for expressions that (to a human) are obviously constant. +For example: + +``` +sqrt (2) +length (vec3 (1, 1, 1)) +``` + +Are not valid constant expressions in Basic4GL, even though it is clear to us that they will always evaluate to the same value. + +### Structures + +Structures are used to group related information together into a single "data structure". +The format is as follows: + +``` +Struc strucname + +dim field [, field [,...]] +[dim field [,[field [,...]]] +[...] + +EndStruc +``` + +Example: + +``` +struc SPlayer +dim pos#(1), vel#(1) +dim dir#, lives, score, deadCounter, inGame +dim leftKey, rightKey, thrustKey, shootKey +dim wasShooting +endstruc +``` + +This defines a data storage format. You can now allocate variables of the new structure type by using a special format of the `Dim` instruction: + +``` +Dim strucname variablename +``` + +Examples: + +``` +Dim SPlayer player +const maxPlayers = 10 +Dim SPlayer players (maxPlayers) +``` + +Each variable now stores all the information described in the structure. You can access these individual fields using the `.` operator as follows: + +``` +structurename.fieldname +``` + +For example: + +``` +player.pos#(0) = 12.3 +players (4).score = players (4).score + 10 +i = 3 +print players (i).lives +``` + +You can also assign variables of the same structure type to one another. This will copy all the fields from one variable to the other. + +Example: + +``` +player (7) = player (6) +``` + +#### Compatibility with other BASICs + +Basic4GL also supports the syntax: +``` +Type typename + +variable as type [, variable as type [...]] +[...] + +End type +``` + +E.g. +``` +struc SpaceMartian +dim name$ +dim x#, y# +dim health(4) +endstruc +``` + +Is equivalent to: +``` +type SpaceMartian +name as string +x, y as single +health(4) as integer +end type +``` + +(Except that in the first example the field names now have `$` and `#` post-fixes.) + +### Arrays inside structures +Structures can contain arrays. Unlike regular arrays, the size of an array in a structure must be fixed at compile time. +This means that the array size must be either a numeric constant, or a named constant, or a constant expression. + +For example: +``` +struc STest: dim a(10): endstruc +const size = 20 +struc STest2: dim array$(size): endstruc +``` + +Will work. + +However, this example: +``` +dim size: size = 20 +struc STest2: dim array$(size): endstruc +``` + +Will cause a compile time error, because size is now a variable and is not fixed at compile time. +(Even though it's obvious to a human that it will always be 20!) + +### Pointers + +Basic4GL has a pointer syntax which is vaguely similar to C++'s `reference` type, but a lot more simplified. + +#### Declaring pointers +Pointers are declared by prefixing a `&` character before the variable name in the `Dim` statement. +The syntax is then the same as "Dim"ming a regular variable, except that array dimensions must be specified with `()` (i.e with no number in the brackets). + +So whereas: +``` +Dim i, r#, a$, array#(10), SomeStructure s, matrix#(3)(3) +``` + +Declares and allocates: +- An integer named "i" +- A real named "r#" +- A string named "a$" +- An array of reals named "array#" +- A structure of type "SomeStructure" named "s" +- A 2D array of reals named "matrix#" + +``` +Dim &pi, &pr#, &pa$, &parray#(), SomeStructure &ps, &pmatrix#()() +``` +Declares: +- An pointer to an integer named "pi" +- A pointer to a real named "pr#" +- A pointer to a string named "pa$" +- A pointer to an array named "parray#" +- A pointer to a structure of type "SomeStructure" named "ps" +- A pointer to a 2D array of reals named "pmatrix#" + +#### Setting pointers + +Pointer variables are initially unset. Attempting to read or write to the data of an unset pointer results in a runtime error. To do anything useful you need to point them to a variable, otherwise known as "set"ting them. + +Pointers are set using this syntax: +``` +&pointer = &variable +``` + +Examples: +``` +Dim a$, &ptr$ +a$ = "Hello world" +&ptr$ = &a$ +print ptr$ +Dim array(10), &element, i +for i = 1 to 10: &element = &array(i): element = i: next +dim matrix#(3)(3), &basisVector#(), axis, i +matrix# = MatrixIdentity () +print "Axis? (0-3): ": axis = Val (input$ ()) ' Enter 4 to crash! +&basisVector# = &matrix# (axis) +for i = 0 to 3: print basisVector# (i) + " ": next +``` + +#### Accessing pointer data + +Once a pointer is set, it can be accessed like any other variable, i.e. read, assigned to, passed to functions e.t.c. +The actual data read from or written to will be that of the variable that it is pointing to. +``` +Dim a, b, &ptr +&ptr = &a +a = 5 ' a is 5, b is 0 +b = ptr ' a is 5, b is 5 +ptr = b + 1 ' a is 6, b is 5 +print "a = " + a + ", b = " + b +``` + +#### Un-setting pointers + +You can "un-set" a pointer by assigning it the special constant `null`, as follows: +``` +Dim val, &ptr +&ptr = &val ' Pointer now set +&ptr = null ' Pointer now un-set +``` + +You can also compare a pointer to `null`. + +``` +if &ptr = null then + + ... + +endif +if &ptr <> null then + + ... + +endif +``` + +### Mixing structures, arrays and pointers +You can mix structures, arrays and pointers mostly in any way you wish. +There are a few limitations to keep in mind however: + +You cannot allocate an array of pointers, as: +``` +Dim &ptrs() +``` +will allocate a pointer to an array. + +If you really need an array of pointers you can use the following workaround: + +``` +struc SPtr: dim &ptr: endstruc +dim SPtr array (100) +``` + +Then you can set the pointers using: +``` +&array (5).ptr = &var +``` +(or similar.) + +## Allocating data +Basic4GL supports a very simple memory allocation scheme. Memory once allocated is permanent (until the program finishes). +There is no concept of freeing a block of allocated memory! (Note: While this has some obvious limitations, it does prevent a large number pointer related bugs. Keep in mind that Basic4GL was never intended to be the next C++...) + +Data is allocated as follows: +``` +alloc pointername [, arraysize [, arraysize [...]]] +``` + +Where `pointername` is the name of a Basic4GL pointer variable DIMmed earlier. + +Examples: +``` +dim &ptri +alloc ptri ' Allocate an integer +dim &ptrr# +alloc ptrr# ' Allocate a real numer +dim &ptrs$ +alloc ptrs$ ' Allocate a string +struc SPlayer: dim x, y, z: endstruc +dim SPlayer &ptrplayer +alloc ptrplayer ' Allocate a player structure +``` +Basic4GL allocates a variable of the type that `pointername` points to, and then points `pointername` to the new variable. + +To allocate an array, add a comma, and list the dimension sizes separated by commas. + +Examples: + +``` +dim &ptrarray () ' Array size is not specified here! +alloc ptrarray, 100 ' Specified here instead! +dim &ptrMatrix#()() +alloc ptrMatrix, 3, 3 +``` + +As with DIMming arrays, specifiying N as the array size will actually create N+1 elements: 0 through to N inclusive. +Also, the array size is calculated at runtime, and is subject to the same rules as DIMming an array +(size must be at least 0 e.t.c). + +## Expressions + +### Operators + +Basic4GL evaluates infix expressions with full operator precedence. + +In most loosely to most tightly bound order: + +| Operator | Description | Example | +|----------|----------------------------------------------------------------------------------------------------------------------|-------------------------| +| or | Bitwise or | a# < 0 or a# > 1000 | +| and | Bitwise and | a# >= 0 and a# <= 1000 | +| xor | Bitwise exclusive or | a = a xor 255 | +| lor | Bitwise lazy or | a# < 0 lor a# > 1000 | +| land | Bitwise lazy and | a# >= 0 and a# <= 1000 | +| not | Bitwise not | not a# = 5 | +| = | Test for equal
        _= can also be used to compare pointers of the same type, or to compare pointers to null._ | a# = 5 | +| <> | Test for not equal
        _<> can also be used to compare pointers of the same type, or to compare pointers to null._ | a# <> 5 | +| \> | Test for greater than a > 10 | | +| \>= | Test for greater or equal a# >= 0 | | +| < | Test for less than a# < 9.5 | | +| <= | Test for less or equal a <= 1000 | | +| + | Add numeric values, or concatenate strings | | +| - | Subtract | | +| * | Multiply | | +| / | Divide | | +| % | Remainder | | +| - | (with single operand) Negate | a * -b | + +Notes: + +`+` and `-` have equal precedence (except when minus is used to negate a single value). +The comparison operators: `=`, `<>`, `>`, `>=`, `<`, `<=` all have equal precedence. +Operators with equal precedence are evaluated from left to right. + +You can force Basic4GL to evaluate expressions in a different order by enclosing parts of them in round brackets. For example: + +``` +(5 + 10) / 5 +``` + +Will add 5 to 10, then divide the result by 5 (giving 3), whereas: + +``` +5 + 10 / 5 +``` + +Will divide first, then add, and the resulting value will be 7. + + + +> [!TIP] +> +> Operators generally operate on standard integer, real and to a lesser extent string types. +> However certain operators have been extended to work with 1D and 2D arrays of real numbers for vector and matrix functions. +> +> These are explained in the **Programmer's Guide**. + +Also, the `=` and `<>` operators can also be used to compare pointers to each other, or to compare pointers to `null`. + +### Expression operands + +An expression operand can be any of the following: + +- A variable. E.g. `a$` +- An array variable. E.g. `x# (index)` +- A literal constant. E.g. `3.14159265` +- A named constant. E.g. `M_PI` +- A function result. E.g. `Sqrt (2)` + +### Boolean values and expressions +Basic4GL stores boolean values as integers, where `0` is `false` and anything non `0` is `true`. + +The comparison operators `<`, `<=`, `=`, `>=`, `>`, and `<>` all evaluate to `-1` if the comparison is `true` or `0` if it is `false`. + +The `and` and `or` operators perform a bitwise "and" or "or" of the respective operands. + +Effectively this means that `and` and `or` can be used in both boolean expressions and bit manipulation. + +Boolean example: +``` +If a < 0 or a > 10 Then Print "Out of range": Endif +``` + +Bitwise example: +``` +glClear (GL_DEPTH_BUFFER_BIT or GL_COLOR_BUFFER_BIT) +``` + +### Lazy evaluation +Basic4GL supports lazy evaluation through the `land` and `lor` operators. +Here "lazy" means that Basic4GL will stop evaluating a boolean (`true`/`false`) expression as soon as it knows what the result will be. + +For example, the expression: +``` +age# < 15 land not accompanied_by_adult +``` + +will not even evaluate `not accompanied_by_adult` if `age#` were set to `42` (for example), +because Basic4GL already knows that `age# < 15` evaluates to `false` and therefore the whole expression will evaluate to `false`. + +Besides the lazy behaviour, `land` is exactly equivalent to `and` and `lor` is exactly equivalent to `or`. + +Proper use of lazy evaluation can make your programs more efficient, +and can be useful in situations where evaluating all of the expression may produce undesirable results. + +For example: +``` +if i >= 0 and i <= 10 and array(i) = searchValue then +``` +could halt your program with an "Array index out of range" error if `i` happened to be `11` (assuming `array` is a 0..10 element array). +Whereas: +``` +if i >= 0 land i <= 10 land array(i) = searchValue then +``` +will not halt your program, because `array(i)` is only ever evaluated if `i >= 0` and `i <= 10` have already evaluated to true. + +## Flow control + +### Goto + +Jumps directly to a new position in the source code. + +Format: +``` +Goto labelName +``` + +Where `labelName` is a Basic4GL label declared as the first identifier on a line, followed by a colon. +Basic4GL will jump straight to the offset of the `labelName` label, and continue execution. + +For example: +``` +Loop: +Print "Hello " +Goto Loop +``` + +Creates an infinite loop, where "Hello" is printed again and again. + +### Gosub +Calls a subroutine. + +Format: +``` +Gosub labelName +``` + +Where `labelName` is a Basic4GL label, declared exactly the same way as with the `Goto` instruction. + +The subroutine should directly follow the `labelName` label, and be terminated with a `Return` instruction. +When `Return` executes, Basic4GL will jump to the instruction immediately after the `Gosub` instruction. + +Example: +``` +Dim name$: name$ = "Bob" +locate 10, 10: gosub Name +locate 20, 4: gosub Name +locate 3, 15: gosub Name +locate 30, 20: gosub Name +end + +Name: +print name$ +Return +``` + +To encounter a `Return` instruction, without a corresponding `Gosub` is a runtime error. +A `Gosub` without a `Return` will not cause a runtime error, but will waste stack space. +If too many `Gosub`s are without `Return`s will eventually cause a "stack overflow" runtime error + +### If .. Then .. Elseif .. Else .. Endif +Executes a block of code conditionally. + +Format: +``` +If expression Then +If block +Endif +``` + +Or: +``` +If expression Then +If block +Else +Else block +Endif +``` + +Basic4GL evaluates `expression`. It must evaluate to an integer (usually the result of a boolean expression). +If the expression evalutes to true (non zero), then the `If block` instructions are executed. +Otherwise the `Else block` instructions are executed if present. + +Example 1: +``` +If lives < 1 then +Print "Game Over" +End +Endif +``` + +Example 2: +``` +If score > highscore Then +Print "New high score!" +highscore = score +Else +Print "Better luck next time." +Endif +``` + +Basic4GL also supports the `Elseif` keyword, which is equivalent to an `else` followed by an `if`, but removes the need for an extra `endif` at the end of the `if` structure. +Thus: +``` +if expression1 then +... +elseif expression2 then +... +endif +``` + +Is equivalent to: +``` +if expression then +... +else +if expression2 then +... +endif +endif +``` + +Any number of `endif` sections can be placed after the initial `if`. You cannot place an `endif` after the `else` section however. + +Example 3: +``` +dim a +for a = 0 to 10 +if a = 0 then printr "Zero" +elseif a = 1 then printr "One" +elseif a = 2 then printr "Two" +elseif a = 3 then printr "Three" +elseif a = 4 then printr "Four" +elseif a = 5 then printr "Five" +elseif a = 6 then printr "Six" +elseif a = 7 then printr "Seven" +elseif a = 8 then printr "Eight" +elseif a = 9 then printr "Nine" +elseif a = 10 then printr "Ten" +else +printr "???" +endif +next +``` + +Example 4: +``` +dim score +print "Enter score (0-100): " +score = Val (Input$ ()) +print "Your grade is: " +if score < 20 then printr "F" +elseif score < 30 then printr "E" +elseif score < 50 then printr "D" +elseif score < 70 then printr "C" +elseif score < 90 then printr "B" +else printr "A" +endif +``` + +#### Compatibility with other BASICs + +Basic4GL also supports the syntax: +``` +If condition Then +ifblock +end if +``` + +The `if` must follow immediately after the `end`, otherwise it will be interpreted as an `end` program instruction. + +### While .. Wend +Executes a code block repeatedly while an expression is true. + +Format: +``` +While expression +Code block +Wend +``` +This creates a conditional loop. Basic4GL evalutes `expression`, which again must evaluate to an integer (and is usually a boolean expression). +If the expression evaluates to false (zero), then Basic4GL will jump straight to the instruction following the `Wend`, and continue. +If the expression evaluates to true Basic4GL will execute the code block, then re-evaluate the expression. +Basic4GL will continue executing the code block until the expression evaluates to false. + +Example: +``` +While lives > 0 +' Do gameplay +... +Wend +' Game over +... +``` + +### For .. next +Used to create loops with a loop counter variable. + +Format: +``` +For variable = begin-value To end-value +Code block +Next +``` + +Or: +``` +For variable = begin-value To end-value step step-constant +Code block +Next +``` +This creates a loop, where `variable` counts from `begin-value` to `end-value`. +`Variable` must be a numeric type (integer or real), and cannot be an array element or structure field. +`Step-constant` must be a constant expression (integer or real). +If no `step` is given the step-constant defaults to `1`. + +Basic4GL will count either upwards or downwards depending on whether the step-constant is positive or negative. +If step-constant is positive, the `for..next` construct is exactly equivalent to: +``` +variable = begin-value +While variable <= end-value +Code block +variable = variable + step-constant +Wend +``` + +If step-constant is negative, it is equivalent to: +``` +variable = begin-value +While variable >= end-value +Code block +variable = variable + step-constant +Wend +``` + +And if step-constant is zero, it is equivalent to: +``` +variable = begin-value +While variable <> end-value +Code block +Wend +``` + +Example 1: +``` +Dim index +For index = 1 to 10 +Printr "Index = " + index +Next +``` + +Example 2: +``` +Dim count: count = 10 +Dim squared(count), index +For index = 0 to count +squared (index) = index * index +Next +``` + +Example 3: +``` +dim angle# +glTranslatef (0, 0, -3) +glBegin (GL_LINE_LOOP) +for angle# = 0 to 2 * m_pi step 2 * m_pi / 360 +glVertex2f (sin (angle#), cos (angle#)) +next +glEnd () +SwapBuffers () +``` + +Example 4: +``` +dim count +for count = 10 to 1 step -1 +cls: locate 20, 12: printr count +Sleep (1000) +next +cls: locate 15, 12: print "Blast off!!" +``` + +### Do .. loop +Also used to execute a code block a number of times. + +Format: +``` +do +Code block +loop +``` + +Or: +``` +do while condition +Code block +loop +``` + +Or: +``` +do until condition +Code block +loop +``` + +Or: +``` +do +Code block +loop while condition +``` + +Or: +``` +do +Code block +loop until condition +``` + +## Functions and subroutines +User defined functions and subroutines are created with the `function` and `sub` keywords respectively. +They are blocks of code that are "called", much like when you `gosub` to a label. At this point the computer executes the code inside the function/subroutine and then resumes executing from the instruction after the one that called the function/subroutine. + +> [!TIP] +> +> You are strongly recommended to use functions/subroutines instead of gosub/return, as it is generally considered to be better programming practice. + +Functions/subroutines introduce a number of features not supported by gosub/return: + +- Local variables - Prevent two unrelated parts of code from interfering with each other by modifying each others' variables. +- Parameters - Provide a convenient and less error prone (than using global variables) way to pass data to a routine. +- Return values - Provide a convenient and less error prone (than using global variables) way to pass data back from a routine. +- Better encapsulation - A function/subroutine can only be executed by calling it explicitly. You do not have to setup gotos to "jump around" the routine to prevent it from executing when it shouldn't. + +### Sub/End Sub +To create a subroutine, use `Sub` and `End Sub` + +Format: +``` +Sub name([param[, param[,...]]]) + +... + +End Sub +``` + +Where name is the name of the subroutine, and must not have already been used for a variable, function, other subroutine etc. +param are optional parameters that will be passed to the subroutine, and can be used inside it like variables. + +Examples: +``` +sub MySubroutine() +print "Hello" +end sub +sub PrintAt(x, y, text$) +locate x, y +print text$ +end sub +``` + +The format for parameters is the same as when DIMming a variable. +You can specify integer, real or string (`%`, `#` and `$` suffixes), structures and pointers. + +Array parameters are specified by suffixing the variable with empty brackets `()`. + +> [!NOTE] +> +> You do not specify the array size for array parameters. + +To specify a 2D or 3D array, use `()` and `()()` respectively (and so on). + +For example: +``` +sub PrintTextArray(array$()) +dim i +for i = 0 to arraymax(array$) +printr array$(i) +next +end sub +dim a$(3) +a$(0) = "This" +a$(1) = "is" +a$(2) = "a" +a$(3) = "test" +PrintTextArray(a$) +``` + +### Return (from subroutine) + +Program control returns from a subroutine as soon as its last instruction has executed. +Alternatively you can return immediately from a subroutine with the `return` command. + +Format: +``` +Return +``` + +### Calling a subroutine +Subroutines are called the same way as Basic4GL built-in routines and functions. + +Format: +``` +name([value1[,value2[,...]]]) +``` +Local variables +To declare a local variable, simply declare it with dim inside the body of the subroutine. + +Example: +``` +sub DrawStars(count) +dim i ' This is a local variable +for i = 1 to count +print "*" +next +printr +end sub + +dim i ' This is a global variable +i = 3 +DrawStars(20) +print i +``` + +Local variables can only be accessed inside the subroutine that they are DIMmed. +Their memory is reclaimed as soon as the subroutine finishes. + +An important feature of local variables is that if a variable of the same name is DIMmed in two different subroutines, +(or if one is DIMmed outside any subroutine), they are treated as two completely different variables, +each with its own separate storage. + +This is very useful for temporary variables (like `for..next` loop counters), +as the variable is guaranteed not to be overwritten by another subroutine that your subroutine may call. + +### Function/end function +To create a function, use `function` and `end function`. + +Format: +``` +Function name([param[, param[, ...]]]) + +... + +End Function +``` + +Where name is the name of the function, and must not have already been used for a variable, function, +other subroutine etc. +`param` are optional parameters that will be passed to the function, and can be used inside it like variables. + +`name` also determines the "return type" of the function (what kind of value it returns), +and can be treated much like a variable in a DIM, in that you can suffix it with (`%`, `#`, `$`) to return an integer, +real or string respectively, or precede it with a structure name to return a structure. + +To declare a function that returns an array, suffix the declaration with a pair of empty brackets. + +A function must explicitly return a value with the `return` keyword. + +### Return (from function) +A function must return a value to the caller with the `return` keyword. + +Format: +``` +Return expression +``` + +Where expression is the expression that will be evaluated, and whose result will be sent back to the caller. + +Examples: +``` +function AddTwoNumbers(n1, n2) +return n1 + n2 +end function +function SumArray(array()) +dim sum, i +for i = 0 to arraymax(array) +sum = sum + array(i) +next +return sum +end function +``` + +### Calling a function +A function can be called exactly the same way as a subroutine. +However, a function can also be called within an expression, and its result used as part of the expression in the same way as a constant or variable. + +Example 1: +``` +function Reverse$(s$) + dim result$, i + for i = 1 to len(s$) + result$ = result$ + mid$(s$, len(s$) - i + 1, 1) + next + return result$ +end function + +print Reverse$("?efil laer eht siht sI") +``` + +Example 2: +``` +function Random(min, max) + return rnd() % (max - min + 1) + min +end function + +dim dice(5), i +for i = 1 to 5: dice(i) = Random(1, 6): next +for i = 1 to 5: print dice(i); " ";: next +``` + +Example 3: +``` +function UpdateChar$(c$, delta) + dim a + a = asc(c$) + a = a + delta + if a > 255 then a = a - 256 endif + if a < 0 then a = a + 256 endif + return chr$(a) +end function + +function UpdateWord$(w$, delta) + dim result$, i + for i = 1 to len(w$) + result$ = result$ + UpdateChar$(mid$(w$, i, 1), delta) + next + return result$ +end function + +dim word$, encoded$, decoded$ +input "Word"; word$ +encoded$ = UpdateWord$(word$, 1) +printr "Encoded: "; encoded$ +decoded$ = UpdateWord$(encoded$, -1) +printr "Decoded: "; decoded$ +``` + +### Declare +You can "forward declare" a function or subroutine with the `declare` keyword. + +Format: +``` +Declare sub name([param[, param[, ...]]]) +``` + +Or: +``` +Declare function name([param[, param[, ...]]]) +``` + +"Forward declaring" a function/subroutine allows the compiler to compile calls to the function/subroutine +before it has compiled the function body. + +### Function restrictions +Be aware that there are a couple of restrictions on what can be placed inside a function or subroutine: + +- You cannot define a label inside a function/subroutine. +- You cannot use the `goto` or `gosub` commands inside a function/subroutine. + +## Program data +Basic4GL provides the standard `Data`, `Read` and `Reset` mechanism for entering data directly into programs. +This is basically a shorthand way of hard-coding data into programs and is typically used to initialise arrays. + +The actual data stored is a list of values. Each value is either a string or a number (int or real). + +### Data +To specify the data elements, use `Data`. + +Format: +``` +Data element [, element [, ...]] +``` + +Examples: +``` +Dim 12.4, -3.4, 12, 0, 44 +Dim My age, 20, My height, 156 +Dim "A long time ago, in a galaxy far away, yada yada yada" +``` + +If the data element can be parsed as a number, it will be stored as such. Otherwise, it will be stored as a string. + +Strings can either be quoted (enclosed in double quotes) or unquoted. Quoted strings can contain commas (`,`), colons (`:`) and single quotes (`'`). +Unquoted strings cannot contain these characters, because: + +- Comma starts a new data element +- Colon starts a new instruction +- Single starts a program comment + +So it is best to quote strings if you are unsure. + +### Read +In order to do something with the data, you need to read it into variables, using `Read`. + +Format: +``` +Read variable [, variable [, ...]] +``` + +Variable must be a simple variable type, either a string, integer or real. +(In other words, you can't read a structure or an array with a single read statement, +although you can write code to read each element individually). + +Read copies an element of data into the variable, and then moves the data position along one. +> [!CAUTION] +> +> If there is no data, or the program has run out of data, you will get an "Out of DATA" runtime error. + +> [!CAUTION] +> +> Attempting to read a string value into a number variable (integer or real) will also generate a runtime error. + +Example 1: +``` +data age, 22, height, 175, shoesize, 12 +dim name$(3), value(3), i +for i = 1 to 3 +read name$(i), value(i) +next +for i = 1 to 3 +printr name$(i) + "=" + value(i) +next +``` + +### Reset +`Reset` tells Basic4GL where to start loading data from. + +Format: +``` +Reset labelname +``` + +Where `labelname` is a Basic4GL program label. + +The next `Read` will begin reading data from the first `Data` statement after `labelname`. +``` +ThisData: + data 1, 2, 3, 4, 5 + +ThatData: + data cat, dog, fish, mouse, horse + +dim a$, i +printr "1) This data" +printr "2) That data" +print "Please press 1 or 2" + +while a$ <> "1" and a$ <> "2" + a$ = Inkey$ () +wend + +if a$ = "1" then + reset ThisData +else + reset ThatData +endif + +printr +for i = 1 to 5 + read a$ + printr a$ +next +``` + +## External functions +Basic4GL supports a number of external functions. + +> [!TIP] +> +> You can see a full list by selecting "Help|Function and Constant list..." and selecting the "Functions" tab. +> This lists all the external functions Basic4GL recognises, +> along with their return types (if they return a value), and parameter types. + +External functions are called with the following format: +``` +FunctionName ([param [, param [, ...]]) +``` + +Examples: +``` +Beep() +glClear (GL_COLOR_BUFFER_BIT) +glVertex3f (-2.5, 10, 0) +``` + +A small number of functions do not require their arguments to be enclosed in brackets (mainly for historical reasons.) +These functions are: `Cls`, `Print`, `Printr` and `Locate`. +For example: +``` +Cls +Locate 17, 12 +Print "Hello" +``` + +### Traditional BASIC syntax +When "traditional BASIC" syntax is used, functions that do not return a value must **not** have their parameters enclosed in brackets. + +Some examples: +``` +sleep 1000 +glBegin GL_TRIANGLES +SprSetX x# +glVertex3f 10, 4, 2 +``` + +Functions which do return a value must still have their parameters enclosed in brackets (or have empty brackets if there are no parameters) + +Examples are: +``` +a = rnd() % 10 +texture = LoadTexture(filename$) +a$ = inkey$() +``` + +Some functions return a value, which can be assigned to a variable, used in an expression or as a parameter to another external function. + +Examples: +``` +print Sqrt (2) +if ScanKeyDown (VK_UP) then ... +locate (TextCols()-Len(a$))/2, TextRows()/2: Print a$ +``` + +## Runtime compilation + +> [!WARNING] +> +> Basic4GLj does not currently support Basic4GL's Runtime compilation feature in the current release version, 0.5.0; +> Runtime compilation support is planned for future Basic4GLj project milestones. + +Basic4GL code can also be compiled and executed at runtime. The source can be a file on disk, or a text string in memory. +The runtime compile is the same as the compile time compiler, and accepts all the same code. The only restriction is that you cannot use "include" within runtime compiled code. + +The main commands are `Comp` and `Exec` to compile and execute respectively. +(Actually `Comp` is a function, but it's so closely associated with the `Exec` command that I've included it here.) + +There is also support for calling functions in runtime-compiled code, using the `Runtime` keyword. + +### Comp +`Comp` compiles a text string and return a handle that can be used to execute the compiled code at runtime. + +Format: +``` +Comp(codetext) +``` + +Where `codetext` is a text string, or an array of text strings, containing code to be compiled at runtime. + +If the text compiled successfully, `Comp` returns a non-zero integer handle to identify the compiled code. +If the compiler encountered an error, `Comp` returns zero, and the error description can be retrieved with `CompilerError()`, `CompilerErrorLine()` and `CompilerErrorCol()`. + +Example 1: +``` +dim code1, code2 +code1 = Comp("printr " + chr$(34) + "Ding" + chr$(34)) +code2 = Comp("printr " + chr$(34) + "Dong" + chr$(34)) +exec code1 +exec code2 +exec code1 +exec code2 +``` + +Example 2: +``` +dim prog$(10), code +prog$(0) = "dim x, y" +prog$(1) = "for y = 1 to 10" +prog$(2) = "for x = 1 to y" +prog$(3) = "print " + chr$(34) + "*" + chr$(34) + ";" +prog$(4) = "next" +prog$(5) = "printr" +prog$(6) = "next" +Compile(prog$) +exec +``` + +### CompFile +`CompFile` compiles a file on disk and return a handle that can be used to execute the compiled code at runtime. + +Format: +``` +CompFile(filename) +``` +Where filename is the filename as a text string. + +If the file was read and compiled successfully, `CompFile` returns a non-zero integer handle to identify the compiled code. +If the compiler encountered an error, `CompFile` returns zero, and the error description can be retrieved with `CompilerError()`, `CompilerErrorLine()` and `CompilerErrorCol()`. + +### Exec +`Exec` executes runtime-compiled code. + +Format: +``` +Exec +``` +``` +Exec handle +``` +Where `handle` is an integer handle returned from a successful call to `Comp` or `CompFile`. + +If no handle is supplied, Exec executes the last code compiled (or bound if `BindCode` has been executed.) + +> [!CAUTION] +> +> Be warned that any runtime errors will halt your program. + +### Functions/subs inside compiled code +Normally you cannot have two functions or subs with the same name. + +However, Basic4GL will allow this if the functions/subs are in different compiled code blocks, +or if one is in the main program and the other(s) in compiled code blocks. + +Basic4GL applies "scoping" logic to determine which function/sub is to be called as follows: + +- If calling code is in the main program, the function/sub is assumed to be in the main program +- If calling code is runtime-compiled, the function/sub is first assumed to be in the runtime-compiled code then in the main program (if not found in the runtime code) + +The scoping logic only applies to functions and subs, however. +Other things like global variables, labels etc are not scoped this way. + +Example: +``` +' Subroutines in main code +sub Sub1(): printr "Main 1": end sub +sub Sub2(): printr "Main 2": end sub + +' Subroutines in compiled code +Comp("sub Sub1(): printr " + chr$(34) + "Runtime 1" + chr$(34) + ": end sub: Sub1(): Sub2()") +' Execute compiled code +exec +printr +' Call main subroutines +Sub1() +Sub2() +``` + +## Calling functions/subs in runtime code +Runtime compiled code can call functions/subs in the main program easily. +Calling runtime-compiled functions/subs from your main program requires you declare the function with "runtime" first. + +### Runtime + +The `Runtime` keyword is used to declare a function or sub that can be implemented either: +- In the main program +- In one or more sections of runtime compiled code +- Or, all of the above + +The syntax is much the same as the `Declare` keyword. + +Format: +``` +Runtime Sub prototype +``` +``` +Runtime Function prototype +``` + +Where prototype defines the function/sub, its parameters and return type (if applicable). + +Examples: +``` +runtime sub MySub() +runtime sub MoveBadGuy(SBadGuy& badguy) +runtime function CalcY#(x#) +``` + +Once declared with `runtime`, the sub/function can be called from your main program. +Basic4GL will check at runtime to see if the function/sub being called has been implemented, +checking the current runtime-compiled code first, then the main program. +If the function/sub is found, Basic4GL calls it. +Otherwise, a runtime error results, and your program stops. + +As with `exec`, the `current` runtime-compiled code is the last code that was compiled with `Comp`, or bound with `BindCode`. + +Example: +``` +runtime sub MySub() +sub MySub() +printr "Main program" +end sub + +' Will call MySub() in main program +MySub() +dim code +code = Comp("sub MySub(): printr " + chr$(34) + "Runtime compiled code" + chr$(34) + ": end sub") + +' Will call MySub() in runtime code +MySub() +' Will call MySub() in main program +bindcode 0 +MySub() +' Will call MySub() in runtime code +bindcode code +MySub() +``` + +### BindCode +The `BindCode` command is used to make runtime-compiled code current. +This affects the `Exec` command (when called without a parameter), and where Basic4GL looks for `Runtime` functions. + +Format: +``` +BindCode 0 +``` +``` +BindCode handle +``` +Where `handle` is an integer handle returned from a successful call to `Comp` or `CompFile`. + +`BindCode 0` has special meaning. No runtime-compiled code is considered bound. +Any `runtime` functions called must therefore be implemented in the main program itself. +`Exec` without a parameter will cause a runtime exception. + +## Other Basic4GL instructions +There are two more Basic4GL instructions that have yet to be discussed. + +### End +Causes Basic4GL to stop executing the program. + +### Run +Causes Basic4GL to deallocate all variables, reset OpenGL, deallocate any resources (such as OpenGL textures), +clear the Gosub-Return stack and begin executing the program again from the top. + +The program will begin executing again as if you had just clicked Run in the Basic4GL editor. + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +Language guide + +16-Feb-2008 +Tom Mulgrew + +Documentation modified for Markdown (.md) formatting by Nathaniel Nielsen \ No newline at end of file diff --git a/docs/network-engine-guide.md b/docs/network-engine-guide.md new file mode 100644 index 00000000..04ff9ddd --- /dev/null +++ b/docs/network-engine-guide.md @@ -0,0 +1,505 @@ + +# Programmer's Guide: The Basic4GL Network Engine + +## Custom network protocol + +Basic4GL does not support regular TCP/IP networking, or common protocols such as ftp or http. + + +The Basic4GL network routines use their own custom protocol, built on top of UDP/IP. You can use it to write programs that network with other Basic4GL programs, but not (for example) to write an FTP client. + +> [!IMPORTANT] +> +> IP v6 not supported +> Please be aware that IP v6 is not supported at this time. + + +The Basic4GL network engine is designed primarily for writing low latency networked games. It allows you to establish a network connection between two running Basic4GL programs and send blocks of data (which we call "messages") back and forth. Basic4GL will attempt to send these messages as quickly as possible (with some extra logic for reliability and/or ordering if required). + +And that's all it does. + +It is up to you to decide what data to send, how to send it, and how often. But with a bit of planning it is possible to write some responsive multiplayer lan or internet games without too much fuss. + +The network engine uses UDP/IP packets for communication. Any network that can do TCP/IP can do UDP/IP (as TCP is built on top of UDP), so your programs can run over the Internet and TCP/IP local networks. + +Basic4GL uses its own protocol for handling connection lifetime and reliable packet delivery which is optimised towards writing responsive networked games. It lets you choose which messages must get through and which ones don't matter if they get lost on the way. You can also choose which messages must arrive in the same order they were sent and which ones don't matter so much. This upshot of this is that a carefully designed application will have the best chance to be able to continue smoothly if a data packet is lost in transmission, which is important for realtime games. (Unlike TCP/IP which has to stop for a few seconds if it hits an error). The downside is because the Basic4GL network engine uses its own protocol it can only talk to other applications using the same protocol, i.e. other Basic4GL programs. + +The network engine supports: + +- Automatic connection lifetime handling (using "timeouts" and "keep-alives") +- Reliable/unreliable messages +- Ordered/unordered messages +- Automatic message fragmentation and reassembly +- Optional "message smoothing" compensates for varying network latency to ensure messages are received and applied smoothly. + +## Reading/writing messages with File I/O functions + +The bulk of a program's network code usually involves: + +- Writing some data to a "message", then sending down a network connection. +- Receiving a "message" from a network connection and reading the data inside it. + +A network message is a block of data, similar to a small disk file. In fact Basic4GL uses the file I/O functions to read and write the contents of network messages. Instead of using OpenFileWrite and OpenFileRead, you use SendMessage and ReceiveMessage, but otherwise it's just like accessing a disk file. + +Compare this program to write a simple text file: + +``` +dim file +file = OpenFileWrite("files\test.txt") ' Open a file for output +WriteString(file, "Some text") ' Write some text +CloseFile(file) ' Close the file +``` + +With this program to send a message over a network connection: + +``` +dim msg +... +msg = SendMessage(connection) ' Create a message to send down connection +WriteString(msg, "Some text") ' Write some text +CloseFile(msg) ' Send the message +``` + +(Note: The above program is incomplete...) + +All of Basic4GL's file I/O functions except `OpenFileRead` and `OpenFileWrite` can be used with Basic4GL network messages. + +These functions are described in the File I/O section of the Basic4GL Programmer's Guide. + +## Server-client connections + +Two connect two computer over a network, you must do the following: + +- One computer is chosen as the "server". The other is the "client". +- The server listens on a network port number for connection requests. +- The client creates a new connection and connects it to the server's port. +- The server accepts the connection, creating a corresponding connection at its own end. + +At this point both the client and the server have a "connection" with which they can send and receive data. Data sent down the server's connection will be received by the client's connection and vice versa. + +(Note: This can be extended to connect multiple computers together, by having one as the server and having the rest of them as clients that connect to the server. In this case the server will have multiple "connection"s, one for each client.) + +## Servers and server connections + +### NewServer + +Format: +``` +NewServer(port) +``` + +Where port is the port number on which the server "listen"s for connection requests. +NewServer() creates a server and returns a handle to identify the server to other functions (such as AcceptConnection()). + +Example: +``` +dim server +server = NewServer(8000) ' Create a new server on port 8000 + +' ... Run the program + +DeleteServer(server) ' Close and delete the server +``` + +### DeleteServer + +Format: +``` +DeleteServer(server) +``` + +Where server is a server handle returned from `NewServer()`. +Shuts down and deletes the server. Any connections accepted by the server will automatically be disconnected and deleted. + +It is good practice to close server objects (and connections) when finished with them. +If not closed explicitly, Basic4GL will automatically close them when the program ends. + +### ConnectionPending + +Format: +``` +ConnectionPending(server) +``` + +Where `server` is a server handle returned from `NewServer()`. + +`ConnectionPending()` returns `true` if a client has asked for a connection to the server and is waiting for the server to accept or reject it. +The connection can now be accepted with `AcceptConnection()` or rejected with `RejectConnection()`. + +### AcceptConnection + +Format: +``` +AcceptConnection(server) +``` + +Where server is a server handle returned from `NewServer()`. + +`AcceptConnection()` accepts a pending connection request, creates a corresponding connection object and returns a handle for it. +If no connection is pending, `AcceptConnection()` does nothing and returns `0`. + +Example: +``` +const port = 8000 +dim server, connection + +' Create server +server = NewServer(port) +printr "Server created. Waiting for connections" + +' Wait for incoming connections +while true +if ConnectionPending(server) then +printr "Connection accepted" + + ' Accept connection + connection = AcceptConnection(server) + + ' ... Do something here + Sleep(1000) + + ' Close connection now that we're finished + DeleteConnection(connection) + endif +wend +``` + +### RejectConnection + +Rejects an incoming connection request. + +Format: +``` +RejectConnection(server) +``` + +Where `server` is a server handle returned from `NewServer()`. + + +It is good practice to reject any pending connections when you know that you cannot handle them - for example if your server already has all the connections it can handle. The client connection will disconnect immediately rather than wait and eventually timeout from receiving no response. + +Rejecting the connection also removes it from the pending connection queue, so if your server is able to accept connections again later on it will not see the old pending connection request. + +## Client connections + +### NewConnection + +Format: +``` +NewConnection(address, port) +``` + +Creates a new connection and attempts to connect to a server at the specified address and port. +address is a text string specifying the network name to connect to. It can either be a DNS address (e.g. "someserver.com"), a numeric IP address (e.g. "192.168.0.1") or "localhost" (meaning connect to the same computer). +port is the port number. It must be the same one as the server is listening on, otherwise it wont find the server. + +`NewConnection()` returns a handle identifying the connection that can be passed to other functions (such as `SendMessage()`). + +### DeleteConnection + +Deletes a network connection. + +Format: +``` +DeleteConnection(connection) +``` + +Where `connection` is a connection handle returned by `NewConnection()` or `AcceptConnection()`. + +If the connection is active, it will be closed, and a notification sent to the corresponding connection at the other end to inform it of the close. + +Basic4GL also automatically closes and deletes any outstanding network connections when the program finishes. + +## Connection state +Programs should monitor the "connection state" of their connections, especially to detect whether the connection has become disconnected, which happens if either end call `DeleteConnection()`, or if the connection has been disconnected somehow. + +Client connections follow this pattern: + +1. NewConnection() called +2. Handshaking +3. Connected +4. (Connection used to send/receive messages) +5. Disconnected + +Or if the connection is not accepted: + +1. NewConnection() called +2. Handshaking +3. Disconnected + +Server accepted connections are similar to client connections, except they are never in the "Handshaking" state (the connection is considered connected as soon as AcceptConnection() is called): + +1. AcceptConnection() called +2. Connected +3. (Connection used to send/receive messages) +4. Disconnected + +The following functions are used to detect the different states. + +### ConnectionConnected + +Format: +``` +ConnectionConnected(connection) +``` + +Where connection is a connection handle returned by NewConnection() or AcceptConnection(). + +ConnectionConnected() returns true if the connection is still connected, or false if the connection has been disconnected. +Connections are considered "connected" when they are created, and remain that way until either: + +The connection is closed at the other end (by DeleteConnection()), or +The connection times out due to lack of network activity. +(Note: This does not mean that you have to keep sending network messages to prevent a connection timing out. The network engine automatically sends "keep alive" notifications if necessary to inform the other side that the connection is still alive.) + +### ConnectionHandshaking + +Format: +``` +ConnectionHandshaking(connection) +``` + +Where connection is a connection handle returned by NewConnection() or AcceptConnection(). + +Returns true if the connection is in the hand-shaking state. + +Connections created by NewConnection() are considered to be "hand-shaking" until the server accepts the connection (and the confirmation notification is received). +Once the connection is established, it leaves the hand-shaking state (ConnectionHandshaking() will then return false), and the connection is ready to send and receive messages. + +Note: Server connections created with AcceptConnection() do not have a hand-shaking phase. For these ConnectionHandshaking() will always return false, as the connection is fully established as soon as it has been accepted. + +### ConnectionAddress + +Format: +``` +ConnectionAddress(connection) +``` + +Where connection is a connection handle returned by NewConnection() or AcceptConnection(). + +Returns the IP address of the computer at the other end of the network connection, in numeric format (e.g. "192.168.0.1"). + +Sending messages +Data is passed through connections as "messages", variable length blocks of data which are transmitted and received as a single item. + +### SendMessage + +Format: +``` +SendMessage(connection, channel, reliable, smoothed) +``` + +Where connection is a connection handle returned by NewConnection() or AcceptConnection(). + +SendMessage() creates a message ready to be sent down connection, and returns a handle representing the message. +You can then pass this handle to the Write...() file I/O functions (WriteByte(), WriteString(), etc) to write data to the message, just as you would write data to a file. Refer to the file I/O functions in the Basic4GL Programmers' Guide for more information. + +Once the message is ready, call CloseFile() to close the message and send it. + +SendMessage() has 3 options which affect message delivery: + +#### Channel + +Channel is a "channel number" and affects the order in which messages are received. + +Depending on network conditions messages can arrive at the receiving end in a different order than which they were sent. For example a message (or part of a message) may be lost in transmission and have to be resent, delaying it long enough for other messages to get in infront of it. + +The Basic4GL network engine supports ordering of messages through "channels". Every connection has 32 channels (numbered 0 through 31 inclusive). Messages sent within a single channel are guaranteed to be received in the same order as they were sent - with the exception of channel # 0 which is the unordered channel. + +Two messages sent down different channels are not guaranteed to be received in the same order. + +The multiple channels to allow you to specify for which messages the ordering is important. A good choice of channels can affect network performance, especially over unreliable networks (such as an internet connection). If an ordered message is delayed, the whole channel will stall until the message is received and slotted into its correct order. However other channels will still keep receive messages. So if a game was using on ordered channel for chat messages, and a different channel for position updates, the engine can keep receiving position updates even if a chat message is lost and must be re-transmitted. + +#### Reliable +Reliable is true if the message must be delivered. + +Depending on network conditions, some messages may be lost in transmission. The reliable flag specifies whether this is acceptable for this message (reliable = false) or whether the message must get through, in which case the network engine will keep resending the packet until delivery is confirmed. + +#### Smoothed +Packet "smoothing" attempts to smooth out the network lag by measuring the average amount of time it takes for packets to arrive, and occasionally delaying early arriving packets before releasing them to the application. + +This can result in a smoother experience (especially for messages conveying position updates and game events), but be aware that it does add extra latency to some packets. + +## Receiving messages + +### MessagePending + +Format: +``` +MessagePending(connection) +``` + +Where connection is a connection handle returned by NewConnection() or AcceptConnection(). + +MessagePending() returns true if a message has been received and can be fetched with ReceiveMessage(). + +### MessageChannel + +Format: +``` +MessageChannel(connection) +``` + +Where connection is a connection handle returned by NewConnection() or AcceptConnection(). + +MessageChannel() returns the channel number of the pending message. (See SendMessage() for more information). + +### MessageReliable + +Format: +``` +MessageReliable(connection) +``` + +Where connection is a connection handle returned by NewConnection() or AcceptConnection(). + +MessageReliable() returns whether the pending message was sent as a reliable message (MessageReliable() = true) or as an unreliable message. (See SendMessage() for more information). + +### MessageSmoothed + +Format: +``` +MessageSmoothed(connection) +``` + +Where connection is a connection handle returned by NewConnection() or AcceptConnection(). + +MessageSmoothed() returns whether the pending message was sent as a smoothed message (MessageSmoothed() = true) or not. (See SendMessage() for more information). + +### ReceiveMessage + +Format: +``` +ReceiveMessage(connection) +``` + +Where connection is a connection handle returned by NewConnection() or AcceptConnection(). + +ReceivedMessage() fetches the current pending message from the connection and returns a handle representing the message. +You can then pass this handle to the Read...() file I/O functions (ReadByte(), ReadChar(), etc) to read data from the message, just as you would read data from a file. The Seek() and EndOfFile() functions may also be used. Refer to the file I/O functions in the Basic4GL Programmers' Guide for more information. + +Once you have finished with the message, you should discard it with CloseFile(), in order to free up resources. + +## Connection and handshaking flags + +There are two flags which indicate the current connection state of a connection: + +1. Connected (see function: `ConnectionConnected()`) +2. Handshaking (see function: `ConnectionHandshaking()`) + +### Client connections +When a client connection is created with `NewConnection()`, _connected_ and _handshaking_ are both set. + +- If the connection succeeds, _connected_ remains set, and _handshaking_ is cleared. +- If the connection fails (either rejected by the server, or times out), connected is cleared. (_handshaking_ may remain set though...) + +Thus, the code to establish a client connection might look something like this: +``` +dim connection, address$, port + +' Get connection details +print "Address?:": address$ = input$() +print "Port?:": port = val(input$()) + +' Attempt to connect to server +printr "Connecting..." +connection = NewConnection(address$, port) +while ConnectionConnected(connection) and ConnectionHandshaking(connection): wend + +' Check if succeeded +if ConnectionConnected(connection) then +printr "Connection succeeded" +' Do something with connection +' ... +else +printr "Connection failed" +endif + +' Close connection +DeleteConnection(connection) +``` + +If you attempt to use a connection while in the handshaking stage the network engine will do it's best to accommodate this. + +Specifically: + +- Outgoing messages will not be sent immediately. Instead, they will be placed in a queue until the connection is established, and then sent. +- No messages will be received until the connection is established. + +### Server connections +When a server connection is created with `AcceptConnection()`, _connected_ is set and _handshaking_ is cleared. +The connection is considered established and can be used immediately. + +## Connection settings +Network connections have a number of parameters which affect how they behave and perform in different network conditions. These affect timeouts, automatic resends, timing and also have an effect on the amount of bandwidth used. Often you will not need to configure these parameters as they have defaults should work in a number of different network conditions. However they are available should you need them. + +Be careful when adjusting connection settings, as they can cause the network connection to fail if setup incorrectly. + +Connection settings can be changed after a connection is created (with NewConnection or AcceptConnection). + +### SetConnectionTimeout + +Format: +``` +SetConnectionTimeout(milliseconds) +``` + +Where milliseconds is the number of milliseconds after which a connection times out and disconnects if no response is received from the other side. +The default is 60000 (60 seconds). + +### SetConnectionHandshakeTimeout + +Format: +``` +SetConnectionHandshakeTimeout(milliseconds) +``` + +Where millisecondsis the number of milliseconds after which a connection attempt will timeout if no reply is received from the server. +The default is 10000 (10 seconds). + +### SetConnectionKeepAlive + +Format: +``` +SetConnectionKeepAlive(milliseconds) +``` + +If the connection has not sent anything for this amount of time it will automatically send a "keep alive" message to let the other end know that it is still connected. This prevents the connection from timing out at the other end. + +How often keep alive messages need to be send depends on the connection time-out, as well as the network latency, and packet loss. Setting it to a quarter of the connection time-out (for example), will give the network engine 3 or 4 attempts to get the keep-alive packet through before the connection times out. + +If your program already sends a constant stream of network traffic (e.g. position updates in a real time game) then that traffic will keep the connection alive, and explicit "keep-alive" packets are not important. + +### SetConnectionReliableResend + +Format: +``` +SetConnectionReliableResend(milliseconds) +``` + +This affects sending of reliable messages. When a reliable messages is sent, the connection will continually send the message until it receives confirmation from the other end that the message has been delivered. This setting controls how long the connection waits before resending the message. The default is 200 (0.2 seconds). + +The lower this value is, the less delay there will be when packet loss occurs. However setting the value lower than the ping time will use up extra bandwidth, as a reliable message will be sent twice (or more) before the confirmation notification is received. + +### SetConnectionDuplicates + +Format: +``` +SetConnectionDuplicates(count) +``` + +Specifies the number of times each message is duplicated when sent. The default is 1. +Setting this number higher decreases the likelyhood of packet loss at the cost of extra bandwidth. + +### SetConnectionSmoothingPercentage + +Format: +``` +SetConnectionSmoothingPercentage(percentage) +``` + +This setting only affects packets that have been sent with the "smoothing" parameter set to true. + +The "smoothing" timing algorithm attempts to add artificial lag such that this percentage of packets arrive on time. The default is 80 (percent). + +Setting this number lower will decrease artificial lag but decreases "smoothness", whereas setting it higher will increase artificial lag and increase "smoothness". \ No newline at end of file diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md new file mode 100644 index 00000000..63b15466 --- /dev/null +++ b/docs/opcode-reference.md @@ -0,0 +1,115 @@ +The following OpCodes are used by Basic4GL's virtual machine and are defined in [OpCode.java](https://github.com/NateIsStalling/Basic4GLj/blob/main/runtime/src/main/java/com/basic4gl/runtime/types/OpCode.java) + +## General + +| Mnemonic | OpCode | Description | +| --- | --- | --- | +| NOP | 0x00 | No operation | +| END | 0x01 | End program | +| LOAD_CONST | 0x02 | Load constant into reg | +| LOAD_VAR | 0x03 | Load address of variable into reg | +| LOAD_LOCAL_VAR | 0x04 | Load address of local variable into reg | +| DEREF | 0x05 | Dereference reg (load value at [reg] into reg) | +| ADD_CONST | 0x06 | Add constant to reg. Used to address into a structure | +| ARRAY_INDEX | 0x07 | IN: reg = array index, reg2 = array address. OUT: reg = element address. | +| PUSH | 0x08 | Push reg to stack | +| POP | 0x09 | Pop stack into reg2 | +| SAVE | 0x0A | Save int, real or string in reg into [reg2] | +| COPY | 0x0B | Copy structure at [reg2] into [reg]. Instruction value points to data type. | +| DECLARE | 0x0C | Allocate a variable | +| DECLARE_LOCAL | 0x0D | Allocate a local variable | +| TIMESHARE | 0x0E | Perform a timesharing break | +| FREE_TEMP | 0x0F | Free temporary data | +| ALLOC | 0x10 | Allocate variable memory | +| DATA_READ | 0x11 | Read program data into data at [reg]. Instruction contains target data type. | +| DATA_RESET | 0x12 | Reset program data pointer | +| SAVE_PARAM | 0x13 | Save int, real or string in reg into parameter in pending stack frame. Instruction contains data type. Parameter # is however many parameters have been set since *CREATE_USER_FRAME* was executed.| +| SAVE_PARAM_PTR | 0x14 | Save pointer to data in [reg] into parameter pointer. | +| COPY_USER_STACK | 0x15 | Copy data at [reg] to top of user stack. reg is then adjusted to point to the data in the user stack. | +| MOVE_TEMP | 0x16 | Free temp data and move data at [reg] into temp data. (Also handles if [reg] points to temp data). | +| CHECK_PTR | 0x17 | Check pointer scope before saving to variable. (Ptr is in reg, variable is [reg2]) | +| CHECK_PTRS | 0x18 | Check all pointers in block at [reg] before copying to [reg2]. Instruction contains data type. | +| REG_DESTRUCTOR | 0x19 | Register string destruction block at [reg] (will be temp or stack depending on reg) | + + +## Flow control + +| Mnemonic | OpCode | Description | +| --- | --- | --- | +| JUMP | 0x40 | Unconditional jump | +| JUMP_TRUE | 0x41 | Jump if reg <> 0 | +| JUMP_FALSE | 0x42 | Jump if reg == 0 | +| CALL_FUNC | 0x43 | Call external function | +| CALL_OPERATOR_FUNC | 0x44 | Call external operator function | +| CALL_DLL | 0x45 | Call DLL function | +| CALL | 0x46 | Call VM function | +| CREATE_USER_FRAME | 0x47 | Create user stack frame in preparation for a call | +| CALL_USER_FUNC | 0x48 | Call user defined function | +| RETURN | 0x49 | Return from VM function | +| RETURN_USER_FUNC | 0x4A | Return from user defined function | +| NO_VALUE_RETURNED | 0x4B | Generates a runtime error if executed | + +### 0x4C - 0x50 added after version 2.5.0 + +| Mnemonic | OpCode | Description | +| --- | --- | --- | +| BINDCODE | 0x4C | Bind a runtime code block to be executed | +| EXEC | 0x4D | Execute runtime code block | +| CREATE_RUNTIME_FRAME | 0x4F | Create a stack frame to call a function/sub in runtime code block | +| END_CALLBACK | 0x50 | End callback initiated by built-in function or DLL function, and return control to that function. | + + +## Operations + +### Mathematical + +| Mnemonic | OpCode | Description | +| --- | --- | --- | +| OP_NEG | 0x60 | | +| OP_PLUS | 0x61 | Doubles as string concatenation | +| OP_MINUS | 0x62 | | +| OP_TIMES | 0x63 | | +| OP_DIV | 0x64 | | +| OP_MOD | 0x65 | | + +### Logical + +| Mnemonic | OpCode | Description | +| --- | --- | --- | +| OP_NOT | 0x80 | | +| OP_EQUAL | 0x81 | | +| OP_NOT_EQUAL | 0x82 | | +| OP_GREATER | 0x83 | | +| OP_GREATER_EQUAL | 0x84 | | +| OP_LESS | 0x85 | | +| OP_LESS_EQUAL | 0x86 | | +| OP_AND | 0x87 | | +| OP_OR | 0x88 | | +| OP_XOR | 0x89 | | + +### Conversion + +| Mnemonic | OpCode | Description | +| --- | --- | --- | +| CONV_INT_REAL | 0xA0 | Convert integer in reg to real | +| CONV_INT_STRING | 0xA1 | Convert integer in reg to string | +| CONV_REAL_STRING | 0xA2 | Convert real in reg to string | +| CONV_REAL_INT | 0xA3 | | +| CONV_INT_REAL2 | 0xA4 | Convert integer in reg2 to real | +| CONV_INT_STRING2 | 0xA5 | Convert integer in reg2 to string | +| CONV_REAL_STRING2 | 0xA6 | Convert real in reg2 to string | +| CONV_REAL_INT2 | 0xA7 | | + + +## Misc Routine + +| Mnemonic | OpCode | Description | +| --- | --- | --- | +| RUN | 0xC0 | Restart program. Re-initializes variables, display, state e.t.c | + + +## Debugging + +| Mnemonic | OpCode | Description | +| --- | --- | --- | +| BREAKPT | 0xE0 | Breakpoint | diff --git a/docs/opengl-guide.md b/docs/opengl-guide.md new file mode 100644 index 00000000..8f7634b2 --- /dev/null +++ b/docs/opengl-guide.md @@ -0,0 +1,246 @@ +# OpenGL Guide + +This document will not teach you OpenGL! + +Instead it is designed to give you the information you need to use OpenGL tutorials and example programs from other sources in Basic4GL. + +> [!TIP] +> +> One such source is Neon Helium Productions OpenGL tutorials http://nehe.gamedev.net. +> +> Basic4GL conversions of tutorials 2 - 11 are available in the sample program directory (named nehe2, nehe3, e.t.c). +> +> The next important resource is an OpenGL function reference. +> You can find one for the LWJGL 3 OpenGL implementation at https://javadoc.lwjgl.org/org/lwjgl/opengl/GL11.html + +> [!WARNING] +> +> _Nehe9.gb_ and _Nehe17.gb_ sample programs have known issues and may crash. +> +> Please report issues with OpenGL sample programs to https://github.com/NateIsStalling/Basic4GLj/issues + +## OpenGL support + +> [!NOTE] +> +> Basic4GLj uses LWJGL 3 for OpenGL v1.1 support + +Basic4GL supports version OpenGL v1.1, and supports all functions of the Win32 OpenGL implementation, except for: + +- glBitmap +- glDrawElements +- glDrawPixels +- glEdgeFlagPointer +- glGetMap- (range) +- glGetPixelMap- (range) +- glGetTexImage +- glIndexPointer +- glInterleavedArrays +- glMap- (range) +- glNormalPointer +- glPixelMap- (range) +- glReadPixels +- glTexCoordPointer +- glTexImage1d +- glTexSubImage1d +- glVertexPointer + +> [!CAUTION] +> +> `glCallList` and `glCallLists` are not supported in the current Basic4GLj version +> and may result in compile or runtime errors. + +### OpenGL Extension support: +Basic4GL also supports the following functions from the `GL_ARB_multitexture` extension: + +- glMultiTexCoord2f +- glMultiTexCoord2d +- glActiveTexture + +### OpenGL GLU function support: + +> [!IMPORTANT] +> +> OpenGL GLU constants are unavailable, they are unsupported by the current version of LWJGL that is used by Basic4GLj. +> +> GLU functions available in previous versions of Basic4GL are available with modifications. + +| Function | Basic4GL for Windows | Basic4GLj | +|-----------------|----------------------|--------------------| +| gluOrtho2d | Supported | Supported [^1] | +| gluPerspective | Supported | Supported [^2] | +| gluLookat | Supported | Not Supported [^3] | + +[^1]: gluOrtho2D is mapped to glOrtho in Basic4GLj +[^2]: gluPerspective uses glFrustrum implementation in Basic4GLj +[^3]: gluLookAt will currently throw an UnsupportedOperationException if called in Basic4GLj + +## Basic4GL OpenGL implementation +### Basic4GL OpenGL initialization +Firstly, Basic4GL creates a window for you and initializes it for OpenGL. +Therefore, Basic4GL programs skip the initialization stage and can start executing OpenGL commands straight away. + +Example: +``` +glTranslatef (0, 0, -4) +glBegin (GL_TRIANGLES) +glColor3f (1, 0, 0): glVertex2f ( 0, 1) +glColor3f (0, 1, 0): glVertex2f (-1,-1) +glColor3f (0, 0, 1): glVertex2f ( 1,-1) +glEnd () +SwapBuffers () +``` + +Basic4GL also performs the following OpenGL calls at the beginning of each program: +``` +` Initialise the view port +glViewport (0, 0, WindowWidth(), WindowHeight()) + +` Create projection matrix, 60 degree field of view, near clip plane at 1, far clip plane at 1000 +glMatrixMode (GL_PROJECTION) +glLoadIdentity () +gluPerspective (60, (1.0*WindowWidth()) / WindowHeight(), 1, 1000) + +` Initialise the model view matrix +glMatrixMode(GL_MODELVIEW) +glLoadIdentity() + +` Enable depth testing +glEnable (GL_DEPTH_TEST) +glDepthFunc (GL_LEQUAL) +``` + +This is simply for convenience and saves typing - if you're happy with the default settings. +Otherwise, feel free to roll your own projection matrix e.t.c. +The `WindowWidth()` and `WindowHeight()` functions will return the width and height of the output window. +(Multiplying the `WindowWidth()` by `1.0` simply converts it to a real value instead of an integer, so that real division is used instead of integer). + +> [!WARNING] +> +> Modern retina and high resolution displays may scale window dimensions to accommodate different display configurations. +> +> Basic4GLj attempts to scale output to accommodate modern, high resolution display settings, +> and `WindowWidth()` and `WindowHeight()` may not reflect the applied resolution scaling. +> +> If you experience issues with `WindowWidth()` and `WindowHeight()`, +> please report issues on the Basic4GLj project page on GitHub: +> https://github.com/NateIsStalling/Basic4GLj/issues + +### Double buffered OpenGL window. +The OpenGL window is double buffered. + +This means it has a back buffer, which is hidden away from the user, and a front buffer which corresponds to the visible image on the screen. + +All OpenGL rendering occurs in the back buffer. Once the scene is complete and ready to be displayed it is "swapped" to the front buffer, which displays it on the screen. +In Basic4GL you do this with the `SwapBuffers()` command. + +### SwapBuffers +`SwapBuffers()` will swap the completed scene from the back buffer. +This immediately displays the result of the image that has just been rendered. + +`SwapBuffers()` is a crucial part of any Basic4GL OpenGL program. +Without it the user won't see anything rendered, because it will all be sitting in the back buffer, +which is not displayed. + +A simple example: + +``` +while true +glClearColor (rnd()%100/100.0, rnd()%100/100.0, rnd()%100/100.0, rnd()%100/100.0) +glClear (GL_COLOR_BUFFER_BIT) +SwapBuffers () +wend +``` + +Will repeatedly clear the OpenGL window to a random colour, and display it. The visual result is random flickering colours. +Without the `SwapBuffers()` call, the above program would not appear to do anything, +as nothing ever gets through to the front buffer. + +> [!NOTE] +> +> `SwapBuffers()` will either copy the back buffer to the front buffer, or exchange the buffers. +> This appears to depend on the hardware, screen mode and OpenGL implementation. +> For example, my on NVidia GeForce2, `SwapBuffers` appears to exchange in fullscreen mode and copy in windowed mode. + +### Image and texture loading + +Basic4GLj supports image formats compatible with LWJGL 3 stb bindings, +including: _JPG_, _PNG_, _TGA_, _BMP_, _PSD_, _GIF_, _HDR_, _PIC_. + +_PCX_ texture support is provided by Apache Commons Imaging library. + +See _LICENSES_ directory in project's git repository and distributions +for license information about LWJGL 3, stb, and Apache Commons. + +> [!IMPORTANT] +> +> Legacy Basic4GL for Windows versions use the Corona open-source image library to load image files, +> for use in OpenGL textures, including Windows Bitmap, JPEG, and other formats. +> +> If you experience issues loading any texture formats in Basic4GLj, +> please report any issues on the project's GitHub page at https://github.com/NateIsStalling/Basic4GLj/issues + +### LoadTex +The easiest way to get a texture into Basic4GL is to use the `LoadTex()` functions. + +Format: +``` +LoadTex(filename) +``` + +Where filename is a string containing the filename of an image to load into an OpenGL texture. + +This will allocate an OpenGL texture, load the image into the texture, +and return the OpenGL texture handle (a numeric handle known as the "texture name"). +The function returns `0`, if for any reason it cannot load the image and store it in a texture. + +> [!TIP] +> +> See the **Sprite Library Guide** for more information on loading textures. + +### Multitexturing +Basic4GL uses OpenGL v1.1. + +Multitexturing is not natively part of the v1.1, but is available through the OpenGL extensions mechanism. + +Basic4GL automatically hooks into this extension and makes the associated functions and constants available to Basic4GL programs. (If the extension is not available, calling the functions will simply do nothing.) + +### ExtensionSupported +The Basic4GL function `ExtensionSupported` is the easiest way to test whether the current hardware supports multitexturing, as for example: + +``` +if not ExtensionSupported ("GL_ARB_multitexture") then +Do something else... +``` + +(You can also check for other extensions, however this version of Basic4GL only supports `GL_ARB_multitexture`..) + +This is exactly equivalent to calling `glGetString (GL_EXTENSIONS)` and testing the resulting string for the presence of `"GL_ARB_multitexture"` + +### MaxTextureUnits +`MaxTextureUnits()` will return the number of available texturing units. + +``` +dim units +units = MaxTextureUnits () +``` + +Note: This is exactly equivalent to: +``` +dim units +glGetIntegerv (GL_MAX_TEXTURE_UNITS_ARB, units) +``` +### glMultitexCoord2f, glMultitexCoord2d and glActiveTexture +These are the actual multitexturing functions that Basic4GL supports. + +> [!TIP] +> +> See the _MultitextureDemo.gb_ example program for an example of multitexturing in action. + +## Credits +Basic4GL, Copyright (C) 2003 Tom Mulgrew + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen \ No newline at end of file diff --git a/docs/runtime-compilation-guide.md b/docs/runtime-compilation-guide.md new file mode 100644 index 00000000..e5ed0009 --- /dev/null +++ b/docs/runtime-compilation-guide.md @@ -0,0 +1,167 @@ +# Programmer's Guide: Runtime Compilation + +> [!IMPORTANT] +> +> As of Basic4GL language version 2.5.1 the following functions are deprecated: `Compile`, `CompileFile`, `Execute` +> +> If at all possible you should use the `comp` and `exec` commands that are documented in the Language Syntax Guide +> (as they are now built in language commands). + +These old functions are still available for backwards compatibility with older Basic4GL programs. +However, they cannot be used in combination with user functions and subs, +and you will get a runtime error if you try to do so. + +The exception is the following functions: `CompilerError`, `CompilerErrorLine` and `CompilerErrorCol`. + +These are still considered current, and can be used with the new `comp` command. + +The following functions can be used to compile and execute code at runtime: +- `Compile` +- `CompileFile` +- `Execute` + +### Compile + +`Compile(text)` compiles text into program code, and returns an integer that identifies the compiled code. + +Example: +``` +dim code +code = Compile("printr " + chr$(34) + "Hello world" + chr$(34)) +Execute(code) +``` + +`Text` can either be a string, or an array of strings. + +If the text compiles successfully, `Compile` returns an integer handle that can be passed to the `Execute` function. + +If the text does not compile, `Compile` returns `0`, and the error description and position can be extracted using the +`CompilerError`, `CompilerErrorLine` and `CompilerErrorCol` functions. + +### CompileFile +`CompileFile(filename)` loads a file from disk, compiles it, and returns an integer that identifies the compiled code. + +Example: + +``` +dim code +code = CompileFile("md2viewer.gb") +if code = 0 lor not Execute(code) then +printr CompilerError() +endif +``` +> [!Important] +> +> `CompileFile` can compile just about any program that can be loaded into Basic4GL and compiled manually. +> However, there are some limitations: +> - `CompileFile` does not support includes. +> - `CompileFile` does not support plugins. + +### Execute +`Execute(handle)` executes a block of compiled code. + +`handle` must be a valid handle returned from a successful `Compile` or `CompileFile`. + +If the code completes without any errors, `Execute` returns `true`. Otherwise, `execute` returns `false`, +and the error message can be read using the `CompilerError`, `CompilerErrorLine` and `CompilerErrorCol` functions. + +The compiled code will execute until one of the following occurs: + +- The end of the code is reached. +- An `end` instruction is reached. +- A `run` instruction is reached (this is considered a run-time error in dynamically compiled code). +- A runtime error occurs. + +The program will then continue executing from the next instruction after the `Execute` call. + +## Compiling with a "symbol prefix" +These special versions of the above functions can be used to compile code with an automatic "symbol prefix": + +- `Compile(text, symbol prefix)` +- `CompileFile(filename, symbol prefix)` + +The _symbol prefix_ is a text string that is automatically prefixed to the front of every variable name, +label name or structure name that the runtime compiled program refers to. + +For example: +``` +dim text$, program, i, __value +text$ = "value = value * value" +program = Compile(text$, "__") +printr CompilerError() + +for i = 1 to 10 + __value = i + execute(program) + printr i + ", " + __value +next +``` + +Here our code to compile at runtime is `value = value * value`. + +But because we pass a symbol prefix of `"__"` (double underscore) to the `Compile()` command, +what actually gets compiled is `__value = __value * __value`. + +The important thing here is that the runtime code cannot access any of the main program's variables except those that we have prefixed with `"__"`. +It cannot access `i` for example, as `i = 5` would effectively be compiled as `__i = 5`. + +We can use this to limit exactly which variables, labels and structure types the runtime compiled program has access to. + +### CompilerError, CompilerErrorLine, CompilerErrorCol +`CompilerError()` returns the error message generated by the last `Compile`, `CompileFile` or `Execute` call. + +If there was no error (i.e. the call was successful), `CompilerError` returns an empty string (`""`). + +`CompilerErrorLine()` and `CompilerErrorCol()` return the line and column of the last error (if applicable). +For a `Compile` or `CompileFile` call, this is the position of the error in the code being compiled. +For an `Execute` call, this is the instruction that caused the run-time error. + +## Runtime compilation issues +There are a some potential issues with runtime compiled code that need to be kept in mind. +It is tempting to think that runtime compiled code is crash-proof, +because any runtime errors immediately return control to the calling program which can deal with the error appropriately. + +This is true. However, the runtime compiled code has access to everything in the parent program +(by default, although this can be controlled by "symbol prefixes" - see above), +including all variables, labels etc, and can often mess things up enough that +the program will not run correctly when control is returned. + +For example, the following program: +``` +dim text$, code +text$ = "return" +gosub ExecuteIt +end + +ExecuteIt: + code = Compile(text$) + if code = 0 lor not Execute(code) then + printr CompilerError() + endif + return +``` +stops with a runtime error `"Return without gosub"`, because the runtime compiled code actually returns to the line after the `gosub`, +before finally ending when it reaches the `end` instruction. + +This returns control back to the instruction after the `Execute(code)` instruction which attempts to `return` again! + +Another issue is that the executed code may never return at all! + +Consider: +``` +print "Starting" +Execute(Compile("while true: wend")) +print "Ending" +``` + +The last line is never executed, because the dynamically compiled code (`"while true: wend"`) never exits, and control is never returned. + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +_Programmer's guide_ + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen \ No newline at end of file diff --git a/docs/sound-guide.md b/docs/sound-guide.md new file mode 100644 index 00000000..377df071 --- /dev/null +++ b/docs/sound-guide.md @@ -0,0 +1,227 @@ + +# Programmer's Guide: Sound + +## Sound System + +### Playing Sound Effects + +The following file formats are currently supported for playing sound effects using the `loadsound` and `playsound` functions: + +| File Extension | Codec | +|----------------|---------------| +| wav | CodecWav | +| ogg | CodecJOrbis | +| xm | CodecIBXM | +| s3m | CodecIBXM | +| mod | CodecIBXM | + +### Playing Music + +Ogg Vorbis files are supported for playing music continuously using the `playmusic` function. + +> [!IMPORTANT] +> +> Legacy Basic4GL for Windows versions use the Audiere sound library, +> which supports a number of different sound formats such as _.wav_, streamed music formats such as Ogg Vorbis, +> and "mod" formats like _.mod_, _.s3m_, _.xm_ and _.it_. +> +> If you experience issues loading any sound formats in Basic4GLj, +> please report any issues on the project's GitHub page at https://github.com/NateIsStalling/Basic4GLj/issues + +> [!WARNING] +> +> _.mp3_ files are not currently supported by Basic4GLj +> +> (since legacy Basic4GL versions do not support _.mp3_ files in the standard Sound library) + + +### Sound System Licenses + +Basic4GLj depends on a fork of Paulscode-SoundSystem to support sound codecs for LWJGL 3 which can be found here: + +https://github.com/NateIsStalling/Paulscode-SoundSystem/tree/lwjgl3 + +Licenses for Paulscode-SoundSystem and related sound Codecs can be found in Basic4GLj's app module `dist` directory or in release packages under `/LICENSES/sound system` + +## Sharing Standalone Programs + +> [!IMPORTANT] +> +> Guides for sharing Basic4GLj programs are a work in progress. +> +> If you experience any issues sharing programs, +> please report any trouble you experience on the project's GitHub page at https://github.com/NateIsStalling/Basic4GLj/issues + +If you use sound or music functions in your program, and you wish to share it as a standalone program, +be aware that you must also: + +- manually add any sound files to the exported _.zip_ archive + +which must be placed in the same folder as your exported _.jar_ file, depending on the file path used in your program. + +Otherwise, your program will run silently. + +> [!IMPORTANT] +> +> Having to manually add files to the exported _.zip_ archive should be fixed in future versions of Basic4GLj + +## Sound functions + +### LoadSound +Sounds are loaded as follows: +``` +dim sound +... +sound = LoadSound (filename) +``` + +`Filename` must refer to a file of a supported sound format. + +### PlaySound +Once the sound has been loaded, it can be played as follows: +``` +PlaySound (sound) +``` +or +``` +PlaySound(sound, volume, looped) +``` + +Here sound is the sound handle that was returned from `LoadSound(...)`. +Volume is the sound volume, where `1 = full volume`, `0.5 = half volume`, etc. + +> [!WARNING] +> +> You can also use values greater than `1`, but be warned that the sound may "clip" and become distorted. + +Setting `looped` to `true` will cause the sound to play continuously in a loop. + +> [!NOTE] +> +> If volume and looped are not specified they default to volume = 1 and looped = false. + +`PlaySound(...)` returns the number of the "voice" that was chosen to play the sound. + +This number is useful if you want to stop the sound later (especially for looped sounds like footsteps), +as you can pass it to the `StopSoundVoice(...)` function. + +> [!IMPORTANT] +> +> Basic4GL supports 10 voices, which defines the maximum number of sounds that can be played simultaneously. + + +### DeleteSound +`DeleteSound (sound)` deletes the sound from memory. + +If you don't explicitly delete them, Basic4GL will automatically do so when your program finishes. + +### StopSoundVoice +To stop a sound playing, use: +``` +StopSoundVoice(voice) +``` +`Voice` is the number of the voice you wish to stop playing. +This number is returned from `PlaySound(...)` when the sound was started. + +### StopSounds +You can also stop all sounds with: +``` +StopSounds() +``` + +## Music functions +These functions are used to stream in and play music files, such as Ogg Vorbis, or "mod" files (_.mod_, _.s3m_, _.xm_, _.it_, etc). + +### PlayMusic +Start playing a music file with: +``` +PlayMusic(filename) +``` +or +``` +PlayMusic(filename, volume, looped) +``` + +`Filename` must be a file of a supported music format. `Volume` and `looped` behave the same as with `PlaySound(...)`. + +This will open the file and start playing it immediately. +Unlike regular sound files music files are "streamed". This means that the file is not loaded into memory all at once. + +Instead, the file is loaded in continuously while the music is playing. + +> [!IMPORTANT] +> +> Basic4GL supports playing one music file at a time only. +> +> If a music file is already playing, it will stop and the new file will play instead. + +Example: +``` +dim filename$ +printr"Filename:": input filename$ +PlayMusic(filename$) +if SoundError() <> "" then printr SoundError(): end endif +while MusicPlaying(): Sleep(100): wend +StopMusic +StopMusic() will stop music file from playing. +``` + +### MusicPlaying +`MusicPlaying()` returns true while the music file is playing. + +## SetMusicVolume +To set the music volume while music is playing, use: + +### SetMusicVolume(volume) + +Where volume behaves the same as with `PlaySound()` or `PlayMusic()`. + +## Sound and music errors +If a sound or music function fails, Basic4GL will store a description of the error, which can be retrieved with the `SoundError()` function. + +### SoundError +`SoundError()` returns a text string describing the result of the last sound or music function call. + +If the call was successful, `SoundError()` returns the empty string (`""`). Otherwise, it returns the text of the error message. + +Example: +``` +dim sound, i +sound = LoadSound("c:\windows\media\chimes.wav") +if SoundError() <> "" then +printr SoundError() +else +PlaySound(sound) +Sleep(2000) +endif +``` + +You can test whether the Basic4GL sound engine has initialised correctly by placing the following code at the top of your program. +``` +if SoundError() <> "" then +print SoundError() +end +endif +``` + +If the sound engine has not initialised correctly (because an error occurred), it will print the message: + +> Sound playback is not available; the sound engine failed to initialize. + +and stop. + +> [!IMPORTANT] +> +> The initialization error message is typically caused by issues with initializing LWJGL 3 or creating an OpenAL context. +> +> Please report any sound initialization issues on the Basic4GLj's GitHub page at https://github.com/NateIsStalling/Basic4GLj/issues + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +_Programmer's guide_ + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Sound System notes and Markdown formatting by Nathaniel Nielsen \ No newline at end of file diff --git a/docs/sprite-library-guide.md b/docs/sprite-library-guide.md new file mode 100644 index 00000000..39a22233 --- /dev/null +++ b/docs/sprite-library-guide.md @@ -0,0 +1,1265 @@ + + +# Sprite library guide +This document details Basic4GL's integrated sprite library and various routines. + +## Sprite routines +Basic4GL's contains an integrated sprite library, designed to simplify the process of writing 2D sprite based games and applications. +Internally sprites are drawn using OpenGL similar to Basic4GL's text mechanism. However - as with text - no OpenGL experience is needed to use the sprite routines. + +Basic4GL sprites support: + +- Scaling & rotation +- Colours & transparency +- Animated sprites +- Z order +- Tile maps (a 2D grid of tiles) +- Parallax scrolling + +#### A simple example of a Basic4GL sprite program: + +``` +dim texture, sprite +texture = LoadTex("data\ball.png") +sprite = NewSprite (texture) +SprSetVel (vec2 (2, 2)) +SprSetPos (100, 100) +locate 13, 12: print "Bouncing ball" + +while true + AnimateSprites () + if SprLeft () < 0 or SprRight () > SpriteAreaWidth () then + SprSetXVel (-SprXVel ()) + endif + if SprTop () < 0 or SprBottom () > SpriteAreaHeight () then + SprSetYVel (-SprYVel ()) + endif +wend +``` + +> [!TIP] +> +> There are also some larger examples supplied with Basic4GL. +> See AsteroidDemo2.gb (and compare it to AsteroidDemo.gb), and CavernDemo.gb. + +## Integration with the text system +The Basic4GL sprite engine is built on top of the Basic4GL text engine, and shares some its mechanism and functions. +The sprites and text are redrawn at the same time, which by default is whenever either the text on the screen changes, or a change is made to a sprite. + +Also, if you switch the text mode to buffered mode (using `TextMode (TEXT_BUFFERED)`), +sprites are automatically switched to buffered mode also, and will only be drawn when `DrawText ()` is called. + +You may think it strange to have text commands controlling when sprites are drawn, and you're probably right! +The reason is text support was implemented first, and so the functions were named `TextMode()` and `DrawText()`, instead of (maybe) `TextAndSpriteMode()` and `DrawTextAndSprites()`. +When sprite support was added, I chose not to rename the functions, in order to maintain backward compatibility with existing Basic4GL code. + +See the `TextMode()` and `DrawText()` definitions (in the **"Text Output"** section) for more information. + +## Loading textures +> [!WARNING] +> +> The following commands are deprecated as of Basic4GL language version 2.5.6: +> - LoadTexture +> - LoadMipmapTexture +> - LoadImageStrip +> - LoadMipmapImageStrip +> - ImageStripFrames +> +> These commands are still available, so that old Basic4GL programs will still compile, +> however you are advised to use the `LoadTex`, `LoadTexStrip` and `TexStripFrames` commands instead. + +> [!NOTE] +> +> Basic4GLj currently supports functions deprecated in Basic4GL language version 2.5.6. + +Basic4GL sprites are drawn using OpenGL textures. So in order to display a sprite, you must first load the texture into OpenGL and then assign it to the sprite. + +### LoadTex() +The easiest way to load a single OpenGL texture is with the `LoadTex()` function. + +For example: +``` +' Load texture +dim texture +texture = LoadTex("data\star.bmp") ' Load texture and return handle + +' Create sprite +dim sprite +sprite = NewSprite (texture) ' Create sprite, and assign texture +SprSetPos (320, 240) +``` + +`LoadTex()` loads a texture into OpenGL, and returns the OpenGL texture "name" (an integer that identifies the texture). + +You can then pass that "name" to a sprite, in order to create a sprite that displays that texture. + +### TexStripFrames and LoadTexStrip +Basic4GL also supports animated sprites. + +These require multiple OpenGL textures (one texture for each animation frame) which are passed to the sprite as an array of OpenGL texture "name"s. +You could achieve this by having multiple image files and loading them all-in-one by one. +This is a bit clumsy however, so Basic4GL supports the concept of "image strips". + +An image strip is a single image, that contains multiple subimages. + +> [!TIP] +> +> Have a look at the "explode.png" image in the "Programs\Data" folder if you need an example. + +Basic4GL provides routines to load such an image, chop it up into the separate subimages and upload them into OpenGL as separate textures. + +`LoadTexStrip()` will do all of the above and return an array of OpenGL texture handles that can be passed to a Basic4GL sprite. + +Format: +``` +LoadTexStrip(filename [, frameWidth, frameHeight]) +``` + +The `frameWidth` and `frameHeight` are optional. If not specified, Basic4GL will use the width or height of the image (whatever is smaller). +This means that if all the frames are on one row in the image file, and they are square, you do not need to specify what size the frames are. +Frame widths and heights will usually be powers of 2 (1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, ...), so that they can be loaded into an OpenGL texture. + +If they are not, Basic4GL will automatically scale each frame to a suitable size before loading it into an OpenGL texture. +The scaling routine is primative and works by either dropping or duplicating rows or columns. + +> [!TIP] +> +> For better results, use an image editor (Gimp, Photoshop etc) to scale the image beforehand so that the frames sizes are a power of 2. +> These programs have more sophisticated image scaling algorithms, and the result will generally look better. + +Example: +``` +dim sprite +sprite = NewSprite(LoadTexStrip("data\explode.png")) +SprSetPos (320, 240) +SprSetAnimSpeed (1) + +while true + WaitTimer (100) + AnimateSprites () +wend +``` + +Example 2: +``` +dim sprite +sprite = NewSprite(LoadTexStrip("data\spacetiles.png", 32, 32)) +SprSetPos (320, 240) +SprSetAnimSpeed (1) + +while true + WaitTimer (100) + AnimateSprites () +wend +``` + +To calculate the number of frames in an image strip, use `TexStripFrames()`. + +Format: +``` +TexStripFrames(filename [, frameWidth, frameHeight]) +``` + +This returns the number of images in the image strip (and is useful if you want to assign them to an array, e.g. to be shared between multiple sprites). + +``` +' Load images +dim &explodeFrames () +alloc explodeFrames, TexStripFrames("data\explode.png") - 1 +explodeFrames = LoadTexStrip("data\explode.png") + +' Create sprites +dim sprites (10), i +for i = 1 to 10 + sprites (i) = NewSprite (explodeFrames) + SprSetPos (rnd () % int (SpriteAreaWidth ()), rnd () % int (SpriteAreaHeight ())) + SprSetScale (5) + SprSetAnimSpeed (rnd () % 10 * .1 + .5) +next + +' Main loop +while true +WaitTimer (100) +AnimateSprites () +wend +``` + +## Advanced texture loading options +There are also a number of options that affect the way Basic4GL handles images when they are loaded. + +By default Basic4GL: + +- Does not treat any one colour as transparent (images that contain transparency information, like PNG files, are still treated correctly however) +- Automatically generates "mipmap" textures for each texture it loads +- Removes blank frames from the end of texture strips +- Sets up each texture to use linear filtering +- Basic4GL has a number of routines that change this behaviour. These routines affect all subsequent LoadTex and LoadTexStrip calls until the program ends. + +### SetTexTransparentCol +`SetTexTransparentCol` specifies a colour to treat as transparent. +Basic4GL will replace all pixels of this colour with transparent pixels when it loads the texture. + +Format: +``` +SetTexTransparentCol(red, green, blue) +SetTexTransparentCol(colour) +``` + +Where `red`, `green`, and `blue` are integers representing the intensity of the corresponding colour component. `0` = minimum, `255` = maximum. +Or colour is an integer calculated as: `colour = red * 65536 + green * 256 + blue` + +Example: +``` +' Replace black with transparent pixels +SetTexTransparentCol(0, 0, 0) + +' Load textures +dim textures(TexStripFrames("data\spacetiles.png") - 1) = LoadTexStrip("data\spacetiles.png") + +' Build tile map +data 0, 0, 0, 1, 0 +data 0, 1, 5, 0, 2 +data 2, 3, 0, 0, 4 +data 0, 0, 1, 2, 0 +data 1, 4, 3, 0, 0 + +dim tiles(4)(4), x, y + +for y = 0 to 4: for x = 0 to 4: read tiles(x)(y): next: next + +' Create tile map sprite +dim tile = NewTileMap(textures) +SprSetSolid(false) +SprSetTiles(tiles) + +' Animate over a blue background +glClearColor(0, .1, .3, 1) + +while true + SprSetPos(SprPos() + vec2(2, 1)) +wend +``` + +### SetTexNoTransparentCol +`SetTexNoTransparentCol()` sets Basic4GL back to its original behaviour, where no colour is treated as transparent. + +### SetTexIgnoreBlankFrames +By default `LoadTexStrip()` will automatically detect blank frames at the end of the texture strip and remove them. +A frame is considered blank if all its pixels are fully transparent, +or if all its pixels match the current transparent colour (if one is set). + +You can switch this behaviour off with: +``` +SetTexIgnoreBlankFrames(false) +``` + +In this case blank frames will be loaded in and stored in the texture array. + +To re-enable this behaviour, use: +``` +SetTexIgnoreBlankFrames(true) +``` + +### SetTexMipmap +By default, whenever Basic4GL loads a texture with `LoadTex` or `LoadTexStrip`, it will create corresponding mipmap textures. + +These are smaller versions of the texture that will automatically be used when the texture is squeezed into a smaller number of pixels. +This will usually look better than trying to draw the original texture scaled down, which can introduce visual artifacts such as "moire patterns". +However, the mipmap textures do take up a third more texture memory than a non mipmap texture on its own. + +You can disable creation of mipmap textures with: +``` +SetTexMipmap(false) +``` + +To re-enable it, use: +``` +SetTexMipmap(true) +``` + +Example: +``` +' Create a sprite with a mipmapped texture +SetTexMipmap(true) +dim sprite1 = NewSprite(LoadTex("data/cube.bmp")) +SprSetPos(160, 240) +SprSetSpin(1) + +' Create a sprite with the same texture, no mipmapping +SetTexMipmap(false) +dim sprite2 = NewSprite(LoadTex("data/cube.bmp")) +SprSetPos(480, 240) +SprSetSpin(1) + +locate 7, 10: print "Mipmap" +locate 25, 10: print "No mipmap" + +' Spin textures to show difference +while true + AnimateSprites() + WaitTimer(50) +wend +``` + +### SetTexLinearFilter +Linear filtering controls how a texture is drawn when it is magnified. +- When linear filtering is enabled (the default) texture pixels are interpolated making magnified textures appear smooth. +- When linear filtering is disabled, no interpolation takes place, and magnified texture pixels appear as distinct rectangles. + +To disable linear filtering, use: +``` +SetTexLinearFilter(false) +``` + +To re-enable it, use: +``` +SetTexLinearFilter(true) +``` + +Example: +``` +' Create a sprite with linear filtering +SetTexLinearFilter(true) +dim sprite1 = NewSprite(LoadTex("data/asteroid.png")) +SprSetSize(300, 300) +SprSetPos(160, 240) +SprSetSpin(1) +' Create a sprite with no linear filtering +SetTexLinearFilter(false) +dim sprite2 = NewSprite(LoadTex("data/asteroid.png")) +SprSetSize(300, 300) +SprSetPos(480, 240) +SprSetSpin(1) + +locate 4, 1: print "Linear filter" +locate 22, 1: print "No linear filter" + +' Spin textures to show difference +while true + AnimateSprites() + WaitTimer(50) +wend +``` + +Note, loading a texture with linear filtering has exactly the same effect as configuring the texture with OpenGL commands: +``` +glBindTexture(GL_TEXTURE_2D, texture) +glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) +``` + +Loading a texture without linear filtering has exactly the same effect as configuring it with: +``` +glBindTexture(GL_TEXTURE_2D, texture) +glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) +``` + +## Creating sprites and deleting sprites +Sprites can be created with `NewSprite()` or `NewTileMap()`. +For now, we will only concentrate on regular sprites (and the `NewSprite()` function). +Tile maps will be explained in the tile map section further below. + +### NewSprite +`NewSprite()` creates a single sprite and returns a handle to it. + +Format: +``` +NewSprite () +NewSprite (texture) +NewSprite (textureArray) +``` +Where: +- `texture` is an OpenGL texture. +- `textureArray` is an array of OpenGL textures. + +All 3 different formats return a sprite handle. This is a number that identifies the sprite, and is used to access it and manipulate it. +- If texture is specified, then the texture is loaded into the sprite. +- If textures is specified, then the array of textures is loaded into the sprite. +- If nothing is specified, then no textures are associated with the sprite, and you will need to call one of the texture setting functions to add some. + +### DeleteSprite +(Applies to sprites and tile maps.) + +DeleteSprite can be used to free a sprite once you have finished with it. + +Format: +``` +DeleteSprite (spriteHandle) +``` +Where `spriteHandle` is a sprite handle returned from `NewSprite()` (or `NewTileMap ()`). + +### ClearSprites +`ClearSprite` will delete all sprites and tile-maps from the screen. + +Format: +``` +ClearSprites() +``` + +## Binding sprites +To manipulate a sprite, you must first "bind" it. This works much in the same way as OpenGL texture binding. +Once bound, all sprite functions operate on that sprite until the binding is changed to another sprite. + +`NewSprite()` and `NewTileMap()` automatically bind the sprite they have just created. + +Otherwise, you need to call `BindSprite()` to set the currently bound sprite. + +### BindSprite +(Applies to sprites and tile maps.) + +Sets the currently bound sprite. All sprite changes from there on will be applied to the sprite until another sprite is bound. + +Format: +``` +BindSprite (spriteHandle) +``` + +Where `spriteHandle` is a sprite handle returned from `NewSprite()` (or `NewTileMap ()`). + +## Setting the sprite texture + +### SprSetTexture, SprSetTextures +(Applies to sprites and tile maps.) + +Loads a texture or set of textures into a sprite. + +Format: +``` +SprSetTexture (texture) +SprSetTextures (textureArray) +``` +Where: +- `texture` is an OpenGL texture. +- `textureArray` is an array of OpenGL textures. + +The `texture` or `textureArray` will completely replace any textures already in the sprite. + +### SprAddTexture, SprAddTextures +(Applies to sprites and tile maps.) + +Adds a texture or textures to a sprite. + +Format: +``` +SprAddTexture (texture) +SprAddTextures (textureArray) +``` + +Where: +- `texture` is an OpenGL texture. +- `textureArray` is an array of OpenGL textures. + +The `texture` or textures in `textureArray` are added to the end of any existing textures in the sprite. + +## Sprite properties +You animate and move sprites around by setting various sprite properties, such as their position, angle, colour e.t.c. +For each property there is usually one or more functions to set the property, and one or more to get (read) the property from the sprite. + +The "Set" functions always operate on the bound sprite. + +The "Get" functions usually come in 2 forms. One that operates on the bound sprite, and one that is passed a sprite handle to operate on. +(The second form is useful if you want to copy properties from one sprite to another, or set one sprite's properties dependent on those of another sprite.) + +### SprSetPos, SprSetX, SprSetY, SprPos, SprX, SprY +(Applies to sprites and tile maps.) + +These functions are used to move sprites around. + +Format: +``` +SprSetPos (vector) +SprSetPos (x, y) +SprSetX (x) +SprSetY(y) +``` +``` +SprPos () / SprPos (spriteHandle) +SprX () / SprX (spriteHandle) +SprY () / SprY (spriteHandle) +``` + +Where: +- `vector` is a real numbered 2D vector. (e.g `dim vec#(1)` ). +- `x` is a real number representing the sprite's horizontal position. +- `y` is a real number representing the sprite's vertical position. + +By default, the top left corner of the screen is at x = 0, y = 0, and the bottom right is at x = 640, y = 480. + +> [!NOTE] +> +> Increasing `y` corresponds to the down direction, +> unlike most OpenGL configurations in which increasing `y` corresponds to up. + +You can change the dimensions of the sprite area by calling `ResizeSpriteArea()` (which works much the same as `ResizeText()`). + +The position that the sprite appears in is also dependent on the "sprite camera". (See the `SprCamera` functions for more information.) + +### SprSetZOrder, SprZOrder +(Applies to sprites and tile maps.) + +Format: +``` +SprSetZOrder (zOrder) +``` +``` +SprZOrder () / SprZOrder (spriteHandle) +``` +Where: +- `zOrder` is a real number. + +The Z order is a real valued number, stored with each sprite which determines whether it will appear in front or behind other sprites. +Smaller values (or more negative values) appear in-front of larger values. + +Also, any sprite with a Z order of 0 or greater will appear behind any text on the screen, +and any sprite with a Z order less than 0 will appear in-front of the text. + +When parallax scrolling is switched on for a sprite or tile map (using `SprSetParallax()`), +the Z order affects whether it appears far away and scrolls slowly (positive Z order values) +or close and scrolls quickly (negative Z order values). + +If a sprite has a Z order of 0, switching on parallax scrolling will have no effect. + +### SprSetSize, SprSetXSize, SprSetYSize, SprXSize, SprYSize +(Applies to sprites and tile maps.) + +These functions set the size of the sprite, or in the case of tilemaps, the size of each tile. + +Format: +``` +SprSetSize (vector) +SprSetSize (xSize, ySize) +SprSetXSize (xSize) +SprSetYSize (ySize) +``` +``` +SprXSize () / SprXSize (spriteHandle) +SprYSize () / SprYSize (spriteHandle) +``` +> [!NOTE] +> +> The default sprite (and tile) size is 32 x 32. + +### SprSetXCentre, SprSetYCentre, SprXCentre, SprYCentre +(Applies to sprites and tile maps.) + +Format: +``` +SprSetXCentre (xCentre) +SprSetYCentre (yCentre) +``` +``` +SprXCentre () / SprXCentre (spriteHandle) +SprYCentre () / SprYCentre (spriteHandle) +``` +Used to specify the centre of the sprite. This is the point that is lined up to the sprite's position, and is also where the sprite rotates around. +By default, this is (.5, .5) for regular sprites (corresponding to the centre of the sprite) and (0, 0) for tile maps (corresponding to the top left corner). + +A value of 1 corresponds to a single sprite with or height, or a single tile width or height (for tile maps). + +### SprSetScale, SprScale +(Applies to sprites and tile maps.) + +Format: +``` +SprSetScale (scaleFactor) +``` +``` +SprScale () / SprScale (spriteHandle) +``` +Where: +- `scaleFactor` is a real number. + +Used to scale a sprite. Alternatively you could just multiply the X and Y size to achieve exactly the same effect. +The default scale is 1. + +### SprSetXFlip, SprSetYFlip, SprXFlip, SprYFlip +> [!IMPORTANT] +> +> Applies to sprites only! + +Format: +``` +SprSetXFlip (xFlip) +SprSetYFlip (yFlip) +``` +``` +SprXFlip () / SprXFlip (spriteHandle) +SprYFlip () / SprYFlip (spriteHandle) +``` + +Where: +- `xFlip` and `yFlip` are integer values. If the value is true, the sprite is flipped in that direction. + +### SprLeft, SprTop, SprRight, SprBottom +> [!IMPORTANT] +> +> Applies to sprites only! + +These are readonly functions that return the effective position of the edges of the sprite, taking into account position, size, scaling and sprite's centre position. + +Format: +``` +SprLeft () / SprLeft (spriteHandle) +SprRight () / SprRight (spriteHandle) +SprTop () / SprTop (spriteHandle) +SprBottom () / SprBottom (spriteHandle) +``` + +These functions all return a real value, corresponding to the horizontal coordinate (`SprLeft` & `SprRight`) +or vertical coordinate (`SprTop` & `SprBottom`) of the corresponding edge of the sprite. + +These functions are useful for primitive collision detection, or bouncing sprites against the edge of the screen. + +### SprSetAngle, SprAngle +(Applies to sprites and tile maps.) + +Used to rotate sprites. + +Format: +``` +SprSetAngle (angle) +``` +``` +SprAngle () / SprAngle (spriteHandle) +``` +Where: +- `angle` is a real number specifying the number degrees to rotate the sprite by in the anticlockwise direction. + +### SprSetVisible, SprVisible +(Applies to sprites and tile maps.) + +Used to show / hide a sprite. + +Format: +``` +SprSetVisible (visible) +``` +``` +SprVisible () / SprVisible (spriteHandle) +``` + +Where: +- `Visible` is an integer value, set to `true` to show the sprite or `false` to hide it. + +(By default sprites are made visible when created.) + +### SprSetFrame, SprFrame +> [!IMPORTANT] +> +> Applies to sprites only! + +Once multiple textures are loaded into a sprite, you can animate it by setting the sprite frame. + +Format: +``` +SprSetFrame (frameNo) +``` +``` +SprFrame () / SprFrame (spriteHandle) +``` + +Where: +- `frameNo` is the frame number to display. The first loaded frame is 0. + +### SprSetColor, SprColor +(Applies to sprites and tile maps.) + +Used to set the colour of the sprite, and (optionally) it's transparency. + +Format: +``` +SprSetColor (colourVec) +SprSetColor (red, green, blue) +SprSetColor (red, green, blue, alpha) +``` +``` +SprColor () / SprColor (spriteHandle) +``` + +Where: +- `colourVec` is a 3D or 4D vector (array) of real values. +- `red`, `green`, `blue` are real values representing the red, green and blue intensities of the colour respectively (`0` = no intensity, `1` = full intensity) +- `alpha` is a real value representing how transparent the object is (`0` = fully transparent / invisible, `1` = fully solid). + +If the alpha component isn't specified, it defaults to `1`. + +If the sprite is set to solid (`SprSetSolid()`) then it will always be drawn solid, and alpha has no effect. + +Tile maps, by default are solid. Regular sprites are transparent. + +### SprSetAlpha, SprAlpha +(Applies to sprites and tile maps.) + +Used to set just the alpha component of the sprite's colour. + +Format: +``` +SprSetAlpha (alpha) +``` +``` +SprAlpha () / SprAlpha (spriteHandle) +``` + +Where: +- `alpha` is a real value representing how transparent the object is (`0` = fully transparent / invisible, `1` = fully solid). + +See `SprSetColor()` above for more info on alpha and sprite transparency. + +### SprSetBlendFunc +(Applies to sprites and tile maps.) + +Allows you to change the blending mode of a sprite. This is an advanced function, used to enable special types of transparency. + +Format: +``` +SprSetBlendFunc(sfactor,dfactor) +``` + +Where `sfactor` and `dfactor` are OpenGL blending function constants for the source and destination pixels respectively. + +The function accepts the same constants as the OpenGL `glBlendFunc` function. + +The default blending function is `sfactor = GL_SRC_ALPHA`, `dfactor = GL_ONE_MINUS_SRC_ALPHA`. + +Example: +``` +dim stars(10), i, tex +tex = LoadTex("data/star.bmp") +for i = 1 to 10 + stars(i) = NewSprite(tex) + SprSetSize(500, 500) + SprSetColor((rnd() % 1001) / 1000.0, (rnd() % 1001) / 1000.0, (rnd() % 1001) / 1000.0) + SprSetPos(rnd() % int(SpriteAreaWidth()), rnd() % int(SpriteAreaHeight())) + SprSetVel(((rnd() % 2001) - 1000) / 1000.0, ((rnd() % 2001) - 1000) / 1000.0) + SprSetBlendFunc(GL_SRC_ALPHA, GL_ONE) +next + +do + AnimateSprites() + for i = 1 to 10 + BindSprite(stars(i)) + if SprX() < 0 or SprX() > SpriteAreaWidth() then + SprSetXVel(-SprXVel()) + endif + if SprY() < 0 or SprY() > SpriteAreaHeight() then + SprSetYVel(-SprYVel()) + endif + next +loop +``` + +### SprSetSolid, SprSolid +(Applies to sprites and tile maps.) + +Specifies whether a sprite is solid or transparent. + +Format: +``` +SprSetSolid (solid) +``` +``` +SprSolid () / SprSolid (spriteHandle) +``` + +Where: +- `solid` is an integer value. `True` = solid, `false` = transparent. + +Solid sprites and tiles are drawn as solid rectangular blocks. Transparent ones use the transparency information stored inside the textures (if any) plus the alpha value. + +By default tile maps are solid, and regular sprites are transparent. + +### SprSetParallax, SprParallax +(Applies to sprites and tile maps.) + +Format: +``` +SprSetParallax (parallax) +``` +``` +SprParallax () / SprParallax (sprHandle) +``` + +Where: +- `parallax` is an integer value. `True` = parallax scrolling, `false` = regular scrolling. + +Parallax scrolling uses the Z order information to create a parallax scrolling effect. +Positive Z order values correspond to further away objects, which are displayed as smaller, and scroll slower. +Negative Z order values correspond to closer objects, which are displayed as larger and scroll faster. + +## Sprite animation +You can animate sprites by updating the above properties manually. +However, to simplify things, there are also a number of properties that you can set on a sprite in order to have it animate automatically. + +These include: +- Velocity +- Spin +- Animation speed (animating through frames) + +These properties can only be set on regular sprites! They do not apply to tile maps. + +Once set, these properties will automatically update the sprite every time `AnimateSprites()` is called. + +### AnimateSprites + +Format: +``` +AnimateSprites() +``` + +Updates each sprite, adding velocity to position, spin to angle and animation speed to frame number. + +### AnimateSpriteFrames() + +Format: +``` +AnimateSpriteFrames() +``` + +Like updates each sprite, adding animation speed to frame number. +Similar to AnimateSprites, but updates only the animation frames. Sprites do not move or rotate. + +### SprSetVel, SprSetXVel, SprSetYVel, SprVel, SprXVel, SprYVel +> [!IMPORTANT] +> +> Applies to sprites only! + +Format: +``` +SprSetVel (vector) +SprSetVel (x, y) +SprSetXVel (x) +SprSetYVel (y) +``` +``` +SprVel () / SprVel (spriteHandle) +SprXVel () / SprXVel (spriteHandle) +SprYVel () / SprYVel (spriteHandle) +``` + +Where: +- `vector` is a 2D real valued vector. +- `x` is a real value representing the X component of the velocity. +- `y` is a real value representing the Y component of the velocity. + +A sprite's velocity is added to its position every time `AnimateSprites()` is called, to make the sprite move. + +The default velocity is (0, 0). + +### SprSetSpin, SprSpin +> [!IMPORTANT] +> +> Applies to sprites only! + +Format: +``` +SprSetSpin (spin) +``` +``` +SprSpin () / SprSpin (spriteHandle) +``` +Where: +- `spin` is a real number value. + +A sprite's spin is added to its angle every time `AnimateSprites()` is called, to make a sprite spin. + +### SprSetAnimSpeed, SprAnimSpeed +> [!IMPORTANT] +> +> Applies to sprites only! + +Format: +``` +SprSetAnimSpeed (speed) +``` +``` +SprAnimSpeed () / SprAnimSpeed (spriteHandle) +``` + +Where: +- `speed` is a real number value. + +A sprite's animation speed is added to it's frame every time `AnimateSprites()` is called, to make a sprite animate through its frames. + +A sprite can be set to animate once, or animate in a loop (`SprSetAnimLoop()`). + +### SprSetAnimLoop, SprAnimLoop +> [!IMPORTANT] +> +> Applies to sprites only! + +Format: +``` +SprSetAnimLoop (loop) +``` +``` +SprAnimLoop () / SprAnimLoop (spriteHandle) +``` +Where: +- `loop` is an integer value. `True` = sprite animates in a loop. `False` = sprite animates once then stops. + +By default, sprites animate in a loop. + +### SprAnimDone +> [!IMPORTANT] +> +> Applies to sprites only! + +Format: +``` +SprAnimDone () / SprAnimDone (spriteHandle) +``` + +`SprAnimDone ()` returns true if a sprite has completed its animation (i.e. has reached the last frame.) +(Obviously this does not apply to looped animations, as they never finish!) + +Tile map routines +> [!TIP] +> See CavernDemo.gb for a simple demo of tile maps in action. + +In addition to regular sprites, Basic4GL supports a special type of sprite called a "Tile map". + +A tile map is a 2D grid. Each grid contains a number, which indicates the index of the image that is to be displayed at that point in the grid. +Tile maps are an efficient way of representing large images, often many times higher and wider than the actual screen size. +They are typically used for backgrounds in 2D games like platformers and side scrollers. + +Tile maps are implemented as a special kind of sprite, so many of the functions that operate on general sprites +will also work on tile maps (e.g setting position, size, scale, colour, angle, transparency). +Although not all of them though, so be aware that there are functions that will only operate on regular sprites (and not tile maps), and vice-versa. +The type of sprites that a function will operate on is displayed in red underneath each function description. + +## Creating tile maps + +### NewTileMap +Tile maps are created with `NewTileMap()`. This has the same format as `NewSprite()`. + +Format: +``` +NewTileMap () +NewTileMap (texture) +NewTileMap (textureArray) +``` +Where: +- `texture` is an OpenGL texture. +- `textureArray` is an array of OpenGL textures. + +Each of the 3 functions returns a handle to the new tile map object, that can be used to manipulate it later. + +Like regular sprites, tile maps need to be given textures to indicate what is to be displayed. +You can either pass these textures to the `NewTileMap()` function, +or add them later with the `SprSetTexture(s)` or `SprAddTexture(s)` functions. + +When a tile map is created, it is automatically "bound" (see `BindSprite()` for more info.) + +## Setting up tiles +Once a tile map has been created and has some textures, you need to specify the tiles. +This is the actual 2D grid of numbers, where each number indicates which texture is to be displayed at that point, +where 0 indicates the first texture loaded. + +You setup the grid as a 2D array of integers, and then pass the array to the tile map using SprSetTiles(). + +### SprSetTiles +> [!IMPORTANT] +> +> Applies to tile maps only! + +Format: +``` +SprSetTiles (tilesArray) / SprSetTiles (spriteHandle, tilesArray) +``` + +Where: +- `tilesArray` is a 2D array of integers. + +This loads the tiles into the tile map. Once the tile map has textures and tiles, it is ready to display. + +Example: +``` +data 1, 1, 1, 1 +data 1, 0, 0, 1 +data 1, 0, 0, 1 +data 1, 1, 1, 1 + +' Read in tiles +dim tiles(3)(3), x, y, tileMap +for y = 0 to 3 +for x = 0 to 3 +read tiles (x)(y) +next +next + +' Create tilemap +tileMap = NewTileMap (LoadTexStrip("data\cavernTiles.png", 32, 32)) +SprSetTiles (tiles) +``` + +Note: By default tilemaps repeat infinitely in the horizontal and vertical directions. + +You can now move the tilemap around just like any regular sprite with the standard sprite routines. + +### SprXTiles, SprYTiles + +> [!IMPORTANT] +> +> Applies to tile maps only! + +Occasionally it is useful to know the number of tiles a tile map has across or down. To achieve this you can use the `SprXTiles` or `SprYTiles` functions. + +Format: +``` +SprXTiles () / SprXTiles (spriteHandle) +SprYTiles () / SprYTiles (spriteHandle) +``` + +- `SprXTiles()` returns the number of tiles across (horizontally). +- `SprYTiles()` returns the number of tiles down (vertically). + +### SprSetXRepeat, SprSetYRepeat, SprXRepeat, SprYRepeat + +> [!IMPORTANT] +> +> Applies to tile maps only! + +Format: +``` +SprSetXRepeat (xRepeat) +SprSetYRepeat (yRepeat) +``` +``` +SprXRepeat () / SprXRepeat (spriteHandle) +SprYRepeat () / SprYRepeat (spriteHandle) +``` + +Where: +- `xRepeat` and `yRepeat` are integer values, set to `true` to repeat the tile map infinitely along the given dimension, and false to disable repeating. + +(By default X and Y repeat are on, when the tile map is created.) + +## Copying sprites +You can copy on sprite to another using `CopySprite()`. The target sprite will then become an identical copy of the original. + +### CopySprite +(Applies to sprites and tile maps.) + +Format: +``` +CopySprite (spriteHandle) +``` + +All the properties of the `spriteHandle` sprite are copied to the currently bound sprite, making the two completely identical. + +> [!NOTE] +> +> While you can copy a regular sprite to a regular sprite or a tile map to another tile map, +> you cannot copy a tile map to a sprite or vice-versa. + +Distinguishing Sprite types +You can distinguish a regular sprite from a tile map using the SprType() function. + +### SprType +(Applies to sprites and tile maps.) + +Format: +``` +SprType () / SprType (spriteHandle) +``` + +This returns an integer constant, which will be: + +- `SPR_SPRITE` if the sprite is a regular sprite +- `SPR_TILEMAP` is the sprite is a tile map +- `SPR_INVALID` if the sprite handle does not correspond to a regular sprite or tile map. + +## Setting the sprite area size +You can resize the sprite area, just like you can resize the text area. + +The default sprite area size is 640 x 480, but you can pretty much set it to anything you want. +All sprites and tile maps will be scaled accordingly. + +### ResizeSpriteArea +Format: +``` +ResizeSpriteArea (width, height) +``` + +Where: +- `width` and `height` are real numbers corresponding to the width and height of the sprite area respectively. + +### SpriteAreaWidth, SpriteAreaHeight +Format: +``` +SpriteAreaWidth() +``` +``` +SpriteAreaHeight() +``` + +These functions return the respective width and height of the sprite area (as set by the most recent `ResizeSpriteArea()` call). + +Useful for finding the screen boundaries. + +## The Sprite Camera +Basic4GL has the concept of a sprite "camera". You can use this to create scrolling games, +by setting up your sprites to move around in an area bigger than the screen size, and moving the camera around it. + +This is also an easy way to setup parallax scrolling. + +By enabling parallax mode (`SprSetParallax()`) and setting the Z order to a positive number (`SprSetZOrder()`), +you can cause background sprites and tile maps to appear smaller, and move slower in response to camera movement. + +Example: +``` +TextMode (TEXT_BUFFERED) + +data 1, 1, 1, 1 +data -1,-1,-1, 1 +data -1,-1,-1, 1 +data -1,-1,-1, 1 + +' Read in tiles +dim tiles(3)(3), x, y, tileMap, tileMap2, tileMap3 +dim textures (TexStripFrames ("data\cavernTiles.png", 32, 32) - 1) +for y = 0 to 3 +for x = 0 to 3 +read tiles (x)(y) +next +next + +' Load textures +textures = LoadTexStrip("data\cavernTiles.png", 32, 32) + +' Create tilemaps +tileMap = NewTileMap (textures) +SprSetTiles (tiles) + +tileMap2 = NewTileMap (textures) +SprSetTiles (tiles) +SprSetColor (.75, .5, .5) +SprSetZOrder (100) +SprSetParallax (true) +SprSetAngle (45) + +tileMap3 = NewTileMap (textures) +SprSetTiles (tiles) +SprSetColor (.25, .25, .5) +SprSetZOrder (200) +SprSetParallax (true) + +while true +while SyncTimer (10) +SprCameraSetPos (SprCameraPos () + vec2 (2, .5)) +wend +DrawText () +wend +``` + +### SprCameraSetPos, SprCameraSetX, SprCameraSetY, SprCameraPos, SprCameraX, SprCameraY +Used to move the sprite camera around. All sprites and tilemaps are drawn in relation to the sprite camera. + +Format: +``` +SprCameraSetPos (positionVector) +SprCameraSetX (xPosition) +SprCameraSetY (yPosition) +``` + +``` +SprCameraPos () +SprCameraX () +SprCameraY () +``` + +Where: +- `positionVector` is a 2D vector of real values (e.g dim camVec# (1)). +- `xPosition` and `yPosition` are real values representing the camera's X and Y position respectively. + +> [!NOTE] +> The default camera position is (0, 0). + +### SprCameraSetAngle, SprCameraAngle +As well as moving the camera, you can also rotate it. +All sprites and tile maps are then drawn rotated in relation to the rotation of the camera. + +Format: +``` +SprCameraSetAngle (angle) +``` +``` +SprCameraAngle () +``` + +Where: +- `angle` is a real number value representing the new camera angle in degrees. + +Positive angles rotate the camera anticlockwise, and give the effect of rotating all the sprites and tile maps clockwise. + +### SprCameraSetFov, SprCameraFov +You can also set the field of view used for the parallax scrolling effect. + +Format: +``` +SprCameraSetFov (fov) +``` +``` +SprCameraFov () +``` + +Where: +- `fov` is a real number value representing the new camera field-of-view in degrees. + +The field-of-view must be at least 1 degree wide and no more than 175 degrees. + +Altering the camera's field of view has no effect on sprites or tile maps which are not in parallax mode. + +### SprCameraSetZ, SprCameraZ +Format: +``` +SprCameraSetZ (zPosition) +``` +``` +SprCameraZ () +``` + +Where: +- `zPosition` is a real number value representing the Z position of the camera. + +This can be used to set the sprite camera's Z position. It affects parallax sprites and tile maps only. +Positive values of zPosition make the camera appear to move forward. Negative values of zPosition make the camera appear to move backwards. + +Example: +``` +TextMode (TEXT_BUFFERED) + +data 1, -1, -1, -1 +data 1, -1, -1, -1 +data 1, -1, -1, -1 +data 1, -1, -1, -1 +dim tiles(3)(3), x, y +for y = 0 to 3: for x = 0 to 3: read tiles (x)(y): next: next + +dim textures(TexStripFrames("data\cavernTiles.png", 32, 32) - 1) +textures = LoadTexStrip("data\cavernTiles.png", 32, 32) + +const numLayers = 5 +dim layers(numLayers), i +for i = 1 to numLayers +layers(i) = NewTileMap (textures) +SprSetTiles (tiles) +SprSetYRepeat (false) +SprSetZOrder (i * 100) +SprSetY (240 - 64) +SprSetSolid (false) +SprSetParallax (true) +next + +while true +SprCameraSetZ (SprCameraZ () + 1) +DrawText () +wend +``` + +## Credits +Basic4GL, Copyright (C) 2003 Tom Mulgrew + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen \ No newline at end of file diff --git a/docs/standard-function-guide.md b/docs/standard-function-guide.md new file mode 100644 index 00000000..b87c6056 --- /dev/null +++ b/docs/standard-function-guide.md @@ -0,0 +1,291 @@ +# Programmer's Guide: Standard Functions + +General purpose math, string, and timing functions. + +## General purpose functions +These functions are used for general purpose operations, such as mathematics equations and string manipulation. + +### abs +Abs(x) returns the absolute value of x. + +### arraymax +`ArrayMax(array)` returns the index of the highest element of array. + +Iterating elements `0..ArrayMax(array)` will therefore visit every element inside the array. +`ArrayMax` is a special function in that array can be any type, so long as it is an array. + +### asc +`Asc(x)` takes a single string parameter `x`, and returns the ASCII value of the first character. + +This is the opposite of the `chr$` function + +### atn +`Atn(x)` returns the Arc Tangent value of `x`, in radians. + +### atnd +`Atnd(x)` returns the Arc Tangent value of `x`, in degrees. + +### atn2 +`Atn2(x, y)` returns the Arc Tangent value of `x`, `y`, in radians. + +### atn2d +`Atn2(x, y)` returns the Arc Tangent value of `x`, `y`, in degrees. + +### beep +`Beep()` causes the computer to beep. + +> [!IMPORTANT] +> +> `Beep()` does nothing in the current version of Basic4GLj + +### chr$ +`Chr$(x)` takes a single integer parameter `x`, and returns a string character whose ASCII value is `x`. + +Example: +``` +Printr Chr$(72)+Chr$(101)+Chr$(108)+Chr$(108)+Chr$(111) +``` + +### cos +`Cos(x)` returns the Cosine of `x`, where `x` is measured in radians. + +### cosd +`Cosd(x)` returns the Cosine of `x`, where `x` is measured in degrees. + +### exp +`Exp(x)` returns `e` raised to the power of `x`. + +`Exp` is the inverse of `Log`. + +### int +`Int(x)` casts a real valued x to an integer. + +> [!IMPORTANT] +> +> The rounding is slightly different to the implicit type cast when a real value is assigned to an integer. +> +> `Int(x)` rounds `x` towards negative infinity, whereas implicit type casting always rounds towards `0`. + +Example: +``` +dim a#, i1, i2: a# = -5.1 +i1 = a# +i2 = Int(a#) +printr "i1 = " + i1 +printr "i2 = " + i2 +``` + +### left$ +`Left$(s,c)` returns a string containing the first `c` characters of `s`. +`s` is a string value, `c` is an integer value. + +For example, `Left$("ABCDEFG", 3)` returns `ABC` + +### lcase$ +`LCase$ (x)` returns `x` converted to lowercase. + +### len +`Len(x)` returns the length of the string `x` in characters. + +### log +`Log(x)` returns the natural logarithm of `x`. + +`Log` is the inverse of `Exp`. + +### mid$ +`Mid$(s,i,c)` returns a string containing `c` consecutive characters of string `s`, starting from the `i`th character. + +For example, `Mid$("ABCDEFG", 4, 3)` returns `"DEF"`. + +### performancecounter +`PerformanceCounter()` returns the number of milliseconds that have elapsed since the computer was turned on. + +This function is very similar to `TickCount()`, +except `PerformanceCounter()` is accurate to _1 millisecond_ whereas `TickCount()` is only accurate to _10ms_. + +Therefore, I strongly recommend using `PerformanceCounter()` for any timing operations. + +The old `TickCount()` function is retained only for backwards compatibility with existing Basic4GL programs. + +### pow +`Pow(x,y)` returns `x` raised to the power of `y`. + +### right$ +`Right$(s,c)` returns a string containing the last `c` characters of `s`. + +For example, `Right$("ABCDEFG", 3)` returns `"EFG"` + +### rnd +`Rnd()` returns a random integer value, between `0` and `RND_MAX`. +(`RND_MAX = 32767`, but could be different in future ports of Basic4GL to different platforms or operating systems.) + +> [!IMPORTANT] +> +> The Basic4GL for Java port uses `RND_MAX = 32767` for random behavior compatibility with previous versions of Basic4GL. + +To return a random number between `0` and `x-1` (inclusive), use: +``` +Rnd() % x +``` + +To return a random number between `1` and `x` (inclusive), use: +``` +Rnd() % x + 1 +``` + +### sgn +`Sgn(x)` returns: + +- `1`, if `x` is greater than `0` +- `0`, if `x` equals `0` +- `-1`, if `x` is less than `0` + +### sin +`Sin(x)` returns the Sine of `x`, where `x` is measured in radians. + +### sind +`Sind(x)` returns the Sine of `x`, where `x` is measured in degrees. + +### sqr +`Sqr(x)` returns the square root of `x`. + +(Actually the square root of the absolute value of `x`.) + +### sqrt +`Sqrt(x)` is exactly the same as `Sqr(x)` + +### str$ +`Str$(x)` converts an integer value `x` into a string representation of `x`. + +For example, `Str$(-13.4)` returns `"-13.4"`. + +### tan +`Tan(x)` returns the Tangent of `x`, where `x` is measured in radians. + +### tand +`Tand(x)` returns the Tangent of `x`, where `x` is measured in degrees. + +### tanh +`Tanh(x)` returns the Hyperbolic Tangent of `x`, where `x` is measured in radians. + +### tickcount +`TickCount()` returns the number of milliseconds that have elapsed since the computer was turned on. + +> [!NOTE] +> +> This function is only accurate to about 10ms. I strongly advise using `PerformanceCounter()` instead. + +### ucase$ +`UCase$ (x)` returns `x` converted to uppercase. + +### val +`Val(x)` converts a string `x` into a numeric value. +If `x` cannot be converted into a number, then `Val(x)` returns `0`. + +For example, `Val("27.2")` returns `27.2`. + +`Val` is the opposite of `Str$`. + +## Timing +### Sleep +Pauses execution for a number of milliseconds. + +Format: +``` +Sleep (milliseconds) +``` + +> [!NOTE] +> +> The application is completely unresponsive while sleeping. +> +> Therefore, Basic4GL will not sleep for more than 5000 milliseconds (5 seconds) at a time. + +To sleep for more than 5 seconds, use a loop. + +For example: +``` +Dim i +For i = 1 to 60: Sleep (1000): Next +Will pause for 60 seconds, but still give the user the opportunity to break out of the program if he/she wishes. +``` + +## WaitTimer, SyncTimer and ResetTimer + +### WaitTimer + +This function is similar to `Sleep`, and indeed has the same format: +``` +WaitTimer (milliseconds) +``` + +The difference is that `WaitTimer` waits until `milliseconds` milliseconds has elapsed from the previous `WaitTimer` call. + +This difference is significant if `WaitTimer` is used inside an animation loop, +with other code that may take some time to execute (such as rendering a frame). + +For example: +``` +While true +Draw a frame +WaitTimer (100) +Wend +``` + +If _Draw a frame_ were to take `40` milliseconds, then `WaitTimer` will pause for only `60` milliseconds, +ensuring that the loop is correctly iterated `10` times a second. + +Even simple animations can potentially take up to the resync period of the monitor (anything from 1/100th to 1/50th of a second), +if the user's graphics card is configured to wait for retrace before drawing. + +### SyncTimer +`SyncTimer` returns true if you need to update the internal state of the application to catch up to the clock. + +This can be used to force an animation to update internally so many times per second, regardless of a PC's rendering speed, and is intended to be used as follows: +``` +While main-loop-condition +Render scene +While SyncTimer (delay) +Update state +Wend +``` + +For example, if delay was `10` milliseconds, then Update state will execute `100` times per second, +regardless of whether the computer is capable of rendering `20` or `100` frames per second. + +Example: +``` +dim x, y, a#, b# +while true +glClear (GL_DEPTH_BUFFER_BIT or GL_COLOR_BUFFER_BIT) +glLoadIdentity () +glTranslatef (0, 0, -16) +glRotatef (a#, 0, 0, 1) +for y = -5 to 5: for x = -5 to 5 +glPushMatrix () +glTranslatef (x * 3, y * 3, 0) +glRotatef ((x + y) * 60 + b#, 1, 0, 0) +glBegin (GL_QUADS) +glColor3f (1, 0, 0): glVertex2f ( 1, 1) +glColor3f (0, 1, 0): glVertex2f (-1, 1) +glColor3f (0, 0, 1): glVertex2f (-1,-1) +glColor3f (1, 1, 1): glVertex2f ( 1,-1) +glEnd () +glPopMatrix () +Next: Next +SwapBuffers () +while SyncTimer (10) +a# = a# + 0.9: b# = b# + 3.6 +wend +wend +``` + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +_Programmer's guide_ + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen \ No newline at end of file diff --git a/docs/text-output-guide.md b/docs/text-output-guide.md new file mode 100644 index 00000000..cd21d7fd --- /dev/null +++ b/docs/text-output-guide.md @@ -0,0 +1,478 @@ + +# Programmer's Guide: Text Output + +## Basic text output +### Print +Basic text output is performed using the `Print` or `Printr` function. + +Format: +``` +Print text-parameters ; +``` + +Or: +``` +Print text-parameters +``` + +Where text-parameters is a list of parameters, separated by semicolons (`;`). + +`Print` leaves the cursor positioned after the last character printed. + +`Printr` will automatically move the cursor to the start of the next line after the text has been printed. +If the cursor reaches the bottom of the screen, the text will scroll up the screen to make room for the new line. + +Example: +``` +Printr "Hello "; +Printr "and welcome to "; +Printr "Basic4GL" +Print +Print "Have a nice day" +``` + +#### Traditional BASIC syntax +`Print` behaves slightly differently when "traditional BASIC" syntax is enabled, or "Basic4GL with traditional print" syntax is enabled. + +You can enable the "traditional BASIC" syntax by placing a +``` +language traditional +``` + +line at the top of your program, or +``` +language traditional_print +``` + +for just the print command syntax. + +In this mode, the `print` command will move the cursor to the next line if it does not end with a trailing semicolon (`;`) + +For example: + +``` +language traditional +print "Line 1" +print "Line 2" +print "Line 3" +``` + +Will print: +> Line1 +> +> Line2 +> +> Line3 + +If the `print` command does end with a semicolon, then the cursor will remain on the same line. So: +``` +language traditional +print "Welcome "; +print "to "; +print "Basic4GL" +``` + +Will print: +> Welcome to Basic4GL + +Thus, the `printr` command is not required in this syntax (but it is still available for compatibility sake). + +### Locate +Locate positions the text cursor on the screen. + +Format: +``` +Locate X-position, Y-position +``` + +The Basic4GL text cursor is invisible. It determines to where on the screen `Print` and `Printr` will write. + +By default, the Basic4GL displays 40 characters across by 25 characters down. + +> [!TIP] +> +> This can be changed using the `ResizeText()` function. + +- The topmost row is row 0. +- The leftmost column is column 0. + +Example: +``` +Dim d# +While True +Cls +Locate Sin (d#) * 15 + 18, 10 +Print "Hello" +Sleep (100) +d# = d# + 0.1 +Wend +``` + +### CursorCol, CursorRow +`CursorCol()` returns the column the cursor is on. + +`CursorRow()` returns the row the cursor is on. + +- The topmost row is row 0. +- The leftmost column is column 0. + +### Color +Sets the text colour. + +Format: +``` +Color (red, green, blue) +``` + +Where `red`, `green` and `blue` are integers between `0` and `255` inclusive indicating the intensity of their respective colour component. + +Once the text colour is set, any text printed will be in that colour until the text colour is changed. + +Example: +``` +dim t +TextMode (TEXT_BUFFERED) +while true +for t = 1 to 10: color (rnd()%255, rnd()%255, rnd()%255): print chr$(rnd()%255): next +DrawText () +wend +``` + +### Cls +`Cls` clears all text from the screen and repositions the cursor to the top left. + +### ClearLine +`ClearLine ()` clears the current line (the one which the cursor is on). + +Example: +``` +dim i +SetTextScroll (false) +for i = 0 to 24: printr i: next +locate 0, 10 +ClearLine () ' Line 10 is cleared +``` + +### ClearRegion +Clears a rectangular region of the screen. + +Format: +``` +ClearRegion (x1, y1, x2, y2) +``` + +Where `x1`, `y1`, `x2`, `y2` are integers that define the top left column and row (`x1`, `y1`) +and the bottom right column and row (`x2`, `y2`) of the rectangular region to be cleared. + +Example: +``` +dim x, y +SetTextScroll (false) +TextMode (TEXT_BUFFERED) +for y = 1 to TextRows () +for x = 1 to TextCols () +print "#" +next +next +ClearRegion (5, 5, 35, 9) +locate 13, 7: print "Cleared region" +DrawText () +``` + +### TextRows, TextCols and ResizeText +`TextRows ()` returns the number of text columns. + +`TextCols ()` returns the number of text rows. + +`ResizeText (x, y)` resizes the text display to `y` rows by `x` columns and clears the text. + +Example: +``` +dim i, a$ +a$ = "Basic4GL" +i = 100 +while i >= 4 +ResizeText (i * 2 + 1, i + 1) +Locate (TextCols() - Len(a$)) / 2, TextRows() / 2 +Print a$ +Sleep (50) +i = i - 2 +wend +``` + +## Text scrolling +Advancing the cursor past the end of the line causes it to wrap around onto the next line. + +Advancing the cursor past the end of the bottom-most line, +or performing a `Printr` on the bottom-most line causes the text to scroll up by one line. + +Example 1: +``` +Print glGetString (GL_EXTENSIONS) +``` + +Example 2: +``` +dim d# +while true +locate sin(d#)*15+17, TextRows()-1 +Printr "Hello" +Sleep (50) +d# = d# + 0.3 +wend +``` + +Alternatively you can disable text scrolling with the `TextScroll` command. + +### SetTextScroll +`SetTextScroll ()` enables or disables text scrolling when the cursor reaches the bottom of the text screen. + +Format: +``` +SetTextScroll (scroll) +``` + +Where `scroll` can equal `true` to enable text scrolling or `false` to disable it. + +> [!IMPORTANT] +> +> Text scrolling is enabled by default. + +Example: +``` +SetTextScroll (false) +dim row +print "########################################" +for row = 2 to 24 +print "# #" +next +print "########################################" +``` + +### TextScroll +`TextScroll ()` returns `true` if text scrolling is enabled, or `false` if it isn't. + +## Fonts +Basic4GL fonts are special transparent images, consisting of a 16 x 16 grid of characters. + +You can set a new font by calling: + +### Font (texture) + +Where texture is an OpenGL texture handle (usually returned from `LoadTex()`). + +Example: +``` +printr "Normal font" +dim texture +texture = LoadTex("data\charset2.png") +Font (texture) +printr "charset2.png font" +``` + +To get the texture handle for the default font, call: +``` +DefaultFont () +``` + +Example: +``` +dim texture +texture = LoadTex("data\charset2.png") +Font (texture) +printr "charset2.png font" +Font (DefaultFont ()) +printr "Normal font" +``` + +## Text modes +Basic4GL has 3 different modes for rendering text on the screen. You choose one by executing the appropriate `TextMode()` call: + +1. `TextMode (TEXT_SIMPLE)` +2. `TextMode (TEXT_BUFFERED)` +3. `TextMode (TEXT_OVERLAID)` + +> [!IMPORTANT] +> +> The default mode is TEXT_SIMPLE. + +In `TEXT_SIMPLE` mode, Basic4GL redraws the screen after each `Print`, `Printr`, `Cls` or `ResizeText()`. + +This mode is easy to use, and the results are instant. + +However, there are a number of situations where you may find it favourable to use `TEXT_BUFFERED`. + +In `TEXT_BUFFERED` mode, Basic4GL does not update the screen until you call `DrawText ()`. +This has advantages if you are animating a large amount of text: + +- Reduces flicker. +- The screen is only updated once all text has been drawn. +- Reduces screen resync delay. +- Depending on your video card and OpenGL settings, your OpenGL system may wait for vertical synchronization before every screen update. +- This can lead to unnecessarily slow animations in `TEXT_SIMPLE` mode, as Basic4GL must stop and wait for vertical resync after every `Print` statement. However, you must remember to call `DrawText()` or the user won't see any changes. + +Example: +``` +TextMode (TEXT_BUFFERED) +dim d#, t +while true +for t = 1 to 10 +Locate sin(d#*t/19.0+t)*14+14,t*2+1 +print " Thing " +next +DrawText () +Sleep (10) +d# = d# + .1 +wend +``` + +`TEXT_OVERLAID` mode is used to combine OpenGL graphics with text. +This mode is necessary if you wish to use OpenGL graphics commands and text at the same time. + +This would cause problems in `TEXT_SIMPLE` or `TEXT_BUFFERED` mode, as both modes automatically clear the screen before rendering the text. + +In `TEXT_OVERLAID` mode the `DrawText()` function will not clear the screen, or copy the result to the front buffer. It will simply render the current text transparently over the top of the current scene. +You must therefore manually clear the screen and swap it to the font buffer at the appropriate times. + +The advantage of this mode is that it gives you a finer degree of control, and allows you to combine text and other graphics, such as OpenGL rendered objects. + +Example: +``` +TextMode (TEXT_OVERLAID) +locate 12, 12: print "This is a square" + +dim a# +while true +glClear (GL_DEPTH_BUFFER_BIT or GL_COLOR_BUFFER_BIT) +glLoadIdentity () +glTranslatef (0, 0, -2) +glRotatef (a#, 0, 0, 1) +glBegin (GL_QUADS) +glColor3f (1, 0, 0): glVertex2f ( 1, 1) +glColor3f (0, 1, 0): glVertex2f (-1, 1) +glColor3f (0, 0, 1): glVertex2f (-1,-1) +glColor3f (1, 1, 1): glVertex2f ( 1,-1) +glEnd () +DrawText () +SwapBuffers () +a# = a# + 0.3 +wend +``` + +### DrawText + +Format: +``` +DrawText() +DrawText(flags) +``` +The `DrawText` command is used to draw text and/or sprites. The default (no parameter) version draws all text and sprites that are on the screen. + +Alternatively you can control what it draws by passing it a bitmask composed of one or more of the following flags: + +| Flag | Description | +|----------------------|-----------------------------------------------| +| DRAW_TEXT | Draw text | +| DRAW_SPRITES_BEHIND | Draw all sprites behind the text | +| DRAW_SPRITES_INFRONT | Draw all sprites infront of the text | +| DRAW_SPRITES | Draw all sprites behind or infront of the text | + +Example: +``` +TextMode(TEXT_OVERLAID) +glDisable(GL_DEPTH_TEST) + +' Create some bouncing balls +const ballcount = 100 +dim tex = LoadTex("data/ball.png") +dim sprites(ballcount), i + +for i = 1 to ballcount + sprites(i) = NewSprite(tex) + if rnd()%2 then SprSetZOrder(-1) endif + SprSetPos(rnd() % 640, rnd() % 480) + if rnd()%2 then SprSetXVel(1) else SprSetXVel(-1) endif + if rnd()%2 then SprSetYVel(1) else SprSetYVel(-1) endif +next + +do + ' Clear the screen background + glBegin(GL_QUADS) + glColor3f(.5, 0, 0) + glVertex3f(-10, 10, -5) + glVertex3f( 10, 10, -5) + glColor3f(0, 0, .5) + glVertex3f( 10, -10, -5) + glVertex3f(-10, -10, -5) + glEnd() + + ' Draw behind sprites and small text + ResizeText(80, 50) + locate 35, 20: print "Small text" + DrawText(DRAW_SPRITES_BEHIND or DRAW_TEXT) + + ' Draw large text and infront sprites + ResizeText(20, 12) + locate 6, 7: print "Big text" + DrawText(DRAW_TEXT or DRAW_SPRITES_INFRONT) + ' Show completed frame + SwapBuffers() + + ' Animate bouncing balls + while SyncTimer(10) + AnimateSprites() + for i = 1 to ballcount + BindSprite(sprites(i)) + if SprX() < 0 or SprX() > 640 then + SprSetXVel(-SprXVel()) + endif + if SprY() < 0 or SprY() > 480 then + SprSetYVel(-SprYVel()) + endif + next + wend +loop +``` + +## Reading from the screen + +### CharAt$ +`CharAt$(x, y)` returns the character at column `x` and row `y`. + +Example: +``` +TextMode(TEXT_BUFFERED) + +dim d#, t, x, y, crash: crash = false: x = TextCols()/2 + +while not crash + for t = 1 to 5: locate sin(d#+t)*15+15,t*2+2: print" Thing! ": next + + y=y-1 + + if y<0 then + y = TextRows()-1: cls + else + if ScanKeyDown(VK_LEFT) and x > 2 then x = x - 1 endif + if ScanKeyDown(VK_RIGHT) and x < 36 then x = x + 1 endif + + crash = CharAt$(x,y)<>" " + locate x, y: print"X" + endif + + DrawText() + + WaitTimer (80) + d# = d#+0.06 +wend +``` + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +_Programmer's guide_ + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen \ No newline at end of file diff --git a/docs/trigonometry-function-guide.md b/docs/trigonometry-function-guide.md new file mode 100644 index 00000000..3b77f118 --- /dev/null +++ b/docs/trigonometry-function-guide.md @@ -0,0 +1,272 @@ +# Programmer's Guide: Trigonometry Functions + +> [!TIP] +> +> For additional math routines like `cos`, `sin`, `tan` and `log`, see the [Standard Function Guide +](https://github.com/NateIsStalling/Basic4GLj/wiki/Standard-Function-Guide) + +## Vector and Matrix routines +Basic4GL contains built in support for matrix and vector arithmetic, through a library of trigonometry functions, and also through extensions to standard mathematical operators (+, -, * e.t.c) to work with vector and matrix types. + +### Vector storage format +Vectors are stored as an array of reals. For example: +``` +dim vec#(3) +vec# = vec4 (1, 2, 3, 1) ' Create a vector and assign it to vec# +``` + +To be eligible for use with the built-in trigonometry functions, the array must have 2, 3 or 4 elements. (Remember that declaring an array as size 3 actually results in 4 elements, 0 through 3 inclusive). + +Element `0` stores the `x` component, element `1` stores `y` component, `2` stores `z` and `3` stores `w`. + +Certain trigonometry functions that operate on 4 component vectors will automatically substitute `z = 0` and/or `w = 1` when short version vectors are passed in. + +### Matrix storage format +A matrix is a 4 x 4 array of reals, and must always be "DIM"med as: +``` +matrixname#(3)(3) +``` + +Example: +``` +dim matrix#(3)(3) +matrix# = IdentityMatrix () ' Assign a matrix to matrix# +``` +The first array dimension corresponds to the x coordinate of the matrix, and the second to the y. + +Basic4GL vector and matrix storage format and operations are designed to mirror those of OpenGL. +As such vectors are multiplied as column vectors on the right hand side of matrices. Matrices are stored as an array of column vectors. + +## Creating vectors +Vectors are just arrays, so you can read from and write to them like any other array. +``` +dim v#(3), i +for i = 0 to 3: v#(i) = i: next ' Create a (0 1 2 3) vector +dim v1#(3), v2#(3), dotProd# +dotProd# = v1#(0)*v2#(0) + v1#(1)*v2#(1) + v1#(2)*v2#(2) +' Calculate the vector dot product +' (Note: we could also have said dotProd# = v1# * v2#) +``` + +However, there are a set of routines for creating vectors quickly and simply: + +### vec4, vec3 and vec2 +`vec4(x, y, z, w)` returns a 4 component vector with `x`, `y`, `z` and `w` components initialised accordingly. + +`vec3(x, y, z)` returns a 3 component vector with `x`, `y` and `z` components initialised accordingly. + +`vec2(x, y)` returns a 2 component vector with `x` and `y` components initialised accordingly. + +Examples: + +``` +dim lightsource#(3) +lightsource# = vec4(0, 100, 0, 1) ' Lightsource at (0 100 0) +``` + +This is exactly equivalent to: +``` +dim lightsource#(3) +lightsource#(0) = 0 +lightsource#(1) = 100 +lightsource#(2) = 0 +lightsource#(3) = 1 +``` +The first version is simply a more compact alternative. + +## Extended mathematics operators +Certain mathematics operators have been extended to accept vectors and or matrices as input, and (where appropriate) return a vector or a matrix as a result. + +- _vec_ = A vector +- _matrix_ = A matrix +- _real_ = A real value + +| Expression | Result | +|-------------------|------------------------------------------------------------------------------------------------------------| +| -vec | Returns vec negated. That is vec scaled by -1 | +| -matrix | Returns matrix negated. I.e matrix scaled by -1 | +| vec * real | Returns vector scaled by real | +| real * vec | Returns vector scaled by real | +| matrix * real | Returns matrix scaled by real | +| real * matrix | Returns matrix scaled by real | +| matrix * vec | Returns vec multiplied as a column vector on the right hand side of matrix. The result is another vector. | +| matrix1 * matrix2 | Returns matrix2 multiplied on the right hand side of matrix1. The result is another matrix. | +| vec1 * vec2 | Returns the dot product of vec1 and vec2, as a real value. | +| vec / real | Returns vec scaled by 1 / real | +| matrix / real | Returns matrix scaled by 1 / real | +| vec1 + vec2 | Returns vec2 added to vec1 as a vector | +| matrix1 + matrix2 | Returns matrix2 added to matrix1 as matrix | +| vec1 - vec2 | Returns vec2 subtracted from vec1 as a vector | +| matrix1 - matrix2 | Returns matrix2 subtracted from matrix1 as a matrix | + +## Matrix creation functions +These are based on the OpenGL matrix functions (`glTranslate-`, `glRotate-`, e.t.c). + +### MatrixZero +`MatrixZero ()` returns a matrix where every element is zero. + +``` +dim m#(3)(3) +m# = MatrixZero () +``` + +### MatrixIdentity +`MatrixIdentity ()` returns the identity matrix. + +### MatrixScale +`MatrixScale (scale)` returns a scale matrix + +### MatrixTranslate +`MatrixTranslate (x, y, z)` returns a translation matrix. + +### MatrixRotateX, MatrixRotateY and MatrixRotateZ +`MatrixRotateX (angle)` returns a matrix that rotates anticlockwise around the positive X axis by `angle` degrees. + +Likewise, `MatrixRotateY (angle)` and `MatrixRotateZ (angle)` return matrices that rotate around their respective axes. + +There is no function for creating a rotation matrix around an arbitrary axis (like `glRotate-` in OpenGL) because I'm not smart enough! :-) (If anyone wants to send me the maths, I'll add one...) + +### MatrixBasis +`MatrixBasis (vecx, vecy, vecz)` creates a matrix from 3 basis vectors. + +### MatrixCrossProduct +`MatrixCrossProduct (vec)` creates a cross product matrix for `vec`. + +This matrix has the property that when multiplied with a vector `v`, the result is `vec x v`. + +That is the cross product of `vec` and `v`. + +## Using Matrices with OpenGL + +### glLoadMatrixf, glMultMatrixf +You can copy a standard matrix into OpenGL, replacing the perspective, model-view or texture matrix (whatever was last selected by `glMatrixMode ()`). +You can also multiply the current OpenGL matrix with a standard matrix. +The new matrix will transform vertices passed to OpenGL (or texture coordinates for the texture matrix), just as if you had built the matrix with `glRotate-`, `glTranslate-`, `glScale-`,... commands. + +`glLoadMatrixf (matrix)` will replace the current OpenGL matrix with matrix. + +`glMultMatrixf (matrix)` will multiply the current OpenGL matrix by matrix. The resulting matrix replaces the previous OpenGL matrix. + +(Note: `glLoadMatrixd` and `glMultMatrixd` also work. However, as Basic4GL works with floats internally rather than doubles, there is no particular advantage in using these functions.) + +#### Examples: +The following examples all draw a square 10 units "into the screen", rotated anticlockwise by 20 degrees. + +##### Example 1. + +``` +' Standard OpenGL matrix routines +glLoadIdentity () +glTranslatef (0, 0, -10) +glRotatef (20, 0, 0, 1) +glBegin (GL_QUADS) +glVertex2f (-1, 1): glVertex2f (-1, -1): glVertex2f (1, -1): glVertex2f (1, 1) +glEnd () +``` + +##### Example 2. + +``` +' Using glMultMatrixf to multiply in basic matrices +glLoadMatrixf (MatrixIdentity ()) +glMultMatrixf (MatrixTranslate (0, 0, -10)) +glMultMatrixf (MatrixRotateZ (20)) +glBegin (GL_QUADS) +glVertex2f (-1, 1): glVertex2f (-1, -1): glVertex2f (1, -1): glVertex2f (1, 1) +glEnd () +``` + +##### Example 3. + +``` +' Build a complete matrix and load into OpenGL in one go +glLoadMatrixf (MatrixTranslate (0, 0, -10) * MatrixRotateZ (20)) +glBegin (GL_QUADS) +glVertex2f (-1, 1): glVertex2f (-1, -1): glVertex2f (1, -1): glVertex2f (1, 1) +glEnd () +``` + +##### Example 4. + +``` +' Matrix stored in a variable +dim m#(3)(3) +m# = MatrixTranslate (0, 0, -10) * MatrixRotateZ (20) +glLoadMatrixf (m#) +glBegin (GL_QUADS) +glVertex2f (-1, 1): glVertex2f (-1, -1): glVertex2f (1, -1): glVertex2f (1, 1) +glEnd () +Alternatively we could simply transform the vertices before passing them to OpenGL + +dim m#(3)(3) +m# = MatrixTranslate (0, 0, -10) * MatrixRotateZ (20) +glBegin (GL_QUADS) +glVertex3fv (m# * vec3(-1, 1, 0)) +glVertex3fv (m# * vec3(-1, -1, 0)) +glVertex3fv (m# * vec3(1, -1, 0)) +glVertex3fv (m# * vec3(1, 1, 0)) +glEnd () +``` + +Which works just as well. +However, keep in mind that if we perform the transformations ourselves we deny OpenGL the opportunity to perform the transformations, and make use of any optimisations such as hardware transformations supported on modern 3D graphics cards. + +## Other trigonometry functions + +### CrossProduct +`CrossProduct (vec1, vec2)` returns the vector cross product of `vec1` and `vec2`. +The result is a vector. + +### Length +`Length (vec)` returns the length of `vec`. +This is equivalent to `sqr(vec*vec)` + +### Normalize +`Normalize (vec)` returns `vec` scaled to length `1`. +This is equivalent to `vec / Length(vec)` + +### Determinant +`Determinant (matrix)` returns the matrix determinant of `matrix`. The result is a real value. + +### Transpose +`Transpose (matrix)` returns `matrix` transposed. (That is matrix mirrored about the diagonal.) + +### RTInvert +`RTInvert (matrix)` returns `matrix` inverted, for any matrix containing only rotations and translations. +If matrix contains any other transformations apart from rotations and translations then the result is undefined, and will not be the inverse of `matrix`. + +### Orthonormalize +`Orthonormalize (matrix)` returns an orthonormal matrix by performing a series of normalizations and cross products on the basis vectors of `matrix`. + +This is useful for matrices that are nearly orthonormal. For example to ensure a matrix (that should be orthonormal) hasn't accumulated rounding errors after a large number of transformations. + +## Handling the w coordinate +Some of the above functions (such as `CrossProduct`) and operators (such as `+`) take two vectors and return a single result vector. + +Basic4GL sets the `w` coordinate of the resulting vector as follows: + +- If `w = 0` for both input vectors, then `w = 0` for the resulting vector +- Otherwise `w` is set to `1` + +If this is not the behaviour that you want, you will have to set the `w` coordinate manually. + +There is no special treatment of `w` when multiplying a vector by a matrix, `w` is calculated like any other component. + +You will need to divide through by `w` manually if this is the behaviour you require. +``` +dim vec#(3), matrix#(3)(3) +... +vec# = matrix# * vec# ' Multiply vector by matrix +vec# = vec# / vec#(3) ' Divide through by w +``` + + +## Credits +Basic4GL, Copyright (C) 2003-2007 Tom Mulgrew + +_Programmer's guide_ + +26-Jul-2008 +Tom Mulgrew + +Documentation modified for Markdown formatting by Nathaniel Nielsen \ No newline at end of file From 1462c3e6da4f524034a8b6ab3e58ab688264772a Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Thu, 16 Jul 2026 02:00:38 -0400 Subject: [PATCH 29/38] move samples and docs --- app/build.gradle | 8 +++++ .../command-line-function-guide.md | 0 docs/{ => basic4gl}/compatibility-notes.md | 0 docs/{ => basic4gl}/file-io-guide.md | 0 docs/{ => basic4gl}/index.md | 0 .../keyboard-mouse-joystick-guide.md | 0 docs/{ => basic4gl}/language-syntax-guide.md | 0 docs/{ => basic4gl}/network-engine-guide.md | 0 docs/{ => basic4gl}/opcode-reference.md | 0 docs/{ => basic4gl}/opengl-guide.md | 0 .../programmers-guide-command-line.md | 0 .../runtime-compilation-guide.md | 0 docs/{ => basic4gl}/sound-guide.md | 0 docs/{ => basic4gl}/sprite-library-guide.md | 0 .../{ => basic4gl}/standard-function-guide.md | 0 docs/{ => basic4gl}/text-output-guide.md | 0 .../trigonometry-function-guide.md | 0 .../lwjgl}/lwjgl-release-custom-zip.json | 0 .../dist/docs => docs/lwjgl}/lwjgl_build.txt | 0 .../dist/docs => docs/lwjgl}/lwjgl_readme.txt | 0 language-adapter/build.gradle | 31 ++++++++++++++++++ .../Programs/AsteroidDemo.gb | 0 .../Programs/AsteroidDemo2.gb | 0 .../samples => samples}/Programs/Boom02.gb | 0 .../Programs/BounceClient.gb | 0 .../Programs/BounceServer.gb | 0 .../samples => samples}/Programs/Camera.gb | 0 .../Programs/CavernDemo.gb | 0 .../samples => samples}/Programs/Corridoor.gb | 0 .../Programs/CubePyramidDemo.gb | 0 .../Programs/Data/Ball.png | Bin .../Programs/Data/Ball.tga | Bin .../Programs/Data/Bumps.bmp | Bin .../Programs/Data/CavernTiles.png | Bin .../Programs/Data/Crate.bmp | Bin .../Programs/Data/Cube.bmp | Bin .../Programs/Data/Explode.png | Bin .../Programs/Data/Explode.tga | Bin .../Programs/Data/F117.png | Bin .../Programs/Data/F117.tga | Bin .../Programs/Data/FB01.png | Bin .../Programs/Data/Font.bmp | Bin .../Programs/Data/GLASS.png | Bin .../Programs/Data/GLASS.tga | Bin .../samples => samples}/Programs/Data/Mud.bmp | Bin .../Programs/Data/NeHe.bmp | Bin .../Programs/Data/Star.bmp | Bin .../Programs/Data/StickDude.png | Bin .../samples => samples}/Programs/Data/Tim.bmp | Bin .../Programs/Data/asteroid.png | Bin .../Programs/Data/asteroid.tga | Bin .../Programs/Data/charset2.png | Bin .../Programs/Data/spaceTiles.png | Bin .../samples => samples}/Programs/Explode2.gb | 0 .../Programs/Files/Cyberdemon.PCX | Bin .../Programs/Files/HILLS01.DAT | 0 .../Programs/Files/Mastermind.PCX | Bin .../Programs/Files/Spaceship.bgm | Bin .../Programs/Files/World.txt | 0 .../Programs/Files/cyberdemon.MD2 | Bin .../Programs/Files/mastermind.MD2 | Bin .../samples => samples}/Programs/Flames01.gb | 0 .../samples => samples}/Programs/Flyaround.gb | 0 .../Programs/Flyaround2.gb | 0 .../Programs/Heightmap1.gb | 0 .../Programs/Heightmap2.gb | 0 .../Programs/Heightmap3.gb | 0 .../Programs/Heightmap4.gb | 0 .../Programs/Heightmap5.gb | 0 .../Programs/Heightmap6.gb | 0 .../Programs/Include/BGM/BGM.inc | 0 .../Programs/Include/MD2/MD2.inc | 0 .../Programs/Include/MD2/MD2Routines.inc | 0 .../Programs/Include/MD2/MD2Structures.inc | 0 .../Programs/Include/Misc.inc | 0 .../Programs/Include/Text.inc | 0 .../Programs/Include/TextToHex.inc | 0 .../Programs/IncludeExample.gb | 0 .../samples => samples}/Programs/Koch02.gb | 0 .../Programs/LitMD2Viewer.gb | 0 .../samples => samples}/Programs/MD2viewer.gb | 0 .../Programs/MultitextureDemo.gb | 0 .../dist/samples => samples}/Programs/PB03.gb | 0 .../Programs/ParticleDemo.gb | 0 .../Programs/ParticleDemo2.gb | 0 .../samples => samples}/Programs/PongDemo.gb | 0 .../Programs/Sounds/BRKGLASS.WAV | Bin .../Programs/Sounds/ENTITY.WAV | Bin .../Programs/Sounds/FUSION.WAV | Bin .../Programs/Sounds/GONG.WAV | Bin .../Programs/Sounds/Harp.wav | Bin .../Programs/Sounds/PHOTON.WAV | Bin .../Programs/Sounds/explos.wav | Bin .../Programs/Sounds/gunshot5.wav | Bin .../Programs/Sounds/laser.wav | Bin .../Programs/SparksDemo.gb | 0 .../Programs/StarField4.gb | 0 .../Programs/Starfield1.gb | 0 .../Programs/Starfield2.gb | 0 .../Programs/Starfield3.gb | 0 .../Programs/Starfield5.gb | 0 .../Programs/Starfield6.gb | 0 .../samples => samples}/Programs/Stars.gb | 0 .../samples => samples}/Programs/StickDude.gb | 0 .../Programs/TextureDemo.gb | 0 .../Programs/Textures/00001.jpg | Bin .../Programs/Textures/00002.jpg | Bin .../Programs/Textures/00003.jpg | Bin .../Programs/Textures/00004.jpg | Bin .../Programs/Textures/00005.jpg | Bin .../Programs/Textures/00006.jpg | Bin .../Programs/Textures/00007.jpg | Bin .../Programs/Textures/00008.jpg | Bin .../Programs/Textures/00009.jpg | Bin .../Programs/Textures/001.jpg | Bin .../Programs/Textures/Ceil01.jpg | Bin .../Programs/Textures/Lightning.jpg | Bin .../Programs/Textures/Wall01.jpg | Bin .../Programs/Textures/b001.jpg | Bin .../Programs/Textures/b002.jpg | Bin .../Programs/Textures/b003.jpg | Bin .../Programs/Textures/b004.jpg | Bin .../Programs/Textures/b005.jpg | Bin .../Programs/Textures/b006.jpg | Bin .../Programs/Textures/f001.jpg | Bin .../Programs/Textures/f002.jpg | Bin .../Programs/Textures/f003.jpg | Bin .../Programs/Textures/f004.jpg | Bin .../Programs/Textures/f005.jpg | Bin .../Programs/Textures/f006.jpg | Bin .../Programs/Textures/floor01.jpg | Bin .../Programs/Textures/l001.jpg | Bin .../Programs/Textures/l002.jpg | Bin .../Programs/Textures/l003.jpg | Bin .../Programs/Textures/l004.jpg | Bin .../Programs/Textures/l005.jpg | Bin .../Programs/Textures/l006.jpg | Bin .../Programs/Textures/pavement.jpg | Bin .../Programs/Textures/r001.jpg | Bin .../Programs/Textures/r002.jpg | Bin .../Programs/Textures/r003.jpg | Bin .../Programs/Textures/r004.jpg | Bin .../Programs/Textures/r005.jpg | Bin .../Programs/Textures/r006.jpg | Bin .../Programs/Textures/road01.jpg | Bin .../Programs/Textures/t001.jpg | Bin .../Programs/Textures/t002.jpg | Bin .../Programs/Textures/t003.jpg | Bin .../Programs/Textures/t004.jpg | Bin .../Programs/Textures/t005.jpg | Bin .../Programs/Textures/t006.jpg | Bin .../Programs/Textures/water.bmp | Bin .../dist/samples => samples}/Programs/Tube.gb | 0 .../samples => samples}/Programs/WalkDemo.gb | 0 .../Programs/WriteFileDemo.gb | 0 .../samples => samples}/Programs/hills01.gb | 0 .../samples => samples}/Programs/nehe10.gb | 0 .../samples => samples}/Programs/nehe11.gb | 0 .../samples => samples}/Programs/nehe12.gb | 0 .../samples => samples}/Programs/nehe16.gb | 0 .../samples => samples}/Programs/nehe17.gb | 0 .../samples => samples}/Programs/nehe2.gb | 0 .../samples => samples}/Programs/nehe3.gb | 0 .../samples => samples}/Programs/nehe4.gb | 0 .../samples => samples}/Programs/nehe5.gb | 0 .../samples => samples}/Programs/nehe6.gb | 0 .../samples => samples}/Programs/nehe7.gb | 0 .../samples => samples}/Programs/nehe8.gb | 0 .../samples => samples}/Programs/nehe9.gb | 0 .../samples => samples}/Programs/snakedemo.gb | 0 170 files changed, 39 insertions(+) rename docs/{ => basic4gl}/command-line-function-guide.md (100%) rename docs/{ => basic4gl}/compatibility-notes.md (100%) rename docs/{ => basic4gl}/file-io-guide.md (100%) rename docs/{ => basic4gl}/index.md (100%) rename docs/{ => basic4gl}/keyboard-mouse-joystick-guide.md (100%) rename docs/{ => basic4gl}/language-syntax-guide.md (100%) rename docs/{ => basic4gl}/network-engine-guide.md (100%) rename docs/{ => basic4gl}/opcode-reference.md (100%) rename docs/{ => basic4gl}/opengl-guide.md (100%) rename docs/{ => basic4gl}/programmers-guide-command-line.md (100%) rename docs/{ => basic4gl}/runtime-compilation-guide.md (100%) rename docs/{ => basic4gl}/sound-guide.md (100%) rename docs/{ => basic4gl}/sprite-library-guide.md (100%) rename docs/{ => basic4gl}/standard-function-guide.md (100%) rename docs/{ => basic4gl}/text-output-guide.md (100%) rename docs/{ => basic4gl}/trigonometry-function-guide.md (100%) rename {app/src/main/dist/docs => docs/lwjgl}/lwjgl-release-custom-zip.json (100%) rename {app/src/main/dist/docs => docs/lwjgl}/lwjgl_build.txt (100%) rename {app/src/main/dist/docs => docs/lwjgl}/lwjgl_readme.txt (100%) rename {app/src/main/dist/samples => samples}/Programs/AsteroidDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/AsteroidDemo2.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Boom02.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/BounceClient.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/BounceServer.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Camera.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/CavernDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Corridoor.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/CubePyramidDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Ball.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Ball.tga (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Bumps.bmp (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/CavernTiles.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Crate.bmp (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Cube.bmp (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Explode.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Explode.tga (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/F117.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/F117.tga (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/FB01.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Font.bmp (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/GLASS.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/GLASS.tga (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Mud.bmp (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/NeHe.bmp (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Star.bmp (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/StickDude.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/Tim.bmp (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/asteroid.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/asteroid.tga (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/charset2.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Data/spaceTiles.png (100%) rename {app/src/main/dist/samples => samples}/Programs/Explode2.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Files/Cyberdemon.PCX (100%) rename {app/src/main/dist/samples => samples}/Programs/Files/HILLS01.DAT (100%) rename {app/src/main/dist/samples => samples}/Programs/Files/Mastermind.PCX (100%) rename {app/src/main/dist/samples => samples}/Programs/Files/Spaceship.bgm (100%) rename {app/src/main/dist/samples => samples}/Programs/Files/World.txt (100%) rename {app/src/main/dist/samples => samples}/Programs/Files/cyberdemon.MD2 (100%) rename {app/src/main/dist/samples => samples}/Programs/Files/mastermind.MD2 (100%) rename {app/src/main/dist/samples => samples}/Programs/Flames01.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Flyaround.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Flyaround2.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Heightmap1.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Heightmap2.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Heightmap3.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Heightmap4.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Heightmap5.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Heightmap6.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Include/BGM/BGM.inc (100%) rename {app/src/main/dist/samples => samples}/Programs/Include/MD2/MD2.inc (100%) rename {app/src/main/dist/samples => samples}/Programs/Include/MD2/MD2Routines.inc (100%) rename {app/src/main/dist/samples => samples}/Programs/Include/MD2/MD2Structures.inc (100%) rename {app/src/main/dist/samples => samples}/Programs/Include/Misc.inc (100%) rename {app/src/main/dist/samples => samples}/Programs/Include/Text.inc (100%) rename {app/src/main/dist/samples => samples}/Programs/Include/TextToHex.inc (100%) rename {app/src/main/dist/samples => samples}/Programs/IncludeExample.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Koch02.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/LitMD2Viewer.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/MD2viewer.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/MultitextureDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/PB03.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/ParticleDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/ParticleDemo2.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/PongDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Sounds/BRKGLASS.WAV (100%) rename {app/src/main/dist/samples => samples}/Programs/Sounds/ENTITY.WAV (100%) rename {app/src/main/dist/samples => samples}/Programs/Sounds/FUSION.WAV (100%) rename {app/src/main/dist/samples => samples}/Programs/Sounds/GONG.WAV (100%) rename {app/src/main/dist/samples => samples}/Programs/Sounds/Harp.wav (100%) rename {app/src/main/dist/samples => samples}/Programs/Sounds/PHOTON.WAV (100%) rename {app/src/main/dist/samples => samples}/Programs/Sounds/explos.wav (100%) rename {app/src/main/dist/samples => samples}/Programs/Sounds/gunshot5.wav (100%) rename {app/src/main/dist/samples => samples}/Programs/Sounds/laser.wav (100%) rename {app/src/main/dist/samples => samples}/Programs/SparksDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/StarField4.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Starfield1.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Starfield2.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Starfield3.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Starfield5.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Starfield6.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Stars.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/StickDude.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/TextureDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/00001.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/00002.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/00003.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/00004.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/00005.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/00006.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/00007.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/00008.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/00009.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/001.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/Ceil01.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/Lightning.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/Wall01.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/b001.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/b002.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/b003.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/b004.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/b005.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/b006.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/f001.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/f002.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/f003.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/f004.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/f005.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/f006.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/floor01.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/l001.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/l002.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/l003.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/l004.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/l005.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/l006.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/pavement.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/r001.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/r002.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/r003.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/r004.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/r005.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/r006.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/road01.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/t001.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/t002.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/t003.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/t004.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/t005.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/t006.jpg (100%) rename {app/src/main/dist/samples => samples}/Programs/Textures/water.bmp (100%) rename {app/src/main/dist/samples => samples}/Programs/Tube.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/WalkDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/WriteFileDemo.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/hills01.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe10.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe11.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe12.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe16.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe17.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe2.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe3.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe4.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe5.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe6.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe7.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe8.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/nehe9.gb (100%) rename {app/src/main/dist/samples => samples}/Programs/snakedemo.gb (100%) diff --git a/app/build.gradle b/app/build.gradle index a340bcd9..3fa0fdad 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,6 +48,14 @@ distributions { from(pathingJar) { into "lib" } + from("${rootProject.projectDir}/docs/basic4gl") { + into "docs/basic4gl" + exclude "**/.DS_Store", "**/.*" + } + from("${rootProject.projectDir}/samples") { + into "samples" + exclude "**/.DS_Store", "**/.*" + } } } } diff --git a/docs/command-line-function-guide.md b/docs/basic4gl/command-line-function-guide.md similarity index 100% rename from docs/command-line-function-guide.md rename to docs/basic4gl/command-line-function-guide.md diff --git a/docs/compatibility-notes.md b/docs/basic4gl/compatibility-notes.md similarity index 100% rename from docs/compatibility-notes.md rename to docs/basic4gl/compatibility-notes.md diff --git a/docs/file-io-guide.md b/docs/basic4gl/file-io-guide.md similarity index 100% rename from docs/file-io-guide.md rename to docs/basic4gl/file-io-guide.md diff --git a/docs/index.md b/docs/basic4gl/index.md similarity index 100% rename from docs/index.md rename to docs/basic4gl/index.md diff --git a/docs/keyboard-mouse-joystick-guide.md b/docs/basic4gl/keyboard-mouse-joystick-guide.md similarity index 100% rename from docs/keyboard-mouse-joystick-guide.md rename to docs/basic4gl/keyboard-mouse-joystick-guide.md diff --git a/docs/language-syntax-guide.md b/docs/basic4gl/language-syntax-guide.md similarity index 100% rename from docs/language-syntax-guide.md rename to docs/basic4gl/language-syntax-guide.md diff --git a/docs/network-engine-guide.md b/docs/basic4gl/network-engine-guide.md similarity index 100% rename from docs/network-engine-guide.md rename to docs/basic4gl/network-engine-guide.md diff --git a/docs/opcode-reference.md b/docs/basic4gl/opcode-reference.md similarity index 100% rename from docs/opcode-reference.md rename to docs/basic4gl/opcode-reference.md diff --git a/docs/opengl-guide.md b/docs/basic4gl/opengl-guide.md similarity index 100% rename from docs/opengl-guide.md rename to docs/basic4gl/opengl-guide.md diff --git a/docs/programmers-guide-command-line.md b/docs/basic4gl/programmers-guide-command-line.md similarity index 100% rename from docs/programmers-guide-command-line.md rename to docs/basic4gl/programmers-guide-command-line.md diff --git a/docs/runtime-compilation-guide.md b/docs/basic4gl/runtime-compilation-guide.md similarity index 100% rename from docs/runtime-compilation-guide.md rename to docs/basic4gl/runtime-compilation-guide.md diff --git a/docs/sound-guide.md b/docs/basic4gl/sound-guide.md similarity index 100% rename from docs/sound-guide.md rename to docs/basic4gl/sound-guide.md diff --git a/docs/sprite-library-guide.md b/docs/basic4gl/sprite-library-guide.md similarity index 100% rename from docs/sprite-library-guide.md rename to docs/basic4gl/sprite-library-guide.md diff --git a/docs/standard-function-guide.md b/docs/basic4gl/standard-function-guide.md similarity index 100% rename from docs/standard-function-guide.md rename to docs/basic4gl/standard-function-guide.md diff --git a/docs/text-output-guide.md b/docs/basic4gl/text-output-guide.md similarity index 100% rename from docs/text-output-guide.md rename to docs/basic4gl/text-output-guide.md diff --git a/docs/trigonometry-function-guide.md b/docs/basic4gl/trigonometry-function-guide.md similarity index 100% rename from docs/trigonometry-function-guide.md rename to docs/basic4gl/trigonometry-function-guide.md diff --git a/app/src/main/dist/docs/lwjgl-release-custom-zip.json b/docs/lwjgl/lwjgl-release-custom-zip.json similarity index 100% rename from app/src/main/dist/docs/lwjgl-release-custom-zip.json rename to docs/lwjgl/lwjgl-release-custom-zip.json diff --git a/app/src/main/dist/docs/lwjgl_build.txt b/docs/lwjgl/lwjgl_build.txt similarity index 100% rename from app/src/main/dist/docs/lwjgl_build.txt rename to docs/lwjgl/lwjgl_build.txt diff --git a/app/src/main/dist/docs/lwjgl_readme.txt b/docs/lwjgl/lwjgl_readme.txt similarity index 100% rename from app/src/main/dist/docs/lwjgl_readme.txt rename to docs/lwjgl/lwjgl_readme.txt diff --git a/language-adapter/build.gradle b/language-adapter/build.gradle index c2352017..77fbb5da 100644 --- a/language-adapter/build.gradle +++ b/language-adapter/build.gradle @@ -51,6 +51,37 @@ test { useJUnitPlatform() } +processResources { + from("${rootProject.projectDir}/docs/basic4gl") { + into "basic4gl-content/docs/basic4gl" + exclude "**/.DS_Store", "**/.*" + } + from("${rootProject.projectDir}/samples") { + into "basic4gl-content/samples" + exclude "**/.DS_Store", "**/.*" + } + doLast { + writeResourceIndex( + file("${rootProject.projectDir}/docs/basic4gl"), + file("${destinationDir}/basic4gl-content/docs/basic4gl.index")) + writeResourceIndex( + file("${rootProject.projectDir}/samples/Programs"), + file("${destinationDir}/basic4gl-content/samples/Programs.index")) + } +} + +def writeResourceIndex(File sourceDir, File indexFile) { + indexFile.parentFile.mkdirs() + def basePath = sourceDir.toPath() + def entries = fileTree(sourceDir) + .matching { exclude "**/.DS_Store", "**/.*" } + .files + .findAll { it.isFile() } + .collect { basePath.relativize(it.toPath()).toString().replace(File.separatorChar, '/' as char) } + .sort { a, b -> a.compareToIgnoreCase(b) } + indexFile.text = entries.join(System.lineSeparator()) + System.lineSeparator() +} + spotless { java { // Use the default importOrder configuration diff --git a/app/src/main/dist/samples/Programs/AsteroidDemo.gb b/samples/Programs/AsteroidDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/AsteroidDemo.gb rename to samples/Programs/AsteroidDemo.gb diff --git a/app/src/main/dist/samples/Programs/AsteroidDemo2.gb b/samples/Programs/AsteroidDemo2.gb similarity index 100% rename from app/src/main/dist/samples/Programs/AsteroidDemo2.gb rename to samples/Programs/AsteroidDemo2.gb diff --git a/app/src/main/dist/samples/Programs/Boom02.gb b/samples/Programs/Boom02.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Boom02.gb rename to samples/Programs/Boom02.gb diff --git a/app/src/main/dist/samples/Programs/BounceClient.gb b/samples/Programs/BounceClient.gb similarity index 100% rename from app/src/main/dist/samples/Programs/BounceClient.gb rename to samples/Programs/BounceClient.gb diff --git a/app/src/main/dist/samples/Programs/BounceServer.gb b/samples/Programs/BounceServer.gb similarity index 100% rename from app/src/main/dist/samples/Programs/BounceServer.gb rename to samples/Programs/BounceServer.gb diff --git a/app/src/main/dist/samples/Programs/Camera.gb b/samples/Programs/Camera.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Camera.gb rename to samples/Programs/Camera.gb diff --git a/app/src/main/dist/samples/Programs/CavernDemo.gb b/samples/Programs/CavernDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/CavernDemo.gb rename to samples/Programs/CavernDemo.gb diff --git a/app/src/main/dist/samples/Programs/Corridoor.gb b/samples/Programs/Corridoor.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Corridoor.gb rename to samples/Programs/Corridoor.gb diff --git a/app/src/main/dist/samples/Programs/CubePyramidDemo.gb b/samples/Programs/CubePyramidDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/CubePyramidDemo.gb rename to samples/Programs/CubePyramidDemo.gb diff --git a/app/src/main/dist/samples/Programs/Data/Ball.png b/samples/Programs/Data/Ball.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Ball.png rename to samples/Programs/Data/Ball.png diff --git a/app/src/main/dist/samples/Programs/Data/Ball.tga b/samples/Programs/Data/Ball.tga similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Ball.tga rename to samples/Programs/Data/Ball.tga diff --git a/app/src/main/dist/samples/Programs/Data/Bumps.bmp b/samples/Programs/Data/Bumps.bmp similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Bumps.bmp rename to samples/Programs/Data/Bumps.bmp diff --git a/app/src/main/dist/samples/Programs/Data/CavernTiles.png b/samples/Programs/Data/CavernTiles.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/CavernTiles.png rename to samples/Programs/Data/CavernTiles.png diff --git a/app/src/main/dist/samples/Programs/Data/Crate.bmp b/samples/Programs/Data/Crate.bmp similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Crate.bmp rename to samples/Programs/Data/Crate.bmp diff --git a/app/src/main/dist/samples/Programs/Data/Cube.bmp b/samples/Programs/Data/Cube.bmp similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Cube.bmp rename to samples/Programs/Data/Cube.bmp diff --git a/app/src/main/dist/samples/Programs/Data/Explode.png b/samples/Programs/Data/Explode.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Explode.png rename to samples/Programs/Data/Explode.png diff --git a/app/src/main/dist/samples/Programs/Data/Explode.tga b/samples/Programs/Data/Explode.tga similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Explode.tga rename to samples/Programs/Data/Explode.tga diff --git a/app/src/main/dist/samples/Programs/Data/F117.png b/samples/Programs/Data/F117.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/F117.png rename to samples/Programs/Data/F117.png diff --git a/app/src/main/dist/samples/Programs/Data/F117.tga b/samples/Programs/Data/F117.tga similarity index 100% rename from app/src/main/dist/samples/Programs/Data/F117.tga rename to samples/Programs/Data/F117.tga diff --git a/app/src/main/dist/samples/Programs/Data/FB01.png b/samples/Programs/Data/FB01.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/FB01.png rename to samples/Programs/Data/FB01.png diff --git a/app/src/main/dist/samples/Programs/Data/Font.bmp b/samples/Programs/Data/Font.bmp similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Font.bmp rename to samples/Programs/Data/Font.bmp diff --git a/app/src/main/dist/samples/Programs/Data/GLASS.png b/samples/Programs/Data/GLASS.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/GLASS.png rename to samples/Programs/Data/GLASS.png diff --git a/app/src/main/dist/samples/Programs/Data/GLASS.tga b/samples/Programs/Data/GLASS.tga similarity index 100% rename from app/src/main/dist/samples/Programs/Data/GLASS.tga rename to samples/Programs/Data/GLASS.tga diff --git a/app/src/main/dist/samples/Programs/Data/Mud.bmp b/samples/Programs/Data/Mud.bmp similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Mud.bmp rename to samples/Programs/Data/Mud.bmp diff --git a/app/src/main/dist/samples/Programs/Data/NeHe.bmp b/samples/Programs/Data/NeHe.bmp similarity index 100% rename from app/src/main/dist/samples/Programs/Data/NeHe.bmp rename to samples/Programs/Data/NeHe.bmp diff --git a/app/src/main/dist/samples/Programs/Data/Star.bmp b/samples/Programs/Data/Star.bmp similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Star.bmp rename to samples/Programs/Data/Star.bmp diff --git a/app/src/main/dist/samples/Programs/Data/StickDude.png b/samples/Programs/Data/StickDude.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/StickDude.png rename to samples/Programs/Data/StickDude.png diff --git a/app/src/main/dist/samples/Programs/Data/Tim.bmp b/samples/Programs/Data/Tim.bmp similarity index 100% rename from app/src/main/dist/samples/Programs/Data/Tim.bmp rename to samples/Programs/Data/Tim.bmp diff --git a/app/src/main/dist/samples/Programs/Data/asteroid.png b/samples/Programs/Data/asteroid.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/asteroid.png rename to samples/Programs/Data/asteroid.png diff --git a/app/src/main/dist/samples/Programs/Data/asteroid.tga b/samples/Programs/Data/asteroid.tga similarity index 100% rename from app/src/main/dist/samples/Programs/Data/asteroid.tga rename to samples/Programs/Data/asteroid.tga diff --git a/app/src/main/dist/samples/Programs/Data/charset2.png b/samples/Programs/Data/charset2.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/charset2.png rename to samples/Programs/Data/charset2.png diff --git a/app/src/main/dist/samples/Programs/Data/spaceTiles.png b/samples/Programs/Data/spaceTiles.png similarity index 100% rename from app/src/main/dist/samples/Programs/Data/spaceTiles.png rename to samples/Programs/Data/spaceTiles.png diff --git a/app/src/main/dist/samples/Programs/Explode2.gb b/samples/Programs/Explode2.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Explode2.gb rename to samples/Programs/Explode2.gb diff --git a/app/src/main/dist/samples/Programs/Files/Cyberdemon.PCX b/samples/Programs/Files/Cyberdemon.PCX similarity index 100% rename from app/src/main/dist/samples/Programs/Files/Cyberdemon.PCX rename to samples/Programs/Files/Cyberdemon.PCX diff --git a/app/src/main/dist/samples/Programs/Files/HILLS01.DAT b/samples/Programs/Files/HILLS01.DAT similarity index 100% rename from app/src/main/dist/samples/Programs/Files/HILLS01.DAT rename to samples/Programs/Files/HILLS01.DAT diff --git a/app/src/main/dist/samples/Programs/Files/Mastermind.PCX b/samples/Programs/Files/Mastermind.PCX similarity index 100% rename from app/src/main/dist/samples/Programs/Files/Mastermind.PCX rename to samples/Programs/Files/Mastermind.PCX diff --git a/app/src/main/dist/samples/Programs/Files/Spaceship.bgm b/samples/Programs/Files/Spaceship.bgm similarity index 100% rename from app/src/main/dist/samples/Programs/Files/Spaceship.bgm rename to samples/Programs/Files/Spaceship.bgm diff --git a/app/src/main/dist/samples/Programs/Files/World.txt b/samples/Programs/Files/World.txt similarity index 100% rename from app/src/main/dist/samples/Programs/Files/World.txt rename to samples/Programs/Files/World.txt diff --git a/app/src/main/dist/samples/Programs/Files/cyberdemon.MD2 b/samples/Programs/Files/cyberdemon.MD2 similarity index 100% rename from app/src/main/dist/samples/Programs/Files/cyberdemon.MD2 rename to samples/Programs/Files/cyberdemon.MD2 diff --git a/app/src/main/dist/samples/Programs/Files/mastermind.MD2 b/samples/Programs/Files/mastermind.MD2 similarity index 100% rename from app/src/main/dist/samples/Programs/Files/mastermind.MD2 rename to samples/Programs/Files/mastermind.MD2 diff --git a/app/src/main/dist/samples/Programs/Flames01.gb b/samples/Programs/Flames01.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Flames01.gb rename to samples/Programs/Flames01.gb diff --git a/app/src/main/dist/samples/Programs/Flyaround.gb b/samples/Programs/Flyaround.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Flyaround.gb rename to samples/Programs/Flyaround.gb diff --git a/app/src/main/dist/samples/Programs/Flyaround2.gb b/samples/Programs/Flyaround2.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Flyaround2.gb rename to samples/Programs/Flyaround2.gb diff --git a/app/src/main/dist/samples/Programs/Heightmap1.gb b/samples/Programs/Heightmap1.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Heightmap1.gb rename to samples/Programs/Heightmap1.gb diff --git a/app/src/main/dist/samples/Programs/Heightmap2.gb b/samples/Programs/Heightmap2.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Heightmap2.gb rename to samples/Programs/Heightmap2.gb diff --git a/app/src/main/dist/samples/Programs/Heightmap3.gb b/samples/Programs/Heightmap3.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Heightmap3.gb rename to samples/Programs/Heightmap3.gb diff --git a/app/src/main/dist/samples/Programs/Heightmap4.gb b/samples/Programs/Heightmap4.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Heightmap4.gb rename to samples/Programs/Heightmap4.gb diff --git a/app/src/main/dist/samples/Programs/Heightmap5.gb b/samples/Programs/Heightmap5.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Heightmap5.gb rename to samples/Programs/Heightmap5.gb diff --git a/app/src/main/dist/samples/Programs/Heightmap6.gb b/samples/Programs/Heightmap6.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Heightmap6.gb rename to samples/Programs/Heightmap6.gb diff --git a/app/src/main/dist/samples/Programs/Include/BGM/BGM.inc b/samples/Programs/Include/BGM/BGM.inc similarity index 100% rename from app/src/main/dist/samples/Programs/Include/BGM/BGM.inc rename to samples/Programs/Include/BGM/BGM.inc diff --git a/app/src/main/dist/samples/Programs/Include/MD2/MD2.inc b/samples/Programs/Include/MD2/MD2.inc similarity index 100% rename from app/src/main/dist/samples/Programs/Include/MD2/MD2.inc rename to samples/Programs/Include/MD2/MD2.inc diff --git a/app/src/main/dist/samples/Programs/Include/MD2/MD2Routines.inc b/samples/Programs/Include/MD2/MD2Routines.inc similarity index 100% rename from app/src/main/dist/samples/Programs/Include/MD2/MD2Routines.inc rename to samples/Programs/Include/MD2/MD2Routines.inc diff --git a/app/src/main/dist/samples/Programs/Include/MD2/MD2Structures.inc b/samples/Programs/Include/MD2/MD2Structures.inc similarity index 100% rename from app/src/main/dist/samples/Programs/Include/MD2/MD2Structures.inc rename to samples/Programs/Include/MD2/MD2Structures.inc diff --git a/app/src/main/dist/samples/Programs/Include/Misc.inc b/samples/Programs/Include/Misc.inc similarity index 100% rename from app/src/main/dist/samples/Programs/Include/Misc.inc rename to samples/Programs/Include/Misc.inc diff --git a/app/src/main/dist/samples/Programs/Include/Text.inc b/samples/Programs/Include/Text.inc similarity index 100% rename from app/src/main/dist/samples/Programs/Include/Text.inc rename to samples/Programs/Include/Text.inc diff --git a/app/src/main/dist/samples/Programs/Include/TextToHex.inc b/samples/Programs/Include/TextToHex.inc similarity index 100% rename from app/src/main/dist/samples/Programs/Include/TextToHex.inc rename to samples/Programs/Include/TextToHex.inc diff --git a/app/src/main/dist/samples/Programs/IncludeExample.gb b/samples/Programs/IncludeExample.gb similarity index 100% rename from app/src/main/dist/samples/Programs/IncludeExample.gb rename to samples/Programs/IncludeExample.gb diff --git a/app/src/main/dist/samples/Programs/Koch02.gb b/samples/Programs/Koch02.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Koch02.gb rename to samples/Programs/Koch02.gb diff --git a/app/src/main/dist/samples/Programs/LitMD2Viewer.gb b/samples/Programs/LitMD2Viewer.gb similarity index 100% rename from app/src/main/dist/samples/Programs/LitMD2Viewer.gb rename to samples/Programs/LitMD2Viewer.gb diff --git a/app/src/main/dist/samples/Programs/MD2viewer.gb b/samples/Programs/MD2viewer.gb similarity index 100% rename from app/src/main/dist/samples/Programs/MD2viewer.gb rename to samples/Programs/MD2viewer.gb diff --git a/app/src/main/dist/samples/Programs/MultitextureDemo.gb b/samples/Programs/MultitextureDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/MultitextureDemo.gb rename to samples/Programs/MultitextureDemo.gb diff --git a/app/src/main/dist/samples/Programs/PB03.gb b/samples/Programs/PB03.gb similarity index 100% rename from app/src/main/dist/samples/Programs/PB03.gb rename to samples/Programs/PB03.gb diff --git a/app/src/main/dist/samples/Programs/ParticleDemo.gb b/samples/Programs/ParticleDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/ParticleDemo.gb rename to samples/Programs/ParticleDemo.gb diff --git a/app/src/main/dist/samples/Programs/ParticleDemo2.gb b/samples/Programs/ParticleDemo2.gb similarity index 100% rename from app/src/main/dist/samples/Programs/ParticleDemo2.gb rename to samples/Programs/ParticleDemo2.gb diff --git a/app/src/main/dist/samples/Programs/PongDemo.gb b/samples/Programs/PongDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/PongDemo.gb rename to samples/Programs/PongDemo.gb diff --git a/app/src/main/dist/samples/Programs/Sounds/BRKGLASS.WAV b/samples/Programs/Sounds/BRKGLASS.WAV similarity index 100% rename from app/src/main/dist/samples/Programs/Sounds/BRKGLASS.WAV rename to samples/Programs/Sounds/BRKGLASS.WAV diff --git a/app/src/main/dist/samples/Programs/Sounds/ENTITY.WAV b/samples/Programs/Sounds/ENTITY.WAV similarity index 100% rename from app/src/main/dist/samples/Programs/Sounds/ENTITY.WAV rename to samples/Programs/Sounds/ENTITY.WAV diff --git a/app/src/main/dist/samples/Programs/Sounds/FUSION.WAV b/samples/Programs/Sounds/FUSION.WAV similarity index 100% rename from app/src/main/dist/samples/Programs/Sounds/FUSION.WAV rename to samples/Programs/Sounds/FUSION.WAV diff --git a/app/src/main/dist/samples/Programs/Sounds/GONG.WAV b/samples/Programs/Sounds/GONG.WAV similarity index 100% rename from app/src/main/dist/samples/Programs/Sounds/GONG.WAV rename to samples/Programs/Sounds/GONG.WAV diff --git a/app/src/main/dist/samples/Programs/Sounds/Harp.wav b/samples/Programs/Sounds/Harp.wav similarity index 100% rename from app/src/main/dist/samples/Programs/Sounds/Harp.wav rename to samples/Programs/Sounds/Harp.wav diff --git a/app/src/main/dist/samples/Programs/Sounds/PHOTON.WAV b/samples/Programs/Sounds/PHOTON.WAV similarity index 100% rename from app/src/main/dist/samples/Programs/Sounds/PHOTON.WAV rename to samples/Programs/Sounds/PHOTON.WAV diff --git a/app/src/main/dist/samples/Programs/Sounds/explos.wav b/samples/Programs/Sounds/explos.wav similarity index 100% rename from app/src/main/dist/samples/Programs/Sounds/explos.wav rename to samples/Programs/Sounds/explos.wav diff --git a/app/src/main/dist/samples/Programs/Sounds/gunshot5.wav b/samples/Programs/Sounds/gunshot5.wav similarity index 100% rename from app/src/main/dist/samples/Programs/Sounds/gunshot5.wav rename to samples/Programs/Sounds/gunshot5.wav diff --git a/app/src/main/dist/samples/Programs/Sounds/laser.wav b/samples/Programs/Sounds/laser.wav similarity index 100% rename from app/src/main/dist/samples/Programs/Sounds/laser.wav rename to samples/Programs/Sounds/laser.wav diff --git a/app/src/main/dist/samples/Programs/SparksDemo.gb b/samples/Programs/SparksDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/SparksDemo.gb rename to samples/Programs/SparksDemo.gb diff --git a/app/src/main/dist/samples/Programs/StarField4.gb b/samples/Programs/StarField4.gb similarity index 100% rename from app/src/main/dist/samples/Programs/StarField4.gb rename to samples/Programs/StarField4.gb diff --git a/app/src/main/dist/samples/Programs/Starfield1.gb b/samples/Programs/Starfield1.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Starfield1.gb rename to samples/Programs/Starfield1.gb diff --git a/app/src/main/dist/samples/Programs/Starfield2.gb b/samples/Programs/Starfield2.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Starfield2.gb rename to samples/Programs/Starfield2.gb diff --git a/app/src/main/dist/samples/Programs/Starfield3.gb b/samples/Programs/Starfield3.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Starfield3.gb rename to samples/Programs/Starfield3.gb diff --git a/app/src/main/dist/samples/Programs/Starfield5.gb b/samples/Programs/Starfield5.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Starfield5.gb rename to samples/Programs/Starfield5.gb diff --git a/app/src/main/dist/samples/Programs/Starfield6.gb b/samples/Programs/Starfield6.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Starfield6.gb rename to samples/Programs/Starfield6.gb diff --git a/app/src/main/dist/samples/Programs/Stars.gb b/samples/Programs/Stars.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Stars.gb rename to samples/Programs/Stars.gb diff --git a/app/src/main/dist/samples/Programs/StickDude.gb b/samples/Programs/StickDude.gb similarity index 100% rename from app/src/main/dist/samples/Programs/StickDude.gb rename to samples/Programs/StickDude.gb diff --git a/app/src/main/dist/samples/Programs/TextureDemo.gb b/samples/Programs/TextureDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/TextureDemo.gb rename to samples/Programs/TextureDemo.gb diff --git a/app/src/main/dist/samples/Programs/Textures/00001.jpg b/samples/Programs/Textures/00001.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/00001.jpg rename to samples/Programs/Textures/00001.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/00002.jpg b/samples/Programs/Textures/00002.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/00002.jpg rename to samples/Programs/Textures/00002.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/00003.jpg b/samples/Programs/Textures/00003.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/00003.jpg rename to samples/Programs/Textures/00003.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/00004.jpg b/samples/Programs/Textures/00004.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/00004.jpg rename to samples/Programs/Textures/00004.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/00005.jpg b/samples/Programs/Textures/00005.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/00005.jpg rename to samples/Programs/Textures/00005.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/00006.jpg b/samples/Programs/Textures/00006.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/00006.jpg rename to samples/Programs/Textures/00006.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/00007.jpg b/samples/Programs/Textures/00007.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/00007.jpg rename to samples/Programs/Textures/00007.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/00008.jpg b/samples/Programs/Textures/00008.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/00008.jpg rename to samples/Programs/Textures/00008.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/00009.jpg b/samples/Programs/Textures/00009.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/00009.jpg rename to samples/Programs/Textures/00009.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/001.jpg b/samples/Programs/Textures/001.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/001.jpg rename to samples/Programs/Textures/001.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/Ceil01.jpg b/samples/Programs/Textures/Ceil01.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/Ceil01.jpg rename to samples/Programs/Textures/Ceil01.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/Lightning.jpg b/samples/Programs/Textures/Lightning.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/Lightning.jpg rename to samples/Programs/Textures/Lightning.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/Wall01.jpg b/samples/Programs/Textures/Wall01.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/Wall01.jpg rename to samples/Programs/Textures/Wall01.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/b001.jpg b/samples/Programs/Textures/b001.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/b001.jpg rename to samples/Programs/Textures/b001.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/b002.jpg b/samples/Programs/Textures/b002.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/b002.jpg rename to samples/Programs/Textures/b002.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/b003.jpg b/samples/Programs/Textures/b003.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/b003.jpg rename to samples/Programs/Textures/b003.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/b004.jpg b/samples/Programs/Textures/b004.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/b004.jpg rename to samples/Programs/Textures/b004.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/b005.jpg b/samples/Programs/Textures/b005.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/b005.jpg rename to samples/Programs/Textures/b005.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/b006.jpg b/samples/Programs/Textures/b006.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/b006.jpg rename to samples/Programs/Textures/b006.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/f001.jpg b/samples/Programs/Textures/f001.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/f001.jpg rename to samples/Programs/Textures/f001.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/f002.jpg b/samples/Programs/Textures/f002.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/f002.jpg rename to samples/Programs/Textures/f002.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/f003.jpg b/samples/Programs/Textures/f003.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/f003.jpg rename to samples/Programs/Textures/f003.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/f004.jpg b/samples/Programs/Textures/f004.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/f004.jpg rename to samples/Programs/Textures/f004.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/f005.jpg b/samples/Programs/Textures/f005.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/f005.jpg rename to samples/Programs/Textures/f005.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/f006.jpg b/samples/Programs/Textures/f006.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/f006.jpg rename to samples/Programs/Textures/f006.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/floor01.jpg b/samples/Programs/Textures/floor01.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/floor01.jpg rename to samples/Programs/Textures/floor01.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/l001.jpg b/samples/Programs/Textures/l001.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/l001.jpg rename to samples/Programs/Textures/l001.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/l002.jpg b/samples/Programs/Textures/l002.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/l002.jpg rename to samples/Programs/Textures/l002.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/l003.jpg b/samples/Programs/Textures/l003.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/l003.jpg rename to samples/Programs/Textures/l003.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/l004.jpg b/samples/Programs/Textures/l004.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/l004.jpg rename to samples/Programs/Textures/l004.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/l005.jpg b/samples/Programs/Textures/l005.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/l005.jpg rename to samples/Programs/Textures/l005.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/l006.jpg b/samples/Programs/Textures/l006.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/l006.jpg rename to samples/Programs/Textures/l006.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/pavement.jpg b/samples/Programs/Textures/pavement.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/pavement.jpg rename to samples/Programs/Textures/pavement.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/r001.jpg b/samples/Programs/Textures/r001.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/r001.jpg rename to samples/Programs/Textures/r001.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/r002.jpg b/samples/Programs/Textures/r002.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/r002.jpg rename to samples/Programs/Textures/r002.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/r003.jpg b/samples/Programs/Textures/r003.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/r003.jpg rename to samples/Programs/Textures/r003.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/r004.jpg b/samples/Programs/Textures/r004.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/r004.jpg rename to samples/Programs/Textures/r004.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/r005.jpg b/samples/Programs/Textures/r005.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/r005.jpg rename to samples/Programs/Textures/r005.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/r006.jpg b/samples/Programs/Textures/r006.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/r006.jpg rename to samples/Programs/Textures/r006.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/road01.jpg b/samples/Programs/Textures/road01.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/road01.jpg rename to samples/Programs/Textures/road01.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/t001.jpg b/samples/Programs/Textures/t001.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/t001.jpg rename to samples/Programs/Textures/t001.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/t002.jpg b/samples/Programs/Textures/t002.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/t002.jpg rename to samples/Programs/Textures/t002.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/t003.jpg b/samples/Programs/Textures/t003.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/t003.jpg rename to samples/Programs/Textures/t003.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/t004.jpg b/samples/Programs/Textures/t004.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/t004.jpg rename to samples/Programs/Textures/t004.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/t005.jpg b/samples/Programs/Textures/t005.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/t005.jpg rename to samples/Programs/Textures/t005.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/t006.jpg b/samples/Programs/Textures/t006.jpg similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/t006.jpg rename to samples/Programs/Textures/t006.jpg diff --git a/app/src/main/dist/samples/Programs/Textures/water.bmp b/samples/Programs/Textures/water.bmp similarity index 100% rename from app/src/main/dist/samples/Programs/Textures/water.bmp rename to samples/Programs/Textures/water.bmp diff --git a/app/src/main/dist/samples/Programs/Tube.gb b/samples/Programs/Tube.gb similarity index 100% rename from app/src/main/dist/samples/Programs/Tube.gb rename to samples/Programs/Tube.gb diff --git a/app/src/main/dist/samples/Programs/WalkDemo.gb b/samples/Programs/WalkDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/WalkDemo.gb rename to samples/Programs/WalkDemo.gb diff --git a/app/src/main/dist/samples/Programs/WriteFileDemo.gb b/samples/Programs/WriteFileDemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/WriteFileDemo.gb rename to samples/Programs/WriteFileDemo.gb diff --git a/app/src/main/dist/samples/Programs/hills01.gb b/samples/Programs/hills01.gb similarity index 100% rename from app/src/main/dist/samples/Programs/hills01.gb rename to samples/Programs/hills01.gb diff --git a/app/src/main/dist/samples/Programs/nehe10.gb b/samples/Programs/nehe10.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe10.gb rename to samples/Programs/nehe10.gb diff --git a/app/src/main/dist/samples/Programs/nehe11.gb b/samples/Programs/nehe11.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe11.gb rename to samples/Programs/nehe11.gb diff --git a/app/src/main/dist/samples/Programs/nehe12.gb b/samples/Programs/nehe12.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe12.gb rename to samples/Programs/nehe12.gb diff --git a/app/src/main/dist/samples/Programs/nehe16.gb b/samples/Programs/nehe16.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe16.gb rename to samples/Programs/nehe16.gb diff --git a/app/src/main/dist/samples/Programs/nehe17.gb b/samples/Programs/nehe17.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe17.gb rename to samples/Programs/nehe17.gb diff --git a/app/src/main/dist/samples/Programs/nehe2.gb b/samples/Programs/nehe2.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe2.gb rename to samples/Programs/nehe2.gb diff --git a/app/src/main/dist/samples/Programs/nehe3.gb b/samples/Programs/nehe3.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe3.gb rename to samples/Programs/nehe3.gb diff --git a/app/src/main/dist/samples/Programs/nehe4.gb b/samples/Programs/nehe4.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe4.gb rename to samples/Programs/nehe4.gb diff --git a/app/src/main/dist/samples/Programs/nehe5.gb b/samples/Programs/nehe5.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe5.gb rename to samples/Programs/nehe5.gb diff --git a/app/src/main/dist/samples/Programs/nehe6.gb b/samples/Programs/nehe6.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe6.gb rename to samples/Programs/nehe6.gb diff --git a/app/src/main/dist/samples/Programs/nehe7.gb b/samples/Programs/nehe7.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe7.gb rename to samples/Programs/nehe7.gb diff --git a/app/src/main/dist/samples/Programs/nehe8.gb b/samples/Programs/nehe8.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe8.gb rename to samples/Programs/nehe8.gb diff --git a/app/src/main/dist/samples/Programs/nehe9.gb b/samples/Programs/nehe9.gb similarity index 100% rename from app/src/main/dist/samples/Programs/nehe9.gb rename to samples/Programs/nehe9.gb diff --git a/app/src/main/dist/samples/Programs/snakedemo.gb b/samples/Programs/snakedemo.gb similarity index 100% rename from app/src/main/dist/samples/Programs/snakedemo.gb rename to samples/Programs/snakedemo.gb From 5567cc0302aa19a91ed4f0e4e4453e510844dde2 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Thu, 16 Jul 2026 02:02:17 -0400 Subject: [PATCH 30/38] work on docs and sample templates --- .../basic4gl/desktop/spi/EditorPlugin.java | 11 + .../basic4gl/desktop/spi/PluginContext.java | 4 + .../spi/content/ClasspathContentSource.java | 59 ++ .../basic4gl/desktop/spi/content/Content.java | 1 + .../desktop/spi/content/ContentDocument.java | 12 + .../desktop/spi/content/ContentMetadata.java | 1 + .../desktop/spi/content/ContentPaths.java | 63 ++ .../spi/content/ContentRegistration.java | 7 + .../desktop/spi/content/ContentResource.java | 8 + .../desktop/spi/content/ContentService.java | 14 +- .../desktop/spi/content/ContentSource.java | 18 + .../spi/content/ContentValidation.java | 34 ++ .../spi/content/DirectoryContentSource.java | 48 ++ .../content/DirectoryDocumentProvider.java | 138 +++++ .../content/DirectoryTemplateProvider.java | 121 ++++ .../spi/content/DocumentDescriptor.java | 24 + .../desktop/spi/content/DocumentProvider.java | 21 + .../desktop/spi/content/IndexedContent.java | 25 + .../desktop/spi/content/JarContentSource.java | 14 + .../LegacyContentDocumentProvider.java | 59 ++ .../spi/content/LegacyTemplateProvider.java | 75 +++ .../spi/content/ManifestDocumentProvider.java | 57 ++ .../desktop/spi/content/MapContentSource.java | 62 ++ .../desktop/spi/content/Template.java | 1 + .../spi/content/TemplateCreationRequest.java | 14 + .../spi/content/TemplateDescriptor.java | 26 + .../desktop/spi/content/TemplateProvider.java | 21 + .../desktop/spi/content/ZipContentSource.java | 85 +++ .../spi/content/ContentDescriptorTest.java | 95 +++ .../content/ContentHelperProviderTest.java | 73 +++ .../desktop/spi/content/ContentPathsTest.java | 40 ++ .../spi/content/ContentSourceTest.java | 93 +++ .../com/basic4gl/desktop/BasicEditor.java | 51 ++ .../java/com/basic4gl/desktop/MainWindow.java | 28 +- .../content/ContentDocumentViewer.java | 257 ++++++++ .../desktop/content/ContentMaterializer.java | 138 +++++ .../desktop/content/MarkdownHtmlSupport.java | 34 ++ .../desktop/content/MarkdownViewer.java | 26 +- .../desktop/content/TemplateInstantiator.java | 45 ++ .../content/catalog/ContentBrowseNode.java | 54 ++ .../content/catalog/ContentCatalog.java | 223 +++++++ .../catalog/ContentCatalogListener.java | 6 + .../content/catalog/ContentGlobalId.java | 24 + .../content/catalog/ContentPanelItem.java | 29 + .../content/catalog/ContentPanelModel.java | 191 ++++++ .../desktop/content/catalog/ContentScope.java | 8 + .../content/catalog/ContentSearchIndex.java | 63 ++ .../content/catalog/ContentSearchResult.java | 5 + .../catalog/ContentSelectionSummary.java | 11 + .../catalog/DefaultContentService.java | 37 ++ .../content/catalog/DocumentCatalogEntry.java | 21 + .../content/catalog/TemplateCatalogEntry.java | 21 + .../render/ContentNavigationHandler.java | 18 + .../content/render/ContentRenderRequest.java | 21 + .../content/render/ContentRenderer.java | 11 + .../content/render/HtmlContentRenderer.java | 18 + .../render/MarkdownContentRenderer.java | 156 +++++ .../render/PlainTextContentRenderer.java | 28 + .../content/render/RendererSupport.java | 70 +++ .../render/UnsupportedContentRenderer.java | 22 + .../desktop/editor/IEditorPresenter.java | 3 + .../basic4gl/desktop/editor/IFileViewer.java | 3 +- .../desktop/panels/DocsPanelProvider.java | 550 +++++++++++++----- .../images/material/icon_template.png | Bin 0 -> 382 bytes .../content/ContentDocumentViewerTest.java | 51 ++ .../content/ContentMaterializerTest.java | 94 +++ .../content/TemplateInstantiatorTest.java | 90 +++ .../content/catalog/ContentCatalogTest.java | 187 ++++++ .../catalog/ContentPanelModelTest.java | 156 +++++ .../content/render/ContentRendererTest.java | 133 +++++ .../adapter/Basic4GLEditorPluginAdapter.java | 31 + .../adapter/Basic4GLContentResourcesTest.java | 25 + 72 files changed, 4090 insertions(+), 173 deletions(-) create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ClasspathContentSource.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentDocument.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentPaths.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentRegistration.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentResource.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentSource.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentValidation.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryContentSource.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryDocumentProvider.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryTemplateProvider.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentDescriptor.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentProvider.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/IndexedContent.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/JarContentSource.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyContentDocumentProvider.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyTemplateProvider.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ManifestDocumentProvider.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/MapContentSource.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateCreationRequest.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateDescriptor.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateProvider.java create mode 100644 app-spi/src/main/java/com/basic4gl/desktop/spi/content/ZipContentSource.java create mode 100644 app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentDescriptorTest.java create mode 100644 app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentHelperProviderTest.java create mode 100644 app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentPathsTest.java create mode 100644 app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentSourceTest.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/ContentDocumentViewer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/ContentMaterializer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/MarkdownHtmlSupport.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/TemplateInstantiator.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentBrowseNode.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalog.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalogListener.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentGlobalId.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelItem.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchIndex.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchResult.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSelectionSummary.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/DefaultContentService.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/DocumentCatalogEntry.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/catalog/TemplateCatalogEntry.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/render/ContentNavigationHandler.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderRequest.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/render/HtmlContentRenderer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/render/MarkdownContentRenderer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/render/PlainTextContentRenderer.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/render/RendererSupport.java create mode 100644 app/src/main/java/com/basic4gl/desktop/content/render/UnsupportedContentRenderer.java create mode 100644 app/src/main/resources/images/material/icon_template.png create mode 100644 app/src/test/java/com/basic4gl/desktop/content/ContentDocumentViewerTest.java create mode 100644 app/src/test/java/com/basic4gl/desktop/content/ContentMaterializerTest.java create mode 100644 app/src/test/java/com/basic4gl/desktop/content/TemplateInstantiatorTest.java create mode 100644 app/src/test/java/com/basic4gl/desktop/content/catalog/ContentCatalogTest.java create mode 100644 app/src/test/java/com/basic4gl/desktop/content/catalog/ContentPanelModelTest.java create mode 100644 app/src/test/java/com/basic4gl/desktop/content/render/ContentRendererTest.java create mode 100644 language-adapter/src/test/java/com/basic4gl/language/adapter/Basic4GLContentResourcesTest.java diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorPlugin.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorPlugin.java index ca20f717..ec0f2783 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorPlugin.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/EditorPlugin.java @@ -3,6 +3,17 @@ import com.basic4gl.desktop.spi.language.LanguageSupport; public abstract class EditorPlugin { + public String getId() { + String pluginName = getName(); + String normalizedName = pluginName == null + ? "" + : pluginName.trim().replaceAll("\\s+", "-").toLowerCase(); + if (normalizedName.isBlank()) { + return getClass().getName(); + } + return getClass().getName() + ":" + normalizedName; + } + public abstract String getName(); public abstract String getDescription(); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java index 850cdee6..0eb2fdaf 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/PluginContext.java @@ -1,5 +1,7 @@ package com.basic4gl.desktop.spi; +import com.basic4gl.desktop.spi.content.ContentService; + public interface PluginContext { // CommandRegistry commands(); // MenuRegistry menus(); @@ -12,6 +14,8 @@ public interface PluginContext { DialogService dialogs(); MenuService menus(); + + ContentService content(); // ProjectService projects(); // EditorService editors(); FileOpener files(); diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ClasspathContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ClasspathContentSource.java new file mode 100644 index 00000000..1974b717 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ClasspathContentSource.java @@ -0,0 +1,59 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +public final class ClasspathContentSource implements ContentSource { + + private final ClassLoader classLoader; + private final String rootPrefix; + private final List resources; + + public ClasspathContentSource(ClassLoader classLoader, String rootPrefix, String indexResourcePath) + throws IOException { + this.classLoader = Objects.requireNonNull(classLoader, "classLoader"); + this.rootPrefix = normalizePrefix(rootPrefix); + String indexPath = ContentPaths.normalize(indexResourcePath); + try (InputStream input = classLoader.getResourceAsStream(indexPath)) { + if (input == null) { + throw new FileNotFoundException("Classpath content index not found: " + indexPath); + } + this.resources = new String(input.readAllBytes(), StandardCharsets.UTF_8) + .lines() + .map(String::trim) + .filter(line -> !line.isBlank()) + .filter(line -> !line.startsWith("#")) + .map(ContentPaths::normalize) + .map(ContentResource::new) + .sorted((left, right) -> left.path().compareToIgnoreCase(right.path())) + .toList(); + } + } + + @Override + public InputStream open(String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + InputStream input = classLoader.getResourceAsStream(rootPrefix + safePath); + if (input == null) { + throw new FileNotFoundException("Classpath content resource not found: " + safePath); + } + return input; + } + + @Override + public Collection resources() { + return resources; + } + + private static String normalizePrefix(String rootPrefix) { + if (rootPrefix == null || rootPrefix.isBlank()) { + return ""; + } + return ContentPaths.normalize(rootPrefix) + "/"; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java index 2b03b8ef..c2eefe3d 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Content.java @@ -2,6 +2,7 @@ import java.io.File; +@Deprecated public class Content { private String name; private ContentMetadata metadata; diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentDocument.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentDocument.java new file mode 100644 index 00000000..96cdeb73 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentDocument.java @@ -0,0 +1,12 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.Objects; + +public record ContentDocument(String mediaType, ContentSource source, String entryPath) { + + public ContentDocument { + mediaType = ContentValidation.requireNonBlank(mediaType, "mediaType"); + source = Objects.requireNonNull(source, "source"); + entryPath = ContentValidation.requireNonBlank(entryPath, "entryPath"); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java index 50128c79..f66b744a 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentMetadata.java @@ -1,5 +1,6 @@ package com.basic4gl.desktop.spi.content; +@Deprecated public class ContentMetadata { private String category; private String description; diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentPaths.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentPaths.java new file mode 100644 index 00000000..83a78687 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentPaths.java @@ -0,0 +1,63 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Locale; + +public final class ContentPaths { + + private ContentPaths() {} + + public static String normalize(String path) { + rejectUnsafePath(path); + String normalizedSeparators = path.replace('\\', '/'); + Deque segments = new ArrayDeque<>(); + for (String segment : normalizedSeparators.split("/+")) { + if (segment.isEmpty() || ".".equals(segment)) { + continue; + } + if ("..".equals(segment)) { + if (segments.isEmpty()) { + throw new IllegalArgumentException("Path escapes the content root: " + path); + } + segments.removeLast(); + continue; + } + segments.addLast(segment); + } + if (segments.isEmpty()) { + throw new IllegalArgumentException("Path must not resolve to the content root"); + } + return String.join("/", segments); + } + + public static String resolve(String currentDocumentPath, String relativeTarget) { + String currentPath = normalize(currentDocumentPath); + rejectUnsafePath(relativeTarget); + String parent = ""; + int lastSlash = currentPath.lastIndexOf('/'); + if (lastSlash >= 0) { + parent = currentPath.substring(0, lastSlash); + } + String combined = parent.isBlank() ? relativeTarget : parent + "/" + relativeTarget; + return normalize(combined); + } + + private static void rejectUnsafePath(String path) { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("Path must not be null or blank"); + } + String trimmed = path.trim(); + String slashPath = trimmed.replace('\\', '/'); + if (slashPath.startsWith("/") || slashPath.startsWith("//")) { + throw new IllegalArgumentException("Absolute paths are not allowed: " + path); + } + if (slashPath.matches("^[A-Za-z]:($|/.*)")) { + throw new IllegalArgumentException("Windows absolute paths are not allowed: " + path); + } + String lower = slashPath.toLowerCase(Locale.ROOT); + if (lower.matches("^[a-z][a-z0-9+.-]*:.*")) { + throw new IllegalArgumentException("URI schemes are not allowed: " + path); + } + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentRegistration.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentRegistration.java new file mode 100644 index 00000000..a1073a6e --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentRegistration.java @@ -0,0 +1,7 @@ +package com.basic4gl.desktop.spi.content; + +public interface ContentRegistration extends AutoCloseable { + + @Override + void close(); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentResource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentResource.java new file mode 100644 index 00000000..a11d10c0 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentResource.java @@ -0,0 +1,8 @@ +package com.basic4gl.desktop.spi.content; + +public record ContentResource(String path) { + + public ContentResource { + path = ContentValidation.requireNonBlank(path, "path"); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java index 640b6808..b741abe8 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentService.java @@ -1,7 +1,17 @@ package com.basic4gl.desktop.spi.content; public interface ContentService { - void registerTemplate(Template template); + ContentRegistration registerDocumentProvider(DocumentProvider provider); - void registerDocument(Content content); + ContentRegistration registerTemplateProvider(TemplateProvider provider); + + @Deprecated + default void registerTemplate(Template template) { + registerTemplateProvider(new LegacyTemplateProvider(template)); + } + + @Deprecated + default void registerDocument(Content content) { + registerDocumentProvider(new LegacyContentDocumentProvider(content)); + } } diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentSource.java new file mode 100644 index 00000000..35e3c3b7 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentSource.java @@ -0,0 +1,18 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; + +public interface ContentSource { + + /** + * Opens a normalized path relative to this source's root. + */ + InputStream open(String normalizedPath) throws IOException; + + /** + * Returns all files belonging to this content bundle. + */ + Collection resources() throws IOException; +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentValidation.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentValidation.java new file mode 100644 index 00000000..3521791e --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ContentValidation.java @@ -0,0 +1,34 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.List; +import java.util.Set; + +final class ContentValidation { + + private ContentValidation() {} + + static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be null or blank"); + } + return value; + } + + static String optionalString(String value) { + return value == null ? "" : value; + } + + static List copyList(List values) { + if (values == null) { + return List.of(); + } + return List.copyOf(values); + } + + static Set copySet(Set values) { + if (values == null) { + return Set.of(); + } + return Set.copyOf(values); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryContentSource.java new file mode 100644 index 00000000..c40e3455 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryContentSource.java @@ -0,0 +1,48 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Objects; + +public final class DirectoryContentSource implements ContentSource { + + private final Path root; + + public DirectoryContentSource(Path root) { + this.root = Objects.requireNonNull(root, "root").toAbsolutePath().normalize(); + if (!Files.isDirectory(this.root)) { + throw new IllegalArgumentException("Content root is not a directory: " + root); + } + } + + @Override + public InputStream open(String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + Path target = root.resolve(safePath.replace("/", root.getFileSystem().getSeparator())) + .normalize(); + Path realRoot = root.toRealPath(); + Path realTarget = target.toRealPath(); + if (!realTarget.startsWith(realRoot) || !Files.isRegularFile(realTarget)) { + throw new FileNotFoundException("Content resource not found: " + normalizedPath); + } + return Files.newInputStream(realTarget); + } + + @Override + public Collection resources() throws IOException { + Path realRoot = root.toRealPath(); + try (var paths = Files.walk(realRoot)) { + return paths.filter(Files::isRegularFile) + .map(realRoot::relativize) + .map(path -> path.toString().replace('\\', '/')) + .map(ContentPaths::normalize) + .map(ContentResource::new) + .sorted((left, right) -> left.path().compareToIgnoreCase(right.path())) + .toList(); + } + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryDocumentProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryDocumentProvider.java new file mode 100644 index 00000000..4e32d2cd --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryDocumentProvider.java @@ -0,0 +1,138 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class DirectoryDocumentProvider implements DocumentProvider { + + private final String id; + private final String version; + private final ContentSource source; + private final List rootCategory; + private final List index; + + public DirectoryDocumentProvider(String id, String version, Path root, List rootCategory) + throws IOException { + this(id, version, new DirectoryContentSource(root), rootCategory); + } + + public DirectoryDocumentProvider(String id, String version, ContentSource source, List rootCategory) + throws IOException { + this.id = ContentValidation.requireNonBlank(id, "id"); + this.version = ContentValidation.requireNonBlank(version, "version"); + this.source = source; + this.rootCategory = ContentValidation.copyList(rootCategory); + this.index = buildIndex(); + } + + @Override + public String id() { + return id; + } + + @Override + public String version() { + return version; + } + + @Override + public List getIndex() { + return index; + } + + @Override + public ContentDocument openDocument(String documentId) throws IOException { + for (DocumentDescriptor descriptor : index) { + if (descriptor.id().equals(documentId)) { + return new ContentDocument(mediaType(descriptor.id()), source, descriptor.id()); + } + } + throw new IOException("Unknown document: " + documentId); + } + + protected List buildIndex() throws IOException { + List descriptors = new ArrayList<>(); + int sortOrder = 0; + for (ContentResource resource : source.resources()) { + String path = resource.path(); + if (!isMarkdown(path) || hidden(path)) { + continue; + } + descriptors.add(new DocumentDescriptor( + path, + title(path), + "", + category(path), + tags(path), + "index.md".equalsIgnoreCase(path) ? -1000 : sortOrder++, + List.of())); + } + return List.copyOf(descriptors); + } + + private String title(String path) throws IOException { + try (var input = source.open(path)) { + String text = new String(input.readAllBytes(), StandardCharsets.UTF_8); + for (String line : text.split("\\R")) { + if (line.startsWith("# ")) { + return line.substring(2).trim(); + } + } + } + String filename = path.substring(path.lastIndexOf('/') + 1); + return splitTitle(filename.replaceFirst("\\.[^.]+$", "")); + } + + private List category(String path) { + List category = new ArrayList<>(rootCategory); + if (!"index.md".equalsIgnoreCase(path)) { + category.add("Reference"); + } + return List.copyOf(category); + } + + private Set tags(String path) { + if ("index.md".equalsIgnoreCase(path)) { + return Set.of("learn", "getting-started"); + } + return Set.of("reference", "guide"); + } + + private static boolean isMarkdown(String path) { + String lower = path.toLowerCase(Locale.ROOT); + return lower.endsWith(".md") || lower.endsWith(".markdown"); + } + + private static String mediaType(String path) { + return isMarkdown(path) ? "text/markdown" : "text/plain"; + } + + static boolean hidden(String path) { + for (String segment : path.split("/")) { + if (segment.startsWith(".")) { + return true; + } + } + return false; + } + + static String splitTitle(String text) { + String spaced = text.replace('-', ' ').replace('_', ' ').replaceAll("(?<=[a-z])(?=[A-Z])", " "); + StringBuilder result = new StringBuilder(); + for (String word : spaced.split("\\s+")) { + if (word.isBlank()) { + continue; + } + if (!result.isEmpty()) { + result.append(' '); + } + result.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + return result.toString(); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryTemplateProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryTemplateProvider.java new file mode 100644 index 00000000..34c2b459 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DirectoryTemplateProvider.java @@ -0,0 +1,121 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class DirectoryTemplateProvider implements TemplateProvider { + + private static final Set SHARED_DIRECTORIES = Set.of("include", "data", "files", "sounds", "textures"); + + private final String id; + private final String version; + private final ContentSource source; + private final List rootCategory; + private final List index; + + public DirectoryTemplateProvider(String id, String version, Path root, List rootCategory) + throws IOException { + this(id, version, new DirectoryContentSource(root), rootCategory); + } + + public DirectoryTemplateProvider(String id, String version, ContentSource source, List rootCategory) + throws IOException { + this.id = ContentValidation.requireNonBlank(id, "id"); + this.version = ContentValidation.requireNonBlank(version, "version"); + this.source = source; + this.rootCategory = ContentValidation.copyList(rootCategory); + this.index = buildIndex(); + } + + @Override + public String id() { + return id; + } + + @Override + public String version() { + return version; + } + + @Override + public Collection getIndex() { + return index; + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) throws IOException { + TemplateDescriptor descriptor = find(templateId); + Files.createDirectories(request.destination()); + copyResource(descriptor.entryPoint(), request.destination()); + for (ContentResource resource : source.resources()) { + if (DirectoryDocumentProvider.hidden(resource.path())) { + continue; + } + if (isSharedResource(resource.path())) { + copyResource(resource.path(), request.destination()); + } + } + } + + protected List buildIndex() throws IOException { + List descriptors = new ArrayList<>(); + int sortOrder = 0; + for (ContentResource resource : source.resources()) { + String path = resource.path(); + if (DirectoryDocumentProvider.hidden(path) + || path.contains("/") + || !path.toLowerCase(Locale.ROOT).endsWith(".gb")) { + continue; + } + descriptors.add(new TemplateDescriptor( + path, + DirectoryDocumentProvider.splitTitle(path.replaceFirst("\\.[^.]+$", "")), + "", + rootCategory.isEmpty() ? List.of("Samples") : rootCategory, + Set.of("sample"), + sortOrder++, + path, + List.of())); + } + return List.copyOf(descriptors); + } + + private TemplateDescriptor find(String templateId) throws IOException { + for (TemplateDescriptor descriptor : index) { + if (descriptor.id().equals(templateId)) { + return descriptor; + } + } + throw new IOException("Unknown template: " + templateId); + } + + private void copyResource(String path, Path destination) throws IOException { + String safePath = ContentPaths.normalize(path); + Path target = destination + .resolve(safePath.replace("/", destination.getFileSystem().getSeparator())) + .normalize(); + if (!target.startsWith(destination)) { + throw new IOException("Template resource escapes destination: " + path); + } + Files.createDirectories(target.getParent()); + try (InputStream input = source.open(safePath)) { + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private boolean isSharedResource(String path) { + int slash = path.indexOf('/'); + if (slash <= 0) { + return false; + } + return SHARED_DIRECTORIES.contains(path.substring(0, slash).toLowerCase(Locale.ROOT)); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentDescriptor.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentDescriptor.java new file mode 100644 index 00000000..bc5cef6b --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentDescriptor.java @@ -0,0 +1,24 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.List; +import java.util.Set; + +public record DocumentDescriptor( + String id, + String title, + String description, + List categoryPath, + Set tags, + int sortOrder, + List relatedTemplateIds) + implements IndexedContent { + + public DocumentDescriptor { + id = ContentValidation.requireNonBlank(id, "id"); + title = ContentValidation.requireNonBlank(title, "title"); + description = ContentValidation.optionalString(description); + categoryPath = ContentValidation.copyList(categoryPath); + tags = ContentValidation.copySet(tags); + relatedTemplateIds = ContentValidation.copyList(relatedTemplateIds); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentProvider.java new file mode 100644 index 00000000..9d3d7910 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/DocumentProvider.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.util.Collection; + +public interface DocumentProvider { + + /** + * Stable provider ID within the owning plugin. + */ + String id(); + + /** + * Used for cache invalidation. Prefer a plugin or content version. + */ + String version(); + + Collection getIndex(); + + ContentDocument openDocument(String documentId) throws IOException; +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/IndexedContent.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/IndexedContent.java new file mode 100644 index 00000000..28b5c9cc --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/IndexedContent.java @@ -0,0 +1,25 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.List; +import java.util.Set; + +public interface IndexedContent { + + /** + * Stable and unique within the provider. + */ + String id(); + + String title(); + + String description(); + + /** + * Logical UI hierarchy, not necessarily filesystem directories. + */ + List categoryPath(); + + Set tags(); + + int sortOrder(); +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/JarContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/JarContentSource.java new file mode 100644 index 00000000..a9d7d3a7 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/JarContentSource.java @@ -0,0 +1,14 @@ +package com.basic4gl.desktop.spi.content; + +import java.nio.file.Path; + +public final class JarContentSource extends ZipContentSource { + + public JarContentSource(Path jarPath) { + super(jarPath); + } + + public JarContentSource(Path jarPath, String rootPrefix) { + super(jarPath, rootPrefix); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyContentDocumentProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyContentDocumentProvider.java new file mode 100644 index 00000000..1a9d1899 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyContentDocumentProvider.java @@ -0,0 +1,59 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +@Deprecated +public final class LegacyContentDocumentProvider implements DocumentProvider { + + private final Content content; + private final DocumentDescriptor descriptor; + + public LegacyContentDocumentProvider(Content content) { + this.content = content; + this.descriptor = new DocumentDescriptor( + content.getFile().getName(), + content.getName(), + content.getMetadata().getDescription(), + List.of( + content.getMetadata().getCategory() == null + ? "Legacy" + : content.getMetadata().getCategory()), + Set.of( + content.getMetadata().getTags() == null + ? new String[0] + : content.getMetadata().getTags()), + 0, + List.of()); + } + + @Override + public String id() { + return "legacy-document-" + descriptor.id(); + } + + @Override + public String version() { + return content.getMetadata().getVersion() == null + ? "legacy" + : content.getMetadata().getVersion(); + } + + @Override + public Collection getIndex() { + return List.of(descriptor); + } + + @Override + public ContentDocument openDocument(String documentId) throws IOException { + if (!descriptor.id().equals(documentId)) { + throw new IOException("Unknown legacy document: " + documentId); + } + return new ContentDocument( + "text/plain", + new DirectoryContentSource(content.getFile().getParentFile().toPath()), + content.getFile().getName()); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyTemplateProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyTemplateProvider.java new file mode 100644 index 00000000..b56fac60 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/LegacyTemplateProvider.java @@ -0,0 +1,75 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +@Deprecated +public final class LegacyTemplateProvider implements TemplateProvider { + + private final Template template; + private final TemplateDescriptor descriptor; + + public LegacyTemplateProvider(Template template) { + this.template = template; + ContentMetadata metadata = template.getMetadata(); + Content[] content = template.getContent() == null ? new Content[0] : template.getContent(); + String entryPoint = content.length == 0 || content[0].getFile() == null + ? "" + : content[0].getFile().getName(); + this.descriptor = new TemplateDescriptor( + template.getName(), + template.getName(), + metadata.getDescription(), + List.of(metadata.getCategory() == null ? "Legacy" : metadata.getCategory()), + Set.of(metadata.getTags() == null ? new String[0] : metadata.getTags()), + 0, + entryPoint, + List.of()); + } + + @Override + public String id() { + return "legacy-template-" + descriptor.id(); + } + + @Override + public String version() { + return template.getMetadata().getVersion() == null + ? "legacy" + : template.getMetadata().getVersion(); + } + + @Override + public Collection getIndex() { + return List.of(descriptor); + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) throws IOException { + if (!descriptor.id().equals(templateId)) { + throw new IOException("Unknown legacy template: " + templateId); + } + Files.createDirectories(request.destination()); + Content[] content = template.getContent() == null ? new Content[0] : template.getContent(); + for (Content item : content) { + if (item.getFile() == null || !item.getFile().isFile()) { + continue; + } + Path target = + request.destination().resolve(item.getFile().getName()).normalize(); + if (!target.startsWith(request.destination())) { + throw new IOException("Legacy template resource escapes destination: " + + item.getFile().getName()); + } + try (InputStream input = Files.newInputStream(item.getFile().toPath())) { + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ManifestDocumentProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ManifestDocumentProvider.java new file mode 100644 index 00000000..d62a9b7d --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ManifestDocumentProvider.java @@ -0,0 +1,57 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +public final class ManifestDocumentProvider implements DocumentProvider { + + private final String id; + private final String version; + private final ContentSource source; + private final List descriptors; + + public ManifestDocumentProvider( + String id, String version, ContentSource source, Collection descriptors) { + this.id = ContentValidation.requireNonBlank(id, "id"); + this.version = ContentValidation.requireNonBlank(version, "version"); + this.source = source; + this.descriptors = List.copyOf(descriptors == null ? List.of() : descriptors); + } + + @Override + public String id() { + return id; + } + + @Override + public String version() { + return version; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public ContentDocument openDocument(String documentId) throws IOException { + for (DocumentDescriptor descriptor : descriptors) { + if (descriptor.id().equals(documentId)) { + return new ContentDocument(mediaType(descriptor.id()), source, descriptor.id()); + } + } + throw new IOException("Unknown document: " + documentId); + } + + private static String mediaType(String path) { + String lower = path.toLowerCase(); + if (lower.endsWith(".html") || lower.endsWith(".htm")) { + return "text/html"; + } + if (lower.endsWith(".txt")) { + return "text/plain"; + } + return "text/markdown"; + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/MapContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/MapContentSource.java new file mode 100644 index 00000000..c63a995f --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/MapContentSource.java @@ -0,0 +1,62 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +public final class MapContentSource implements ContentSource { + + private final Map> resources; + + public MapContentSource(Map> resources) { + Objects.requireNonNull(resources, "resources"); + Map> copied = new LinkedHashMap<>(); + for (Map.Entry> entry : resources.entrySet()) { + String path = ContentPaths.normalize(entry.getKey()); + Supplier supplier = Objects.requireNonNull(entry.getValue(), "resource supplier"); + if (copied.put(path, supplier::get) != null) { + throw new IllegalArgumentException("Duplicate content resource path: " + path); + } + } + this.resources = Map.copyOf(copied); + } + + public static MapContentSource fromBytes(Map resources) { + Objects.requireNonNull(resources, "resources"); + Map> suppliers = new LinkedHashMap<>(); + for (Map.Entry entry : resources.entrySet()) { + byte[] bytes = + Objects.requireNonNull(entry.getValue(), "resource bytes").clone(); + suppliers.put(entry.getKey(), () -> new ByteArrayInputStream(bytes)); + } + return new MapContentSource(suppliers); + } + + @Override + public InputStream open(String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + Supplier supplier = resources.get(safePath); + if (supplier == null) { + throw new FileNotFoundException("Content resource not found: " + normalizedPath); + } + InputStream input = supplier.get(); + if (input == null) { + throw new IOException("Content resource supplier returned null: " + safePath); + } + return input; + } + + @Override + public Collection resources() { + return resources.keySet().stream() + .sorted(String::compareToIgnoreCase) + .map(ContentResource::new) + .toList(); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java index 1527b430..1b369edd 100644 --- a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/Template.java @@ -1,5 +1,6 @@ package com.basic4gl.desktop.spi.content; +@Deprecated public class Template { private String name; diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateCreationRequest.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateCreationRequest.java new file mode 100644 index 00000000..fa06470c --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateCreationRequest.java @@ -0,0 +1,14 @@ +package com.basic4gl.desktop.spi.content; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; + +public record TemplateCreationRequest(Path destination, String projectName, Map variables) { + + public TemplateCreationRequest { + destination = Objects.requireNonNull(destination, "destination"); + projectName = ContentValidation.optionalString(projectName); + variables = variables == null ? Map.of() : Map.copyOf(variables); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateDescriptor.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateDescriptor.java new file mode 100644 index 00000000..6b4016db --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateDescriptor.java @@ -0,0 +1,26 @@ +package com.basic4gl.desktop.spi.content; + +import java.util.List; +import java.util.Set; + +public record TemplateDescriptor( + String id, + String title, + String description, + List categoryPath, + Set tags, + int sortOrder, + String entryPoint, + List relatedDocumentIds) + implements IndexedContent { + + public TemplateDescriptor { + id = ContentValidation.requireNonBlank(id, "id"); + title = ContentValidation.requireNonBlank(title, "title"); + description = ContentValidation.optionalString(description); + categoryPath = ContentValidation.copyList(categoryPath); + tags = ContentValidation.copySet(tags); + entryPoint = ContentValidation.optionalString(entryPoint); + relatedDocumentIds = ContentValidation.copyList(relatedDocumentIds); + } +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateProvider.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateProvider.java new file mode 100644 index 00000000..2e709ccd --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/TemplateProvider.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.IOException; +import java.util.Collection; + +public interface TemplateProvider { + + /** + * Stable provider ID within the owning plugin. + */ + String id(); + + /** + * Used for cache invalidation. Prefer a plugin or content version. + */ + String version(); + + Collection getIndex(); + + void instantiate(String templateId, TemplateCreationRequest request) throws IOException; +} diff --git a/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ZipContentSource.java b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ZipContentSource.java new file mode 100644 index 00000000..886571c5 --- /dev/null +++ b/app-spi/src/main/java/com/basic4gl/desktop/spi/content/ZipContentSource.java @@ -0,0 +1,85 @@ +package com.basic4gl.desktop.spi.content; + +import java.io.FileNotFoundException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Objects; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class ZipContentSource implements ContentSource { + + private final Path zipPath; + private final String rootPrefix; + + public ZipContentSource(Path zipPath) { + this(zipPath, ""); + } + + public ZipContentSource(Path zipPath, String rootPrefix) { + this.zipPath = + Objects.requireNonNull(zipPath, "zipPath").toAbsolutePath().normalize(); + if (rootPrefix == null || rootPrefix.isBlank()) { + this.rootPrefix = ""; + } else { + this.rootPrefix = ContentPaths.normalize(rootPrefix) + "/"; + } + } + + @Override + public InputStream open(String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + ZipFile zipFile = openZipFile(); + ZipEntry entry = zipFile.getEntry(rootPrefix + safePath); + if (entry == null || entry.isDirectory()) { + zipFile.close(); + throw new FileNotFoundException("Content resource not found: " + normalizedPath); + } + InputStream input = zipFile.getInputStream(entry); + return new FilterInputStream(input) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + zipFile.close(); + } + } + }; + } + + @Override + public Collection resources() throws IOException { + try (ZipFile zipFile = openZipFile()) { + return zipFile.stream() + .filter(entry -> !entry.isDirectory()) + .map(ZipEntry::getName) + .filter(this::isUnderRoot) + .map(this::removeRoot) + .map(ContentPaths::normalize) + .map(ContentResource::new) + .sorted((left, right) -> left.path().compareToIgnoreCase(right.path())) + .toList(); + } catch (IllegalArgumentException ex) { + throw new IOException("ZIP content contains an unsafe resource path", ex); + } + } + + protected ZipFile openZipFile() throws IOException { + return new ZipFile(zipPath.toFile()); + } + + private boolean isUnderRoot(String entryName) { + return rootPrefix.isEmpty() || entryName.startsWith(rootPrefix); + } + + private String removeRoot(String entryName) { + if (rootPrefix.isEmpty()) { + return entryName; + } + return entryName.substring(rootPrefix.length()); + } +} diff --git a/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentDescriptorTest.java b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentDescriptorTest.java new file mode 100644 index 00000000..ab7ed1d0 --- /dev/null +++ b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentDescriptorTest.java @@ -0,0 +1,95 @@ +package com.basic4gl.desktop.spi.content; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ContentDescriptorTest { + + @Test + void documentDescriptorRejectsBlankIdsAndTitles() { + assertThrows( + IllegalArgumentException.class, () -> new DocumentDescriptor("", "Title", null, null, null, 0, null)); + assertThrows( + IllegalArgumentException.class, () -> new DocumentDescriptor("doc", " ", null, null, null, 0, null)); + } + + @Test + void documentDescriptorUsesEmptyDefaultsAndDefensiveCopies() { + List categoryPath = new ArrayList<>(List.of("Graphics")); + Set tags = new HashSet<>(Set.of("learn")); + List relatedTemplateIds = new ArrayList<>(List.of("sprite-demo")); + + DocumentDescriptor descriptor = + new DocumentDescriptor("sprites", "Sprites", null, categoryPath, tags, 5, relatedTemplateIds); + + categoryPath.add("Mutated"); + tags.add("mutated"); + relatedTemplateIds.add("mutated"); + + assertEquals("", descriptor.description()); + assertEquals(List.of("Graphics"), descriptor.categoryPath()); + assertEquals(Set.of("learn"), descriptor.tags()); + assertEquals(List.of("sprite-demo"), descriptor.relatedTemplateIds()); + assertThrows( + UnsupportedOperationException.class, + () -> descriptor.categoryPath().add("Nope")); + } + + @Test + void templateDescriptorRejectsBlankIdsAndUsesEmptyDefaults() { + assertThrows( + IllegalArgumentException.class, + () -> new TemplateDescriptor(" ", "Template", null, null, null, 0, null, null)); + + TemplateDescriptor descriptor = new TemplateDescriptor("starter", "Starter", null, null, null, 0, null, null); + + assertEquals("", descriptor.description()); + assertEquals("", descriptor.entryPoint()); + assertEquals(List.of(), descriptor.categoryPath()); + assertEquals(Set.of(), descriptor.tags()); + assertEquals(List.of(), descriptor.relatedDocumentIds()); + } + + @Test + void contentDocumentRequiresMediaTypeSourceAndEntryPath() { + ContentSource source = new MapLikeTestSource(); + + assertThrows(IllegalArgumentException.class, () -> new ContentDocument("", source, "index.md")); + assertThrows(NullPointerException.class, () -> new ContentDocument("text/markdown", null, "index.md")); + assertThrows(IllegalArgumentException.class, () -> new ContentDocument("text/markdown", source, " ")); + } + + @Test + void templateCreationRequestCopiesVariablesAndDefaultsProjectName() { + Map variables = new HashMap<>(); + variables.put("name", "Sprite Demo"); + + TemplateCreationRequest request = new TemplateCreationRequest(Path.of("out"), null, variables); + variables.put("name", "Changed"); + + assertEquals("", request.projectName()); + assertEquals(Map.of("name", "Sprite Demo"), request.variables()); + assertThrows( + UnsupportedOperationException.class, () -> request.variables().put("other", "value")); + } + + private static final class MapLikeTestSource implements ContentSource { + @Override + public java.io.InputStream open(String normalizedPath) { + return new java.io.ByteArrayInputStream(new byte[0]); + } + + @Override + public java.util.Collection resources() { + return List.of(); + } + } +} diff --git a/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentHelperProviderTest.java b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentHelperProviderTest.java new file mode 100644 index 00000000..bc54a88f --- /dev/null +++ b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentHelperProviderTest.java @@ -0,0 +1,73 @@ +package com.basic4gl.desktop.spi.content; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ContentHelperProviderTest { + + @TempDir + Path tempDir; + + @Test + void directoryDocumentProviderInfersMarkdownDescriptors() throws Exception { + Files.writeString(tempDir.resolve("index.md"), "# Welcome\nStart"); + Files.writeString(tempDir.resolve("language-guide.md"), "# Language Guide\nReference"); + + DirectoryDocumentProvider provider = new DirectoryDocumentProvider("docs", "1", tempDir, List.of("Basic4GL")); + + assertEquals(2, provider.getIndex().size()); + DocumentDescriptor index = provider.getIndex().stream() + .filter(descriptor -> descriptor.id().equals("index.md")) + .findFirst() + .orElseThrow(); + assertEquals("Welcome", index.title()); + assertTrue(index.tags().contains("learn")); + + ContentDocument document = provider.openDocument("language-guide.md"); + assertEquals("text/markdown", document.mediaType()); + } + + @Test + void directoryTemplateProviderCopiesSelectedProgramAndSharedDirectoriesOnly() throws Exception { + Files.writeString(tempDir.resolve("SampleOne.gb"), "one"); + Files.writeString(tempDir.resolve("SampleTwo.gb"), "two"); + Files.createDirectories(tempDir.resolve("Data")); + Files.writeString(tempDir.resolve("Data/sprite.png"), "asset"); + Files.createDirectories(tempDir.resolve("Ignore")); + Files.writeString(tempDir.resolve("Ignore/file.txt"), "ignored"); + + DirectoryTemplateProvider provider = new DirectoryTemplateProvider("samples", "1", tempDir, List.of("Samples")); + Path destination = tempDir.resolveSibling("out"); + + provider.instantiate("SampleOne.gb", new TemplateCreationRequest(destination, "Sample One", null)); + + assertEquals("one", Files.readString(destination.resolve("SampleOne.gb"))); + assertFalse(Files.exists(destination.resolve("SampleTwo.gb"))); + assertEquals("asset", Files.readString(destination.resolve("Data/sprite.png"))); + assertFalse(Files.exists(destination.resolve("Ignore/file.txt"))); + } + + @Test + void classpathContentSourceEnumeratesFromIndex() throws Exception { + Path classes = Files.createDirectories(tempDir.resolve("classes")); + Files.createDirectories(classes.resolve("content/docs")); + Files.writeString(classes.resolve("content/docs/index.md"), "# Index"); + Files.writeString(classes.resolve("content/docs.index"), "index.md\n", StandardCharsets.UTF_8); + + try (URLClassLoader classLoader = + new URLClassLoader(new java.net.URL[] {classes.toUri().toURL()})) { + ClasspathContentSource source = + new ClasspathContentSource(classLoader, "content/docs", "content/docs.index"); + + assertEquals(List.of(new ContentResource("index.md")), source.resources()); + assertEquals("# Index", new String(source.open("index.md").readAllBytes(), StandardCharsets.UTF_8)); + } + } +} diff --git a/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentPathsTest.java b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentPathsTest.java new file mode 100644 index 00000000..a3d7b155 --- /dev/null +++ b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentPathsTest.java @@ -0,0 +1,40 @@ +package com.basic4gl.desktop.spi.content; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ContentPathsTest { + + @Test + void normalizesSeparatorsAndDotSegments() { + assertEquals("tutorials/images/sprite.png", ContentPaths.normalize("tutorials\\./images//sprite.png")); + assertEquals("images/sprite.png", ContentPaths.normalize("tutorials/../images/sprite.png")); + } + + @Test + void resolvesRelativeTargetsAgainstCurrentDocumentParent() { + assertEquals( + "docs/images/sprite.png", ContentPaths.resolve("docs/tutorials/sprites.md", "../images/sprite.png")); + assertEquals("docs/tutorials/page2.md", ContentPaths.resolve("docs/tutorials/sprites.md", "page2.md")); + } + + @Test + void rejectsEscapesAbsolutePathsAndUriSchemes() { + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("../../outside.png")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("/absolute/path")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("C:\\absolute\\path")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("file:///tmp/index.md")); + assertThrows( + IllegalArgumentException.class, () -> ContentPaths.normalize("jar:file:///tmp/docs.jar!/index.md")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("https://example.invalid/index.md")); + } + + @Test + void rejectsBlankAndRootOnlyPaths() { + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize(null)); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize(" ")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize(".")); + assertThrows(IllegalArgumentException.class, () -> ContentPaths.normalize("a/..")); + } +} diff --git a/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentSourceTest.java b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentSourceTest.java new file mode 100644 index 00000000..1988d3e0 --- /dev/null +++ b/app-spi/src/test/java/com/basic4gl/desktop/spi/content/ContentSourceTest.java @@ -0,0 +1,93 @@ +package com.basic4gl.desktop.spi.content; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ContentSourceTest { + + @TempDir + Path tempDir; + + @Test + void mapContentSourceOpensAndListsNormalizedResources() throws Exception { + MapContentSource source = + MapContentSource.fromBytes(Map.of("docs\\index.md", "Hello".getBytes(StandardCharsets.UTF_8))); + + assertEquals("Hello", readString(source.open("docs/index.md"))); + assertEquals(Set.of("docs/index.md"), paths(source)); + assertThrows(IllegalArgumentException.class, () -> source.open("../outside.md")); + assertThrows(FileNotFoundException.class, () -> source.open("missing.md")); + } + + @Test + void directoryContentSourceOpensAndListsResources() throws Exception { + Path docs = Files.createDirectories(tempDir.resolve("docs/images")); + Files.writeString(tempDir.resolve("docs/index.md"), "Index"); + Files.writeString(docs.resolve("sprite.png"), "Image"); + + DirectoryContentSource source = new DirectoryContentSource(tempDir); + + assertEquals("Index", readString(source.open("docs/index.md"))); + assertEquals(Set.of("docs/index.md", "docs/images/sprite.png"), paths(source)); + assertThrows(IllegalArgumentException.class, () -> source.open("../outside.md")); + } + + @Test + void zipContentSourceOpensAndListsResourcesUnderPrefix() throws Exception { + Path zipPath = tempDir.resolve("docs.zip"); + writeZip( + zipPath, + Map.of( + "content/index.md", "Index", + "content/images/sprite.png", "Image", + "other/ignored.md", "Ignored")); + + ZipContentSource source = new ZipContentSource(zipPath, "content"); + + assertEquals("Index", readString(source.open("index.md"))); + assertEquals(Set.of("index.md", "images/sprite.png"), paths(source)); + } + + @Test + void jarContentSourceUsesZipBehavior() throws Exception { + Path jarPath = tempDir.resolve("docs.jar"); + writeZip(jarPath, Map.of("docs/index.md", "Index")); + + JarContentSource source = new JarContentSource(jarPath, "docs"); + + assertEquals("Index", readString(source.open("index.md"))); + assertEquals(Set.of("index.md"), paths(source)); + } + + private static Set paths(ContentSource source) throws Exception { + return source.resources().stream().map(ContentResource::path).collect(Collectors.toSet()); + } + + private static String readString(InputStream input) throws Exception { + try (input) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static void writeZip(Path zipPath, Map entries) throws Exception { + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(zipPath))) { + for (Map.Entry entry : entries.entrySet()) { + output.putNextEntry(new ZipEntry(entry.getKey())); + output.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java index aded1014..cf55dd94 100644 --- a/app/src/main/java/com/basic4gl/desktop/BasicEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/BasicEditor.java @@ -8,13 +8,22 @@ import com.basic4gl.debug.protocol.callbacks.VariablesCallback; import com.basic4gl.debug.protocol.types.DisassembledInstruction; import com.basic4gl.debug.protocol.types.Variable; +import com.basic4gl.desktop.content.ContentDocumentViewer; +import com.basic4gl.desktop.content.ContentMaterializer; import com.basic4gl.desktop.content.FileEditor; import com.basic4gl.desktop.content.FileManager; +import com.basic4gl.desktop.content.TemplateInstantiator; +import com.basic4gl.desktop.content.catalog.ContentCatalog; +import com.basic4gl.desktop.content.catalog.DefaultContentService; +import com.basic4gl.desktop.content.catalog.DocumentCatalogEntry; +import com.basic4gl.desktop.content.catalog.TemplateCatalogEntry; import com.basic4gl.desktop.debugger.*; import com.basic4gl.desktop.editor.ApMode; import com.basic4gl.desktop.editor.BasicTokenMaker; import com.basic4gl.desktop.editor.IEditorPresenter; import com.basic4gl.desktop.spi.*; +import com.basic4gl.desktop.spi.content.ContentDocument; +import com.basic4gl.desktop.spi.content.ContentService; import com.basic4gl.desktop.spi.language.LanguageSupport; import com.basic4gl.desktop.util.*; import com.basic4gl.language.adapter.Basic4GLEditorPluginAdapter; @@ -24,6 +33,7 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.nio.file.Path; import java.util.*; import java.util.concurrent.CountDownLatch; import javax.swing.SwingUtilities; @@ -83,6 +93,11 @@ public class BasicEditor implements MainEditor, IApplicationHost, IFileProvider, private final Basic4GLEditorPluginAdapter basic4gl; private final DialogService dialogService; private final EditorCommandsService commandsService; + private final ContentCatalog contentCatalog = new ContentCatalog(); + private final ContentService contentService; + private final ContentMaterializer contentMaterializer = + new ContentMaterializer(Path.of(System.getProperty("user.home"), ".basic4glj", "cache")); + private final TemplateInstantiator templateInstantiator = new TemplateInstantiator(); public BasicEditor( String libraryPath, @@ -98,6 +113,7 @@ public BasicEditor( this.menuService = menuService; this.commandsService = commandsService; this.basic4gl = new Basic4GLEditorPluginAdapter(this); + this.contentService = new DefaultContentService(contentCatalog, basic4gl.getId(), basic4gl.getName()); this.basic4gl.setOnPluginStateChanged(this::refreshSyntaxHighlighting); this.basic4gl.setOnPluginDirectoryHistoryChanged(this::syncPluginDirectorySettings); } @@ -1111,6 +1127,40 @@ public MenuService menus() { return menuService; } + @Override + public ContentService content() { + return contentService; + } + + public ContentCatalog contentCatalog() { + return contentCatalog; + } + + public void openContentDocument(DocumentCatalogEntry entry) { + try { + ContentDocument document = + entry.provider().openDocument(entry.descriptor().id()); + Path materializedRoot = contentMaterializer.materialize( + entry.globalId().pluginId(), + entry.globalId().providerId(), + entry.providerVersion(), + document.source()); + presenter.openDocumentationPreview(new ContentDocumentViewer( + entry.globalId().value(), entry.descriptor().title(), document, materializedRoot)); + } catch (IOException | RuntimeException ex) { + dialogService.showDialog("Unable to open documentation: " + ex.getMessage()); + } + } + + public void instantiateTemplate(TemplateCatalogEntry entry, Path destination, String projectName) { + try { + Optional entryPoint = templateInstantiator.instantiate(entry, destination, projectName, Map.of()); + entryPoint.ifPresent(path -> commandsService.openFileWithPreferredViewer(path.toFile())); + } catch (IOException | RuntimeException ex) { + dialogService.showDialog("Unable to create sample: " + ex.getMessage()); + } + } + @Override public FileOpener files() { return fileOpener; @@ -1160,6 +1210,7 @@ public String getDefaultDebuggerPort() { } public void onCloseAll() { + contentCatalog.unregisterPlugin(basic4gl.getId()); basic4gl.onCloseAll(); } diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 17c36f78..0954e786 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -134,10 +134,9 @@ public void caretUpdate(CaretEvent e) { private static final Dimension TAB_VIEW_MODE_BUTTON_SIZE = new Dimension(34, 30); private static final String TAB_FILE_VIEWER_PROPERTY = "basic4gl.fileViewer"; private static final Color SEGMENTED_BACKGROUND = new Color(0xE0E0E0); - private static final String SEGMENTED_BUTTON_STYLE = - "arc: 14; borderWidth: 0; focusWidth: 0; innerFocusWidth: 0;" - + " margin: 6,8,6,8; background: #E0E0E0;" - + " hoverBackground: #D6D6D6; selectedBackground: #FFFFFF"; + private static final String SEGMENTED_BUTTON_STYLE = "arc: 14; borderWidth: 0; focusWidth: 0; innerFocusWidth: 0;" + + " margin: 6,8,6,8; background: #E0E0E0;" + + " hoverBackground: #D6D6D6; selectedBackground: #FFFFFF"; private final JMenu bookmarkSubMenu = new JMenu("Bookmarks"); private final JMenu breakpointSubMenu = new JMenu("Breakpoints"); @@ -737,7 +736,7 @@ public void windowDeactivated(WindowEvent e) {} new BookmarksPanelProvider(), (IEditorPanelProvider) debugPresenter, new SymbolsPanelProvider(), - new DocsPanelProvider(fileManager), + new DocsPanelProvider(), }; configureLeftSidebar(); @@ -1601,6 +1600,25 @@ public void changedUpdate(DocumentEvent e) { refreshSidebarContent(); } + public void openDocumentationPreview(ContentDocumentViewer viewer) { + int existingPreviewIndex = findUnpinnedDocumentationPreviewTab(); + if (existingPreviewIndex >= 0) { + closeTab(existingPreviewIndex); + } + addTab(viewer); + tabControl.setSelectedIndex(tabControl.getTabCount() - 1); + } + + private int findUnpinnedDocumentationPreviewTab() { + for (int i = 0; i < tabControl.getTabCount(); i++) { + IFileViewer viewer = getFileViewerAt(i); + if (viewer instanceof ContentDocumentViewer documentViewer && !documentViewer.isPinned()) { + return i; + } + } + return -1; + } + @Override public void placeCursorAtProcessed(final int row, int col) { lastSourceRow = row; diff --git a/app/src/main/java/com/basic4gl/desktop/content/ContentDocumentViewer.java b/app/src/main/java/com/basic4gl/desktop/content/ContentDocumentViewer.java new file mode 100644 index 00000000..2511c60c --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/ContentDocumentViewer.java @@ -0,0 +1,257 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.content.render.ContentNavigationHandler; +import com.basic4gl.desktop.content.render.ContentRenderRequest; +import com.basic4gl.desktop.content.render.ContentRenderer; +import com.basic4gl.desktop.content.render.HtmlContentRenderer; +import com.basic4gl.desktop.content.render.MarkdownContentRenderer; +import com.basic4gl.desktop.content.render.PlainTextContentRenderer; +import com.basic4gl.desktop.content.render.UnsupportedContentRenderer; +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.spi.content.ContentDocument; +import com.basic4gl.desktop.spi.content.ContentPaths; +import java.awt.BorderLayout; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Locale; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; + +public final class ContentDocumentViewer implements IFileViewer { + + private final String contentId; + private final String title; + private final ContentDocument rootDocument; + private final Path materializedRoot; + private final List renderers; + private final JPanel contentPane = new JPanel(new BorderLayout()); + private final JPanel documentHost = new JPanel(new BorderLayout()); + private final JButton backButton = new JButton("Back"); + private final JButton forwardButton = new JButton("Forward"); + private final JButton homeButton = new JButton("Home"); + private final JButton keepOpenButton = new JButton("Keep Open"); + private final ArrayDeque backStack = new ArrayDeque<>(); + private final ArrayDeque forwardStack = new ArrayDeque<>(); + + private String currentPath; + private boolean pinned; + + public ContentDocumentViewer(String contentId, String title, ContentDocument document, Path materializedRoot) + throws IOException { + this( + contentId, + title, + document, + materializedRoot, + List.of( + new MarkdownContentRenderer(), + new HtmlContentRenderer(), + new PlainTextContentRenderer(), + new UnsupportedContentRenderer())); + } + + public ContentDocumentViewer( + String contentId, + String title, + ContentDocument document, + Path materializedRoot, + List renderers) + throws IOException { + this.contentId = contentId == null || contentId.isBlank() ? "content" : contentId; + this.title = title == null || title.isBlank() ? "Documentation" : title; + this.rootDocument = document; + this.materializedRoot = materializedRoot; + this.renderers = List.copyOf(renderers); + this.currentPath = ContentPaths.normalize(document.entryPath()); + buildChrome(); + renderCurrent(); + } + + public void navigateTo(String normalizedPath) { + String nextPath = ContentPaths.normalize(normalizedPath); + if (nextPath.equals(currentPath)) { + return; + } + backStack.push(currentPath); + forwardStack.clear(); + currentPath = nextPath; + renderOrShowError(); + } + + public void goBack() { + if (backStack.isEmpty()) { + return; + } + forwardStack.push(currentPath); + currentPath = backStack.pop(); + renderOrShowError(); + } + + public void goForward() { + if (forwardStack.isEmpty()) { + return; + } + backStack.push(currentPath); + currentPath = forwardStack.pop(); + renderOrShowError(); + } + + public void goHome() { + navigateTo(rootDocument.entryPath()); + } + + public boolean isPinned() { + return pinned; + } + + public void keepOpen() { + pinned = true; + keepOpenButton.setEnabled(false); + } + + public String getCurrentPath() { + return currentPath; + } + + @Override + public String getTitle() { + return title; + } + + @Override + public String getFilePath() { + return "content:" + contentId + "#" + currentPath; + } + + @Override + public JComponent getContentPane() { + return contentPane; + } + + @Override + public File getFile() { + return null; + } + + @Override + public String getShortFilename() { + return title; + } + + @Override + public boolean isModified() { + return false; + } + + @Override + public void setModified() {} + + @Override + public ViewerType getViewerType() { + return ViewerType.DOCUMENTATION_VIEWER; + } + + @Override + public boolean hasPreview() { + return false; + } + + @Override + public void setViewMode(ViewMode viewMode) {} + + @Override + public ViewMode getViewMode() { + return ViewMode.PREVIEW; + } + + private void buildChrome() { + JPanel toolbar = new JPanel(); + toolbar.add(backButton); + toolbar.add(forwardButton); + toolbar.add(homeButton); + toolbar.add(keepOpenButton); + toolbar.add(new JLabel(title)); + + backButton.addActionListener(e -> goBack()); + forwardButton.addActionListener(e -> goForward()); + homeButton.addActionListener(e -> goHome()); + keepOpenButton.addActionListener(e -> keepOpen()); + + contentPane.add(toolbar, BorderLayout.NORTH); + contentPane.add(documentHost, BorderLayout.CENTER); + refreshButtons(); + } + + private void renderOrShowError() { + try { + renderCurrent(); + } catch (IOException | RuntimeException ex) { + showError(ex); + } + } + + private void renderCurrent() throws IOException { + ContentDocument currentDocument = + new ContentDocument(mediaTypeForPath(currentPath), rootDocument.source(), currentPath); + ContentRenderer renderer = rendererFor(currentDocument.mediaType()); + JComponent component = renderer.render(new ContentRenderRequest( + currentDocument, materializedRoot, currentPath, new ContentNavigationHandler() { + @Override + public void navigateTo(String normalizedPath) { + ContentDocumentViewer.this.navigateTo(normalizedPath); + } + + @Override + public void openExternal(URI uri) {} + })); + documentHost.removeAll(); + documentHost.add(component, BorderLayout.CENTER); + refreshButtons(); + documentHost.revalidate(); + documentHost.repaint(); + } + + private ContentRenderer rendererFor(String mediaType) { + return renderers.stream() + .filter(renderer -> renderer.supports(mediaType)) + .findFirst() + .orElseGet(UnsupportedContentRenderer::new); + } + + private String mediaTypeForPath(String path) { + String lower = path.toLowerCase(Locale.ROOT); + if (lower.endsWith(".md") || lower.endsWith(".markdown")) { + return "text/markdown"; + } + if (lower.endsWith(".html") || lower.endsWith(".htm")) { + return "text/html"; + } + if (lower.endsWith(".txt")) { + return "text/plain"; + } + return rootDocument.mediaType(); + } + + private void refreshButtons() { + backButton.setEnabled(!backStack.isEmpty()); + forwardButton.setEnabled(!forwardStack.isEmpty()); + homeButton.setEnabled(!currentPath.equals(rootDocument.entryPath())); + } + + private void showError(Exception ex) { + SwingUtilities.invokeLater(() -> { + documentHost.removeAll(); + documentHost.add(new JLabel("Unable to open documentation: " + ex.getMessage()), BorderLayout.CENTER); + refreshButtons(); + documentHost.revalidate(); + documentHost.repaint(); + }); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/ContentMaterializer.java b/app/src/main/java/com/basic4gl/desktop/content/ContentMaterializer.java new file mode 100644 index 00000000..80a01046 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/ContentMaterializer.java @@ -0,0 +1,138 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.spi.content.ContentPaths; +import com.basic4gl.desktop.spi.content.ContentResource; +import com.basic4gl.desktop.spi.content.ContentSource; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +public final class ContentMaterializer { + + private final Path cacheRoot; + + public ContentMaterializer(Path cacheRoot) { + this.cacheRoot = + Objects.requireNonNull(cacheRoot, "cacheRoot").toAbsolutePath().normalize(); + } + + public Path materialize(String pluginId, String providerId, String providerVersion, ContentSource source) + throws IOException { + Objects.requireNonNull(source, "source"); + Path finalRoot = versionRoot(pluginId, providerId, providerVersion); + if (Files.isDirectory(finalRoot)) { + return finalRoot; + } + + Path providerRoot = finalRoot.getParent(); + Files.createDirectories(providerRoot); + Path tempRoot = Files.createTempDirectory(providerRoot, ".tmp-"); + boolean moved = false; + try { + copyResources(source, tempRoot); + if (Files.isDirectory(finalRoot)) { + deleteRecursively(tempRoot); + return finalRoot; + } + moveIntoPlace(tempRoot, finalRoot); + moved = true; + return finalRoot; + } catch (FileAlreadyExistsException ex) { + deleteRecursively(tempRoot); + return finalRoot; + } finally { + if (!moved && Files.exists(tempRoot)) { + deleteRecursively(tempRoot); + } + } + } + + public void deleteObsoleteVersions(String pluginId, String providerId, String activeProviderVersion) + throws IOException { + Path providerRoot = cacheRoot + .resolve("content") + .resolve(safeKey(pluginId)) + .resolve(safeKey(providerId)) + .normalize(); + if (!Files.isDirectory(providerRoot)) { + return; + } + String activeKey = safeKey(activeProviderVersion); + try (var paths = Files.list(providerRoot)) { + for (Path path : paths.toList()) { + if (Files.isDirectory(path) && !path.getFileName().toString().equals(activeKey)) { + deleteRecursively(path); + } + } + } + } + + private void copyResources(ContentSource source, Path tempRoot) throws IOException { + Set copiedPaths = new HashSet<>(); + for (ContentResource resource : source.resources()) { + String resourcePath; + try { + resourcePath = ContentPaths.normalize(resource.path()); + } catch (IllegalArgumentException ex) { + throw new IOException("Unsafe content resource path: " + resource.path(), ex); + } + if (!copiedPaths.add(resourcePath)) { + throw new IOException("Duplicate content resource path: " + resourcePath); + } + Path target = tempRoot.resolve( + resourcePath.replace("/", tempRoot.getFileSystem().getSeparator())) + .normalize(); + if (!target.startsWith(tempRoot)) { + throw new IOException("Content resource escapes materialized root: " + resourcePath); + } + Files.createDirectories(target.getParent()); + try (InputStream input = source.open(resourcePath)) { + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private Path versionRoot(String pluginId, String providerId, String providerVersion) { + return cacheRoot + .resolve("content") + .resolve(safeKey(pluginId)) + .resolve(safeKey(providerId)) + .resolve(safeKey(providerVersion)) + .normalize(); + } + + private static String safeKey(String value) { + if (value == null || value.isBlank()) { + return "_"; + } + String safe = value.trim().replaceAll("[^A-Za-z0-9._-]", "_"); + return safe.isBlank() ? "_" : safe; + } + + private static void moveIntoPlace(Path tempRoot, Path finalRoot) throws IOException { + try { + Files.move(tempRoot, finalRoot, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(tempRoot, finalRoot); + } + } + + private static void deleteRecursively(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (var paths = Files.walk(root)) { + for (Path path : + paths.sorted((left, right) -> right.compareTo(left)).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/MarkdownHtmlSupport.java b/app/src/main/java/com/basic4gl/desktop/content/MarkdownHtmlSupport.java new file mode 100644 index 00000000..50cbcf46 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/MarkdownHtmlSupport.java @@ -0,0 +1,34 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.MainWindow; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public final class MarkdownHtmlSupport { + + private static final String DOCS_MARKDOWN_STYLESHEET_RESOURCE = "/css/docs-markdown.css"; + + private MarkdownHtmlSupport() {} + + public static String buildMarkdownDocumentHtml(String bodyHtml) { + String stylesheetText = readTextResource(DOCS_MARKDOWN_STYLESHEET_RESOURCE); + return "" + + bodyHtml + + ""; + } + + private static String readTextResource(String resourcePath) { + try (InputStream input = MainWindow.class.getResourceAsStream(resourcePath)) { + if (input == null) { + return ""; + } + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException ex) { + System.err.println("Unable to load resource " + resourcePath + ": " + ex.getMessage()); + return ""; + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java index 6d0d4ba0..0439fcda 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/content/MarkdownViewer.java @@ -2,7 +2,6 @@ import static com.basic4gl.desktop.util.HtmlUtil.markdownToHtml; -import com.basic4gl.desktop.MainWindow; import com.basic4gl.desktop.editor.IFileEditorActionListener; import com.basic4gl.desktop.editor.IToggleBreakpointListener; import com.basic4gl.desktop.spi.PluginContext; @@ -10,7 +9,6 @@ import com.basic4gl.desktop.util.IFileManager; import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -20,7 +18,6 @@ public class MarkdownViewer extends HtmlViewer { - private static final String DOCS_MARKDOWN_STYLESHEET_RESOURCE = "/css/docs-markdown.css"; private static final String DOCS_EXPLORER_TAB_TITLE = "Explorer"; public MarkdownViewer() { @@ -82,27 +79,6 @@ public ViewerType getViewerType() { @Override protected String renderPreviewHtml(String source) { - return buildMarkdownDocumentHtml(markdownToHtml(source == null ? "" : source)); - } - - private String readTextResource(String resourcePath) { - try (InputStream input = MainWindow.class.getResourceAsStream(resourcePath)) { - if (input == null) { - return ""; - } - return new String(input.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException ex) { - System.err.println("Unable to load resource " + resourcePath + ": " + ex.getMessage()); - return ""; - } - } - - private String buildMarkdownDocumentHtml(String bodyHtml) { - String stylesheetText = readTextResource(DOCS_MARKDOWN_STYLESHEET_RESOURCE); - return "" - + bodyHtml - + ""; + return MarkdownHtmlSupport.buildMarkdownDocumentHtml(markdownToHtml(source == null ? "" : source)); } } diff --git a/app/src/main/java/com/basic4gl/desktop/content/TemplateInstantiator.java b/app/src/main/java/com/basic4gl/desktop/content/TemplateInstantiator.java new file mode 100644 index 00000000..41127fbf --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/TemplateInstantiator.java @@ -0,0 +1,45 @@ +package com.basic4gl.desktop.content; + +import com.basic4gl.desktop.content.catalog.TemplateCatalogEntry; +import com.basic4gl.desktop.spi.content.ContentPaths; +import com.basic4gl.desktop.spi.content.TemplateCreationRequest; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class TemplateInstantiator { + + public Optional instantiate( + TemplateCatalogEntry entry, Path destination, String projectName, Map variables) + throws IOException { + Objects.requireNonNull(entry, "entry"); + Path safeDestination = Objects.requireNonNull(destination, "destination") + .toAbsolutePath() + .normalize(); + if (Files.exists(safeDestination)) { + throw new FileAlreadyExistsException(safeDestination.toString()); + } + + entry.provider() + .instantiate( + entry.descriptor().id(), new TemplateCreationRequest(safeDestination, projectName, variables)); + + String entryPoint = entry.descriptor().entryPoint(); + if (entryPoint == null || entryPoint.isBlank()) { + return Optional.empty(); + } + String normalizedEntryPoint = ContentPaths.normalize(entryPoint); + Path entryPointPath = safeDestination + .resolve(normalizedEntryPoint.replace( + "/", safeDestination.getFileSystem().getSeparator())) + .normalize(); + if (!entryPointPath.startsWith(safeDestination)) { + throw new IOException("Template entry point escapes destination: " + entryPoint); + } + return Optional.of(entryPointPath); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentBrowseNode.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentBrowseNode.java new file mode 100644 index 00000000..214cbc20 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentBrowseNode.java @@ -0,0 +1,54 @@ +package com.basic4gl.desktop.content.catalog; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public final class ContentBrowseNode { + + private final String name; + private final List children = new ArrayList<>(); + private final List items = new ArrayList<>(); + + public ContentBrowseNode(String name) { + this.name = name; + } + + public String name() { + return name; + } + + public List children() { + return List.copyOf(children); + } + + public List items() { + return List.copyOf(items); + } + + void add(List categoryPath, ContentPanelItem item) { + if (categoryPath.isEmpty()) { + items.add(item); + sort(); + return; + } + String childName = categoryPath.get(0); + ContentBrowseNode child = children.stream() + .filter(node -> node.name.equals(childName)) + .findFirst() + .orElseGet(() -> { + ContentBrowseNode node = new ContentBrowseNode(childName); + children.add(node); + return node; + }); + child.add(categoryPath.subList(1, categoryPath.size()), item); + sort(); + } + + private void sort() { + children.sort(Comparator.comparing(ContentBrowseNode::name, String.CASE_INSENSITIVE_ORDER)); + items.sort( + Comparator.comparing((ContentPanelItem item) -> item.content().sortOrder()) + .thenComparing(ContentPanelItem::title, String.CASE_INSENSITIVE_ORDER)); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalog.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalog.java new file mode 100644 index 00000000..f14d5d7b --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalog.java @@ -0,0 +1,223 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.ContentRegistration; +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class ContentCatalog { + + private final Map> documentProviders = new LinkedHashMap<>(); + private final Map> templateProviders = new LinkedHashMap<>(); + private final Set providerKeys = new HashSet<>(); + private final List listeners = new ArrayList<>(); + + public synchronized ContentRegistration registerDocumentProvider( + String pluginId, String pluginDisplayName, DocumentProvider provider) { + Objects.requireNonNull(provider, "provider"); + String providerId = requireNonBlank(provider.id(), "provider.id"); + String providerVersion = requireNonBlank(provider.version(), "provider.version"); + String providerKey = providerKey(pluginId, providerId); + rejectDuplicateProvider(providerKey); + + Collection descriptors = requireIndex(provider.getIndex(), "document index"); + List entries = new ArrayList<>(); + Set itemIds = new HashSet<>(); + for (DocumentDescriptor descriptor : descriptors) { + Objects.requireNonNull(descriptor, "document descriptor"); + if (!itemIds.add(descriptor.id())) { + throw new IllegalArgumentException( + "Duplicate document ID in provider " + providerId + ": " + descriptor.id()); + } + entries.add(new DocumentCatalogEntry( + new ContentGlobalId(pluginId, providerId, descriptor.id()), + pluginDisplayName, + providerVersion, + descriptor, + provider)); + } + + providerKeys.add(providerKey); + documentProviders.put(providerKey, List.copyOf(entries)); + warnForMissingDocumentRelationships(pluginId, entries); + notifyListeners(); + return new Registration(providerKey); + } + + public synchronized ContentRegistration registerTemplateProvider( + String pluginId, String pluginDisplayName, TemplateProvider provider) { + Objects.requireNonNull(provider, "provider"); + String providerId = requireNonBlank(provider.id(), "provider.id"); + String providerVersion = requireNonBlank(provider.version(), "provider.version"); + String providerKey = providerKey(pluginId, providerId); + rejectDuplicateProvider(providerKey); + + Collection descriptors = requireIndex(provider.getIndex(), "template index"); + List entries = new ArrayList<>(); + Set itemIds = new HashSet<>(); + for (TemplateDescriptor descriptor : descriptors) { + Objects.requireNonNull(descriptor, "template descriptor"); + if (!itemIds.add(descriptor.id())) { + throw new IllegalArgumentException( + "Duplicate template ID in provider " + providerId + ": " + descriptor.id()); + } + entries.add(new TemplateCatalogEntry( + new ContentGlobalId(pluginId, providerId, descriptor.id()), + pluginDisplayName, + providerVersion, + descriptor, + provider)); + } + + providerKeys.add(providerKey); + templateProviders.put(providerKey, List.copyOf(entries)); + warnForMissingTemplateRelationships(pluginId, entries); + notifyListeners(); + return new Registration(providerKey); + } + + public synchronized List documents() { + return documentProviders.values().stream().flatMap(List::stream).toList(); + } + + public synchronized List templates() { + return templateProviders.values().stream().flatMap(List::stream).toList(); + } + + public synchronized void addListener(ContentCatalogListener listener) { + listeners.add(Objects.requireNonNull(listener, "listener")); + } + + public synchronized void removeListener(ContentCatalogListener listener) { + listeners.remove(listener); + } + + public void unregisterPlugin(String pluginId) { + requireNonBlank(pluginId, "pluginId"); + boolean removed; + synchronized (this) { + removed = providerKeys.removeIf(providerKey -> providerKey.startsWith(pluginId + ":")); + documentProviders.keySet().removeIf(providerKey -> providerKey.startsWith(pluginId + ":")); + templateProviders.keySet().removeIf(providerKey -> providerKey.startsWith(pluginId + ":")); + } + if (removed) { + notifyListeners(); + } + } + + private void unregisterProvider(String providerKey) { + synchronized (this) { + if (!providerKeys.remove(providerKey)) { + return; + } + documentProviders.remove(providerKey); + templateProviders.remove(providerKey); + } + notifyListeners(); + } + + private void rejectDuplicateProvider(String providerKey) { + if (providerKeys.contains(providerKey)) { + throw new IllegalArgumentException("Duplicate content provider ID for plugin: " + providerKey); + } + } + + private static Collection requireIndex(Collection index, String description) { + if (index == null) { + throw new IllegalArgumentException(description + " must not be null"); + } + return index; + } + + private static String providerKey(String pluginId, String providerId) { + return requireNonBlank(pluginId, "pluginId") + ":" + providerId; + } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be null or blank"); + } + return value; + } + + private void warnForMissingDocumentRelationships(String pluginId, List entries) { + Set templateIds = templateIds(pluginId); + for (DocumentCatalogEntry entry : entries) { + for (String templateId : entry.descriptor().relatedTemplateIds()) { + if (!templateIds.contains(templateId)) { + System.err.println("Missing related template '" + templateId + "' for document '" + + entry.descriptor().id() + "'"); + } + } + } + } + + private void warnForMissingTemplateRelationships(String pluginId, List entries) { + Set documentIds = documentIds(pluginId); + for (TemplateCatalogEntry entry : entries) { + for (String documentId : entry.descriptor().relatedDocumentIds()) { + if (!documentIds.contains(documentId)) { + System.err.println("Missing related document '" + documentId + "' for template '" + + entry.descriptor().id() + "'"); + } + } + } + } + + private Set documentIds(String pluginId) { + Set ids = new HashSet<>(); + for (DocumentCatalogEntry entry : documents()) { + if (entry.globalId().pluginId().equals(pluginId)) { + ids.add(entry.descriptor().id()); + } + } + return ids; + } + + private Set templateIds(String pluginId) { + Set ids = new HashSet<>(); + for (TemplateCatalogEntry entry : templates()) { + if (entry.globalId().pluginId().equals(pluginId)) { + ids.add(entry.descriptor().id()); + } + } + return ids; + } + + private void notifyListeners() { + List listenerSnapshot; + synchronized (this) { + listenerSnapshot = List.copyOf(listeners); + } + for (ContentCatalogListener listener : listenerSnapshot) { + listener.contentCatalogChanged(); + } + } + + private final class Registration implements ContentRegistration { + private final String providerKey; + private boolean closed; + + private Registration(String providerKey) { + this.providerKey = providerKey; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + unregisterProvider(providerKey); + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalogListener.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalogListener.java new file mode 100644 index 00000000..410f3bce --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentCatalogListener.java @@ -0,0 +1,6 @@ +package com.basic4gl.desktop.content.catalog; + +public interface ContentCatalogListener { + + void contentCatalogChanged(); +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentGlobalId.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentGlobalId.java new file mode 100644 index 00000000..578b0dd3 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentGlobalId.java @@ -0,0 +1,24 @@ +package com.basic4gl.desktop.content.catalog; + +import java.util.Objects; + +public record ContentGlobalId(String pluginId, String providerId, String itemId) { + + public ContentGlobalId { + pluginId = requireNonBlank(pluginId, "pluginId"); + providerId = requireNonBlank(providerId, "providerId"); + itemId = requireNonBlank(itemId, "itemId"); + } + + public String value() { + return pluginId + ":" + providerId + ":" + itemId; + } + + private static String requireNonBlank(String value, String fieldName) { + Objects.requireNonNull(value, fieldName); + if (value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelItem.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelItem.java new file mode 100644 index 00000000..c7b609e5 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelItem.java @@ -0,0 +1,29 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.IndexedContent; +import java.util.List; +import java.util.Set; + +public record ContentPanelItem( + String globalId, + String title, + String subtitle, + String description, + List categoryPath, + Set tags, + boolean template, + IndexedContent content) { + + public String displayName() { + return title == null ? "" : title; + } + + public String displaySubtitle() { + return subtitle == null ? "" : subtitle; + } + + @Override + public String toString() { + return displayName(); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java new file mode 100644 index 00000000..a60d11e2 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java @@ -0,0 +1,191 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.IndexedContent; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public final class ContentPanelModel { + + private final ContentSearchIndex searchIndex = new ContentSearchIndex(); + + public List items(ContentCatalog catalog, ContentScope scope, String query) { + List documents = filteredDocuments(catalog.documents(), scope); + List templates = filteredTemplates(catalog.templates(), scope); + if (query != null && !query.isBlank()) { + return searchIndex.search(documents, templates, query).stream() + .map(this::toItem) + .toList(); + } + + List items = new ArrayList<>(); + documents.stream().map(this::toItem).forEach(items::add); + templates.stream().map(this::toItem).forEach(items::add); + items.sort((left, right) -> { + int sort = + Integer.compare(left.content().sortOrder(), right.content().sortOrder()); + if (sort != 0) { + return sort; + } + return left.title().compareToIgnoreCase(right.title()); + }); + return List.copyOf(items); + } + + public ContentBrowseNode browse(ContentCatalog catalog, ContentScope scope) { + ContentBrowseNode root = new ContentBrowseNode(scopeLabel(scope)); + for (ContentPanelItem item : items(catalog, scope, "")) { + root.add(item.categoryPath(), item); + } + return root; + } + + public ContentSelectionSummary summary(ContentPanelItem item) { + IndexedContent content = item.content(); + String category = String.join(" / ", content.categoryPath()); + if (content instanceof DocumentDescriptor descriptor) { + String kind = documentKindLabel(descriptor.tags()); + return new ContentSelectionSummary( + descriptor.title(), + kind, + descriptor.description(), + category, + descriptor.relatedTemplateIds(), + "Open"); + } + if (content instanceof TemplateDescriptor descriptor) { + String kind = templateKindLabel(descriptor.tags()); + String action = hasTag(descriptor.tags(), "sample") ? "Open Sample" : "Create Program"; + return new ContentSelectionSummary( + descriptor.title(), + kind, + descriptor.description(), + category, + descriptor.relatedDocumentIds(), + action); + } + return new ContentSelectionSummary( + content.title(), "Content", content.description(), category, List.of(), "Open"); + } + + private List filteredDocuments(List documents, ContentScope scope) { + return documents.stream() + .filter(entry -> switch (scope) { + case ALL -> true; + case LEARN -> isLearning(entry.descriptor().tags()); + case REFERENCE -> isReference(entry.descriptor().tags()); + case SAMPLES -> false; + }) + .toList(); + } + + private List filteredTemplates(List templates, ContentScope scope) { + return templates.stream() + .filter(entry -> switch (scope) { + case ALL -> true; + case LEARN, SAMPLES -> hasTag(entry.descriptor().tags(), "sample"); + case REFERENCE -> false; + }) + .toList(); + } + + private ContentPanelItem toItem(ContentSearchResult result) { + IndexedContent content = result.content(); + return new ContentPanelItem( + result.globalId(), + content.title(), + subtitle(content, result.template()), + content.description(), + content.categoryPath(), + content.tags(), + result.template(), + content); + } + + private ContentPanelItem toItem(DocumentCatalogEntry entry) { + DocumentDescriptor descriptor = entry.descriptor(); + return new ContentPanelItem( + entry.globalId().value(), + descriptor.title(), + subtitle(descriptor, false), + descriptor.description(), + descriptor.categoryPath(), + descriptor.tags(), + false, + descriptor); + } + + private ContentPanelItem toItem(TemplateCatalogEntry entry) { + TemplateDescriptor descriptor = entry.descriptor(); + return new ContentPanelItem( + entry.globalId().value(), + descriptor.title(), + subtitle(descriptor, true), + descriptor.description(), + descriptor.categoryPath(), + descriptor.tags(), + true, + descriptor); + } + + private String subtitle(IndexedContent content, boolean template) { + String kind = template ? templateKindLabel(content.tags()) : documentKindLabel(content.tags()); + String category = String.join(" / ", content.categoryPath()); + return category.isBlank() ? kind : kind + " · " + category; + } + + private String documentKindLabel(Set tags) { + if (hasTag(tags, "tutorial")) { + return "Tutorial"; + } + if (hasTag(tags, "guide")) { + return "Guide"; + } + if (hasTag(tags, "reference")) { + return "Reference"; + } + return "Document"; + } + + private String templateKindLabel(Set tags) { + if (hasTag(tags, "sample")) { + return "Sample"; + } + if (hasTag(tags, "starter")) { + return "Starter"; + } + if (hasTag(tags, "project-template")) { + return "Project Template"; + } + return "Template"; + } + + private boolean isLearning(Set tags) { + return hasAnyTag(tags, Set.of("learn", "tutorial", "getting-started")); + } + + private boolean isReference(Set tags) { + return hasAnyTag(tags, Set.of("reference", "guide")); + } + + private boolean hasAnyTag(Set tags, Set needles) { + return needles.stream().anyMatch(needle -> hasTag(tags, needle)); + } + + private boolean hasTag(Set tags, String needle) { + String normalizedNeedle = needle.toLowerCase(Locale.ROOT); + return tags.stream().map(tag -> tag.toLowerCase(Locale.ROOT)).anyMatch(normalizedNeedle::equals); + } + + private String scopeLabel(ContentScope scope) { + return switch (scope) { + case ALL -> "All"; + case LEARN -> "Learn"; + case REFERENCE -> "Reference"; + case SAMPLES -> "Samples"; + }; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java new file mode 100644 index 00000000..18f27529 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java @@ -0,0 +1,8 @@ +package com.basic4gl.desktop.content.catalog; + +public enum ContentScope { + ALL, + LEARN, + REFERENCE, + SAMPLES +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchIndex.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchIndex.java new file mode 100644 index 00000000..9adfac85 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchIndex.java @@ -0,0 +1,63 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.IndexedContent; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +public final class ContentSearchIndex { + + public List search( + List documents, List templates, String query) { + String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + List results = new ArrayList<>(); + for (DocumentCatalogEntry entry : documents) { + int score = score(entry.descriptor(), entry.pluginDisplayName(), normalizedQuery); + if (score > 0) { + results.add(new ContentSearchResult(entry.globalId().value(), entry.descriptor(), false, score)); + } + } + for (TemplateCatalogEntry entry : templates) { + int score = score(entry.descriptor(), entry.pluginDisplayName(), normalizedQuery); + if (score > 0) { + results.add(new ContentSearchResult(entry.globalId().value(), entry.descriptor(), true, score)); + } + } + results.sort(Comparator.comparingInt(ContentSearchResult::score) + .reversed() + .thenComparing(result -> result.content().sortOrder()) + .thenComparing(result -> result.content().title(), String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(results); + } + + private int score(IndexedContent content, String pluginDisplayName, String query) { + if (query.isBlank()) { + return 1; + } + String title = content.title().toLowerCase(Locale.ROOT); + if (title.equals(query)) { + return 100; + } + if (title.startsWith(query)) { + return 80; + } + if (title.contains(query)) { + return 60; + } + if (content.tags().stream().anyMatch(tag -> tag.toLowerCase(Locale.ROOT).equals(query))) { + return 50; + } + if (content.description().toLowerCase(Locale.ROOT).contains(query)) { + return 30; + } + if (content.categoryPath().stream() + .anyMatch(category -> category.toLowerCase(Locale.ROOT).contains(query))) { + return 25; + } + if (pluginDisplayName.toLowerCase(Locale.ROOT).contains(query)) { + return 10; + } + return 0; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchResult.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchResult.java new file mode 100644 index 00000000..29646491 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSearchResult.java @@ -0,0 +1,5 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.IndexedContent; + +public record ContentSearchResult(String globalId, IndexedContent content, boolean template, int score) {} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSelectionSummary.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSelectionSummary.java new file mode 100644 index 00000000..d8aba563 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentSelectionSummary.java @@ -0,0 +1,11 @@ +package com.basic4gl.desktop.content.catalog; + +import java.util.List; + +public record ContentSelectionSummary( + String title, + String kindLabel, + String description, + String category, + List relatedIds, + String primaryAction) {} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/DefaultContentService.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/DefaultContentService.java new file mode 100644 index 00000000..cb2d5588 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/DefaultContentService.java @@ -0,0 +1,37 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.ContentRegistration; +import com.basic4gl.desktop.spi.content.ContentService; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.util.Objects; + +public final class DefaultContentService implements ContentService { + + private final ContentCatalog catalog; + private final String pluginId; + private final String pluginDisplayName; + + public DefaultContentService(ContentCatalog catalog, String pluginId, String pluginDisplayName) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.pluginId = requireNonBlank(pluginId, "pluginId"); + this.pluginDisplayName = pluginDisplayName == null ? "" : pluginDisplayName; + } + + @Override + public ContentRegistration registerDocumentProvider(DocumentProvider provider) { + return catalog.registerDocumentProvider(pluginId, pluginDisplayName, provider); + } + + @Override + public ContentRegistration registerTemplateProvider(TemplateProvider provider) { + return catalog.registerTemplateProvider(pluginId, pluginDisplayName, provider); + } + + private static String requireNonBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be null or blank"); + } + return value; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/DocumentCatalogEntry.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/DocumentCatalogEntry.java new file mode 100644 index 00000000..96a8a8f0 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/DocumentCatalogEntry.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import java.util.Objects; + +public record DocumentCatalogEntry( + ContentGlobalId globalId, + String pluginDisplayName, + String providerVersion, + DocumentDescriptor descriptor, + DocumentProvider provider) { + + public DocumentCatalogEntry { + globalId = Objects.requireNonNull(globalId, "globalId"); + pluginDisplayName = pluginDisplayName == null ? "" : pluginDisplayName; + providerVersion = providerVersion == null ? "" : providerVersion; + descriptor = Objects.requireNonNull(descriptor, "descriptor"); + provider = Objects.requireNonNull(provider, "provider"); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/TemplateCatalogEntry.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/TemplateCatalogEntry.java new file mode 100644 index 00000000..202fb11b --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/TemplateCatalogEntry.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.content.catalog; + +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.util.Objects; + +public record TemplateCatalogEntry( + ContentGlobalId globalId, + String pluginDisplayName, + String providerVersion, + TemplateDescriptor descriptor, + TemplateProvider provider) { + + public TemplateCatalogEntry { + globalId = Objects.requireNonNull(globalId, "globalId"); + pluginDisplayName = pluginDisplayName == null ? "" : pluginDisplayName; + providerVersion = providerVersion == null ? "" : providerVersion; + descriptor = Objects.requireNonNull(descriptor, "descriptor"); + provider = Objects.requireNonNull(provider, "provider"); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/ContentNavigationHandler.java b/app/src/main/java/com/basic4gl/desktop/content/render/ContentNavigationHandler.java new file mode 100644 index 00000000..7b11ef6c --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/ContentNavigationHandler.java @@ -0,0 +1,18 @@ +package com.basic4gl.desktop.content.render; + +import java.net.URI; + +public interface ContentNavigationHandler { + + ContentNavigationHandler NO_OP = new ContentNavigationHandler() { + @Override + public void navigateTo(String normalizedPath) {} + + @Override + public void openExternal(URI uri) {} + }; + + void navigateTo(String normalizedPath); + + void openExternal(URI uri); +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderRequest.java b/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderRequest.java new file mode 100644 index 00000000..92ff7718 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderRequest.java @@ -0,0 +1,21 @@ +package com.basic4gl.desktop.content.render; + +import com.basic4gl.desktop.spi.content.ContentDocument; +import java.nio.file.Path; +import java.util.Objects; + +public record ContentRenderRequest( + ContentDocument document, + Path materializedRoot, + String currentPath, + ContentNavigationHandler navigationHandler) { + + public ContentRenderRequest { + document = Objects.requireNonNull(document, "document"); + materializedRoot = Objects.requireNonNull(materializedRoot, "materializedRoot") + .toAbsolutePath() + .normalize(); + currentPath = currentPath == null || currentPath.isBlank() ? document.entryPath() : currentPath; + navigationHandler = navigationHandler == null ? ContentNavigationHandler.NO_OP : navigationHandler; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderer.java new file mode 100644 index 00000000..24aaabfd --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/ContentRenderer.java @@ -0,0 +1,11 @@ +package com.basic4gl.desktop.content.render; + +import java.io.IOException; +import javax.swing.JComponent; + +public interface ContentRenderer { + + boolean supports(String mediaType); + + JComponent render(ContentRenderRequest request) throws IOException; +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/HtmlContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/HtmlContentRenderer.java new file mode 100644 index 00000000..b99ca6fc --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/HtmlContentRenderer.java @@ -0,0 +1,18 @@ +package com.basic4gl.desktop.content.render; + +import java.io.IOException; +import javax.swing.JComponent; + +public final class HtmlContentRenderer implements ContentRenderer { + + @Override + public boolean supports(String mediaType) { + return RendererSupport.mediaTypeEquals(mediaType, "text/html"); + } + + @Override + public JComponent render(ContentRenderRequest request) throws IOException { + String html = RendererSupport.readString(request.materializedRoot(), request.currentPath()); + return RendererSupport.htmlComponent(html, request.navigationHandler()); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/MarkdownContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/MarkdownContentRenderer.java new file mode 100644 index 00000000..437a8005 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/MarkdownContentRenderer.java @@ -0,0 +1,156 @@ +package com.basic4gl.desktop.content.render; + +import com.basic4gl.desktop.content.MarkdownHtmlSupport; +import com.basic4gl.desktop.spi.content.ContentPaths; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import javax.swing.JComponent; +import org.commonmark.node.Image; +import org.commonmark.node.Link; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.AttributeProvider; +import org.commonmark.renderer.html.AttributeProviderContext; +import org.commonmark.renderer.html.AttributeProviderFactory; +import org.commonmark.renderer.html.HtmlRenderer; + +public final class MarkdownContentRenderer implements ContentRenderer { + + private static final long MAX_IMAGE_BYTES = 10L * 1024L * 1024L; + private static final int MAX_IMAGE_DIMENSION = 8192; + + @Override + public boolean supports(String mediaType) { + return RendererSupport.mediaTypeEquals(mediaType, "text/markdown") + || RendererSupport.mediaTypeEquals(mediaType, "text/x-markdown"); + } + + @Override + public JComponent render(ContentRenderRequest request) throws IOException { + String markdown = RendererSupport.readString(request.materializedRoot(), request.currentPath()); + Parser parser = Parser.builder().build(); + HtmlRenderer renderer = HtmlRenderer.builder() + .escapeHtml(true) + .attributeProviderFactory( + new ContentAttributeProviderFactory(request.materializedRoot(), request.currentPath())) + .build(); + Node document = parser.parse(markdown); + String html = MarkdownHtmlSupport.buildMarkdownDocumentHtml( + "" + renderer.render(document) + ""); + return RendererSupport.htmlComponent(html, request.navigationHandler()); + } + + private record ContentAttributeProviderFactory(Path materializedRoot, String currentPath) + implements AttributeProviderFactory { + @Override + public AttributeProvider create(AttributeProviderContext context) { + return new ContentAttributeProvider(materializedRoot, currentPath); + } + } + + private record ContentAttributeProvider(Path materializedRoot, String currentPath) implements AttributeProvider { + @Override + public void setAttributes(Node node, String tagName, Map attributes) { + if (node instanceof Image) { + rewriteImage(attributes); + } else if (node instanceof Link) { + rewriteLink(attributes); + } + } + + private void rewriteImage(Map attributes) { + String destination = attributes.get("src"); + if (destination == null || isExternal(destination)) { + attributes.put("src", missingImagePlaceholder("Remote image blocked")); + return; + } + try { + String resolved = ContentPaths.resolve(currentPath, destination); + Path imagePath = RendererSupport.resolveMaterializedPath(materializedRoot, resolved); + if (!Files.isRegularFile(imagePath) || !isAllowedImage(imagePath)) { + attributes.put("src", missingImagePlaceholder("Image unavailable")); + return; + } + attributes.put("src", imagePath.toUri().toString()); + } catch (IOException | IllegalArgumentException ex) { + attributes.put("src", missingImagePlaceholder("Image unavailable")); + } + } + + private void rewriteLink(Map attributes) { + String destination = attributes.get("href"); + if (destination == null) { + return; + } + if (isExternal(destination)) { + return; + } + try { + String resolved = ContentPaths.resolve(currentPath, destination); + if (isNavigableDocument(resolved)) { + attributes.put("href", "content:" + resolved); + } else { + Path target = RendererSupport.resolveMaterializedPath(materializedRoot, resolved); + attributes.put("href", target.toUri().toString()); + } + } catch (IOException | IllegalArgumentException ex) { + attributes.put("href", "#"); + } + } + + private boolean isAllowedImage(Path imagePath) throws IOException { + if (Files.size(imagePath) > MAX_IMAGE_BYTES) { + return false; + } + try (ImageInputStream input = ImageIO.createImageInputStream(imagePath.toFile())) { + if (input == null) { + return true; + } + var readers = ImageIO.getImageReaders(input); + if (!readers.hasNext()) { + return true; + } + ImageReader reader = readers.next(); + try { + reader.setInput(input); + return reader.getWidth(0) <= MAX_IMAGE_DIMENSION && reader.getHeight(0) <= MAX_IMAGE_DIMENSION; + } finally { + reader.dispose(); + } + } + } + + private boolean isNavigableDocument(String path) { + String lower = path.toLowerCase(Locale.ROOT); + return lower.endsWith(".md") + || lower.endsWith(".markdown") + || lower.endsWith(".html") + || lower.endsWith(".htm") + || lower.endsWith(".txt"); + } + + private boolean isExternal(String destination) { + String lower = destination.toLowerCase(Locale.ROOT); + return lower.startsWith("http://") + || lower.startsWith("https://") + || lower.startsWith("file:") + || lower.startsWith("jar:"); + } + + private String missingImagePlaceholder(String message) { + String svg = "" + + "" + + "" + + message + ""; + return "data:image/svg+xml;utf8," + URLEncoder.encode(svg, StandardCharsets.UTF_8); + } + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/PlainTextContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/PlainTextContentRenderer.java new file mode 100644 index 00000000..b1aa88d4 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/PlainTextContentRenderer.java @@ -0,0 +1,28 @@ +package com.basic4gl.desktop.content.render; + +import java.awt.BorderLayout; +import java.awt.Font; +import java.io.IOException; +import javax.swing.JComponent; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; + +public final class PlainTextContentRenderer implements ContentRenderer { + + @Override + public boolean supports(String mediaType) { + return RendererSupport.mediaTypeEquals(mediaType, "text/plain"); + } + + @Override + public JComponent render(ContentRenderRequest request) throws IOException { + JTextArea textArea = + new JTextArea(RendererSupport.readString(request.materializedRoot(), request.currentPath())); + textArea.setEditable(false); + textArea.setFont(new Font("Monospaced", Font.PLAIN, 12)); + JPanel panel = new JPanel(new BorderLayout()); + panel.add(new JScrollPane(textArea), BorderLayout.CENTER); + return panel; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/RendererSupport.java b/app/src/main/java/com/basic4gl/desktop/content/render/RendererSupport.java new file mode 100644 index 00000000..282ae817 --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/RendererSupport.java @@ -0,0 +1,70 @@ +package com.basic4gl.desktop.content.render; + +import com.basic4gl.desktop.spi.content.ContentPaths; +import java.awt.BorderLayout; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import javax.swing.JComponent; +import javax.swing.JEditorPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.event.HyperlinkEvent; + +final class RendererSupport { + + private RendererSupport() {} + + static boolean mediaTypeEquals(String actual, String expected) { + String normalizedActual = + actual == null ? "" : actual.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); + return normalizedActual.equals(expected); + } + + static String readString(Path materializedRoot, String currentPath) throws IOException { + Path path = resolveMaterializedPath(materializedRoot, currentPath); + return Files.readString(path, StandardCharsets.UTF_8); + } + + static Path resolveMaterializedPath(Path materializedRoot, String normalizedPath) throws IOException { + String safePath = ContentPaths.normalize(normalizedPath); + Path path = materializedRoot + .resolve(safePath.replace("/", materializedRoot.getFileSystem().getSeparator())) + .normalize(); + if (!path.startsWith(materializedRoot)) { + throw new IOException("Content path escapes materialized root: " + normalizedPath); + } + return path; + } + + static JComponent htmlComponent(String html, ContentNavigationHandler navigationHandler) { + JEditorPane pane = new JEditorPane(); + pane.setEditable(false); + pane.setContentType("text/html"); + pane.putClientProperty("basic4gl.content.html", html); + pane.addHyperlinkListener(event -> { + if (event.getEventType() != HyperlinkEvent.EventType.ACTIVATED) { + return; + } + String description = event.getDescription(); + if (description != null && description.startsWith("content:")) { + navigationHandler.navigateTo(description.substring("content:".length())); + return; + } + if (event.getURL() != null) { + URI uri = URI.create(event.getURL().toExternalForm()); + if ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) { + navigationHandler.openExternal(uri); + } + } + }); + pane.setText(html); + pane.setCaretPosition(0); + JPanel panel = new JPanel(new BorderLayout()); + panel.add(new JScrollPane(pane), BorderLayout.CENTER); + return panel; + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/content/render/UnsupportedContentRenderer.java b/app/src/main/java/com/basic4gl/desktop/content/render/UnsupportedContentRenderer.java new file mode 100644 index 00000000..f476044a --- /dev/null +++ b/app/src/main/java/com/basic4gl/desktop/content/render/UnsupportedContentRenderer.java @@ -0,0 +1,22 @@ +package com.basic4gl.desktop.content.render; + +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; + +import javax.swing.JComponent; + +public final class UnsupportedContentRenderer implements ContentRenderer { + + @Override + public boolean supports(String mediaType) { + return true; + } + + @Override + public JComponent render(ContentRenderRequest request) { + String html = "" + + "

        Unsupported content

        This document uses media type " + + escapeHtml(request.document().mediaType()) + + ", which cannot be displayed by this version of Basic4GLj.

        "; + return RendererSupport.htmlComponent(html, request.navigationHandler()); + } +} diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java index 47e01487..cf4b7b44 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IEditorPresenter.java @@ -3,6 +3,7 @@ import com.basic4gl.debug.protocol.callbacks.DisassembleCallback; import com.basic4gl.debug.protocol.callbacks.StackTraceCallback; import com.basic4gl.debug.protocol.callbacks.VariablesCallback; +import com.basic4gl.desktop.content.ContentDocumentViewer; import java.io.File; import java.util.List; @@ -42,4 +43,6 @@ public interface IEditorPresenter { void setRecentItems(List files); void refreshSyntaxHighlighting(); + + void openDocumentationPreview(ContentDocumentViewer viewer); } diff --git a/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java b/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java index 8ee7f257..076bbfff 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/IFileViewer.java @@ -73,7 +73,8 @@ enum ViewerType { AUDIO_VIEWER("Audio Viewer"), HEX_VIEWER("Hex Editor"), MARKDOWN_VIEWER("Markdown Viewer"), - HTML_VIEWER("HTML Viewer"); + HTML_VIEWER("HTML Viewer"), + DOCUMENTATION_VIEWER("Documentation"); public final String display; diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java index bf98cc5b..4e871aa9 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -1,40 +1,86 @@ package com.basic4gl.desktop.panels; -import static com.basic4gl.desktop.Theme.*; -import static com.basic4gl.desktop.util.SwingIconUtil.createImageIcon; +import static com.basic4gl.desktop.Theme.ICON_CHEVRON_DOWN; +import static com.basic4gl.desktop.Theme.ICON_MENU_DOCS; +import static com.basic4gl.desktop.Theme.ICON_MENU_DOCS_SOLID; +import static com.basic4gl.desktop.Theme.ICON_SEARCH; +import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; +import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; +import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; +import static com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground; +import static com.basic4gl.desktop.util.SwingUtil.hideSplitPaneHandle; -import com.basic4gl.desktop.content.FileManager; +import com.basic4gl.desktop.BasicEditor; +import com.basic4gl.desktop.content.catalog.ContentBrowseNode; +import com.basic4gl.desktop.content.catalog.ContentCatalogListener; +import com.basic4gl.desktop.content.catalog.ContentPanelItem; +import com.basic4gl.desktop.content.catalog.ContentPanelModel; +import com.basic4gl.desktop.content.catalog.ContentScope; +import com.basic4gl.desktop.content.catalog.ContentSelectionSummary; +import com.basic4gl.desktop.content.catalog.TemplateCatalogEntry; import com.basic4gl.desktop.spi.EditorPlugin; import com.basic4gl.desktop.spi.PluginContext; -import java.awt.*; +import com.basic4gl.desktop.util.RoundedCardPanel; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.Insets; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; -import java.io.File; -import java.util.Arrays; -import java.util.Comparator; -import java.util.Locale; -import javax.swing.*; +import java.nio.file.Path; +import java.util.List; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTextField; +import javax.swing.JTextPane; +import javax.swing.JToggleButton; +import javax.swing.JTree; +import javax.swing.ListSelectionModel; +import javax.swing.ScrollPaneConstants; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; import javax.swing.border.EmptyBorder; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; -import javax.swing.filechooser.FileSystemView; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeModel; -import javax.swing.tree.TreePath; public class DocsPanelProvider implements IEditorPanelProvider { - private final JTabbedPane docsTabs = new JTabbedPane(); - private final JTree docsExplorerTree = new JTree(); - private final JTextField docsExplorerSearchField = new JTextField(); - private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); + private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); - private final FileManager fileManager; + private final ContentPanelModel contentModel = new ContentPanelModel(); + private final JButton scopeButton = new JButton(); + private final JTextField searchField = new JTextField(); + private final JTree browseTree = new JTree(); + private final DefaultListModel searchListModel = new DefaultListModel<>(); + private final JList searchList = new JList<>(searchListModel); + private final JPanel resultsCards = new JPanel(new CardLayout()); + private final JTextPane summaryPane = new JTextPane(); + private final JLabel selectionNameLabel = new JLabel("Select an item."); + private final JButton primaryAction = new JButton("Open"); + private final ContentCatalogListener catalogListener = this::refreshContent; - public DocsPanelProvider(FileManager fileManager) { - this.fileManager = fileManager; - } + private BasicEditor editor; + private ContentPanelItem selectedItem; + private ContentScope currentScope = ContentScope.ALL; @Override public String id() { @@ -68,76 +114,196 @@ public EditorLayout getLayoutConstraints() { @Override public JPanel build(PluginContext context) { - JPanel panel = new JPanel(new BorderLayout(0, 6)); - Color panelBackground = com.basic4gl.desktop.util.SwingUtil.createLighterPanelBackground(); - panel.setBackground(panelBackground); - panel.setOpaque(true); - - JPanel header = new JPanel(); - header.setLayout(new BoxLayout(header, BoxLayout.X_AXIS)); - header.setBackground(panelBackground); - JLabel title = new JLabel("Docs Explorer"); - Font baseFont = title.getFont(); - title.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); - title.setForeground(new Color(0x424242)); - title.setBorder(new EmptyBorder(0, 8, 0, 8)); - - JButton refresh = new JButton(createImageIcon(ICON_REFRESH)); - refresh.setToolTipText("Refresh Docs Explorer"); - refresh.setFocusable(false); - refresh.putClientProperty("JButton.buttonType", "toolBarButton"); - refresh.setOpaque(false); - refresh.addActionListener(e -> refreshDocsExplorerTree()); - - JToggleButton searchToggle = new JToggleButton(createImageIcon(ICON_SEARCH)); - searchToggle.setToolTipText("Show search"); - searchToggle.setFocusable(false); - searchToggle.putClientProperty("JButton.buttonType", "toolBarButton"); - searchToggle.setOpaque(false); - - header.add(title); - header.add(Box.createHorizontalGlue()); - header.add(searchToggle); - header.add(refresh); - panel.add(header, BorderLayout.NORTH); + if (context instanceof BasicEditor basicEditor) { + this.editor = basicEditor; + this.editor.contentCatalog().addListener(catalogListener); + } + + JPanel panelCardHost = new JPanel(new CardLayout()); + JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); + Color panelBackground = createLighterPanelBackground(); + lookupPanel.setBackground(panelBackground); + lookupPanel.setBorder(new EmptyBorder(0, 6, 6, 6)); + + configureScopeButton(); + JToggleButton searchToggle = createHeaderSearchToggleButton(); + JPanel lookupHeader = new JPanel(); + lookupHeader.setBackground(panelBackground); + lookupHeader.setLayout(new BoxLayout(lookupHeader, BoxLayout.X_AXIS)); + lookupHeader.add(scopeButton); + lookupHeader.add(Box.createHorizontalGlue()); + lookupHeader.add(searchToggle); JPanel searchBar = new JPanel(new BorderLayout(6, 0)); searchBar.setBackground(panelBackground); searchBar.setBorder(new EmptyBorder(0, 8, 0, 8)); - docsExplorerSearchField.setToolTipText("Search markdown files"); - searchBar.add(docsExplorerSearchField, BorderLayout.CENTER); + searchField.setToolTipText("Search documentation and samples"); + searchBar.add(searchField, BorderLayout.CENTER); searchBar.setVisible(false); searchToggle.addActionListener(e -> { boolean visible = searchToggle.isSelected(); searchBar.setVisible(visible); if (visible) { - docsExplorerSearchField.requestFocusInWindow(); + searchField.requestFocusInWindow(); } - panel.revalidate(); - panel.repaint(); + lookupPanel.revalidate(); + lookupPanel.repaint(); }); - docsExplorerSearchField.getDocument().addDocumentListener(new DocumentListener() { + + configureBrowseTree(panelBackground); + configureSearchList(panelBackground); + JScrollPane browseScrollPane = new JScrollPane(browseTree); + JScrollPane searchScrollPane = new JScrollPane(searchList); + browseScrollPane.setBorder(null); + searchScrollPane.setBorder(null); + configureSmoothScrolling(browseScrollPane); + configureSmoothScrolling(searchScrollPane); + resultsCards.add(browseScrollPane, "browse"); + resultsCards.add(searchScrollPane, "search"); + + JPanel resultsPanel = new JPanel(new BorderLayout(0, 6)); + resultsPanel.setBackground(panelBackground); + JPanel resultsHeader = new JPanel(new BorderLayout(0, 6)); + resultsHeader.setOpaque(false); + resultsHeader.add(lookupHeader, BorderLayout.NORTH); + resultsHeader.add(searchBar, BorderLayout.SOUTH); + resultsPanel.add(resultsHeader, BorderLayout.NORTH); + resultsPanel.add(resultsCards, BorderLayout.CENTER); + + configureSummary(panelBackground); + JScrollPane detailsScrollPane = new JScrollPane(createDetailsPanel(panelBackground)); + detailsScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + detailsScrollPane.setBorder(null); + configureSmoothScrolling(detailsScrollPane); + + JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + splitPane.setResizeWeight(0.66); + splitPane.setBorder(null); + hideSplitPaneHandle(splitPane); + splitPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); + splitPane.putClientProperty("JSplitPane.style", "plain"); + splitPane.setTopComponent(createRoundedCardHost(resultsPanel, panelBackground, "docs-results")); + splitPane.setBottomComponent(createRoundedCardHost(detailsScrollPane, panelBackground, "docs-details")); + lookupPanel.add(splitPane, BorderLayout.CENTER); + + searchField.getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent e) { - refreshDocsExplorerTree(); + refreshContent(); } @Override public void removeUpdate(DocumentEvent e) { - refreshDocsExplorerTree(); + refreshContent(); } @Override public void changedUpdate(DocumentEvent e) { - refreshDocsExplorerTree(); + refreshContent(); } }); - docsExplorerTree.setBackground(panelBackground); - docsExplorerTree.setRootVisible(true); - docsExplorerTree.setShowsRootHandles(true); - docsExplorerTree.setRowHeight(22); - docsExplorerTree.setCellRenderer(new DefaultTreeCellRenderer() { + refreshContent(); + panelCardHost.add(lookupPanel, "main"); + ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); + return panelCardHost; + } + + @Override + public void refresh(EditorPlugin languageProvider) { + refreshContent(); + } + + @Override + public void onFileModified(String filePath) {} + + @Override + public void dispose() { + if (editor != null) { + editor.contentCatalog().removeListener(catalogListener); + } + } + + @Override + public void onCompileSucceeded() {} + + private void configureScopeButton() { + scopeButton.setFocusable(false); + scopeButton.setIcon(createScaledIcon(ICON_CHEVRON_DOWN, 18)); + scopeButton.setHorizontalTextPosition(SwingConstants.LEFT); + scopeButton.setIconTextGap(6); + scopeButton.putClientProperty("JButton.buttonType", "toolBarButton"); + scopeButton.setOpaque(false); + scopeButton.setMargin(new Insets(5, 8, 5, 8)); + Font baseFont = scopeButton.getFont(); + scopeButton.setFont(new Font(baseFont.getName(), Font.BOLD, baseFont.getSize() + 2)); + scopeButton.setForeground(new Color(0x5B717F)); + scopeButton.setToolTipText("Choose content scope"); + updateScopeButtonText(); + scopeButton.addActionListener(e -> createScopePopup().show(scopeButton, 0, scopeButton.getHeight())); + } + + private JPopupMenu createScopePopup() { + JPopupMenu popup = new JPopupMenu(); + for (ContentScope scope : ContentScope.values()) { + JMenuItem item = new JMenuItem(scopeLabel(scope)); + item.setEnabled(scope != currentScope); + item.addActionListener(e -> { + currentScope = scope; + updateScopeButtonText(); + refreshContent(); + }); + popup.add(item); + } + return popup; + } + + private void updateScopeButtonText() { + scopeButton.setText(scopeLabel(currentScope)); + } + + private String scopeLabel(ContentScope scope) { + return switch (scope) { + case ALL -> "All"; + case LEARN -> "Learn"; + case REFERENCE -> "Reference"; + case SAMPLES -> "Samples"; + }; + } + + private JToggleButton createHeaderSearchToggleButton() { + JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); + button.setToolTipText("Show search"); + button.setFocusable(false); + button.putClientProperty("JButton.buttonType", "toolBarButton"); + button.setOpaque(false); + button.setMargin(new Insets(6, 6, 6, 6)); + button.setPreferredSize(HEADER_ICON_BUTTON_SIZE); + button.setMinimumSize(HEADER_ICON_BUTTON_SIZE); + button.setMaximumSize(HEADER_ICON_BUTTON_SIZE); + return button; + } + + private JComponent createRoundedCardHost(JComponent content, Color panelBackground, String key) { + Color cardBackground = createLighterPanelBackground(); + JPanel card = new RoundedCardPanel(); + card.setLayout(new BorderLayout()); + card.setBackground(cardBackground); + card.setBorder(new EmptyBorder(4, 4, 4, 4)); + card.add(content, BorderLayout.CENTER); + + JPanel host = new JPanel(new CardLayout()); + host.setOpaque(false); + host.add(card, key); + ((CardLayout) host.getLayout()).show(host, key); + return host; + } + + private void configureBrowseTree(Color panelBackground) { + browseTree.setBackground(panelBackground); + browseTree.setRootVisible(true); + browseTree.setShowsRootHandles(true); + browseTree.setCellRenderer(new DefaultTreeCellRenderer() { @Override public Component getTreeCellRendererComponent( JTree tree, @@ -147,108 +313,212 @@ public Component getTreeCellRendererComponent( boolean leaf, int row, boolean hasFocus) { - JLabel label = (JLabel) - super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); - if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof File file) { - label.setText(fileSystemView.getSystemDisplayName(file)); - if (label.getText() == null || label.getText().isBlank()) { - label.setText(file.getName().isBlank() ? file.getPath() : file.getName()); - } - label.setIcon(fileSystemView.getSystemIcon(file)); - label.setToolTipText(file.getAbsolutePath()); + JLabel label = (JLabel) super.getTreeCellRendererComponent( + tree, value, selected, expanded, leaf, row, hasFocus); + Object userObject = value instanceof DefaultMutableTreeNode treeNode ? treeNode.getUserObject() : value; + if (userObject instanceof ContentPanelItem item) { + label.setText(formatItemLabel(item)); + label.setToolTipText(item.displayName()); + } else { + label.setText(escapeHtml(String.valueOf(userObject))); + label.setToolTipText(null); } return label; } }); - docsExplorerTree.addMouseListener(new MouseAdapter() { + browseTree.addTreeSelectionListener(e -> { + Object selectedNode = browseTree.getLastSelectedPathComponent(); + if (!(selectedNode instanceof DefaultMutableTreeNode treeNode)) { + return; + } + Object selected = treeNode.getUserObject(); + if (selected instanceof ContentPanelItem item) { + selectItem(item); + } + }); + browseTree.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { - if (e.getClickCount() != 2) { - return; + if (e.getClickCount() == 2) { + runPrimaryAction(); } - TreePath path = docsExplorerTree.getPathForLocation(e.getX(), e.getY()); - if (path == null) { - return; - } - Object userObject = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); - if (!(userObject instanceof File selectedFile) || !selectedFile.isFile()) { - return; + } + }); + } + + private void configureSearchList(Color panelBackground) { + searchList.setBackground(panelBackground); + searchList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + searchList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent( + JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = + (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof ContentPanelItem item) { + label.setText(formatItemLabel(item)); + label.setToolTipText(item.displayName()); } - if (selectedFile.getName().toLowerCase(Locale.ROOT).endsWith(".md")) { - context.commands().openFileWithPreferredViewer(selectedFile); + return label; + } + }); + searchList.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + selectItem(searchList.getSelectedValue()); + } + }); + searchList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + runPrimaryAction(); } } }); + } - JScrollPane scrollPane = new JScrollPane(docsExplorerTree); - scrollPane.setBorder(null); - scrollPane.setBackground(panelBackground); - com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling(scrollPane); + private String formatItemLabel(ContentPanelItem item) { + String subtitle = item.displaySubtitle(); + if (subtitle.isBlank()) { + return "" + escapeHtml(item.displayName()) + ""; + } + return "" + escapeHtml(item.displayName()) + "
        " + + escapeHtml(subtitle) + ""; + } - JPanel content = new JPanel(new BorderLayout(0, 6)); - content.setBackground(panelBackground); - content.add(searchBar, BorderLayout.NORTH); - content.add(scrollPane, BorderLayout.CENTER); - panel.add(content, BorderLayout.CENTER); - return panel; + private void configureSummary(Color panelBackground) { + summaryPane.setEditable(false); + summaryPane.setContentType("text/html"); + summaryPane.setBorder(null); + summaryPane.setBackground(panelBackground); + primaryAction.setEnabled(false); + primaryAction.setFocusable(false); + primaryAction.setMargin(new Insets(4, 8, 4, 8)); + primaryAction.setForeground(new Color(0x5B717F)); + primaryAction.addActionListener(e -> runPrimaryAction()); + Font nameFont = selectionNameLabel.getFont(); + selectionNameLabel.setFont(new Font(nameFont.getName(), Font.BOLD, nameFont.getSize())); + int titleHeight = selectionNameLabel.getPreferredSize().height; + selectionNameLabel.setMinimumSize(new Dimension(0, titleHeight)); + selectionNameLabel.setPreferredSize(new Dimension(0, titleHeight)); + setSelectionName("Select an item."); } - @Override - public void refresh(EditorPlugin languageProvider) {} + private JPanel createDetailsPanel(Color panelBackground) { + JPanel detailsPanel = new JPanel(new BorderLayout(0, 0)); + detailsPanel.setBackground(panelBackground); + JPanel detailsHeader = new JPanel(new BorderLayout(8, 0)); + detailsHeader.setBackground(panelBackground); + detailsHeader.setOpaque(true); + detailsHeader.setBorder(new EmptyBorder(6, 8, 4, 8)); + detailsHeader.add(selectionNameLabel, BorderLayout.CENTER); + JPanel detailsActions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + detailsActions.setOpaque(false); + detailsActions.add(primaryAction); + detailsHeader.add(detailsActions, BorderLayout.EAST); + detailsPanel.add(detailsHeader, BorderLayout.NORTH); + detailsPanel.add(summaryPane, BorderLayout.CENTER); + return detailsPanel; + } - @Override - public void onFileModified(String filePath) {} + private void refreshContent() { + if (editor == null) { + setSummary(null); + return; + } + String query = + searchField.getText() == null ? "" : searchField.getText().trim(); + if (query.isBlank()) { + ContentBrowseNode root = contentModel.browse(editor.contentCatalog(), currentScope); + browseTree.setModel(new DefaultTreeModel(toTreeNode(root))); + browseTree.expandRow(0); + ((CardLayout) resultsCards.getLayout()).show(resultsCards, "browse"); + } else { + searchListModel.clear(); + for (ContentPanelItem item : contentModel.items(editor.contentCatalog(), currentScope, query)) { + searchListModel.addElement(item); + } + ((CardLayout) resultsCards.getLayout()).show(resultsCards, "search"); + } + setSummary(selectedItem); + } - @Override - public void dispose() {} + private DefaultMutableTreeNode toTreeNode(ContentBrowseNode node) { + DefaultMutableTreeNode treeNode = new DefaultMutableTreeNode(node.name()); + for (ContentBrowseNode child : node.children()) { + treeNode.add(toTreeNode(child)); + } + for (ContentPanelItem item : node.items()) { + treeNode.add(new DefaultMutableTreeNode(item)); + } + return treeNode; + } - @Override - public void onCompileSucceeded() {} + private void selectItem(ContentPanelItem item) { + selectedItem = item; + setSummary(item); + } - private void refreshDocsExplorerTree() { - if (fileManager == null) { + private void setSummary(ContentPanelItem item) { + if (item == null) { + setSelectionName("Select an item."); + summaryPane.setText("Select an item."); + primaryAction.setText("Open"); + primaryAction.setEnabled(false); return; } - File root = new File(fileManager.getCurrentDirectory()); - String searchNeedle = docsExplorerSearchField.getText() == null - ? "" - : docsExplorerSearchField.getText().trim().toLowerCase(Locale.ROOT); - DefaultMutableTreeNode rootNode = buildDocsTreeNode(root, 0, searchNeedle); - if (rootNode == null) { - rootNode = new DefaultMutableTreeNode(root); - } - docsExplorerTree.setModel(new DefaultTreeModel(rootNode)); - if (docsExplorerTree.getRowCount() > 0) { - docsExplorerTree.expandRow(0); - } + ContentSelectionSummary summary = contentModel.summary(item); + setSelectionName(summary.title()); + summaryPane.setText("" + + "

        " + escapeHtml(summary.kindLabel()) + "

        " + + "

        " + escapeHtml(summary.description()) + "

        " + + "

        Category: " + escapeHtml(summary.category()) + "

        " + + relatedHtml(summary.relatedIds()) + + ""); + summaryPane.setCaretPosition(0); + primaryAction.setText(summary.primaryAction()); + primaryAction.setEnabled(true); } - private DefaultMutableTreeNode buildDocsTreeNode(File file, int depth, String searchNeedle) { - boolean hasSearch = searchNeedle != null && !searchNeedle.isBlank(); - String fileName = file.getName().toLowerCase(Locale.ROOT); - String absolutePath = file.getAbsolutePath().toLowerCase(Locale.ROOT); - boolean matchesSearch = !hasSearch || fileName.contains(searchNeedle) || absolutePath.contains(searchNeedle); + private void setSelectionName(String name) { + String text = name == null ? "" : name; + selectionNameLabel.setText(text); + selectionNameLabel.setToolTipText(text.isBlank() ? null : text); + } - if (!file.isDirectory()) { - boolean isMarkdown = fileName.endsWith(".md"); - return (isMarkdown && matchesSearch) ? new DefaultMutableTreeNode(file) : null; + private String relatedHtml(List relatedIds) { + if (relatedIds == null || relatedIds.isEmpty()) { + return ""; } + return "

        Related: " + escapeHtml(String.join(", ", relatedIds)) + "

        "; + } - DefaultMutableTreeNode node = new DefaultMutableTreeNode(file); - File[] children = file.listFiles(); - if (children == null) { - return (depth == 0 || matchesSearch) ? node : null; + private void runPrimaryAction() { + if (editor == null || selectedItem == null) { + return; } - Arrays.sort(children, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); - for (File child : children) { - if (child.getName().startsWith(".")) { - continue; - } - DefaultMutableTreeNode childNode = buildDocsTreeNode(child, depth + 1, searchNeedle); - if (childNode != null) { - node.add(childNode); - } + if (!selectedItem.template()) { + editor.contentCatalog().documents().stream() + .filter(entry -> entry.globalId().value().equals(selectedItem.globalId())) + .findFirst() + .ifPresent(editor::openContentDocument); + return; + } + editor.contentCatalog().templates().stream() + .filter(entry -> entry.globalId().value().equals(selectedItem.globalId())) + .findFirst() + .ifPresent(this::instantiateTemplate); + } + + private void instantiateTemplate(TemplateCatalogEntry entry) { + JFileChooser chooser = new JFileChooser(editor.currentDirectory()); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setDialogTitle("Choose destination folder"); + int result = chooser.showSaveDialog(SwingUtilities.getWindowAncestor(primaryAction)); + if (result != JFileChooser.APPROVE_OPTION) { + return; } - return (depth == 0 || node.getChildCount() > 0 || matchesSearch) ? node : null; + Path destination = chooser.getSelectedFile().toPath(); + editor.instantiateTemplate(entry, destination, entry.descriptor().title()); } } diff --git a/app/src/main/resources/images/material/icon_template.png b/app/src/main/resources/images/material/icon_template.png new file mode 100644 index 0000000000000000000000000000000000000000..177c3d31e20bcbc5b40df406fb6c533bd7ca3179 GIT binary patch literal 382 zcmV-^0fGLBP)4zW@JXdvFUj-2NFL?I#y!nVFtuhGI1LZk_w59vl%8w=``Of2Q2`AsEq{UG&8-G>0F=(uu0SP0K^#~bj!jHB_J(e@>NyG<-krP z;jTsuK!D-yj_tnO!OR(@5(AzKd-a$@B@JHGi2)0PcKmg~)cs-1Ti`s-%c?VYtb4En zLc9TjcZhFqb%y5;0Og>18r|Ccn75$u9JmZG9|C~Cu&(*W0p9=s0RR6yo9>JN000I_ cL_t&o0RNA$F*g;Tpa1{>07*qoM6N<$g4U&{xc~qF literal 0 HcmV?d00001 diff --git a/app/src/test/java/com/basic4gl/desktop/content/ContentDocumentViewerTest.java b/app/src/test/java/com/basic4gl/desktop/content/ContentDocumentViewerTest.java new file mode 100644 index 00000000..fc8558c3 --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/ContentDocumentViewerTest.java @@ -0,0 +1,51 @@ +package com.basic4gl.desktop.content; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.editor.IFileViewer; +import com.basic4gl.desktop.spi.content.ContentDocument; +import com.basic4gl.desktop.spi.content.MapContentSource; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ContentDocumentViewerTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void documentationViewerTracksNavigationHistoryAndPinnedState() throws Exception { + MapContentSource source = MapContentSource.fromBytes(Map.of( + "index.md", "# Home".getBytes(StandardCharsets.UTF_8), + "page2.txt", "Page 2".getBytes(StandardCharsets.UTF_8))); + Path root = new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()) + .materialize("plugin", "docs", "1", source); + + ContentDocumentViewer viewer = new ContentDocumentViewer( + "plugin:docs:index", "Docs", new ContentDocument("text/markdown", source, "index.md"), root); + + assertEquals(IFileViewer.ViewerType.DOCUMENTATION_VIEWER, viewer.getViewerType()); + assertEquals("index.md", viewer.getCurrentPath()); + assertFalse(viewer.isModified()); + assertTrue(viewer.getFilePath().startsWith("content:plugin:docs:index#index.md")); + + viewer.navigateTo("page2.txt"); + assertEquals("page2.txt", viewer.getCurrentPath()); + + viewer.goBack(); + assertEquals("index.md", viewer.getCurrentPath()); + + viewer.goForward(); + assertEquals("page2.txt", viewer.getCurrentPath()); + + viewer.goHome(); + assertEquals("index.md", viewer.getCurrentPath()); + + viewer.keepOpen(); + assertTrue(viewer.isPinned()); + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/ContentMaterializerTest.java b/app/src/test/java/com/basic4gl/desktop/content/ContentMaterializerTest.java new file mode 100644 index 00000000..5af40c75 --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/ContentMaterializerTest.java @@ -0,0 +1,94 @@ +package com.basic4gl.desktop.content; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.spi.content.ContentResource; +import com.basic4gl.desktop.spi.content.ContentSource; +import com.basic4gl.desktop.spi.content.DirectoryContentSource; +import com.basic4gl.desktop.spi.content.MapContentSource; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ContentMaterializerTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void materializesMapContentAndReusesSameVersion() throws Exception { + ContentMaterializer materializer = + new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()); + MapContentSource source = MapContentSource.fromBytes(Map.of( + "index.md", "Index".getBytes(StandardCharsets.UTF_8), + "images/sprite.png", "Image".getBytes(StandardCharsets.UTF_8))); + + Path first = materializer.materialize("plugin id", "docs", "1.0", source); + Path second = materializer.materialize("plugin id", "docs", "1.0", source); + + assertEquals(first, second); + assertEquals("Index", Files.readString(first.resolve("index.md"))); + assertEquals("Image", Files.readString(first.resolve("images/sprite.png"))); + assertTrue(first.toString().contains("plugin_id")); + } + + @Test + public void providerVersionChangesMaterializedDirectory() throws Exception { + ContentMaterializer materializer = + new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()); + MapContentSource source = + MapContentSource.fromBytes(Map.of("index.md", "Index".getBytes(StandardCharsets.UTF_8))); + + Path version1 = materializer.materialize("plugin", "docs", "1", source); + Path version2 = materializer.materialize("plugin", "docs", "2", source); + + assertNotEquals(version1, version2); + + materializer.deleteObsoleteVersions("plugin", "docs", "2"); + + assertFalse(Files.exists(version1)); + assertTrue(Files.exists(version2)); + } + + @Test + public void materializesDirectorySource() throws Exception { + Path root = temporaryFolder.newFolder("docs").toPath(); + Files.createDirectories(root.resolve("tutorials")); + Files.writeString(root.resolve("tutorials/sprites.md"), "Sprites"); + + ContentMaterializer materializer = + new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()); + Path materialized = materializer.materialize("plugin", "docs", "1", new DirectoryContentSource(root)); + + assertEquals("Sprites", Files.readString(materialized.resolve("tutorials/sprites.md"))); + } + + @Test + public void rejectsUnsafeResourcePaths() throws Exception { + ContentMaterializer materializer = + new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()); + + assertThrows(IOException.class, () -> materializer.materialize("plugin", "docs", "1", new UnsafeSource())); + } + + private static final class UnsafeSource implements ContentSource { + @Override + public InputStream open(String normalizedPath) { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public Collection resources() { + return List.of(new ContentResource("../../outside.md")); + } + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/TemplateInstantiatorTest.java b/app/src/test/java/com/basic4gl/desktop/content/TemplateInstantiatorTest.java new file mode 100644 index 00000000..34155e1f --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/TemplateInstantiatorTest.java @@ -0,0 +1,90 @@ +package com.basic4gl.desktop.content; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.content.catalog.ContentGlobalId; +import com.basic4gl.desktop.content.catalog.TemplateCatalogEntry; +import com.basic4gl.desktop.spi.content.TemplateCreationRequest; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class TemplateInstantiatorTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void instantiatesGeneratedTemplateAndReturnsEntryPoint() throws Exception { + GeneratedTemplateProvider provider = new GeneratedTemplateProvider(); + TemplateCatalogEntry entry = new TemplateCatalogEntry( + new ContentGlobalId("plugin", "templates", "sample"), + "Plugin", + "1", + new TemplateDescriptor( + "sample", "Sample", "", List.of("Graphics"), Set.of("sample"), 0, "src/main.gb", List.of()), + provider); + Path destination = temporaryFolder.getRoot().toPath().resolve("SpriteDemo"); + + Optional entryPoint = + new TemplateInstantiator().instantiate(entry, destination, "Sprite Demo", Map.of("message", "Hello")); + + assertTrue(entryPoint.isPresent()); + assertEquals(destination.resolve("src/main.gb").toAbsolutePath().normalize(), entryPoint.get()); + assertEquals("Hello", Files.readString(entryPoint.get())); + } + + @Test + public void refusesExistingDestinationBeforeCallingProvider() throws Exception { + GeneratedTemplateProvider provider = new GeneratedTemplateProvider(); + Path destination = temporaryFolder.newFolder("Existing").toPath(); + TemplateCatalogEntry entry = new TemplateCatalogEntry( + new ContentGlobalId("plugin", "templates", "sample"), + "Plugin", + "1", + new TemplateDescriptor("sample", "Sample", "", List.of(), Set.of("sample"), 0, "main.gb", List.of()), + provider); + + assertThrows(FileAlreadyExistsException.class, () -> new TemplateInstantiator() + .instantiate(entry, destination, "", Map.of())); + assertFalse(provider.called); + } + + private static final class GeneratedTemplateProvider implements TemplateProvider { + private boolean called; + + @Override + public String id() { + return "templates"; + } + + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return List.of(); + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) throws IOException { + called = true; + Path entryPoint = request.destination().resolve("src/main.gb"); + Files.createDirectories(entryPoint.getParent()); + Files.writeString(entryPoint, request.variables().getOrDefault("message", "")); + } + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentCatalogTest.java b/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentCatalogTest.java new file mode 100644 index 00000000..e384dc01 --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentCatalogTest.java @@ -0,0 +1,187 @@ +package com.basic4gl.desktop.content.catalog; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.spi.content.ContentRegistration; +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import com.basic4gl.desktop.spi.content.TemplateCreationRequest; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class ContentCatalogTest { + + @Test + public void registersAndRemovesDocumentProviders() { + ContentCatalog catalog = new ContentCatalog(); + AtomicInteger changes = new AtomicInteger(); + catalog.addListener(changes::incrementAndGet); + + ContentRegistration registration = catalog.registerDocumentProvider( + "plugin", "Plugin", new TestDocumentProvider("docs", List.of(document("sprites", "Sprites")))); + + assertEquals(1, catalog.documents().size()); + assertEquals( + "plugin:docs:sprites", catalog.documents().get(0).globalId().value()); + assertEquals(1, changes.get()); + + registration.close(); + registration.close(); + + assertTrue(catalog.documents().isEmpty()); + assertEquals(2, changes.get()); + } + + @Test + public void rejectsDuplicateProviderIdsForSamePluginAcrossProviderTypes() { + ContentCatalog catalog = new ContentCatalog(); + catalog.registerDocumentProvider( + "plugin", "Plugin", new TestDocumentProvider("content", List.of(document("doc", "Doc")))); + + assertThrows( + IllegalArgumentException.class, + () -> catalog.registerTemplateProvider( + "plugin", + "Plugin", + new TestTemplateProvider("content", List.of(template("sample", "Sample"))))); + } + + @Test + public void duplicateItemIdsRejectProviderAtomically() { + ContentCatalog catalog = new ContentCatalog(); + + assertThrows( + IllegalArgumentException.class, + () -> catalog.registerDocumentProvider( + "plugin", + "Plugin", + new TestDocumentProvider("docs", List.of(document("same", "One"), document("same", "Two"))))); + + assertTrue(catalog.documents().isEmpty()); + } + + @Test + public void missingRelationshipsDoNotRejectProvider() { + ContentCatalog catalog = new ContentCatalog(); + + catalog.registerDocumentProvider( + "plugin", + "Plugin", + new TestDocumentProvider( + "docs", + List.of(new DocumentDescriptor( + "sprites", + "Sprites", + "", + List.of("Graphics"), + Set.of("learn"), + 0, + List.of("missing-template"))))); + + assertEquals(1, catalog.documents().size()); + } + + @Test + public void pluginUnloadRemovesOwnedProviders() { + ContentCatalog catalog = new ContentCatalog(); + catalog.registerDocumentProvider( + "plugin1", "Plugin 1", new TestDocumentProvider("docs", List.of(document("doc", "Doc")))); + catalog.registerTemplateProvider( + "plugin1", "Plugin 1", new TestTemplateProvider("samples", List.of(template("sample", "Sample")))); + catalog.registerDocumentProvider( + "plugin2", "Plugin 2", new TestDocumentProvider("docs", List.of(document("other", "Other")))); + + catalog.unregisterPlugin("plugin1"); + + assertEquals(1, catalog.documents().size()); + assertEquals("plugin2:docs:other", catalog.documents().get(0).globalId().value()); + assertTrue(catalog.templates().isEmpty()); + } + + @Test + public void searchIndexesDocumentsAndTemplatesByMetadata() { + ContentCatalog catalog = new ContentCatalog(); + catalog.registerDocumentProvider( + "plugin", + "Plugin", + new TestDocumentProvider( + "docs", + List.of(new DocumentDescriptor( + "sprites", + "Drawing Sprites", + "Graphics tutorial", + List.of("Graphics"), + Set.of("learn"), + 0, + List.of())))); + catalog.registerTemplateProvider( + "plugin2", + "Samples Plugin", + new TestTemplateProvider( + "samples", + List.of(new TemplateDescriptor( + "sprite-demo", + "Sprite Demo", + "Animation sample", + List.of("Graphics"), + Set.of("sample"), + 0, + "main.gb", + List.of())))); + + List results = + new ContentSearchIndex().search(catalog.documents(), catalog.templates(), "sprite"); + + assertEquals(2, results.size()); + assertEquals("Sprite Demo", results.get(0).content().title()); + assertEquals("Drawing Sprites", results.get(1).content().title()); + } + + private static DocumentDescriptor document(String id, String title) { + return new DocumentDescriptor(id, title, "", List.of(), Set.of(), 0, List.of()); + } + + private static TemplateDescriptor template(String id, String title) { + return new TemplateDescriptor(id, title, "", List.of(), Set.of("sample"), 0, "main.gb", List.of()); + } + + private record TestDocumentProvider(String id, Collection descriptors) + implements DocumentProvider { + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public com.basic4gl.desktop.spi.content.ContentDocument openDocument(String documentId) throws IOException { + throw new IOException("Not used"); + } + } + + private record TestTemplateProvider(String id, Collection descriptors) + implements TemplateProvider { + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) {} + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentPanelModelTest.java b/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentPanelModelTest.java new file mode 100644 index 00000000..f46d6da0 --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/catalog/ContentPanelModelTest.java @@ -0,0 +1,156 @@ +package com.basic4gl.desktop.content.catalog; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.spi.content.DocumentDescriptor; +import com.basic4gl.desktop.spi.content.DocumentProvider; +import com.basic4gl.desktop.spi.content.TemplateCreationRequest; +import com.basic4gl.desktop.spi.content.TemplateDescriptor; +import com.basic4gl.desktop.spi.content.TemplateProvider; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import org.junit.Test; + +public class ContentPanelModelTest { + + @Test + public void scopesUseTagsInsteadOfKindEnums() { + ContentCatalog catalog = catalog(); + ContentPanelModel model = new ContentPanelModel(); + + assertEquals(List.of("Drawing Sprites", "Sprite Demo"), titles(model.items(catalog, ContentScope.LEARN, ""))); + assertEquals(List.of("Library Reference"), titles(model.items(catalog, ContentScope.REFERENCE, ""))); + assertEquals(List.of("Sprite Demo"), titles(model.items(catalog, ContentScope.SAMPLES, ""))); + assertEquals( + List.of("Blank Program", "Drawing Sprites", "Library Reference", "Sprite Demo"), + titles(model.items(catalog, ContentScope.ALL, ""))); + } + + @Test + public void browseBuildsCategoryHierarchy() { + ContentBrowseNode root = new ContentPanelModel().browse(catalog(), ContentScope.LEARN); + + assertEquals("Learn", root.name()); + assertEquals(1, root.children().size()); + ContentBrowseNode graphics = root.children().get(0); + assertEquals("Graphics", graphics.name()); + assertEquals(List.of("Drawing Sprites", "Sprite Demo"), titles(graphics.items())); + } + + @Test + public void searchReturnsFlatRankedResultsWithinScope() { + List results = new ContentPanelModel().items(catalog(), ContentScope.ALL, "sprite"); + + assertEquals(List.of("Sprite Demo", "Drawing Sprites"), titles(results)); + } + + @Test + public void summaryUsesTagDrivenLabelsAndActions() { + ContentPanelModel model = new ContentPanelModel(); + ContentPanelItem sample = + model.items(catalog(), ContentScope.SAMPLES, "").get(0); + + ContentSelectionSummary summary = model.summary(sample); + + assertEquals("Sprite Demo", summary.title()); + assertEquals("Sample", summary.kindLabel()); + assertEquals("Open Sample", summary.primaryAction()); + assertEquals("Graphics", summary.category()); + assertEquals(List.of("drawing-sprites"), summary.relatedIds()); + } + + private static ContentCatalog catalog() { + ContentCatalog catalog = new ContentCatalog(); + catalog.registerDocumentProvider( + "plugin", + "Plugin", + new Docs(List.of( + new DocumentDescriptor( + "drawing-sprites", + "Drawing Sprites", + "Learn graphics", + List.of("Graphics"), + Set.of("learn", "tutorial"), + 0, + List.of("sprite-demo")), + new DocumentDescriptor( + "reference", + "Library Reference", + "API docs", + List.of("Reference"), + Set.of("reference"), + 0, + List.of())))); + catalog.registerTemplateProvider( + "plugin", + "Plugin", + new Templates(List.of( + new TemplateDescriptor( + "blank", + "Blank Program", + "Starter", + List.of("Basics"), + Set.of("starter"), + 0, + "main.gb", + List.of()), + new TemplateDescriptor( + "sprite-demo", + "Sprite Demo", + "Sample", + List.of("Graphics"), + Set.of("sample"), + 1, + "main.gb", + List.of("drawing-sprites"))))); + return catalog; + } + + private static List titles(List items) { + return items.stream().map(ContentPanelItem::title).toList(); + } + + private record Docs(Collection descriptors) implements DocumentProvider { + @Override + public String id() { + return "docs"; + } + + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public com.basic4gl.desktop.spi.content.ContentDocument openDocument(String documentId) throws IOException { + throw new IOException("Not used"); + } + } + + private record Templates(Collection descriptors) implements TemplateProvider { + @Override + public String id() { + return "templates"; + } + + @Override + public String version() { + return "1"; + } + + @Override + public Collection getIndex() { + return descriptors; + } + + @Override + public void instantiate(String templateId, TemplateCreationRequest request) {} + } +} diff --git a/app/src/test/java/com/basic4gl/desktop/content/render/ContentRendererTest.java b/app/src/test/java/com/basic4gl/desktop/content/render/ContentRendererTest.java new file mode 100644 index 00000000..8faaa537 --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/render/ContentRendererTest.java @@ -0,0 +1,133 @@ +package com.basic4gl.desktop.content.render; + +import static org.junit.Assert.*; + +import com.basic4gl.desktop.content.ContentMaterializer; +import com.basic4gl.desktop.spi.content.ContentDocument; +import com.basic4gl.desktop.spi.content.MapContentSource; +import java.awt.Component; +import java.awt.Container; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Map; +import javax.imageio.ImageIO; +import javax.swing.JComponent; +import javax.swing.JEditorPane; +import javax.swing.JTextArea; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ContentRendererTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void markdownRendererRewritesImagesAndInternalLinks() throws Exception { + String markdown = "# Drawing Sprites\n" + + "![Sprite](../images/sprite.png)\n" + + "[Next](page2.md)\n" + + "![Remote](https://example.invalid/remote.png)\n" + + ""; + MapContentSource source = MapContentSource.fromBytes(Map.of( + "tutorials/sprites.md", markdown.getBytes(StandardCharsets.UTF_8), + "tutorials/page2.md", "Next".getBytes(StandardCharsets.UTF_8), + "images/sprite.png", pngBytes())); + Path root = new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()) + .materialize("plugin", "docs", "1", source); + + JComponent component = new MarkdownContentRenderer() + .render(new ContentRenderRequest( + new ContentDocument("text/markdown", source, "tutorials/sprites.md"), + root, + "tutorials/sprites.md", + ContentNavigationHandler.NO_OP)); + + String html = html(component); + assertTrue(html.contains(root.resolve("images/sprite.png").toUri().toString())); + assertTrue(html.contains("content:tutorials/page2.md")); + assertFalse(html.contains("https://example.invalid/remote.png")); + assertTrue(html.contains("<script>alert('x')</script>")); + } + + @Test + public void htmlRendererDisplaysHtmlContent() throws Exception { + MapContentSource source = + MapContentSource.fromBytes(Map.of("index.html", "

        Hello

        ".getBytes(StandardCharsets.UTF_8))); + Path root = new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()) + .materialize("plugin", "docs", "1", source); + + JComponent component = new HtmlContentRenderer() + .render(new ContentRenderRequest( + new ContentDocument("text/html", source, "index.html"), + root, + "index.html", + ContentNavigationHandler.NO_OP)); + + assertTrue(html(component).contains("

        Hello

        ")); + } + + @Test + public void plainTextRendererDisplaysReadOnlyText() throws Exception { + MapContentSource source = + MapContentSource.fromBytes(Map.of("readme.txt", "Hello".getBytes(StandardCharsets.UTF_8))); + Path root = new ContentMaterializer(temporaryFolder.newFolder("cache").toPath()) + .materialize("plugin", "docs", "1", source); + + JComponent component = new PlainTextContentRenderer() + .render(new ContentRenderRequest( + new ContentDocument("text/plain", source, "readme.txt"), + root, + "readme.txt", + ContentNavigationHandler.NO_OP)); + + JTextArea textArea = find(component, JTextArea.class); + assertEquals("Hello", textArea.getText()); + assertFalse(textArea.isEditable()); + } + + @Test + public void unsupportedRendererDoesNotCrash() { + MapContentSource source = MapContentSource.fromBytes(Map.of("data.bin", new byte[0])); + + JComponent component = new UnsupportedContentRenderer() + .render(new ContentRenderRequest( + new ContentDocument("application/octet-stream", source, "data.bin"), + Path.of("."), + "data.bin", + ContentNavigationHandler.NO_OP)); + + assertTrue(html(component).contains("Unsupported content")); + } + + private static String html(JComponent component) { + JEditorPane pane = find(component, JEditorPane.class); + Object html = pane.getClientProperty("basic4gl.content.html"); + return html == null ? pane.getText() : html.toString(); + } + + private static byte[] pngBytes() throws Exception { + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ImageIO.write(image, "png", output); + return output.toByteArray(); + } + + private static T find(Component component, Class type) { + if (type.isInstance(component)) { + return type.cast(component); + } + if (component instanceof Container container) { + for (Component child : container.getComponents()) { + T found = find(child, type); + if (found != null) { + return found; + } + } + } + return null; + } +} diff --git a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java index 2358d6b7..34ee6e67 100644 --- a/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java +++ b/language-adapter/src/main/java/com/basic4gl/language/adapter/Basic4GLEditorPluginAdapter.java @@ -5,6 +5,9 @@ import com.basic4gl.compiler.Preprocessor; import com.basic4gl.compiler.TomBasicCompiler; import com.basic4gl.desktop.spi.*; +import com.basic4gl.desktop.spi.content.ClasspathContentSource; +import com.basic4gl.desktop.spi.content.DirectoryDocumentProvider; +import com.basic4gl.desktop.spi.content.DirectoryTemplateProvider; import com.basic4gl.desktop.spi.language.LanguageSupport; import com.basic4gl.language.adapter.menu.ReferenceWindow; import com.basic4gl.library.plugin.PluginJAR; @@ -13,6 +16,7 @@ import com.basic4gl.library.plugin.PluginJARManager; import com.basic4gl.runtime.Debugger; import com.basic4gl.runtime.TomVM; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashSet; @@ -158,11 +162,38 @@ public void onLoad(PluginContext context) { window.populate(compiler); window.setVisible(true); }); + registerBuiltInContent(context); plugins.setCurrentDirectory(getDefaultPluginDirectory()); applyPluginSettingsToManager(); attemptLoadPluginsFromCurrentDirectory(); } + private void registerBuiltInContent(PluginContext context) { + ClassLoader classLoader = getClass().getClassLoader(); + try { + context.content() + .registerDocumentProvider(new DirectoryDocumentProvider( + "basic4gl-docs", + getVersion(), + new ClasspathContentSource( + classLoader, + "basic4gl-content/docs/basic4gl", + "basic4gl-content/docs/basic4gl.index"), + List.of("Basic4GL"))); + context.content() + .registerTemplateProvider(new DirectoryTemplateProvider( + "basic4gl-samples", + getVersion(), + new ClasspathContentSource( + classLoader, + "basic4gl-content/samples/Programs", + "basic4gl-content/samples/Programs.index"), + List.of("Samples"))); + } catch (IOException | RuntimeException ex) { + System.err.println("Unable to register built-in Basic4GL content: " + ex.getMessage()); + } + } + @Override public Configuration getAppSettings() { return ConfigurationMapper.toEditorConfiguration(appSettings); diff --git a/language-adapter/src/test/java/com/basic4gl/language/adapter/Basic4GLContentResourcesTest.java b/language-adapter/src/test/java/com/basic4gl/language/adapter/Basic4GLContentResourcesTest.java new file mode 100644 index 00000000..3171258c --- /dev/null +++ b/language-adapter/src/test/java/com/basic4gl/language/adapter/Basic4GLContentResourcesTest.java @@ -0,0 +1,25 @@ +package com.basic4gl.language.adapter; + +import static org.junit.jupiter.api.Assertions.*; + +import com.basic4gl.desktop.spi.content.ClasspathContentSource; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class Basic4GLContentResourcesTest { + + @Test + void embedsDocsAndSamplesWithIndexes() throws Exception { + ClassLoader classLoader = getClass().getClassLoader(); + ClasspathContentSource docs = new ClasspathContentSource( + classLoader, "basic4gl-content/docs/basic4gl", "basic4gl-content/docs/basic4gl.index"); + ClasspathContentSource samples = new ClasspathContentSource( + classLoader, "basic4gl-content/samples/Programs", "basic4gl-content/samples/Programs.index"); + + assertTrue( + docs.resources().stream().anyMatch(resource -> resource.path().equals("index.md"))); + assertTrue(samples.resources().stream() + .anyMatch(resource -> resource.path().equals("AsteroidDemo.gb"))); + assertTrue(new String(docs.open("index.md").readAllBytes(), StandardCharsets.UTF_8).contains("Basic4GL")); + } +} From 4ada2fa73b65195d0f5683fc45cbae63ed66a160 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Thu, 16 Jul 2026 03:13:44 -0400 Subject: [PATCH 31/38] work on docs panels --- .../main/java/com/basic4gl/desktop/Theme.java | 5 + .../content/catalog/ContentPanelModel.java | 118 +++++++----------- .../desktop/content/catalog/ContentScope.java | 54 +++++++- .../desktop/panels/DocsPanelProvider.java | 111 +++++++++++----- .../desktop/panels/SymbolsPanelProvider.java | 16 +-- .../images/material/icon_arrow_left.png | Bin 0 -> 376 bytes .../images/material/icon_arrow_right.png | Bin 0 -> 388 bytes .../images/material/icon_document.png | Bin 0 -> 301 bytes .../resources/images/material/icon_home.png | Bin 0 -> 395 bytes .../catalog/ContentPanelModelTest.java | 25 ++-- 10 files changed, 210 insertions(+), 119 deletions(-) create mode 100644 app/src/main/resources/images/material/icon_arrow_left.png create mode 100644 app/src/main/resources/images/material/icon_arrow_right.png create mode 100644 app/src/main/resources/images/material/icon_document.png create mode 100644 app/src/main/resources/images/material/icon_home.png diff --git a/app/src/main/java/com/basic4gl/desktop/Theme.java b/app/src/main/java/com/basic4gl/desktop/Theme.java index be2565bf..ae79ac79 100644 --- a/app/src/main/java/com/basic4gl/desktop/Theme.java +++ b/app/src/main/java/com/basic4gl/desktop/Theme.java @@ -29,6 +29,9 @@ public class Theme { public static final String ICON_SEARCH = THEME_DIRECTORY + "icon_search.png"; public static final String ICON_ARROW_DOWN = THEME_DIRECTORY + "icon_arrow_down.png"; public static final String ICON_ARROW_UP = THEME_DIRECTORY + "icon_arrow_up.png"; + public static final String ICON_ARROW_LEFT = THEME_DIRECTORY + "icon_arrow_left.png"; + public static final String ICON_ARROW_RIGHT = THEME_DIRECTORY + "icon_arrow_right.png"; + public static final String ICON_HOME = THEME_DIRECTORY + "icon_home.png"; public static final String ICON_BOOKMARK_ADD = THEME_DIRECTORY + "icon_bookmark_add.png"; public static final String ICON_MENU_FOLDER = THEME_DIRECTORY + "menu_folder.png"; public static final String ICON_MENU_FOLDER_SOLID = THEME_DIRECTORY + "menu_folder_solid.png"; @@ -38,6 +41,8 @@ public class Theme { public static final String ICON_MENU_FUNCTIONS = THEME_DIRECTORY + "menu_functions.png"; public static final String ICON_MENU_DOCS = THEME_DIRECTORY + "icon_book_outline.png"; public static final String ICON_MENU_DOCS_SOLID = THEME_DIRECTORY + "icon_book.png"; + public static final String ICON_TEMPLATE = THEME_DIRECTORY + "icon_template.png"; + public static final String ICON_DOCUMENT = THEME_DIRECTORY + "icon_document.png"; public static final String ICON_MENU_HELP = THEME_DIRECTORY + "menu_help.png"; public static final String ICON_MENU_HELP_SOLID = THEME_DIRECTORY + "menu_help_solid.png"; public static final String ICON_MENU_DEBUG = THEME_DIRECTORY + "menu_debug.png"; diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java index a60d11e2..1629d74f 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentPanelModel.java @@ -4,14 +4,41 @@ import com.basic4gl.desktop.spi.content.IndexedContent; import com.basic4gl.desktop.spi.content.TemplateDescriptor; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.function.Function; public final class ContentPanelModel { private final ContentSearchIndex searchIndex = new ContentSearchIndex(); + public List scopes(ContentCatalog catalog) { + Set tags = new LinkedHashSet<>(); + catalog.documents().stream() + .flatMap(entry -> entry.descriptor().tags().stream()) + .map(String::trim) + .filter(tag -> !tag.isBlank()) + .map(tag -> tag.toLowerCase(Locale.ROOT)) + .forEach(tags::add); + catalog.templates().stream() + .flatMap(entry -> entry.descriptor().tags().stream()) + .map(String::trim) + .filter(tag -> !tag.isBlank()) + .map(tag -> tag.toLowerCase(Locale.ROOT)) + .forEach(tags::add); + + List scopes = new ArrayList<>(); + scopes.add(ContentScope.ALL); + tags.stream() + .map(ContentScope::forTag) + .sorted(Comparator.comparing(ContentScope::displayName, String.CASE_INSENSITIVE_ORDER)) + .forEach(scopes::add); + return List.copyOf(scopes); + } + public List items(ContentCatalog catalog, ContentScope scope, String query) { List documents = filteredDocuments(catalog.documents(), scope); List templates = filteredTemplates(catalog.templates(), scope); @@ -36,9 +63,14 @@ public List items(ContentCatalog catalog, ContentScope scope, } public ContentBrowseNode browse(ContentCatalog catalog, ContentScope scope) { - ContentBrowseNode root = new ContentBrowseNode(scopeLabel(scope)); + return browse(catalog, scope, ContentPanelItem::categoryPath); + } + + public ContentBrowseNode browse( + ContentCatalog catalog, ContentScope scope, Function> categoryPathProvider) { + ContentBrowseNode root = new ContentBrowseNode(scope.displayName()); for (ContentPanelItem item : items(catalog, scope, "")) { - root.add(item.categoryPath(), item); + root.add(categoryPathProvider.apply(item), item); } return root; } @@ -47,7 +79,7 @@ public ContentSelectionSummary summary(ContentPanelItem item) { IndexedContent content = item.content(); String category = String.join(" / ", content.categoryPath()); if (content instanceof DocumentDescriptor descriptor) { - String kind = documentKindLabel(descriptor.tags()); + String kind = tagLabels(descriptor.tags(), "Document"); return new ContentSelectionSummary( descriptor.title(), kind, @@ -57,8 +89,8 @@ public ContentSelectionSummary summary(ContentPanelItem item) { "Open"); } if (content instanceof TemplateDescriptor descriptor) { - String kind = templateKindLabel(descriptor.tags()); - String action = hasTag(descriptor.tags(), "sample") ? "Open Sample" : "Create Program"; + String kind = tagLabels(descriptor.tags(), "Template"); + String action = descriptor.tags().isEmpty() ? "Create Program" : "Open " + kind; return new ContentSelectionSummary( descriptor.title(), kind, @@ -72,24 +104,11 @@ public ContentSelectionSummary summary(ContentPanelItem item) { } private List filteredDocuments(List documents, ContentScope scope) { - return documents.stream() - .filter(entry -> switch (scope) { - case ALL -> true; - case LEARN -> isLearning(entry.descriptor().tags()); - case REFERENCE -> isReference(entry.descriptor().tags()); - case SAMPLES -> false; - }) - .toList(); + return documents.stream().filter(entry -> scope.matches(entry.descriptor())).toList(); } private List filteredTemplates(List templates, ContentScope scope) { - return templates.stream() - .filter(entry -> switch (scope) { - case ALL -> true; - case LEARN, SAMPLES -> hasTag(entry.descriptor().tags(), "sample"); - case REFERENCE -> false; - }) - .toList(); + return templates.stream().filter(entry -> scope.matches(entry.descriptor())).toList(); } private ContentPanelItem toItem(ContentSearchResult result) { @@ -132,60 +151,19 @@ private ContentPanelItem toItem(TemplateCatalogEntry entry) { } private String subtitle(IndexedContent content, boolean template) { - String kind = template ? templateKindLabel(content.tags()) : documentKindLabel(content.tags()); + String kind = tagLabels(content.tags(), template ? "Template" : "Document"); String category = String.join(" / ", content.categoryPath()); return category.isBlank() ? kind : kind + " · " + category; } - private String documentKindLabel(Set tags) { - if (hasTag(tags, "tutorial")) { - return "Tutorial"; - } - if (hasTag(tags, "guide")) { - return "Guide"; - } - if (hasTag(tags, "reference")) { - return "Reference"; - } - return "Document"; - } - - private String templateKindLabel(Set tags) { - if (hasTag(tags, "sample")) { - return "Sample"; - } - if (hasTag(tags, "starter")) { - return "Starter"; - } - if (hasTag(tags, "project-template")) { - return "Project Template"; + private String tagLabels(Set tags, String fallback) { + if (tags.isEmpty()) { + return fallback; } - return "Template"; - } - - private boolean isLearning(Set tags) { - return hasAnyTag(tags, Set.of("learn", "tutorial", "getting-started")); - } - - private boolean isReference(Set tags) { - return hasAnyTag(tags, Set.of("reference", "guide")); - } - - private boolean hasAnyTag(Set tags, Set needles) { - return needles.stream().anyMatch(needle -> hasTag(tags, needle)); - } - - private boolean hasTag(Set tags, String needle) { - String normalizedNeedle = needle.toLowerCase(Locale.ROOT); - return tags.stream().map(tag -> tag.toLowerCase(Locale.ROOT)).anyMatch(normalizedNeedle::equals); - } - - private String scopeLabel(ContentScope scope) { - return switch (scope) { - case ALL -> "All"; - case LEARN -> "Learn"; - case REFERENCE -> "Reference"; - case SAMPLES -> "Samples"; - }; + return tags.stream() + .map(ContentScope::labelForTag) + .sorted(String.CASE_INSENSITIVE_ORDER) + .reduce((left, right) -> left + ", " + right) + .orElse(fallback); } } diff --git a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java index 18f27529..1bc1be7b 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java +++ b/app/src/main/java/com/basic4gl/desktop/content/catalog/ContentScope.java @@ -1,8 +1,52 @@ package com.basic4gl.desktop.content.catalog; -public enum ContentScope { - ALL, - LEARN, - REFERENCE, - SAMPLES +import com.basic4gl.desktop.spi.content.IndexedContent; +import java.util.Locale; +import java.util.Set; + +public record ContentScope(String tag, String displayName) { + + public static final ContentScope ALL = new ContentScope(null, "All Documentation"); + + public ContentScope { + tag = tag == null || tag.isBlank() ? null : tag.trim().toLowerCase(Locale.ROOT); + displayName = displayName == null || displayName.isBlank() ? labelForTag(tag) : displayName.trim(); + } + + public static ContentScope forTag(String tag) { + return new ContentScope(tag, labelForTag(tag)); + } + + public boolean all() { + return tag == null; + } + + public boolean matches(IndexedContent content) { + return all() || hasTag(content.tags()); + } + + public boolean hasTag(Set tags) { + return tag != null && tags.stream().anyMatch(value -> tag.equals(value.toLowerCase(Locale.ROOT))); + } + + public static String labelForTag(String tag) { + if (tag == null || tag.isBlank()) { + return ""; + } + String[] words = tag.trim().replace('_', '-').split("-+"); + StringBuilder label = new StringBuilder(); + for (String word : words) { + if (word.isBlank()) { + continue; + } + if (!label.isEmpty()) { + label.append(' '); + } + label.append(Character.toUpperCase(word.charAt(0))); + if (word.length() > 1) { + label.append(word.substring(1).toLowerCase(Locale.ROOT)); + } + } + return label.toString(); + } } diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java index 4e871aa9..0bb7d747 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -1,9 +1,11 @@ package com.basic4gl.desktop.panels; import static com.basic4gl.desktop.Theme.ICON_CHEVRON_DOWN; +import static com.basic4gl.desktop.Theme.ICON_DOCUMENT; import static com.basic4gl.desktop.Theme.ICON_MENU_DOCS; import static com.basic4gl.desktop.Theme.ICON_MENU_DOCS_SOLID; import static com.basic4gl.desktop.Theme.ICON_SEARCH; +import static com.basic4gl.desktop.Theme.ICON_TEMPLATE; import static com.basic4gl.desktop.util.HtmlUtil.escapeHtml; import static com.basic4gl.desktop.util.SwingIconUtil.createScaledIcon; import static com.basic4gl.desktop.util.SwingUtil.configureSmoothScrolling; @@ -33,10 +35,12 @@ import java.awt.event.MouseEvent; import java.nio.file.Path; import java.util.List; +import java.util.Locale; import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.DefaultListCellRenderer; import javax.swing.DefaultListModel; +import javax.swing.Icon; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JFileChooser; @@ -61,6 +65,7 @@ import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; public class DocsPanelProvider implements IEditorPanelProvider { @@ -76,6 +81,8 @@ public class DocsPanelProvider implements IEditorPanelProvider { private final JTextPane summaryPane = new JTextPane(); private final JLabel selectionNameLabel = new JLabel("Select an item."); private final JButton primaryAction = new JButton("Open"); + private final Icon documentRowIcon = createScaledIcon(ICON_DOCUMENT, 18); + private final Icon templateRowIcon = createScaledIcon(ICON_TEMPLATE, 18); private final ContentCatalogListener catalogListener = this::refreshContent; private BasicEditor editor; @@ -122,8 +129,8 @@ public JPanel build(PluginContext context) { JPanel panelCardHost = new JPanel(new CardLayout()); JPanel lookupPanel = new JPanel(new BorderLayout(6, 6)); Color panelBackground = createLighterPanelBackground(); + panelCardHost.setBackground(panelBackground); lookupPanel.setBackground(panelBackground); - lookupPanel.setBorder(new EmptyBorder(0, 6, 6, 6)); configureScopeButton(); JToggleButton searchToggle = createHeaderSearchToggleButton(); @@ -245,9 +252,9 @@ private void configureScopeButton() { private JPopupMenu createScopePopup() { JPopupMenu popup = new JPopupMenu(); - for (ContentScope scope : ContentScope.values()) { - JMenuItem item = new JMenuItem(scopeLabel(scope)); - item.setEnabled(scope != currentScope); + for (ContentScope scope : availableScopes()) { + JMenuItem item = new JMenuItem(scope.displayName()); + item.setEnabled(!scope.equals(currentScope)); item.addActionListener(e -> { currentScope = scope; updateScopeButtonText(); @@ -259,16 +266,14 @@ private JPopupMenu createScopePopup() { } private void updateScopeButtonText() { - scopeButton.setText(scopeLabel(currentScope)); + scopeButton.setText(currentScope.displayName()); } - private String scopeLabel(ContentScope scope) { - return switch (scope) { - case ALL -> "All"; - case LEARN -> "Learn"; - case REFERENCE -> "Reference"; - case SAMPLES -> "Samples"; - }; + private List availableScopes() { + if (editor == null) { + return List.of(ContentScope.ALL); + } + return contentModel.scopes(editor.contentCatalog()); } private JToggleButton createHeaderSearchToggleButton() { @@ -301,8 +306,9 @@ private JComponent createRoundedCardHost(JComponent content, Color panelBackgrou private void configureBrowseTree(Color panelBackground) { browseTree.setBackground(panelBackground); - browseTree.setRootVisible(true); + browseTree.setRootVisible(false); browseTree.setShowsRootHandles(true); + browseTree.setRowHeight(20); browseTree.setCellRenderer(new DefaultTreeCellRenderer() { @Override public Component getTreeCellRendererComponent( @@ -313,14 +319,15 @@ public Component getTreeCellRendererComponent( boolean leaf, int row, boolean hasFocus) { - JLabel label = (JLabel) super.getTreeCellRendererComponent( - tree, value, selected, expanded, leaf, row, hasFocus); + JLabel label = (JLabel) + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); Object userObject = value instanceof DefaultMutableTreeNode treeNode ? treeNode.getUserObject() : value; if (userObject instanceof ContentPanelItem item) { - label.setText(formatItemLabel(item)); - label.setToolTipText(item.displayName()); + label.setText(item.displayName()); + label.setIcon(rowIcon(item)); + label.setToolTipText(itemTooltip(item)); } else { - label.setText(escapeHtml(String.valueOf(userObject))); + label.setText(String.valueOf(userObject)); label.setToolTipText(null); } return label; @@ -349,6 +356,7 @@ public void mouseClicked(MouseEvent e) { private void configureSearchList(Color panelBackground) { searchList.setBackground(panelBackground); searchList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + searchList.setFixedCellHeight(20); searchList.setCellRenderer(new DefaultListCellRenderer() { @Override public Component getListCellRendererComponent( @@ -356,8 +364,9 @@ public Component getListCellRendererComponent( JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); if (value instanceof ContentPanelItem item) { - label.setText(formatItemLabel(item)); - label.setToolTipText(item.displayName()); + label.setText(item.displayName()); + label.setIcon(rowIcon(item)); + label.setToolTipText(itemTooltip(item)); } return label; } @@ -377,13 +386,15 @@ public void mouseClicked(MouseEvent e) { }); } - private String formatItemLabel(ContentPanelItem item) { + private Icon rowIcon(ContentPanelItem item) { + return item.template() ? templateRowIcon : documentRowIcon; + } + + private String itemTooltip(ContentPanelItem item) { String subtitle = item.displaySubtitle(); - if (subtitle.isBlank()) { - return "" + escapeHtml(item.displayName()) + ""; - } - return "" + escapeHtml(item.displayName()) + "
        " - + escapeHtml(subtitle) + ""; + return subtitle.isBlank() + ? item.displayName() + : "" + escapeHtml(item.displayName()) + "
        " + escapeHtml(subtitle) + ""; } private void configureSummary(Color panelBackground) { @@ -426,11 +437,14 @@ private void refreshContent() { setSummary(null); return; } + ensureCurrentScopeAvailable(); String query = searchField.getText() == null ? "" : searchField.getText().trim(); if (query.isBlank()) { - ContentBrowseNode root = contentModel.browse(editor.contentCatalog(), currentScope); - browseTree.setModel(new DefaultTreeModel(toTreeNode(root))); + ContentBrowseNode root = browseForCurrentScope(); + DefaultMutableTreeNode rootNode = toTreeNode(root); + browseTree.setModel(new DefaultTreeModel(rootNode)); + browseTree.expandPath(new TreePath(rootNode.getPath())); browseTree.expandRow(0); ((CardLayout) resultsCards.getLayout()).show(resultsCards, "browse"); } else { @@ -443,6 +457,47 @@ private void refreshContent() { setSummary(selectedItem); } + private ContentBrowseNode browseForCurrentScope() { + return contentModel.browse(editor.contentCatalog(), currentScope, this::categoryPathForCurrentScope); + } + + private List categoryPathForCurrentScope(ContentPanelItem item) { + List categoryPath = item.categoryPath(); + if (currentScope.all() || categoryPath.isEmpty()) { + return categoryPath; + } + return categoryPath.stream() + .filter(segment -> !isRedundantScopeSegment(segment)) + .toList(); + } + + private boolean isRedundantScopeSegment(String segment) { + String normalizedSegment = normalizeScopeText(segment); + String normalizedTag = currentScope.tag(); + return normalizedTag != null + && (normalizedSegment.equals(normalizedTag) + || normalizedSegment.equals(normalizedTag + "s") + || normalizedSegment.equals(normalizeScopeText(currentScope.displayName()))); + } + + private String normalizeScopeText(String text) { + return text == null + ? "" + : text.trim() + .toLowerCase(Locale.ROOT) + .replace('_', '-') + .replace(' ', '-'); + } + + private void ensureCurrentScopeAvailable() { + if (currentScope.all() || availableScopes().contains(currentScope)) { + updateScopeButtonText(); + return; + } + currentScope = ContentScope.ALL; + updateScopeButtonText(); + } + private DefaultMutableTreeNode toTreeNode(ContentBrowseNode node) { DefaultMutableTreeNode treeNode = new DefaultMutableTreeNode(node.name()); for (ContentBrowseNode child : node.children()) { diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java index 6ddf4fdd..9a7930a8 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -37,7 +37,7 @@ public class SymbolsPanelProvider implements IEditorPanelProvider { "Select an entry."; private String referenceDetailsHtml = REFERENCE_SELECT_PROMPT_HTML; private final JComboBox referenceLibraryFilter = new JComboBox<>(new String[] {"All libraries"}); - private final JButton referenceFiltersButton = new JButton("All Symbols"); + private final JButton referenceFiltersButton = new JButton("All"); private final JPopupMenu referenceFiltersPopup = new JPopupMenu(); private final JLabel referenceSelectionNameLabel = new JLabel("Select an entry."); private String referenceSelectionName = "Select an entry."; @@ -53,7 +53,7 @@ public class SymbolsPanelProvider implements IEditorPanelProvider { private final JList referenceList = new JList<>(referenceListModel); private final JTextField referenceSearchField = new JTextField(); private final JComboBox referenceKindFilter = - new JComboBox<>(new String[] {"All Symbols", "Functions", "Constants", "Labels", "Variables", "Structs"}); + new JComboBox<>(new String[] {"All", "Functions", "Constants", "Labels", "Variables", "Structs"}); private final JComboBox referenceSourceFilter = new JComboBox<>(new String[] {"All Sources", "Builtin", "Libraries", "Program"}); private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); @@ -103,7 +103,7 @@ public String id() { @Override public String getTitle() { - return "View Symbols"; + return "Code Reference"; } @Override @@ -621,7 +621,7 @@ private void rebuildReferenceFiltersPopup() { referenceFiltersPopup.removeAll(); JMenu typeMenu = new JMenu("Type"); - addReferenceRadioItems(typeMenu, referenceKindFilter, "All Symbols", "All Symbols"); + addReferenceRadioItems(typeMenu, referenceKindFilter, "All", "All"); addReferenceRadioItems(typeMenu, referenceKindFilter, "Functions", "Functions"); addReferenceRadioItems(typeMenu, referenceKindFilter, "Constants", "Constants"); addReferenceRadioItems(typeMenu, referenceKindFilter, "Labels", "Labels"); @@ -646,7 +646,7 @@ private void rebuildReferenceFiltersPopup() { resetItem.addActionListener(e -> { updatingReferenceFilters = true; try { - referenceKindFilter.setSelectedItem("All Symbols"); + referenceKindFilter.setSelectedItem("All"); referenceSourceFilter.setSelectedItem("All Sources"); referenceLibraryFilter.setSelectedItem("All Libraries"); } finally { @@ -680,10 +680,10 @@ private void setReferenceDetailsHtml(String html) { } private void updateReferenceFiltersButtonTooltip() { - String type = Objects.toString(referenceKindFilter.getSelectedItem(), "All Symbols"); + String type = Objects.toString(referenceKindFilter.getSelectedItem(), "All"); String source = Objects.toString(referenceSourceFilter.getSelectedItem(), "All Sources"); String library = Objects.toString(referenceLibraryFilter.getSelectedItem(), "All Libraries"); - referenceFiltersButton.setText(type); + referenceFiltersButton.setText("All".equals(type) ? "Code Reference" : type); referenceFiltersButton.setToolTipText("Type: " + type + " | Source: " + source + " | Library: " + library); } @@ -701,7 +701,7 @@ private void filterReferenceItems() { ReferenceItem previousSelection = referenceList.getSelectedValue(); java.util.List matches = new ArrayList<>(); for (ReferenceItem item : allReferenceItems) { - boolean kindMatches = "All Symbols".equals(selectedKind) + boolean kindMatches = "All".equals(selectedKind) || ("Functions".equals(selectedKind) && ("function".equals(item.kind) || "userfunc".equals(item.kind))) || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) diff --git a/app/src/main/resources/images/material/icon_arrow_left.png b/app/src/main/resources/images/material/icon_arrow_left.png new file mode 100644 index 0000000000000000000000000000000000000000..868df2d06646104db4e3cde56c22be6b2453d614 GIT binary patch literal 376 zcmV-;0f+vHP)gd<-vru1;qPp$ln~>9#!-y70rW zJNtcMAlt)%*GbSh;H^d!Jq&N7G<)l$dJSeloVpugni6m)qH~YYTABoL;;w`;m4FB@ z)p+!;wI!W`C7kdDFpw`1Xifucl?3{00030|HE-sdjJ3c21!IgR09CR W7?v@%YW3&<0000*^p>D8yM;kZpvw%UK}2ePhAHPgU8$e>c=2ip7Bd}57^@-I z-Uw>=q{{WGEgK1DaC!hhJ~;K(!GW8%z=i|Cg$vGn@DeP;x#ph+;tH|gG`TxMG-Y8N zPOl_zUMau9xV-=44X=cBt&9Afb5R%Hz|eG=^(RICVsdGC38YYa-Tn*y0RRC1|ImeC i=l}o!21!IgR09CRkC!o5Eo$5V0000(y~C6{Ou*8*!7dWr5lkI}@tj5C^=X`oE4SlaLdW%_wD(54IZ=WBX^ zsod7o?q+HWX}QCY;=>9wc%@(^o5AlYx0jv^0ZMJi)SSHT6U9kd!%>370OTDk3Gy?5 zGD$(Q08)@_fEGeM*C;^j@cE*AJPN=UkjCW!>95(s{40nZD4&~u1+g=bvgYyfZxP?` zcFZ%B#ziW)QdY6OPtB1B_3~o>X<-GREk+-xFPpxrx3}X90L|H5aIcrB-alGA=GNrx ztd2JU0Ozu{CjbBd|Nmx6X7vC700v1!K~w_(_j0r`E76k300000NkvXXu0mjfHH3u2 literal 0 HcmV?d00001 diff --git a/app/src/main/resources/images/material/icon_home.png b/app/src/main/resources/images/material/icon_home.png new file mode 100644 index 0000000000000000000000000000000000000000..c3c351caee67790008cf79be21d78adfce85f37c GIT binary patch literal 395 zcmV;60d)R}P)eo*@Gj$0$UzKloc0wX!I|#X)({A2 z^D4Ygy{_pZ8(8uALjng`)=P(@8-jVPvr!hE3@?&x?3NIm63p2{#Q9d>_}mOC*?iKw z62b00006Nkl Date: Sat, 18 Jul 2026 12:37:43 -0400 Subject: [PATCH 32/38] runnable tab management and default tab names --- .../java/com/basic4gl/desktop/MainWindow.java | 151 +++++++++++++----- .../basic4gl/desktop/content/FileEditor.java | 80 +++++++++- .../basic4gl/desktop/content/FileManager.java | 23 +++ .../basic4gl/desktop/util/SwingIconUtil.java | 9 ++ .../content/FileEditorDefaultNameTest.java | 28 ++++ 5 files changed, 250 insertions(+), 41 deletions(-) create mode 100644 app/src/test/java/com/basic4gl/desktop/content/FileEditorDefaultNameTest.java diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 0954e786..30785773 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -110,8 +110,9 @@ public void caretUpdate(CaretEvent e) { private final ButtonGroup rightDocsGroup = new ButtonGroup(); private final Map rightDocsButtons = new HashMap<>(); - private final JComboBox runTargetCombo = new JComboBox<>(); - private boolean updatingRunTargetCombo = false; + private final JButton runTargetButton = new JButton(); + private final JPopupMenu runTargetPopup = new JPopupMenu(); + private boolean runTargetFollowsCurrentTab = true; private int expandedLeftSidebarWidth = 260; private int expandedRightDocsWidth = 320; @@ -554,7 +555,7 @@ public void onStepOutRequested() { toolBar.add(openButton); toolBar.add(saveButton); toolBar.add(Box.createHorizontalGlue()); - toolBar.add(runTargetCombo); + toolBar.add(runTargetButton); toolBar.add(runButton); toolBar.addSeparator(); toolBar.add(exportButton); @@ -568,9 +569,19 @@ public void onStepOutRequested() { exportButton.addActionListener(e -> actionExport()); settingsButton.addActionListener(e -> showSettings()); runButton.setToolTipText("Run the program!"); - runTargetCombo.setToolTipText("Select the runnable source file"); - runTargetCombo.setMaximumSize(new Dimension(260, 30)); - runTargetCombo.addActionListener(e -> onRunTargetSelectionChanged()); + runTargetButton.setToolTipText("Select the runnable source file"); + runTargetButton.setMaximumSize(new Dimension(260, 30)); + runTargetButton.setFocusable(false); + runTargetButton.setIcon(createScaledIcon(ICON_CHEVRON_DOWN, 18)); + runTargetButton.setHorizontalTextPosition(SwingConstants.LEFT); + runTargetButton.setIconTextGap(6); + runTargetButton.putClientProperty("JButton.buttonType", "toolBarButton"); + runTargetButton.setOpaque(false); + runTargetButton.setMargin(new Insets(5, 8, 5, 8)); + Font runTargetFont = runTargetButton.getFont(); + runTargetButton.setFont(new Font(runTargetFont.getName(), Font.BOLD, runTargetFont.getSize())); + runTargetButton.setForeground(new Color(0x424242)); + runTargetButton.addActionListener(e -> showRunTargetPopup()); toolBar.setAlignmentY(1); toolBar.setFloatable(false); @@ -1202,6 +1213,7 @@ boolean actionSave() { fileManager.setCurrentDirectory(fileManager.getRunDirectory()); } refreshTabTitle(index); + refreshRunnableFileControls(); } return saved; } @@ -1225,6 +1237,7 @@ boolean actionSave(int index) { fileManager.setCurrentDirectory(fileManager.getRunDirectory()); } refreshTabTitle(index); + refreshRunnableFileControls(); } return saved; } @@ -1255,6 +1268,7 @@ void actionSaveAs() { fileManager.setCurrentDirectory(fileManager.getRunDirectory()); } refreshTabTitle(index); + refreshRunnableFileControls(); } private void refreshTabTitle(int index) { @@ -1979,7 +1993,7 @@ private void configurePrimaryTabHost() { fileViewModeTabs.add(previewViewButton); fileViewModeTabsHost.setVisible(false); tabControl.putClientProperty(TABBED_PANE_TRAILING_COMPONENT, fileViewModeTabsHost); - tabControl.addChangeListener(e -> refreshFileViewModeButtons()); + tabControl.addChangeListener(e -> onSelectedTabChanged()); primaryTabHost.add(tabControl, BorderLayout.CENTER); } @@ -2105,7 +2119,8 @@ private void maybeShowTabPopup(MouseEvent e) { if (contextEditor == null) { return; } - fileManager.setRunnableFilePath(contextEditor.getFilePath()); + runTargetFollowsCurrentTab = false; + fileManager.setRunnableFileEditor(contextEditor); refreshRunnableFileControls(); }); setRunnable.setEnabled(contextEditor != null); @@ -2528,51 +2543,113 @@ private void refreshRunnableFileControls() { return; } - updatingRunTargetCombo = true; - runTargetCombo.removeAllItems(); + FileEditor currentEditor = getCurrentTextEditor(); + if (runTargetFollowsCurrentTab && currentEditor != null) { + updateRunnableFileToCurrentTab(currentEditor); + } - java.util.List runnableTabIndices = new ArrayList<>(); + runTargetButton.setText(getRunTargetButtonLabel(currentEditor)); + runTargetButton.setEnabled(currentEditor != null || hasTextEditors()); + rebuildRunTargetPopup(currentEditor); + } + private boolean hasTextEditors() { for (int i = 0; i < fileManager.getFileEditors().size(); i++) { - FileEditor editor = fileManager.getFileEditors().get(i); - if (editor == null) { - continue; + if (fileManager.getFileEditors().get(i) != null) { + return true; } - runTargetCombo.addItem(editor.getShortFilename()); - runnableTabIndices.add(i); + } + return false; + } + + private String getRunTargetButtonLabel(FileEditor currentEditor) { + if (runTargetFollowsCurrentTab) { + return currentRunTargetLabel(currentEditor); } - int runnableIndex = fileManager.getRunnableFileIndex(); - if (runnableIndex >= 0) { - int comboIndex = runnableTabIndices.indexOf(runnableIndex); - if (comboIndex >= 0 && comboIndex < runTargetCombo.getItemCount()) { - runTargetCombo.setSelectedIndex(comboIndex); - } + FileEditor runnableEditor = getTextEditorAt(fileManager.getRunnableFileIndex()); + if (runnableEditor != null) { + return runnableEditor.getShortFilename(); } - runTargetCombo.setEnabled(runTargetCombo.getItemCount() > 0); - updatingRunTargetCombo = false; + return currentRunTargetLabel(currentEditor); } - private void onRunTargetSelectionChanged() { - if (updatingRunTargetCombo || fileManager == null) { - return; + private String currentRunTargetLabel(FileEditor currentEditor) { + if (currentEditor == null) { + return ""; } - int comboIndex = runTargetCombo.getSelectedIndex(); - if (comboIndex < 0) { - return; + String name = currentEditor.getShortFilename(); + return "Current Program [" + name + "]"; + } + + private void showRunTargetPopup() { + refreshRunnableFileControls(); + runTargetPopup.show(runTargetButton, 0, runTargetButton.getHeight()); + } + + private void rebuildRunTargetPopup(FileEditor currentEditor) { + runTargetPopup.removeAll(); + ButtonGroup runTargetGroup = new ButtonGroup(); + + JRadioButtonMenuItem currentItem = new JRadioButtonMenuItem(currentRunTargetLabel(currentEditor)); + currentItem.setSelected(runTargetFollowsCurrentTab); + currentItem.addActionListener(e -> { + runTargetFollowsCurrentTab = true; + updateRunnableFileToCurrentTab(getCurrentTextEditor()); + refreshRunnableFileControls(); + }); + runTargetGroup.add(currentItem); + runTargetPopup.add(currentItem); + + if (hasTextEditors()) { + runTargetPopup.addSeparator(); } - int textEditorOffset = -1; - for (FileEditor editor : fileManager.getFileEditors()) { + FileEditor runnableEditor = getTextEditorAt(fileManager.getRunnableFileIndex()); + for (int i = 0; i < fileManager.getFileEditors().size(); i++) { + FileEditor editor = fileManager.getFileEditors().get(i); if (editor == null) { continue; } - textEditorOffset++; - if (textEditorOffset == comboIndex) { - fileManager.setRunnableFilePath(editor.getFilePath()); - return; - } + + JRadioButtonMenuItem item = new JRadioButtonMenuItem(editor.getShortFilename()); + item.setSelected(!runTargetFollowsCurrentTab && editor == runnableEditor); + item.addActionListener(e -> { + runTargetFollowsCurrentTab = false; + fileManager.setRunnableFileEditor(editor); + refreshRunnableFileControls(); + }); + runTargetGroup.add(item); + runTargetPopup.add(item); + } + } + + private void onSelectedTabChanged() { + refreshFileViewModeButtons(); + if (runTargetFollowsCurrentTab) { + refreshRunnableFileControls(); + } else { + rebuildRunTargetPopup(getCurrentTextEditor()); + } + } + + private FileEditor getCurrentTextEditor() { + return getTextEditorAt(tabControl.getSelectedIndex()); + } + + private FileEditor getTextEditorAt(int index) { + if (fileManager == null || index < 0 || index >= fileManager.getFileEditors().size()) { + return null; + } + return fileManager.getFileEditors().get(index); + } + + private void updateRunnableFileToCurrentTab(FileEditor currentEditor) { + if (currentEditor != null) { + fileManager.setRunnableFileEditor(currentEditor); + } else { + fileManager.ensureRunnableFileValid(); } } diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileEditor.java b/app/src/main/java/com/basic4gl/desktop/content/FileEditor.java index f52196fb..14c264a0 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileEditor.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileEditor.java @@ -13,7 +13,10 @@ import java.io.FileWriter; import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; import java.util.List; +import java.util.Set; import javax.swing.*; import javax.swing.filechooser.FileNameExtensionFilter; import javax.swing.text.BadLocationException; @@ -24,7 +27,7 @@ import org.fife.ui.rtextarea.*; public class FileEditor implements SearchListener { - public static final String DEFAULT_NAME = "[Unnamed]"; + public static final String DEFAULT_NAME = "Untitled"; private static final int HEADER_BOOKMARK = 0; private static final int HEADER_BREAK_PT = 1; @@ -71,7 +74,7 @@ public FileEditor( this.fileManager = fileManager; this.toggleBreakpointListener = toggleBreakpointListener; - fileName = ""; + fileName = getNextDefaultName(fileManager); filePath = ""; isModified = false; isSaved = false; @@ -333,7 +336,10 @@ public void searchEvent(SearchEvent e) { public String getTitle() { String result; - result = (fileName.isEmpty() ? DEFAULT_NAME : fileName).toLowerCase(); + result = fileName.isEmpty() ? DEFAULT_NAME : fileName; + if (!isDefaultName(result)) { + result = result.toLowerCase(); + } // Append asterisk if modified if (isModified) { @@ -360,7 +366,73 @@ public File getFile() { } public String getShortFilename() { - return !fileName.isEmpty() ? new File(fileName).getName() : DEFAULT_NAME.toLowerCase(); + return !fileName.isEmpty() ? new File(fileName).getName() : DEFAULT_NAME; + } + + private static String getNextDefaultName(IFileManager fileManager) { + if (fileManager instanceof FileManager manager) { + List existingNames = new ArrayList<>(); + for (FileEditor editor : manager.getFileEditors()) { + if (editor != null) { + existingNames.add(editor.fileName); + } + } + return getNextDefaultName(existingNames); + } + + return DEFAULT_NAME; + } + + static String getNextDefaultName(Collection existingNames) { + Set usedIndexes = new HashSet<>(); + for (String existingName : existingNames) { + int index = getDefaultNameIndex(existingName); + if (index > 0) { + usedIndexes.add(index); + } + } + + int index = 1; + while (usedIndexes.contains(index)) { + index++; + } + + return formatDefaultName(index); + } + + private static boolean isDefaultName(String name) { + return getDefaultNameIndex(name) > 0; + } + + private static int getDefaultNameIndex(String name) { + if (name == null) { + return -1; + } + + if (DEFAULT_NAME.equalsIgnoreCase(name)) { + return 1; + } + + String prefix = DEFAULT_NAME + " "; + if (!name.regionMatches(true, 0, prefix, 0, prefix.length())) { + return -1; + } + + String suffix = name.substring(prefix.length()); + try { + int index = Integer.parseInt(suffix); + if (index >= 2 && Integer.toString(index).equals(suffix)) { + return index; + } + } catch (NumberFormatException ignored) { + return -1; + } + + return -1; + } + + private static String formatDefaultName(int index) { + return index == 1 ? DEFAULT_NAME : DEFAULT_NAME + " " + index; } public boolean isModified() { diff --git a/app/src/main/java/com/basic4gl/desktop/content/FileManager.java b/app/src/main/java/com/basic4gl/desktop/content/FileManager.java index 85dc914e..284fedb0 100644 --- a/app/src/main/java/com/basic4gl/desktop/content/FileManager.java +++ b/app/src/main/java/com/basic4gl/desktop/content/FileManager.java @@ -10,6 +10,7 @@ public class FileManager implements IFileManager { private final Vector fileEditors = new Vector<>(); private String runnableFilePath; + private FileEditor runnableFileEditor; private String currentDirectory; // Current working directory @@ -217,16 +218,29 @@ public Vector getFileEditors() { public String getRunnableFilePath() { ensureRunnableFileValid(); + if (runnableFileEditor != null) { + return runnableFileEditor.getFilePath(); + } return runnableFilePath; } public void setRunnableFilePath(String runnableFilePath) { this.runnableFilePath = runnableFilePath; + this.runnableFileEditor = null; + ensureRunnableFileValid(); + } + + public void setRunnableFileEditor(FileEditor runnableFileEditor) { + this.runnableFileEditor = runnableFileEditor; + this.runnableFilePath = runnableFileEditor != null ? runnableFileEditor.getFilePath() : null; ensureRunnableFileValid(); } public int getRunnableFileIndex() { ensureRunnableFileValid(); + if (runnableFileEditor != null) { + return fileEditors.indexOf(runnableFileEditor); + } if (runnableFilePath == null || runnableFilePath.isBlank()) { return -1; } @@ -236,8 +250,15 @@ public int getRunnableFileIndex() { public void ensureRunnableFileValid() { if (fileEditors.isEmpty()) { runnableFilePath = null; + runnableFileEditor = null; + return; + } + + if (runnableFileEditor != null && fileEditors.contains(runnableFileEditor)) { + runnableFilePath = runnableFileEditor.getFilePath(); return; } + runnableFileEditor = null; if (runnableFilePath != null && !runnableFilePath.isBlank() && getTabIndex(runnableFilePath) != -1) { return; @@ -246,9 +267,11 @@ public void ensureRunnableFileValid() { for (FileEditor editor : fileEditors) { if (editor != null) { runnableFilePath = editor.getFilePath(); + runnableFileEditor = editor; return; } } runnableFilePath = null; + runnableFileEditor = null; } } diff --git a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java index de0a9f9e..eb8b11bc 100644 --- a/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java +++ b/app/src/main/java/com/basic4gl/desktop/util/SwingIconUtil.java @@ -54,6 +54,15 @@ public static Icon createScaledIcon(String iconPath, int size) { return new ImageIcon(scaled); } + public static Icon createScaledIcon(String iconPath, int size, Color tint) { + ImageIcon icon = createImageIcon(iconPath, tint); + if (icon == null) { + return null; + } + Image scaled = icon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH); + return new ImageIcon(scaled); + } + private static BufferedImage tintImage(Image source, Color tint) { int width = source.getWidth(null); int height = source.getHeight(null); diff --git a/app/src/test/java/com/basic4gl/desktop/content/FileEditorDefaultNameTest.java b/app/src/test/java/com/basic4gl/desktop/content/FileEditorDefaultNameTest.java new file mode 100644 index 00000000..fb3c3a9f --- /dev/null +++ b/app/src/test/java/com/basic4gl/desktop/content/FileEditorDefaultNameTest.java @@ -0,0 +1,28 @@ +package com.basic4gl.desktop.content; + +import static org.junit.Assert.assertEquals; + +import java.util.List; +import org.junit.Test; + +public class FileEditorDefaultNameTest { + + @Test + public void firstDefaultNameDoesNotUseNumericSuffix() { + assertEquals("Untitled", FileEditor.getNextDefaultName(List.of())); + } + + @Test + public void defaultNamesUseLowestAvailableIndex() { + assertEquals("Untitled 2", FileEditor.getNextDefaultName(List.of("Untitled"))); + assertEquals("Untitled 2", FileEditor.getNextDefaultName(List.of("Untitled", "Untitled 3"))); + assertEquals("Untitled 3", FileEditor.getNextDefaultName(List.of("Untitled", "Untitled 2"))); + } + + @Test + public void defaultNameMatchingIgnoresNonDefaultNames() { + assertEquals( + "Untitled 2", + FileEditor.getNextDefaultName(List.of("Untitled", "Untitled 1", "Untitled-two", "program.gb"))); + } +} From 238b44b29399c477b6d7bddde1e43aec9f75041b Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sat, 18 Jul 2026 13:13:03 -0400 Subject: [PATCH 33/38] work on empty panel layouts --- .../panels/BookmarksPanelProvider.java | 64 +++++++++++++++-- .../desktop/panels/DocsPanelProvider.java | 58 ++++++++++++++- .../panels/FileBrowserPanelProvider.java | 48 ++++++++++++- .../desktop/panels/SymbolsPanelProvider.java | 70 +++++++++++++++---- 4 files changed, 218 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java index 66ddb5e1..1fa59b02 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/BookmarksPanelProvider.java @@ -20,11 +20,15 @@ public class BookmarksPanelProvider implements IEditorPanelProvider { private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final String BOOKMARKS_LIST_CARD = "list"; + private static final String BOOKMARKS_EMPTY_CARD = "empty"; private final List allBookmarks = new ArrayList<>(); private final DefaultListModel bookmarkListModel = new DefaultListModel<>(); private final JList bookmarkList = new JList<>(bookmarkListModel); private final JTextField bookmarkSearchField = new JTextField(); + private final JPanel bookmarkResultsHost = new JPanel(new CardLayout()); + private final JLabel emptyStateLabel = new JLabel("No Bookmarks"); private PluginContext context; @Override @@ -79,12 +83,7 @@ public JPanel build(PluginContext context) { title.setBorder(new EmptyBorder(0, 8, 0, 8)); JButton toggleBookmarkButton = createHeaderIconButton(ICON_BOOKMARK_ADD, "Toggle bookmark"); - toggleBookmarkButton.addActionListener(e -> { - if (this.context != null && this.context.commands() != null) { - this.context.commands().toggleBookmark(); - reloadBookmarks(); - } - }); + toggleBookmarkButton.addActionListener(e -> toggleBookmarkAndReload()); JButton nextBookmarkButton = createHeaderIconButton(ICON_ARROW_DOWN, "Next bookmark"); nextBookmarkButton.addActionListener(e -> { @@ -191,7 +190,38 @@ public void actionPerformed(java.awt.event.ActionEvent e) { scrollPane.setBackground(panelBackground); scrollPane.setBorder(null); - content.add(scrollPane, BorderLayout.CENTER); + JPanel emptyStatePanel = new JPanel(new GridBagLayout()); + emptyStatePanel.setBackground(panelBackground); + JPanel emptyStateContent = new JPanel(); + emptyStateContent.setOpaque(false); + emptyStateContent.setLayout(new BoxLayout(emptyStateContent, BoxLayout.Y_AXIS)); + Color disabledIconColor = UIManager.getColor("Label.disabledForeground"); + if (disabledIconColor == null) { + disabledIconColor = new Color(0xC8C8C8); + } + JLabel emptyIconLabel = new JLabel(createScaledIcon(ICON_BOOKMARK_ADD, 40, disabledIconColor)); + emptyIconLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + emptyStateLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + Font emptyStateFont = emptyStateLabel.getFont(); + emptyStateLabel.setFont(new Font(emptyStateFont.getName(), Font.BOLD, emptyStateFont.getSize() + 2)); + emptyStateLabel.setForeground(new Color(0x5B717F)); + JButton addBookmarkButton = new JButton("Add Bookmark"); + addBookmarkButton.setFocusable(false); + addBookmarkButton.setMargin(new Insets(4, 4, 4, 4)); + addBookmarkButton.setForeground(new Color(0x5B717F)); + addBookmarkButton.setAlignmentX(Component.CENTER_ALIGNMENT); + addBookmarkButton.addActionListener(e -> toggleBookmarkAndReload()); + emptyStateContent.add(emptyIconLabel); + emptyStateContent.add(Box.createVerticalStrut(8)); + emptyStateContent.add(emptyStateLabel); + emptyStateContent.add(Box.createVerticalStrut(8)); + emptyStateContent.add(addBookmarkButton); + emptyStatePanel.add(emptyStateContent); + + bookmarkResultsHost.setOpaque(false); + bookmarkResultsHost.add(scrollPane, BOOKMARKS_LIST_CARD); + bookmarkResultsHost.add(emptyStatePanel, BOOKMARKS_EMPTY_CARD); + content.add(bookmarkResultsHost, BorderLayout.CENTER); panel.add(content, BorderLayout.CENTER); panelCardHost.add(createRoundedCardHost(panel, panelBackground, "bookmarks-main"), "main"); @@ -240,14 +270,27 @@ private void filterBookmarks() { } if (!bookmarkListModel.isEmpty()) { + showBookmarkResults(true, needle); if (previousSelection != null) { bookmarkList.setSelectedValue(previousSelection, true); } else { bookmarkList.setSelectedIndex(0); } + } else { + showBookmarkResults(false, needle); } } + private void showBookmarkResults(boolean hasResults, String needle) { + if (hasResults) { + ((CardLayout) bookmarkResultsHost.getLayout()).show(bookmarkResultsHost, BOOKMARKS_LIST_CARD); + return; + } + + emptyStateLabel.setText(needle == null || needle.isBlank() ? "No Bookmarks" : "No Results"); + ((CardLayout) bookmarkResultsHost.getLayout()).show(bookmarkResultsHost, BOOKMARKS_EMPTY_CARD); + } + private void goToSelectedBookmark() { if (context == null || context.commands() == null) { return; @@ -272,6 +315,13 @@ private JButton createHeaderIconButton(String iconPath, String tooltip) { return button; } + private void toggleBookmarkAndReload() { + if (this.context != null && this.context.commands() != null) { + this.context.commands().toggleBookmark(); + reloadBookmarks(); + } + } + private JToggleButton createHeaderSearchToggleButton() { JToggleButton button = new JToggleButton(createScaledIcon(ICON_SEARCH, 18)); button.setToolTipText("Show search"); diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java index 0bb7d747..10d39bf4 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -30,6 +30,7 @@ import java.awt.Dimension; import java.awt.FlowLayout; import java.awt.Font; +import java.awt.GridBagLayout; import java.awt.Insets; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; @@ -59,6 +60,7 @@ import javax.swing.ScrollPaneConstants; import javax.swing.SwingConstants; import javax.swing.SwingUtilities; +import javax.swing.UIManager; import javax.swing.border.EmptyBorder; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; @@ -78,6 +80,7 @@ public class DocsPanelProvider implements IEditorPanelProvider { private final DefaultListModel searchListModel = new DefaultListModel<>(); private final JList searchList = new JList<>(searchListModel); private final JPanel resultsCards = new JPanel(new CardLayout()); + private final JPanel searchResultsCards = new JPanel(new CardLayout()); private final JTextPane summaryPane = new JTextPane(); private final JLabel selectionNameLabel = new JLabel("Select an item."); private final JButton primaryAction = new JButton("Open"); @@ -165,8 +168,11 @@ public JPanel build(PluginContext context) { searchScrollPane.setBorder(null); configureSmoothScrolling(browseScrollPane); configureSmoothScrolling(searchScrollPane); + searchResultsCards.setOpaque(false); + searchResultsCards.add(searchScrollPane, "list"); + searchResultsCards.add(createSearchEmptyState(panelBackground), "empty"); resultsCards.add(browseScrollPane, "browse"); - resultsCards.add(searchScrollPane, "search"); + resultsCards.add(searchResultsCards, "search"); JPanel resultsPanel = new JPanel(new BorderLayout(0, 6)); resultsPanel.setBackground(panelBackground); @@ -432,6 +438,49 @@ private JPanel createDetailsPanel(Color panelBackground) { return detailsPanel; } + private JPanel createSearchEmptyState(Color panelBackground) { + JPanel noResultsPanel = new JPanel(new GridBagLayout()); + noResultsPanel.setBackground(panelBackground); + JPanel noResultsContent = new JPanel(); + noResultsContent.setOpaque(false); + noResultsContent.setLayout(new BoxLayout(noResultsContent, BoxLayout.Y_AXIS)); + + Color disabledIconColor = UIManager.getColor("Label.disabledForeground"); + if (disabledIconColor == null) { + disabledIconColor = new Color(0x9E9E9E); + } + JLabel noResultsIcon = new JLabel(createScaledIcon(ICON_SEARCH, 40, disabledIconColor)); + noResultsIcon.setAlignmentX(Component.CENTER_ALIGNMENT); + + JLabel noResultsLabel = new JLabel("No Results"); + Font noResultsFont = noResultsLabel.getFont(); + noResultsLabel.setFont(new Font(noResultsFont.getName(), Font.BOLD, noResultsFont.getSize() + 2)); + noResultsLabel.setForeground(new Color(0x5B717F)); + noResultsLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + + JButton resetFiltersButton = new JButton("Reset Filters"); + resetFiltersButton.setFocusable(false); + resetFiltersButton.setMargin(new Insets(4, 4, 4, 4)); + resetFiltersButton.setForeground(new Color(0x5B717F)); + resetFiltersButton.setAlignmentX(Component.CENTER_ALIGNMENT); + resetFiltersButton.addActionListener(e -> resetSearchFilters()); + + noResultsContent.add(noResultsIcon); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(noResultsLabel); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(resetFiltersButton); + noResultsPanel.add(noResultsContent); + return noResultsPanel; + } + + private void resetSearchFilters() { + currentScope = ContentScope.ALL; + updateScopeButtonText(); + searchField.setText(""); + refreshContent(); + } + private void refreshContent() { if (editor == null) { setSummary(null); @@ -452,6 +501,13 @@ private void refreshContent() { for (ContentPanelItem item : contentModel.items(editor.contentCatalog(), currentScope, query)) { searchListModel.addElement(item); } + ((CardLayout) searchResultsCards.getLayout()) + .show(searchResultsCards, searchListModel.isEmpty() ? "empty" : "list"); + if (searchListModel.isEmpty()) { + selectItem(null); + } else { + searchList.setSelectedIndex(0); + } ((CardLayout) resultsCards.getLayout()).show(resultsCards, "search"); } setSummary(selectedItem); diff --git a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java index efc53fd8..2d28568e 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/FileBrowserPanelProvider.java @@ -34,6 +34,7 @@ public class FileBrowserPanelProvider implements IEditorPanelProvider { private final JTree fileBrowserTree = new JTree(); private final JTextField fileSearchField = new JTextField(); private final FileSystemView fileSystemView = FileSystemView.getFileSystemView(); + private final JPanel searchResultsCards = new JPanel(new CardLayout()); private boolean showHiddenFiles = false; private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); @@ -210,16 +211,58 @@ public void mouseReleased(MouseEvent e) { scrollPane.setBackground(panelBackground); configureSmoothScrolling(scrollPane); + searchResultsCards.setOpaque(false); + searchResultsCards.add(scrollPane, "tree"); + searchResultsCards.add(createSearchEmptyState(panelBackground), "empty"); JPanel content = new JPanel(new BorderLayout(0, 6)); content.setBackground(panelBackground); content.add(searchBar, BorderLayout.NORTH); - content.add(scrollPane, BorderLayout.CENTER); + content.add(searchResultsCards, BorderLayout.CENTER); panel.add(content, BorderLayout.CENTER); panelCardHost.add(createRoundedCardHost(panel, panelBackground, "workspace-main"), "main"); ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); return panelCardHost; } + private JPanel createSearchEmptyState(Color panelBackground) { + JPanel noResultsPanel = new JPanel(new GridBagLayout()); + noResultsPanel.setBackground(panelBackground); + JPanel noResultsContent = new JPanel(); + noResultsContent.setOpaque(false); + noResultsContent.setLayout(new BoxLayout(noResultsContent, BoxLayout.Y_AXIS)); + + Color disabledIconColor = UIManager.getColor("Label.disabledForeground"); + if (disabledIconColor == null) { + disabledIconColor = new Color(0x9E9E9E); + } + JLabel noResultsIcon = new JLabel(createScaledIcon(ICON_SEARCH, 40, disabledIconColor)); + noResultsIcon.setAlignmentX(Component.CENTER_ALIGNMENT); + + JLabel noResultsLabel = new JLabel("No Results"); + Font noResultsFont = noResultsLabel.getFont(); + noResultsLabel.setFont(new Font(noResultsFont.getName(), Font.BOLD, noResultsFont.getSize() + 2)); + noResultsLabel.setForeground(new Color(0x5B717F)); + noResultsLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + + JButton resetFiltersButton = new JButton("Reset Filters"); + resetFiltersButton.setFocusable(false); + resetFiltersButton.setMargin(new Insets(4, 4, 4, 4)); + resetFiltersButton.setForeground(new Color(0x5B717F)); + resetFiltersButton.setAlignmentX(Component.CENTER_ALIGNMENT); + resetFiltersButton.addActionListener(e -> { + fileSearchField.setText(""); + refresh(context.currentEditor()); + }); + + noResultsContent.add(noResultsIcon); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(noResultsLabel); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(resetFiltersButton); + noResultsPanel.add(noResultsContent); + return noResultsPanel; + } + private JButton createHeaderIconButton(String iconPath, String tooltip) { JButton button = new JButton(createScaledIcon(iconPath, 18)); button.setToolTipText(tooltip); @@ -327,6 +370,9 @@ public void refresh(EditorPlugin languageProvider) { rootNode = new DefaultMutableTreeNode(root); } fileBrowserTree.setModel(new DefaultTreeModel(rootNode)); + boolean hasSearch = searchNeedle != null && !searchNeedle.isBlank(); + ((CardLayout) searchResultsCards.getLayout()) + .show(searchResultsCards, hasSearch && rootNode.getChildCount() == 0 ? "empty" : "tree"); if (fileBrowserTree.getRowCount() > 0) { fileBrowserTree.expandRow(0); } diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java index 9a7930a8..90f20716 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -46,6 +46,9 @@ public class SymbolsPanelProvider implements IEditorPanelProvider { private final JButton referenceInsertButton = new JButton("Insert"); private final javax.swing.Timer referenceFilterDebounceTimer = new javax.swing.Timer(120, e -> filterReferenceItems()); + private final JPanel referenceResultsHost = new JPanel(new CardLayout()); + private static final String REFERENCE_RESULTS_LIST_CARD = "list"; + private static final String REFERENCE_RESULTS_EMPTY_CARD = "empty"; private SymbolIndexer symbolIndexer; private final java.util.List allReferenceItems = new ArrayList<>(); @@ -232,6 +235,37 @@ public Component getListCellRendererComponent( lookupSplit.putClientProperty("JSplitPane.style", "plain"); JScrollPane listScrollPane = new JScrollPane(referenceList); listScrollPane.setBorder(null); + JPanel noResultsPanel = new JPanel(new GridBagLayout()); + noResultsPanel.setBackground(panelBackground); + JPanel noResultsContent = new JPanel(); + noResultsContent.setOpaque(false); + noResultsContent.setLayout(new BoxLayout(noResultsContent, BoxLayout.Y_AXIS)); + Color disabledIconColor = UIManager.getColor("Label.disabledForeground"); + if (disabledIconColor == null) { + disabledIconColor = new Color(0xC8C8C8); + } + JLabel noResultsIcon = new JLabel(createScaledIcon(ICON_SEARCH, 40, disabledIconColor)); + noResultsIcon.setAlignmentX(Component.CENTER_ALIGNMENT); + JLabel noResultsLabel = new JLabel("No Results"); + Font noResultsFont = noResultsLabel.getFont(); + noResultsLabel.setFont(new Font(noResultsFont.getName(), Font.BOLD, noResultsFont.getSize() + 2)); + noResultsLabel.setForeground(new Color(0x5B717F)); + noResultsLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + JButton resetFiltersButton = new JButton("Reset Filters"); + resetFiltersButton.setFocusable(false); + resetFiltersButton.setMargin(new Insets(4, 4, 4, 4)); + resetFiltersButton.setForeground(new Color(0x5B717F)); + resetFiltersButton.setAlignmentX(Component.CENTER_ALIGNMENT); + resetFiltersButton.addActionListener(e -> resetReferenceFiltersAndSearch()); + noResultsContent.add(noResultsIcon); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(noResultsLabel); + noResultsContent.add(Box.createVerticalStrut(8)); + noResultsContent.add(resetFiltersButton); + noResultsPanel.add(noResultsContent); + referenceResultsHost.setOpaque(false); + referenceResultsHost.add(listScrollPane, REFERENCE_RESULTS_LIST_CARD); + referenceResultsHost.add(noResultsPanel, REFERENCE_RESULTS_EMPTY_CARD); JPanel detailsPanel = new JPanel(new BorderLayout(0, 0)); JPanel detailsHeader = new JPanel(new BorderLayout(8, 0)); detailsHeader.setBackground(panelBackground); @@ -271,7 +305,7 @@ public Component getListCellRendererComponent( symbolsListHeader.add(lookupHeader, BorderLayout.NORTH); symbolsListHeader.add(searchBar, BorderLayout.SOUTH); symbolsListPanel.add(symbolsListHeader, BorderLayout.NORTH); - symbolsListPanel.add(listScrollPane, BorderLayout.CENTER); + symbolsListPanel.add(referenceResultsHost, BorderLayout.CENTER); lookupSplit.setTopComponent(createRoundedCardHost(symbolsListPanel, panelBackground, "symbols-list")); lookupPanel.add(lookupSplit, BorderLayout.CENTER); @@ -643,18 +677,7 @@ private void rebuildReferenceFiltersPopup() { } JMenuItem resetItem = new JMenuItem("Reset filters"); - resetItem.addActionListener(e -> { - updatingReferenceFilters = true; - try { - referenceKindFilter.setSelectedItem("All"); - referenceSourceFilter.setSelectedItem("All Sources"); - referenceLibraryFilter.setSelectedItem("All Libraries"); - } finally { - updatingReferenceFilters = false; - } - updateReferenceFiltersButtonTooltip(); - filterReferenceItems(); - }); + resetItem.addActionListener(e -> resetReferenceFiltersAndSearch()); referenceFiltersPopup.add(typeMenu); referenceFiltersPopup.add(sourceMenu); @@ -691,6 +714,20 @@ private void requestFilterReferenceItems() { referenceFilterDebounceTimer.restart(); } + private void resetReferenceFiltersAndSearch() { + updatingReferenceFilters = true; + try { + referenceSearchField.setText(""); + referenceKindFilter.setSelectedItem("All"); + referenceSourceFilter.setSelectedItem("All Sources"); + referenceLibraryFilter.setSelectedItem("All Libraries"); + } finally { + updatingReferenceFilters = false; + } + updateReferenceFiltersButtonTooltip(); + filterReferenceItems(); + } + private void filterReferenceItems() { String query = referenceSearchField.getText(); String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); @@ -739,12 +776,14 @@ private void filterReferenceItems() { } if (!referenceListModel.isEmpty()) { + showReferenceResultsList(true); if (previousSelection != null && matches.contains(previousSelection)) { referenceList.setSelectedValue(previousSelection, true); } else { referenceList.setSelectedIndex(0); } } else { + showReferenceResultsList(false); setReferenceSelectionName("No selection"); setReferenceDetailsHtml(REFERENCE_NO_MATCHES_HTML); referenceCopyButton.setEnabled(false); @@ -752,6 +791,11 @@ private void filterReferenceItems() { } } + private void showReferenceResultsList(boolean hasResults) { + CardLayout layout = (CardLayout) referenceResultsHost.getLayout(); + layout.show(referenceResultsHost, hasResults ? REFERENCE_RESULTS_LIST_CARD : REFERENCE_RESULTS_EMPTY_CARD); + } + private void updateReferenceSelectionDetails() { ReferenceItem item = referenceList.getSelectedValue(); if (item == null) { From 61d4ef93ab9910daa575fffd29c2780bbeacf0d8 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Sun, 19 Jul 2026 12:21:44 -0400 Subject: [PATCH 34/38] split panel sizing adjustments --- .../desktop/panels/DocsPanelProvider.java | 117 ++++++++++++---- .../desktop/panels/SymbolsPanelProvider.java | 132 ++++++++++++++---- 2 files changed, 198 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java index 10d39bf4..ecb3a692 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/DocsPanelProvider.java @@ -72,6 +72,8 @@ public class DocsPanelProvider implements IEditorPanelProvider { private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); + private static final double RESULTS_SPLIT_WEIGHT = 0.65; + private static final double DETAILS_HEIGHT_RATIO = 0.35; private final ContentPanelModel contentModel = new ContentPanelModel(); private final JButton scopeButton = new JButton(); @@ -91,6 +93,11 @@ public class DocsPanelProvider implements IEditorPanelProvider { private BasicEditor editor; private ContentPanelItem selectedItem; private ContentScope currentScope = ContentScope.ALL; + private JSplitPane contentSplitPane; + private JComponent detailsComponent; + private int detailsDividerSize; + private boolean detailsCollapsed; + private boolean rebuildingSearchList; @Override public String id() { @@ -189,15 +196,17 @@ public JPanel build(PluginContext context) { detailsScrollPane.setBorder(null); configureSmoothScrolling(detailsScrollPane); - JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); - splitPane.setResizeWeight(0.66); - splitPane.setBorder(null); - hideSplitPaneHandle(splitPane); - splitPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); - splitPane.putClientProperty("JSplitPane.style", "plain"); - splitPane.setTopComponent(createRoundedCardHost(resultsPanel, panelBackground, "docs-results")); - splitPane.setBottomComponent(createRoundedCardHost(detailsScrollPane, panelBackground, "docs-details")); - lookupPanel.add(splitPane, BorderLayout.CENTER); + contentSplitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + contentSplitPane.setResizeWeight(RESULTS_SPLIT_WEIGHT); + contentSplitPane.setBorder(null); + hideSplitPaneHandle(contentSplitPane); + contentSplitPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); + contentSplitPane.putClientProperty("JSplitPane.style", "plain"); + contentSplitPane.setTopComponent(createRoundedCardHost(resultsPanel, panelBackground, "docs-results")); + detailsComponent = createRoundedCardHost(detailsScrollPane, panelBackground, "docs-details"); + contentSplitPane.setBottomComponent(detailsComponent); + detailsDividerSize = contentSplitPane.getDividerSize(); + lookupPanel.add(contentSplitPane, BorderLayout.CENTER); searchField.getDocument().addDocumentListener(new DocumentListener() { @Override @@ -378,7 +387,7 @@ public Component getListCellRendererComponent( } }); searchList.addListSelectionListener(e -> { - if (!e.getValueIsAdjusting()) { + if (!e.getValueIsAdjusting() && !rebuildingSearchList) { selectItem(searchList.getSelectedValue()); } }); @@ -497,17 +506,22 @@ private void refreshContent() { browseTree.expandRow(0); ((CardLayout) resultsCards.getLayout()).show(resultsCards, "browse"); } else { - searchListModel.clear(); - for (ContentPanelItem item : contentModel.items(editor.contentCatalog(), currentScope, query)) { - searchListModel.addElement(item); + rebuildingSearchList = true; + try { + searchListModel.clear(); + for (ContentPanelItem item : contentModel.items(editor.contentCatalog(), currentScope, query)) { + searchListModel.addElement(item); + } + if (!searchListModel.isEmpty()) { + searchList.setSelectedIndex(0); + } + } finally { + rebuildingSearchList = false; } + ((CardLayout) searchResultsCards.getLayout()) .show(searchResultsCards, searchListModel.isEmpty() ? "empty" : "list"); - if (searchListModel.isEmpty()) { - selectItem(null); - } else { - searchList.setSelectedIndex(0); - } + selectItem(searchList.getSelectedValue()); ((CardLayout) resultsCards.getLayout()).show(resultsCards, "search"); } setSummary(selectedItem); @@ -532,17 +546,17 @@ private boolean isRedundantScopeSegment(String segment) { String normalizedTag = currentScope.tag(); return normalizedTag != null && (normalizedSegment.equals(normalizedTag) - || normalizedSegment.equals(normalizedTag + "s") - || normalizedSegment.equals(normalizeScopeText(currentScope.displayName()))); + || normalizedSegment.equals(normalizedTag + "s") + || normalizedSegment.equals(normalizeScopeText(currentScope.displayName()))); } private String normalizeScopeText(String text) { return text == null ? "" : text.trim() - .toLowerCase(Locale.ROOT) - .replace('_', '-') - .replace(' ', '-'); + .toLowerCase(Locale.ROOT) + .replace('_', '-') + .replace(' ', '-'); } private void ensureCurrentScopeAvailable() { @@ -572,12 +586,14 @@ private void selectItem(ContentPanelItem item) { private void setSummary(ContentPanelItem item) { if (item == null) { + setDetailsVisible(false); setSelectionName("Select an item."); summaryPane.setText("Select an item."); primaryAction.setText("Open"); primaryAction.setEnabled(false); return; } + setDetailsVisible(true); ContentSelectionSummary summary = contentModel.summary(item); setSelectionName(summary.title()); summaryPane.setText("" @@ -591,6 +607,59 @@ private void setSummary(ContentPanelItem item) { primaryAction.setEnabled(true); } + private void setDetailsVisible(boolean visible) { + if (contentSplitPane == null || detailsComponent == null) { + return; + } + + if (visible) { + if (detailsCollapsed || contentSplitPane.getBottomComponent() != detailsComponent) { + expandDetailsPane(); + } + } else if (!detailsCollapsed || contentSplitPane.getBottomComponent() == detailsComponent) { + collapseDetailsPane(); + } + } + + private void collapseDetailsPane() { + detailsCollapsed = true; + contentSplitPane.setResizeWeight(1.0); + contentSplitPane.setDividerSize(0); + contentSplitPane.setBottomComponent(null); + contentSplitPane.revalidate(); + contentSplitPane.repaint(); + } + + private void expandDetailsPane() { + detailsCollapsed = false; + if (contentSplitPane.getBottomComponent() != detailsComponent) { + contentSplitPane.setBottomComponent(detailsComponent); + } + contentSplitPane.setDividerSize(detailsDividerSize); + contentSplitPane.setResizeWeight(RESULTS_SPLIT_WEIGHT); + contentSplitPane.revalidate(); + + SwingUtilities.invokeLater(() -> { + if (detailsCollapsed || contentSplitPane.getBottomComponent() != detailsComponent) { + return; + } + + int splitPaneHeight = contentSplitPane.getHeight(); + if (splitPaneHeight <= 0) { + return; + } + + int availableHeight = Math.max(0, splitPaneHeight - contentSplitPane.getDividerSize()); + int minimumTopHeight = Math.min(80, availableHeight); + int maximumDetailsHeight = Math.max(0, availableHeight - minimumTopHeight); + int targetDetailsHeight = Math.max(140, + (int) Math.round(availableHeight * DETAILS_HEIGHT_RATIO)); + targetDetailsHeight = Math.min(targetDetailsHeight, maximumDetailsHeight); + + contentSplitPane.setDividerLocation(availableHeight - targetDetailsHeight); + }); + } + private void setSelectionName(String name) { String text = name == null ? "" : name; selectionNameLabel.setText(text); @@ -632,4 +701,4 @@ private void instantiateTemplate(TemplateCatalogEntry entry) { Path destination = chooser.getSelectedFile().toPath(); editor.instantiateTemplate(entry, destination, entry.descriptor().title()); } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java index 90f20716..d1794633 100644 --- a/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java +++ b/app/src/main/java/com/basic4gl/desktop/panels/SymbolsPanelProvider.java @@ -61,9 +61,16 @@ public class SymbolsPanelProvider implements IEditorPanelProvider { new JComboBox<>(new String[] {"All Sources", "Builtin", "Libraries", "Program"}); private static final Dimension HEADER_ICON_BUTTON_SIZE = new Dimension(30, 30); private static final int CARD_ARC = 14; + private static final double RESULTS_SPLIT_WEIGHT = 0.65; + private static final double DETAILS_HEIGHT_RATIO = 0.35; private int lastProgramSymbolsFingerprint = Integer.MIN_VALUE; private boolean updatingReferenceFilters = false; + private JSplitPane referenceSplitPane; + private JComponent referenceDetailsComponent; + private int referenceDetailsDividerSize; + private boolean referenceDetailsCollapsed; + private boolean rebuildingReferenceList; private PluginContext context; @@ -228,11 +235,11 @@ public Component getListCellRendererComponent( referenceSelectionNameLabel.setPreferredSize(new Dimension(0, titleHeight)); setReferenceSelectionName("Select an entry."); - JSplitPane lookupSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); - lookupSplit.setResizeWeight(0.65); - hideSplitPaneHandle(lookupSplit); - lookupSplit.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); - lookupSplit.putClientProperty("JSplitPane.style", "plain"); + referenceSplitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + referenceSplitPane.setResizeWeight(RESULTS_SPLIT_WEIGHT); + hideSplitPaneHandle(referenceSplitPane); + referenceSplitPane.putClientProperty("JComponent.style", "showGrip: false; gripColor: #00000000;"); + referenceSplitPane.putClientProperty("JSplitPane.style", "plain"); JScrollPane listScrollPane = new JScrollPane(referenceList); listScrollPane.setBorder(null); JPanel noResultsPanel = new JPanel(new GridBagLayout()); @@ -282,7 +289,8 @@ public Component getListCellRendererComponent( JScrollPane detailsScrollPane = new JScrollPane(detailsPanel); detailsScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); detailsScrollPane.setBorder(null); - lookupSplit.setBottomComponent(createRoundedCardHost(detailsScrollPane, panelBackground, "symbols-details")); + referenceDetailsComponent = createRoundedCardHost(detailsScrollPane, panelBackground, "symbols-details"); + referenceSplitPane.setBottomComponent(referenceDetailsComponent); JPanel searchBar = new JPanel(new BorderLayout(6, 0)); searchBar.setBackground(panelBackground); @@ -306,9 +314,10 @@ public Component getListCellRendererComponent( symbolsListHeader.add(searchBar, BorderLayout.SOUTH); symbolsListPanel.add(symbolsListHeader, BorderLayout.NORTH); symbolsListPanel.add(referenceResultsHost, BorderLayout.CENTER); - lookupSplit.setTopComponent(createRoundedCardHost(symbolsListPanel, panelBackground, "symbols-list")); + referenceSplitPane.setTopComponent(createRoundedCardHost(symbolsListPanel, panelBackground, "symbols-list")); + referenceDetailsDividerSize = referenceSplitPane.getDividerSize(); - lookupPanel.add(lookupSplit, BorderLayout.CENTER); + lookupPanel.add(referenceSplitPane, BorderLayout.CENTER); referenceSearchField.getDocument().addDocumentListener(new DocumentListener() { @Override @@ -327,7 +336,7 @@ public void changedUpdate(DocumentEvent e) { } }); referenceList.addListSelectionListener(e -> { - if (!e.getValueIsAdjusting()) { + if (!e.getValueIsAdjusting() && !rebuildingReferenceList) { updateReferenceSelectionDetails(); } }); @@ -364,6 +373,7 @@ public void mouseClicked(MouseEvent e) { referenceInsertButton.addActionListener(e -> insertSelectedReference()); referenceCopyButton.addActionListener(e -> copySelectedSymbolName()); updateReferenceFiltersButtonTooltip(); + setReferenceDetailsVisible(false); panelCardHost.add(lookupPanel, "main"); ((CardLayout) panelCardHost.getLayout()).show(panelCardHost, "main"); @@ -740,22 +750,22 @@ private void filterReferenceItems() { for (ReferenceItem item : allReferenceItems) { boolean kindMatches = "All".equals(selectedKind) || ("Functions".equals(selectedKind) - && ("function".equals(item.kind) || "userfunc".equals(item.kind))) + && ("function".equals(item.kind) || "userfunc".equals(item.kind))) || ("Constants".equals(selectedKind) && "constant".equals(item.kind)) || ("Labels".equals(selectedKind) && "label".equals(item.kind)) || ("Variables".equals(selectedKind) && "variable".equals(item.kind)) || ("Structs".equals(selectedKind) && "struc".equals(item.kind)); boolean sourceMatches = "All Sources".equals(selectedSource) || ("Builtin".equals(selectedSource) - && item.library != null - && "Builtin".equalsIgnoreCase(item.library)) + && item.library != null + && "Builtin".equalsIgnoreCase(item.library)) || ("Libraries".equals(selectedSource) - && item.library != null - && !"Builtin".equalsIgnoreCase(item.library) - && !"Program".equalsIgnoreCase(item.library)) + && item.library != null + && !"Builtin".equalsIgnoreCase(item.library) + && !"Program".equalsIgnoreCase(item.library)) || ("Program".equals(selectedSource) - && item.library != null - && "Program".equalsIgnoreCase(item.library)); + && item.library != null + && "Program".equalsIgnoreCase(item.library)); boolean libraryMatches = "All Libraries".equals(selectedLibrary) || (item.library != null && selectedLibrary.equals(item.library)); if (needle.isEmpty() @@ -763,27 +773,37 @@ private void filterReferenceItems() { || item.signature.toLowerCase(Locale.ROOT).contains(needle) || item.kind.toLowerCase(Locale.ROOT).contains(needle) || (item.library != null - && item.library.toLowerCase(Locale.ROOT).contains(needle))) { + && item.library.toLowerCase(Locale.ROOT).contains(needle))) { if (kindMatches && sourceMatches && libraryMatches) { matches.add(item); } } } - referenceListModel.clear(); - for (ReferenceItem match : matches) { - referenceListModel.addElement(match); + rebuildingReferenceList = true; + try { + referenceListModel.clear(); + for (ReferenceItem match : matches) { + referenceListModel.addElement(match); + } + + if (!referenceListModel.isEmpty()) { + if (previousSelection != null && matches.contains(previousSelection)) { + referenceList.setSelectedValue(previousSelection, true); + } else { + referenceList.setSelectedIndex(0); + } + } + } finally { + rebuildingReferenceList = false; } if (!referenceListModel.isEmpty()) { showReferenceResultsList(true); - if (previousSelection != null && matches.contains(previousSelection)) { - referenceList.setSelectedValue(previousSelection, true); - } else { - referenceList.setSelectedIndex(0); - } + updateReferenceSelectionDetails(); } else { showReferenceResultsList(false); + setReferenceDetailsVisible(false); setReferenceSelectionName("No selection"); setReferenceDetailsHtml(REFERENCE_NO_MATCHES_HTML); referenceCopyButton.setEnabled(false); @@ -799,18 +819,76 @@ private void showReferenceResultsList(boolean hasResults) { private void updateReferenceSelectionDetails() { ReferenceItem item = referenceList.getSelectedValue(); if (item == null) { + setReferenceDetailsVisible(false); setReferenceSelectionName("Select an entry."); setReferenceDetailsHtml(REFERENCE_SELECT_PROMPT_HTML); referenceCopyButton.setEnabled(false); referenceInsertButton.setEnabled(false); return; } + setReferenceDetailsVisible(true); setReferenceSelectionName(item.name); setReferenceDetailsHtml(item.details); referenceCopyButton.setEnabled(true); referenceInsertButton.setEnabled(true); } + private void setReferenceDetailsVisible(boolean visible) { + if (referenceSplitPane == null || referenceDetailsComponent == null) { + return; + } + + if (visible) { + if (referenceDetailsCollapsed + || referenceSplitPane.getBottomComponent() != referenceDetailsComponent) { + expandReferenceDetailsPane(); + } + } else if (!referenceDetailsCollapsed + || referenceSplitPane.getBottomComponent() == referenceDetailsComponent) { + collapseReferenceDetailsPane(); + } + } + + private void collapseReferenceDetailsPane() { + referenceDetailsCollapsed = true; + referenceSplitPane.setResizeWeight(1.0); + referenceSplitPane.setDividerSize(0); + referenceSplitPane.setBottomComponent(null); + referenceSplitPane.revalidate(); + referenceSplitPane.repaint(); + } + + private void expandReferenceDetailsPane() { + referenceDetailsCollapsed = false; + if (referenceSplitPane.getBottomComponent() != referenceDetailsComponent) { + referenceSplitPane.setBottomComponent(referenceDetailsComponent); + } + referenceSplitPane.setDividerSize(referenceDetailsDividerSize); + referenceSplitPane.setResizeWeight(RESULTS_SPLIT_WEIGHT); + referenceSplitPane.revalidate(); + + SwingUtilities.invokeLater(() -> { + if (referenceDetailsCollapsed + || referenceSplitPane.getBottomComponent() != referenceDetailsComponent) { + return; + } + + int splitPaneHeight = referenceSplitPane.getHeight(); + if (splitPaneHeight <= 0) { + return; + } + + int availableHeight = Math.max(0, splitPaneHeight - referenceSplitPane.getDividerSize()); + int minimumTopHeight = Math.min(80, availableHeight); + int maximumDetailsHeight = Math.max(0, availableHeight - minimumTopHeight); + int targetDetailsHeight = Math.max(140, + (int) Math.round(availableHeight * DETAILS_HEIGHT_RATIO)); + targetDetailsHeight = Math.min(targetDetailsHeight, maximumDetailsHeight); + + referenceSplitPane.setDividerLocation(availableHeight - targetDetailsHeight); + }); + } + private void insertSelectedReference() { ReferenceItem item = referenceList.getSelectedValue(); if (item == null) { @@ -833,4 +911,4 @@ private void setReferenceSelectionName(String name) { referenceSelectionNameLabel.setText(referenceSelectionName); referenceSelectionNameLabel.setToolTipText(referenceSelectionName.isBlank() ? null : referenceSelectionName); } -} +} \ No newline at end of file From b5995c599707f2f63f2ea0a6c620a94571341482 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Mon, 20 Jul 2026 07:34:43 -0400 Subject: [PATCH 35/38] update docs links --- README.md | 14 ++++----- docs/basic4gl/index.md | 30 +++++++++---------- ...=> keyboard-mouse-joystick-input-guide.md} | 0 docs/basic4gl/trigonometry-function-guide.md | 2 +- 4 files changed, 23 insertions(+), 23 deletions(-) rename docs/basic4gl/{keyboard-mouse-joystick-guide.md => keyboard-mouse-joystick-input-guide.md} (100%) diff --git a/README.md b/README.md index 310a7b5f..628e09e3 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ Check out the [Releases Page](https://github.com/NateIsStalling/Basic4GLj/releas ### Documentation -Check out the [wiki](https://github.com/NateIsStalling/Basic4GLj/wiki) of this repo for the Basic4GL language guide, sprite library guide for 2D game programming, and additional tutorials. +Check out the [docs](./docs) or [wiki](https://github.com/NateIsStalling/Basic4GLj/wiki) of this repo for the Basic4GL language guide, sprite library guide for 2D game programming, and additional tutorials. -- [Language Syntax Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Language-Syntax-Guide) -- [Text Output Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Text-Output-Guide) -- [Sprite Library Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Sprite-Library-Guide) -- [OpenGL Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/OpenGL-Guide) -- [Sound Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Sound-Guide) +- [Language Syntax Guide](./docs/basic4gl/language-syntax-guide.md) +- [Text Output Guide](./docs/basic4gl/text-output-guide.md) +- [Sprite Library Guide](./docs/basic4gl/sprite-library-guide.md) +- [OpenGL Guide](./docs/basic4gl/opengl-guide.md) +- [Sound Guide](./docs/basic4gl/sound-guide.md) (more documentation coming soon!) @@ -55,7 +55,7 @@ _The application depends on the JAR output of its "app-runtime" and "debug-serve ## Sound System -[Sound Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Sound-Guide) tutorial is available on the project's wiki. +[Sound Guide](./docs/basic4gl/sound-guide.md) tutorial is available in the project's docs and wiki. ### Playing Sound Effects diff --git a/docs/basic4gl/index.md b/docs/basic4gl/index.md index 1d8ba738..5c3cfadf 100644 --- a/docs/basic4gl/index.md +++ b/docs/basic4gl/index.md @@ -2,23 +2,23 @@ Welcome to the Basic4GLj wiki! ## Getting Started: * [Get the Latest Release](https://github.com/NateIsStalling/Basic4GLj/releases) -* [Language Syntax Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Language-Syntax-Guide) -* [Sample Programs](https://github.com/NateIsStalling/Basic4GLj/tree/main/app/src/main/dist/samples/Programs) +* [Language Syntax Guide](./language-syntax-guide.md) +* [Sample Programs](https://github.com/NateIsStalling/Basic4GLj/tree/main/samples/Programs) ## Programmer's Guide: -* [Text Output Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Text-Output-Guide) -* [Standard Function Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Standard-Function-Guide) -* [Keyboard, Mouse, and Joystick Input Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Keyboard,-Mouse,-and-Joystick-Input-Guide) -* [File IO Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/File-IO-Guide) -* [Sound Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Sound-Guide) -* [Sprite Library Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Sprite-Library-Guide) -* [OpenGL Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/OpenGL-Guide) -* [Trigonometry Function Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Trigonometry-Function-Guide) -* [Command Line Function Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Command-Line-Function-Guide) -* [Runtime Compilation Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Runtime-Compilation-Guide) -* [Network Engine Guide](https://github.com/NateIsStalling/Basic4GLj/wiki/Network-Engine-Guide) +* [Text Output Guide](./text-output-guide.md) +* [Standard Function Guide](./standard-function-guide.md) +* [Keyboard, Mouse, and Joystick Input Guide](./keyboard-mouse-joystick-input-guide.md) +* [File IO Guide](./file-io-guide.md) +* [Sound Guide](./sound-guide.md) +* [Sprite Library Guide](./sprite-library-guide.md) +* [OpenGL Guide](./opengl-guide.md) +* [Trigonometry Function Guide](./trigonometry-function-guide.md) +* [Command Line Function Guide](./command-line-function-guide.md) +* [Runtime Compilation Guide](./runtime-compilation-guide.md) +* [Network Engine Guide](./network-engine-guide.md) ## Miscellaneous: -* [Compatibility Notes](https://github.com/NateIsStalling/Basic4GLj/wiki/Compatibility-Notes) -* [OpCode Reference](https://github.com/NateIsStalling/Basic4GLj/wiki/OpCode-Reference) \ No newline at end of file +* [Compatibility Notes](./compatibility-notes.md) +* [OpCode Reference](./opcode-reference.md) \ No newline at end of file diff --git a/docs/basic4gl/keyboard-mouse-joystick-guide.md b/docs/basic4gl/keyboard-mouse-joystick-input-guide.md similarity index 100% rename from docs/basic4gl/keyboard-mouse-joystick-guide.md rename to docs/basic4gl/keyboard-mouse-joystick-input-guide.md diff --git a/docs/basic4gl/trigonometry-function-guide.md b/docs/basic4gl/trigonometry-function-guide.md index 3b77f118..a665bd2c 100644 --- a/docs/basic4gl/trigonometry-function-guide.md +++ b/docs/basic4gl/trigonometry-function-guide.md @@ -3,7 +3,7 @@ > [!TIP] > > For additional math routines like `cos`, `sin`, `tan` and `log`, see the [Standard Function Guide -](https://github.com/NateIsStalling/Basic4GLj/wiki/Standard-Function-Guide) +](./standard-function-guide.md) ## Vector and Matrix routines Basic4GL contains built in support for matrix and vector arithmetic, through a library of trigonometry functions, and also through extensions to standard mathematical operators (+, -, * e.t.c) to work with vector and matrix types. From 73185d2f4f44dcfeae42f36f71456c43dcd79094 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Mon, 27 Jul 2026 01:25:44 -0400 Subject: [PATCH 36/38] resolved todo --- .../main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java index 598b791f..9cc12592 100644 --- a/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java +++ b/app/src/main/java/com/basic4gl/desktop/editor/BasicTokenMaker.java @@ -22,7 +22,6 @@ public class BasicTokenMaker extends LanguageSupportTokenMaker { private static final String INCLUDE = "include "; - // TODO "#plugin " should be added to the antlr config private static final String PLUGIN = "#plugin "; private static final char CHAR_COMMENT = '\''; From 241d3b3de502533c5f28142da1f83f604763d903 Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Mon, 27 Jul 2026 01:27:47 -0400 Subject: [PATCH 37/38] cleanup unused layout component --- .../fife/ui/rtextarea/MultiHeaderGutter.java | 702 ------------------ 1 file changed, 702 deletions(-) delete mode 100644 app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java diff --git a/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java b/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java deleted file mode 100644 index 59866660..00000000 --- a/app/src/main/java/org/fife/ui/rtextarea/MultiHeaderGutter.java +++ /dev/null @@ -1,702 +0,0 @@ -// -// Source code recreated from a .class file by IntelliJ IDEA -// (powered by Fernflower decompiler) -// - -package org.fife.ui.rtextarea; - -import java.awt.*; -import java.awt.event.ComponentAdapter; -import java.awt.event.ComponentEvent; -import java.awt.event.MouseAdapter; -import java.beans.PropertyChangeEvent; -import java.beans.PropertyChangeListener; -import java.util.ArrayList; -import java.util.List; -import javax.swing.*; -import javax.swing.border.Border; -import javax.swing.border.EmptyBorder; -import javax.swing.event.DocumentEvent; -import javax.swing.event.DocumentListener; -import javax.swing.text.BadLocationException; -import org.fife.ui.rsyntaxtextarea.ActiveLineRangeEvent; -import org.fife.ui.rsyntaxtextarea.ActiveLineRangeListener; -import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; - -public class MultiHeaderGutter extends JPanel { - public static final Color DEFAULT_ACTIVE_LINE_RANGE_COLOR = new Color(51, 153, 255); - private RTextArea textArea; - private final JPanel headerArea; - private LineNumberList lineNumberList; - private Color lineNumberColor; - private int lineNumberingStartIndex; - private Font lineNumberFont; - private List iconAreas; - private final List autoHideIconArea = new ArrayList<>(); - private boolean iconRowHeaderInheritsGutterBackground; - private FoldIndicator foldIndicator; - private boolean armed; - private int spacingBetweenLineNumbersAndFoldIndicator; - private final MouseAdapter armedListener = new MouseAdapter() { - @Override - public void mouseEntered(java.awt.event.MouseEvent e) { - setArmed(true); - } - - @Override - public void mouseMoved(java.awt.event.MouseEvent e) { - setArmed(true); - } - - @Override - public void mouseExited(java.awt.event.MouseEvent e) { - Component src = (Component) e.getSource(); - Point p = SwingUtilities.convertPoint(src, e.getPoint(), MultiHeaderGutter.this); - if (!MultiHeaderGutter.this.contains(p)) { - setArmed(false); - } - } - }; - private final MultiHeaderGutter.TextAreaListener listener = new MultiHeaderGutter.TextAreaListener(); - - public MultiHeaderGutter(RTextArea textArea) { - this.lineNumberColor = Color.gray; - this.lineNumberFont = RTextArea.getDefaultFont(); - this.lineNumberingStartIndex = 1; - this.iconRowHeaderInheritsGutterBackground = false; - this.spacingBetweenLineNumbersAndFoldIndicator = 0; - this.setTextArea(textArea); - this.setLayout(new BorderLayout()); - if (this.textArea != null) { - this.setLineNumbersEnabled(true); - if (this.textArea instanceof RSyntaxTextArea) { - RSyntaxTextArea bg = (RSyntaxTextArea) this.textArea; - this.setFoldIndicatorEnabled(bg.isCodeFoldingEnabled()); - } - } - - this.setBorder(new MultiHeaderGutter.GutterBorder(0, 0, 0, 1)); - Color bg1 = null; - if (textArea != null) { - bg1 = textArea.getBackground(); - } - - this.setBackground(bg1 != null ? bg1 : Color.WHITE); - - this.headerArea = new JPanel(); - this.headerArea.setLayout(new BoxLayout(this.headerArea, BoxLayout.LINE_AXIS)); - this.add(this.headerArea, "Before"); - this.addMouseListener(armedListener); - this.addMouseMotionListener(armedListener); - this.headerArea.addMouseListener(armedListener); - this.headerArea.addMouseMotionListener(armedListener); - } - - public GutterIconInfo addLineTrackingIcon(int headerIndex, int line, Icon icon) throws BadLocationException { - return this.addLineTrackingIcon(headerIndex, line, icon, (String) null); - } - - public GutterIconInfo addLineTrackingIcon(int headerIndex, int line, Icon icon, String tip) - throws BadLocationException { - int offs = this.textArea.getLineStartOffset(line); - return this.addOffsetTrackingIcon(headerIndex, offs, icon, tip); - } - - public GutterIconInfo addOffsetTrackingIcon(int offs, Icon icon) throws BadLocationException { - return this.addOffsetTrackingIcon(0, offs, icon, (String) null); - } - - public GutterIconInfo addOffsetTrackingIcon(int headerIndex, int offs, Icon icon, String tip) - throws BadLocationException { - if (headerIndex > -1 && headerIndex < iconAreas.size()) { - return this.iconAreas.get(headerIndex).addOffsetTrackingIcon(offs, icon, tip); - } else { - return null; - } - } - - private void clearActiveLineRange() { - for (IconRowHeader iconArea : this.iconAreas) { - iconArea.clearActiveLineRange(); - } - } - - private void clearActiveLineRange(int headerIndex) { - this.iconAreas.get(headerIndex).clearActiveLineRange(); - } - - public Color getActiveLineRangeColor(int headerIndex) { - return this.iconAreas.get(headerIndex).getActiveLineRangeColor(); - } - - public Icon getBookmarkIcon(int headerIndex) { - return this.iconAreas.get(headerIndex).getBookmarkIcon(); - } - - public GutterIconInfo[] getBookmarks(int headerIndex) { - return this.iconAreas.get(headerIndex).getBookmarks(); - } - - public Color getBorderColor() { - return ((MultiHeaderGutter.GutterBorder) this.getBorder()).getColor(); - } - - public Color getFoldBackground() { - return this.foldIndicator.getFoldIconBackground(); - } - - public Color getFoldIndicatorForeground() { - return this.foldIndicator.getForeground(); - } - - public boolean getIconRowHeaderInheritsGutterBackground() { - return this.iconRowHeaderInheritsGutterBackground; - } - - public Color getLineNumberColor() { - return this.lineNumberColor; - } - - public Font getLineNumberFont() { - return this.lineNumberFont; - } - - public int getLineNumberingStartIndex() { - return this.lineNumberingStartIndex; - } - - public boolean getLineNumbersEnabled() { - for (int i = 0; i < this.getComponentCount(); ++i) { - if (this.getComponent(i) == this.lineNumberList) { - return true; - } - } - - return false; - } - - public boolean getShowCollapsedRegionToolTips() { - return this.foldIndicator.getShowCollapsedRegionToolTips(); - } - - public GutterIconInfo[] getTrackingIcons(int headerIndex, Point p) throws BadLocationException { - int offs = this.textArea.viewToModel(new Point(0, p.y)); - int line = this.textArea.getLineOfOffset(offs); - return this.iconAreas.get(headerIndex).getTrackingIcons(line); - } - - public boolean isFoldIndicatorEnabled() { - for (int i = 0; i < this.getComponentCount(); ++i) { - if (this.getComponent(i) == this.foldIndicator) { - return true; - } - } - - return false; - } - - public boolean isArmed() { - return armed; - } - - void setArmed(boolean armed) { - if (armed != this.armed) { - this.armed = armed; - if (this.foldIndicator != null) { - this.foldIndicator.gutterArmedUpdate(armed); - } - } - } - - public boolean isBookmarkingEnabled(int headerIndex) { - return this.iconAreas.get(headerIndex).isBookmarkingEnabled(); - } - - public boolean isIconRowHeaderEnabled(int headerIndex) { - for (int i = 0; i < this.getComponentCount(); ++i) { - if (this.getComponent(i) == this.iconAreas.get(headerIndex)) { - return true; - } - } - - return false; - } - - public void removeTrackingIcon(int headerIndex, GutterIconInfo tag) { - this.iconAreas.get(headerIndex).removeTrackingIcon(tag); - } - - public void removeAllTrackingIcons() { - for (IconRowHeader iconArea : this.iconAreas) { - iconArea.removeAllTrackingIcons(); - } - } - - public void removeAllTrackingIcons(int headerIndex) { - this.iconAreas.get(headerIndex).removeAllTrackingIcons(); - } - - public void setActiveLineRangeColor(int headerIndex, Color color) { - this.iconAreas.get(headerIndex).setActiveLineRangeColor(color); - } - - private void setActiveLineRange(int startLine, int endLine) { - for (IconRowHeader iconArea : this.iconAreas) { - iconArea.setActiveLineRange(startLine, endLine); - } - } - - public void setBookmarkIcon(int headerIndex, Icon icon) { - this.iconAreas.get(headerIndex).setBookmarkIcon(icon); - } - - public void setBookmarkingEnabled(int headerIndex, boolean enabled) { - this.iconAreas.get(headerIndex).setBookmarkingEnabled(enabled); - if (enabled && !this.isIconRowHeaderEnabled(headerIndex)) { - this.setIconRowHeaderEnabled(headerIndex, true); - } - } - - public void setBorderColor(Color color) { - ((MultiHeaderGutter.GutterBorder) this.getBorder()).setColor(color); - this.repaint(); - } - - public void setComponentOrientation(ComponentOrientation o) { - if (o.isLeftToRight()) { - ((MultiHeaderGutter.GutterBorder) this.getBorder()).setEdges(0, 0, 0, 1); - } else { - ((MultiHeaderGutter.GutterBorder) this.getBorder()).setEdges(0, 1, 0, 0); - } - - super.setComponentOrientation(o); - } - - // public void setFoldIcons(Icon collapsedIcon, Icon expandedIcon) { - // if(this.foldIndicator != null) { - // FoldIndicatorIcon collapsedFoldIndicatorIcon = new FoldIndicatorIcon(); - // this.foldIndicator.setFoldIcons(collapsedIcon, expandedIcon); - // } - // - // } - - public void setFoldIndicatorEnabled(boolean enabled) { - if (this.foldIndicator != null) { - if (enabled) { - if (this.foldIndicator.getParent() != this) { - this.add(this.foldIndicator, "After"); - } - } else { - if (this.foldIndicator.getParent() == this) { - this.remove(this.foldIndicator); - } - } - - this.revalidate(); - this.repaint(); - } - } - - public void setFoldBackground(Color bg) { - if (bg == null) { - bg = FoldIndicator.DEFAULT_FOLD_BACKGROUND; - } - - this.foldIndicator.setFoldIconBackground(bg); - } - - public void setArmedFoldBackground(Color bg) { - this.foldIndicator.setFoldIconArmedBackground(bg); - } - - public void setFoldIndicatorArmedForeground(Color fg) { - if (fg == null) { - fg = FoldIndicator.DEFAULT_FOREGROUND; - } - this.foldIndicator.setArmedForeground(fg); - } - - public void setFoldIndicatorStyle(FoldIndicatorStyle style) { - if (this.foldIndicator != null) { - this.foldIndicator.setStyle(style); - this.revalidate(); - this.repaint(); - } - } - - public void setExpandedFoldRenderStrategy(ExpandedFoldRenderStrategy strategy) { - this.foldIndicator.setExpandedFoldRenderStrategy(strategy); - } - - public void setShowArmedFoldRange(boolean show) { - this.foldIndicator.setShowArmedFoldRange(show); - } - - public int getSpacingBetweenLineNumbersAndFoldIndicator() { - return spacingBetweenLineNumbersAndFoldIndicator; - } - - public void setSpacingBetweenLineNumbersAndFoldIndicator(int spacing) { - spacing = Math.max(0, spacing); - if (spacing != this.spacingBetweenLineNumbersAndFoldIndicator) { - this.spacingBetweenLineNumbersAndFoldIndicator = spacing; - if (this.lineNumberList != null) { - this.lineNumberList.setBorder(new EmptyBorder(0, 0, 0, spacing)); - } - this.revalidate(); - this.repaint(); - } - } - - public void setFoldIndicatorForeground(Color fg) { - if (fg == null) { - fg = FoldIndicator.DEFAULT_FOREGROUND; - } - - this.foldIndicator.setForeground(fg); - } - - public int getIconRowHeaderCount() { - return this.iconAreas != null ? this.iconAreas.size() : -1; - } - - public void removeIconRowHeader(int headerIndex) { - setIconRowHeaderEnabled(headerIndex, false); - this.iconAreas.remove(headerIndex); - } - - public void addIconRowHeader() { - if (this.iconAreas == null) { - this.iconAreas = new ArrayList<>(); - } - RTextAreaEditorKit kit = (RTextAreaEditorKit) textArea.getUI().getEditorKit(textArea); - IconRowHeader header = kit.createIconRowHeader(textArea); - header.setInheritsGutterBackground(this.getIconRowHeaderInheritsGutterBackground()); - this.iconAreas.add(header); - header.addMouseListener(armedListener); - header.addMouseMotionListener(armedListener); - this.autoHideIconArea.add(false); - setIconRowHeaderEnabled(this.iconAreas.size() - 1, true); - } - - void addIconRowHeader(RTextArea textArea) { - if (this.iconAreas == null) { - this.iconAreas = new ArrayList<>(); - } - RTextAreaEditorKit kit = (RTextAreaEditorKit) textArea.getUI().getEditorKit(textArea); - IconRowHeader header = kit.createIconRowHeader(textArea); - header.setInheritsGutterBackground(this.getIconRowHeaderInheritsGutterBackground()); - this.iconAreas.add(header); - header.addMouseListener(armedListener); - header.addMouseMotionListener(armedListener); - this.autoHideIconArea.add(false); - setIconRowHeaderEnabled(this.iconAreas.size() - 1, true); - } - - public IconRowHeader getIconRowHeader(int headerIndex) { - return this.iconAreas.get(headerIndex); - } - - void setIconRowHeaderEnabled(int headerIndex, boolean enabled) { - if (this.iconAreas == null) { - return; - } - if (headerIndex > -1 && headerIndex < this.iconAreas.size() && this.iconAreas.get(headerIndex) != null) { - if (enabled) { - int position = (headerIndex < this.headerArea.getComponentCount()) - ? headerIndex - : this.headerArea.getComponentCount(); - // TODO Remove magic number; readd elements in order - this.headerArea.add(this.iconAreas.get(headerIndex), 0); - } else { - this.headerArea.remove(this.iconAreas.get(headerIndex)); - } - - this.revalidate(); - } - } - - public void setIconRowHeaderInheritsGutterBackground(boolean inherits) { - if (inherits != this.iconRowHeaderInheritsGutterBackground) { - this.iconRowHeaderInheritsGutterBackground = inherits; - for (IconRowHeader iconArea : this.iconAreas) { - if (iconArea != null) { - iconArea.setInheritsGutterBackground(inherits); - } - } - } - } - - public void setIconRowHeaderInheritsGutterBackground(int headerIndex, boolean inherits) { - if (inherits != this.iconRowHeaderInheritsGutterBackground) { - this.iconRowHeaderInheritsGutterBackground = inherits; - if (headerIndex > -1 && headerIndex < this.iconAreas.size()) { - if (iconAreas.get(headerIndex) != null) { - this.iconAreas.get(headerIndex).setInheritsGutterBackground(inherits); - } - } - } - } - - public void setLineNumberColor(Color color) { - if (color != null && !color.equals(this.lineNumberColor)) { - this.lineNumberColor = color; - if (this.lineNumberList != null) { - this.lineNumberList.setForeground(color); - } - } - } - - public void setLineNumberFont(Font font) { - if (font == null) { - throw new IllegalArgumentException("font cannot be null"); - } else { - if (!font.equals(this.lineNumberFont)) { - this.lineNumberFont = font; - if (this.lineNumberList != null) { - this.lineNumberList.setFont(font); - } - } - } - } - - public void setLineNumberingStartIndex(int index) { - if (index != this.lineNumberingStartIndex) { - this.lineNumberingStartIndex = index; - this.lineNumberList.setLineNumberingStartIndex(index); - } - } - - void setLineNumbersEnabled(boolean enabled) { - if (this.lineNumberList != null) { - if (enabled) { - this.add(this.lineNumberList); - } else { - this.remove(this.lineNumberList); - } - - this.revalidate(); - } - } - - public void setShowCollapsedRegionToolTips(boolean show) { - if (this.foldIndicator != null) { - this.foldIndicator.setShowCollapsedRegionToolTips(show); - } - } - - void setTextArea(RTextArea textArea) { - if (this.textArea != null) { - this.listener.uninstall(); - } - - if (textArea != null) { - RTextAreaEditorKit kit = (RTextAreaEditorKit) textArea.getUI().getEditorKit(textArea); - if (this.lineNumberList == null) { - this.lineNumberList = kit.createLineNumberList(textArea); - this.lineNumberList.setFont(this.getLineNumberFont()); - this.lineNumberList.setForeground(this.getLineNumberColor()); - this.lineNumberList.setLineNumberingStartIndex(this.getLineNumberingStartIndex()); - this.lineNumberList.setBorder(new EmptyBorder(0, 0, 0, this.spacingBetweenLineNumbersAndFoldIndicator)); - this.lineNumberList.addMouseListener(armedListener); - this.lineNumberList.addMouseMotionListener(armedListener); - } else { - this.lineNumberList.setTextArea(textArea); - } - if (this.iconAreas == null) { - this.iconAreas = new ArrayList<>(); - } - if (!this.iconAreas.isEmpty()) { - for (IconRowHeader iconArea : this.iconAreas) { - iconArea.setTextArea(textArea); - } - } - - if (this.foldIndicator == null) { - this.foldIndicator = new FoldIndicator(textArea); - this.foldIndicator.addMouseListener(armedListener); - this.foldIndicator.addMouseMotionListener(armedListener); - } else { - this.foldIndicator.setTextArea(textArea); - } - - this.listener.install(textArea); - } - - this.textArea = textArea; - } - - public boolean toggleBookmark(int headerIndex, int line) throws BadLocationException { - int bookmarkCount = this.getBookmarks(headerIndex).length; - boolean result = this.iconAreas.get(headerIndex).toggleBookmark(line); - if (this.autoHideIconArea.get(headerIndex)) { - if (this.getBookmarks(headerIndex).length == 0) { - this.setIconRowHeaderEnabled(headerIndex, false); - } else if (bookmarkCount == 0 && this.getBookmarks(headerIndex).length > 0) { - this.setIconRowHeaderEnabled(headerIndex, true); - } - } - return result; - } - - public void setAutohideIconRowHeader(int headerIndex, boolean autohide) { - if (headerIndex > -1 && headerIndex < this.autoHideIconArea.size()) { - this.autoHideIconArea.set(headerIndex, autohide); - } - if (this.iconAreas.get(headerIndex).getBookmarks().length == 0) { - setIconRowHeaderEnabled(headerIndex, false); - } - } - - public void setBorder(Border border) { - if (border instanceof MultiHeaderGutter.GutterBorder) { - super.setBorder(border); - } - } - - private static class GutterBorder extends EmptyBorder { - private Color color = new Color(221, 221, 221); - private Rectangle visibleRect = new Rectangle(); - - public GutterBorder(int top, int left, int bottom, int right) { - super(top, left, bottom, right); - } - - public Color getColor() { - return this.color; - } - - public void paintBorder(Component c, Graphics g, int x, int y, int width, int height) { - this.visibleRect = g.getClipBounds(this.visibleRect); - if (this.visibleRect == null) { - this.visibleRect = ((JComponent) c).getVisibleRect(); - } - - g.setColor(this.color); - if (this.left == 1) { - g.drawLine(0, this.visibleRect.y, 0, this.visibleRect.y + this.visibleRect.height); - } else { - g.drawLine(width - 1, this.visibleRect.y, width - 1, this.visibleRect.y + this.visibleRect.height); - } - } - - public void setColor(Color color) { - this.color = color; - } - - public void setEdges(int top, int left, int bottom, int right) { - this.top = top; - this.left = left; - this.bottom = bottom; - this.right = right; - } - } - - private class TextAreaListener extends ComponentAdapter - implements DocumentListener, PropertyChangeListener, ActiveLineRangeListener { - private boolean installed; - - private TextAreaListener() {} - - public void activeLineRangeChanged(ActiveLineRangeEvent e) { - if (e.getMin() == -1) { - MultiHeaderGutter.this.clearActiveLineRange(); - } else { - MultiHeaderGutter.this.setActiveLineRange(e.getMin(), e.getMax()); - } - } - - public void changedUpdate(DocumentEvent e) {} - - public void componentResized(ComponentEvent e) { - MultiHeaderGutter.this.revalidate(); - } - - protected void handleDocumentEvent(DocumentEvent e) { - for (int i = 0; i < MultiHeaderGutter.this.getComponentCount(); ++i) { - if (MultiHeaderGutter.this.getComponent(i) instanceof AbstractGutterComponent) { - AbstractGutterComponent agc = (AbstractGutterComponent) MultiHeaderGutter.this.getComponent(i); - agc.handleDocumentEvent(e); - } - } - for (int i = 0; i < MultiHeaderGutter.this.headerArea.getComponentCount(); ++i) { - if (MultiHeaderGutter.this.headerArea.getComponent(i) instanceof AbstractGutterComponent) { - AbstractGutterComponent agc = - (AbstractGutterComponent) MultiHeaderGutter.this.headerArea.getComponent(i); - agc.handleDocumentEvent(e); - } - } - } - - public void insertUpdate(DocumentEvent e) { - this.handleDocumentEvent(e); - } - - public void install(RTextArea textArea) { - if (this.installed) { - this.uninstall(); - } - - textArea.addComponentListener(this); - textArea.getDocument().addDocumentListener(this); - textArea.addPropertyChangeListener(this); - if (textArea instanceof RSyntaxTextArea) { - RSyntaxTextArea rsta = (RSyntaxTextArea) textArea; - rsta.addActiveLineRangeListener(this); - rsta.getFoldManager().addPropertyChangeListener(this); - } - - this.installed = true; - } - - public void propertyChange(PropertyChangeEvent e) { - String name = e.getPropertyName(); - if (!"font".equals(name) && !"RSTA.syntaxScheme".equals(name)) { - if ("RSTA.codeFolding".equals(name)) { - boolean var5 = ((Boolean) e.getNewValue()).booleanValue(); - if (MultiHeaderGutter.this.lineNumberList != null) { - MultiHeaderGutter.this.lineNumberList.updateCellWidths(); - } - - MultiHeaderGutter.this.setFoldIndicatorEnabled(var5); - } else if ("FoldsUpdated".equals(name)) { - MultiHeaderGutter.this.repaint(); - } else if ("document".equals(name)) { - RDocument var6 = (RDocument) e.getOldValue(); - if (var6 != null) { - var6.removeDocumentListener(this); - } - - RDocument var7 = (RDocument) e.getNewValue(); - if (var7 != null) { - var7.addDocumentListener(this); - } - } - } else { - for (int old = 0; old < MultiHeaderGutter.this.getComponentCount(); ++old) { - AbstractGutterComponent newDoc = (AbstractGutterComponent) MultiHeaderGutter.this.getComponent(old); - newDoc.lineHeightsChanged(); - } - } - } - - public void removeUpdate(DocumentEvent e) { - this.handleDocumentEvent(e); - } - - public void uninstall() { - if (this.installed) { - MultiHeaderGutter.this.textArea.removeComponentListener(this); - MultiHeaderGutter.this.textArea.getDocument().removeDocumentListener(this); - MultiHeaderGutter.this.textArea.removePropertyChangeListener(this); - if (MultiHeaderGutter.this.textArea instanceof RSyntaxTextArea) { - RSyntaxTextArea rsta = (RSyntaxTextArea) MultiHeaderGutter.this.textArea; - rsta.removeActiveLineRangeListener(this); - rsta.getFoldManager().removePropertyChangeListener(this); - } - - this.installed = false; - } - } - } -} From 24c00dc185017e6804f2f985cdf6310d7dbc05fd Mon Sep 17 00:00:00 2001 From: Nathaniel Nielsen Date: Mon, 27 Jul 2026 22:30:23 -0400 Subject: [PATCH 38/38] feature flag for file explorer --- .../java/com/basic4gl/desktop/MainWindow.java | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/basic4gl/desktop/MainWindow.java b/app/src/main/java/com/basic4gl/desktop/MainWindow.java index 34912881..80325ab3 100644 --- a/app/src/main/java/com/basic4gl/desktop/MainWindow.java +++ b/app/src/main/java/com/basic4gl/desktop/MainWindow.java @@ -85,6 +85,10 @@ public void caretUpdate(CaretEvent e) { } }; + // Feature flags + // TODO remove once the redesigned left sidebar/rail is ready for release. + private static final boolean LEFT_SIDEBAR_ENABLED = false; + // Window private final JFrame frame = new JFrame(BuildInfo.APPLICATION_NAME); private final JSplitPane mainPane; @@ -709,16 +713,18 @@ protected void installDefaults() { hideSplitPaneHandle(contentPane); contentPaneDividerSize = contentPane.getDividerSize(); - workspacePane.setLeftComponent(leftSidebarContent); - workspacePane.setRightComponent(contentPane); - workspacePane.setResizeWeight(0.18); - workspacePane.setDividerLocation(expandedLeftSidebarWidth); - hideSplitPaneHandle(workspacePane); - workspacePaneDividerSize = workspacePane.getDividerSize(); + if (LEFT_SIDEBAR_ENABLED) { + workspacePane.setLeftComponent(leftSidebarContent); + workspacePane.setRightComponent(contentPane); + workspacePane.setResizeWeight(0.18); + workspacePane.setDividerLocation(expandedLeftSidebarWidth); + hideSplitPaneHandle(workspacePane); + workspacePaneDividerSize = workspacePane.getDividerSize(); + } contentPane.setDividerLocation(Math.max(200, frame.getPreferredSize().width - expandedRightDocsWidth)); - topPaneHost.add(workspacePane, BorderLayout.CENTER); + topPaneHost.add(LEFT_SIDEBAR_ENABLED ? workspacePane : contentPane, BorderLayout.CENTER); mainPane.setTopComponent(topPaneHost); mainPane.setBottomComponent(bottomBarContainer); @@ -727,7 +733,9 @@ protected void installDefaults() { mainPaneDividerSize = mainPane.getDividerSize(); leftRailsHost.setLayout(new BoxLayout(leftRailsHost, BoxLayout.Y_AXIS)); - leftRailsHost.add(leftSidebarRail); + if (LEFT_SIDEBAR_ENABLED) { + leftRailsHost.add(leftSidebarRail); + } leftRailsHost.add(Box.createVerticalGlue()); leftRailsHost.add(bottomBarRail); @@ -2347,16 +2355,18 @@ private void configureLeftSidebar() { bottomBarRail.setFloatable(false); bottomBarRail.setRollover(true); - Arrays.stream(panels) - .filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) - .forEach(x -> { - leftSidebarContent.add(x.build(this.basicEditor), x.id()); - addLeftSidebarButton( - x.id(), - createImageIcon(x.getActiveIconPath(), x.getActiveIconTint()), - createImageIcon(x.getInactiveIconPath()), - x.getTitle()); - }); + if (LEFT_SIDEBAR_ENABLED) { + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) + .forEach(x -> { + leftSidebarContent.add(x.build(this.basicEditor), x.id()); + addLeftSidebarButton( + x.id(), + createImageIcon(x.getActiveIconPath(), x.getActiveIconTint()), + createImageIcon(x.getInactiveIconPath()), + x.getTitle()); + }); + } Arrays.stream(panels) .filter(x -> x.getLayoutConstraints() == EditorLayout.SOUTH) @@ -2371,13 +2381,15 @@ private void configureLeftSidebar() { bottomBarContainer.add(bottomBarContent, BorderLayout.CENTER); - // Select first panel if available - Arrays.stream(panels) - .filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) - .findFirst() - .ifPresent(x -> { - selectLeftSidebarSection(x.id(), true); - }); + if (LEFT_SIDEBAR_ENABLED) { + // Select first panel if available + Arrays.stream(panels) + .filter(x -> x.getLayoutConstraints() == EditorLayout.WEST) + .findFirst() + .ifPresent(x -> { + selectLeftSidebarSection(x.id(), true); + }); + } } private void configureRightSidebar() {