From aa724fbcef36ff34ab3f3b9e95e457f8e75307ba Mon Sep 17 00:00:00 2001 From: Rudi Date: Fri, 18 Sep 2026 18:18:53 +0000 Subject: [PATCH] Hotspot detection and screen buffer --- .../java/haus/nightmare/j3270/J3270App.java | 20 + .../haus/nightmare/j3270/config/Settings.java | 119 ++++ .../nightmare/j3270/ui/HotspotEngine.java | 391 ++++++++++++ .../haus/nightmare/j3270/ui/KeyBindings.java | 65 +- .../j3270/ui/ScreenHistoryDialog.java | 323 ++++++++++ .../nightmare/j3270/ui/SettingsDialog.java | 281 ++++++++- .../nightmare/j3270/ui/TerminalPanel.java | 460 +++++++++++++- .../nightmare/j3270/ui/HotspotEngineTest.java | 192 ++++++ .../j3270/ui/SelectionHandlesAndTrimTest.java | 265 ++++++++ .../java/com/ibm/eNetwork/ECL/ECLSession.java | 1 + .../nightmare/lib3270j/Telnet3270Client.java | 10 + .../haus/nightmare/lib3270j/ecl/ECLPS.java | 28 +- .../nightmare/lib3270j/ecl/ECLSession.java | 1 + .../nightmare/lib3270j/input/HodKeyMap.java | 588 ++++++++++++++++++ .../lib3270j/input/InputProcessor.java | 87 ++- .../lib3270j/screen/ScreenBuffer.java | 48 ++ .../lib3270j/screen/ScreenHistoryBuffer.java | 336 ++++++++++ .../lib3270j/input/HodKeyMapTest.java | 140 +++++ .../lib3270j/input/PasteWordBreakTest.java | 88 +++ .../screen/ScreenHistoryBufferTest.java | 194 ++++++ 20 files changed, 3603 insertions(+), 34 deletions(-) create mode 100644 j3270/src/main/java/haus/nightmare/j3270/ui/HotspotEngine.java create mode 100644 j3270/src/main/java/haus/nightmare/j3270/ui/ScreenHistoryDialog.java create mode 100644 j3270/src/test/java/haus/nightmare/j3270/ui/HotspotEngineTest.java create mode 100644 j3270/src/test/java/haus/nightmare/j3270/ui/SelectionHandlesAndTrimTest.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/input/HodKeyMap.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenHistoryBuffer.java create mode 100644 lib3270j/src/test/java/haus/nightmare/lib3270j/input/HodKeyMapTest.java create mode 100644 lib3270j/src/test/java/haus/nightmare/lib3270j/input/PasteWordBreakTest.java create mode 100644 lib3270j/src/test/java/haus/nightmare/lib3270j/screen/ScreenHistoryBufferTest.java diff --git a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java index c1ff2c9..cb02b0d 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java +++ b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java @@ -45,6 +45,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate private MacroDialog macroDialog; private FieldInspectorDialog fieldInspectorDialog; private PrinterSessionDialog printerSessionDialog; + private ScreenHistoryDialog screenHistoryDialog; // Modes menu items private JCheckBoxMenuItem docModeItem; @@ -95,6 +96,9 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate if (printerSessionDialog != null) { printerSessionDialog.dispose(); } + if (screenHistoryDialog != null) { + screenHistoryDialog.dispose(); + } terminalPanel.dispose(); } @@ -140,6 +144,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate if (macroDialog != null && macroDialog.isVisible()) return true; if (fieldInspectorDialog != null && fieldInspectorDialog.isVisible()) return true; if (printerSessionDialog != null && printerSessionDialog.isVisible()) return true; + if (screenHistoryDialog != null && screenHistoryDialog.isVisible()) return true; for (Window owned : getOwnedWindows()) { if (owned != null && owned.isVisible()) { return true; @@ -206,6 +211,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate // 2. Edit menu JMenu editMenu = createMenu("Edit"); + editMenu.add(createMenuItem("Cut", KeyEvent.VK_X, () -> terminalPanel.cutSelection())); editMenu.add(createMenuItem("Copy", KeyEvent.VK_C, () -> terminalPanel.copySelection())); editMenu.add(createMenuItem("Paste", KeyEvent.VK_V, () -> terminalPanel.pasteClipboard())); editMenu.add(createMenuItem("Paste with Line Wrap...", -1, this::showPasteLineWrapDialog)); @@ -337,6 +343,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate viewMenu.add(gfxMenu); viewMenu.addSeparator(); + viewMenu.add(createMenuItem("Screen History...", KeyEvent.VK_H, this::showScreenHistoryDialog, true)); viewMenu.add(createMenuItem("Field Inspector...", -1, this::showFieldInspectorDialog)); menuBar.add(viewMenu); @@ -456,6 +463,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate actionsMenu.addSeparator(); actionsMenu.add(createMenuItem("Run Keystrokes / Script...", -1, this::showScriptDialog)); actionsMenu.add(createMenuItem("Macro Runtime ()...", KeyEvent.VK_M, this::showMacroDialog, true)); + actionsMenu.add(createMenuItem("Screen History...", KeyEvent.VK_H, this::showScreenHistoryDialog, true)); actionsMenu.add(createMenuItem("File Transfer...", KeyEvent.VK_T, this::showFileTransferDialog, true)); menuBar.add(actionsMenu); @@ -592,6 +600,18 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate printerSessionDialog.setVisible(true); } + private void showScreenHistoryDialog() { + if (screenHistoryDialog == null) { + screenHistoryDialog = new ScreenHistoryDialog(this, client); + } else { + if (client != null && client.getScreenHistoryBuffer() != null) { + screenHistoryDialog.refreshHistoryView(client.getScreenHistoryBuffer().size() - 1); + } + } + screenHistoryDialog.setVisible(true); + screenHistoryDialog.toFront(); + } + private void showPasteLineWrapDialog() { if (client == null || !client.getConnectionState().isFullSession()) { JOptionPane.showMessageDialog(this, "Connect to a 3270 session before pasting.", "Not Connected", JOptionPane.WARNING_MESSAGE); diff --git a/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java b/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java index 72ad4f1..31301fe 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java +++ b/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java @@ -439,6 +439,33 @@ public class Settings { flushPrefs(); } + public static boolean getTrimRectSizingHandles() { + return prefs.getBoolean("trimRectSizingHandles", false); + } + + public static void setTrimRectSizingHandles(boolean val) { + prefs.putBoolean("trimRectSizingHandles", val); + flushPrefs(); + } + + public static boolean getTrimRectRemainAfterEdit() { + return prefs.getBoolean("trimRectRemainAfterEdit", false); + } + + public static void setTrimRectRemainAfterEdit(boolean val) { + prefs.putBoolean("trimRectRemainAfterEdit", val); + flushPrefs(); + } + + public static boolean getPasteWordBreak() { + return prefs.getBoolean("pasteWordBreak", true); + } + + public static void setPasteWordBreak(boolean val) { + prefs.putBoolean("pasteWordBreak", val); + flushPrefs(); + } + // ========== Crosshair Ruler ========== public static boolean getCrosshairRuler() { @@ -449,6 +476,71 @@ public class Settings { prefs.putBoolean("crosshairRuler", enabled); } + // ========== Phase 25: Hotspots & Mouse Wheel Navigation ========== + + public static boolean getHotspotsEnabled() { + return prefs.getBoolean("hotspotsEnabled", true); + } + + public static void setHotspotsEnabled(boolean enabled) { + prefs.putBoolean("hotspotsEnabled", enabled); + flushPrefs(); + } + + public static boolean getHotspotsUrlEnabled() { + return prefs.getBoolean("hotspotsUrlEnabled", true); + } + + public static void setHotspotsUrlEnabled(boolean enabled) { + prefs.putBoolean("hotspotsUrlEnabled", enabled); + flushPrefs(); + } + + public static boolean getHotspotsPfEnabled() { + return prefs.getBoolean("hotspotsPfEnabled", true); + } + + public static void setHotspotsPfEnabled(boolean enabled) { + prefs.putBoolean("hotspotsPfEnabled", enabled); + flushPrefs(); + } + + public static boolean getHotspotsMenuChoicesEnabled() { + return prefs.getBoolean("hotspotsMenuChoicesEnabled", true); + } + + public static void setHotspotsMenuChoicesEnabled(boolean enabled) { + prefs.putBoolean("hotspotsMenuChoicesEnabled", enabled); + flushPrefs(); + } + + public static boolean getMouseWheelEnabled() { + return prefs.getBoolean("mouseWheelEnabled", true); + } + + public static void setMouseWheelEnabled(boolean enabled) { + prefs.putBoolean("mouseWheelEnabled", enabled); + flushPrefs(); + } + + public static String getMouseWheelUpKey() { + return prefs.get("mouseWheelUpKey", "[pf7]"); + } + + public static void setMouseWheelUpKey(String key) { + prefs.put("mouseWheelUpKey", key != null && !key.trim().isEmpty() ? key.trim() : "[pf7]"); + flushPrefs(); + } + + public static String getMouseWheelDownKey() { + return prefs.get("mouseWheelDownKey", "[pf8]"); + } + + public static void setMouseWheelDownKey(String key) { + prefs.put("mouseWheelDownKey", key != null && !key.trim().isEmpty() ? key.trim() : "[pf8]"); + flushPrefs(); + } + // ========== Status Bar Visibility ========== public static boolean getStatusBarVisible() { @@ -685,6 +777,18 @@ public class Settings { case "pasteStopAtProtected": setPasteStopAtProtectedLine(Boolean.parseBoolean(value)); break; + case "trimRectSizingHandles": + case "sizingHandles": + setTrimRectSizingHandles(Boolean.parseBoolean(value)); + break; + case "trimRectRemainAfterEdit": + case "remainAfterEdit": + setTrimRectRemainAfterEdit(Boolean.parseBoolean(value)); + break; + case "pasteWordBreak": + case "wordBreak": + setPasteWordBreak(Boolean.parseBoolean(value)); + break; default: log.warning("Unknown behavior/connection key: " + key); } @@ -706,6 +810,18 @@ public class Settings { case "pasteStopAtProtected": setPasteStopAtProtectedLine(Boolean.parseBoolean(value)); break; + case "trimRectSizingHandles": + case "sizingHandles": + setTrimRectSizingHandles(Boolean.parseBoolean(value)); + break; + case "trimRectRemainAfterEdit": + case "remainAfterEdit": + setTrimRectRemainAfterEdit(Boolean.parseBoolean(value)); + break; + case "pasteWordBreak": + case "wordBreak": + setPasteWordBreak(Boolean.parseBoolean(value)); + break; default: log.warning("Unknown clipboard key: " + key); } @@ -840,6 +956,9 @@ public class Settings { w.println("autoSysUnlock = " + getAutoSysUnlock()); w.println("enablePasteFromExcel = " + getEnablePasteFromExcel()); w.println("pasteStopAtProtectedLine = " + getPasteStopAtProtectedLine()); + w.println("trimRectSizingHandles = " + getTrimRectSizingHandles()); + w.println("trimRectRemainAfterEdit = " + getTrimRectRemainAfterEdit()); + w.println("pasteWordBreak = " + getPasteWordBreak()); w.println(); // [entryassist] diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/HotspotEngine.java b/j3270/src/main/java/haus/nightmare/j3270/ui/HotspotEngine.java new file mode 100644 index 0000000..59bb321 --- /dev/null +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/HotspotEngine.java @@ -0,0 +1,391 @@ +package haus.nightmare.j3270.ui; + +import haus.nightmare.lib3270j.screen.ExtendedAttribute; +import haus.nightmare.lib3270j.screen.ScreenBuffer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Real-time hotspot and URL auto-detection engine matching IBM Host On-Demand + * ShowURLOptions and ScreenText parity. + * + * Scans screen text using regular expressions for: + * 1. URLs (http://, https://, ftp://, file://, mailto:, www.) + * 2. Function keys (PF1..PF24, F1..F24, PA1..PA3, [ENTER], ENTER) + * 3. Menu choices ("1.", "2.", "===> 3", "=> 4") + * + * Provides hit-testing for mouse hover/clicks and generates keystroke actions. + */ +public class HotspotEngine { + + public enum HotspotType { + URL, + PF_KEY, + ENTER, + MENU_CHOICE + } + + /** + * Represents an active interactive hotspot region on the terminal screen. + */ + public static class Hotspot { + private final HotspotType type; + private final int startRow; + private final int startCol; + private final int endRow; + private final int endCol; + private final String displayText; + private final String action; + private final String tooltip; + + public Hotspot(HotspotType type, int startRow, int startCol, int endRow, int endCol, + String displayText, String action, String tooltip) { + this.type = type; + this.startRow = startRow; + this.startCol = startCol; + this.endRow = endRow; + this.endCol = endCol; + this.displayText = displayText; + this.action = action; + this.tooltip = tooltip; + } + + public HotspotType getType() { + return type; + } + + public int getStartRow() { + return startRow; + } + + public int getStartCol() { + return startCol; + } + + public int getEndRow() { + return endRow; + } + + public int getEndCol() { + return endCol; + } + + public String getDisplayText() { + return displayText; + } + + public String getAction() { + return action; + } + + public String getTooltip() { + return tooltip; + } + + /** + * Test whether the given row/col coordinate falls within this hotspot. + */ + public boolean contains(int row, int col) { + if (row < startRow || row > endRow) { + return false; + } + if (startRow == endRow) { + return col >= startCol && col <= endCol; + } + if (row == startRow) { + return col >= startCol; + } + if (row == endRow) { + return col <= endCol; + } + return true; // Intermediate rows + } + + @Override + public String toString() { + return "Hotspot{" + type + " [" + startRow + "," + startCol + "]-[" + endRow + "," + endCol + "] '" + displayText + "'}"; + } + } + + // Configurable scan enablement + private boolean urlEnabled = true; + private boolean pfKeyEnabled = true; + private boolean menuChoiceEnabled = true; + + // Regular expression patterns + // 1. URLs + private static final Pattern URL_PATTERN = Pattern.compile( + "(?:https?|ftp|file)://[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|www\\.[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|mailto:[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+", + Pattern.CASE_INSENSITIVE + ); + + // 2. Function keys (PF1..PF24, F1..F24, PA1..PA3, [ENTER], ENTER) + private static final Pattern PF_KEY_PATTERN = Pattern.compile( + "(?:(?<=\\W)|^)(PF(?:[1-9]|1[0-9]|2[0-4])|F(?:[1-9]|1[0-9]|2[0-4])|PA[1-3]|\\[ENTER\\]|ENTER)(?:(?=\\W)|$)", + Pattern.CASE_INSENSITIVE + ); + + // 3. Menu choices: "1.", "2.", "===> 3", "=> 4", "--> 5" + private static final Pattern MENU_NUMBER_PATTERN = Pattern.compile( + "(?:(?<=\\s)|^)(\\d{1,2})\\.(?:(?=\\s)|$)" + ); + + private static final Pattern MENU_PROMPT_PATTERN = Pattern.compile( + "(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)" + ); + + // Caching state to avoid rescanning unchanged screens + private String cachedScreenText = ""; + private int cachedRows = 0; + private int cachedCols = 0; + private List cachedHotspots = Collections.emptyList(); + + public HotspotEngine() { + } + + public boolean isUrlEnabled() { + return urlEnabled; + } + + public void setUrlEnabled(boolean urlEnabled) { + this.urlEnabled = urlEnabled; + invalidateCache(); + } + + public boolean isPfKeyEnabled() { + return pfKeyEnabled; + } + + public void setPfKeyEnabled(boolean pfKeyEnabled) { + this.pfKeyEnabled = pfKeyEnabled; + invalidateCache(); + } + + public boolean isMenuChoiceEnabled() { + return menuChoiceEnabled; + } + + public void setMenuChoiceEnabled(boolean menuChoiceEnabled) { + this.menuChoiceEnabled = menuChoiceEnabled; + invalidateCache(); + } + + public void invalidateCache() { + cachedScreenText = ""; + cachedHotspots = Collections.emptyList(); + } + + /** + * Scan the given ScreenBuffer for interactive hotspots. + */ + public synchronized List scan(ScreenBuffer sb) { + if (sb == null) return Collections.emptyList(); + int rows = sb.getDisplayRows(); + int cols = sb.getDisplayCols(); + + // Build plain text representation + StringBuilder fullText = new StringBuilder(rows * (cols + 1)); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + int baddr = r * cols + c; + ExtendedAttribute ea = sb.getDisplayCell(baddr); + if (ea == null || ea.isFieldAttribute() || ea.ucs4 <= 0x20 || ea.ucs4 == 0xFF) { + fullText.append(' '); + } else { + fullText.append((char) ea.ucs4); + } + } + if (r < rows - 1) { + fullText.append('\n'); + } + } + + return scanText(fullText.toString(), rows, cols); + } + + /** + * Scan formatted screen text (lines separated by '\n') for hotspots. + */ + public synchronized List scanText(String screenText, int rows, int cols) { + if (screenText == null || rows <= 0 || cols <= 0) { + return Collections.emptyList(); + } + + if (screenText.equals(cachedScreenText) && rows == cachedRows && cols == cachedCols) { + return cachedHotspots; + } + + List hotspots = new ArrayList<>(); + String[] lines = screenText.split("\n", -1); + int actualRows = Math.min(rows, lines.length); + + // Scan line by line for precise row/column tracking + for (int r = 0; r < actualRows; r++) { + String line = lines[r]; + boolean[] occupied = new boolean[line.length() + 1]; + + // 1. Scan URLs + if (urlEnabled) { + Matcher mUrl = URL_PATTERN.matcher(line); + while (mUrl.find()) { + int startCol = mUrl.start(); + int endCol = mUrl.end(); + String match = mUrl.group(); + + // Trim trailing punctuation like . , ; : ) ] + while (match.length() > 1) { + char last = match.charAt(match.length() - 1); + if (last == '.' || last == ',' || last == ';' || last == ':' || last == ')' || last == ']' || last == '>') { + match = match.substring(0, match.length() - 1); + endCol--; + } else { + break; + } + } + + if (match.isEmpty()) continue; + + String actionUrl = match; + if (match.toLowerCase().startsWith("www.")) { + actionUrl = "http://" + match; + } + + Hotspot h = new Hotspot( + HotspotType.URL, + r, startCol, r, endCol - 1, + match, actionUrl, "Open URL: " + actionUrl + ); + hotspots.add(h); + markOccupied(occupied, startCol, endCol); + } + } + + // 2. Scan PF / Function keys + if (pfKeyEnabled) { + Matcher mPf = PF_KEY_PATTERN.matcher(line); + while (mPf.find()) { + int startCol = mPf.start(1); + int endCol = mPf.end(1); + if (isRangeOccupied(occupied, startCol, endCol)) continue; + + String keyText = mPf.group(1).toUpperCase(); + String action; + String tooltip; + + if (keyText.equals("ENTER") || keyText.equals("[ENTER]")) { + action = "[enter]"; + tooltip = "Send [ENTER]"; + } else if (keyText.startsWith("PF")) { + String numStr = keyText.substring(2); + action = "[pf" + numStr + "]"; + tooltip = "Send " + action.toUpperCase(); + } else if (keyText.startsWith("F")) { + String numStr = keyText.substring(1); + action = "[pf" + numStr + "]"; + tooltip = "Send " + action.toUpperCase(); + } else if (keyText.startsWith("PA")) { + String numStr = keyText.substring(2); + action = "[pa" + numStr + "]"; + tooltip = "Send " + action.toUpperCase(); + } else { + continue; + } + + Hotspot h = new Hotspot( + keyText.contains("ENTER") ? HotspotType.ENTER : HotspotType.PF_KEY, + r, startCol, r, endCol - 1, + mPf.group(1), action, tooltip + ); + hotspots.add(h); + markOccupied(occupied, startCol, endCol); + } + } + + // 3. Scan Menu choices ("1.", "2.", "===> 3", "=> 4") + if (menuChoiceEnabled) { + // Pattern 3a: Number followed by dot (e.g. " 1. ") + Matcher mNum = MENU_NUMBER_PATTERN.matcher(line); + while (mNum.find()) { + int startCol = mNum.start(1); + int endCol = mNum.end(); + if (isRangeOccupied(occupied, startCol, endCol)) continue; + + String choiceNum = mNum.group(1); + String action = choiceNum + "[enter]"; + String tooltip = "Select option " + choiceNum; + + Hotspot h = new Hotspot( + HotspotType.MENU_CHOICE, + r, startCol, r, endCol - 1, + choiceNum + ".", action, tooltip + ); + hotspots.add(h); + markOccupied(occupied, startCol, endCol); + } + + // Pattern 3b: Prompt followed by number (e.g. "===> 3") + Matcher mPrompt = MENU_PROMPT_PATTERN.matcher(line); + while (mPrompt.find()) { + int startCol = mPrompt.start(); + int endCol = mPrompt.end(); + if (isRangeOccupied(occupied, startCol, endCol)) continue; + + String choiceNum = mPrompt.group(1); + String action = choiceNum + "[enter]"; + String tooltip = "Execute choice " + choiceNum; + + Hotspot h = new Hotspot( + HotspotType.MENU_CHOICE, + r, startCol, r, endCol - 1, + mPrompt.group(), action, tooltip + ); + hotspots.add(h); + markOccupied(occupied, startCol, endCol); + } + } + } + + this.cachedScreenText = screenText; + this.cachedRows = rows; + this.cachedCols = cols; + this.cachedHotspots = Collections.unmodifiableList(hotspots); + + return cachedHotspots; + } + + /** + * Hit-test for a hotspot at the specified terminal row and column. + */ + public Hotspot findHotspot(int row, int col) { + for (Hotspot h : cachedHotspots) { + if (h.contains(row, col)) { + return h; + } + } + return null; + } + + /** + * Query currently active cached hotspots. + */ + public List getHotspots() { + return cachedHotspots; + } + + private static void markOccupied(boolean[] arr, int start, int end) { + for (int i = Math.max(0, start); i < Math.min(arr.length, end); i++) { + arr[i] = true; + } + } + + private static boolean isRangeOccupied(boolean[] arr, int start, int end) { + for (int i = Math.max(0, start); i < Math.min(arr.length, end); i++) { + if (arr[i]) return true; + } + return false; + } +} diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/KeyBindings.java b/j3270/src/main/java/haus/nightmare/j3270/ui/KeyBindings.java index 6e0b1ab..de6d0ca 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/KeyBindings.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/KeyBindings.java @@ -206,7 +206,8 @@ public class KeyBindings { /** * Parse a .kmp file from a Reader. - * Supports both INI-style KEY=action formats and bind statements. + * Supports both IBM Host On-Demand (HoD) .kmp format (hashKey prefixes) + * and IBM Personal Communications (PCOMM) INI-style and bind statements. * * @param reader the reader to parse from * @return Map of j3270 action names to key stroke bindings @@ -214,11 +215,38 @@ public class KeyBindings { */ public static Map parseKmp(Reader reader) throws IOException { BufferedReader br = (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader); - Map> actionToBindings = new LinkedHashMap<>(); - + List rawLines = new ArrayList<>(); + boolean hasHodKeyRemap = false; + boolean hasHodHashKey = false; String line; while ((line = br.readLine()) != null) { - line = line.trim(); + rawLines.add(line); + String trimmed = line.trim(); + if (trimmed.equalsIgnoreCase("[KeyRemap]")) { + hasHodKeyRemap = true; + } + int eq = trimmed.indexOf('='); + if (eq > 0) { + String left = trimmed.substring(0, eq).trim(); + if (left.matches("^[A-Za-z][0-9]+$")) { + hasHodHashKey = true; + } + } + } + + if (hasHodKeyRemap || hasHodHashKey) { + Map hodMap = haus.nightmare.lib3270j.input.HodKeyMap.parseHodKmp( + new StringReader(String.join("\n", rawLines)) + ); + if (hodMap != null && !hodMap.isEmpty()) { + return hodMap; + } + } + + Map> actionToBindings = new LinkedHashMap<>(); + + for (String raw : rawLines) { + line = raw.trim(); // Strip comments int commentIdx = line.indexOf(';'); @@ -262,6 +290,22 @@ public class KeyBindings { return result; } + /** + * Export key bindings to a .kmp file using IBM HoD format. + */ + public static void exportKmp(File file, Map keyBindings) throws IOException { + try (BufferedWriter bw = new BufferedWriter(new FileWriter(file))) { + exportKmp(bw, keyBindings); + } + } + + /** + * Export key bindings to a Writer using IBM HoD format. + */ + public static void exportKmp(Writer writer, Map keyBindings) throws IOException { + haus.nightmare.lib3270j.input.HodKeyMap.exportHodKmp(writer, keyBindings); + } + private static void parseBindStatement(String stmt, Map> actionToBindings) { String[] parts = stmt.split("\\s+", 2); if (parts.length < 2) return; @@ -288,6 +332,19 @@ public class KeyBindings { } private static void parseKeyAssignment(String left, String right, Map> actionToBindings) { + if (left.matches("^[A-Za-z][0-9]+$")) { + haus.nightmare.lib3270j.input.HodKeyMap.KeyEntry entry = + haus.nightmare.lib3270j.input.HodKeyMap.parseHashKey(left, right); + if (entry != null) { + String stroke = entry.toKeyStrokeString(); + String act = haus.nightmare.lib3270j.input.HodKeyMap.resolveMnemonicToAction(right); + if (act != null && stroke != null && !stroke.isEmpty()) { + addBinding(actionToBindings, act, stroke); + return; + } + } + } + // Case A: left is KEY or Modifier-KEY, right is mnemonic String action = resolveMnemonic(right); String keyStroke = null; diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ScreenHistoryDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ScreenHistoryDialog.java new file mode 100644 index 0000000..2860582 --- /dev/null +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ScreenHistoryDialog.java @@ -0,0 +1,323 @@ +package haus.nightmare.j3270.ui; + +import haus.nightmare.j3270.J3270App; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer; +import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer.ScreenHistoryListener; +import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer.ScreenSnapshot; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.awt.datatransfer.Clipboard; +import java.awt.datatransfer.StringSelection; +import java.awt.event.*; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Screen History viewer dialog matching IBM Host On-Demand ScreenHistory UI parity. + * + * Provides: + * - Interactive JSlider scrubber to review past 64 screen buffer frames + * - Previous / Next navigation buttons + * - Exact monospace terminal frame preview with selection capability + * - Copy Screen, Copy Selection, and Copy as Table actions + * - Live update synchronization as new host screens arrive + */ +public class ScreenHistoryDialog extends JDialog implements ScreenHistoryListener { + + private final J3270App parentApp; + private final Telnet3270Client client; + private final ScreenHistoryBuffer historyBuffer; + + private JSlider scrubSlider; + private JButton btnPrev; + private JButton btnNext; + private JLabel infoLabel; + private JTextArea previewArea; + private JButton btnCopyScreen; + private JButton btnCopySelection; + private JButton btnCopyTable; + private JButton btnClose; + + private int currentIndex = -1; // 0-based index into historyBuffer + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + public ScreenHistoryDialog(J3270App parent, Telnet3270Client client) { + super(parent, "Screen History", false); + this.parentApp = parent; + this.client = client; + this.historyBuffer = client != null ? client.getScreenHistoryBuffer() : null; + + AppIcon.applyTo(this); + initComponents(); + setupKeyNavigation(); + + if (historyBuffer != null) { + historyBuffer.addListener(this); + refreshHistoryView(historyBuffer.size() - 1); + } else { + updateEmptyState(); + } + + addWindowListener(new WindowAdapter() { + @Override + public void windowClosed(WindowEvent e) { + if (historyBuffer != null) { + historyBuffer.removeListener(ScreenHistoryDialog.this); + } + } + }); + + setSize(780, 560); + setLocationRelativeTo(parent); + } + + private void initComponents() { + setLayout(new BorderLayout(6, 6)); + JPanel contentPane = new JPanel(new BorderLayout(8, 8)); + contentPane.setBorder(new EmptyBorder(10, 10, 10, 10)); + setContentPane(contentPane); + + // Top Navigation Bar + JPanel topPanel = new JPanel(new BorderLayout(8, 8)); + topPanel.setBorder(BorderFactory.createTitledBorder("History Scrub Navigation")); + + JPanel sliderPanel = new JPanel(new BorderLayout(6, 6)); + btnPrev = new JButton("◄ Prev"); + ThemeManager.styleButton(btnPrev, ThemeManager.ButtonVariant.DEFAULT); + btnPrev.addActionListener(e -> stepHistory(-1)); + + btnNext = new JButton("Next ►"); + ThemeManager.styleButton(btnNext, ThemeManager.ButtonVariant.DEFAULT); + btnNext.addActionListener(e -> stepHistory(1)); + + scrubSlider = new JSlider(1, 1, 1); + scrubSlider.setMajorTickSpacing(10); + scrubSlider.setMinorTickSpacing(1); + scrubSlider.setPaintTicks(true); + scrubSlider.setSnapToTicks(true); + scrubSlider.addChangeListener(e -> { + if (!scrubSlider.getValueIsAdjusting()) { + int selected = scrubSlider.getValue() - 1; + displayScreen(selected); + } + }); + + sliderPanel.add(btnPrev, BorderLayout.WEST); + sliderPanel.add(scrubSlider, BorderLayout.CENTER); + sliderPanel.add(btnNext, BorderLayout.EAST); + + infoLabel = new JLabel("Screen 0 of 0", SwingConstants.CENTER); + infoLabel.setFont(infoLabel.getFont().deriveFont(Font.BOLD, 13f)); + topPanel.add(sliderPanel, BorderLayout.CENTER); + topPanel.add(infoLabel, BorderLayout.SOUTH); + + contentPane.add(topPanel, BorderLayout.NORTH); + + // Center Terminal Preview Pane + previewArea = new JTextArea(); + previewArea.setEditable(false); + previewArea.setFont(new Font("Monospaced", Font.PLAIN, 14)); + previewArea.setBackground(Color.BLACK); + previewArea.setForeground(new Color(0, 255, 65)); // 3270 Green Phosphor + previewArea.setCaretColor(Color.WHITE); + previewArea.setLineWrap(false); + previewArea.setMargin(new Insets(6, 6, 6, 6)); + + JScrollPane scrollPane = new JScrollPane(previewArea); + scrollPane.setBorder(BorderFactory.createLineBorder(new Color(60, 60, 60))); + contentPane.add(scrollPane, BorderLayout.CENTER); + + // Bottom Action Bar + JPanel bottomPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 4)); + + btnCopyScreen = new JButton("Copy Screen"); + ThemeManager.styleButton(btnCopyScreen, ThemeManager.ButtonVariant.DEFAULT); + btnCopyScreen.setToolTipText("Copy entire historical screen to clipboard"); + btnCopyScreen.addActionListener(e -> copyCurrentScreen()); + + btnCopySelection = new JButton("Copy Selection"); + ThemeManager.styleButton(btnCopySelection, ThemeManager.ButtonVariant.DEFAULT); + btnCopySelection.setToolTipText("Copy selected text to clipboard"); + btnCopySelection.addActionListener(e -> copySelectedText()); + + btnCopyTable = new JButton("Copy as Table"); + ThemeManager.styleButton(btnCopyTable, ThemeManager.ButtonVariant.DEFAULT); + btnCopyTable.setToolTipText("Copy screen text with trimmed trailing spaces per row"); + btnCopyTable.addActionListener(e -> copyAsTable()); + + btnClose = new JButton("Close"); + ThemeManager.styleButton(btnClose, ThemeManager.ButtonVariant.DEFAULT); + btnClose.addActionListener(e -> setVisible(false)); + + bottomPanel.add(btnCopyScreen); + bottomPanel.add(btnCopySelection); + bottomPanel.add(btnCopyTable); + bottomPanel.add(Box.createHorizontalStrut(12)); + bottomPanel.add(btnClose); + + contentPane.add(bottomPanel, BorderLayout.SOUTH); + } + + private void setupKeyNavigation() { + // Keyboard shortcuts for rapid scrub navigation + Action prevAction = new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { stepHistory(-1); } + }; + Action nextAction = new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { stepHistory(1); } + }; + Action homeAction = new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { displayScreen(0); } + }; + Action endAction = new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { + if (historyBuffer != null) displayScreen(historyBuffer.size() - 1); + } + }; + Action closeAction = new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { setVisible(false); } + }; + + JRootPane root = getRootPane(); + InputMap im = root.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); + ActionMap am = root.getActionMap(); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, InputEvent.ALT_DOWN_MASK), "prevScreen"); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_PAGE_UP, 0), "prevScreen"); + am.put("prevScreen", prevAction); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, InputEvent.ALT_DOWN_MASK), "nextScreen"); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_PAGE_DOWN, 0), "nextScreen"); + am.put("nextScreen", nextAction); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_HOME, InputEvent.ALT_DOWN_MASK), "homeScreen"); + am.put("homeScreen", homeAction); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_END, InputEvent.ALT_DOWN_MASK), "endScreen"); + am.put("endScreen", endAction); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "closeDialog"); + am.put("closeDialog", closeAction); + } + + private void stepHistory(int delta) { + if (historyBuffer == null || historyBuffer.isEmpty()) return; + int target = currentIndex + delta; + target = Math.max(0, Math.min(target, historyBuffer.size() - 1)); + displayScreen(target); + } + + private void displayScreen(int index) { + if (historyBuffer == null || historyBuffer.isEmpty()) { + updateEmptyState(); + return; + } + + int total = historyBuffer.size(); + index = Math.max(0, Math.min(index, total - 1)); + currentIndex = index; + + ScreenSnapshot snap = historyBuffer.get(index); + if (snap != null) { + previewArea.setText(snap.getText()); + previewArea.setCaretPosition(0); + + String timeStr = dateFormat.format(new Date(snap.getTimestamp())); + infoLabel.setText("Screen " + (index + 1) + " of " + total + " (" + timeStr + ") — " + + snap.getRows() + "x" + snap.getCols() + ", Cursor: [" + + (snap.getCursorRow() + 1) + "," + (snap.getCursorCol() + 1) + "]"); + + scrubSlider.setValue(index + 1); + } + + btnPrev.setEnabled(index > 0); + btnNext.setEnabled(index < total - 1); + btnCopyScreen.setEnabled(true); + btnCopySelection.setEnabled(true); + btnCopyTable.setEnabled(true); + } + + private void updateEmptyState() { + currentIndex = -1; + previewArea.setText("No screen history captured yet.\n\nScreens are automatically recorded as the host session receives updates or unlocks the keyboard."); + infoLabel.setText("Screen 0 of 0 — Empty History"); + scrubSlider.setEnabled(false); + btnPrev.setEnabled(false); + btnNext.setEnabled(false); + btnCopyScreen.setEnabled(false); + btnCopySelection.setEnabled(false); + btnCopyTable.setEnabled(false); + } + + /** + * Refresh slider bounds and select target index. + */ + public synchronized void refreshHistoryView(int targetIndex) { + if (historyBuffer == null || historyBuffer.isEmpty()) { + updateEmptyState(); + return; + } + + int total = historyBuffer.size(); + scrubSlider.setEnabled(true); + scrubSlider.setMinimum(1); + scrubSlider.setMaximum(total); + + int select = targetIndex >= 0 ? Math.min(targetIndex, total - 1) : total - 1; + displayScreen(select); + } + + @Override + public void onHistoryUpdated(ScreenHistoryBuffer buffer, ScreenSnapshot newSnapshot) { + SwingUtilities.invokeLater(() -> { + boolean wasAtLatest = (currentIndex >= 0 && currentIndex == buffer.size() - 2); + int newTotal = buffer.size(); + scrubSlider.setMaximum(newTotal); + + if (wasAtLatest || currentIndex < 0) { + // Keep tracked with latest frame + displayScreen(newTotal - 1); + } else { + // Keep operator's active scrub position, update label + displayScreen(currentIndex); + } + }); + } + + private void copyCurrentScreen() { + if (historyBuffer == null || currentIndex < 0) return; + ScreenSnapshot snap = historyBuffer.get(currentIndex); + if (snap != null) { + setClipboardText(snap.getText()); + } + } + + private void copySelectedText() { + String selected = previewArea.getSelectedText(); + if (selected != null && !selected.isEmpty()) { + setClipboardText(selected); + } else { + copyCurrentScreen(); + } + } + + private void copyAsTable() { + if (historyBuffer == null || currentIndex < 0) return; + ScreenSnapshot snap = historyBuffer.get(currentIndex); + if (snap != null) { + setClipboardText(snap.getFormattedTable()); + } + } + + private void setClipboardText(String text) { + try { + Clipboard cb = Toolkit.getDefaultToolkit().getSystemClipboard(); + cb.setContents(new StringSelection(text), null); + } catch (Exception e) { + Toolkit.getDefaultToolkit().beep(); + } + } +} 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 7d8d925..55b95d2 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java @@ -42,6 +42,18 @@ public class SettingsDialog extends JDialog { private JCheckBox blockPasteCheck; private JCheckBox enablePasteFromExcelCheck; private JCheckBox pasteStopAtProtectedCheck; + private JCheckBox pasteWordBreakCheck; + private JCheckBox trimRectSizingHandlesCheck; + private JCheckBox trimRectRemainAfterEditCheck; + + // Phase 25: Hotspots & Mouse Wheel controls + private JCheckBox hotspotsEnabledCheck; + private JCheckBox hotspotsUrlCheck; + private JCheckBox hotspotsPfCheck; + private JCheckBox hotspotsMenuCheck; + private JCheckBox mouseWheelEnabledCheck; + private JTextField mouseWheelUpField; + private JTextField mouseWheelDownField; // Connection tab private JComboBox startupBehaviorBox; @@ -338,7 +350,17 @@ public class SettingsDialog extends JDialog { JTabbedPane behaviorTabs = new JTabbedPane(); ThemeManager.styleTabbedPane(behaviorTabs); - behaviorTabs.addTab("General", createGeneralBehaviorPanel()); + JPanel generalContent = createGeneralBehaviorPanel(); + JScrollPane generalScroll = new JScrollPane(generalContent); + ThemeManager.styleScrollPane(generalScroll); + generalScroll.setBorder(BorderFactory.createEmptyBorder()); + if (generalScroll.getViewport() != null) { + generalScroll.getViewport().setBackground(generalContent.getBackground()); + } + generalScroll.getVerticalScrollBar().setUnitIncrement(16); + generalScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + + behaviorTabs.addTab("General", generalScroll); behaviorTabs.addTab("Keymap", createKeymapPanel()); panel.add(behaviorTabs, BorderLayout.CENTER); @@ -346,12 +368,13 @@ public class SettingsDialog extends JDialog { } private JPanel createGeneralBehaviorPanel() { - JPanel panel = new JPanel(); + ScrollablePanel panel = new ScrollablePanel(); panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); // Group 1: Typing & Operational Modes JPanel typingGroup = new JPanel(new GridBagLayout()); + typingGroup.setAlignmentX(Component.LEFT_ALIGNMENT); typingGroup.setBorder(BorderFactory.createTitledBorder("Typing & Operational Modes")); GridBagConstraints gbc = new GridBagConstraints(); gbc.insets = new Insets(4, 6, 4, 6); @@ -402,6 +425,7 @@ public class SettingsDialog extends JDialog { // Group 2: Selection & Clipboard JPanel selGroup = new JPanel(new GridBagLayout()); + selGroup.setAlignmentX(Component.LEFT_ALIGNMENT); selGroup.setBorder(BorderFactory.createTitledBorder("Selection & Clipboard")); GridBagConstraints gbcS = new GridBagConstraints(); gbcS.insets = new Insets(4, 6, 4, 6); @@ -430,7 +454,106 @@ public class SettingsDialog extends JDialog { gbcS.gridy = 3; selGroup.add(pasteStopAtProtectedCheck, gbcS); + pasteWordBreakCheck = new JCheckBox("Paste without splitting words across fields", Settings.getPasteWordBreak()); + ThemeManager.styleCheckBox(pasteWordBreakCheck); + gbcS.gridy = 4; + selGroup.add(pasteWordBreakCheck, gbcS); + + trimRectSizingHandlesCheck = new JCheckBox("Trim rectangle sizing handles", Settings.getTrimRectSizingHandles()); + ThemeManager.styleCheckBox(trimRectSizingHandlesCheck); + gbcS.gridy = 5; + selGroup.add(trimRectSizingHandlesCheck, gbcS); + + trimRectRemainAfterEditCheck = new JCheckBox("Trim rectangle remains after cut/copy/paste", Settings.getTrimRectRemainAfterEdit()); + ThemeManager.styleCheckBox(trimRectRemainAfterEditCheck); + gbcS.gridy = 6; + selGroup.add(trimRectRemainAfterEditCheck, gbcS); + panel.add(selGroup); + panel.add(Box.createVerticalStrut(10)); + + // Group 3: Hotspots & Mouse Wheel Navigation (Phase 25) + JPanel hotGroup = new JPanel(new GridBagLayout()); + hotGroup.setAlignmentX(Component.LEFT_ALIGNMENT); + hotGroup.setBorder(BorderFactory.createTitledBorder("Hotspots & Mouse Wheel Navigation")); + GridBagConstraints gbcH = new GridBagConstraints(); + gbcH.insets = new Insets(3, 6, 3, 6); + gbcH.anchor = GridBagConstraints.WEST; + gbcH.fill = GridBagConstraints.HORIZONTAL; + gbcH.gridx = 0; + gbcH.weightx = 1.0; + + hotspotsEnabledCheck = new JCheckBox("Enable Interactive Hotspots (links, function keys, choices)", Settings.getHotspotsEnabled()); + ThemeManager.styleCheckBox(hotspotsEnabledCheck); + gbcH.gridy = 0; + hotGroup.add(hotspotsEnabledCheck, gbcH); + + hotspotsUrlCheck = new JCheckBox("Auto-detect URLs (http://, https://, ftp://, www)", Settings.getHotspotsUrlEnabled()); + ThemeManager.styleCheckBox(hotspotsUrlCheck); + gbcH.gridy = 1; + gbcH.insets = new Insets(2, 24, 2, 6); + hotGroup.add(hotspotsUrlCheck, gbcH); + + hotspotsPfCheck = new JCheckBox("Auto-detect Function keys (PF1..PF24, F1..F24, PA1..PA3, [ENTER])", Settings.getHotspotsPfEnabled()); + ThemeManager.styleCheckBox(hotspotsPfCheck); + gbcH.gridy = 2; + hotGroup.add(hotspotsPfCheck, gbcH); + + hotspotsMenuCheck = new JCheckBox("Auto-detect Menu choices (\"1.\", \"2.\", \"===> 3\")", Settings.getHotspotsMenuChoicesEnabled()); + ThemeManager.styleCheckBox(hotspotsMenuCheck); + gbcH.gridy = 3; + hotGroup.add(hotspotsMenuCheck, gbcH); + + hotspotsEnabledCheck.addActionListener(e -> { + boolean en = hotspotsEnabledCheck.isSelected(); + hotspotsUrlCheck.setEnabled(en); + hotspotsPfCheck.setEnabled(en); + hotspotsMenuCheck.setEnabled(en); + }); + boolean initHotEn = hotspotsEnabledCheck.isSelected(); + hotspotsUrlCheck.setEnabled(initHotEn); + hotspotsPfCheck.setEnabled(initHotEn); + hotspotsMenuCheck.setEnabled(initHotEn); + + mouseWheelEnabledCheck = new JCheckBox("Enable Mouse Wheel Navigation (scroll up/down)", Settings.getMouseWheelEnabled()); + ThemeManager.styleCheckBox(mouseWheelEnabledCheck); + gbcH.gridy = 4; + gbcH.insets = new Insets(6, 6, 3, 6); + hotGroup.add(mouseWheelEnabledCheck, gbcH); + + JPanel wheelKeysPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0)); + wheelKeysPanel.setOpaque(false); + JLabel lblUp = new JLabel("Wheel Up:"); + mouseWheelUpField = new JTextField(Settings.getMouseWheelUpKey(), 6); + ThemeManager.styleTextField(mouseWheelUpField); + JLabel lblDown = new JLabel("Wheel Down:"); + mouseWheelDownField = new JTextField(Settings.getMouseWheelDownKey(), 6); + ThemeManager.styleTextField(mouseWheelDownField); + + wheelKeysPanel.add(lblUp); + wheelKeysPanel.add(mouseWheelUpField); + wheelKeysPanel.add(Box.createHorizontalStrut(12)); + wheelKeysPanel.add(lblDown); + wheelKeysPanel.add(mouseWheelDownField); + + mouseWheelEnabledCheck.addActionListener(e -> { + boolean mwEn = mouseWheelEnabledCheck.isSelected(); + lblUp.setEnabled(mwEn); + mouseWheelUpField.setEnabled(mwEn); + lblDown.setEnabled(mwEn); + mouseWheelDownField.setEnabled(mwEn); + }); + boolean initMwEn = mouseWheelEnabledCheck.isSelected(); + lblUp.setEnabled(initMwEn); + mouseWheelUpField.setEnabled(initMwEn); + lblDown.setEnabled(initMwEn); + mouseWheelDownField.setEnabled(initMwEn); + + gbcH.gridy = 5; + gbcH.insets = new Insets(2, 24, 2, 6); + hotGroup.add(wheelKeysPanel, gbcH); + + panel.add(hotGroup); panel.add(Box.createVerticalGlue()); return panel; @@ -676,14 +799,23 @@ public class SettingsDialog extends JDialog { monoPanel.add(p); } - JPanel bottomPanel = new JPanel(new BorderLayout(8, 0)); + JPanel bottomPanel = new JPanel(); + bottomPanel.setLayout(new BoxLayout(bottomPanel, BoxLayout.Y_AXIS)); bottomPanel.setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6)); - fourColorOverrideCheck = new JCheckBox("Base 4-Color Override mode (3279 green/white/red/turquoise)", Settings.getFourColorOverride()); + fourColorOverrideCheck = new JCheckBox("Base 4 Override Mode (3279)", Settings.getFourColorOverride()); + fourColorOverrideCheck.setToolTipText("Base 4-Color Override mode (3279 green/white/red/turquoise)"); ThemeManager.styleCheckBox(fourColorOverrideCheck); - bottomPanel.add(fourColorOverrideCheck, BorderLayout.WEST); + fourColorOverrideCheck.setAlignmentX(Component.LEFT_ALIGNMENT); + bottomPanel.add(fourColorOverrideCheck); - JButton btnResetColors = new JButton("Reset to Defaults"); + bottomPanel.add(Box.createVerticalStrut(4)); + + JPanel btnRow = new JPanel(new BorderLayout()); + btnRow.setOpaque(false); + btnRow.setAlignmentX(Component.LEFT_ALIGNMENT); + + JButton btnResetColors = new JButton("Restore Defaults"); ThemeManager.styleButton(btnResetColors, ThemeManager.ButtonVariant.DEFAULT); btnResetColors.addActionListener(e -> { for (int i=0; i<16; i++) { @@ -701,7 +833,8 @@ public class SettingsDialog extends JDialog { } JOptionPane.showMessageDialog(main, "Colors reset. Click Apply to save."); }); - bottomPanel.add(btnResetColors, BorderLayout.EAST); + btnRow.add(btnResetColors, BorderLayout.EAST); + bottomPanel.add(btnRow); main.add(hostPanel, BorderLayout.NORTH); main.add(monoPanel, BorderLayout.CENTER); @@ -870,7 +1003,7 @@ public class SettingsDialog extends JDialog { btnPanel.add(btnReset); // Import Keymap — import from .kmp file - JButton btnImportKmp = new JButton("Import Keymap..."); + JButton btnImportKmp = new JButton("Import .kmp..."); ThemeManager.styleButton(btnImportKmp, ThemeManager.ButtonVariant.DEFAULT); btnImportKmp.addActionListener(e -> { JFileChooser chooser = new JFileChooser(); @@ -908,6 +1041,36 @@ public class SettingsDialog extends JDialog { }); btnPanel.add(btnImportKmp); + // Export Keymap — export to .kmp file + JButton btnExportKmp = new JButton("Export .kmp..."); + ThemeManager.styleButton(btnExportKmp, ThemeManager.ButtonVariant.DEFAULT); + btnExportKmp.addActionListener(e -> { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Export Keymap Profile"); + chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("Keyboard Map Profile (*.kmp)", "kmp")); + chooser.setSelectedFile(new File("User.kmp")); + int ret = chooser.showSaveDialog(this); + if (ret == JFileChooser.APPROVE_OPTION) { + File f = chooser.getSelectedFile(); + if (!f.getName().toLowerCase().endsWith(".kmp")) { + f = new File(f.getParentFile(), f.getName() + ".kmp"); + } + try { + KeyBindings.exportKmp(f, tempKeyBindings); + JOptionPane.showMessageDialog(this, + "Successfully exported key bindings to " + f.getName(), + "Keymap Exported", + JOptionPane.INFORMATION_MESSAGE); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, + "Failed to export keymap: " + ex.getMessage(), + "Export Error", + JOptionPane.ERROR_MESSAGE); + } + } + }); + btnPanel.add(btnExportKmp); + main.add(new JScrollPane(table), BorderLayout.CENTER); main.add(btnPanel, BorderLayout.SOUTH); @@ -987,6 +1150,36 @@ public class SettingsDialog extends JDialog { if (pasteStopAtProtectedCheck != null) { Settings.setPasteStopAtProtectedLine(pasteStopAtProtectedCheck.isSelected()); } + if (pasteWordBreakCheck != null) { + Settings.setPasteWordBreak(pasteWordBreakCheck.isSelected()); + } + if (trimRectSizingHandlesCheck != null) { + Settings.setTrimRectSizingHandles(trimRectSizingHandlesCheck.isSelected()); + } + if (trimRectRemainAfterEditCheck != null) { + Settings.setTrimRectRemainAfterEdit(trimRectRemainAfterEditCheck.isSelected()); + } + if (hotspotsEnabledCheck != null) { + Settings.setHotspotsEnabled(hotspotsEnabledCheck.isSelected()); + } + if (hotspotsUrlCheck != null) { + Settings.setHotspotsUrlEnabled(hotspotsUrlCheck.isSelected()); + } + if (hotspotsPfCheck != null) { + Settings.setHotspotsPfEnabled(hotspotsPfCheck.isSelected()); + } + if (hotspotsMenuCheck != null) { + Settings.setHotspotsMenuChoicesEnabled(hotspotsMenuCheck.isSelected()); + } + if (mouseWheelEnabledCheck != null) { + Settings.setMouseWheelEnabled(mouseWheelEnabledCheck.isSelected()); + } + if (mouseWheelUpField != null) { + Settings.setMouseWheelUpKey(mouseWheelUpField.getText().trim()); + } + if (mouseWheelDownField != null) { + Settings.setMouseWheelDownKey(mouseWheelDownField.getText().trim()); + } // Apply Connection if (startupBehaviorBox != null) { @@ -1133,6 +1326,43 @@ public class SettingsDialog extends JDialog { if (pasteStopAtProtectedCheck != null) { pasteStopAtProtectedCheck.setSelected(Settings.getPasteStopAtProtectedLine()); } + if (pasteWordBreakCheck != null) { + pasteWordBreakCheck.setSelected(Settings.getPasteWordBreak()); + } + if (trimRectSizingHandlesCheck != null) { + trimRectSizingHandlesCheck.setSelected(Settings.getTrimRectSizingHandles()); + } + if (trimRectRemainAfterEditCheck != null) { + trimRectRemainAfterEditCheck.setSelected(Settings.getTrimRectRemainAfterEdit()); + } + if (hotspotsEnabledCheck != null) { + hotspotsEnabledCheck.setSelected(Settings.getHotspotsEnabled()); + boolean en = Settings.getHotspotsEnabled(); + if (hotspotsUrlCheck != null) { + hotspotsUrlCheck.setSelected(Settings.getHotspotsUrlEnabled()); + hotspotsUrlCheck.setEnabled(en); + } + if (hotspotsPfCheck != null) { + hotspotsPfCheck.setSelected(Settings.getHotspotsPfEnabled()); + hotspotsPfCheck.setEnabled(en); + } + if (hotspotsMenuCheck != null) { + hotspotsMenuCheck.setSelected(Settings.getHotspotsMenuChoicesEnabled()); + hotspotsMenuCheck.setEnabled(en); + } + } + if (mouseWheelEnabledCheck != null) { + boolean mwEn = Settings.getMouseWheelEnabled(); + mouseWheelEnabledCheck.setSelected(mwEn); + if (mouseWheelUpField != null) { + mouseWheelUpField.setText(Settings.getMouseWheelUpKey()); + mouseWheelUpField.setEnabled(mwEn); + } + if (mouseWheelDownField != null) { + mouseWheelDownField.setText(Settings.getMouseWheelDownKey()); + mouseWheelDownField.setEnabled(mwEn); + } + } // Connection if (startupBehaviorBox != null) { @@ -1302,4 +1532,39 @@ public class SettingsDialog extends JDialog { } } } + + private static class ScrollablePanel extends JPanel implements Scrollable { + public ScrollablePanel() { + super(); + } + + public ScrollablePanel(LayoutManager layout) { + super(layout); + } + + @Override + public Dimension getPreferredScrollableViewportSize() { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) { + return 16; + } + + @Override + public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) { + return 48; + } + + @Override + public boolean getScrollableTracksViewportWidth() { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() { + return false; + } + } } 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 c7a4453..52d1bd7 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java @@ -77,6 +77,15 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private boolean isActivationClick = false; private static final Color SELECTION_COLOR = new Color(75, 110, 175, 102); // 40% alpha blend + public enum DragHandle { + NONE, NW, N, NE, E, SE, S, SW, W, MOVE + } + private boolean trimRectSizingHandles = false; + private DragHandle activeDragHandle = DragHandle.NONE; + private int dragAnchorRow = -1; + private int dragAnchorCol = -1; + private int initialBoxR1 = -1, initialBoxR2 = -1, initialBoxC1 = -1, initialBoxC2 = -1; + // ========== Search Highlight state ========== private int searchHighlightAddr = -1; private int searchHighlightLen = 0; @@ -89,6 +98,14 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private boolean resizeGuard = false; private StatusBar statusBar = null; + // ========== Phase 25: Hotspots & Mouse Wheel ========== + private final HotspotEngine hotspotEngine = new HotspotEngine(); + private HotspotEngine.Hotspot hoveredHotspot = null; + private boolean hotspotsEnabled = haus.nightmare.j3270.config.Settings.getHotspotsEnabled(); + private boolean mouseWheelEnabled = haus.nightmare.j3270.config.Settings.getMouseWheelEnabled(); + private String mouseWheelUpAction = haus.nightmare.j3270.config.Settings.getMouseWheelUpKey(); + private String mouseWheelDownAction = haus.nightmare.j3270.config.Settings.getMouseWheelDownKey(); + // ========== Mode and Listener State ========== private boolean insertMode = false; @@ -179,6 +196,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { setupCursorBlink(); blockSelectMode = haus.nightmare.j3270.config.Settings.getBlockSelectMode(); + trimRectSizingHandles = haus.nightmare.j3270.config.Settings.getTrimRectSizingHandles(); crosshairRulerEnabled = haus.nightmare.j3270.config.Settings.getCrosshairRuler(); String cStyle = haus.nightmare.j3270.config.Settings.getCursorStyle(); cursorStyle = "UNDERLINE".equalsIgnoreCase(cStyle) ? CursorStyle.UNDERLINE : CursorStyle.BLOCK; @@ -218,7 +236,28 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { boolean isGraphic = client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive(); if (isGraphic || lightPenMode) { clearSelection(); + } else if (SwingUtilities.isLeftMouseButton(e) && trimRectSizingHandles && hasSelection()) { + DragHandle handle = getDragHandleAt(e.getX(), e.getY()); + if (handle != DragHandle.NONE) { + activeDragHandle = handle; + isDragging = true; + dragAnchorRow = row; + dragAnchorCol = col; + initialBoxR1 = Math.min(selectionStartRow, selectionEndRow); + initialBoxR2 = Math.max(selectionStartRow, selectionEndRow); + initialBoxC1 = Math.min(selectionStartCol, selectionEndCol); + initialBoxC2 = Math.max(selectionStartCol, selectionEndCol); + repaint(); + return; + } else { + activeDragHandle = DragHandle.NONE; + selectionStartRow = row; + selectionStartCol = col; + selectionEndRow = row; + selectionEndCol = col; + } } else { + activeDragHandle = DragHandle.NONE; selectionStartRow = row; selectionStartCol = col; selectionEndRow = row; @@ -252,8 +291,43 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { col = Math.max(0, Math.min(col, sb.getDisplayCols() - 1)); row = Math.max(0, Math.min(row, sb.getDisplayRows() - 1)); - selectionEndRow = row; - selectionEndCol = col; + if (activeDragHandle != DragHandle.NONE) { + if (activeDragHandle == DragHandle.MOVE) { + int deltaR = row - dragAnchorRow; + int deltaC = col - dragAnchorCol; + int h = initialBoxR2 - initialBoxR1; + int w = initialBoxC2 - initialBoxC1; + int newR1 = Math.max(0, Math.min(initialBoxR1 + deltaR, sb.getDisplayRows() - 1 - h)); + int newC1 = Math.max(0, Math.min(initialBoxC1 + deltaC, sb.getDisplayCols() - 1 - w)); + selectionStartRow = newR1; + selectionStartCol = newC1; + selectionEndRow = newR1 + h; + selectionEndCol = newC1 + w; + } else { + int newR1 = initialBoxR1; + int newR2 = initialBoxR2; + int newC1 = initialBoxC1; + int newC2 = initialBoxC2; + switch (activeDragHandle) { + case NW: newR1 = row; newC1 = col; break; + case N: newR1 = row; break; + case NE: newR1 = row; newC2 = col; break; + case E: newC2 = col; break; + case SE: newR2 = row; newC2 = col; break; + case S: newR2 = row; break; + case SW: newR2 = row; newC1 = col; break; + case W: newC1 = col; break; + default: break; + } + selectionStartRow = Math.min(newR1, newR2); + selectionEndRow = Math.max(newR1, newR2); + selectionStartCol = Math.min(newC1, newC2); + selectionEndCol = Math.max(newC1, newC2); + } + } else { + selectionEndRow = row; + selectionEndCol = col; + } } repaint(); } @@ -275,12 +349,72 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { py = Math.max(0, Math.min(py, gHeight - 1)); client.getGocaDecoder().setGraphicCursorFromPixel(px, py); } + + // Phase 26.1: Drag handle cursor hover detection + if (trimRectSizingHandles && hasSelection() && client != null && client.getConnectionState().isFullSession()) { + DragHandle handle = getDragHandleAt(e.getX(), e.getY()); + if (handle != DragHandle.NONE) { + setCursor(getCursorForDragHandle(handle)); + setToolTipText(null); + return; + } + } + + // Phase 25.2: Real-time hotspot hover detection + if (hotspotsEnabled && client != null && client.getScreenBuffer() != null) { + int ox = getRenderOffsetX(); + int oy = getRenderOffsetY(); + ScreenBuffer sb = client.getScreenBuffer(); + int rows = sb.getDisplayRows(); + int cols = sb.getDisplayCols(); + int col = (e.getX() - ox) / cellWidth; + int row = (e.getY() - oy) / cellHeight; + if (row >= 0 && row < rows && col >= 0 && col < cols) { + hotspotEngine.scan(sb); + HotspotEngine.Hotspot h = hotspotEngine.findHotspot(row, col); + if (h != null) { + if (hoveredHotspot != h) { + hoveredHotspot = h; + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setToolTipText(h.getTooltip()); + repaint(); + } + return; + } + } + } + if (hoveredHotspot != null) { + hoveredHotspot = null; + setCursor(Cursor.getDefaultCursor()); + setToolTipText(null); + repaint(); + } else if (activeDragHandle == DragHandle.NONE) { + setCursor(Cursor.getDefaultCursor()); + } + } + + @Override + public void mouseExited(MouseEvent e) { + if (hoveredHotspot != null) { + hoveredHotspot = null; + setCursor(Cursor.getDefaultCursor()); + setToolTipText(null); + repaint(); + } else if (activeDragHandle == DragHandle.NONE) { + setCursor(Cursor.getDefaultCursor()); + } } @Override public void mouseReleased(MouseEvent e) { if (isDragging) { isDragging = false; + if (activeDragHandle != DragHandle.NONE) { + activeDragHandle = DragHandle.NONE; + isActivationClick = false; + repaint(); + return; + } if (client != null && client.getConnectionState().isFullSession()) { ScreenBuffer sb = client.getScreenBuffer(); int ox = getRenderOffsetX(); @@ -346,6 +480,21 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { return; } + // Phase 25.2: Interactive Hotspot click execution (URLs, PF keys, choices) + if (hotspotsEnabled && client.getConnectionState().isFullSession()) { + HotspotEngine.Hotspot h = hotspotEngine.findHotspot(row, col); + if (h != null) { + if (h.getType() == HotspotEngine.HotspotType.URL) { + openUrl(h.getAction()); + return; + } else { + client.getPS().SendKeys(h.getAction()); + refreshScreen(); + return; + } + } + } + // Automatic Light-Pen / Selectable Field detection on mouse click (HoD parity): if (sb.isFormatted()) { int faPos = sb.findFieldAttribute(clickAddr); @@ -397,6 +546,21 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { addMouseListener(mouseHandler); addMouseMotionListener(mouseHandler); + // Phase 25.2: Mouse wheel navigation (Wheel Up -> [pf7], Wheel Down -> [pf8]) + addMouseWheelListener(e -> { + if (!mouseWheelEnabled || client == null || !client.getConnectionState().isFullSession()) { + return; + } + int rotation = e.getWheelRotation(); + if (rotation < 0) { + client.getPS().SendKeys(mouseWheelUpAction); + refreshScreen(); + } else if (rotation > 0) { + client.getPS().SendKeys(mouseWheelDownAction); + refreshScreen(); + } + }); + // Handle resize: keep user configured font persistent without auto-fit reset addComponentListener(new ComponentAdapter() { @Override @@ -407,8 +571,104 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { }); } + // ========== Phase 25: Hotspot URL Opener ========== + + private void openUrl(String url) { + if (url == null || url.trim().isEmpty()) return; + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(new java.net.URI(url)); + return; + } + } catch (Exception ignored) { + } + // Fallback: command line launcher + try { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + Runtime.getRuntime().exec(new String[]{"rundll32", "url.dll,FileProtocolHandler", url}); + } else if (os.contains("mac")) { + Runtime.getRuntime().exec(new String[]{"open", url}); + } else { + Runtime.getRuntime().exec(new String[]{"xdg-open", url}); + } + } catch (Exception ex) { + Toolkit.getDefaultToolkit().beep(); + } + } + + public HotspotEngine getHotspotEngine() { + return hotspotEngine; + } + // ========== Selection / Copy-Paste ========== + public boolean isBlockSelectMode() { + return blockSelectMode; + } + + public void setBlockSelectMode(boolean val) { + this.blockSelectMode = val; + repaint(); + } + + public boolean isTrimRectSizingHandles() { + return trimRectSizingHandles; + } + + public void setTrimRectSizingHandles(boolean val) { + this.trimRectSizingHandles = val; + repaint(); + } + + public DragHandle getDragHandleAt(int mx, int my) { + if (!hasSelection() || client == null) return DragHandle.NONE; + int ox = getRenderOffsetX(); + int oy = getRenderOffsetY(); + int r1 = Math.min(selectionStartRow, selectionEndRow); + int r2 = Math.max(selectionStartRow, selectionEndRow); + int c1 = Math.min(selectionStartCol, selectionEndCol); + int c2 = Math.max(selectionStartCol, selectionEndCol); + + int x1 = ox + c1 * cellWidth; + int y1 = oy + r1 * cellHeight; + int x2 = ox + (c2 + 1) * cellWidth; + int y2 = oy + (r2 + 1) * cellHeight; + int xm = (x1 + x2) / 2; + int ym = (y1 + y2) / 2; + + int tol = 6; + if (Math.abs(mx - x1) <= tol && Math.abs(my - y1) <= tol) return DragHandle.NW; + if (Math.abs(mx - xm) <= tol && Math.abs(my - y1) <= tol) return DragHandle.N; + if (Math.abs(mx - x2) <= tol && Math.abs(my - y1) <= tol) return DragHandle.NE; + if (Math.abs(mx - x2) <= tol && Math.abs(my - ym) <= tol) return DragHandle.E; + if (Math.abs(mx - x2) <= tol && Math.abs(my - y2) <= tol) return DragHandle.SE; + if (Math.abs(mx - xm) <= tol && Math.abs(my - y2) <= tol) return DragHandle.S; + if (Math.abs(mx - x1) <= tol && Math.abs(my - y2) <= tol) return DragHandle.SW; + if (Math.abs(mx - x1) <= tol && Math.abs(my - ym) <= tol) return DragHandle.W; + + if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) { + return DragHandle.MOVE; + } + + return DragHandle.NONE; + } + + public Cursor getCursorForDragHandle(DragHandle handle) { + switch (handle) { + case NW: return Cursor.getPredefinedCursor(Cursor.NW_RESIZE_CURSOR); + case N: return Cursor.getPredefinedCursor(Cursor.N_RESIZE_CURSOR); + case NE: return Cursor.getPredefinedCursor(Cursor.NE_RESIZE_CURSOR); + case E: return Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR); + case SE: return Cursor.getPredefinedCursor(Cursor.SE_RESIZE_CURSOR); + case S: return Cursor.getPredefinedCursor(Cursor.S_RESIZE_CURSOR); + case SW: return Cursor.getPredefinedCursor(Cursor.SW_RESIZE_CURSOR); + case W: return Cursor.getPredefinedCursor(Cursor.W_RESIZE_CURSOR); + case MOVE: return Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR); + default: return Cursor.getDefaultCursor(); + } + } + public void clearSelection() { selectionStartRow = -1; selectionStartCol = -1; @@ -454,23 +714,30 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { int r2 = Math.max(selectionStartRow, selectionEndRow); int c1, c2; - if (blockSelectMode) { + if (blockSelectMode || trimRectSizingHandles) { c1 = Math.min(selectionStartCol, selectionEndCol); c2 = Math.max(selectionStartCol, selectionEndCol); StringBuilder result = new StringBuilder(); for (int r = r1; r <= r2; r++) { if (r > r1) result.append('\n'); + StringBuilder line = new StringBuilder(); for (int c = c1; c <= c2; c++) { int baddr = r * cols + c; ExtendedAttribute ea = sb.getCell(baddr); char ch = ea.ucs4; if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) { - result.append(' '); + line.append(' '); } else { - result.append(ch); + line.append(ch); } } + // Trim trailing whitespace on copied lines per HoD CutPasteOptions + int end = line.length(); + while (end > 0 && line.charAt(end - 1) == ' ') { + end--; + } + result.append(line.substring(0, end)); } return result.toString(); } else { @@ -485,21 +752,34 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { } StringBuilder result = new StringBuilder(); + StringBuilder line = new StringBuilder(); int prevRow = startAddr / cols; for (int addr = startAddr; addr <= endAddr; addr++) { int curRow = addr / cols; if (curRow != prevRow) { - result.append('\n'); + int end = line.length(); + while (end > 0 && line.charAt(end - 1) == ' ') { + end--; + } + if (result.length() > 0) result.append('\n'); + result.append(line.substring(0, end)); + line.setLength(0); prevRow = curRow; } ExtendedAttribute ea = sb.getCell(addr); char ch = ea.ucs4; if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) { - result.append(' '); + line.append(' '); } else { - result.append(ch); + line.append(ch); } } + int end = line.length(); + while (end > 0 && line.charAt(end - 1) == ' ') { + end--; + } + if (result.length() > 0) result.append('\n'); + result.append(line.substring(0, end)); return result.toString(); } } @@ -510,6 +790,65 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { StringSelection ss = new StringSelection(text); Toolkit.getDefaultToolkit().getSystemClipboard().setContents(ss, null); } + if (!haus.nightmare.j3270.config.Settings.getTrimRectRemainAfterEdit()) { + clearSelection(); + repaint(); + } + } + + public void cutSelection() { + if (!hasSelection() || client == null || !client.getConnectionState().isFullSession()) return; + String text = getSelectedText(); + if (!text.isEmpty()) { + StringSelection ss = new StringSelection(text); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(ss, null); + } + ScreenBuffer sb = client.getScreenBuffer(); + int cols = sb.getCols(); + int r1 = Math.min(selectionStartRow, selectionEndRow); + int r2 = Math.max(selectionStartRow, selectionEndRow); + int c1 = Math.min(selectionStartCol, selectionEndCol); + int c2 = Math.max(selectionStartCol, selectionEndCol); + + if (blockSelectMode || trimRectSizingHandles) { + for (int r = r1; r <= r2; r++) { + for (int c = c1; c <= c2; c++) { + int baddr = r * cols + c; + if (!sb.isProtected(baddr) && !sb.getCell(baddr).isFieldAttribute()) { + ExtendedAttribute ea = sb.getCell(baddr); + ea.ucs4 = ' '; + ea.ec = (byte) 0x40; + if (sb.isFormatted()) { + int faAddr = sb.findFieldAttribute(baddr); + if (faAddr >= 0) { + sb.getCell(faAddr).fa = (byte) (sb.getCell(faAddr).fa | haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY); + } + } + } + } + } + } else { + int startAddr = Math.min(selectionStartRow * cols + selectionStartCol, selectionEndRow * cols + selectionEndCol); + int endAddr = Math.max(selectionStartRow * cols + selectionStartCol, selectionEndRow * cols + selectionEndCol); + for (int baddr = startAddr; baddr <= endAddr; baddr++) { + if (!sb.isProtected(baddr) && !sb.getCell(baddr).isFieldAttribute()) { + ExtendedAttribute ea = sb.getCell(baddr); + ea.ucs4 = ' '; + ea.ec = (byte) 0x40; + if (sb.isFormatted()) { + int faAddr = sb.findFieldAttribute(baddr); + if (faAddr >= 0) { + sb.getCell(faAddr).fa = (byte) (sb.getCell(faAddr).fa | haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY); + } + } + } + } + } + sb.updateDisplaySnapshot(); + if (!haus.nightmare.j3270.config.Settings.getTrimRectRemainAfterEdit()) { + clearSelection(); + } + refreshScreen(); } public void pasteClipboard() { @@ -536,10 +875,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { boolean excelPaste = haus.nightmare.j3270.config.Settings.getEnablePasteFromExcel(); boolean stopAtProtected = haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine(); boolean blockPaste = haus.nightmare.j3270.config.Settings.getBlockPaste(); + boolean wordBreak = haus.nightmare.j3270.config.Settings.getPasteWordBreak(); - if (client.getInputProcessor() != null && (excelPaste || stopAtProtected || blockPaste || text.contains("\t") || text.contains("\n") || text.contains("\r"))) { - client.getInputProcessor().pasteText(text, excelPaste, stopAtProtected, blockPaste); + if (client.getInputProcessor() != null && (excelPaste || stopAtProtected || blockPaste || wordBreak || text.contains("\t") || text.contains("\n") || text.contains("\r"))) { + client.getInputProcessor().pasteText(text, excelPaste, stopAtProtected, blockPaste, wordBreak); } else if (client.getPS() != null) { + client.getPS().setPasteWordBreak(wordBreak); if (blockPaste) { client.getPS().pasteRectangular(text, curRow, curCol); } else { @@ -555,6 +896,9 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { } } } + if (!haus.nightmare.j3270.config.Settings.getTrimRectRemainAfterEdit() && hasSelection()) { + clearSelection(); + } refreshScreen(); } } catch (Exception ignored) {} @@ -592,6 +936,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { JPopupMenu popup = new JPopupMenu(); ThemeManager.stylePopupMenu(popup); + JMenuItem cutItem = new JMenuItem("Cut"); + ThemeManager.styleMenuItem(cutItem); + cutItem.setEnabled(hasSelection() && client != null && client.getConnectionState().isFullSession()); + cutItem.addActionListener(ev -> cutSelection()); + popup.add(cutItem); + JMenuItem copyItem = new JMenuItem("Copy"); ThemeManager.styleMenuItem(copyItem); copyItem.setEnabled(hasSelection()); @@ -627,6 +977,15 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { }); popup.add(blockPasteItem); + JCheckBoxMenuItem handlesItem = new JCheckBoxMenuItem("Trim Sizing Handles", trimRectSizingHandles); + ThemeManager.styleMenuItem(handlesItem); + handlesItem.addActionListener(ev -> { + trimRectSizingHandles = handlesItem.isSelected(); + haus.nightmare.j3270.config.Settings.setTrimRectSizingHandles(trimRectSizingHandles); + repaint(); + }); + popup.add(handlesItem); + popup.show(this, e.getX(), e.getY()); } @@ -636,7 +995,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { int r1 = Math.min(selectionStartRow, selectionEndRow); int r2 = Math.max(selectionStartRow, selectionEndRow); - if (blockSelectMode) { + if (blockSelectMode || trimRectSizingHandles) { int c1 = Math.min(selectionStartCol, selectionEndCol); int c2 = Math.max(selectionStartCol, selectionEndCol); return row >= r1 && row <= r2 && col >= c1 && col <= c2; @@ -738,9 +1097,11 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { bindKeyToMap(im, "APL", haus.nightmare.j3270.config.Settings.getKeyBinding("APL", "alt F3")); // Copy/Paste/Lightpen bindings + String defCut = haus.nightmare.j3270.config.Settings.isMac() ? "meta X, ctrl X" : "ctrl X, shift DELETE"; String defCopy = haus.nightmare.j3270.config.Settings.getDefaultBinding("COPY"); String defPaste = haus.nightmare.j3270.config.Settings.getDefaultBinding("PASTE"); String defSelectAll = haus.nightmare.j3270.config.Settings.getDefaultBinding("SELECTALL"); + bindKeyToMap(im, "CUT", haus.nightmare.j3270.config.Settings.getKeyBinding("CUT", defCut)); bindKeyToMap(im, "COPY", haus.nightmare.j3270.config.Settings.getKeyBinding("COPY", defCopy)); bindKeyToMap(im, "PASTE", haus.nightmare.j3270.config.Settings.getKeyBinding("PASTE", defPaste)); bindKeyToMap(im, "SELECTALL", haus.nightmare.j3270.config.Settings.getKeyBinding("SELECTALL", defSelectAll)); @@ -777,6 +1138,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { am.put("j3270-SYSREQ", createAction(this::handleSysReq)); am.put("j3270-CURSEL", createAction(this::handleCursorSelect)); + am.put("j3270-CUT", createAction(this::cutSelection)); am.put("j3270-COPY", createAction(this::copySelection)); am.put("j3270-PASTE", createAction(this::pasteClipboard)); am.put("j3270-SELECTALL", createAction(this::selectAll)); @@ -1292,11 +1654,23 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { setupFont(); applyModeSettings(); blockSelectMode = haus.nightmare.j3270.config.Settings.getBlockSelectMode(); + trimRectSizingHandles = haus.nightmare.j3270.config.Settings.getTrimRectSizingHandles(); crosshairRulerEnabled = haus.nightmare.j3270.config.Settings.getCrosshairRuler(); inputMaskEnabled = haus.nightmare.j3270.config.Settings.getInputMask(); inputMaskChar = haus.nightmare.j3270.config.Settings.getInputMaskChar(); String cStyle = haus.nightmare.j3270.config.Settings.getCursorStyle(); cursorStyle = "UNDERLINE".equalsIgnoreCase(cStyle) ? CursorStyle.UNDERLINE : CursorStyle.BLOCK; + + // Phase 25: Reload Hotspots and Mouse Wheel preferences + hotspotsEnabled = haus.nightmare.j3270.config.Settings.getHotspotsEnabled(); + hotspotEngine.setUrlEnabled(haus.nightmare.j3270.config.Settings.getHotspotsUrlEnabled()); + hotspotEngine.setPfKeyEnabled(haus.nightmare.j3270.config.Settings.getHotspotsPfEnabled()); + hotspotEngine.setMenuChoiceEnabled(haus.nightmare.j3270.config.Settings.getHotspotsMenuChoicesEnabled()); + hotspotEngine.invalidateCache(); + mouseWheelEnabled = haus.nightmare.j3270.config.Settings.getMouseWheelEnabled(); + mouseWheelUpAction = haus.nightmare.j3270.config.Settings.getMouseWheelUpKey(); + mouseWheelDownAction = haus.nightmare.j3270.config.Settings.getMouseWheelDownKey(); + revalidate(); repaint(); } @@ -1725,6 +2099,63 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { } } + // Phase 26.1: Rectangular Block Selection Sizing Handles & Box Outline + if (trimRectSizingHandles && hasSelection() && client != null && client.getConnectionState().isFullSession()) { + int r1 = Math.min(selectionStartRow, selectionEndRow); + int r2 = Math.max(selectionStartRow, selectionEndRow); + int c1 = Math.min(selectionStartCol, selectionEndCol); + int c2 = Math.max(selectionStartCol, selectionEndCol); + + int x1 = ox + c1 * cellWidth; + int y1 = oy + r1 * cellHeight; + int x2 = ox + (c2 + 1) * cellWidth; + int y2 = oy + (r2 + 1) * cellHeight; + int w = x2 - x1; + int h = y2 - y1; + int xm = (x1 + x2) / 2; + int ym = (y1 + y2) / 2; + + Color oldColor = g2.getColor(); + Stroke oldStroke = g2.getStroke(); + + g2.setColor(Color.YELLOW); + g2.drawRect(x1, y1, w, h); + + drawSizingHandle(g2, x1, y1); + drawSizingHandle(g2, xm, y1); + drawSizingHandle(g2, x2, y1); + drawSizingHandle(g2, x2, ym); + drawSizingHandle(g2, x2, y2); + drawSizingHandle(g2, xm, y2); + drawSizingHandle(g2, x1, y2); + drawSizingHandle(g2, x1, ym); + + g2.setStroke(oldStroke); + g2.setColor(oldColor); + } + + // Phase 25.2: Draw visual underline on hovered hotspot + if (hotspotsEnabled && hoveredHotspot != null) { + Color oldColor = g2.getColor(); + Stroke oldStroke = g2.getStroke(); + g2.setColor(new Color(0, 220, 255)); // Bright cyan hotspot underline + g2.setStroke(new BasicStroke(2.0f)); + + int hr0 = hoveredHotspot.getStartRow(); + int hr1 = hoveredHotspot.getEndRow(); + for (int r = hr0; r <= hr1; r++) { + int c0 = (r == hr0) ? hoveredHotspot.getStartCol() : 0; + int c1 = (r == hr1) ? hoveredHotspot.getEndCol() : cols - 1; + int x1 = ox + c0 * cellWidth; + int x2 = ox + (c1 + 1) * cellWidth; + int ulY = oy + r * cellHeight + fontAscent + Math.max(0, (cellHeight - (fontAscent + fontDescent)) / 2) + 2; + ulY = Math.min(oy + (r + 1) * cellHeight - 1, ulY); + g2.drawLine(x1, ulY, x2 - 1, ulY); + } + g2.setStroke(oldStroke); + g2.setColor(oldColor); + } + // Draw Graphic Cursor if active boolean isGraphicCursor = (client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive()) || (client.getGraphicsPlane() != null && client.getGraphicsPlane().isGraphicCursorAttached()); @@ -1929,6 +2360,13 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { } } + private void drawSizingHandle(Graphics2D g2, int cx, int cy) { + g2.setColor(Color.YELLOW); + g2.fillRect(cx - 2, cy - 2, 5, 5); + g2.setColor(Color.BLACK); + g2.drawRect(cx - 2, cy - 2, 5, 5); + } + public void dispose() { if (blinkTimer != null) blinkTimer.stop(); diff --git a/j3270/src/test/java/haus/nightmare/j3270/ui/HotspotEngineTest.java b/j3270/src/test/java/haus/nightmare/j3270/ui/HotspotEngineTest.java new file mode 100644 index 0000000..88e6b10 --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/ui/HotspotEngineTest.java @@ -0,0 +1,192 @@ +package haus.nightmare.j3270.ui; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for HotspotEngine (Phase 25.2). + * Verifies regex detection of URLs, PF keys, Enter, and menu choices, + * hit-testing, action strings, and word boundary isolation. + */ +public class HotspotEngineTest { + + private HotspotEngine engine; + + @BeforeEach + public void setUp() { + engine = new HotspotEngine(); + } + + @Test + @DisplayName("Detects HTTP, HTTPS, FTP, and WWW URLs with action strings") + public void testUrlDetection() { + String screen = "Visit https://ibm.com/support or http://example.org:8080/path?q=1 for help.\n" + + "Download from ftp://ftp.archive.org/pub/files or browse www.google.com."; + List hotspots = engine.scanText(screen, 2, 80); + + assertEquals(4, hotspots.size()); + + HotspotEngine.Hotspot h0 = hotspots.get(0); + assertEquals(HotspotEngine.HotspotType.URL, h0.getType()); + assertEquals("https://ibm.com/support", h0.getDisplayText()); + assertEquals("https://ibm.com/support", h0.getAction()); + assertEquals(0, h0.getStartRow()); + assertEquals(6, h0.getStartCol()); + assertTrue(h0.contains(0, 10)); + assertFalse(h0.contains(0, 5)); + + HotspotEngine.Hotspot h1 = hotspots.get(1); + assertEquals("http://example.org:8080/path?q=1", h1.getDisplayText()); + assertEquals("http://example.org:8080/path?q=1", h1.getAction()); + + HotspotEngine.Hotspot h2 = hotspots.get(2); + assertEquals("ftp://ftp.archive.org/pub/files", h2.getDisplayText()); + assertEquals("ftp://ftp.archive.org/pub/files", h2.getAction()); + + HotspotEngine.Hotspot h3 = hotspots.get(3); + assertEquals("www.google.com", h3.getDisplayText()); + assertEquals("http://www.google.com", h3.getAction(), "www. URLs should have http:// prepended for action"); + } + + @Test + @DisplayName("Detects PF1..PF24, F1..F24, PA1..PA3, and [ENTER] function keys") + public void testFunctionKeyDetection() { + String screen = "PF1=Help PF3=Exit PF7=Up PF8=Down PF12=Cancel\n" + + "F1=Help F12=Cancel PA1=Abort [ENTER]=Continue"; + List hotspots = engine.scanText(screen, 2, 80); + + assertEquals(9, hotspots.size()); + + // PF1 + assertEquals(HotspotEngine.HotspotType.PF_KEY, hotspots.get(0).getType()); + assertEquals("PF1", hotspots.get(0).getDisplayText()); + assertEquals("[pf1]", hotspots.get(0).getAction()); + + // PF3 + assertEquals("PF3", hotspots.get(1).getDisplayText()); + assertEquals("[pf3]", hotspots.get(1).getAction()); + + // PF7 + assertEquals("PF7", hotspots.get(2).getDisplayText()); + assertEquals("[pf7]", hotspots.get(2).getAction()); + + // PF8 + assertEquals("PF8", hotspots.get(3).getDisplayText()); + assertEquals("[pf8]", hotspots.get(3).getAction()); + + // PF12 + assertEquals("PF12", hotspots.get(4).getDisplayText()); + assertEquals("[pf12]", hotspots.get(4).getAction()); + + // F1 -> [pf1] + assertEquals("F1", hotspots.get(5).getDisplayText()); + assertEquals("[pf1]", hotspots.get(5).getAction()); + + // F12 -> [pf12] + assertEquals("F12", hotspots.get(6).getDisplayText()); + assertEquals("[pf12]", hotspots.get(6).getAction()); + + // PA1 -> [pa1] + assertEquals(HotspotEngine.HotspotType.PF_KEY, hotspots.get(7).getType()); + assertEquals("PA1", hotspots.get(7).getDisplayText()); + assertEquals("[pa1]", hotspots.get(7).getAction()); + + // [ENTER] -> [enter] + assertEquals(HotspotEngine.HotspotType.ENTER, hotspots.get(8).getType()); + assertEquals("[ENTER]", hotspots.get(8).getDisplayText()); + assertEquals("[enter]", hotspots.get(8).getAction()); + } + + @Test + @DisplayName("Isolates function keys with strict word boundaries to avoid false positives") + public void testWordBoundaryIsolation() { + String screen = "STAFF1 MEMBER SPF12 FILE TRANSFER PROMPT"; + List hotspots = engine.scanText(screen, 1, 80); + + // Neither STAFF1 nor SPF12 nor TRANSFER should be detected as PF1, F1, or F12 + assertEquals(0, hotspots.size(), "Embedded letters in words should not trigger PF key hotspots"); + } + + @Test + @DisplayName("Detects numbered menu choices and arrow prompts") + public void testMenuChoiceDetection() { + String screen = " 1. User Settings\n" + + " 2. Session Profiles\n" + + "===> 3"; + List hotspots = engine.scanText(screen, 3, 80); + + assertEquals(3, hotspots.size()); + + HotspotEngine.Hotspot m1 = hotspots.get(0); + assertEquals(HotspotEngine.HotspotType.MENU_CHOICE, m1.getType()); + assertEquals("1.", m1.getDisplayText()); + assertEquals("1[enter]", m1.getAction()); + assertEquals(0, m1.getStartRow()); + + HotspotEngine.Hotspot m2 = hotspots.get(1); + assertEquals("2.", m2.getDisplayText()); + assertEquals("2[enter]", m2.getAction()); + assertEquals(1, m2.getStartRow()); + + HotspotEngine.Hotspot m3 = hotspots.get(2); + assertEquals("===> 3", m3.getDisplayText()); + assertEquals("3[enter]", m3.getAction()); + assertEquals(2, m3.getStartRow()); + } + + @Test + @DisplayName("Hit-testing finds hotspot under specific coordinate") + public void testFindHotspot() { + String screen = "PF3=Exit https://github.com ===> 1"; + engine.scanText(screen, 1, 80); + + // Under PF3 (cols 0..2) + HotspotEngine.Hotspot hPf = engine.findHotspot(0, 1); + assertNotNull(hPf); + assertEquals("PF3", hPf.getDisplayText()); + + // Space between hotspots + assertNull(engine.findHotspot(0, 5)); + + // Under URL (cols 11..28) + HotspotEngine.Hotspot hUrl = engine.findHotspot(0, 15); + assertNotNull(hUrl); + assertEquals("https://github.com", hUrl.getDisplayText()); + + // Under prompt (cols 32..37) + HotspotEngine.Hotspot hPrompt = engine.findHotspot(0, 34); + assertNotNull(hPrompt); + assertEquals("===> 1", hPrompt.getDisplayText()); + } + + @Test + @DisplayName("Toggling engine options enables or disables specific hotspot categories") + public void testTogglingCategories() { + String screen = "PF3=Exit https://example.com 1. Options"; + + engine.scanText(screen, 1, 80); + assertEquals(3, engine.getHotspots().size()); + + // Disable URLs + engine.setUrlEnabled(false); + List noUrls = engine.scanText(screen, 1, 80); + assertEquals(2, noUrls.size()); + assertFalse(noUrls.stream().anyMatch(h -> h.getType() == HotspotEngine.HotspotType.URL)); + + // Disable PF keys + engine.setPfKeyEnabled(false); + List menuOnly = engine.scanText(screen, 1, 80); + assertEquals(1, menuOnly.size()); + assertEquals(HotspotEngine.HotspotType.MENU_CHOICE, menuOnly.get(0).getType()); + + // Disable menu choices + engine.setMenuChoiceEnabled(false); + List empty = engine.scanText(screen, 1, 80); + assertEquals(0, empty.size()); + } +} diff --git a/j3270/src/test/java/haus/nightmare/j3270/ui/SelectionHandlesAndTrimTest.java b/j3270/src/test/java/haus/nightmare/j3270/ui/SelectionHandlesAndTrimTest.java new file mode 100644 index 0000000..f76b726 --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/ui/SelectionHandlesAndTrimTest.java @@ -0,0 +1,265 @@ +package haus.nightmare.j3270.ui; + +import haus.nightmare.j3270.config.Settings; +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.ConnectionState; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.Cursor; +import java.awt.HeadlessException; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.LinkedHashMap; +import java.util.Map; + +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; +import static org.junit.jupiter.api.Assertions.*; + +public class SelectionHandlesAndTrimTest { + + private boolean origRemainAfterEdit; + private boolean origSizingHandles; + + @BeforeEach + public void setUp() { + origRemainAfterEdit = Settings.getTrimRectRemainAfterEdit(); + origSizingHandles = Settings.getTrimRectSizingHandles(); + } + + @AfterEach + public void tearDown() { + Settings.setTrimRectRemainAfterEdit(origRemainAfterEdit); + Settings.setTrimRectSizingHandles(origSizingHandles); + } + + private TerminalPanel createTestPanel() { + TerminalPanel panel = new TerminalPanel(); + panel.setSize(800, 600); + panel.cellWidth = 10; + panel.cellHeight = 20; + + Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23)); + client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270); + ScreenBuffer sb = client.getScreenBuffer(); + sb.erase(false); + panel.setClient(client); + + return panel; + } + + @Test + @DisplayName("Trailing whitespace is trimmed on copied lines in block selection mode") + public void testBlockSelectionTrailingWhitespaceTrim() { + try { + TerminalPanel panel = createTestPanel(); + ScreenBuffer sb = panel.getClient().getScreenBuffer(); + int cols = sb.getCols(); + + // Populate row 0: "HELLO " + String text0 = "HELLO"; + for (int i = 0; i < text0.length(); i++) { + sb.getCell(i).ucs4 = text0.charAt(i); + } + + // Populate row 1: "WORLD 123 " + String text1 = "WORLD 123"; + for (int i = 0; i < text1.length(); i++) { + sb.getCell(cols + i).ucs4 = text1.charAt(i); + } + + // Select rectangle from (row 0, col 0) to (row 1, col 20) + panel.setBlockSelectMode(true); + panel.setSelectionRange(0, cols + 20); + + String copied = panel.getSelectedText(); + // Should be "HELLO\nWORLD 123" without the trailing spaces up to col 20 + assertEquals("HELLO\nWORLD 123", copied); + } catch (HeadlessException ignored) {} + } + + @Test + @DisplayName("Trailing whitespace is trimmed on copied lines in stream selection mode") + public void testStreamSelectionTrailingWhitespaceTrim() { + try { + TerminalPanel panel = createTestPanel(); + ScreenBuffer sb = panel.getClient().getScreenBuffer(); + int cols = sb.getCols(); + + // Populate row 0: "LINE1 " + String text0 = "LINE1"; + for (int i = 0; i < text0.length(); i++) { + sb.getCell(i).ucs4 = text0.charAt(i); + } + + // Populate row 1: "LINE2" + String text1 = "LINE2"; + for (int i = 0; i < text1.length(); i++) { + sb.getCell(cols + i).ucs4 = text1.charAt(i); + } + + panel.setBlockSelectMode(false); + panel.setTrimRectSizingHandles(false); + // Select from row 0, col 0 to row 1, col 10 + panel.setSelectionRange(0, cols + 10); + + String copied = panel.getSelectedText(); + assertEquals("LINE1\nLINE2", copied); + } catch (HeadlessException ignored) {} + } + + @Test + @DisplayName("Drag handle detection identifies 8 resize handles, MOVE inside box, and NONE outside") + public void testDragHandleDetection() { + try { + TerminalPanel panel = createTestPanel(); + panel.setBlockSelectMode(true); + panel.setTrimRectSizingHandles(true); + + // Without selection, should return NONE + assertEquals(TerminalPanel.DragHandle.NONE, panel.getDragHandleAt(100, 100)); + + // Select rectangle: rows 2 to 5, cols 10 to 20 + ScreenBuffer sb = panel.getClient().getScreenBuffer(); + int cols = sb.getCols(); + panel.setSelectionRange(2 * cols + 10, 5 * cols + 20); + + int ox = panel.getRenderOffsetX(); + int oy = panel.getRenderOffsetY(); + int cw = panel.cellWidth; + int ch = panel.cellHeight; + + int x1 = ox + 10 * cw; + int y1 = oy + 2 * ch; + int x2 = ox + 21 * cw; + int y2 = oy + 6 * ch; + int xm = (x1 + x2) / 2; + int ym = (y1 + y2) / 2; + + // Test 8 handles + assertEquals(TerminalPanel.DragHandle.NW, panel.getDragHandleAt(x1, y1)); + assertEquals(TerminalPanel.DragHandle.N, panel.getDragHandleAt(xm, y1)); + assertEquals(TerminalPanel.DragHandle.NE, panel.getDragHandleAt(x2, y1)); + assertEquals(TerminalPanel.DragHandle.E, panel.getDragHandleAt(x2, ym)); + assertEquals(TerminalPanel.DragHandle.SE, panel.getDragHandleAt(x2, y2)); + assertEquals(TerminalPanel.DragHandle.S, panel.getDragHandleAt(xm, y2)); + assertEquals(TerminalPanel.DragHandle.SW, panel.getDragHandleAt(x1, y2)); + assertEquals(TerminalPanel.DragHandle.W, panel.getDragHandleAt(x1, ym)); + + // Test inside box -> MOVE + assertEquals(TerminalPanel.DragHandle.MOVE, panel.getDragHandleAt(xm, ym)); + + // Test far outside -> NONE + assertEquals(TerminalPanel.DragHandle.NONE, panel.getDragHandleAt(0, 0)); + assertEquals(TerminalPanel.DragHandle.NONE, panel.getDragHandleAt(x2 + 50, y2 + 50)); + } catch (HeadlessException ignored) {} + } + + @Test + @DisplayName("Cursor mapping for all drag handles") + public void testCursorForDragHandles() { + try { + TerminalPanel panel = createTestPanel(); + assertEquals(Cursor.NW_RESIZE_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.NW).getType()); + assertEquals(Cursor.N_RESIZE_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.N).getType()); + assertEquals(Cursor.NE_RESIZE_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.NE).getType()); + assertEquals(Cursor.E_RESIZE_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.E).getType()); + assertEquals(Cursor.SE_RESIZE_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.SE).getType()); + assertEquals(Cursor.S_RESIZE_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.S).getType()); + assertEquals(Cursor.SW_RESIZE_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.SW).getType()); + assertEquals(Cursor.W_RESIZE_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.W).getType()); + assertEquals(Cursor.MOVE_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.MOVE).getType()); + assertEquals(Cursor.DEFAULT_CURSOR, panel.getCursorForDragHandle(TerminalPanel.DragHandle.NONE).getType()); + } catch (HeadlessException ignored) {} + } + + @Test + @DisplayName("trimRectRemainAfterEdit controls whether selection is retained after copy") + public void testTrimRectRemainAfterEdit() { + try { + TerminalPanel panel = createTestPanel(); + ScreenBuffer sb = panel.getClient().getScreenBuffer(); + + sb.getCell(0).ucs4 = 'A'; + panel.setSelectionRange(0, 5); + assertTrue(panel.hasSelection()); + + // By default (false), copySelection() should clear the selection + Settings.setTrimRectRemainAfterEdit(false); + panel.copySelection(); + assertFalse(panel.hasSelection(), "Selection should be cleared after copy when remainAfterEdit is false"); + + // When set to true, copySelection() should preserve the selection + Settings.setTrimRectRemainAfterEdit(true); + panel.setSelectionRange(0, 5); + assertTrue(panel.hasSelection()); + panel.copySelection(); + assertTrue(panel.hasSelection(), "Selection should remain active after copy when remainAfterEdit is true"); + } catch (HeadlessException ignored) {} + } + + @Test + @DisplayName("cutSelection clears unprotected cells, sets FA_MODIFY, and respects remainAfterEdit") + public void testCutSelectionBehavior() { + try { + TerminalPanel panel = createTestPanel(); + ScreenBuffer sb = panel.getClient().getScreenBuffer(); + + // Field at 0: Unprotected field + sb.setCellFA(0, (byte) (FA_PRINTABLE | FA_MODIFY)); + String data = "TESTDATA"; + for (int i = 0; i < data.length(); i++) { + sb.getCell(1 + i).ucs4 = data.charAt(i); + } + + // Select the field contents + panel.setSelectionRange(1, 8); + assertTrue(panel.hasSelection()); + + Settings.setTrimRectRemainAfterEdit(false); + panel.cutSelection(); + + // Characters should be replaced with spaces + for (int i = 1; i <= 8; i++) { + assertEquals(' ', sb.getCell(i).ucs4, "Cell " + i + " should be erased to space"); + } + // FA_MODIFY should remain or be set + assertTrue((sb.getCell(0).ec & FA_MODIFY) != 0, "FA_MODIFY should be set on field attribute"); + assertFalse(panel.hasSelection(), "Selection should be cleared when remainAfterEdit is false"); + } catch (HeadlessException ignored) {} + } + + @Test + @DisplayName("KeyBindings exportKmp generates HoD format and round-trips through parseKmp") + public void testKeyBindingsExportAndParseRoundTrip() throws Exception { + Map keymap = new LinkedHashMap<>(); + keymap.put("ENTER", "ENTER"); + keymap.put("shift TAB", "TAB"); + keymap.put("CLEAR", "PAUSE"); + keymap.put("PF1", "F1"); + keymap.put("PF13", "shift F1"); + keymap.put("ESCAPE", "ESCAPE"); + + StringWriter writer = new StringWriter(); + KeyBindings.exportKmp(writer, keymap); + String exported = writer.toString(); + + assertTrue(exported.contains("[KeyRemap]"), "Exported KMP must contain [KeyRemap] section header"); + assertTrue(exported.contains("[enter]"), "Exported KMP must contain HoD mnemonics"); + assertTrue(exported.contains("[backtab]"), "Exported KMP must contain [backtab]"); + + // Roundtrip parse + Map parsed = KeyBindings.parseKmp(new StringReader(exported)); + assertNotNull(parsed); + assertEquals("ENTER", parsed.get("ENTER")); + assertEquals("TAB", parsed.get("shift TAB")); + assertEquals("PAUSE", parsed.get("CLEAR")); + assertEquals("F1", parsed.get("PF1")); + assertEquals("shift F1", parsed.get("PF13")); + assertEquals("ESCAPE", parsed.get("ESCAPE")); + } +} diff --git a/lib3270j/src/main/java/com/ibm/eNetwork/ECL/ECLSession.java b/lib3270j/src/main/java/com/ibm/eNetwork/ECL/ECLSession.java index 78f87c6..99f04d2 100644 --- a/lib3270j/src/main/java/com/ibm/eNetwork/ECL/ECLSession.java +++ b/lib3270j/src/main/java/com/ibm/eNetwork/ECL/ECLSession.java @@ -36,6 +36,7 @@ public class ECLSession extends ECLConnection implements ECLConstants { public static final String BLOCK_PASTE = "blockPaste"; public static final String PASTE_FIELD_WRAP = "pasteFieldWrap"; public static final String PASTE_LINE_WRAP = "pasteLineWrap"; + public static final String PASTE_WORD_BREAK = "pasteWordBreak"; private final haus.nightmare.lib3270j.ecl.ECLSession delegate; private final ECLPS ps; diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java index 664e565..b5e3cf2 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java @@ -92,6 +92,9 @@ public class Telnet3270Client { ps.notifyAlarm(); } @Override public void onKeyboardUnlocked() { + if (screenBuffer != null) { + screenBuffer.getHistoryBuffer().snapshot(screenBuffer); + } ps.notifyKeyUnlocked(); } }); @@ -658,6 +661,9 @@ public class Telnet3270Client { public void setWordWrap(boolean b) { if (screenBuffer != null) screenBuffer.setEntryAssistWordWrap(b); if (oia != null) oia.notifyOIAChanged(); } public void toggleWordWrap() { setWordWrap(!isWordWrap()); } + public boolean isPasteWordBreak() { return inputProcessor != null && inputProcessor.isPasteWordBreak(); } + public void setPasteWordBreak(boolean b) { if (inputProcessor != null) inputProcessor.setPasteWordBreak(b); } + /** APL Keyboard Mode operations. */ public boolean isAplMode() { return inputProcessor != null && inputProcessor.isAplKeyboardMode(); } public void setAplMode(boolean b) { if (inputProcessor != null) inputProcessor.setAplKeyboardMode(b); } @@ -705,4 +711,8 @@ public class Telnet3270Client { public haus.nightmare.lib3270j.graphics.GocaDecoder getGocaDecoder() { return dsProcessor.getGocaDecoder(); } + + public haus.nightmare.lib3270j.screen.ScreenHistoryBuffer getScreenHistoryBuffer() { + return screenBuffer != null ? screenBuffer.getHistoryBuffer() : null; + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java index 9c30f4b..e4ce459 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java @@ -845,13 +845,37 @@ public class ECLPS implements ECLConstants { public void SetPasteStopAtProtectedLine(boolean val) { setPasteStopAtProtectedLine(val); } + private boolean pasteWordBreak = true; + + public boolean isPasteWordBreak() { + if (session != null && session.getProperties() != null) { + String p = session.getProperties().getProperty(ECLSession.PASTE_WORD_BREAK); + if (p != null) return Boolean.parseBoolean(p); + } + return inputProcessor != null ? inputProcessor.isPasteWordBreak() : pasteWordBreak; + } + + public boolean IsPasteWordBreak() { return isPasteWordBreak(); } + + public void setPasteWordBreak(boolean val) { + this.pasteWordBreak = val; + if (session != null && session.getProperties() != null) { + session.getProperties().setProperty(ECLSession.PASTE_WORD_BREAK, String.valueOf(val)); + } + if (inputProcessor != null) { + inputProcessor.setPasteWordBreak(val); + } + } + + public void SetPasteWordBreak(boolean val) { setPasteWordBreak(val); } + public synchronized int pasteFromExcel(String text, int row, int col) { if (text == null || text.isEmpty() || screen == null) return 0; if (row >= 0 && col >= 0) { setCursorPos(row, col); } if (inputProcessor != null) { - return inputProcessor.pasteText(text, true, isPasteStopAtProtectedLine(), isBlockPaste()); + return inputProcessor.pasteText(text, true, isPasteStopAtProtectedLine(), isBlockPaste(), isPasteWordBreak()); } return 0; } @@ -867,7 +891,7 @@ public class ECLPS implements ECLConstants { if (text == null || text.isEmpty() || screen == null) return 0; if (inputProcessor != null && (text.contains("\t") || isEnablePasteFromExcel() || isPasteStopAtProtectedLine() || isBlockPaste())) { setCursorPos(row, col); - return inputProcessor.pasteText(text, isEnablePasteFromExcel(), isPasteStopAtProtectedLine(), isBlockPaste()); + return inputProcessor.pasteText(text, isEnablePasteFromExcel(), isPasteStopAtProtectedLine(), isBlockPaste(), isPasteWordBreak()); } int rows = screen.getRows(); int cols = screen.getCols(); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLSession.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLSession.java index 61a58f9..8c997f6 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLSession.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLSession.java @@ -41,6 +41,7 @@ public class ECLSession { public static final String BLOCK_PASTE = "blockPaste"; public static final String PASTE_FIELD_WRAP = "pasteFieldWrap"; public static final String PASTE_LINE_WRAP = "pasteLineWrap"; + public static final String PASTE_WORD_BREAK = "pasteWordBreak"; public static final String ENTRYASSIST_DOCMODE = "EntryAssist_DOCmode"; public static final String ENTRYASSIST_DOCWORDWRAP = "EntryAssist_DOCwordWrap"; public static final String ENTRYASSIST_STARTCOL = "EntryAssist_startCol"; diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/HodKeyMap.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/HodKeyMap.java new file mode 100644 index 0000000..8a6994d --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/HodKeyMap.java @@ -0,0 +1,588 @@ +package haus.nightmare.lib3270j.input; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.util.*; + +/** + * IBM Host On-Demand (HoD) .kmp keyboard map profile parser and serializer. + * Translates HoD hashKey codes (e.g. A67 -> Alt+C, B35 -> End, A82 -> Alt+R) + * and duplicate/numpad key representations. + * + * Implemented in pure Java standard library for lib3270j. + */ +public class HodKeyMap { + + public static final int MOD_SHIFT = 1; + public static final int MOD_CTRL = 2; + public static final int MOD_META = 4; + public static final int MOD_ALT = 8; + + public static final int LOCATION_STANDARD = 1; + public static final int LOCATION_LEFT = 2; + public static final int LOCATION_RIGHT = 3; + public static final int LOCATION_NUMPAD = 4; + + public static final int KEY_LOC_LEFT = 0x1000000; // 16777216 + public static final int KEY_LOC_NUMPAD = 0x2000000; // 33554432 + + // HoD duplicate keycodes for numpad arrows + public static final int NUMPAD_UP_CODE = 224; + public static final int NUMPAD_DOWN_CODE = 225; + public static final int NUMPAD_LEFT_CODE = 226; + public static final int NUMPAD_RIGHT_CODE = 227; + + // Standard AWT VK codes + public static final int VK_ENTER = 10; + public static final int VK_CLEAR = 12; + public static final int VK_SHIFT = 16; + public static final int VK_CONTROL = 17; + public static final int VK_ALT = 18; + public static final int VK_PAGE_UP = 33; + public static final int VK_PAGE_DOWN = 34; + public static final int VK_END = 35; + public static final int VK_HOME = 36; + public static final int VK_LEFT = 37; + public static final int VK_UP = 38; + public static final int VK_RIGHT = 39; + public static final int VK_DOWN = 40; + public static final int VK_DELETE = 127; + public static final int VK_INSERT = 155; + public static final int VK_META = 157; + + private static final Set NUMPAD_DUP_KEYS = new HashSet<>(Arrays.asList( + VK_ENTER, VK_CLEAR, VK_PAGE_UP, VK_PAGE_DOWN, VK_END, VK_HOME, VK_DELETE, VK_INSERT + )); + + private static final Set LEFT_DUP_KEYS = new HashSet<>(Arrays.asList( + VK_SHIFT, VK_CONTROL, VK_ALT, VK_META + )); + + // Common keycode to standard name mapping + private static final Map KEYCODE_NAMES = new HashMap<>(); + private static final Map NAME_KEYCODES = new HashMap<>(); + + static { + // Alphanumeric A-Z (65-90) + for (char c = 'A'; c <= 'Z'; c++) { + KEYCODE_NAMES.put((int) c, String.valueOf(c)); + NAME_KEYCODES.put(String.valueOf(c), (int) c); + } + // Digits 0-9 (48-57) + for (char c = '0'; c <= '9'; c++) { + KEYCODE_NAMES.put((int) c, String.valueOf(c)); + NAME_KEYCODES.put(String.valueOf(c), (int) c); + } + // Function keys F1-F24 + for (int i = 1; i <= 24; i++) { + int vk = (i <= 12) ? (111 + i) : (111 + i); // F1 is 112 + KEYCODE_NAMES.put(111 + i, "F" + i); + NAME_KEYCODES.put("F" + i, 111 + i); + } + // Specific keys + registerKey(VK_ENTER, "ENTER"); + registerKey(13, "ENTER"); + registerKey(9, "TAB"); + registerKey(8, "BACK_SPACE"); + registerKey(27, "ESCAPE"); + registerKey(32, "SPACE"); + registerKey(VK_PAGE_UP, "PAGE_UP"); + registerKey(VK_PAGE_DOWN, "PAGE_DOWN"); + registerKey(VK_END, "END"); + registerKey(VK_HOME, "HOME"); + registerKey(VK_LEFT, "LEFT"); + registerKey(VK_UP, "UP"); + registerKey(VK_RIGHT, "RIGHT"); + registerKey(VK_DOWN, "DOWN"); + registerKey(VK_INSERT, "INSERT"); + registerKey(VK_DELETE, "DELETE"); + registerKey(VK_CLEAR, "CLEAR"); + registerKey(144, "NUM_LOCK"); + registerKey(145, "SCROLL_LOCK"); + registerKey(19, "PAUSE"); + registerKey(44, "PRINTSCREEN"); + + // Symbols + registerKey(59, "SEMICOLON"); + registerKey(61, "EQUALS"); + registerKey(44, "COMMA"); + registerKey(45, "MINUS"); + registerKey(46, "PERIOD"); + registerKey(47, "SLASH"); + registerKey(192, "BACK_QUOTE"); + registerKey(91, "OPEN_BRACKET"); + registerKey(92, "BACK_SLASH"); + registerKey(93, "CLOSE_BRACKET"); + registerKey(222, "QUOTE"); + } + + private static void registerKey(int code, String name) { + KEYCODE_NAMES.put(code, name); + NAME_KEYCODES.put(name.toUpperCase(), code); + } + + public static class KeyEntry { + private final int keyCode; + private final int modifiers; + private final int keyLocation; + private final String value; + + public KeyEntry(int keyCode, int modifiers, int keyLocation, String value) { + this.keyCode = keyCode; + this.modifiers = modifiers; + this.keyLocation = keyLocation; + this.value = value; + } + + public int getKeyCode() { return keyCode; } + public int getModifiers() { return modifiers; } + public int getKeyLocation() { return keyLocation; } + public String getValue() { return value; } + + public boolean isShift() { return (modifiers & MOD_SHIFT) != 0; } + public boolean isCtrl() { return (modifiers & MOD_CTRL) != 0; } + public boolean isMeta() { return (modifiers & MOD_META) != 0; } + public boolean isAlt() { return (modifiers & MOD_ALT) != 0; } + + public String toKeyStrokeString() { + StringBuilder sb = new StringBuilder(); + if (isCtrl()) sb.append("ctrl "); + if (isAlt()) sb.append("alt "); + if (isShift()) sb.append("shift "); + if (isMeta()) sb.append("meta "); + + String name = KEYCODE_NAMES.get(keyCode); + if (name != null) { + sb.append(name); + } else { + sb.append("KEY_").append(keyCode); + } + return sb.toString().trim(); + } + + public String toHashKey() { + return HodKeyMap.hashKey(keyCode, modifiers, keyLocation); + } + + @Override + public String toString() { + return toHashKey() + "=" + value + " (" + toKeyStrokeString() + ")"; + } + } + + /** + * Compute HoD modifier prefix character. + */ + public static char getModifierPrefix(int modifiers) { + int m = 0; + if ((modifiers & MOD_SHIFT) != 0) m |= 1; + if ((modifiers & MOD_CTRL) != 0) m |= 2; + if ((modifiers & MOD_META) != 0) m |= 4; + if ((modifiers & MOD_ALT) != 0) m |= 8; + switch (m) { + case 1: return 'S'; + case 2: return 'C'; + case 3: return 'D'; + case 4: return 'M'; + case 5: return 'E'; + case 6: return 'F'; + case 7: return 'G'; + case 8: return 'A'; + case 9: return 'H'; + case 10: return 'I'; + case 11: return 'J'; + case 12: return 'K'; + case 13: return 'L'; + case 14: return 'N'; + case 15: return 'O'; + default: return 'B'; + } + } + + /** + * Decode HoD modifier prefix character into modifier bitmask. + */ + public static int getModifiersFromPrefix(char c) { + switch (Character.toUpperCase(c)) { + case 'S': return MOD_SHIFT; + case 'C': return MOD_CTRL; + case 'D': return MOD_SHIFT | MOD_CTRL; + case 'M': return MOD_META; + case 'E': return MOD_SHIFT | MOD_META; + case 'F': return MOD_CTRL | MOD_META; + case 'G': return MOD_SHIFT | MOD_CTRL | MOD_META; + case 'A': return MOD_ALT; + case 'H': return MOD_SHIFT | MOD_ALT; + case 'I': return MOD_CTRL | MOD_ALT; + case 'J': return MOD_SHIFT | MOD_CTRL | MOD_ALT; + case 'K': return MOD_META | MOD_ALT; + case 'L': return MOD_SHIFT | MOD_META | MOD_ALT; + case 'N': return MOD_CTRL | MOD_META | MOD_ALT; + case 'O': return MOD_SHIFT | MOD_CTRL | MOD_META | MOD_ALT; + default: return 0; // 'B' = base/none + } + } + + /** + * Generates a HoD hashKey string for a given keycode, modifier mask, and key location. + */ + public static String hashKey(int keyCode, int modifiers, int keyLocation) { + char prefix = getModifierPrefix(modifiers); + String codeStr; + + if (keyLocation == LOCATION_NUMPAD) { + if (keyCode == VK_UP) { + codeStr = String.valueOf(NUMPAD_UP_CODE); + } else if (keyCode == VK_DOWN) { + codeStr = String.valueOf(NUMPAD_DOWN_CODE); + } else if (keyCode == VK_LEFT) { + codeStr = String.valueOf(NUMPAD_LEFT_CODE); + } else if (keyCode == VK_RIGHT) { + codeStr = String.valueOf(NUMPAD_RIGHT_CODE); + } else if (NUMPAD_DUP_KEYS.contains(keyCode)) { + codeStr = String.valueOf(KEY_LOC_NUMPAD + keyCode); + } else { + codeStr = String.valueOf(keyCode); + } + } else if (keyLocation == LOCATION_LEFT && LEFT_DUP_KEYS.contains(keyCode)) { + codeStr = String.valueOf(KEY_LOC_LEFT + keyCode); + } else { + codeStr = String.valueOf(keyCode); + } + + return prefix + codeStr; + } + + /** + * Parses a HoD hashKey and optional value. + */ + public static KeyEntry parseHashKey(String hashKey, String value) { + if (hashKey == null || hashKey.length() < 2) { + return null; + } + char prefix = hashKey.charAt(0); + int mods = getModifiersFromPrefix(prefix); + int rawCode; + try { + rawCode = Integer.parseInt(hashKey.substring(1)); + } catch (NumberFormatException e) { + return null; + } + + int keyCode = rawCode; + int location = LOCATION_STANDARD; + + if (rawCode == NUMPAD_UP_CODE) { + keyCode = VK_UP; + location = LOCATION_NUMPAD; + } else if (rawCode == NUMPAD_DOWN_CODE) { + keyCode = VK_DOWN; + location = LOCATION_NUMPAD; + } else if (rawCode == NUMPAD_LEFT_CODE) { + keyCode = VK_LEFT; + location = LOCATION_NUMPAD; + } else if (rawCode == NUMPAD_RIGHT_CODE) { + keyCode = VK_RIGHT; + location = LOCATION_NUMPAD; + } else if (rawCode >= KEY_LOC_NUMPAD) { + keyCode = rawCode - KEY_LOC_NUMPAD; + location = LOCATION_NUMPAD; + } else if (rawCode >= KEY_LOC_LEFT) { + keyCode = rawCode - KEY_LOC_LEFT; + location = LOCATION_LEFT; + } + + return new KeyEntry(keyCode, mods, location, value); + } + + /** + * Converts a human-readable keystroke expression (e.g. "alt C", "ctrl shift ENTER") + * into a KeyEntry. + */ + public static KeyEntry keyStrokeToEntry(String stroke, String value) { + if (stroke == null || stroke.trim().isEmpty()) return null; + String s = stroke.replace("+", " ").trim(); + String[] tokens = s.split("\\s+"); + + int mods = 0; + int location = LOCATION_STANDARD; + Integer keyCode = null; + + for (String t : tokens) { + String lower = t.toLowerCase(); + if ("ctrl".equals(lower) || "control".equals(lower)) { + mods |= MOD_CTRL; + } else if ("shift".equals(lower)) { + mods |= MOD_SHIFT; + } else if ("alt".equals(lower)) { + mods |= MOD_ALT; + } else if ("meta".equals(lower) || "cmd".equals(lower)) { + mods |= MOD_META; + } else if ("numpad".equals(lower)) { + location = LOCATION_NUMPAD; + } else { + String upper = t.toUpperCase(); + if (NAME_KEYCODES.containsKey(upper)) { + keyCode = NAME_KEYCODES.get(upper); + } else if (upper.startsWith("NUMPAD") && upper.length() > 6) { + location = LOCATION_NUMPAD; + String rest = upper.substring(6); + if (NAME_KEYCODES.containsKey(rest)) { + keyCode = NAME_KEYCODES.get(rest); + } + } else if (upper.startsWith("KEY_")) { + try { + keyCode = Integer.parseInt(upper.substring(4)); + } catch (NumberFormatException ignored) {} + } + } + } + + if (keyCode == null) return null; + return new KeyEntry(keyCode, mods, location, value); + } + + /** + * Parses an IBM HoD .kmp profile. + * Returns a map of j3270 action names to normalized key strokes. + */ + public static Map parseHodKmp(Reader reader) throws IOException { + BufferedReader br = (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader); + Map> actionToBindings = new LinkedHashMap<>(); + + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + // Comments + int cIdx = line.indexOf(';'); + if (cIdx >= 0) line = line.substring(0, cIdx).trim(); + cIdx = line.indexOf('#'); + if (cIdx >= 0) line = line.substring(0, cIdx).trim(); + + if (line.isEmpty() || line.startsWith("[")) { + continue; + } + + int eq = line.indexOf('='); + if (eq <= 0) continue; + + String left = line.substring(0, eq).trim(); + String right = line.substring(eq + 1).trim(); + + // Ignore configuration/session properties + if (left.equalsIgnoreCase("sessionType") || + left.equalsIgnoreCase("codePage") || + left.equalsIgnoreCase("autoApply") || + left.equalsIgnoreCase("keyVersion") || + left.equalsIgnoreCase("disableKeyBuffer") || + left.equalsIgnoreCase("id") || + left.equalsIgnoreCase("Description") || + left.equalsIgnoreCase("Version")) { + continue; + } + + // Extended custom function mapping + if (left.startsWith("XF:")) { + continue; + } + + // HoD hashKey assignment: e.g. A67=[clear] + if (left.matches("^[A-Za-z][0-9]+$")) { + KeyEntry entry = parseHashKey(left, right); + if (entry != null) { + String stroke = entry.toKeyStrokeString(); + String action = resolveMnemonicToAction(right); + if (action != null && stroke != null && !stroke.isEmpty()) { + actionToBindings.computeIfAbsent(action, k -> new ArrayList<>()).add(stroke); + } + } + } + } + + Map result = new LinkedHashMap<>(); + for (Map.Entry> entry : actionToBindings.entrySet()) { + result.put(entry.getKey(), String.join(", ", entry.getValue())); + } + return result; + } + + /** + * Exports key bindings into standard IBM HoD .kmp file format. + */ + public static void exportHodKmp(Writer writer, Map keyBindings) throws IOException { + writer.write("[KeyRemap]\n"); + writer.write("keyVersion=2\n"); + writer.write("sessionType=1\n"); + writer.write("codePage=037\n"); + writer.write("autoApply=false\n"); + writer.write("disableKeyBuffer=false\n"); + + for (Map.Entry entry : keyBindings.entrySet()) { + String action = entry.getKey(); + String strokeList = entry.getValue(); + if (strokeList == null || strokeList.trim().isEmpty() || "UNBOUND".equalsIgnoreCase(strokeList.trim())) { + continue; + } + + String mnemonic = actionToMnemonic(action); + String[] strokes = strokeList.split(","); + for (String stroke : strokes) { + KeyEntry ke = keyStrokeToEntry(stroke.trim(), mnemonic); + if (ke != null) { + writer.write(ke.toHashKey() + "=" + mnemonic + "\n"); + } + } + } + writer.flush(); + } + + /** + * Map HoD mnemonic string (with or without brackets) to standard j3270 action name. + */ + public static String resolveMnemonicToAction(String token) { + if (token == null) return null; + String clean = token.trim(); + if (clean.startsWith("[") && clean.endsWith("]")) { + clean = clean.substring(1, clean.length() - 1); + } + clean = clean.toLowerCase(); + + switch (clean) { + case "enter": + case "enterreset": + return "ENTER"; + case "newline": + return "NEWLINE"; + case "tab": + return "TAB"; + case "backtab": + return "shift TAB"; + case "reset": + return "ESCAPE"; + case "clear": + return "CLEAR"; + case "eraseeof": + case "eof": + return "END"; + case "erasefld": + case "erinp": + case "eraseinput": + return "ERASE_INPUT"; + case "dup": + return "DUP"; + case "fieldmark": + case "fldmark": + return "FIELD_MARK"; + case "fldext": + case "field-exit": + return "NEWLINE"; + case "attn": + return "ATTN"; + case "sysreq": + return "SYSREQ"; + case "cursel": + return "CURSEL"; + case "copy": + return "COPY"; + case "paste": + return "PASTE"; + case "selectall": + return "SELECTALL"; + case "insert": + return "INSERT"; + case "delete": + return "DELETE"; + case "backspace": + return "BACK_SPACE"; + case "home": + return "HOME"; + case "end": + return "END"; + case "up": + return "UP"; + case "down": + return "DOWN"; + case "left": + return "LEFT"; + case "right": + return "RIGHT"; + case "pageup": + return "PAGE_UP"; + case "pagedn": + case "pagedown": + return "PAGE_DOWN"; + case "docmode": + return "DOCMODE"; + case "wordwrap": + return "WORDWRAP"; + case "apl": + return "APL"; + case "rule": + return "CROSSHAIR_RULER"; + case "statusbar": + return "STATUS_BAR"; + default: + if (clean.matches("pf[0-9]+")) { + return clean.toUpperCase(); + } + if (clean.matches("pa[1-3]")) { + return clean.toUpperCase(); + } + if (clean.matches("spf[0-9]+")) { + int n = Integer.parseInt(clean.substring(3)); + return "PF" + (n + 12); + } + return clean.toUpperCase(); + } + } + + /** + * Map j3270 action name to HoD mnemonic token with brackets. + */ + public static String actionToMnemonic(String action) { + if (action == null) return "[]"; + String u = action.toUpperCase().trim(); + switch (u) { + case "ENTER": return "[enter]"; + case "NEWLINE": return "[newline]"; + case "TAB": return "[tab]"; + case "BACKTAB": + case "SHIFT TAB": return "[backtab]"; + case "ESCAPE": + case "RESET": return "[reset]"; + case "CLEAR": return "[clear]"; + case "END": + case "ERASEEOF": return "[eraseeof]"; + case "ERASE_INPUT": return "[erasefld]"; + case "DUP": return "[dup]"; + case "FIELD_MARK": return "[fieldmark]"; + case "ATTN": return "[attn]"; + case "SYSREQ": return "[sysreq]"; + case "CURSEL": return "[cursel]"; + case "COPY": return "[copy]"; + case "PASTE": return "[paste]"; + case "SELECTALL": return "[selectall]"; + case "INSERT": return "[insert]"; + case "DELETE": return "[delete]"; + case "BACK_SPACE": return "[backspace]"; + case "HOME": return "[home]"; + case "UP": return "[up]"; + case "DOWN": return "[down]"; + case "LEFT": return "[left]"; + case "RIGHT": return "[right]"; + case "PAGE_UP": return "[pageup]"; + case "PAGE_DOWN": return "[pagedn]"; + case "DOCMODE": return "[docmode]"; + case "WORDWRAP": return "[wordwrap]"; + case "APL": return "[apl]"; + case "CROSSHAIR_RULER": return "[rule]"; + case "STATUS_BAR": return "[statusbar]"; + default: + if (u.matches("PF[0-9]+")) return "[" + u.toLowerCase() + "]"; + if (u.matches("PA[1-3]")) return "[" + u.toLowerCase() + "]"; + return "[" + u.toLowerCase() + "]"; + } + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java index be27d33..482930c 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java @@ -467,14 +467,15 @@ public class InputProcessor { * - '\n' or '\r\n' triggers a NewLine action advancing to the first unprotected field of the next line. * When pasteStopAtProtectedLine is true: * - Halts or truncates paste if cursor reaches a protected line/boundary or if the current field is full. - * - * @param text text to paste - * @param enablePasteFromExcel whether to parse tabs as field advances and newlines as row advances - * @param pasteStopAtProtectedLine whether to halt paste when encountering protected boundaries - * @return number of characters pasted */ - public int pasteText(String text, boolean enablePasteFromExcel, boolean pasteStopAtProtectedLine) { - return pasteText(text, enablePasteFromExcel, pasteStopAtProtectedLine, this.blockPaste); + private boolean pasteWordBreak = true; + + public boolean isPasteWordBreak() { + return pasteWordBreak; + } + + public void setPasteWordBreak(boolean pasteWordBreak) { + this.pasteWordBreak = pasteWordBreak; } /** @@ -483,10 +484,27 @@ public class InputProcessor { * @param text text to paste * @param enablePasteFromExcel whether to parse tabs as field advances and newlines as row advances * @param pasteStopAtProtectedLine whether to halt paste when encountering protected boundaries - * @param blockPaste whether newlines align each line with the initial starting cursor column * @return number of characters pasted */ + public int pasteText(String text, boolean enablePasteFromExcel, boolean pasteStopAtProtectedLine) { + return pasteText(text, enablePasteFromExcel, pasteStopAtProtectedLine, this.blockPaste, this.pasteWordBreak); + } + public int pasteText(String text, boolean enablePasteFromExcel, boolean pasteStopAtProtectedLine, boolean blockPaste) { + return pasteText(text, enablePasteFromExcel, pasteStopAtProtectedLine, blockPaste, this.pasteWordBreak); + } + + /** + * Pastes text onto the screen with tabular, boundary, block alignment, and word break controls. + * + * @param text text to paste + * @param enablePasteFromExcel whether to parse tabs as field advances and newlines as row advances + * @param pasteStopAtProtectedLine whether to halt paste when encountering protected boundaries + * @param blockPaste whether newlines align each line with the initial starting cursor column + * @param pasteWordBreak whether to prevent splitting words across field boundaries + * @return number of characters pasted + */ + public int pasteText(String text, boolean enablePasteFromExcel, boolean pasteStopAtProtectedLine, boolean blockPaste, boolean pasteWordBreak) { if (text == null || text.isEmpty() || screen == null || keyboardLocked) { return 0; } @@ -573,9 +591,10 @@ public class InputProcessor { if (ch >= 0x20 && ch != 0x7F) { if (screen.isFormatted()) { int baddr = screen.getCursorAddress(); + int totalScreenSize = screen.getRows() * screen.getCols(); ExtendedAttribute ea = screen.getCell(baddr); if (ea.isFieldAttribute()) { - baddr = (baddr + 1) % (screen.getRows() * screen.getCols()); + baddr = (baddr + 1) % totalScreenSize; } byte faVal = screen.getFieldAttributeAt(baddr); if (faIsProtected(faVal & 0xFF)) { @@ -589,6 +608,56 @@ public class InputProcessor { } } } + + if (pasteWordBreak) { + int remaining = screen.getFieldRemainingLength(baddr); + int faAddr = screen.findFieldAttribute(baddr); + int startOfField = (faAddr >= 0) ? (faAddr + 1) % totalScreenSize : 0; + + if (ch == ' ') { + // If separating space occurs when current field is full, advance to next field + if (remaining <= 0) { + tab(); + i++; + continue; + } + // If cursor just transitioned to start of next field after completing a word, + // don't insert a leading delimiter space into the new field + if (baddr == startOfField && i > 0 && !Character.isWhitespace(text.charAt(i - 1))) { + i++; + continue; + } + } else { + // Check if at the start of a word + boolean isWordStart = (i == 0 || Character.isWhitespace(text.charAt(i - 1))); + if (isWordStart) { + int wordEnd = i; + while (wordEnd < len) { + char wc = text.charAt(wordEnd); + if (Character.isWhitespace(wc) || wc == '\t' || wc == '\r' || wc == '\n') { + break; + } + wordEnd++; + } + int wordLen = wordEnd - i; + // If the word cannot fit in remaining space and we're not at start of field, + // advance to next field to prevent splitting the word + if (wordLen > remaining && baddr != startOfField) { + if (pasteStopAtProtectedLine && isLineProtected(screen.getCursorRow())) { + break; + } + tab(); + baddr = screen.getCursorAddress(); + if (screen.getCell(baddr).isFieldAttribute()) { + baddr = (baddr + 1) % totalScreenSize; + } + if (faIsProtected(screen.getFieldAttributeAt(baddr) & 0xFF)) { + break; + } + } + } + } + } } typeCharacter(ch); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java index a98d1f5..f9d544d 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java @@ -70,6 +70,13 @@ public class ScreenBuffer { private final Condition syncCondition = syncLock.newCondition(); private final List updateListeners = new CopyOnWriteArrayList<>(); + // Phase 25: Screen History rolling 64-frame ring buffer + private final ScreenHistoryBuffer historyBuffer = new ScreenHistoryBuffer(); + + public ScreenHistoryBuffer getHistoryBuffer() { + return historyBuffer; + } + public ReentrantLock getSyncLock() { return syncLock; } @@ -99,6 +106,7 @@ public class ScreenBuffer { public void notifyScreenUpdate() { updateDisplaySnapshot(); + historyBuffer.snapshot(this); signalWaiters(); for (ScreenUpdateListener l : updateListeners) { try { @@ -481,6 +489,46 @@ public class ScreenBuffer { return buffer[fa_addr].fa; } + /** + * Calculates the remaining characters available in the current field from baddr + * to the next field attribute (or end of unformatted screen). + */ + public synchronized int getFieldRemainingLength(int baddr) { + int size = rows * cols; + if (size <= 0) return 0; + baddr = ((baddr % size) + size) % size; + if (!formatted) { + return size - baddr; + } + if (buffer[baddr].isFieldAttribute()) { + baddr = (baddr + 1) % size; + } + int remaining = 0; + int curr = baddr; + while (!buffer[curr].isFieldAttribute() && remaining < size) { + remaining++; + curr = (curr + 1) % size; + } + return remaining; + } + + /** + * Calculates the total character capacity of the field governed by faAddr. + */ + public synchronized int getFieldLength(int faAddr) { + int size = rows * cols; + if (size <= 0) return 0; + if (!formatted || faAddr < 0) return size; + faAddr = ((faAddr % size) + size) % size; + int count = 0; + int curr = (faAddr + 1) % size; + while (!buffer[curr].isFieldAttribute() && count < size) { + count++; + curr = (curr + 1) % size; + } + return count; + } + /** * Returns the effective foreground color byte for the given buffer address. * If the cell has an explicit foreground color attribute (0xF0-0xFF), it is returned. diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenHistoryBuffer.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenHistoryBuffer.java new file mode 100644 index 0000000..1777053 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenHistoryBuffer.java @@ -0,0 +1,336 @@ +package haus.nightmare.lib3270j.screen; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Circular ring buffer that captures 3270 screen buffer snapshots. + * Implements a rolling 64-frame buffer matching IBM Host On-Demand (HoD) + * ScreenHistory storage parity. + * + * Captures screen buffer state upon each host write or unlock, suppressing + * identical consecutive frames. + */ +public class ScreenHistoryBuffer { + + public static final int DEFAULT_CAPACITY = 64; + + /** + * An immutable snapshot representing a 3270 screen frame at a specific moment in time. + */ + public static class ScreenSnapshot { + private final int rows; + private final int cols; + private final int cursorAddress; + private final ExtendedAttribute[] cells; + private final long timestamp; + private final String text; + + public ScreenSnapshot(int rows, int cols, int cursorAddress, ExtendedAttribute[] bufferCells, long timestamp) { + this.rows = rows; + this.cols = cols; + this.cursorAddress = cursorAddress; + this.timestamp = timestamp; + + int size = rows * cols; + this.cells = new ExtendedAttribute[size]; + StringBuilder sb = new StringBuilder(size + rows); + + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + int idx = r * cols + c; + ExtendedAttribute ea = new ExtendedAttribute(); + if (bufferCells != null && idx < bufferCells.length && bufferCells[idx] != null) { + ea.copyFrom(bufferCells[idx]); + } + this.cells[idx] = ea; + + if (ea.isFieldAttribute()) { + sb.append(' '); + } else { + char ch = ea.ucs4; + if (ch <= 0x20 || ch == 0xFF) { + sb.append(' '); + } else { + sb.append(ch); + } + } + } + if (r < rows - 1) { + sb.append('\n'); + } + } + this.text = sb.toString(); + } + + public int getRows() { + return rows; + } + + public int getCols() { + return cols; + } + + public int getCursorAddress() { + return cursorAddress; + } + + public int getCursorRow() { + return cols > 0 ? cursorAddress / cols : 0; + } + + public int getCursorCol() { + return cols > 0 ? cursorAddress % cols : 0; + } + + public long getTimestamp() { + return timestamp; + } + + public String getText() { + return text; + } + + public ExtendedAttribute[] getCells() { + return cells; + } + + public ExtendedAttribute getCell(int row, int col) { + if (row < 0 || row >= rows || col < 0 || col >= cols) { + return null; + } + return cells[row * cols + col]; + } + + public char getChar(int row, int col) { + if (row < 0 || row >= rows || col < 0 || col >= cols) { + return ' '; + } + ExtendedAttribute ea = cells[row * cols + col]; + if (ea == null || ea.isFieldAttribute() || ea.ucs4 <= 0x20 || ea.ucs4 == 0xFF) { + return ' '; + } + return (char) ea.ucs4; + } + + public String getLine(int row) { + if (row < 0 || row >= rows) return ""; + int start = row * cols; + StringBuilder sb = new StringBuilder(cols); + for (int c = 0; c < cols; c++) { + ExtendedAttribute ea = cells[start + c]; + if (ea == null || ea.isFieldAttribute() || ea.ucs4 <= 0x20 || ea.ucs4 == 0xFF) { + sb.append(' '); + } else { + sb.append((char) ea.ucs4); + } + } + return sb.toString(); + } + + /** + * Returns plain text with trailing whitespace stripped from each row. + */ + public String getFormattedTable() { + StringBuilder sb = new StringBuilder(); + for (int r = 0; r < rows; r++) { + String line = getLine(r); + int end = line.length(); + while (end > 0 && Character.isWhitespace(line.charAt(end - 1))) { + end--; + } + sb.append(line.substring(0, end)); + if (r < rows - 1) { + sb.append('\n'); + } + } + return sb.toString(); + } + + /** + * Returns text for a bounded rectangular region. + */ + public String getTextRange(int startRow, int startCol, int endRow, int endCol) { + int r0 = Math.max(0, Math.min(startRow, endRow)); + int r1 = Math.min(rows - 1, Math.max(startRow, endRow)); + int c0 = Math.max(0, Math.min(startCol, endCol)); + int c1 = Math.min(cols - 1, Math.max(startCol, endCol)); + + StringBuilder sb = new StringBuilder(); + for (int r = r0; r <= r1; r++) { + for (int c = c0; c <= c1; c++) { + sb.append(getChar(r, c)); + } + if (r < r1) { + sb.append('\n'); + } + } + return sb.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ScreenSnapshot)) return false; + ScreenSnapshot that = (ScreenSnapshot) o; + if (rows != that.rows || cols != that.cols || cursorAddress != that.cursorAddress) { + return false; + } + return text.equals(that.text); + } + + @Override + public int hashCode() { + int result = rows; + result = 31 * result + cols; + result = 31 * result + cursorAddress; + result = 31 * result + text.hashCode(); + return result; + } + } + + /** + * Listener interface to receive updates when history frames are captured. + */ + public interface ScreenHistoryListener { + void onHistoryUpdated(ScreenHistoryBuffer buffer, ScreenSnapshot newSnapshot); + } + + private final int capacity; + private final ScreenSnapshot[] ring; + private int head = 0; // index of the oldest element in a full buffer + private int count = 0; // number of elements currently stored + private final List listeners = new CopyOnWriteArrayList<>(); + + public ScreenHistoryBuffer() { + this(DEFAULT_CAPACITY); + } + + public ScreenHistoryBuffer(int capacity) { + if (capacity <= 0) { + throw new IllegalArgumentException("History buffer capacity must be positive: " + capacity); + } + this.capacity = capacity; + this.ring = new ScreenSnapshot[capacity]; + } + + public void addListener(ScreenHistoryListener listener) { + if (listener != null && !listeners.contains(listener)) { + listeners.add(listener); + } + } + + public void removeListener(ScreenHistoryListener listener) { + listeners.remove(listener); + } + + /** + * Capture a snapshot of the current state of a ScreenBuffer. + * If the screen text and cursor position are identical to the latest snapshot, + * duplicate snapshot insertion is suppressed. + * + * @param buffer the source ScreenBuffer to snapshot + * @return the newly captured ScreenSnapshot, or null if duplicate suppressed + */ + public synchronized ScreenSnapshot snapshot(ScreenBuffer buffer) { + if (buffer == null) return null; + + int rows = buffer.getRows(); + int cols = buffer.getCols(); + int cursor = buffer.getCursorAddress(); + int size = rows * cols; + ExtendedAttribute[] cells = new ExtendedAttribute[size]; + for (int i = 0; i < size; i++) { + cells[i] = buffer.getCell(i); + } + + ScreenSnapshot newSnap = new ScreenSnapshot(rows, cols, cursor, cells, System.currentTimeMillis()); + + // Deduplicate against the latest snapshot if identical + ScreenSnapshot latest = getLatest(); + if (latest != null && latest.equals(newSnap)) { + return null; + } + + // Insert into ring buffer + if (count < capacity) { + ring[count++] = newSnap; + } else { + ring[head] = newSnap; + head = (head + 1) % capacity; + } + + // Notify listeners + for (ScreenHistoryListener l : listeners) { + try { + l.onHistoryUpdated(this, newSnap); + } catch (Exception ignored) {} + } + + return newSnap; + } + + /** + * Total number of snapshots currently stored (0 to capacity). + */ + public synchronized int size() { + return count; + } + + /** + * Buffer maximum capacity (64). + */ + public int getCapacity() { + return capacity; + } + + /** + * Check if buffer is empty. + */ + public synchronized boolean isEmpty() { + return count == 0; + } + + /** + * Retrieve snapshot by chronological index: + * 0 is the oldest snapshot, size() - 1 is the most recent snapshot. + */ + public synchronized ScreenSnapshot get(int index) { + if (index < 0 || index >= count) { + throw new IndexOutOfBoundsException("History index " + index + " out of bounds [0, " + count + ")"); + } + int actualIndex = (head + index) % capacity; + return ring[actualIndex]; + } + + /** + * Retrieve the most recent snapshot, or null if buffer is empty. + */ + public synchronized ScreenSnapshot getLatest() { + if (count == 0) return null; + int latestIndex = (head + count - 1) % capacity; + return ring[latestIndex]; + } + + /** + * Clear all recorded history snapshots. + */ + public synchronized void clear() { + Arrays.fill(ring, null); + head = 0; + count = 0; + } + + /** + * Returns an immutable copy of all stored snapshots in chronological order (oldest to newest). + */ + public synchronized List getSnapshots() { + List list = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + list.add(get(i)); + } + return list; + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/HodKeyMapTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/HodKeyMapTest.java new file mode 100644 index 0000000..2abf2fa --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/HodKeyMapTest.java @@ -0,0 +1,140 @@ +package haus.nightmare.lib3270j.input; + +import org.junit.jupiter.api.Test; +import java.io.File; +import java.io.FileReader; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +public class HodKeyMapTest { + + @Test + public void testModifierPrefixes() { + assertEquals('B', HodKeyMap.getModifierPrefix(0)); + assertEquals('S', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT)); + assertEquals('C', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_CTRL)); + assertEquals('D', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL)); + assertEquals('A', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_ALT)); + assertEquals('H', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_ALT)); + assertEquals('I', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT)); + assertEquals('J', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT)); + + assertEquals(0, HodKeyMap.getModifiersFromPrefix('B')); + assertEquals(HodKeyMap.MOD_SHIFT, HodKeyMap.getModifiersFromPrefix('S')); + assertEquals(HodKeyMap.MOD_CTRL, HodKeyMap.getModifiersFromPrefix('C')); + assertEquals(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL, HodKeyMap.getModifiersFromPrefix('D')); + assertEquals(HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('A')); + assertEquals(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('H')); + assertEquals(HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('I')); + assertEquals(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('J')); + } + + @Test + public void testHashKeyParsingStandardKeys() { + // A67 = Alt + C + HodKeyMap.KeyEntry ke1 = HodKeyMap.parseHashKey("A67", "[clear]"); + assertNotNull(ke1); + assertEquals(67, ke1.getKeyCode()); + assertEquals(HodKeyMap.MOD_ALT, ke1.getModifiers()); + assertEquals(HodKeyMap.LOCATION_STANDARD, ke1.getKeyLocation()); + assertEquals("alt C", ke1.toKeyStrokeString()); + assertEquals("[clear]", ke1.getValue()); + + // B35 = End + HodKeyMap.KeyEntry ke2 = HodKeyMap.parseHashKey("B35", "[eraseeof]"); + assertNotNull(ke2); + assertEquals(35, ke2.getKeyCode()); + assertEquals(0, ke2.getModifiers()); + assertEquals("END", ke2.toKeyStrokeString()); + + // A82 = Alt + R + HodKeyMap.KeyEntry ke3 = HodKeyMap.parseHashKey("A82", "[reset]"); + assertNotNull(ke3); + assertEquals(82, ke3.getKeyCode()); + assertEquals(HodKeyMap.MOD_ALT, ke3.getModifiers()); + assertEquals("alt R", ke3.toKeyStrokeString()); + } + + @Test + public void testDuplicateAndNumpadKeys() { + // Numpad Down: 225 + HodKeyMap.KeyEntry keDown = HodKeyMap.parseHashKey("B225", "[down]"); + assertNotNull(keDown); + assertEquals(HodKeyMap.VK_DOWN, keDown.getKeyCode()); + assertEquals(HodKeyMap.LOCATION_NUMPAD, keDown.getKeyLocation()); + assertEquals("DOWN", keDown.toKeyStrokeString()); + + // Numpad Enter: 0x2000000 + 10 = 33554442 + HodKeyMap.KeyEntry keEnter = HodKeyMap.parseHashKey("B33554442", "[enter]"); + assertNotNull(keEnter); + assertEquals(HodKeyMap.VK_ENTER, keEnter.getKeyCode()); + assertEquals(HodKeyMap.LOCATION_NUMPAD, keEnter.getKeyLocation()); + assertEquals("ENTER", keEnter.toKeyStrokeString()); + + // Generation of hash keys + assertEquals("B224", HodKeyMap.hashKey(HodKeyMap.VK_UP, 0, HodKeyMap.LOCATION_NUMPAD)); + assertEquals("B225", HodKeyMap.hashKey(HodKeyMap.VK_DOWN, 0, HodKeyMap.LOCATION_NUMPAD)); + assertEquals("B226", HodKeyMap.hashKey(HodKeyMap.VK_LEFT, 0, HodKeyMap.LOCATION_NUMPAD)); + assertEquals("B227", HodKeyMap.hashKey(HodKeyMap.VK_RIGHT, 0, HodKeyMap.LOCATION_NUMPAD)); + assertEquals("B33554442", HodKeyMap.hashKey(HodKeyMap.VK_ENTER, 0, HodKeyMap.LOCATION_NUMPAD)); + } + + @Test + public void testParseHodKmpString() throws Exception { + String kmpContent = + "[KeyRemap]\n" + + "XF:[textlogicaldisp]=[textlogicaldisp]\n" + + "A67=[clear]\n" + + "autoApply=false\n" + + "keyVersion=2\n" + + "B35=[eraseeof]\n" + + "sessionType=1\n" + + "disableKeyBuffer=false\n" + + "A82=[reset]\n" + + "codePage=037\n"; + + Map map = HodKeyMap.parseHodKmp(new StringReader(kmpContent)); + assertEquals("alt C", map.get("CLEAR")); + assertEquals("END", map.get("END")); + assertEquals("alt R", map.get("ESCAPE")); + } + + @Test + public void testParseStandardKmpFileIfPresent() throws Exception { + File f = new File("/home/rudi/Documents/IBM HoD/Standard.kmp"); + if (f.exists()) { + try (FileReader fr = new FileReader(f)) { + Map map = HodKeyMap.parseHodKmp(fr); + assertEquals("alt C", map.get("CLEAR")); + assertEquals("END", map.get("END")); + assertEquals("alt R", map.get("ESCAPE")); + } + } + } + + @Test + public void testExportHodKmp() throws Exception { + Map bindings = new LinkedHashMap<>(); + bindings.put("CLEAR", "alt C"); + bindings.put("END", "END"); + bindings.put("ESCAPE", "alt R"); + + StringWriter sw = new StringWriter(); + HodKeyMap.exportHodKmp(sw, bindings); + String out = sw.toString(); + + assertTrue(out.contains("[KeyRemap]")); + assertTrue(out.contains("A67=[clear]")); + assertTrue(out.contains("B35=[eraseeof]")); + assertTrue(out.contains("A82=[reset]")); + + // Verify roundtrip + Map parsed = HodKeyMap.parseHodKmp(new StringReader(out)); + assertEquals("alt C", parsed.get("CLEAR")); + assertEquals("END", parsed.get("END")); + assertEquals("alt R", parsed.get("ESCAPE")); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/PasteWordBreakTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/PasteWordBreakTest.java new file mode 100644 index 0000000..2ea9e3f --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/PasteWordBreakTest.java @@ -0,0 +1,88 @@ +package haus.nightmare.lib3270j.input; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.ecl.ECLPS; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; +import static org.junit.jupiter.api.Assertions.*; + +public class PasteWordBreakTest { + + private ScreenBuffer screen; + private EbcdicTranslator translator; + private InputProcessor input; + private ECLPS ps; + + @BeforeEach + public void setUp() { + translator = new EbcdicTranslator(); + screen = new ScreenBuffer(TerminalModel.IBM_3278_2, translator); + input = new InputProcessor(screen, translator, null); + ps = new ECLPS(screen, input, translator); + + screen.erase(false); + // Field 1: pos 0 (FA), pos 1..5 unprotected (length 5) + // pos 6: FA (unprotected), pos 7..16 unprotected (length 10) + // pos 17: FA (protected) + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.setFieldAttribute(6, (byte) FA_PRINTABLE); + screen.setFieldAttribute(17, (byte) (FA_PRINTABLE | FA_PROTECT)); + } + + @Test + public void testPasteWordBreakPreventsSplittingAcrossFields() { + // Cursor at pos 1 (start of Field 1) + screen.setCursorAddress(1); + input.typeCharacter('A'); + input.typeCharacter('B'); + input.typeCharacter('C'); + // Now cursor is at pos 4, remaining space in Field 1 is 2 (pos 4 and 5) + + // Paste "WORLD": length 5, does not fit in 2 positions + // With pasteWordBreak=true, it should skip to Field 2 (pos 7) and paste WORLD + input.setPasteWordBreak(true); + input.pasteText("WORLD", false, false, false, true); + + // Field 1: pos 1='A', 2='B', 3='C' with trailing blanks untouched + assertEquals("ABC ", ps.getString(1, 5)); + + // Field 2: pos 7..11 should have "WORLD" + assertEquals("WORLD", ps.getString(7, 5)); + } + + @Test + public void testPasteWithoutWordBreakSplitsWord() { + // Cursor at pos 1 (start of Field 1) + screen.setCursorAddress(1); + input.typeCharacter('A'); + input.typeCharacter('B'); + input.typeCharacter('C'); + // Now cursor is at pos 4, remaining space in Field 1 is 2 (pos 4 and 5) + + // With pasteWordBreak=false, "WO" goes to pos 4..5, and "RLD" spills into Field 2 + input.setPasteWordBreak(false); + input.pasteText("WORLD", false, false, false, false); + + assertEquals("ABCWO", ps.getString(1, 5)); + + // Field 2 receives "RLD" + assertEquals("RLD", ps.getString(7, 3)); + } + + @Test + public void testPasteWordBreakHandlesSpaceDelimiterWithoutLeadingSpaceInNextField() { + screen.setCursorAddress(1); + // Field 1 has length 5. We paste "HELLO WORLD". + // "HELLO" takes pos 1..5. The space is a delimiter. + // "WORLD" should start at pos 7 with no leading space! + input.setPasteWordBreak(true); + input.pasteText("HELLO WORLD", false, false, false, true); + + assertEquals("HELLO", ps.getString(1, 5)); + assertEquals("WORLD", ps.getString(7, 5)); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/screen/ScreenHistoryBufferTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/screen/ScreenHistoryBufferTest.java new file mode 100644 index 0000000..a7a763d --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/screen/ScreenHistoryBufferTest.java @@ -0,0 +1,194 @@ +package haus.nightmare.lib3270j.screen; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for ScreenHistoryBuffer (Phase 25.1). + * Tests rolling 64-frame ring buffer, deduplication, chronological ordering, + * text/table extraction, and listener callbacks. + */ +public class ScreenHistoryBufferTest { + + private ScreenBuffer screenBuffer; + private ScreenHistoryBuffer historyBuffer; + + @BeforeEach + public void setUp() { + screenBuffer = new ScreenBuffer(); + historyBuffer = new ScreenHistoryBuffer(64); + } + + private void writeString(int row, int col, String text) { + for (int i = 0; i < text.length(); i++) { + screenBuffer.setChar(row, col + i, text.charAt(i)); + } + } + + @Test + @DisplayName("Initial buffer state is empty") + public void testInitialState() { + assertEquals(0, historyBuffer.size()); + assertTrue(historyBuffer.isEmpty()); + assertEquals(64, historyBuffer.getCapacity()); + assertNull(historyBuffer.getLatest()); + } + + @Test + @DisplayName("Captures snapshot with correct dimensions, text, and cursor") + public void testSnapshotCapture() { + writeString(1, 0, "HELLO"); + screenBuffer.setCursorAddress(85); + + ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer); + assertNotNull(snap); + assertEquals(1, historyBuffer.size()); + assertFalse(historyBuffer.isEmpty()); + assertEquals(24, snap.getRows()); + assertEquals(80, snap.getCols()); + assertEquals(85, snap.getCursorAddress()); + assertEquals(1, snap.getCursorRow()); + assertEquals(5, snap.getCursorCol()); + + assertEquals('H', snap.getChar(1, 0)); + assertEquals('E', snap.getChar(1, 1)); + assertEquals('L', snap.getChar(1, 2)); + assertEquals('L', snap.getChar(1, 3)); + assertEquals('O', snap.getChar(1, 4)); + assertEquals("HELLO", snap.getLine(1).trim()); + } + + @Test + @DisplayName("Deduplicates identical consecutive snapshots") + public void testDeduplication() { + screenBuffer.setChar(0, 0, 'A'); + ScreenHistoryBuffer.ScreenSnapshot s1 = historyBuffer.snapshot(screenBuffer); + assertNotNull(s1); + assertEquals(1, historyBuffer.size()); + + // Snapshot identical buffer state without changes + ScreenHistoryBuffer.ScreenSnapshot s2 = historyBuffer.snapshot(screenBuffer); + assertNull(s2, "Consecutive identical snapshot should be suppressed"); + assertEquals(1, historyBuffer.size()); + + // Now modify screen buffer + screenBuffer.setChar(0, 1, 'B'); + ScreenHistoryBuffer.ScreenSnapshot s3 = historyBuffer.snapshot(screenBuffer); + assertNotNull(s3, "Snapshot should be captured after buffer modification"); + assertEquals(2, historyBuffer.size()); + } + + @Test + @DisplayName("Rolls over correctly when capacity of 64 frames is exceeded") + public void testRollingRingBufferOverflow() { + // Capture 70 distinct screens + for (int i = 0; i < 70; i++) { + screenBuffer.clear(); + screenBuffer.setCursorAddress(0); + String str = "SCREEN_" + String.format("%03d", i); + writeString(0, 0, str); + ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer); + assertNotNull(snap); + } + + // Buffer size must be capped at 64 + assertEquals(64, historyBuffer.size()); + + // The oldest snapshot (index 0) should be screen 6 (70 - 64 = 6) + ScreenHistoryBuffer.ScreenSnapshot oldest = historyBuffer.get(0); + assertTrue(oldest.getText().startsWith("SCREEN_006"), + "Oldest frame should be SCREEN_006, was: " + oldest.getText().substring(0, 10)); + + // The newest snapshot (index 63) should be screen 69 + ScreenHistoryBuffer.ScreenSnapshot newest = historyBuffer.get(63); + assertTrue(newest.getText().startsWith("SCREEN_069"), + "Newest frame should be SCREEN_069, was: " + newest.getText().substring(0, 10)); + + // getLatest() must match newest + assertEquals(newest, historyBuffer.getLatest()); + + // Check full sequential list + List all = historyBuffer.getSnapshots(); + assertEquals(64, all.size()); + for (int i = 0; i < 64; i++) { + String expected = "SCREEN_" + String.format("%03d", i + 6); + assertTrue(all.get(i).getText().startsWith(expected)); + } + } + + @Test + @DisplayName("Formatted table trims trailing whitespace per line") + public void testFormattedTable() { + screenBuffer.setCursorAddress(0); + writeString(0, 0, "ABC"); + + ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer); + assertNotNull(snap); + String table = snap.getFormattedTable(); + String[] lines = table.split("\n", -1); + assertEquals(24, lines.length); + assertEquals("ABC", lines[0]); + assertEquals("", lines[1]); + } + + @Test + @DisplayName("Bounded text range returns exact sub-box") + public void testTextRange() { + screenBuffer.setCursorAddress(0); + writeString(0, 0, "ROW0"); + writeString(1, 0, "ROW1"); + + ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer); + assertNotNull(snap); + + String sub = snap.getTextRange(0, 0, 1, 3); + assertEquals("ROW0\nROW1", sub); + } + + @Test + @DisplayName("ScreenHistoryListener receives callbacks on new snapshots") + public void testListenerNotification() { + AtomicInteger callCount = new AtomicInteger(0); + ScreenHistoryBuffer.ScreenHistoryListener listener = (buf, snap) -> callCount.incrementAndGet(); + + historyBuffer.addListener(listener); + + screenBuffer.setChar(0, 0, 'X'); + historyBuffer.snapshot(screenBuffer); + assertEquals(1, callCount.get()); + + // Duplicate snapshot doesn't fire listener + historyBuffer.snapshot(screenBuffer); + assertEquals(1, callCount.get()); + + // New change fires listener + screenBuffer.setChar(0, 1, 'Y'); + historyBuffer.snapshot(screenBuffer); + assertEquals(2, callCount.get()); + + historyBuffer.removeListener(listener); + screenBuffer.setChar(0, 2, 'Z'); + historyBuffer.snapshot(screenBuffer); + assertEquals(2, callCount.get(), "Removed listener should not receive callbacks"); + } + + @Test + @DisplayName("ScreenBuffer integration automatically records history on notifyScreenUpdate") + public void testScreenBufferAutomaticSnapshot() { + ScreenHistoryBuffer autoHist = screenBuffer.getHistoryBuffer(); + assertNotNull(autoHist); + int initialSize = autoHist.size(); + + screenBuffer.setChar(0, 0, 'T'); + screenBuffer.notifyScreenUpdate(); + + assertEquals(initialSize + 1, autoHist.size()); + assertTrue(autoHist.getLatest().getText().startsWith("T")); + } +}