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;
+ }
}