Hotspot detection and screen buffer
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 36s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 1m20s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m28s
Release j3270 / Build & Publish Release (push) Successful in 1m39s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 36s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 1m20s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m28s
Release j3270 / Build & Publish Release (push) Successful in 1m39s
This commit is contained in:
@@ -45,6 +45,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
|||||||
private MacroDialog macroDialog;
|
private MacroDialog macroDialog;
|
||||||
private FieldInspectorDialog fieldInspectorDialog;
|
private FieldInspectorDialog fieldInspectorDialog;
|
||||||
private PrinterSessionDialog printerSessionDialog;
|
private PrinterSessionDialog printerSessionDialog;
|
||||||
|
private ScreenHistoryDialog screenHistoryDialog;
|
||||||
|
|
||||||
// Modes menu items
|
// Modes menu items
|
||||||
private JCheckBoxMenuItem docModeItem;
|
private JCheckBoxMenuItem docModeItem;
|
||||||
@@ -95,6 +96,9 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
|||||||
if (printerSessionDialog != null) {
|
if (printerSessionDialog != null) {
|
||||||
printerSessionDialog.dispose();
|
printerSessionDialog.dispose();
|
||||||
}
|
}
|
||||||
|
if (screenHistoryDialog != null) {
|
||||||
|
screenHistoryDialog.dispose();
|
||||||
|
}
|
||||||
terminalPanel.dispose();
|
terminalPanel.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +144,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
|||||||
if (macroDialog != null && macroDialog.isVisible()) return true;
|
if (macroDialog != null && macroDialog.isVisible()) return true;
|
||||||
if (fieldInspectorDialog != null && fieldInspectorDialog.isVisible()) return true;
|
if (fieldInspectorDialog != null && fieldInspectorDialog.isVisible()) return true;
|
||||||
if (printerSessionDialog != null && printerSessionDialog.isVisible()) return true;
|
if (printerSessionDialog != null && printerSessionDialog.isVisible()) return true;
|
||||||
|
if (screenHistoryDialog != null && screenHistoryDialog.isVisible()) return true;
|
||||||
for (Window owned : getOwnedWindows()) {
|
for (Window owned : getOwnedWindows()) {
|
||||||
if (owned != null && owned.isVisible()) {
|
if (owned != null && owned.isVisible()) {
|
||||||
return true;
|
return true;
|
||||||
@@ -206,6 +211,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
|||||||
|
|
||||||
// 2. Edit menu
|
// 2. Edit menu
|
||||||
JMenu editMenu = createMenu("Edit");
|
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("Copy", KeyEvent.VK_C, () -> terminalPanel.copySelection()));
|
||||||
editMenu.add(createMenuItem("Paste", KeyEvent.VK_V, () -> terminalPanel.pasteClipboard()));
|
editMenu.add(createMenuItem("Paste", KeyEvent.VK_V, () -> terminalPanel.pasteClipboard()));
|
||||||
editMenu.add(createMenuItem("Paste with Line Wrap...", -1, this::showPasteLineWrapDialog));
|
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.add(gfxMenu);
|
||||||
|
|
||||||
viewMenu.addSeparator();
|
viewMenu.addSeparator();
|
||||||
|
viewMenu.add(createMenuItem("Screen History...", KeyEvent.VK_H, this::showScreenHistoryDialog, true));
|
||||||
viewMenu.add(createMenuItem("Field Inspector...", -1, this::showFieldInspectorDialog));
|
viewMenu.add(createMenuItem("Field Inspector...", -1, this::showFieldInspectorDialog));
|
||||||
menuBar.add(viewMenu);
|
menuBar.add(viewMenu);
|
||||||
|
|
||||||
@@ -456,6 +463,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
|||||||
actionsMenu.addSeparator();
|
actionsMenu.addSeparator();
|
||||||
actionsMenu.add(createMenuItem("Run Keystrokes / Script...", -1, this::showScriptDialog));
|
actionsMenu.add(createMenuItem("Run Keystrokes / Script...", -1, this::showScriptDialog));
|
||||||
actionsMenu.add(createMenuItem("Macro Runtime (<HASCRIPT>)...", KeyEvent.VK_M, this::showMacroDialog, true));
|
actionsMenu.add(createMenuItem("Macro Runtime (<HASCRIPT>)...", 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));
|
actionsMenu.add(createMenuItem("File Transfer...", KeyEvent.VK_T, this::showFileTransferDialog, true));
|
||||||
menuBar.add(actionsMenu);
|
menuBar.add(actionsMenu);
|
||||||
|
|
||||||
@@ -592,6 +600,18 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
|||||||
printerSessionDialog.setVisible(true);
|
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() {
|
private void showPasteLineWrapDialog() {
|
||||||
if (client == null || !client.getConnectionState().isFullSession()) {
|
if (client == null || !client.getConnectionState().isFullSession()) {
|
||||||
JOptionPane.showMessageDialog(this, "Connect to a 3270 session before pasting.", "Not Connected", JOptionPane.WARNING_MESSAGE);
|
JOptionPane.showMessageDialog(this, "Connect to a 3270 session before pasting.", "Not Connected", JOptionPane.WARNING_MESSAGE);
|
||||||
|
|||||||
@@ -439,6 +439,33 @@ public class Settings {
|
|||||||
flushPrefs();
|
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 ==========
|
// ========== Crosshair Ruler ==========
|
||||||
|
|
||||||
public static boolean getCrosshairRuler() {
|
public static boolean getCrosshairRuler() {
|
||||||
@@ -449,6 +476,71 @@ public class Settings {
|
|||||||
prefs.putBoolean("crosshairRuler", enabled);
|
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 ==========
|
// ========== Status Bar Visibility ==========
|
||||||
|
|
||||||
public static boolean getStatusBarVisible() {
|
public static boolean getStatusBarVisible() {
|
||||||
@@ -685,6 +777,18 @@ public class Settings {
|
|||||||
case "pasteStopAtProtected":
|
case "pasteStopAtProtected":
|
||||||
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
|
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
|
||||||
break;
|
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:
|
default:
|
||||||
log.warning("Unknown behavior/connection key: " + key);
|
log.warning("Unknown behavior/connection key: " + key);
|
||||||
}
|
}
|
||||||
@@ -706,6 +810,18 @@ public class Settings {
|
|||||||
case "pasteStopAtProtected":
|
case "pasteStopAtProtected":
|
||||||
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
|
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
|
||||||
break;
|
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:
|
default:
|
||||||
log.warning("Unknown clipboard key: " + key);
|
log.warning("Unknown clipboard key: " + key);
|
||||||
}
|
}
|
||||||
@@ -840,6 +956,9 @@ public class Settings {
|
|||||||
w.println("autoSysUnlock = " + getAutoSysUnlock());
|
w.println("autoSysUnlock = " + getAutoSysUnlock());
|
||||||
w.println("enablePasteFromExcel = " + getEnablePasteFromExcel());
|
w.println("enablePasteFromExcel = " + getEnablePasteFromExcel());
|
||||||
w.println("pasteStopAtProtectedLine = " + getPasteStopAtProtectedLine());
|
w.println("pasteStopAtProtectedLine = " + getPasteStopAtProtectedLine());
|
||||||
|
w.println("trimRectSizingHandles = " + getTrimRectSizingHandles());
|
||||||
|
w.println("trimRectRemainAfterEdit = " + getTrimRectRemainAfterEdit());
|
||||||
|
w.println("pasteWordBreak = " + getPasteWordBreak());
|
||||||
w.println();
|
w.println();
|
||||||
|
|
||||||
// [entryassist]
|
// [entryassist]
|
||||||
|
|||||||
@@ -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<Hotspot> 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<Hotspot> 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<Hotspot> 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<Hotspot> 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<Hotspot> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,7 +206,8 @@ public class KeyBindings {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a .kmp file from a Reader.
|
* Parse a .kmp file from a Reader.
|
||||||
* Supports both INI-style KEY<n>=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
|
* @param reader the reader to parse from
|
||||||
* @return Map of j3270 action names to key stroke bindings
|
* @return Map of j3270 action names to key stroke bindings
|
||||||
@@ -214,11 +215,38 @@ public class KeyBindings {
|
|||||||
*/
|
*/
|
||||||
public static Map<String, String> parseKmp(Reader reader) throws IOException {
|
public static Map<String, String> parseKmp(Reader reader) throws IOException {
|
||||||
BufferedReader br = (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader);
|
BufferedReader br = (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader);
|
||||||
Map<String, List<String>> actionToBindings = new LinkedHashMap<>();
|
List<String> rawLines = new ArrayList<>();
|
||||||
|
boolean hasHodKeyRemap = false;
|
||||||
|
boolean hasHodHashKey = false;
|
||||||
String line;
|
String line;
|
||||||
while ((line = br.readLine()) != null) {
|
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<String, String> hodMap = haus.nightmare.lib3270j.input.HodKeyMap.parseHodKmp(
|
||||||
|
new StringReader(String.join("\n", rawLines))
|
||||||
|
);
|
||||||
|
if (hodMap != null && !hodMap.isEmpty()) {
|
||||||
|
return hodMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<String>> actionToBindings = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (String raw : rawLines) {
|
||||||
|
line = raw.trim();
|
||||||
|
|
||||||
// Strip comments
|
// Strip comments
|
||||||
int commentIdx = line.indexOf(';');
|
int commentIdx = line.indexOf(';');
|
||||||
@@ -262,6 +290,22 @@ public class KeyBindings {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export key bindings to a .kmp file using IBM HoD format.
|
||||||
|
*/
|
||||||
|
public static void exportKmp(File file, Map<String, String> 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<String, String> keyBindings) throws IOException {
|
||||||
|
haus.nightmare.lib3270j.input.HodKeyMap.exportHodKmp(writer, keyBindings);
|
||||||
|
}
|
||||||
|
|
||||||
private static void parseBindStatement(String stmt, Map<String, List<String>> actionToBindings) {
|
private static void parseBindStatement(String stmt, Map<String, List<String>> actionToBindings) {
|
||||||
String[] parts = stmt.split("\\s+", 2);
|
String[] parts = stmt.split("\\s+", 2);
|
||||||
if (parts.length < 2) return;
|
if (parts.length < 2) return;
|
||||||
@@ -288,6 +332,19 @@ public class KeyBindings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void parseKeyAssignment(String left, String right, Map<String, List<String>> actionToBindings) {
|
private static void parseKeyAssignment(String left, String right, Map<String, List<String>> 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<n> or Modifier-KEY<n>, right is mnemonic
|
// Case A: left is KEY<n> or Modifier-KEY<n>, right is mnemonic
|
||||||
String action = resolveMnemonic(right);
|
String action = resolveMnemonic(right);
|
||||||
String keyStroke = null;
|
String keyStroke = null;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,18 @@ public class SettingsDialog extends JDialog {
|
|||||||
private JCheckBox blockPasteCheck;
|
private JCheckBox blockPasteCheck;
|
||||||
private JCheckBox enablePasteFromExcelCheck;
|
private JCheckBox enablePasteFromExcelCheck;
|
||||||
private JCheckBox pasteStopAtProtectedCheck;
|
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
|
// Connection tab
|
||||||
private JComboBox<Settings.StartupBehavior> startupBehaviorBox;
|
private JComboBox<Settings.StartupBehavior> startupBehaviorBox;
|
||||||
@@ -338,7 +350,17 @@ public class SettingsDialog extends JDialog {
|
|||||||
JTabbedPane behaviorTabs = new JTabbedPane();
|
JTabbedPane behaviorTabs = new JTabbedPane();
|
||||||
ThemeManager.styleTabbedPane(behaviorTabs);
|
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());
|
behaviorTabs.addTab("Keymap", createKeymapPanel());
|
||||||
|
|
||||||
panel.add(behaviorTabs, BorderLayout.CENTER);
|
panel.add(behaviorTabs, BorderLayout.CENTER);
|
||||||
@@ -346,12 +368,13 @@ public class SettingsDialog extends JDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private JPanel createGeneralBehaviorPanel() {
|
private JPanel createGeneralBehaviorPanel() {
|
||||||
JPanel panel = new JPanel();
|
ScrollablePanel panel = new ScrollablePanel();
|
||||||
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
|
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
|
||||||
panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
|
panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
|
||||||
|
|
||||||
// Group 1: Typing & Operational Modes
|
// Group 1: Typing & Operational Modes
|
||||||
JPanel typingGroup = new JPanel(new GridBagLayout());
|
JPanel typingGroup = new JPanel(new GridBagLayout());
|
||||||
|
typingGroup.setAlignmentX(Component.LEFT_ALIGNMENT);
|
||||||
typingGroup.setBorder(BorderFactory.createTitledBorder("Typing & Operational Modes"));
|
typingGroup.setBorder(BorderFactory.createTitledBorder("Typing & Operational Modes"));
|
||||||
GridBagConstraints gbc = new GridBagConstraints();
|
GridBagConstraints gbc = new GridBagConstraints();
|
||||||
gbc.insets = new Insets(4, 6, 4, 6);
|
gbc.insets = new Insets(4, 6, 4, 6);
|
||||||
@@ -402,6 +425,7 @@ public class SettingsDialog extends JDialog {
|
|||||||
|
|
||||||
// Group 2: Selection & Clipboard
|
// Group 2: Selection & Clipboard
|
||||||
JPanel selGroup = new JPanel(new GridBagLayout());
|
JPanel selGroup = new JPanel(new GridBagLayout());
|
||||||
|
selGroup.setAlignmentX(Component.LEFT_ALIGNMENT);
|
||||||
selGroup.setBorder(BorderFactory.createTitledBorder("Selection & Clipboard"));
|
selGroup.setBorder(BorderFactory.createTitledBorder("Selection & Clipboard"));
|
||||||
GridBagConstraints gbcS = new GridBagConstraints();
|
GridBagConstraints gbcS = new GridBagConstraints();
|
||||||
gbcS.insets = new Insets(4, 6, 4, 6);
|
gbcS.insets = new Insets(4, 6, 4, 6);
|
||||||
@@ -430,7 +454,106 @@ public class SettingsDialog extends JDialog {
|
|||||||
gbcS.gridy = 3;
|
gbcS.gridy = 3;
|
||||||
selGroup.add(pasteStopAtProtectedCheck, gbcS);
|
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(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());
|
panel.add(Box.createVerticalGlue());
|
||||||
|
|
||||||
return panel;
|
return panel;
|
||||||
@@ -676,14 +799,23 @@ public class SettingsDialog extends JDialog {
|
|||||||
monoPanel.add(p);
|
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));
|
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);
|
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);
|
ThemeManager.styleButton(btnResetColors, ThemeManager.ButtonVariant.DEFAULT);
|
||||||
btnResetColors.addActionListener(e -> {
|
btnResetColors.addActionListener(e -> {
|
||||||
for (int i=0; i<16; i++) {
|
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.");
|
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(hostPanel, BorderLayout.NORTH);
|
||||||
main.add(monoPanel, BorderLayout.CENTER);
|
main.add(monoPanel, BorderLayout.CENTER);
|
||||||
@@ -870,7 +1003,7 @@ public class SettingsDialog extends JDialog {
|
|||||||
btnPanel.add(btnReset);
|
btnPanel.add(btnReset);
|
||||||
|
|
||||||
// Import Keymap — import from .kmp file
|
// Import Keymap — import from .kmp file
|
||||||
JButton btnImportKmp = new JButton("Import Keymap...");
|
JButton btnImportKmp = new JButton("Import .kmp...");
|
||||||
ThemeManager.styleButton(btnImportKmp, ThemeManager.ButtonVariant.DEFAULT);
|
ThemeManager.styleButton(btnImportKmp, ThemeManager.ButtonVariant.DEFAULT);
|
||||||
btnImportKmp.addActionListener(e -> {
|
btnImportKmp.addActionListener(e -> {
|
||||||
JFileChooser chooser = new JFileChooser();
|
JFileChooser chooser = new JFileChooser();
|
||||||
@@ -908,6 +1041,36 @@ public class SettingsDialog extends JDialog {
|
|||||||
});
|
});
|
||||||
btnPanel.add(btnImportKmp);
|
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(new JScrollPane(table), BorderLayout.CENTER);
|
||||||
main.add(btnPanel, BorderLayout.SOUTH);
|
main.add(btnPanel, BorderLayout.SOUTH);
|
||||||
|
|
||||||
@@ -987,6 +1150,36 @@ public class SettingsDialog extends JDialog {
|
|||||||
if (pasteStopAtProtectedCheck != null) {
|
if (pasteStopAtProtectedCheck != null) {
|
||||||
Settings.setPasteStopAtProtectedLine(pasteStopAtProtectedCheck.isSelected());
|
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
|
// Apply Connection
|
||||||
if (startupBehaviorBox != null) {
|
if (startupBehaviorBox != null) {
|
||||||
@@ -1133,6 +1326,43 @@ public class SettingsDialog extends JDialog {
|
|||||||
if (pasteStopAtProtectedCheck != null) {
|
if (pasteStopAtProtectedCheck != null) {
|
||||||
pasteStopAtProtectedCheck.setSelected(Settings.getPasteStopAtProtectedLine());
|
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
|
// Connection
|
||||||
if (startupBehaviorBox != null) {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
private boolean isActivationClick = false;
|
private boolean isActivationClick = false;
|
||||||
private static final Color SELECTION_COLOR = new Color(75, 110, 175, 102); // 40% alpha blend
|
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 ==========
|
// ========== Search Highlight state ==========
|
||||||
private int searchHighlightAddr = -1;
|
private int searchHighlightAddr = -1;
|
||||||
private int searchHighlightLen = 0;
|
private int searchHighlightLen = 0;
|
||||||
@@ -89,6 +98,14 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
private boolean resizeGuard = false;
|
private boolean resizeGuard = false;
|
||||||
private StatusBar statusBar = null;
|
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 ==========
|
// ========== Mode and Listener State ==========
|
||||||
private boolean insertMode = false;
|
private boolean insertMode = false;
|
||||||
|
|
||||||
@@ -179,6 +196,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
setupCursorBlink();
|
setupCursorBlink();
|
||||||
|
|
||||||
blockSelectMode = haus.nightmare.j3270.config.Settings.getBlockSelectMode();
|
blockSelectMode = haus.nightmare.j3270.config.Settings.getBlockSelectMode();
|
||||||
|
trimRectSizingHandles = haus.nightmare.j3270.config.Settings.getTrimRectSizingHandles();
|
||||||
crosshairRulerEnabled = haus.nightmare.j3270.config.Settings.getCrosshairRuler();
|
crosshairRulerEnabled = haus.nightmare.j3270.config.Settings.getCrosshairRuler();
|
||||||
String cStyle = haus.nightmare.j3270.config.Settings.getCursorStyle();
|
String cStyle = haus.nightmare.j3270.config.Settings.getCursorStyle();
|
||||||
cursorStyle = "UNDERLINE".equalsIgnoreCase(cStyle) ? CursorStyle.UNDERLINE : CursorStyle.BLOCK;
|
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();
|
boolean isGraphic = client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive();
|
||||||
if (isGraphic || lightPenMode) {
|
if (isGraphic || lightPenMode) {
|
||||||
clearSelection();
|
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 {
|
} else {
|
||||||
|
activeDragHandle = DragHandle.NONE;
|
||||||
|
selectionStartRow = row;
|
||||||
|
selectionStartCol = col;
|
||||||
|
selectionEndRow = row;
|
||||||
|
selectionEndCol = col;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeDragHandle = DragHandle.NONE;
|
||||||
selectionStartRow = row;
|
selectionStartRow = row;
|
||||||
selectionStartCol = col;
|
selectionStartCol = col;
|
||||||
selectionEndRow = row;
|
selectionEndRow = row;
|
||||||
@@ -252,9 +291,44 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
col = Math.max(0, Math.min(col, sb.getDisplayCols() - 1));
|
col = Math.max(0, Math.min(col, sb.getDisplayCols() - 1));
|
||||||
row = Math.max(0, Math.min(row, sb.getDisplayRows() - 1));
|
row = Math.max(0, Math.min(row, sb.getDisplayRows() - 1));
|
||||||
|
|
||||||
|
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;
|
selectionEndRow = row;
|
||||||
selectionEndCol = col;
|
selectionEndCol = col;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
repaint();
|
repaint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,12 +349,72 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
py = Math.max(0, Math.min(py, gHeight - 1));
|
py = Math.max(0, Math.min(py, gHeight - 1));
|
||||||
client.getGocaDecoder().setGraphicCursorFromPixel(px, py);
|
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
|
@Override
|
||||||
public void mouseReleased(MouseEvent e) {
|
public void mouseReleased(MouseEvent e) {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
|
if (activeDragHandle != DragHandle.NONE) {
|
||||||
|
activeDragHandle = DragHandle.NONE;
|
||||||
|
isActivationClick = false;
|
||||||
|
repaint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (client != null && client.getConnectionState().isFullSession()) {
|
if (client != null && client.getConnectionState().isFullSession()) {
|
||||||
ScreenBuffer sb = client.getScreenBuffer();
|
ScreenBuffer sb = client.getScreenBuffer();
|
||||||
int ox = getRenderOffsetX();
|
int ox = getRenderOffsetX();
|
||||||
@@ -346,6 +480,21 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
return;
|
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):
|
// Automatic Light-Pen / Selectable Field detection on mouse click (HoD parity):
|
||||||
if (sb.isFormatted()) {
|
if (sb.isFormatted()) {
|
||||||
int faPos = sb.findFieldAttribute(clickAddr);
|
int faPos = sb.findFieldAttribute(clickAddr);
|
||||||
@@ -397,6 +546,21 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
addMouseListener(mouseHandler);
|
addMouseListener(mouseHandler);
|
||||||
addMouseMotionListener(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
|
// Handle resize: keep user configured font persistent without auto-fit reset
|
||||||
addComponentListener(new ComponentAdapter() {
|
addComponentListener(new ComponentAdapter() {
|
||||||
@Override
|
@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 ==========
|
// ========== 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() {
|
public void clearSelection() {
|
||||||
selectionStartRow = -1;
|
selectionStartRow = -1;
|
||||||
selectionStartCol = -1;
|
selectionStartCol = -1;
|
||||||
@@ -454,23 +714,30 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
int r2 = Math.max(selectionStartRow, selectionEndRow);
|
int r2 = Math.max(selectionStartRow, selectionEndRow);
|
||||||
int c1, c2;
|
int c1, c2;
|
||||||
|
|
||||||
if (blockSelectMode) {
|
if (blockSelectMode || trimRectSizingHandles) {
|
||||||
c1 = Math.min(selectionStartCol, selectionEndCol);
|
c1 = Math.min(selectionStartCol, selectionEndCol);
|
||||||
c2 = Math.max(selectionStartCol, selectionEndCol);
|
c2 = Math.max(selectionStartCol, selectionEndCol);
|
||||||
|
|
||||||
StringBuilder result = new StringBuilder();
|
StringBuilder result = new StringBuilder();
|
||||||
for (int r = r1; r <= r2; r++) {
|
for (int r = r1; r <= r2; r++) {
|
||||||
if (r > r1) result.append('\n');
|
if (r > r1) result.append('\n');
|
||||||
|
StringBuilder line = new StringBuilder();
|
||||||
for (int c = c1; c <= c2; c++) {
|
for (int c = c1; c <= c2; c++) {
|
||||||
int baddr = r * cols + c;
|
int baddr = r * cols + c;
|
||||||
ExtendedAttribute ea = sb.getCell(baddr);
|
ExtendedAttribute ea = sb.getCell(baddr);
|
||||||
char ch = ea.ucs4;
|
char ch = ea.ucs4;
|
||||||
if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) {
|
if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) {
|
||||||
result.append(' ');
|
line.append(' ');
|
||||||
} else {
|
} 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();
|
return result.toString();
|
||||||
} else {
|
} else {
|
||||||
@@ -485,21 +752,34 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StringBuilder result = new StringBuilder();
|
StringBuilder result = new StringBuilder();
|
||||||
|
StringBuilder line = new StringBuilder();
|
||||||
int prevRow = startAddr / cols;
|
int prevRow = startAddr / cols;
|
||||||
for (int addr = startAddr; addr <= endAddr; addr++) {
|
for (int addr = startAddr; addr <= endAddr; addr++) {
|
||||||
int curRow = addr / cols;
|
int curRow = addr / cols;
|
||||||
if (curRow != prevRow) {
|
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;
|
prevRow = curRow;
|
||||||
}
|
}
|
||||||
ExtendedAttribute ea = sb.getCell(addr);
|
ExtendedAttribute ea = sb.getCell(addr);
|
||||||
char ch = ea.ucs4;
|
char ch = ea.ucs4;
|
||||||
if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) {
|
if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) {
|
||||||
result.append(' ');
|
line.append(' ');
|
||||||
} else {
|
} 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();
|
return result.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,6 +790,65 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
StringSelection ss = new StringSelection(text);
|
StringSelection ss = new StringSelection(text);
|
||||||
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(ss, null);
|
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() {
|
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 excelPaste = haus.nightmare.j3270.config.Settings.getEnablePasteFromExcel();
|
||||||
boolean stopAtProtected = haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine();
|
boolean stopAtProtected = haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine();
|
||||||
boolean blockPaste = haus.nightmare.j3270.config.Settings.getBlockPaste();
|
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"))) {
|
if (client.getInputProcessor() != null && (excelPaste || stopAtProtected || blockPaste || wordBreak || text.contains("\t") || text.contains("\n") || text.contains("\r"))) {
|
||||||
client.getInputProcessor().pasteText(text, excelPaste, stopAtProtected, blockPaste);
|
client.getInputProcessor().pasteText(text, excelPaste, stopAtProtected, blockPaste, wordBreak);
|
||||||
} else if (client.getPS() != null) {
|
} else if (client.getPS() != null) {
|
||||||
|
client.getPS().setPasteWordBreak(wordBreak);
|
||||||
if (blockPaste) {
|
if (blockPaste) {
|
||||||
client.getPS().pasteRectangular(text, curRow, curCol);
|
client.getPS().pasteRectangular(text, curRow, curCol);
|
||||||
} else {
|
} 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();
|
refreshScreen();
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
@@ -592,6 +936,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
JPopupMenu popup = new JPopupMenu();
|
JPopupMenu popup = new JPopupMenu();
|
||||||
ThemeManager.stylePopupMenu(popup);
|
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");
|
JMenuItem copyItem = new JMenuItem("Copy");
|
||||||
ThemeManager.styleMenuItem(copyItem);
|
ThemeManager.styleMenuItem(copyItem);
|
||||||
copyItem.setEnabled(hasSelection());
|
copyItem.setEnabled(hasSelection());
|
||||||
@@ -627,6 +977,15 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
});
|
});
|
||||||
popup.add(blockPasteItem);
|
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());
|
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 r1 = Math.min(selectionStartRow, selectionEndRow);
|
||||||
int r2 = Math.max(selectionStartRow, selectionEndRow);
|
int r2 = Math.max(selectionStartRow, selectionEndRow);
|
||||||
|
|
||||||
if (blockSelectMode) {
|
if (blockSelectMode || trimRectSizingHandles) {
|
||||||
int c1 = Math.min(selectionStartCol, selectionEndCol);
|
int c1 = Math.min(selectionStartCol, selectionEndCol);
|
||||||
int c2 = Math.max(selectionStartCol, selectionEndCol);
|
int c2 = Math.max(selectionStartCol, selectionEndCol);
|
||||||
return row >= r1 && row <= r2 && col >= c1 && col <= c2;
|
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"));
|
bindKeyToMap(im, "APL", haus.nightmare.j3270.config.Settings.getKeyBinding("APL", "alt F3"));
|
||||||
|
|
||||||
// Copy/Paste/Lightpen bindings
|
// 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 defCopy = haus.nightmare.j3270.config.Settings.getDefaultBinding("COPY");
|
||||||
String defPaste = haus.nightmare.j3270.config.Settings.getDefaultBinding("PASTE");
|
String defPaste = haus.nightmare.j3270.config.Settings.getDefaultBinding("PASTE");
|
||||||
String defSelectAll = haus.nightmare.j3270.config.Settings.getDefaultBinding("SELECTALL");
|
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, "COPY", haus.nightmare.j3270.config.Settings.getKeyBinding("COPY", defCopy));
|
||||||
bindKeyToMap(im, "PASTE", haus.nightmare.j3270.config.Settings.getKeyBinding("PASTE", defPaste));
|
bindKeyToMap(im, "PASTE", haus.nightmare.j3270.config.Settings.getKeyBinding("PASTE", defPaste));
|
||||||
bindKeyToMap(im, "SELECTALL", haus.nightmare.j3270.config.Settings.getKeyBinding("SELECTALL", defSelectAll));
|
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-SYSREQ", createAction(this::handleSysReq));
|
||||||
am.put("j3270-CURSEL", createAction(this::handleCursorSelect));
|
am.put("j3270-CURSEL", createAction(this::handleCursorSelect));
|
||||||
|
|
||||||
|
am.put("j3270-CUT", createAction(this::cutSelection));
|
||||||
am.put("j3270-COPY", createAction(this::copySelection));
|
am.put("j3270-COPY", createAction(this::copySelection));
|
||||||
am.put("j3270-PASTE", createAction(this::pasteClipboard));
|
am.put("j3270-PASTE", createAction(this::pasteClipboard));
|
||||||
am.put("j3270-SELECTALL", createAction(this::selectAll));
|
am.put("j3270-SELECTALL", createAction(this::selectAll));
|
||||||
@@ -1292,11 +1654,23 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
|
|||||||
setupFont();
|
setupFont();
|
||||||
applyModeSettings();
|
applyModeSettings();
|
||||||
blockSelectMode = haus.nightmare.j3270.config.Settings.getBlockSelectMode();
|
blockSelectMode = haus.nightmare.j3270.config.Settings.getBlockSelectMode();
|
||||||
|
trimRectSizingHandles = haus.nightmare.j3270.config.Settings.getTrimRectSizingHandles();
|
||||||
crosshairRulerEnabled = haus.nightmare.j3270.config.Settings.getCrosshairRuler();
|
crosshairRulerEnabled = haus.nightmare.j3270.config.Settings.getCrosshairRuler();
|
||||||
inputMaskEnabled = haus.nightmare.j3270.config.Settings.getInputMask();
|
inputMaskEnabled = haus.nightmare.j3270.config.Settings.getInputMask();
|
||||||
inputMaskChar = haus.nightmare.j3270.config.Settings.getInputMaskChar();
|
inputMaskChar = haus.nightmare.j3270.config.Settings.getInputMaskChar();
|
||||||
String cStyle = haus.nightmare.j3270.config.Settings.getCursorStyle();
|
String cStyle = haus.nightmare.j3270.config.Settings.getCursorStyle();
|
||||||
cursorStyle = "UNDERLINE".equalsIgnoreCase(cStyle) ? CursorStyle.UNDERLINE : CursorStyle.BLOCK;
|
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();
|
revalidate();
|
||||||
repaint();
|
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
|
// Draw Graphic Cursor if active
|
||||||
boolean isGraphicCursor = (client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive())
|
boolean isGraphicCursor = (client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive())
|
||||||
|| (client.getGraphicsPlane() != null && client.getGraphicsPlane().isGraphicCursorAttached());
|
|| (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() {
|
public void dispose() {
|
||||||
if (blinkTimer != null)
|
if (blinkTimer != null)
|
||||||
blinkTimer.stop();
|
blinkTimer.stop();
|
||||||
|
|||||||
@@ -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<HotspotEngine.Hotspot> 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<HotspotEngine.Hotspot> 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<HotspotEngine.Hotspot> 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<HotspotEngine.Hotspot> 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<HotspotEngine.Hotspot> 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<HotspotEngine.Hotspot> 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<HotspotEngine.Hotspot> empty = engine.scanText(screen, 1, 80);
|
||||||
|
assertEquals(0, empty.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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<String, String> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ public class ECLSession extends ECLConnection implements ECLConstants {
|
|||||||
public static final String BLOCK_PASTE = "blockPaste";
|
public static final String BLOCK_PASTE = "blockPaste";
|
||||||
public static final String PASTE_FIELD_WRAP = "pasteFieldWrap";
|
public static final String PASTE_FIELD_WRAP = "pasteFieldWrap";
|
||||||
public static final String PASTE_LINE_WRAP = "pasteLineWrap";
|
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 haus.nightmare.lib3270j.ecl.ECLSession delegate;
|
||||||
private final ECLPS ps;
|
private final ECLPS ps;
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ public class Telnet3270Client {
|
|||||||
ps.notifyAlarm();
|
ps.notifyAlarm();
|
||||||
}
|
}
|
||||||
@Override public void onKeyboardUnlocked() {
|
@Override public void onKeyboardUnlocked() {
|
||||||
|
if (screenBuffer != null) {
|
||||||
|
screenBuffer.getHistoryBuffer().snapshot(screenBuffer);
|
||||||
|
}
|
||||||
ps.notifyKeyUnlocked();
|
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 setWordWrap(boolean b) { if (screenBuffer != null) screenBuffer.setEntryAssistWordWrap(b); if (oia != null) oia.notifyOIAChanged(); }
|
||||||
public void toggleWordWrap() { setWordWrap(!isWordWrap()); }
|
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. */
|
/** APL Keyboard Mode operations. */
|
||||||
public boolean isAplMode() { return inputProcessor != null && inputProcessor.isAplKeyboardMode(); }
|
public boolean isAplMode() { return inputProcessor != null && inputProcessor.isAplKeyboardMode(); }
|
||||||
public void setAplMode(boolean b) { if (inputProcessor != null) inputProcessor.setAplKeyboardMode(b); }
|
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() {
|
public haus.nightmare.lib3270j.graphics.GocaDecoder getGocaDecoder() {
|
||||||
return dsProcessor.getGocaDecoder();
|
return dsProcessor.getGocaDecoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public haus.nightmare.lib3270j.screen.ScreenHistoryBuffer getScreenHistoryBuffer() {
|
||||||
|
return screenBuffer != null ? screenBuffer.getHistoryBuffer() : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -845,13 +845,37 @@ public class ECLPS implements ECLConstants {
|
|||||||
|
|
||||||
public void SetPasteStopAtProtectedLine(boolean val) { setPasteStopAtProtectedLine(val); }
|
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) {
|
public synchronized int pasteFromExcel(String text, int row, int col) {
|
||||||
if (text == null || text.isEmpty() || screen == null) return 0;
|
if (text == null || text.isEmpty() || screen == null) return 0;
|
||||||
if (row >= 0 && col >= 0) {
|
if (row >= 0 && col >= 0) {
|
||||||
setCursorPos(row, col);
|
setCursorPos(row, col);
|
||||||
}
|
}
|
||||||
if (inputProcessor != null) {
|
if (inputProcessor != null) {
|
||||||
return inputProcessor.pasteText(text, true, isPasteStopAtProtectedLine(), isBlockPaste());
|
return inputProcessor.pasteText(text, true, isPasteStopAtProtectedLine(), isBlockPaste(), isPasteWordBreak());
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -867,7 +891,7 @@ public class ECLPS implements ECLConstants {
|
|||||||
if (text == null || text.isEmpty() || screen == null) return 0;
|
if (text == null || text.isEmpty() || screen == null) return 0;
|
||||||
if (inputProcessor != null && (text.contains("\t") || isEnablePasteFromExcel() || isPasteStopAtProtectedLine() || isBlockPaste())) {
|
if (inputProcessor != null && (text.contains("\t") || isEnablePasteFromExcel() || isPasteStopAtProtectedLine() || isBlockPaste())) {
|
||||||
setCursorPos(row, col);
|
setCursorPos(row, col);
|
||||||
return inputProcessor.pasteText(text, isEnablePasteFromExcel(), isPasteStopAtProtectedLine(), isBlockPaste());
|
return inputProcessor.pasteText(text, isEnablePasteFromExcel(), isPasteStopAtProtectedLine(), isBlockPaste(), isPasteWordBreak());
|
||||||
}
|
}
|
||||||
int rows = screen.getRows();
|
int rows = screen.getRows();
|
||||||
int cols = screen.getCols();
|
int cols = screen.getCols();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class ECLSession {
|
|||||||
public static final String BLOCK_PASTE = "blockPaste";
|
public static final String BLOCK_PASTE = "blockPaste";
|
||||||
public static final String PASTE_FIELD_WRAP = "pasteFieldWrap";
|
public static final String PASTE_FIELD_WRAP = "pasteFieldWrap";
|
||||||
public static final String PASTE_LINE_WRAP = "pasteLineWrap";
|
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_DOCMODE = "EntryAssist_DOCmode";
|
||||||
public static final String ENTRYASSIST_DOCWORDWRAP = "EntryAssist_DOCwordWrap";
|
public static final String ENTRYASSIST_DOCWORDWRAP = "EntryAssist_DOCwordWrap";
|
||||||
public static final String ENTRYASSIST_STARTCOL = "EntryAssist_startCol";
|
public static final String ENTRYASSIST_STARTCOL = "EntryAssist_startCol";
|
||||||
|
|||||||
@@ -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<Integer> 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<Integer> 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<Integer, String> KEYCODE_NAMES = new HashMap<>();
|
||||||
|
private static final Map<String, Integer> 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<String, String> parseHodKmp(Reader reader) throws IOException {
|
||||||
|
BufferedReader br = (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader);
|
||||||
|
Map<String, List<String>> 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<String, String> result = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, List<String>> 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<String, String> 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<String, String> 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() + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
* - '\n' or '\r\n' triggers a NewLine action advancing to the first unprotected field of the next line.
|
||||||
* When pasteStopAtProtectedLine is true:
|
* When pasteStopAtProtectedLine is true:
|
||||||
* - Halts or truncates paste if cursor reaches a protected line/boundary or if the current field is full.
|
* - 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) {
|
private boolean pasteWordBreak = true;
|
||||||
return pasteText(text, enablePasteFromExcel, pasteStopAtProtectedLine, this.blockPaste);
|
|
||||||
|
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 text text to paste
|
||||||
* @param enablePasteFromExcel whether to parse tabs as field advances and newlines as row advances
|
* @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 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
|
* @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) {
|
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) {
|
if (text == null || text.isEmpty() || screen == null || keyboardLocked) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -573,9 +591,10 @@ public class InputProcessor {
|
|||||||
if (ch >= 0x20 && ch != 0x7F) {
|
if (ch >= 0x20 && ch != 0x7F) {
|
||||||
if (screen.isFormatted()) {
|
if (screen.isFormatted()) {
|
||||||
int baddr = screen.getCursorAddress();
|
int baddr = screen.getCursorAddress();
|
||||||
|
int totalScreenSize = screen.getRows() * screen.getCols();
|
||||||
ExtendedAttribute ea = screen.getCell(baddr);
|
ExtendedAttribute ea = screen.getCell(baddr);
|
||||||
if (ea.isFieldAttribute()) {
|
if (ea.isFieldAttribute()) {
|
||||||
baddr = (baddr + 1) % (screen.getRows() * screen.getCols());
|
baddr = (baddr + 1) % totalScreenSize;
|
||||||
}
|
}
|
||||||
byte faVal = screen.getFieldAttributeAt(baddr);
|
byte faVal = screen.getFieldAttributeAt(baddr);
|
||||||
if (faIsProtected(faVal & 0xFF)) {
|
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);
|
typeCharacter(ch);
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ public class ScreenBuffer {
|
|||||||
private final Condition syncCondition = syncLock.newCondition();
|
private final Condition syncCondition = syncLock.newCondition();
|
||||||
private final List<ScreenUpdateListener> updateListeners = new CopyOnWriteArrayList<>();
|
private final List<ScreenUpdateListener> 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() {
|
public ReentrantLock getSyncLock() {
|
||||||
return syncLock;
|
return syncLock;
|
||||||
}
|
}
|
||||||
@@ -99,6 +106,7 @@ public class ScreenBuffer {
|
|||||||
|
|
||||||
public void notifyScreenUpdate() {
|
public void notifyScreenUpdate() {
|
||||||
updateDisplaySnapshot();
|
updateDisplaySnapshot();
|
||||||
|
historyBuffer.snapshot(this);
|
||||||
signalWaiters();
|
signalWaiters();
|
||||||
for (ScreenUpdateListener l : updateListeners) {
|
for (ScreenUpdateListener l : updateListeners) {
|
||||||
try {
|
try {
|
||||||
@@ -481,6 +489,46 @@ public class ScreenBuffer {
|
|||||||
return buffer[fa_addr].fa;
|
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.
|
* 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.
|
* If the cell has an explicit foreground color attribute (0xF0-0xFF), it is returned.
|
||||||
|
|||||||
@@ -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<ScreenHistoryListener> 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<ScreenSnapshot> getSnapshots() {
|
||||||
|
List<ScreenSnapshot> list = new ArrayList<>(count);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
list.add(get(i));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> parsed = HodKeyMap.parseHodKmp(new StringReader(out));
|
||||||
|
assertEquals("alt C", parsed.get("CLEAR"));
|
||||||
|
assertEquals("END", parsed.get("END"));
|
||||||
|
assertEquals("alt R", parsed.get("ESCAPE"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ScreenHistoryBuffer.ScreenSnapshot> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user