diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/HotspotEngine.java b/j3270/src/main/java/haus/nightmare/j3270/ui/HotspotEngine.java index 29653a7..af23a94 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/HotspotEngine.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/HotspotEngine.java @@ -129,7 +129,7 @@ public class HotspotEngine { Pattern.CASE_INSENSITIVE ); - // 3. Menu choices: "1.", "2.", "===> 3", "=> 4", "--> 5" + // 3. Menu choices: "1.", "2.", "===> 3", "=> 4", "--> 5", ISPF (" 0 Settings", " 1 View", "10 SCLM", " X Exit") private static final Pattern MENU_NUMBER_PATTERN = Pattern.compile( "(?:(?<=\\s)|^)(\\d{1,2})\\.(?:(?=\\s)|$)" ); @@ -138,6 +138,10 @@ public class HotspotEngine { "(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)" ); + private static final Pattern ISPF_MENU_PATTERN = Pattern.compile( + "(?:^\\s*|(?<=\\s{3,}))(?:(\\d{1,2}(?:\\.\\d{1,2})?)(?:\\s*[-–—]\\s*|\\s+)|([A-Za-z])(?:\\s*[-–—]\\s*|\\s{2,}))([A-Za-z][A-Za-z0-9/_-]*)" + ); + // Caching state to avoid rescanning unchanged screens private String cachedScreenText = ""; private int cachedRows = 0; @@ -369,6 +373,28 @@ public class HotspotEngine { hotspots.add(h); markOccupied(occupied, startCol, endCol); } + + // Pattern 3c: ISPF menu choices (e.g. " 1 View", " 2 Edit", "10 SCLM", " X Exit", " M More", " 1 - View", " 3.1 Library") + Matcher mIspf = ISPF_MENU_PATTERN.matcher(line); + while (mIspf.find()) { + String choiceStr = mIspf.group(1) != null ? mIspf.group(1) : mIspf.group(2).toUpperCase(); + String labelStr = mIspf.group(3); + int startCol = mIspf.group(1) != null ? mIspf.start(1) : mIspf.start(2); + int endCol = mIspf.end(3); + if (isRangeOccupied(occupied, startCol, endCol)) continue; + + String action = choiceStr + "[enter]"; + String tooltip = "Select option " + choiceStr + " (" + labelStr + ")"; + String display = line.substring(startCol, endCol); + + Hotspot h = new Hotspot( + HotspotType.MENU_CHOICE, + r, startCol, r, endCol - 1, + display, action, tooltip + ); + hotspots.add(h); + markOccupied(occupied, startCol, endCol); + } } } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java index 54b8a41..ef14668 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java @@ -533,7 +533,7 @@ public class SettingsDialog extends JDialog { gbcH.gridy = 2; hotGroup.add(hotspotsPfCheck, gbcH); - hotspotsMenuCheck = new JCheckBox("Auto-detect Menu choices (\"1.\", \"2.\", \"===> 3\")", Settings.getHotspotsMenuChoicesEnabled()); + hotspotsMenuCheck = new JCheckBox("Auto-detect Menu choices (ISPF, \"1.\", \"===> 3\")", Settings.getHotspotsMenuChoicesEnabled()); ThemeManager.styleCheckBox(hotspotsMenuCheck); gbcH.gridy = 3; hotGroup.add(hotspotsMenuCheck, gbcH); diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java index a3707d5..039639a 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java @@ -533,6 +533,18 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I if (h.getType() == HotspotEngine.HotspotType.URL) { openUrl(h.getAction()); return; + } else if (h.getType() == HotspotEngine.HotspotType.MENU_CHOICE) { + // In 3270 / ISPF, clicking on a menu choice hotspot positions the cursor + // on a protected screen label. Typing directly into a protected cell triggers + // INHIBIT_PROTECTED_FIELD keyboard lock. Menu choices must be submitted via + // the command/option line (first unprotected field reachable via [home]). + String action = h.getAction(); + if (!action.startsWith("[home]")) { + action = "[home][eraseeof]" + action; + } + client.getPS().SendKeys(action); + refreshScreen(); + return; } else { client.getPS().SendKeys(h.getAction()); refreshScreen(); diff --git a/j3270/src/test/java/haus/nightmare/j3270/ui/HotspotEngineTest.java b/j3270/src/test/java/haus/nightmare/j3270/ui/HotspotEngineTest.java index 88e6b10..582483c 100644 --- a/j3270/src/test/java/haus/nightmare/j3270/ui/HotspotEngineTest.java +++ b/j3270/src/test/java/haus/nightmare/j3270/ui/HotspotEngineTest.java @@ -189,4 +189,106 @@ public class HotspotEngineTest { List empty = engine.scanText(screen, 1, 80); assertEquals(0, empty.size()); } + + @Test + @DisplayName("Detects ISPF primary option menu choices (numbers, two-digit, and letters)") + public void testIspfPrimaryOptionMenuDetection() { + String screen = + " Menu Utilities Compilers Options Status Help\n" + + " ──────────────────────────────────────────────────────────────────────────────\n" + + " ISPF Primary Option Menu\n" + + " Option ===>\n" + + " \n" + + " 0 Settings Terminal and user parameters User ID . : RUDI\n" + + " 1 View Display source data or listings Time. . . : 12:31\n" + + " 2 Edit Create or change source data Terminal. : 3278\n" + + " 3 Utilities Perform utility functions Screen. . : 1\n" + + " 4 Foreground Interactive language processing Language. : ENGLISH\n" + + " 5 Batch Submit job for language processing Appl ID . : ISR\n" + + " 6 Command Enter TSO or Workstation commands TSO logon : DEVPROC\n" + + " 7 Dialog Test Perform dialog testing TSO prefix: RUDI\n" + + " 9 IBM Products IBM program development products System ID : VS01\n" + + " 10 SCLM SW Configuration Library Manager Release . : ISPF 7.5\n" + + " 11 Workplace ISPF Object/Action Workplace\n" + + " M More Additional workplace options\n" + + " X Exit Terminate ISPF using log and list defaults"; + + List hotspots = engine.scanText(screen, 18, 80); + + // 14 total hotspots: 13 ISPF menu choices (0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, M, X) + 1 [ENTER] key on row 11 + assertEquals(14, hotspots.size()); + List menuChoices = hotspots.stream() + .filter(h -> h.getType() == HotspotEngine.HotspotType.MENU_CHOICE) + .collect(java.util.stream.Collectors.toList()); + assertEquals(13, menuChoices.size()); + + // Option 0 + HotspotEngine.Hotspot h0 = menuChoices.get(0); + assertEquals(HotspotEngine.HotspotType.MENU_CHOICE, h0.getType()); + assertEquals("0 Settings", h0.getDisplayText()); + assertEquals("0[enter]", h0.getAction()); + assertEquals("Select option 0 (Settings)", h0.getTooltip()); + assertEquals(5, h0.getStartRow()); + + // Option 1 + HotspotEngine.Hotspot h1 = menuChoices.get(1); + assertEquals("1 View", h1.getDisplayText()); + assertEquals("1[enter]", h1.getAction()); + assertEquals("Select option 1 (View)", h1.getTooltip()); + assertEquals(6, h1.getStartRow()); + + // Option 2 + HotspotEngine.Hotspot h2 = menuChoices.get(2); + assertEquals("2 Edit", h2.getDisplayText()); + assertEquals("2[enter]", h2.getAction()); + assertEquals("Select option 2 (Edit)", h2.getTooltip()); + + // Option 10 (two-digit, index 9 after 0,1,2,3,4,5,6,7,9) + HotspotEngine.Hotspot h10 = menuChoices.get(9); + assertEquals("10 SCLM", h10.getDisplayText()); + assertEquals("10[enter]", h10.getAction()); + assertEquals("Select option 10 (SCLM)", h10.getTooltip()); + + // Option M (letter, index 11) + HotspotEngine.Hotspot hM = menuChoices.get(11); + assertEquals("M More", hM.getDisplayText()); + assertEquals("M[enter]", hM.getAction()); + assertEquals("Select option M (More)", hM.getTooltip()); + + // Option X (exit, index 12) + HotspotEngine.Hotspot hX = menuChoices.get(12); + assertEquals("X Exit", hX.getDisplayText()); + assertEquals("X[enter]", hX.getAction()); + assertEquals("Select option X (Exit)", hX.getTooltip()); + } + + @Test + @DisplayName("Detects ISPF dotted sub-options, dashes, and multi-column menus") + public void testIspfSubOptionsAndMultiColumn() { + String screen = + " 3.1 Library - Compress or print data set\n" + + " 3.2 DataSet - Allocate, rename, or delete\n" + + " 1 - View 2 - Edit"; + + List hotspots = engine.scanText(screen, 3, 80); + assertEquals(4, hotspots.size()); + + HotspotEngine.Hotspot h31 = hotspots.get(0); + assertEquals("3.1 Library", h31.getDisplayText()); + assertEquals("3.1[enter]", h31.getAction()); + assertEquals("Select option 3.1 (Library)", h31.getTooltip()); + + HotspotEngine.Hotspot h32 = hotspots.get(1); + assertEquals("3.2 DataSet", h32.getDisplayText()); + assertEquals("3.2[enter]", h32.getAction()); + + HotspotEngine.Hotspot h1 = hotspots.get(2); + assertEquals("1 - View", h1.getDisplayText()); + assertEquals("1[enter]", h1.getAction()); + + HotspotEngine.Hotspot h2 = hotspots.get(3); + assertEquals("2 - Edit", h2.getDisplayText()); + assertEquals("2[enter]", h2.getAction()); + } } + diff --git a/j3270/src/test/java/haus/nightmare/j3270/ui/TerminalPanelSelectionTest.java b/j3270/src/test/java/haus/nightmare/j3270/ui/TerminalPanelSelectionTest.java index e34411d..8ce5f82 100644 --- a/j3270/src/test/java/haus/nightmare/j3270/ui/TerminalPanelSelectionTest.java +++ b/j3270/src/test/java/haus/nightmare/j3270/ui/TerminalPanelSelectionTest.java @@ -258,4 +258,78 @@ public class TerminalPanelSelectionTest { } catch (HeadlessException ignored) { } } + + @Test + @DisplayName("Clicking an ISPF menu choice navigates to home/option line, types choice, and sends AID_ENTER") + public void testClickOnIspfMenuChoiceSubmitsViaHomeAndEnter() { + try { + TerminalPanel panel = new TerminalPanel(); + panel.setSize(800, 600); + + Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23)); + client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270); + + ScreenBuffer sb = client.getScreenBuffer(); + sb.erase(false); + + // Row 3 (col 0): Protected label "Option ===> " + int row3Start = 3 * 80; + sb.setCellFA(row3Start, (byte) (FA_PRINTABLE | FA_PROTECT)); + String promptText = "Option ===> "; + for (int i = 0; i < promptText.length(); i++) { + sb.getCell(row3Start + 1 + i).ucs4 = promptText.charAt(i); + sb.getCell(row3Start + 1 + i).ec = (byte) (0xC1 + i); + } + + // Unprotected command input field after "Option ===> " at address 253 (row 3 col 13) + int inputFieldAddr = row3Start + 13; + sb.setCellFA(inputFieldAddr, (byte) FA_PRINTABLE); // Unprotected! + for (int i = 0; i < 20; i++) { + sb.getCell(inputFieldAddr + 1 + i).ucs4 = ' '; + sb.getCell(inputFieldAddr + 1 + i).ec = (byte) 0x40; + } + + // Row 6 (address 480): Protected field with ISPF menu option " 1 View" + int row6Start = 6 * 80; + sb.setCellFA(row6Start, (byte) (FA_PRINTABLE | FA_PROTECT)); + String menuText = " 1 View Display source data or listings"; + for (int i = 0; i < menuText.length(); i++) { + sb.getCell(row6Start + 1 + i).ucs4 = menuText.charAt(i); + sb.getCell(row6Start + 1 + i).ec = (byte) (menuText.charAt(i) == ' ' ? 0x40 : 0xC1 + i); + } + + sb.updateFormatted(); + sb.updateDisplaySnapshot(); + + panel.setClient(client); + panel.setHotspotsDirty(true); + + int cellW = panel.cellWidth > 0 ? panel.cellWidth : 10; + int cellH = panel.cellHeight > 0 ? panel.cellHeight : 18; + int ox = panel.getRenderOffsetX(); + int oy = panel.getRenderOffsetY(); + + MouseListener ml = panel.getMouseListeners()[0]; + + // Click at row 6, col 2 (on the "1" in " 1 View") + int clickX = ox + 2 * cellW + cellW / 2; + int clickY = oy + 6 * cellH + cellH / 2; + + ml.mousePressed(new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), + 0, clickX, clickY, 1, false, MouseEvent.BUTTON1)); + ml.mouseReleased(new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(), + 0, clickX, clickY, 1, false, MouseEvent.BUTTON1)); + + // AID_ENTER must have been transmitted + assertEquals(AID_ENTER, client.getInputProcessor().getLastAid(), "Clicking menu choice must transmit AID_ENTER"); + + // Keyboard lock should be standard system lock awaiting host response, NOT protected field error + assertNotEquals(com.ibm.eNetwork.ECL.ECLConstants.INHIBIT_PROTECTED_FIELD, client.getOIA().getInputInhibited(), + "Keyboard must not be locked with protected field violation"); + + // Option "1" must have been typed into the unprotected command input field (address inputFieldAddr + 1) + assertEquals('1', (char) sb.getCell(inputFieldAddr + 1).ucs4, "Option '1' must be placed on the command/option line"); + } catch (HeadlessException ignored) { + } + } }