From 1f98c0d70f6e3ed45cdb43343dc3f34292a442b5 Mon Sep 17 00:00:00 2001 From: Rudi Date: Fri, 2 Oct 2026 13:55:18 +0000 Subject: [PATCH] Hotspot detection debugging --- .../haus/nightmare/a3270/ui/HotspotEngine.kt | 31 +++++ .../haus/nightmare/a3270/ui/SettingsDialog.kt | 2 +- .../haus/nightmare/a3270/ui/TerminalView.kt | 11 ++ .../a3270/HotspotAndHistoryFeaturesTest.kt | 117 ++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/main/java/haus/nightmare/a3270/ui/HotspotEngine.kt b/src/main/java/haus/nightmare/a3270/ui/HotspotEngine.kt index 621dbd3..6a9422c 100644 --- a/src/main/java/haus/nightmare/a3270/ui/HotspotEngine.kt +++ b/src/main/java/haus/nightmare/a3270/ui/HotspotEngine.kt @@ -93,6 +93,10 @@ class HotspotEngine { "(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)" ) + private val 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/_-]*)" + ) + private fun markOccupied(arr: BooleanArray, start: Int, end: Int) { val s = start.coerceAtLeast(0) val e = end.coerceAtMost(arr.size) @@ -308,6 +312,33 @@ class HotspotEngine { hotspots.add(h) markOccupied(occupied, startCol, endCol) } + + // 3c. Scan ISPF Menu choices (e.g. " 1 View", " 2 Edit", "10 SCLM", " X Exit", " M More", " 1 - View", " 3.1 Library") + val mIspf = ISPF_MENU_PATTERN.matcher(line) + while (mIspf.find()) { + val choiceStr = mIspf.group(1) ?: mIspf.group(2)?.uppercase() ?: continue + val labelStr = mIspf.group(3) ?: continue + val startCol = if (mIspf.group(1) != null) mIspf.start(1) else mIspf.start(2) + val endCol = mIspf.end(3) + if (isRangeOccupied(occupied, startCol, endCol)) continue + + val action = "$choiceStr[enter]" + val tooltip = "Select option $choiceStr ($labelStr)" + val display = line.substring(startCol, endCol) + + val h = Hotspot( + type = HotspotType.MENU_CHOICE, + startRow = r, + startCol = startCol, + endRow = r, + endCol = endCol - 1, + displayText = display, + action = action, + tooltip = tooltip + ) + hotspots.add(h) + markOccupied(occupied, startCol, endCol) + } } } diff --git a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt index 1d069c6..2b511e7 100644 --- a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt @@ -441,7 +441,7 @@ fun SettingsDialog( ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text("Auto-Detect Menu Choices", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White) - Text("\"1.\", \"2.\", \"===> 3\", \"=> 4\"", fontSize = 11.sp, color = Color.LightGray) + Text("ISPF, \"1.\", \"2.\", \"===> 3\", \"=> 4\"", fontSize = 11.sp, color = Color.LightGray) } Switch( checked = hotspotsMenuChoicesEnabled, diff --git a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt index 19f3d19..7feb078 100644 --- a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt +++ b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt @@ -205,6 +205,17 @@ fun TerminalView( if (h.type == HotspotEngine.HotspotType.URL) { onOpenUrl?.invoke(h.action) handledHotspot = true + } else if (h.type == HotspotEngine.HotspotType.MENU_CHOICE) { + // In 3270 / ISPF, clicking/tapping 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]). + var action = h.action + if (!action.startsWith("[home]")) { + action = "[home][eraseeof]$action" + } + onSendHotspotAction?.invoke(action) + handledHotspot = true } else { onSendHotspotAction?.invoke(h.action) handledHotspot = true diff --git a/src/test/java/haus/nightmare/a3270/HotspotAndHistoryFeaturesTest.kt b/src/test/java/haus/nightmare/a3270/HotspotAndHistoryFeaturesTest.kt index ec64e21..aba4730 100644 --- a/src/test/java/haus/nightmare/a3270/HotspotAndHistoryFeaturesTest.kt +++ b/src/test/java/haus/nightmare/a3270/HotspotAndHistoryFeaturesTest.kt @@ -54,6 +54,105 @@ class HotspotAndHistoryFeaturesTest { assertTrue(enterHotspots.any { it.action == "[enter]" }) } + @Test + fun testIspfPrimaryOptionMenuDetection() { + val engine = HotspotEngine() + val 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" + + val 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) + val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE } + assertEquals(13, menuChoices.size) + + // Option 0 + val h0 = menuChoices[0] + assertEquals(HotspotType.MENU_CHOICE, h0.type) + assertEquals("0 Settings", h0.displayText) + assertEquals("0[enter]", h0.action) + assertEquals("Select option 0 (Settings)", h0.tooltip) + assertEquals(5, h0.startRow) + + // Option 1 + val h1 = menuChoices[1] + assertEquals("1 View", h1.displayText) + assertEquals("1[enter]", h1.action) + assertEquals("Select option 1 (View)", h1.tooltip) + assertEquals(6, h1.startRow) + + // Option 2 + val h2 = menuChoices[2] + assertEquals("2 Edit", h2.displayText) + assertEquals("2[enter]", h2.action) + assertEquals("Select option 2 (Edit)", h2.tooltip) + + // Option 10 (two-digit, index 9 after 0,1,2,3,4,5,6,7,9) + val h10 = menuChoices[9] + assertEquals("10 SCLM", h10.displayText) + assertEquals("10[enter]", h10.action) + assertEquals("Select option 10 (SCLM)", h10.tooltip) + + // Option M (letter, index 11) + val hM = menuChoices[11] + assertEquals("M More", hM.displayText) + assertEquals("M[enter]", hM.action) + assertEquals("Select option M (More)", hM.tooltip) + + // Option X (exit, index 12) + val hX = menuChoices[12] + assertEquals("X Exit", hX.displayText) + assertEquals("X[enter]", hX.action) + assertEquals("Select option X (Exit)", hX.tooltip) + } + + @Test + fun testIspfSubOptionsAndMultiColumn() { + val engine = HotspotEngine() + val screen = + " 3.1 Library - Compress or print data set\n" + + " 3.2 DataSet - Allocate, rename, or delete\n" + + " 1 - View 2 - Edit" + + val hotspots = engine.scanText(screen, 3, 80) + assertEquals(4, hotspots.size) + + val h31 = hotspots[0] + assertEquals("3.1 Library", h31.displayText) + assertEquals("3.1[enter]", h31.action) + assertEquals("Select option 3.1 (Library)", h31.tooltip) + + val h32 = hotspots[1] + assertEquals("3.2 DataSet", h32.displayText) + assertEquals("3.2[enter]", h32.action) + + val h1 = hotspots[2] + assertEquals("1 - View", h1.displayText) + assertEquals("1[enter]", h1.action) + + val h2 = hotspots[3] + assertEquals("2 - Edit", h2.displayText) + assertEquals("2[enter]", h2.action) + } + @Test fun testHotspotHitTesting() { val engine = HotspotEngine() @@ -192,4 +291,22 @@ class HotspotAndHistoryFeaturesTest { assertEquals(true, hsPf) assertEquals(false, hsChoices) } + + @Test + fun testMenuChoiceActionPrefixingForSafeSubmission() { + val engine = HotspotEngine() + val screen = " 1 View Display source data or listings" + val hotspots = engine.scanText(screen, 1, 80) + assertEquals(1, hotspots.size) + val h = hotspots[0] + assertEquals(HotspotType.MENU_CHOICE, h.type) + assertEquals("1[enter]", h.action) + + // Menu choices must be submitted via [home][eraseeof] to prevent protected field keyboard lock + var safeAction = h.action + if (!safeAction.startsWith("[home]")) { + safeAction = "[home][eraseeof]$safeAction" + } + assertEquals("[home][eraseeof]1[enter]", safeAction) + } }