diff --git a/CHANGELOG.md b/CHANGELOG.md index e6171003..92544b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- Fixed F5 not refreshing the Markdown Preview from disk +- Added font zoom (Alt+Scroll) for Terminal and Markdown Preview; Ctrl+0 resets; right-click "Zoom" submenu - Added Find bar (Ctrl+F / F3 / Shift+F3) to Markdown Preview - Added automatic Markdown Preview tab when Claude writes a plan file (configurable in Settings → General) - Added `show_markdown` MCP tool: displays markdown text in a NetBeans Markdown Preview tab diff --git a/CLAUDE.md b/CLAUDE.md index 3e5913fa..a1983623 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,31 @@ When finishing a release by adding `# MAJOR.MINOR` to CHANGELOG.md, the commit m A NetBeans IDE plugin that embeds Claude Code CLI as a PTY-based terminal session connected to the IDE via three channels: PTY, MCP SSE, and an HTTP hook. +### Why JediTerm and not NetBeans' built-in terminal + +The terminal is JetBrains **JediTerm** (`jediterm-core`/`jediterm-ui`), not NetBeans' built-in +terminal (`org.netbeans.lib.terminalemulator.Term` / the "Open in Terminal" action). The built-in +terminal was evaluated and rejected for the following reasons: + +- **Broken TUI rendering (decisive).** `lib.terminalemulator` does not support the alternate + screen buffer (DECSET 1049) nor 256-color SGR codes (`ESC[38;5;Nm` / `ESC[48;5;Nm`). Claude Code + is a full-screen `xterm-256color` TUI that relies on both — it would render corrupted. +- **No screen-buffer introspection.** Only whole-row text (`Term.getRowText(int)`) is exposed; no + per-cell/attribute access. `ScreenContentDetector` polls the rendered screen to detect + interactive choice menus, which would be unreliable without cell/attribute data. +- **Context menu not extensible.** `Term`'s right-click menu is hardcoded in + `org.netbeans.modules.terminal.ioprovider.Terminal` with no public hook. JediTerm, by contrast, + exposes a `createPopupMenu` / `TerminalActionProvider` extension point (see `ZoomableJediTermWidget`). +- **No per-instance font/zoom API.** Term font size is a global setting only — incompatible with + the per-surface zoom feature. +- **Friend-only API.** `lib.terminalemulator` and `terminal` declare no public packages + (friend-package restricted), so they are not a supported third-party API and may break between + NetBeans releases. + +Only PTY hosting is equivalent (`StreamTerm.connect(streams)` vs. JediTerm's `TtyConnector`). +Replacing JediTerm would mean rewriting `process/`, `controller/`, and screen detection for a +net loss of capability — do not pursue it. + ### Process startup `ClaudeProcess.start(workingDir, profile, extraCliArgs, mode, resumeSessionId)`: diff --git a/pom.xml b/pom.xml index b34092da..aab214c0 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.23.12-SNAPSHOT + 0.23.17-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java index cc407472..f73b519a 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java @@ -4,6 +4,8 @@ import com.jediterm.terminal.ui.JediTermWidget; import io.github.nbclaudecodegui.controller.ClaudeSessionController; import io.github.nbclaudecodegui.ui.common.BasicTextContextMenu; +import io.github.nbclaudecodegui.ui.common.Zoomable; +import io.github.nbclaudecodegui.ui.common.ZoomSupport; import io.github.nbclaudecodegui.model.ChoiceMenuModel; import io.github.nbclaudecodegui.model.ClaudeSessionModel; import io.github.nbclaudecodegui.model.EditMode; @@ -19,8 +21,12 @@ import java.awt.FlowLayout; import java.awt.Color; import java.awt.Dimension; +import java.awt.KeyEventDispatcher; +import java.awt.KeyboardFocusManager; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; import java.io.File; import java.io.IOException; import java.nio.file.Path; @@ -103,7 +109,7 @@ openAtStartup = false ) public class ClaudeSessionTab extends TopComponent - implements ClaudeSessionModel.ClaudeSessionModelListener { + implements ClaudeSessionModel.ClaudeSessionModelListener, Zoomable { private static final Logger LOG = Logger.getLogger(ClaudeSessionTab.class.getName()); @@ -174,7 +180,21 @@ public class ClaudeSessionTab extends TopComponent // UI — session (created once a session starts, nulled on stop) // ------------------------------------------------------------------------- - private JediTermWidget terminalWidget; + private JediTermWidget terminalWidget; + private NetBeansSettingsProvider settingsProvider; + private int termZoomDelta = + org.openide.util.NbPreferences.forModule(ClaudeSessionTab.class) + .getInt("terminalZoomDelta", 0); + private final KeyEventDispatcher zoomKeyInterceptor = e -> { + if (e.getID() != KeyEvent.KEY_PRESSED || terminalWidget == null) return false; + if (!SwingUtilities.isDescendingFrom(e.getComponent(), terminalWidget)) return false; + if (e.getKeyCode() == KeyEvent.VK_0 + && (e.getModifiersEx() & InputEvent.CTRL_DOWN_MASK) != 0) { + SwingUtilities.invokeLater(this::resetZoom); + return true; + } + return false; + }; private JSplitPane splitPane; private JPanel errorPanel; JPanel southCard; @@ -438,6 +458,44 @@ protected void componentActivated() { requestFocusOnInput(); } + @Override + protected void componentShowing() { + super.componentShowing(); + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .addKeyEventDispatcher(zoomKeyInterceptor); + } + + @Override + protected void componentHidden() { + super.componentHidden(); + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .removeKeyEventDispatcher(zoomKeyInterceptor); + } + + // ------------------------------------------------------------------------- + // Zoomable + // ------------------------------------------------------------------------- + + @Override public void zoomIn() { applyTermZoom(termZoomDelta + 1); } + @Override public void zoomOut() { applyTermZoom(termZoomDelta - 1); } + @Override public void resetZoom() { applyTermZoom(0); } + @Override public int getZoomDelta() { return termZoomDelta; } + @Override public int getMinDelta() { return 6 - (int) ClaudeCodePreferences.getTerminalFontSize(); } + @Override public int getMaxDelta() { return 72 - (int) ClaudeCodePreferences.getTerminalFontSize(); } + + private void applyTermZoom(int d) { + int clamped = Math.max(getMinDelta(), Math.min(getMaxDelta(), d)); + termZoomDelta = clamped; + org.openide.util.NbPreferences.forModule(ClaudeSessionTab.class) + .putInt("terminalZoomDelta", clamped); + if (settingsProvider != null) { + settingsProvider.setZoomDelta(clamped); + } + if (terminalWidget instanceof ZoomableJediTermWidget zw) { + zw.refreshFont(); + } + } + @Override public boolean canClose() { return true; @@ -754,7 +812,9 @@ private void startSession(File dir, String profileName, String extraCliArgs, (cmd, err) -> showStartError(cmd, err, model.getWorkingDirectory(), capturedConfigDir)); updateDisplayName(dir); sessionTag = "[" + dir.getName() + "] "; - JediTermWidget widget = new JediTermWidget(new NetBeansSettingsProvider()); + settingsProvider = new NetBeansSettingsProvider(); + settingsProvider.setZoomDelta(termZoomDelta); + ZoomableJediTermWidget widget = new ZoomableJediTermWidget(settingsProvider, this); Color termBg = UIManager.getColor("EditorPane.background"); if (termBg == null) termBg = UIManager.getColor("Panel.background"); if (termBg != null) { @@ -842,6 +902,12 @@ public void componentResized(ComponentEvent e) { } }); + widget.getTerminalPanel().addMouseWheelListener( + ZoomSupport.createWheelListener(this)); + // The Zoom submenu is spliced into JediTerm's native context menu by + // ZoomableJediTermWidget (JediTerm ignores setComponentPopupMenu). + ZoomSupport.bindResetKey(this, this); + add(splitPane, BorderLayout.CENTER); revalidate(); repaint(); diff --git a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java index a7f2cbd6..ea4dbdd7 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java @@ -3,7 +3,12 @@ import io.github.nbclaudecodegui.settings.ClaudeCodePreferences; import io.github.nbclaudecodegui.ui.common.MarkdownRenderer; import io.github.nbclaudecodegui.ui.common.UiUtils; +import io.github.nbclaudecodegui.ui.common.Zoomable; +import io.github.nbclaudecodegui.ui.common.ZoomSupport; import io.github.nbclaudecodegui.ui.common.markdown.MarkdownFindBar; +import javax.swing.text.html.HTMLEditorKit; +import javax.swing.text.html.StyleSheet; +import org.openide.util.NbPreferences; import java.awt.BorderLayout; import java.awt.Toolkit; import java.awt.datatransfer.StringSelection; @@ -62,7 +67,7 @@ preferredID = "MarkdownPreviewTopComponent", persistenceType = TopComponent.PERSISTENCE_ALWAYS ) -public class MarkdownPreviewTab extends TopComponent { +public class MarkdownPreviewTab extends TopComponent implements Zoomable { /** * Open live-preview tabs keyed by absolute file path. @@ -113,6 +118,9 @@ public class MarkdownPreviewTab extends TopComponent { /** Scroll ratio saved by writeExternal; applied in componentOpened() after IDE restart. */ double savedScrollRatio = 0.0; + int mdZoomDelta = + NbPreferences.forModule(MarkdownPreviewTab.class).getInt("markdownZoomDelta", 0); + /** Creates a new empty tab instance; content and title are configured by factory methods. */ MarkdownPreviewTab() { } @@ -160,11 +168,14 @@ public static void openLive(String filePath, String content, FileObject fo) { tab.scrollPane = new JScrollPane(tab.pane); tab.add(tab.scrollPane, BorderLayout.CENTER); - // Attach hyperlink listener, context menu, and find bar + // Attach hyperlink listener, context menu, find bar, and zoom tab.attachHyperlinkListener(); tab.bindRefreshKey(tab.pane); tab.pane.setComponentPopupMenu(tab.buildContextMenu()); tab.initFindBar(tab.pane); + tab.pane.addMouseWheelListener(ZoomSupport.createWheelListener(tab, true)); + ZoomSupport.bindResetKey(tab.pane, tab); + tab.applyMdZoom(tab.mdZoomDelta); if (fo != null) { tab.fileListener = makeFileListener(tab, fo); @@ -507,6 +518,40 @@ private void attachHyperlinkListener() { }); } + // ------------------------------------------------------------------------- + // Zoomable + // ------------------------------------------------------------------------- + + @Override public void zoomIn() { applyMdZoom(mdZoomDelta + 1); } + @Override public void zoomOut() { applyMdZoom(mdZoomDelta - 1); } + @Override public void resetZoom() { applyMdZoom(0); } + @Override public int getZoomDelta() { return mdZoomDelta; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + + private void applyMdZoom(int d) { + int clamped = Math.max(getMinDelta(), Math.min(getMaxDelta(), d)); + mdZoomDelta = clamped; + NbPreferences.forModule(MarkdownPreviewTab.class).putInt("markdownZoomDelta", clamped); + updateZoomCss(); + } + + private void updateZoomCss() { + if (pane == null) return; + HTMLEditorKit kit = (HTMLEditorKit) pane.getEditorKit(); + StyleSheet ss = kit.getStyleSheet(); + int d = mdZoomDelta; + ss.addRule("body { font-size: " + (13 + d) + "pt; }"); + ss.addRule("pre { font-size: " + (12 + d) + "pt; }"); + ss.addRule("code { font-size: " + (12 + d) + "pt; }"); + ss.addRule("h1 { font-size: " + (16 + d) + "pt; }"); + ss.addRule("h2 { font-size: " + (14 + d) + "pt; }"); + ss.addRule("h3 { font-size: " + (13 + d) + "pt; }"); + ss.addRule(".info { font-size: " + (11 + d) + "pt; }"); + String text = pane.getText(); + pane.setText(text); + } + JPopupMenu buildContextMenu() { JPopupMenu menu = new JPopupMenu(); @@ -554,6 +599,8 @@ JPopupMenu buildContextMenu() { menu.add(forward); menu.add(refresh); menu.addSeparator(); + menu.add(ZoomSupport.buildZoomMenu(this)); + menu.addSeparator(); menu.add(find); menu.addSeparator(); menu.add(selectAll); @@ -593,8 +640,11 @@ public void popupMenuWillBecomeVisible(PopupMenuEvent e) { return menu; } - private void bindRefreshKey(JEditorPane p) { + void bindRefreshKey(JEditorPane p) { KeyStroke f5 = KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0); + // WHEN_FOCUSED overrides the NetBeans global F5 action while the pane is focused + p.getInputMap(JComponent.WHEN_FOCUSED).put(f5, "md-refresh"); + // WHEN_IN_FOCUSED_WINDOW still covers F5 from elsewhere in the tab (e.g. find bar) p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(f5, "md-refresh"); p.getActionMap().put("md-refresh", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { forceReload(); } @@ -653,6 +703,9 @@ protected void componentOpened() { bindRefreshKey(pane); pane.setComponentPopupMenu(buildContextMenu()); initFindBar(pane); + pane.addMouseWheelListener(ZoomSupport.createWheelListener(this, true)); + ZoomSupport.bindResetKey(pane, this); + applyMdZoom(mdZoomDelta); } filePath = path; diff --git a/src/main/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProvider.java b/src/main/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProvider.java index 32dd05f0..a4b87d0b 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProvider.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProvider.java @@ -23,9 +23,15 @@ */ final class NetBeansSettingsProvider extends DefaultSettingsProvider { + private int zoomDelta = 0; + + void setZoomDelta(int d) { + this.zoomDelta = d; + } + @Override public float getTerminalFontSize() { - return ClaudeCodePreferences.getTerminalFontSize(); + return ClaudeCodePreferences.getTerminalFontSize() + zoomDelta; } @Override diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ZoomableJediTermWidget.java b/src/main/java/io/github/nbclaudecodegui/ui/ZoomableJediTermWidget.java new file mode 100644 index 00000000..52e6cbb4 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/ZoomableJediTermWidget.java @@ -0,0 +1,91 @@ +package io.github.nbclaudecodegui.ui; + +import com.jediterm.terminal.model.StyleState; +import com.jediterm.terminal.model.TerminalTextBuffer; +import com.jediterm.terminal.ui.JediTermWidget; +import com.jediterm.terminal.ui.TerminalActionProvider; +import com.jediterm.terminal.ui.TerminalPanel; +import com.jediterm.terminal.ui.settings.SettingsProvider; +import io.github.nbclaudecodegui.ui.common.Zoomable; +import io.github.nbclaudecodegui.ui.common.ZoomSupport; +import javax.swing.JPopupMenu; + +/** + * A {@link JediTermWidget} that splices a "Zoom" submenu into the terminal's + * native right-click context menu. + * + *

JediTerm builds its context menu internally: {@code TerminalPanel} has its + * own mouse listener that calls the protected + * {@code TerminalPanel.createPopupMenu(TerminalActionProvider)} and fills it from + * the {@code TerminalActionProvider} chain. It never consults Swing's + * {@code JComponent.setComponentPopupMenu()}. Adding a Zoom item via the Swing + * popup mechanism therefore produces a separate single-item popup + * instead of extending the native Copy/Paste/Clear menu. + * + *

The only clean integration point is to subclass {@code TerminalPanel} and + * override {@code createPopupMenu}: build the native menu via {@code super}, then + * append the Zoom submenu. This widget overrides the protected + * {@link #createTerminalPanel} factory so JediTerm uses that customized panel, + * and exposes {@link #refreshFont()} so callers can re-apply the font after a + * zoom change without reflection. + */ +public class ZoomableJediTermWidget extends JediTermWidget { + + private final Zoomable zoomable; + private ZoomTerminalPanel zoomPanel; + + /** + * @param settingsProvider terminal settings (font, colors); also supplies the + * zoom-adjusted font size + * @param zoomable the surface whose font the context-menu Zoom submenu + * and {@link #refreshFont()} operate on + */ + public ZoomableJediTermWidget(SettingsProvider settingsProvider, Zoomable zoomable) { + super(settingsProvider); + this.zoomable = zoomable; + } + + /** + * Re-applies the (possibly zoom-adjusted) font to the terminal and resizes + * the grid. Delegates to {@code TerminalPanel.reinitFontAndResize()}, which + * is {@code protected} and reachable only from within a panel subclass. + */ + public void refreshFont() { + if (zoomPanel != null) { + zoomPanel.refreshFont(); + } + } + + @Override + protected TerminalPanel createTerminalPanel(SettingsProvider settingsProvider, + StyleState styleState, + TerminalTextBuffer textBuffer) { + // Invoked from the JediTermWidget super-constructor (before this subclass's + // constructor body runs). 'zoomable' is read only later, lazily, from + // createPopupMenu()/refreshFont() — never here — so its not-yet-assigned + // state at this point is harmless. + zoomPanel = new ZoomTerminalPanel(settingsProvider, textBuffer, styleState); + return zoomPanel; + } + + /** Terminal panel that appends the Zoom submenu to the native context menu. */ + private final class ZoomTerminalPanel extends TerminalPanel { + + ZoomTerminalPanel(SettingsProvider settingsProvider, + TerminalTextBuffer textBuffer, + StyleState styleState) { + super(settingsProvider, textBuffer, styleState); + } + + @Override + protected JPopupMenu createPopupMenu(TerminalActionProvider provider) { + JPopupMenu menu = super.createPopupMenu(provider); + ZoomSupport.appendZoomMenu(menu, zoomable); + return menu; + } + + void refreshFont() { + reinitFontAndResize(); + } + } +} diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java new file mode 100644 index 00000000..397cef9d --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java @@ -0,0 +1,143 @@ +package io.github.nbclaudecodegui.ui.common; + +import java.awt.Component; +import java.awt.Point; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import javax.swing.AbstractAction; +import javax.swing.JComponent; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import java.awt.event.KeyEvent; + +/** Shared zoom utilities for terminal and markdown preview surfaces. */ +public final class ZoomSupport { + + private ZoomSupport() {} + + /** + * Returns the modifier mask used for mouse-wheel zoom. + * NetBeans standard binding is Alt+Wheel; we use that unconditionally. + */ + public static int discoverZoomWheelModifiers() { + return InputEvent.ALT_DOWN_MASK; + } + + /** Creates a MouseWheelListener that calls zoomIn/zoomOut when Alt is held. */ + public static MouseWheelListener createWheelListener(Zoomable zoomable) { + return createWheelListener(zoomable, false); + } + + /** + * Creates a MouseWheelListener that calls zoomIn/zoomOut when Alt is held. + *

+ * When {@code forwardToScrollAncestor} is {@code true}, non-zoom wheel events + * (Alt not held) are re-dispatched to the nearest ancestor {@link JScrollPane}. + * This is required when the listener is attached to a component (e.g. a + * {@code JEditorPane} inside a {@code JScrollPane}) whose plain scrolling + * relied on Swing's automatic forwarding to the scroll pane — registering any + * wheel listener on that component disables the JDK's + * {@code dispatchMouseWheelToAncestor} fallback, so we replicate it here. + * Surfaces that scroll via their own wheel listener (e.g. JediTerm's + * terminal panel) pass {@code false} and keep their original behavior. + */ + public static MouseWheelListener createWheelListener(Zoomable zoomable, + boolean forwardToScrollAncestor) { + int mask = discoverZoomWheelModifiers(); + return (MouseWheelEvent e) -> { + if ((e.getModifiersEx() & mask) != 0) { + e.consume(); + if (e.getWheelRotation() < 0) { + zoomable.zoomIn(); + } else { + zoomable.zoomOut(); + } + } else if (forwardToScrollAncestor) { + forwardToScrollAncestor(e); + } + }; + } + + /** + * Re-dispatches a non-zoom wheel event to the nearest ancestor + * {@link JScrollPane}, mirroring the JDK's native + * {@code Component.dispatchMouseWheelToAncestor} so plain scrolling matches + * the pre-zoom default. + */ + private static void forwardToScrollAncestor(MouseWheelEvent e) { + Component src = e.getComponent(); + if (src == null) { + return; + } + JScrollPane sp = (JScrollPane) + SwingUtilities.getAncestorOfClass(JScrollPane.class, src); + if (sp == null) { + return; + } + Point p = SwingUtilities.convertPoint(src, e.getPoint(), sp); + sp.dispatchEvent(new MouseWheelEvent(sp, e.getID(), e.getWhen(), + e.getModifiersEx(), p.x, p.y, e.getXOnScreen(), e.getYOnScreen(), + e.getClickCount(), e.isPopupTrigger(), e.getScrollType(), + e.getScrollAmount(), e.getWheelRotation(), + e.getPreciseWheelRotation())); + } + + /** Builds a "Zoom" JMenu with Increase, Decrease, and Reset items. */ + public static JMenu buildZoomMenu(Zoomable zoomable) { + JMenu menu = new JMenu("Zoom"); + + JMenuItem increase = new JMenuItem("Increase (Alt+Scroll Up)"); + increase.addActionListener(e -> zoomable.zoomIn()); + + JMenuItem decrease = new JMenuItem("Decrease (Alt+Scroll Down)"); + decrease.addActionListener(e -> zoomable.zoomOut()); + + JMenuItem reset = new JMenuItem("Reset (Ctrl+0)"); + reset.addActionListener(e -> zoomable.resetZoom()); + + menu.add(increase); + menu.add(decrease); + menu.addSeparator(); + menu.add(reset); + return menu; + } + + /** + * Appends a separator (only when {@code menu} already has items) followed by + * the "Zoom" submenu produced by {@link #buildZoomMenu(Zoomable)} to the given + * popup. Used to splice the Zoom controls into JediTerm's native terminal + * context menu, which JediTerm builds internally and does not expose via + * Swing's {@code setComponentPopupMenu}. + * + * @param menu the popup to extend (typically JediTerm's native context menu); + * must not be {@code null} + * @param zoomable the surface whose font is zoomed by the submenu items; a + * {@code null} value makes this a no-op + */ + public static void appendZoomMenu(JPopupMenu menu, Zoomable zoomable) { + if (zoomable == null) { + return; + } + if (menu.getComponentCount() > 0) { + menu.addSeparator(); + } + menu.add(buildZoomMenu(zoomable)); + } + + /** Binds Ctrl+0 → resetZoom() on the given component via InputMap/ActionMap. */ + public static void bindResetKey(JComponent comp, Zoomable zoomable) { + KeyStroke ks = KeyStroke.getKeyStroke(KeyEvent.VK_0, InputEvent.CTRL_DOWN_MASK); + // WHEN_FOCUSED overrides the NetBeans global Ctrl+0 action while comp is focused + comp.getInputMap(JComponent.WHEN_FOCUSED).put(ks, "zoom-reset"); + comp.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, "zoom-reset"); + comp.getActionMap().put("zoom-reset", new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { zoomable.resetZoom(); } + }); + } +} diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/Zoomable.java b/src/main/java/io/github/nbclaudecodegui/ui/common/Zoomable.java new file mode 100644 index 00000000..31d07aa4 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/Zoomable.java @@ -0,0 +1,11 @@ +package io.github.nbclaudecodegui.ui.common; + +/** Implemented by UI surfaces that support font-size zoom. */ +public interface Zoomable { + void zoomIn(); + void zoomOut(); + void resetZoom(); + int getZoomDelta(); + int getMinDelta(); + int getMaxDelta(); +} diff --git a/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java new file mode 100644 index 00000000..c0eb3065 --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java @@ -0,0 +1,68 @@ +package io.github.nbclaudecodegui.ui; + +import io.github.nbclaudecodegui.ui.common.MarkdownRenderer; +import java.awt.event.KeyEvent; +import javax.swing.JComponent; +import javax.swing.JEditorPane; +import javax.swing.KeyStroke; +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MarkdownZoomTest { + + private MarkdownPreviewTab tab; + + @BeforeEach + void setup() { + MarkdownPreviewTab.clearOpenTabsForTest(); + tab = new MarkdownPreviewTab(); + tab.pane = MarkdownRenderer.createOutputPane("test", null); + tab.mdZoomDelta = 0; + } + + @Test + void zoomIn_incrementsDelta() { + tab.zoomIn(); + assertEquals(1, tab.getZoomDelta()); + } + + @Test + void zoomOut_decrementsDelta() { + tab.zoomOut(); + assertEquals(-1, tab.getZoomDelta()); + } + + @Test + void zoomIn_clampedAtMax() { + for (int i = 0; i < 30; i++) tab.zoomIn(); + assertEquals(tab.getMaxDelta(), tab.getZoomDelta()); + } + + @Test + void zoomOut_clampedAtMin() { + for (int i = 0; i < 30; i++) tab.zoomOut(); + assertEquals(tab.getMinDelta(), tab.getZoomDelta()); + } + + @Test + void resetZoom_setsZero() { + tab.zoomIn(); + tab.zoomIn(); + tab.resetZoom(); + assertEquals(0, tab.getZoomDelta()); + } + + @Test + void bindRefreshKey_registersF5InFocusedAndWindowMaps() { + tab.bindRefreshKey(tab.pane); + + KeyStroke f5 = KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0); + assertEquals("md-refresh", + tab.pane.getInputMap(JComponent.WHEN_FOCUSED).get(f5), + "F5 must be bound in WHEN_FOCUSED to beat the NetBeans global action"); + assertEquals("md-refresh", + tab.pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).get(f5)); + assertNotNull(tab.pane.getActionMap().get("md-refresh")); + } +} diff --git a/src/test/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProviderZoomTest.java b/src/test/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProviderZoomTest.java new file mode 100644 index 00000000..fe42e1fc --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProviderZoomTest.java @@ -0,0 +1,25 @@ +package io.github.nbclaudecodegui.ui; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class NetBeansSettingsProviderZoomTest { + + @Test + void setZoomDelta_affectsGetTerminalFontSize() { + NetBeansSettingsProvider p = new NetBeansSettingsProvider(); + float base = p.getTerminalFontSize(); + p.setZoomDelta(3); + assertEquals(base + 3, p.getTerminalFontSize(), 0.01f); + } + + @Test + void defaultDeltaIsZero() { + NetBeansSettingsProvider p = new NetBeansSettingsProvider(); + assertEquals(0, p.getTerminalFontSize() - p.getTerminalFontSize(), 0.01f); + // verify delta 0 does not shift size + float before = p.getTerminalFontSize(); + p.setZoomDelta(0); + assertEquals(before, p.getTerminalFontSize(), 0.01f); + } +} diff --git a/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java new file mode 100644 index 00000000..730cccf6 --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java @@ -0,0 +1,245 @@ +package io.github.nbclaudecodegui.ui.common; + +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import javax.swing.Action; +import javax.swing.JComponent; +import javax.swing.JEditorPane; +import javax.swing.JLabel; +import javax.swing.KeyStroke; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JSeparator; +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class ZoomSupportTest { + + @Test + void discoverZoomWheelModifiers_returnsAltMask() { + assertEquals(InputEvent.ALT_DOWN_MASK, ZoomSupport.discoverZoomWheelModifiers()); + } + + @Test + void createWheelListener_callsZoomInOnUpWithAltModifier() { + int[] calls = {0, 0}; + Zoomable z = new Zoomable() { + @Override public void zoomIn() { calls[0]++; } + @Override public void zoomOut() { calls[1]++; } + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + MouseWheelListener listener = ZoomSupport.createWheelListener(z); + JLabel src = new JLabel(); + MouseWheelEvent up = new MouseWheelEvent(src, MouseWheelEvent.MOUSE_WHEEL, + 0L, InputEvent.ALT_DOWN_MASK, 0, 0, 1, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -1); + listener.mouseWheelMoved(up); + assertEquals(1, calls[0]); + assertEquals(0, calls[1]); + } + + @Test + void createWheelListener_ignoresWheelWithoutModifier() { + int[] calls = {0, 0}; + Zoomable z = new Zoomable() { + @Override public void zoomIn() { calls[0]++; } + @Override public void zoomOut() { calls[1]++; } + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + MouseWheelListener listener = ZoomSupport.createWheelListener(z); + JLabel src = new JLabel(); + MouseWheelEvent noMod = new MouseWheelEvent(src, MouseWheelEvent.MOUSE_WHEEL, + 0L, 0, 0, 0, 1, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -1); + listener.mouseWheelMoved(noMod); + assertEquals(0, calls[0]); + assertEquals(0, calls[1]); + } + + @Test + void buildZoomMenu_menuHasExpectedItems() { + Zoomable z = new Zoomable() { + @Override public void zoomIn() {} + @Override public void zoomOut() {} + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + JMenu menu = ZoomSupport.buildZoomMenu(z); + assertEquals("Zoom", menu.getText()); + // 3 items + 1 separator = 4 components + assertEquals(4, menu.getMenuComponentCount()); + } + + private static Zoomable stubZoomable() { + return new Zoomable() { + @Override public void zoomIn() {} + @Override public void zoomOut() {} + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + } + + @Test + void appendZoomMenu_addsSeparatorAndZoomSubmenuAfterExistingItems() { + JPopupMenu menu = new JPopupMenu(); + JMenuItem first = new JMenuItem("Copy"); + JMenuItem second = new JMenuItem("Paste"); + menu.add(first); + menu.add(second); + + ZoomSupport.appendZoomMenu(menu, stubZoomable()); + + assertEquals(4, menu.getComponentCount()); + assertSame(first, menu.getComponent(0)); + assertSame(second, menu.getComponent(1)); + assertTrue(menu.getComponent(2) instanceof JSeparator); + assertTrue(menu.getComponent(3) instanceof JMenu); + JMenu zoom = (JMenu) menu.getComponent(3); + assertEquals("Zoom", zoom.getText()); + assertEquals(4, zoom.getMenuComponentCount()); + } + + @Test + void appendZoomMenu_noLeadingSeparatorOnEmptyMenu() { + JPopupMenu menu = new JPopupMenu(); + + ZoomSupport.appendZoomMenu(menu, stubZoomable()); + + assertEquals(1, menu.getComponentCount()); + assertTrue(menu.getComponent(0) instanceof JMenu); + assertEquals("Zoom", ((JMenu) menu.getComponent(0)).getText()); + } + + @Test + void appendZoomMenu_nullZoomableIsNoOp() { + JPopupMenu menu = new JPopupMenu(); + menu.add(new JMenuItem("Copy")); + + ZoomSupport.appendZoomMenu(menu, null); + + assertEquals(1, menu.getComponentCount()); + } + + // --- Scroll-forwarding overload (Markdown Preview regression) ------------- + + private static Zoomable countingZoomable(int[] calls) { + return new Zoomable() { + @Override public void zoomIn() { calls[0]++; } + @Override public void zoomOut() { calls[1]++; } + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + } + + /** pane inside a JScrollPane; returns the scroll pane with a recording wheel listener. */ + private static JScrollPane scrolledPane(JEditorPane pane, int[] forwarded) { + JScrollPane sp = new JScrollPane(pane); + sp.addMouseWheelListener((MouseWheelEvent e) -> forwarded[0]++); + return sp; + } + + private static MouseWheelEvent wheel(java.awt.Component src, int modifiers) { + return new MouseWheelEvent(src, MouseWheelEvent.MOUSE_WHEEL, + 0L, modifiers, 0, 0, 1, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -1); + } + + @Test + void createWheelListener_forwardTrue_redispatchesPlainWheelToScrollAncestor() { + int[] calls = {0, 0}; + int[] forwarded = {0}; + JEditorPane pane = new JEditorPane(); + scrolledPane(pane, forwarded); + + MouseWheelListener listener = ZoomSupport.createWheelListener(countingZoomable(calls), true); + MouseWheelEvent plain = wheel(pane, 0); + listener.mouseWheelMoved(plain); + + assertEquals(1, forwarded[0], "plain wheel must be forwarded to the JScrollPane"); + assertEquals(0, calls[0]); + assertEquals(0, calls[1]); + assertFalse(plain.isConsumed(), "forwarded event must not be consumed"); + } + + @Test + void createWheelListener_forwardFalse_doesNotForward_terminalBehaviorUnchanged() { + int[] calls = {0, 0}; + int[] forwarded = {0}; + JEditorPane pane = new JEditorPane(); + scrolledPane(pane, forwarded); + + // Explicit false overload + ZoomSupport.createWheelListener(countingZoomable(calls), false) + .mouseWheelMoved(wheel(pane, 0)); + // Legacy single-arg method must keep the same (non-forwarding) behavior + ZoomSupport.createWheelListener(countingZoomable(calls)) + .mouseWheelMoved(wheel(pane, 0)); + + assertEquals(0, forwarded[0], "no forwarding when forwardToScrollAncestor is false"); + assertEquals(0, calls[0]); + assertEquals(0, calls[1]); + } + + @Test + void createWheelListener_forwardTrue_altWheelStillZoomsAndIsNotForwarded() { + int[] calls = {0, 0}; + int[] forwarded = {0}; + JEditorPane pane = new JEditorPane(); + scrolledPane(pane, forwarded); + + MouseWheelListener listener = ZoomSupport.createWheelListener(countingZoomable(calls), true); + MouseWheelEvent alt = wheel(pane, InputEvent.ALT_DOWN_MASK); + listener.mouseWheelMoved(alt); + + assertEquals(1, calls[0], "Alt+wheel up must zoom in"); + assertEquals(0, calls[1]); + assertEquals(0, forwarded[0], "zoom event must not be forwarded to the scroll pane"); + assertTrue(alt.isConsumed(), "zoom event must be consumed"); + } + + // --- bindResetKey: Ctrl+0 must reach the focused surface (NetBeans steals + // WHEN_IN_FOCUSED_WINDOW), so it must also be in WHEN_FOCUSED ----------- + + @Test + void bindResetKey_registersCtrl0InFocusedAndWindowMaps() { + int[] resets = {0}; + Zoomable z = new Zoomable() { + @Override public void zoomIn() {} + @Override public void zoomOut() {} + @Override public void resetZoom() { resets[0]++; } + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + JLabel comp = new JLabel(); + + ZoomSupport.bindResetKey(comp, z); + + KeyStroke ctrl0 = KeyStroke.getKeyStroke(KeyEvent.VK_0, InputEvent.CTRL_DOWN_MASK); + assertEquals("zoom-reset", + comp.getInputMap(JComponent.WHEN_FOCUSED).get(ctrl0), + "Ctrl+0 must be bound in WHEN_FOCUSED to beat the NetBeans global action"); + assertEquals("zoom-reset", + comp.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).get(ctrl0)); + Action action = comp.getActionMap().get("zoom-reset"); + assertNotNull(action); + action.actionPerformed(null); + assertEquals(1, resets[0], "the bound action must invoke resetZoom()"); + } +}