From d597feb57c753941c5e005473ab9e7482f214fc6 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 27 Apr 2026 20:08:30 +0300 Subject: [PATCH] Improved keyboard navigation in choice menus: arrow keys, Tab, Shift+Tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Left/Right cycles Yes → No → Cancel (skips disabled buttons) - Up/Down on Yes/No navigates to last/first radio or checkbox option - Left/Right on radio/checkbox: Right goes to text field (if any) else Send; Left goes to Send; skips disabled targets - Up/Down on radio/checkbox: cycles among options; from top goes to Yes, from bottom goes to Send - Checkboxes now have Up/Down navigation (same logic as radios) - Send ↔ Cancel: Up/Down between them; Left/Right on Send goes to first option - Tab order: Yes → No → options in display order (toggle + text field) → Send → Cancel - All navigation skips disabled controls Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../nbclaudecodegui/ui/ChoiceMenuPanel.java | 309 +++++++++++++- .../ui/ChoiceMenuPanelTest.java | 379 ++++++++++++++++++ 4 files changed, 671 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a88deee8..d87fbb57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Improved keyboard navigation in choice menus: arrow keys, Tab, Shift+Tab - Added terminal font and font size settings (Tools → Options → Claude Code → General); defaults to auto-detection preferring Adwaita Mono for full Unicode coverage of Claude Code TUI symbols (https://github.com/nbclaudecodegui/NetbeansClaudeCodeGui/issues/18) - Reorganized General and Advanced settings tabs: moved technical settings (hang timeout, MCP port, Enable MCP integration, session list limit, debug mode) to Advanced - Added a setting to control whether the choice menu grabs keyboard focus (https://github.com/nbclaudecodegui/NetbeansClaudeCodeGui/issues/20) diff --git a/pom.xml b/pom.xml index 07490203..a8aebd31 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.22.27-SNAPSHOT + 0.22.31-SNAPSHOT nbm Netbeans 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/ChoiceMenuPanel.java b/src/main/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanel.java index 8f2cae6d..d103965e 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanel.java @@ -422,11 +422,14 @@ public void keyPressed(java.awt.event.KeyEvent e) { } } - // Arrow key navigation between radio buttons (needed because some radios are - // wrapped in a sub-JPanel, breaking BasicRadioButtonUI's sibling traversal). - // Navigation is cyclic: wraps around at top/bottom. - // Invisible radio buttons (type-input options) are skipped; their text field gets focus instead. + // Arrow key navigation for radio buttons. + // UP/DOWN: cyclic among enabled, visible, non-null radios. + // LEFT: focus Send if enabled, else Cancel if enabled. + // RIGHT: focus adjacent text field if present and enabled; otherwise Send/Cancel. + final JButton finalSendForNav = sendBtn; + final JButton finalCancelForNav = cancelBtn; final JTextField[] finalTypeFieldsNav = typeFields; + final java.util.List finalYesNoBtnsForRb = yesNoBtns; for (int i = 0; i < radioButtons.length; i++) { if (radioButtons[i] == null) continue; final int idx = i; @@ -434,16 +437,22 @@ public void keyPressed(java.awt.event.KeyEvent e) { @Override public void actionPerformed(java.awt.event.ActionEvent e) { int n = radioButtons.length; int target = (idx - 1 + n) % n; - while (target != idx && (radioButtons[target] == null || !radioButtons[target].isVisible())) { + while (target != idx && (radioButtons[target] == null + || !radioButtons[target].isVisible() || !radioButtons[target].isEnabled())) { target = (target - 1 + n) % n; } - if (target != idx && radioButtons[target] != null) { + if (target != idx && radioButtons[target] != null && radioButtons[target].isEnabled()) { radioButtons[target].setSelected(true); - if (finalTypeFieldsNav[target] != null) { + if (finalTypeFieldsNav[target] != null && finalTypeFieldsNav[target].isEnabled()) { finalTypeFieldsNav[target].requestFocusInWindow(); } else { radioButtons[target].requestFocusInWindow(); } + } else { + // No other radio to go to — fall back to first enabled Yes/No button (Yes) + for (int j = 0; j < finalYesNoBtnsForRb.size(); j++) { + if (finalYesNoBtnsForRb.get(j).isEnabled()) { finalYesNoBtnsForRb.get(j).requestFocusInWindow(); return; } + } } } }; @@ -451,41 +460,296 @@ public void keyPressed(java.awt.event.KeyEvent e) { @Override public void actionPerformed(java.awt.event.ActionEvent e) { int n = radioButtons.length; int target = (idx + 1) % n; - while (target != idx && (radioButtons[target] == null || !radioButtons[target].isVisible())) { + while (target != idx && (radioButtons[target] == null + || !radioButtons[target].isVisible() || !radioButtons[target].isEnabled())) { target = (target + 1) % n; } - if (target != idx && radioButtons[target] != null) { + if (target != idx && radioButtons[target] != null && radioButtons[target].isEnabled()) { radioButtons[target].setSelected(true); - if (finalTypeFieldsNav[target] != null) { + if (finalTypeFieldsNav[target] != null && finalTypeFieldsNav[target].isEnabled()) { finalTypeFieldsNav[target].requestFocusInWindow(); } else { radioButtons[target].requestFocusInWindow(); } + } else { + // No other radio to go to — fall back to first enabled Send/Cancel + focusFirstEnabled(finalSendForNav, finalCancelForNav); } } }; radioButtons[i].getInputMap(JComponent.WHEN_FOCUSED) .put(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), "prev"); - radioButtons[i].getInputMap(JComponent.WHEN_FOCUSED) - .put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "prev"); radioButtons[i].getInputMap(JComponent.WHEN_FOCUSED) .put(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "next"); - radioButtons[i].getInputMap(JComponent.WHEN_FOCUSED) - .put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "next"); radioButtons[i].getActionMap().put("prev", prevAction); radioButtons[i].getActionMap().put("next", nextAction); + if (finalSendForNav != null || finalCancelForNav != null) { + radioButtons[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "rbLeft"); + radioButtons[i].getActionMap().put("rbLeft", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + focusFirstEnabled(finalSendForNav, finalCancelForNav); + } + }); + } + if (typeFields[i] != null) { + final JTextField tfForRight = typeFields[i]; + radioButtons[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "rbRight"); + radioButtons[i].getActionMap().put("rbRight", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + if (tfForRight.isEnabled()) { + tfForRight.requestFocusInWindow(); + } else { + focusFirstEnabled(finalSendForNav, finalCancelForNav); + } + } + }); + } else if (finalSendForNav != null || finalCancelForNav != null) { + radioButtons[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "rbRight"); + radioButtons[i].getActionMap().put("rbRight", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + focusFirstEnabled(finalSendForNav, finalCancelForNav); + } + }); + } } - // Bug 3: explicit Tab order: Yes/No → checkboxes → radios (+ type-fields) → Submit/Send → Cancel + // Left/Right cycle: Yes / No / Cancel (all in one ring) — skips disabled buttons. + // Up/Down from Yes/No buttons only (not Cancel) → last/first enabled radio or checkbox. + final java.util.List allLRBtns = new java.util.ArrayList<>(yesNoBtns); + allLRBtns.add(cancelBtn); + if (allLRBtns.size() > 1) { + final int lrCount = allLRBtns.size(); + final JRadioButton[] finalRadiosYN = radioButtons; + final JCheckBox[] finalCheckBoxesYN = checkBoxes; + for (int i = 0; i < lrCount; i++) { + JButton btn = allLRBtns.get(i); + final int lrIdx = i; + btn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "lrPrev"); + btn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "lrNext"); + btn.getActionMap().put("lrPrev", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + int target = (lrIdx - 1 + lrCount) % lrCount; + while (target != lrIdx && !allLRBtns.get(target).isEnabled()) { + target = (target - 1 + lrCount) % lrCount; + } + if (allLRBtns.get(target).isEnabled()) allLRBtns.get(target).requestFocusInWindow(); + } + }); + btn.getActionMap().put("lrNext", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + int target = (lrIdx + 1) % lrCount; + while (target != lrIdx && !allLRBtns.get(target).isEnabled()) { + target = (target + 1) % lrCount; + } + if (allLRBtns.get(target).isEnabled()) allLRBtns.get(target).requestFocusInWindow(); + } + }); + } + for (JButton btn : yesNoBtns) { + btn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), "ynUp"); + btn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "ynDown"); + btn.getActionMap().put("ynUp", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + for (int j = finalRadiosYN.length - 1; j >= 0; j--) { + if (finalRadiosYN[j] != null && finalRadiosYN[j].isEnabled()) { finalRadiosYN[j].requestFocusInWindow(); return; } + if (finalCheckBoxesYN[j] != null && finalCheckBoxesYN[j].isEnabled()) { finalCheckBoxesYN[j].requestFocusInWindow(); return; } + } + } + }); + btn.getActionMap().put("ynDown", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + for (int j = 0; j < finalRadiosYN.length; j++) { + if (finalRadiosYN[j] != null && finalRadiosYN[j].isEnabled()) { finalRadiosYN[j].requestFocusInWindow(); return; } + if (finalCheckBoxesYN[j] != null && finalCheckBoxesYN[j].isEnabled()) { finalCheckBoxesYN[j].requestFocusInWindow(); return; } + } + } + }); + } + } + + // Checkbox UP/DOWN/LEFT/RIGHT navigation. + // UP: previous enabled radio/checkbox; if none, last enabled Yes/No button. + // DOWN: next enabled radio/checkbox; if none, first enabled Send/Cancel. + // LEFT: focus Send/Cancel (first enabled). + // RIGHT: adjacent text field if enabled; else Send/Cancel. + final JButton finalSendForCb = sendBtn; + final JButton finalCancelForCb = cancelBtn; + final java.util.List finalYesNoBtnsForCb = yesNoBtns; + final JRadioButton[] finalRadiosForCb = radioButtons; + final JCheckBox[] finalCbsForCb = checkBoxes; + for (int i = 0; i < checkBoxes.length; i++) { + if (checkBoxes[i] == null) continue; + final int cbIdx = i; + // UP + checkBoxes[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), "cbUp"); + checkBoxes[i].getActionMap().put("cbUp", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + for (int j = cbIdx - 1; j >= 0; j--) { + if (finalRadiosForCb[j] != null && finalRadiosForCb[j].isEnabled()) { finalRadiosForCb[j].requestFocusInWindow(); return; } + if (finalCbsForCb[j] != null && finalCbsForCb[j].isEnabled()) { finalCbsForCb[j].requestFocusInWindow(); return; } + } + for (int j = 0; j < finalYesNoBtnsForCb.size(); j++) { + if (finalYesNoBtnsForCb.get(j).isEnabled()) { finalYesNoBtnsForCb.get(j).requestFocusInWindow(); return; } + } + } + }); + // DOWN + checkBoxes[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "cbDown"); + checkBoxes[i].getActionMap().put("cbDown", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + for (int j = cbIdx + 1; j < finalCbsForCb.length; j++) { + if (finalRadiosForCb[j] != null && finalRadiosForCb[j].isEnabled()) { finalRadiosForCb[j].requestFocusInWindow(); return; } + if (finalCbsForCb[j] != null && finalCbsForCb[j].isEnabled()) { finalCbsForCb[j].requestFocusInWindow(); return; } + } + focusFirstEnabled(finalSendForCb, finalCancelForCb); + } + }); + // LEFT + if (finalSendForCb != null || finalCancelForCb != null) { + checkBoxes[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "cbLeft"); + checkBoxes[i].getActionMap().put("cbLeft", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + focusFirstEnabled(finalSendForCb, finalCancelForCb); + } + }); + } + // RIGHT + if (typeFields[i] != null) { + final JTextField tfForRight = typeFields[i]; + checkBoxes[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "cbRight"); + checkBoxes[i].getActionMap().put("cbRight", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + if (tfForRight.isEnabled()) { + tfForRight.requestFocusInWindow(); + } else { + focusFirstEnabled(finalSendForCb, finalCancelForCb); + } + } + }); + } else if (finalSendForCb != null || finalCancelForCb != null) { + checkBoxes[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "cbRight"); + checkBoxes[i].getActionMap().put("cbRight", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + focusFirstEnabled(finalSendForCb, finalCancelForCb); + } + }); + } + } + + // Send ↔ Cancel Up/Down (skip if target disabled); Send Left/Right → first enabled radio/checkbox. + if (sendBtn != null) { + final JButton finalCancelForSend = cancelBtn; + sendBtn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), "sendUp"); + sendBtn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "sendDown"); + sendBtn.getActionMap().put("sendUp", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + if (finalCancelForSend.isEnabled()) finalCancelForSend.requestFocusInWindow(); + } + }); + sendBtn.getActionMap().put("sendDown", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + if (finalCancelForSend.isEnabled()) finalCancelForSend.requestFocusInWindow(); + } + }); + + final JButton finalSendForCancel = sendBtn; + cancelBtn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), "cancelUp"); + cancelBtn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "cancelDown"); + cancelBtn.getActionMap().put("cancelUp", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + if (finalSendForCancel.isEnabled()) finalSendForCancel.requestFocusInWindow(); + } + }); + cancelBtn.getActionMap().put("cancelDown", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + if (finalSendForCancel.isEnabled()) finalSendForCancel.requestFocusInWindow(); + } + }); + + boolean hasToggle = false; + for (int j = 0; j < radioButtons.length; j++) { + if (radioButtons[j] != null || checkBoxes[j] != null) { hasToggle = true; break; } + } + if (hasToggle) { + final JRadioButton[] finalRadiosSend = radioButtons; + final JCheckBox[] finalCbSend = checkBoxes; + AbstractAction sendToFirst = new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + for (int j = 0; j < finalRadiosSend.length; j++) { + if (finalRadiosSend[j] != null && finalRadiosSend[j].isEnabled()) { finalRadiosSend[j].requestFocusInWindow(); return; } + if (finalCbSend[j] != null && finalCbSend[j].isEnabled()) { finalCbSend[j].requestFocusInWindow(); return; } + } + } + }; + sendBtn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "sendLR"); + sendBtn.getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "sendLR"); + sendBtn.getActionMap().put("sendLR", sendToFirst); + } + } + + // Text field UP/DOWN → navigate to the adjacent enabled option's toggle (radio/checkbox) + final java.util.List finalYesNoBtns = yesNoBtns; + final JRadioButton[] finalRadiosTF = radioButtons; + final JCheckBox[] finalCheckBoxesTF = checkBoxes; + for (int i = 0; i < typeFields.length; i++) { + if (typeFields[i] == null) continue; + final int tfIdx = i; + typeFields[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), "tfUp"); + typeFields[i].getInputMap(JComponent.WHEN_FOCUSED) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "tfDown"); + typeFields[i].getActionMap().put("tfUp", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + for (int j = tfIdx - 1; j >= 0; j--) { + if (finalRadiosTF[j] != null && finalRadiosTF[j].isEnabled()) { finalRadiosTF[j].requestFocusInWindow(); return; } + if (finalCheckBoxesTF[j] != null && finalCheckBoxesTF[j].isEnabled()) { finalCheckBoxesTF[j].requestFocusInWindow(); return; } + } + for (int j = 0; j < finalYesNoBtns.size(); j++) { + if (finalYesNoBtns.get(j).isEnabled()) { finalYesNoBtns.get(j).requestFocusInWindow(); return; } + } + } + }); + typeFields[i].getActionMap().put("tfDown", new AbstractAction() { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { + for (int j = tfIdx + 1; j < finalRadiosTF.length; j++) { + if (finalRadiosTF[j] != null && finalRadiosTF[j].isEnabled()) { finalRadiosTF[j].requestFocusInWindow(); return; } + if (finalCheckBoxesTF[j] != null && finalCheckBoxesTF[j].isEnabled()) { finalCheckBoxesTF[j].requestFocusInWindow(); return; } + } + for (JButton yb : finalYesNoBtns) { + if (yb.isEnabled()) { yb.requestFocusInWindow(); return; } + } + } + }); + } + + // Tab order: Yes/No → options in otherOptions index order (toggle + text field) → Send → Cancel java.util.List tabOrder = new java.util.ArrayList<>(); tabOrder.addAll(yesNoBtns); - for (JCheckBox cb : checkBoxes) { - if (cb != null) tabOrder.add(cb); - } - for (int i = 0; i < radioButtons.length; i++) { + for (int i = 0; i < otherOptions.size(); i++) { if (radioButtons[i] != null) { tabOrder.add(radioButtons[i]); if (typeFields[i] != null) tabOrder.add(typeFields[i]); + } else if (checkBoxes[i] != null) { + tabOrder.add(checkBoxes[i]); + if (typeFields[i] != null) tabOrder.add(typeFields[i]); } } if (freeField != null) tabOrder.add(freeField); @@ -604,6 +868,13 @@ private static void setPlaceholder(JTextField tf, String hint) { }); } + /** Focuses the first of the given buttons that is non-null and enabled; does nothing if none qualifies. */ + private static void focusFirstEnabled(JButton... buttons) { + for (JButton b : buttons) { + if (b != null && b.isEnabled()) { b.requestFocusInWindow(); return; } + } + } + private static String escapeHtml(String s) { return s.replace("&", "&").replace("<", "<").replace(">", ">"); } diff --git a/src/test/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanelTest.java b/src/test/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanelTest.java index e6fc022e..0e5868a3 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanelTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanelTest.java @@ -6,6 +6,7 @@ import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; +import java.awt.event.KeyEvent; import java.util.ArrayList; import java.util.List; import javax.swing.JButton; @@ -15,6 +16,7 @@ import javax.swing.JRadioButton; import javax.swing.JSplitPane; import javax.swing.JTextField; +import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; @@ -688,6 +690,350 @@ void splitPaneRemainsEnabledWhenChoiceMenuShown() throws Exception { }); } + // ------------------------------------------------------------------------- + // Arrow-key navigation tests + // ------------------------------------------------------------------------- + + @Test + void testYesNoButtonsHaveLeftRightBindings() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Q?", + List.of(new Option("Yes", "1"), new Option("No", "2")), -1), a -> {}, false); + + List ynBtns = collectYesNoButtons(panel); + assertEquals(2, ynBtns.size(), "must find two Yes/No buttons"); + for (JButton btn : ynBtns) { + assertNotNull(btn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0)), btn.getText() + " must have LEFT binding"); + assertNotNull(btn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0)), btn.getText() + " must have RIGHT binding"); + } + }); + } + + @Test + void testYesNoButtonsHaveUpDownBindings() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Q?", + List.of(new Option("Yes", "1"), new Option("No", "2"), + new Option("Maybe", "3")), -1), a -> {}, false); + + List ynBtns = collectYesNoButtons(panel); + assertEquals(2, ynBtns.size()); + for (JButton btn : ynBtns) { + assertNotNull(btn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0)), btn.getText() + " must have UP binding"); + assertNotNull(btn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0)), btn.getText() + " must have DOWN binding"); + } + }); + } + + @Test + void testYesNoDownActionNavigatesToFirstRadio() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + JFrame frame = new JFrame(); + frame.add(panel); + frame.setSize(400, 300); + frame.setVisible(true); + try { + panel.show(new ChoiceMenuModel("Q?", + List.of(new Option("Yes", "1"), new Option("No", "2"), + new Option("Option A", "3"), new Option("Option B", "4")), -1), + a -> {}, false); + + List ynBtns = collectYesNoButtons(panel); + assertFalse(ynBtns.isEmpty()); + JButton yesBtn = ynBtns.get(0); + + // Fire the ynDown action directly + javax.swing.Action down = yesBtn.getActionMap().get("ynDown"); + assertNotNull(down, "ynDown action must be registered"); + down.actionPerformed(new java.awt.event.ActionEvent(yesBtn, 0, "ynDown")); + + JRadioButton firstRadio = findRadioButton(panel, "Option A"); + assertNotNull(firstRadio); + // Verify action was invoked without exception (focus may not transfer in headless) + } finally { + frame.dispose(); + } + }); + } + + @Test + void testYesNoUpActionNavigatesToLastRadio() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + JFrame frame = new JFrame(); + frame.add(panel); + frame.setSize(400, 300); + frame.setVisible(true); + try { + panel.show(new ChoiceMenuModel("Q?", + List.of(new Option("Yes", "1"), new Option("No", "2"), + new Option("Option A", "3"), new Option("Option B", "4")), -1), + a -> {}, false); + + List ynBtns = collectYesNoButtons(panel); + JButton yesBtn = ynBtns.get(0); + javax.swing.Action up = yesBtn.getActionMap().get("ynUp"); + assertNotNull(up, "ynUp action must be registered"); + up.actionPerformed(new java.awt.event.ActionEvent(yesBtn, 0, "ynUp")); + // action fires without exception — focus may not transfer in headless env + } finally { + frame.dispose(); + } + }); + } + + @Test + void testYesNoUpNavigatesToLastCheckboxWhenNoRadios() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + JFrame frame = new JFrame(); + frame.add(panel); + frame.setSize(400, 300); + frame.setVisible(true); + try { + panel.show(new ChoiceMenuModel("Q?", + List.of(new Option("Yes", "1"), new Option("No", "2"), + new Option("Release A", "3", null, false, true), + new Option("Release B", "4", null, false, true)), -1), + a -> {}, false); + + List ynBtns = collectYesNoButtons(panel); + assertFalse(ynBtns.isEmpty(), "must have Yes/No buttons"); + JButton yesBtn = ynBtns.get(0); + javax.swing.Action up = yesBtn.getActionMap().get("ynUp"); + assertNotNull(up, "ynUp must be registered even when options are checkboxes"); + up.actionPerformed(new java.awt.event.ActionEvent(yesBtn, 0, "ynUp")); + // fires without exception + } finally { + frame.dispose(); + } + }); + } + + @Test + void testTypeInputRadioHasRightBinding() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Option A", "1"), + new Option("Type something.", "2")), 0), a -> {}, false); + + JRadioButton typeRb = findRadioButtonByName(panel, "typeInputRb"); + assertNotNull(typeRb, "typeInputRb must exist"); + assertNotNull(typeRb.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0)), + "type-input radio must have RIGHT binding to focus its text field"); + }); + } + + @Test + void testPlainRadioHasRightBindingToSend() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Option A", "1"), + new Option("Option B", "2")), 0), a -> {}, false); + + JRadioButton rb = findRadioButton(panel, "Option A"); + assertNotNull(rb); + assertNotNull(rb.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0)), + "plain radio (no text field) must have RIGHT binding → Send"); + }); + } + + @Test + void testPlainRadioHasLeftBinding() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Option A", "1"), + new Option("Option B", "2")), 0), a -> {}, false); + + JRadioButton rb = findRadioButton(panel, "Option A"); + assertNotNull(rb); + assertNotNull(rb.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0)), + "plain radio must have LEFT binding → Send"); + }); + } + + @Test + void testCancelIncludedInLeftRightCycle() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Q?", + List.of(new Option("Yes", "1"), new Option("No", "2")), -1), a -> {}, false); + + JButton cancelBtn = findButton(panel, "Cancel"); + assertNotNull(cancelBtn, "Cancel must exist"); + assertNotNull(cancelBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0)), + "Cancel must have LEFT binding in the Yes/No/Cancel cycle"); + assertNotNull(cancelBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0)), + "Cancel must have RIGHT binding in the Yes/No/Cancel cycle"); + }); + } + + @Test + void testPlainCheckboxHasUpDownBindings() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Release A", "1", null, false, true), + new Option("Release B", "2", null, false, true)), 0), + a -> {}, false); + + JCheckBox cb = findCheckBox(panel, "Release A"); + assertNotNull(cb, "checkbox must exist"); + assertNotNull(cb.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0)), + "checkbox must have UP binding"); + assertNotNull(cb.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0)), + "checkbox must have DOWN binding"); + }); + } + + @Test + void testPlainCheckboxHasLeftAndRightBindings() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Release A", "1", null, false, true), + new Option("Release B", "2", null, false, true)), 0), + a -> {}, false); + + JCheckBox cb = findCheckBox(panel, "Release A"); + assertNotNull(cb, "checkbox must exist"); + assertNotNull(cb.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0)), + "plain checkbox must have LEFT binding → Submit"); + assertNotNull(cb.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0)), + "plain checkbox must have RIGHT binding → Submit"); + }); + } + + @Test + void testSendHasUpDownBindings() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Option A", "1")), 0), a -> {}, false); + + JButton sendBtn = findButton(panel, "Send"); + assertNotNull(sendBtn, "Send must exist"); + assertNotNull(sendBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0)), + "Send must have UP binding → Cancel"); + assertNotNull(sendBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0)), + "Send must have DOWN binding → Cancel"); + }); + } + + @Test + void testCancelHasUpDownBindingsWhenSendPresent() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Option A", "1")), 0), a -> {}, false); + + JButton cancelBtn = findButton(panel, "Cancel"); + assertNotNull(cancelBtn, "Cancel must exist"); + assertNotNull(cancelBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0)), + "Cancel must have UP binding → Send"); + assertNotNull(cancelBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0)), + "Cancel must have DOWN binding → Send"); + }); + } + + @Test + void testCancelHasNoUpDownBindingsWhenNoSend() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Q?", + List.of(new Option("Yes", "1"), new Option("No", "2")), -1), a -> {}, false); + + JButton cancelBtn = findButton(panel, "Cancel"); + assertNotNull(cancelBtn, "Cancel must exist"); + assertNull(cancelBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0)), + "Cancel must NOT have UP binding when there is no Send button"); + assertNull(cancelBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0)), + "Cancel must NOT have DOWN binding when there is no Send button"); + }); + } + + @Test + void testSendHasLeftRightBindingsWhenRadiosExist() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Option A", "1"), new Option("Option B", "2")), 0), + a -> {}, false); + + JButton sendBtn = findButton(panel, "Send"); + assertNotNull(sendBtn, "Send must exist"); + assertNotNull(sendBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0)), + "Send must have LEFT binding → first radio"); + assertNotNull(sendBtn.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0)), + "Send must have RIGHT binding → first radio"); + }); + } + + @Test + void testTypeInputCheckboxHasRightBinding() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + // hasCheckbox=true + hasTextInput=true → typeInputCb + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Type something.", "1", null, false, true, true)), 0), + a -> {}, false); + + JCheckBox cb = findCheckBoxByName(panel, "typeInputCb"); + assertNotNull(cb, "typeInputCb checkbox must exist"); + assertNotNull(cb.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0)), + "typeInputCb must have RIGHT binding to focus its text field"); + }); + } + + @Test + void testTextFieldsHaveUpDownBindings() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ChoiceMenuPanel panel = new ChoiceMenuPanel(() -> null); + panel.show(new ChoiceMenuModel("Choose:", + List.of(new Option("Type something.", "1"), + new Option("Type other.", "2")), 0), a -> {}, false); + + List fields = collectTextFields(panel); + assertFalse(fields.isEmpty(), "must find text fields"); + for (JTextField tf : fields) { + assertNotNull(tf.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0)), + "text field must have UP binding"); + assertNotNull(tf.getInputMap(javax.swing.JComponent.WHEN_FOCUSED) + .get(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0)), + "text field must have DOWN binding"); + } + }); + } + // ------------------------------------------------------------------------- // helpers // ------------------------------------------------------------------------- @@ -774,4 +1120,37 @@ private static JTextField findTextField(java.awt.Container container) { } return null; } + + private static List collectTextFields(java.awt.Container container) { + List result = new ArrayList<>(); + for (Component c : container.getComponents()) { + if (c instanceof JTextField tf) result.add(tf); + else if (c instanceof java.awt.Container sub) result.addAll(collectTextFields(sub)); + } + return result; + } + + private static List collectYesNoButtons(java.awt.Container container) { + List result = new ArrayList<>(); + for (Component c : container.getComponents()) { + if (c instanceof JButton btn) { + String t = btn.getText(); + if (!"Send".equals(t) && !"Submit".equals(t) && !"Cancel".equals(t)) result.add(btn); + } else if (c instanceof java.awt.Container sub) { + result.addAll(collectYesNoButtons(sub)); + } + } + return result; + } + + private static JCheckBox findCheckBoxByName(java.awt.Container container, String name) { + for (Component c : container.getComponents()) { + if (c instanceof JCheckBox cb && name.equals(cb.getName())) return cb; + else if (c instanceof java.awt.Container sub) { + JCheckBox found = findCheckBoxByName(sub, name); + if (found != null) return found; + } + } + return null; + } }