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 @@
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()"); + } +}