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

This commit is contained in:
2026-09-18 18:18:53 +00:00
parent 8a30efe9c0
commit aa724fbcef
20 changed files with 3603 additions and 34 deletions
@@ -45,6 +45,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
private MacroDialog macroDialog;
private FieldInspectorDialog fieldInspectorDialog;
private PrinterSessionDialog printerSessionDialog;
private ScreenHistoryDialog screenHistoryDialog;
// Modes menu items
private JCheckBoxMenuItem docModeItem;
@@ -95,6 +96,9 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
if (printerSessionDialog != null) {
printerSessionDialog.dispose();
}
if (screenHistoryDialog != null) {
screenHistoryDialog.dispose();
}
terminalPanel.dispose();
}
@@ -140,6 +144,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
if (macroDialog != null && macroDialog.isVisible()) return true;
if (fieldInspectorDialog != null && fieldInspectorDialog.isVisible()) return true;
if (printerSessionDialog != null && printerSessionDialog.isVisible()) return true;
if (screenHistoryDialog != null && screenHistoryDialog.isVisible()) return true;
for (Window owned : getOwnedWindows()) {
if (owned != null && owned.isVisible()) {
return true;
@@ -206,6 +211,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
// 2. Edit menu
JMenu editMenu = createMenu("Edit");
editMenu.add(createMenuItem("Cut", KeyEvent.VK_X, () -> terminalPanel.cutSelection()));
editMenu.add(createMenuItem("Copy", KeyEvent.VK_C, () -> terminalPanel.copySelection()));
editMenu.add(createMenuItem("Paste", KeyEvent.VK_V, () -> terminalPanel.pasteClipboard()));
editMenu.add(createMenuItem("Paste with Line Wrap...", -1, this::showPasteLineWrapDialog));
@@ -337,6 +343,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
viewMenu.add(gfxMenu);
viewMenu.addSeparator();
viewMenu.add(createMenuItem("Screen History...", KeyEvent.VK_H, this::showScreenHistoryDialog, true));
viewMenu.add(createMenuItem("Field Inspector...", -1, this::showFieldInspectorDialog));
menuBar.add(viewMenu);
@@ -456,6 +463,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
actionsMenu.addSeparator();
actionsMenu.add(createMenuItem("Run Keystrokes / Script...", -1, this::showScriptDialog));
actionsMenu.add(createMenuItem("Macro Runtime (<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));
menuBar.add(actionsMenu);
@@ -592,6 +600,18 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
printerSessionDialog.setVisible(true);
}
private void showScreenHistoryDialog() {
if (screenHistoryDialog == null) {
screenHistoryDialog = new ScreenHistoryDialog(this, client);
} else {
if (client != null && client.getScreenHistoryBuffer() != null) {
screenHistoryDialog.refreshHistoryView(client.getScreenHistoryBuffer().size() - 1);
}
}
screenHistoryDialog.setVisible(true);
screenHistoryDialog.toFront();
}
private void showPasteLineWrapDialog() {
if (client == null || !client.getConnectionState().isFullSession()) {
JOptionPane.showMessageDialog(this, "Connect to a 3270 session before pasting.", "Not Connected", JOptionPane.WARNING_MESSAGE);
@@ -439,6 +439,33 @@ public class Settings {
flushPrefs();
}
public static boolean getTrimRectSizingHandles() {
return prefs.getBoolean("trimRectSizingHandles", false);
}
public static void setTrimRectSizingHandles(boolean val) {
prefs.putBoolean("trimRectSizingHandles", val);
flushPrefs();
}
public static boolean getTrimRectRemainAfterEdit() {
return prefs.getBoolean("trimRectRemainAfterEdit", false);
}
public static void setTrimRectRemainAfterEdit(boolean val) {
prefs.putBoolean("trimRectRemainAfterEdit", val);
flushPrefs();
}
public static boolean getPasteWordBreak() {
return prefs.getBoolean("pasteWordBreak", true);
}
public static void setPasteWordBreak(boolean val) {
prefs.putBoolean("pasteWordBreak", val);
flushPrefs();
}
// ========== Crosshair Ruler ==========
public static boolean getCrosshairRuler() {
@@ -449,6 +476,71 @@ public class Settings {
prefs.putBoolean("crosshairRuler", enabled);
}
// ========== Phase 25: Hotspots & Mouse Wheel Navigation ==========
public static boolean getHotspotsEnabled() {
return prefs.getBoolean("hotspotsEnabled", true);
}
public static void setHotspotsEnabled(boolean enabled) {
prefs.putBoolean("hotspotsEnabled", enabled);
flushPrefs();
}
public static boolean getHotspotsUrlEnabled() {
return prefs.getBoolean("hotspotsUrlEnabled", true);
}
public static void setHotspotsUrlEnabled(boolean enabled) {
prefs.putBoolean("hotspotsUrlEnabled", enabled);
flushPrefs();
}
public static boolean getHotspotsPfEnabled() {
return prefs.getBoolean("hotspotsPfEnabled", true);
}
public static void setHotspotsPfEnabled(boolean enabled) {
prefs.putBoolean("hotspotsPfEnabled", enabled);
flushPrefs();
}
public static boolean getHotspotsMenuChoicesEnabled() {
return prefs.getBoolean("hotspotsMenuChoicesEnabled", true);
}
public static void setHotspotsMenuChoicesEnabled(boolean enabled) {
prefs.putBoolean("hotspotsMenuChoicesEnabled", enabled);
flushPrefs();
}
public static boolean getMouseWheelEnabled() {
return prefs.getBoolean("mouseWheelEnabled", true);
}
public static void setMouseWheelEnabled(boolean enabled) {
prefs.putBoolean("mouseWheelEnabled", enabled);
flushPrefs();
}
public static String getMouseWheelUpKey() {
return prefs.get("mouseWheelUpKey", "[pf7]");
}
public static void setMouseWheelUpKey(String key) {
prefs.put("mouseWheelUpKey", key != null && !key.trim().isEmpty() ? key.trim() : "[pf7]");
flushPrefs();
}
public static String getMouseWheelDownKey() {
return prefs.get("mouseWheelDownKey", "[pf8]");
}
public static void setMouseWheelDownKey(String key) {
prefs.put("mouseWheelDownKey", key != null && !key.trim().isEmpty() ? key.trim() : "[pf8]");
flushPrefs();
}
// ========== Status Bar Visibility ==========
public static boolean getStatusBarVisible() {
@@ -685,6 +777,18 @@ public class Settings {
case "pasteStopAtProtected":
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
break;
case "trimRectSizingHandles":
case "sizingHandles":
setTrimRectSizingHandles(Boolean.parseBoolean(value));
break;
case "trimRectRemainAfterEdit":
case "remainAfterEdit":
setTrimRectRemainAfterEdit(Boolean.parseBoolean(value));
break;
case "pasteWordBreak":
case "wordBreak":
setPasteWordBreak(Boolean.parseBoolean(value));
break;
default:
log.warning("Unknown behavior/connection key: " + key);
}
@@ -706,6 +810,18 @@ public class Settings {
case "pasteStopAtProtected":
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
break;
case "trimRectSizingHandles":
case "sizingHandles":
setTrimRectSizingHandles(Boolean.parseBoolean(value));
break;
case "trimRectRemainAfterEdit":
case "remainAfterEdit":
setTrimRectRemainAfterEdit(Boolean.parseBoolean(value));
break;
case "pasteWordBreak":
case "wordBreak":
setPasteWordBreak(Boolean.parseBoolean(value));
break;
default:
log.warning("Unknown clipboard key: " + key);
}
@@ -840,6 +956,9 @@ public class Settings {
w.println("autoSysUnlock = " + getAutoSysUnlock());
w.println("enablePasteFromExcel = " + getEnablePasteFromExcel());
w.println("pasteStopAtProtectedLine = " + getPasteStopAtProtectedLine());
w.println("trimRectSizingHandles = " + getTrimRectSizingHandles());
w.println("trimRectRemainAfterEdit = " + getTrimRectRemainAfterEdit());
w.println("pasteWordBreak = " + getPasteWordBreak());
w.println();
// [entryassist]
@@ -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.
* 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
* @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 {
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;
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
int commentIdx = line.indexOf(';');
@@ -262,6 +290,22 @@ public class KeyBindings {
return result;
}
/**
* Export key bindings to a .kmp file using IBM HoD format.
*/
public static void exportKmp(File file, Map<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) {
String[] parts = stmt.split("\\s+", 2);
if (parts.length < 2) return;
@@ -288,6 +332,19 @@ public class KeyBindings {
}
private static void parseKeyAssignment(String left, String right, Map<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
String action = resolveMnemonic(right);
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 enablePasteFromExcelCheck;
private JCheckBox pasteStopAtProtectedCheck;
private JCheckBox pasteWordBreakCheck;
private JCheckBox trimRectSizingHandlesCheck;
private JCheckBox trimRectRemainAfterEditCheck;
// Phase 25: Hotspots & Mouse Wheel controls
private JCheckBox hotspotsEnabledCheck;
private JCheckBox hotspotsUrlCheck;
private JCheckBox hotspotsPfCheck;
private JCheckBox hotspotsMenuCheck;
private JCheckBox mouseWheelEnabledCheck;
private JTextField mouseWheelUpField;
private JTextField mouseWheelDownField;
// Connection tab
private JComboBox<Settings.StartupBehavior> startupBehaviorBox;
@@ -338,7 +350,17 @@ public class SettingsDialog extends JDialog {
JTabbedPane behaviorTabs = new JTabbedPane();
ThemeManager.styleTabbedPane(behaviorTabs);
behaviorTabs.addTab("General", createGeneralBehaviorPanel());
JPanel generalContent = createGeneralBehaviorPanel();
JScrollPane generalScroll = new JScrollPane(generalContent);
ThemeManager.styleScrollPane(generalScroll);
generalScroll.setBorder(BorderFactory.createEmptyBorder());
if (generalScroll.getViewport() != null) {
generalScroll.getViewport().setBackground(generalContent.getBackground());
}
generalScroll.getVerticalScrollBar().setUnitIncrement(16);
generalScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
behaviorTabs.addTab("General", generalScroll);
behaviorTabs.addTab("Keymap", createKeymapPanel());
panel.add(behaviorTabs, BorderLayout.CENTER);
@@ -346,12 +368,13 @@ public class SettingsDialog extends JDialog {
}
private JPanel createGeneralBehaviorPanel() {
JPanel panel = new JPanel();
ScrollablePanel panel = new ScrollablePanel();
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
// Group 1: Typing & Operational Modes
JPanel typingGroup = new JPanel(new GridBagLayout());
typingGroup.setAlignmentX(Component.LEFT_ALIGNMENT);
typingGroup.setBorder(BorderFactory.createTitledBorder("Typing & Operational Modes"));
GridBagConstraints gbc = new GridBagConstraints();
gbc.insets = new Insets(4, 6, 4, 6);
@@ -402,6 +425,7 @@ public class SettingsDialog extends JDialog {
// Group 2: Selection & Clipboard
JPanel selGroup = new JPanel(new GridBagLayout());
selGroup.setAlignmentX(Component.LEFT_ALIGNMENT);
selGroup.setBorder(BorderFactory.createTitledBorder("Selection & Clipboard"));
GridBagConstraints gbcS = new GridBagConstraints();
gbcS.insets = new Insets(4, 6, 4, 6);
@@ -430,7 +454,106 @@ public class SettingsDialog extends JDialog {
gbcS.gridy = 3;
selGroup.add(pasteStopAtProtectedCheck, gbcS);
pasteWordBreakCheck = new JCheckBox("Paste without splitting words across fields", Settings.getPasteWordBreak());
ThemeManager.styleCheckBox(pasteWordBreakCheck);
gbcS.gridy = 4;
selGroup.add(pasteWordBreakCheck, gbcS);
trimRectSizingHandlesCheck = new JCheckBox("Trim rectangle sizing handles", Settings.getTrimRectSizingHandles());
ThemeManager.styleCheckBox(trimRectSizingHandlesCheck);
gbcS.gridy = 5;
selGroup.add(trimRectSizingHandlesCheck, gbcS);
trimRectRemainAfterEditCheck = new JCheckBox("Trim rectangle remains after cut/copy/paste", Settings.getTrimRectRemainAfterEdit());
ThemeManager.styleCheckBox(trimRectRemainAfterEditCheck);
gbcS.gridy = 6;
selGroup.add(trimRectRemainAfterEditCheck, gbcS);
panel.add(selGroup);
panel.add(Box.createVerticalStrut(10));
// Group 3: Hotspots & Mouse Wheel Navigation (Phase 25)
JPanel hotGroup = new JPanel(new GridBagLayout());
hotGroup.setAlignmentX(Component.LEFT_ALIGNMENT);
hotGroup.setBorder(BorderFactory.createTitledBorder("Hotspots & Mouse Wheel Navigation"));
GridBagConstraints gbcH = new GridBagConstraints();
gbcH.insets = new Insets(3, 6, 3, 6);
gbcH.anchor = GridBagConstraints.WEST;
gbcH.fill = GridBagConstraints.HORIZONTAL;
gbcH.gridx = 0;
gbcH.weightx = 1.0;
hotspotsEnabledCheck = new JCheckBox("Enable Interactive Hotspots (links, function keys, choices)", Settings.getHotspotsEnabled());
ThemeManager.styleCheckBox(hotspotsEnabledCheck);
gbcH.gridy = 0;
hotGroup.add(hotspotsEnabledCheck, gbcH);
hotspotsUrlCheck = new JCheckBox("Auto-detect URLs (http://, https://, ftp://, www)", Settings.getHotspotsUrlEnabled());
ThemeManager.styleCheckBox(hotspotsUrlCheck);
gbcH.gridy = 1;
gbcH.insets = new Insets(2, 24, 2, 6);
hotGroup.add(hotspotsUrlCheck, gbcH);
hotspotsPfCheck = new JCheckBox("Auto-detect Function keys (PF1..PF24, F1..F24, PA1..PA3, [ENTER])", Settings.getHotspotsPfEnabled());
ThemeManager.styleCheckBox(hotspotsPfCheck);
gbcH.gridy = 2;
hotGroup.add(hotspotsPfCheck, gbcH);
hotspotsMenuCheck = new JCheckBox("Auto-detect Menu choices (\"1.\", \"2.\", \"===> 3\")", Settings.getHotspotsMenuChoicesEnabled());
ThemeManager.styleCheckBox(hotspotsMenuCheck);
gbcH.gridy = 3;
hotGroup.add(hotspotsMenuCheck, gbcH);
hotspotsEnabledCheck.addActionListener(e -> {
boolean en = hotspotsEnabledCheck.isSelected();
hotspotsUrlCheck.setEnabled(en);
hotspotsPfCheck.setEnabled(en);
hotspotsMenuCheck.setEnabled(en);
});
boolean initHotEn = hotspotsEnabledCheck.isSelected();
hotspotsUrlCheck.setEnabled(initHotEn);
hotspotsPfCheck.setEnabled(initHotEn);
hotspotsMenuCheck.setEnabled(initHotEn);
mouseWheelEnabledCheck = new JCheckBox("Enable Mouse Wheel Navigation (scroll up/down)", Settings.getMouseWheelEnabled());
ThemeManager.styleCheckBox(mouseWheelEnabledCheck);
gbcH.gridy = 4;
gbcH.insets = new Insets(6, 6, 3, 6);
hotGroup.add(mouseWheelEnabledCheck, gbcH);
JPanel wheelKeysPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
wheelKeysPanel.setOpaque(false);
JLabel lblUp = new JLabel("Wheel Up:");
mouseWheelUpField = new JTextField(Settings.getMouseWheelUpKey(), 6);
ThemeManager.styleTextField(mouseWheelUpField);
JLabel lblDown = new JLabel("Wheel Down:");
mouseWheelDownField = new JTextField(Settings.getMouseWheelDownKey(), 6);
ThemeManager.styleTextField(mouseWheelDownField);
wheelKeysPanel.add(lblUp);
wheelKeysPanel.add(mouseWheelUpField);
wheelKeysPanel.add(Box.createHorizontalStrut(12));
wheelKeysPanel.add(lblDown);
wheelKeysPanel.add(mouseWheelDownField);
mouseWheelEnabledCheck.addActionListener(e -> {
boolean mwEn = mouseWheelEnabledCheck.isSelected();
lblUp.setEnabled(mwEn);
mouseWheelUpField.setEnabled(mwEn);
lblDown.setEnabled(mwEn);
mouseWheelDownField.setEnabled(mwEn);
});
boolean initMwEn = mouseWheelEnabledCheck.isSelected();
lblUp.setEnabled(initMwEn);
mouseWheelUpField.setEnabled(initMwEn);
lblDown.setEnabled(initMwEn);
mouseWheelDownField.setEnabled(initMwEn);
gbcH.gridy = 5;
gbcH.insets = new Insets(2, 24, 2, 6);
hotGroup.add(wheelKeysPanel, gbcH);
panel.add(hotGroup);
panel.add(Box.createVerticalGlue());
return panel;
@@ -676,14 +799,23 @@ public class SettingsDialog extends JDialog {
monoPanel.add(p);
}
JPanel bottomPanel = new JPanel(new BorderLayout(8, 0));
JPanel bottomPanel = new JPanel();
bottomPanel.setLayout(new BoxLayout(bottomPanel, BoxLayout.Y_AXIS));
bottomPanel.setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6));
fourColorOverrideCheck = new JCheckBox("Base 4-Color Override mode (3279 green/white/red/turquoise)", Settings.getFourColorOverride());
fourColorOverrideCheck = new JCheckBox("Base 4 Override Mode (3279)", Settings.getFourColorOverride());
fourColorOverrideCheck.setToolTipText("Base 4-Color Override mode (3279 green/white/red/turquoise)");
ThemeManager.styleCheckBox(fourColorOverrideCheck);
bottomPanel.add(fourColorOverrideCheck, BorderLayout.WEST);
fourColorOverrideCheck.setAlignmentX(Component.LEFT_ALIGNMENT);
bottomPanel.add(fourColorOverrideCheck);
JButton btnResetColors = new JButton("Reset to Defaults");
bottomPanel.add(Box.createVerticalStrut(4));
JPanel btnRow = new JPanel(new BorderLayout());
btnRow.setOpaque(false);
btnRow.setAlignmentX(Component.LEFT_ALIGNMENT);
JButton btnResetColors = new JButton("Restore Defaults");
ThemeManager.styleButton(btnResetColors, ThemeManager.ButtonVariant.DEFAULT);
btnResetColors.addActionListener(e -> {
for (int i=0; i<16; i++) {
@@ -701,7 +833,8 @@ public class SettingsDialog extends JDialog {
}
JOptionPane.showMessageDialog(main, "Colors reset. Click Apply to save.");
});
bottomPanel.add(btnResetColors, BorderLayout.EAST);
btnRow.add(btnResetColors, BorderLayout.EAST);
bottomPanel.add(btnRow);
main.add(hostPanel, BorderLayout.NORTH);
main.add(monoPanel, BorderLayout.CENTER);
@@ -870,7 +1003,7 @@ public class SettingsDialog extends JDialog {
btnPanel.add(btnReset);
// Import Keymap — import from .kmp file
JButton btnImportKmp = new JButton("Import Keymap...");
JButton btnImportKmp = new JButton("Import .kmp...");
ThemeManager.styleButton(btnImportKmp, ThemeManager.ButtonVariant.DEFAULT);
btnImportKmp.addActionListener(e -> {
JFileChooser chooser = new JFileChooser();
@@ -908,6 +1041,36 @@ public class SettingsDialog extends JDialog {
});
btnPanel.add(btnImportKmp);
// Export Keymap — export to .kmp file
JButton btnExportKmp = new JButton("Export .kmp...");
ThemeManager.styleButton(btnExportKmp, ThemeManager.ButtonVariant.DEFAULT);
btnExportKmp.addActionListener(e -> {
JFileChooser chooser = new JFileChooser();
chooser.setDialogTitle("Export Keymap Profile");
chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("Keyboard Map Profile (*.kmp)", "kmp"));
chooser.setSelectedFile(new File("User.kmp"));
int ret = chooser.showSaveDialog(this);
if (ret == JFileChooser.APPROVE_OPTION) {
File f = chooser.getSelectedFile();
if (!f.getName().toLowerCase().endsWith(".kmp")) {
f = new File(f.getParentFile(), f.getName() + ".kmp");
}
try {
KeyBindings.exportKmp(f, tempKeyBindings);
JOptionPane.showMessageDialog(this,
"Successfully exported key bindings to " + f.getName(),
"Keymap Exported",
JOptionPane.INFORMATION_MESSAGE);
} catch (Exception ex) {
JOptionPane.showMessageDialog(this,
"Failed to export keymap: " + ex.getMessage(),
"Export Error",
JOptionPane.ERROR_MESSAGE);
}
}
});
btnPanel.add(btnExportKmp);
main.add(new JScrollPane(table), BorderLayout.CENTER);
main.add(btnPanel, BorderLayout.SOUTH);
@@ -987,6 +1150,36 @@ public class SettingsDialog extends JDialog {
if (pasteStopAtProtectedCheck != null) {
Settings.setPasteStopAtProtectedLine(pasteStopAtProtectedCheck.isSelected());
}
if (pasteWordBreakCheck != null) {
Settings.setPasteWordBreak(pasteWordBreakCheck.isSelected());
}
if (trimRectSizingHandlesCheck != null) {
Settings.setTrimRectSizingHandles(trimRectSizingHandlesCheck.isSelected());
}
if (trimRectRemainAfterEditCheck != null) {
Settings.setTrimRectRemainAfterEdit(trimRectRemainAfterEditCheck.isSelected());
}
if (hotspotsEnabledCheck != null) {
Settings.setHotspotsEnabled(hotspotsEnabledCheck.isSelected());
}
if (hotspotsUrlCheck != null) {
Settings.setHotspotsUrlEnabled(hotspotsUrlCheck.isSelected());
}
if (hotspotsPfCheck != null) {
Settings.setHotspotsPfEnabled(hotspotsPfCheck.isSelected());
}
if (hotspotsMenuCheck != null) {
Settings.setHotspotsMenuChoicesEnabled(hotspotsMenuCheck.isSelected());
}
if (mouseWheelEnabledCheck != null) {
Settings.setMouseWheelEnabled(mouseWheelEnabledCheck.isSelected());
}
if (mouseWheelUpField != null) {
Settings.setMouseWheelUpKey(mouseWheelUpField.getText().trim());
}
if (mouseWheelDownField != null) {
Settings.setMouseWheelDownKey(mouseWheelDownField.getText().trim());
}
// Apply Connection
if (startupBehaviorBox != null) {
@@ -1133,6 +1326,43 @@ public class SettingsDialog extends JDialog {
if (pasteStopAtProtectedCheck != null) {
pasteStopAtProtectedCheck.setSelected(Settings.getPasteStopAtProtectedLine());
}
if (pasteWordBreakCheck != null) {
pasteWordBreakCheck.setSelected(Settings.getPasteWordBreak());
}
if (trimRectSizingHandlesCheck != null) {
trimRectSizingHandlesCheck.setSelected(Settings.getTrimRectSizingHandles());
}
if (trimRectRemainAfterEditCheck != null) {
trimRectRemainAfterEditCheck.setSelected(Settings.getTrimRectRemainAfterEdit());
}
if (hotspotsEnabledCheck != null) {
hotspotsEnabledCheck.setSelected(Settings.getHotspotsEnabled());
boolean en = Settings.getHotspotsEnabled();
if (hotspotsUrlCheck != null) {
hotspotsUrlCheck.setSelected(Settings.getHotspotsUrlEnabled());
hotspotsUrlCheck.setEnabled(en);
}
if (hotspotsPfCheck != null) {
hotspotsPfCheck.setSelected(Settings.getHotspotsPfEnabled());
hotspotsPfCheck.setEnabled(en);
}
if (hotspotsMenuCheck != null) {
hotspotsMenuCheck.setSelected(Settings.getHotspotsMenuChoicesEnabled());
hotspotsMenuCheck.setEnabled(en);
}
}
if (mouseWheelEnabledCheck != null) {
boolean mwEn = Settings.getMouseWheelEnabled();
mouseWheelEnabledCheck.setSelected(mwEn);
if (mouseWheelUpField != null) {
mouseWheelUpField.setText(Settings.getMouseWheelUpKey());
mouseWheelUpField.setEnabled(mwEn);
}
if (mouseWheelDownField != null) {
mouseWheelDownField.setText(Settings.getMouseWheelDownKey());
mouseWheelDownField.setEnabled(mwEn);
}
}
// Connection
if (startupBehaviorBox != null) {
@@ -1302,4 +1532,39 @@ public class SettingsDialog extends JDialog {
}
}
}
private static class ScrollablePanel extends JPanel implements Scrollable {
public ScrollablePanel() {
super();
}
public ScrollablePanel(LayoutManager layout) {
super(layout);
}
@Override
public Dimension getPreferredScrollableViewportSize() {
return getPreferredSize();
}
@Override
public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) {
return 16;
}
@Override
public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) {
return 48;
}
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
@Override
public boolean getScrollableTracksViewportHeight() {
return false;
}
}
}
@@ -77,6 +77,15 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
private boolean isActivationClick = false;
private static final Color SELECTION_COLOR = new Color(75, 110, 175, 102); // 40% alpha blend
public enum DragHandle {
NONE, NW, N, NE, E, SE, S, SW, W, MOVE
}
private boolean trimRectSizingHandles = false;
private DragHandle activeDragHandle = DragHandle.NONE;
private int dragAnchorRow = -1;
private int dragAnchorCol = -1;
private int initialBoxR1 = -1, initialBoxR2 = -1, initialBoxC1 = -1, initialBoxC2 = -1;
// ========== Search Highlight state ==========
private int searchHighlightAddr = -1;
private int searchHighlightLen = 0;
@@ -89,6 +98,14 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
private boolean resizeGuard = false;
private StatusBar statusBar = null;
// ========== Phase 25: Hotspots & Mouse Wheel ==========
private final HotspotEngine hotspotEngine = new HotspotEngine();
private HotspotEngine.Hotspot hoveredHotspot = null;
private boolean hotspotsEnabled = haus.nightmare.j3270.config.Settings.getHotspotsEnabled();
private boolean mouseWheelEnabled = haus.nightmare.j3270.config.Settings.getMouseWheelEnabled();
private String mouseWheelUpAction = haus.nightmare.j3270.config.Settings.getMouseWheelUpKey();
private String mouseWheelDownAction = haus.nightmare.j3270.config.Settings.getMouseWheelDownKey();
// ========== Mode and Listener State ==========
private boolean insertMode = false;
@@ -179,6 +196,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
setupCursorBlink();
blockSelectMode = haus.nightmare.j3270.config.Settings.getBlockSelectMode();
trimRectSizingHandles = haus.nightmare.j3270.config.Settings.getTrimRectSizingHandles();
crosshairRulerEnabled = haus.nightmare.j3270.config.Settings.getCrosshairRuler();
String cStyle = haus.nightmare.j3270.config.Settings.getCursorStyle();
cursorStyle = "UNDERLINE".equalsIgnoreCase(cStyle) ? CursorStyle.UNDERLINE : CursorStyle.BLOCK;
@@ -218,7 +236,28 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
boolean isGraphic = client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive();
if (isGraphic || lightPenMode) {
clearSelection();
} else if (SwingUtilities.isLeftMouseButton(e) && trimRectSizingHandles && hasSelection()) {
DragHandle handle = getDragHandleAt(e.getX(), e.getY());
if (handle != DragHandle.NONE) {
activeDragHandle = handle;
isDragging = true;
dragAnchorRow = row;
dragAnchorCol = col;
initialBoxR1 = Math.min(selectionStartRow, selectionEndRow);
initialBoxR2 = Math.max(selectionStartRow, selectionEndRow);
initialBoxC1 = Math.min(selectionStartCol, selectionEndCol);
initialBoxC2 = Math.max(selectionStartCol, selectionEndCol);
repaint();
return;
} else {
activeDragHandle = DragHandle.NONE;
selectionStartRow = row;
selectionStartCol = col;
selectionEndRow = row;
selectionEndCol = col;
}
} else {
activeDragHandle = DragHandle.NONE;
selectionStartRow = row;
selectionStartCol = col;
selectionEndRow = row;
@@ -252,8 +291,43 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
col = Math.max(0, Math.min(col, sb.getDisplayCols() - 1));
row = Math.max(0, Math.min(row, sb.getDisplayRows() - 1));
selectionEndRow = row;
selectionEndCol = col;
if (activeDragHandle != DragHandle.NONE) {
if (activeDragHandle == DragHandle.MOVE) {
int deltaR = row - dragAnchorRow;
int deltaC = col - dragAnchorCol;
int h = initialBoxR2 - initialBoxR1;
int w = initialBoxC2 - initialBoxC1;
int newR1 = Math.max(0, Math.min(initialBoxR1 + deltaR, sb.getDisplayRows() - 1 - h));
int newC1 = Math.max(0, Math.min(initialBoxC1 + deltaC, sb.getDisplayCols() - 1 - w));
selectionStartRow = newR1;
selectionStartCol = newC1;
selectionEndRow = newR1 + h;
selectionEndCol = newC1 + w;
} else {
int newR1 = initialBoxR1;
int newR2 = initialBoxR2;
int newC1 = initialBoxC1;
int newC2 = initialBoxC2;
switch (activeDragHandle) {
case NW: newR1 = row; newC1 = col; break;
case N: newR1 = row; break;
case NE: newR1 = row; newC2 = col; break;
case E: newC2 = col; break;
case SE: newR2 = row; newC2 = col; break;
case S: newR2 = row; break;
case SW: newR2 = row; newC1 = col; break;
case W: newC1 = col; break;
default: break;
}
selectionStartRow = Math.min(newR1, newR2);
selectionEndRow = Math.max(newR1, newR2);
selectionStartCol = Math.min(newC1, newC2);
selectionEndCol = Math.max(newC1, newC2);
}
} else {
selectionEndRow = row;
selectionEndCol = col;
}
}
repaint();
}
@@ -275,12 +349,72 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
py = Math.max(0, Math.min(py, gHeight - 1));
client.getGocaDecoder().setGraphicCursorFromPixel(px, py);
}
// Phase 26.1: Drag handle cursor hover detection
if (trimRectSizingHandles && hasSelection() && client != null && client.getConnectionState().isFullSession()) {
DragHandle handle = getDragHandleAt(e.getX(), e.getY());
if (handle != DragHandle.NONE) {
setCursor(getCursorForDragHandle(handle));
setToolTipText(null);
return;
}
}
// Phase 25.2: Real-time hotspot hover detection
if (hotspotsEnabled && client != null && client.getScreenBuffer() != null) {
int ox = getRenderOffsetX();
int oy = getRenderOffsetY();
ScreenBuffer sb = client.getScreenBuffer();
int rows = sb.getDisplayRows();
int cols = sb.getDisplayCols();
int col = (e.getX() - ox) / cellWidth;
int row = (e.getY() - oy) / cellHeight;
if (row >= 0 && row < rows && col >= 0 && col < cols) {
hotspotEngine.scan(sb);
HotspotEngine.Hotspot h = hotspotEngine.findHotspot(row, col);
if (h != null) {
if (hoveredHotspot != h) {
hoveredHotspot = h;
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
setToolTipText(h.getTooltip());
repaint();
}
return;
}
}
}
if (hoveredHotspot != null) {
hoveredHotspot = null;
setCursor(Cursor.getDefaultCursor());
setToolTipText(null);
repaint();
} else if (activeDragHandle == DragHandle.NONE) {
setCursor(Cursor.getDefaultCursor());
}
}
@Override
public void mouseExited(MouseEvent e) {
if (hoveredHotspot != null) {
hoveredHotspot = null;
setCursor(Cursor.getDefaultCursor());
setToolTipText(null);
repaint();
} else if (activeDragHandle == DragHandle.NONE) {
setCursor(Cursor.getDefaultCursor());
}
}
@Override
public void mouseReleased(MouseEvent e) {
if (isDragging) {
isDragging = false;
if (activeDragHandle != DragHandle.NONE) {
activeDragHandle = DragHandle.NONE;
isActivationClick = false;
repaint();
return;
}
if (client != null && client.getConnectionState().isFullSession()) {
ScreenBuffer sb = client.getScreenBuffer();
int ox = getRenderOffsetX();
@@ -346,6 +480,21 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
return;
}
// Phase 25.2: Interactive Hotspot click execution (URLs, PF keys, choices)
if (hotspotsEnabled && client.getConnectionState().isFullSession()) {
HotspotEngine.Hotspot h = hotspotEngine.findHotspot(row, col);
if (h != null) {
if (h.getType() == HotspotEngine.HotspotType.URL) {
openUrl(h.getAction());
return;
} else {
client.getPS().SendKeys(h.getAction());
refreshScreen();
return;
}
}
}
// Automatic Light-Pen / Selectable Field detection on mouse click (HoD parity):
if (sb.isFormatted()) {
int faPos = sb.findFieldAttribute(clickAddr);
@@ -397,6 +546,21 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
addMouseListener(mouseHandler);
addMouseMotionListener(mouseHandler);
// Phase 25.2: Mouse wheel navigation (Wheel Up -> [pf7], Wheel Down -> [pf8])
addMouseWheelListener(e -> {
if (!mouseWheelEnabled || client == null || !client.getConnectionState().isFullSession()) {
return;
}
int rotation = e.getWheelRotation();
if (rotation < 0) {
client.getPS().SendKeys(mouseWheelUpAction);
refreshScreen();
} else if (rotation > 0) {
client.getPS().SendKeys(mouseWheelDownAction);
refreshScreen();
}
});
// Handle resize: keep user configured font persistent without auto-fit reset
addComponentListener(new ComponentAdapter() {
@Override
@@ -407,8 +571,104 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
});
}
// ========== Phase 25: Hotspot URL Opener ==========
private void openUrl(String url) {
if (url == null || url.trim().isEmpty()) return;
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(new java.net.URI(url));
return;
}
} catch (Exception ignored) {
}
// Fallback: command line launcher
try {
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
Runtime.getRuntime().exec(new String[]{"rundll32", "url.dll,FileProtocolHandler", url});
} else if (os.contains("mac")) {
Runtime.getRuntime().exec(new String[]{"open", url});
} else {
Runtime.getRuntime().exec(new String[]{"xdg-open", url});
}
} catch (Exception ex) {
Toolkit.getDefaultToolkit().beep();
}
}
public HotspotEngine getHotspotEngine() {
return hotspotEngine;
}
// ========== Selection / Copy-Paste ==========
public boolean isBlockSelectMode() {
return blockSelectMode;
}
public void setBlockSelectMode(boolean val) {
this.blockSelectMode = val;
repaint();
}
public boolean isTrimRectSizingHandles() {
return trimRectSizingHandles;
}
public void setTrimRectSizingHandles(boolean val) {
this.trimRectSizingHandles = val;
repaint();
}
public DragHandle getDragHandleAt(int mx, int my) {
if (!hasSelection() || client == null) return DragHandle.NONE;
int ox = getRenderOffsetX();
int oy = getRenderOffsetY();
int r1 = Math.min(selectionStartRow, selectionEndRow);
int r2 = Math.max(selectionStartRow, selectionEndRow);
int c1 = Math.min(selectionStartCol, selectionEndCol);
int c2 = Math.max(selectionStartCol, selectionEndCol);
int x1 = ox + c1 * cellWidth;
int y1 = oy + r1 * cellHeight;
int x2 = ox + (c2 + 1) * cellWidth;
int y2 = oy + (r2 + 1) * cellHeight;
int xm = (x1 + x2) / 2;
int ym = (y1 + y2) / 2;
int tol = 6;
if (Math.abs(mx - x1) <= tol && Math.abs(my - y1) <= tol) return DragHandle.NW;
if (Math.abs(mx - xm) <= tol && Math.abs(my - y1) <= tol) return DragHandle.N;
if (Math.abs(mx - x2) <= tol && Math.abs(my - y1) <= tol) return DragHandle.NE;
if (Math.abs(mx - x2) <= tol && Math.abs(my - ym) <= tol) return DragHandle.E;
if (Math.abs(mx - x2) <= tol && Math.abs(my - y2) <= tol) return DragHandle.SE;
if (Math.abs(mx - xm) <= tol && Math.abs(my - y2) <= tol) return DragHandle.S;
if (Math.abs(mx - x1) <= tol && Math.abs(my - y2) <= tol) return DragHandle.SW;
if (Math.abs(mx - x1) <= tol && Math.abs(my - ym) <= tol) return DragHandle.W;
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) {
return DragHandle.MOVE;
}
return DragHandle.NONE;
}
public Cursor getCursorForDragHandle(DragHandle handle) {
switch (handle) {
case NW: return Cursor.getPredefinedCursor(Cursor.NW_RESIZE_CURSOR);
case N: return Cursor.getPredefinedCursor(Cursor.N_RESIZE_CURSOR);
case NE: return Cursor.getPredefinedCursor(Cursor.NE_RESIZE_CURSOR);
case E: return Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR);
case SE: return Cursor.getPredefinedCursor(Cursor.SE_RESIZE_CURSOR);
case S: return Cursor.getPredefinedCursor(Cursor.S_RESIZE_CURSOR);
case SW: return Cursor.getPredefinedCursor(Cursor.SW_RESIZE_CURSOR);
case W: return Cursor.getPredefinedCursor(Cursor.W_RESIZE_CURSOR);
case MOVE: return Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
default: return Cursor.getDefaultCursor();
}
}
public void clearSelection() {
selectionStartRow = -1;
selectionStartCol = -1;
@@ -454,23 +714,30 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
int r2 = Math.max(selectionStartRow, selectionEndRow);
int c1, c2;
if (blockSelectMode) {
if (blockSelectMode || trimRectSizingHandles) {
c1 = Math.min(selectionStartCol, selectionEndCol);
c2 = Math.max(selectionStartCol, selectionEndCol);
StringBuilder result = new StringBuilder();
for (int r = r1; r <= r2; r++) {
if (r > r1) result.append('\n');
StringBuilder line = new StringBuilder();
for (int c = c1; c <= c2; c++) {
int baddr = r * cols + c;
ExtendedAttribute ea = sb.getCell(baddr);
char ch = ea.ucs4;
if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) {
result.append(' ');
line.append(' ');
} else {
result.append(ch);
line.append(ch);
}
}
// Trim trailing whitespace on copied lines per HoD CutPasteOptions
int end = line.length();
while (end > 0 && line.charAt(end - 1) == ' ') {
end--;
}
result.append(line.substring(0, end));
}
return result.toString();
} else {
@@ -485,21 +752,34 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
StringBuilder result = new StringBuilder();
StringBuilder line = new StringBuilder();
int prevRow = startAddr / cols;
for (int addr = startAddr; addr <= endAddr; addr++) {
int curRow = addr / cols;
if (curRow != prevRow) {
result.append('\n');
int end = line.length();
while (end > 0 && line.charAt(end - 1) == ' ') {
end--;
}
if (result.length() > 0) result.append('\n');
result.append(line.substring(0, end));
line.setLength(0);
prevRow = curRow;
}
ExtendedAttribute ea = sb.getCell(addr);
char ch = ea.ucs4;
if (ea.isFieldAttribute() || ch <= 0x20 || ch == 0xFF) {
result.append(' ');
line.append(' ');
} else {
result.append(ch);
line.append(ch);
}
}
int end = line.length();
while (end > 0 && line.charAt(end - 1) == ' ') {
end--;
}
if (result.length() > 0) result.append('\n');
result.append(line.substring(0, end));
return result.toString();
}
}
@@ -510,6 +790,65 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
StringSelection ss = new StringSelection(text);
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(ss, null);
}
if (!haus.nightmare.j3270.config.Settings.getTrimRectRemainAfterEdit()) {
clearSelection();
repaint();
}
}
public void cutSelection() {
if (!hasSelection() || client == null || !client.getConnectionState().isFullSession()) return;
String text = getSelectedText();
if (!text.isEmpty()) {
StringSelection ss = new StringSelection(text);
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(ss, null);
}
ScreenBuffer sb = client.getScreenBuffer();
int cols = sb.getCols();
int r1 = Math.min(selectionStartRow, selectionEndRow);
int r2 = Math.max(selectionStartRow, selectionEndRow);
int c1 = Math.min(selectionStartCol, selectionEndCol);
int c2 = Math.max(selectionStartCol, selectionEndCol);
if (blockSelectMode || trimRectSizingHandles) {
for (int r = r1; r <= r2; r++) {
for (int c = c1; c <= c2; c++) {
int baddr = r * cols + c;
if (!sb.isProtected(baddr) && !sb.getCell(baddr).isFieldAttribute()) {
ExtendedAttribute ea = sb.getCell(baddr);
ea.ucs4 = ' ';
ea.ec = (byte) 0x40;
if (sb.isFormatted()) {
int faAddr = sb.findFieldAttribute(baddr);
if (faAddr >= 0) {
sb.getCell(faAddr).fa = (byte) (sb.getCell(faAddr).fa | haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY);
}
}
}
}
}
} else {
int startAddr = Math.min(selectionStartRow * cols + selectionStartCol, selectionEndRow * cols + selectionEndCol);
int endAddr = Math.max(selectionStartRow * cols + selectionStartCol, selectionEndRow * cols + selectionEndCol);
for (int baddr = startAddr; baddr <= endAddr; baddr++) {
if (!sb.isProtected(baddr) && !sb.getCell(baddr).isFieldAttribute()) {
ExtendedAttribute ea = sb.getCell(baddr);
ea.ucs4 = ' ';
ea.ec = (byte) 0x40;
if (sb.isFormatted()) {
int faAddr = sb.findFieldAttribute(baddr);
if (faAddr >= 0) {
sb.getCell(faAddr).fa = (byte) (sb.getCell(faAddr).fa | haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY);
}
}
}
}
}
sb.updateDisplaySnapshot();
if (!haus.nightmare.j3270.config.Settings.getTrimRectRemainAfterEdit()) {
clearSelection();
}
refreshScreen();
}
public void pasteClipboard() {
@@ -536,10 +875,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
boolean excelPaste = haus.nightmare.j3270.config.Settings.getEnablePasteFromExcel();
boolean stopAtProtected = haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine();
boolean blockPaste = haus.nightmare.j3270.config.Settings.getBlockPaste();
boolean wordBreak = haus.nightmare.j3270.config.Settings.getPasteWordBreak();
if (client.getInputProcessor() != null && (excelPaste || stopAtProtected || blockPaste || text.contains("\t") || text.contains("\n") || text.contains("\r"))) {
client.getInputProcessor().pasteText(text, excelPaste, stopAtProtected, blockPaste);
if (client.getInputProcessor() != null && (excelPaste || stopAtProtected || blockPaste || wordBreak || text.contains("\t") || text.contains("\n") || text.contains("\r"))) {
client.getInputProcessor().pasteText(text, excelPaste, stopAtProtected, blockPaste, wordBreak);
} else if (client.getPS() != null) {
client.getPS().setPasteWordBreak(wordBreak);
if (blockPaste) {
client.getPS().pasteRectangular(text, curRow, curCol);
} else {
@@ -555,6 +896,9 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
}
}
if (!haus.nightmare.j3270.config.Settings.getTrimRectRemainAfterEdit() && hasSelection()) {
clearSelection();
}
refreshScreen();
}
} catch (Exception ignored) {}
@@ -592,6 +936,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
JPopupMenu popup = new JPopupMenu();
ThemeManager.stylePopupMenu(popup);
JMenuItem cutItem = new JMenuItem("Cut");
ThemeManager.styleMenuItem(cutItem);
cutItem.setEnabled(hasSelection() && client != null && client.getConnectionState().isFullSession());
cutItem.addActionListener(ev -> cutSelection());
popup.add(cutItem);
JMenuItem copyItem = new JMenuItem("Copy");
ThemeManager.styleMenuItem(copyItem);
copyItem.setEnabled(hasSelection());
@@ -627,6 +977,15 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
});
popup.add(blockPasteItem);
JCheckBoxMenuItem handlesItem = new JCheckBoxMenuItem("Trim Sizing Handles", trimRectSizingHandles);
ThemeManager.styleMenuItem(handlesItem);
handlesItem.addActionListener(ev -> {
trimRectSizingHandles = handlesItem.isSelected();
haus.nightmare.j3270.config.Settings.setTrimRectSizingHandles(trimRectSizingHandles);
repaint();
});
popup.add(handlesItem);
popup.show(this, e.getX(), e.getY());
}
@@ -636,7 +995,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
int r1 = Math.min(selectionStartRow, selectionEndRow);
int r2 = Math.max(selectionStartRow, selectionEndRow);
if (blockSelectMode) {
if (blockSelectMode || trimRectSizingHandles) {
int c1 = Math.min(selectionStartCol, selectionEndCol);
int c2 = Math.max(selectionStartCol, selectionEndCol);
return row >= r1 && row <= r2 && col >= c1 && col <= c2;
@@ -738,9 +1097,11 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
bindKeyToMap(im, "APL", haus.nightmare.j3270.config.Settings.getKeyBinding("APL", "alt F3"));
// Copy/Paste/Lightpen bindings
String defCut = haus.nightmare.j3270.config.Settings.isMac() ? "meta X, ctrl X" : "ctrl X, shift DELETE";
String defCopy = haus.nightmare.j3270.config.Settings.getDefaultBinding("COPY");
String defPaste = haus.nightmare.j3270.config.Settings.getDefaultBinding("PASTE");
String defSelectAll = haus.nightmare.j3270.config.Settings.getDefaultBinding("SELECTALL");
bindKeyToMap(im, "CUT", haus.nightmare.j3270.config.Settings.getKeyBinding("CUT", defCut));
bindKeyToMap(im, "COPY", haus.nightmare.j3270.config.Settings.getKeyBinding("COPY", defCopy));
bindKeyToMap(im, "PASTE", haus.nightmare.j3270.config.Settings.getKeyBinding("PASTE", defPaste));
bindKeyToMap(im, "SELECTALL", haus.nightmare.j3270.config.Settings.getKeyBinding("SELECTALL", defSelectAll));
@@ -777,6 +1138,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
am.put("j3270-SYSREQ", createAction(this::handleSysReq));
am.put("j3270-CURSEL", createAction(this::handleCursorSelect));
am.put("j3270-CUT", createAction(this::cutSelection));
am.put("j3270-COPY", createAction(this::copySelection));
am.put("j3270-PASTE", createAction(this::pasteClipboard));
am.put("j3270-SELECTALL", createAction(this::selectAll));
@@ -1292,11 +1654,23 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
setupFont();
applyModeSettings();
blockSelectMode = haus.nightmare.j3270.config.Settings.getBlockSelectMode();
trimRectSizingHandles = haus.nightmare.j3270.config.Settings.getTrimRectSizingHandles();
crosshairRulerEnabled = haus.nightmare.j3270.config.Settings.getCrosshairRuler();
inputMaskEnabled = haus.nightmare.j3270.config.Settings.getInputMask();
inputMaskChar = haus.nightmare.j3270.config.Settings.getInputMaskChar();
String cStyle = haus.nightmare.j3270.config.Settings.getCursorStyle();
cursorStyle = "UNDERLINE".equalsIgnoreCase(cStyle) ? CursorStyle.UNDERLINE : CursorStyle.BLOCK;
// Phase 25: Reload Hotspots and Mouse Wheel preferences
hotspotsEnabled = haus.nightmare.j3270.config.Settings.getHotspotsEnabled();
hotspotEngine.setUrlEnabled(haus.nightmare.j3270.config.Settings.getHotspotsUrlEnabled());
hotspotEngine.setPfKeyEnabled(haus.nightmare.j3270.config.Settings.getHotspotsPfEnabled());
hotspotEngine.setMenuChoiceEnabled(haus.nightmare.j3270.config.Settings.getHotspotsMenuChoicesEnabled());
hotspotEngine.invalidateCache();
mouseWheelEnabled = haus.nightmare.j3270.config.Settings.getMouseWheelEnabled();
mouseWheelUpAction = haus.nightmare.j3270.config.Settings.getMouseWheelUpKey();
mouseWheelDownAction = haus.nightmare.j3270.config.Settings.getMouseWheelDownKey();
revalidate();
repaint();
}
@@ -1725,6 +2099,63 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
}
// Phase 26.1: Rectangular Block Selection Sizing Handles & Box Outline
if (trimRectSizingHandles && hasSelection() && client != null && client.getConnectionState().isFullSession()) {
int r1 = Math.min(selectionStartRow, selectionEndRow);
int r2 = Math.max(selectionStartRow, selectionEndRow);
int c1 = Math.min(selectionStartCol, selectionEndCol);
int c2 = Math.max(selectionStartCol, selectionEndCol);
int x1 = ox + c1 * cellWidth;
int y1 = oy + r1 * cellHeight;
int x2 = ox + (c2 + 1) * cellWidth;
int y2 = oy + (r2 + 1) * cellHeight;
int w = x2 - x1;
int h = y2 - y1;
int xm = (x1 + x2) / 2;
int ym = (y1 + y2) / 2;
Color oldColor = g2.getColor();
Stroke oldStroke = g2.getStroke();
g2.setColor(Color.YELLOW);
g2.drawRect(x1, y1, w, h);
drawSizingHandle(g2, x1, y1);
drawSizingHandle(g2, xm, y1);
drawSizingHandle(g2, x2, y1);
drawSizingHandle(g2, x2, ym);
drawSizingHandle(g2, x2, y2);
drawSizingHandle(g2, xm, y2);
drawSizingHandle(g2, x1, y2);
drawSizingHandle(g2, x1, ym);
g2.setStroke(oldStroke);
g2.setColor(oldColor);
}
// Phase 25.2: Draw visual underline on hovered hotspot
if (hotspotsEnabled && hoveredHotspot != null) {
Color oldColor = g2.getColor();
Stroke oldStroke = g2.getStroke();
g2.setColor(new Color(0, 220, 255)); // Bright cyan hotspot underline
g2.setStroke(new BasicStroke(2.0f));
int hr0 = hoveredHotspot.getStartRow();
int hr1 = hoveredHotspot.getEndRow();
for (int r = hr0; r <= hr1; r++) {
int c0 = (r == hr0) ? hoveredHotspot.getStartCol() : 0;
int c1 = (r == hr1) ? hoveredHotspot.getEndCol() : cols - 1;
int x1 = ox + c0 * cellWidth;
int x2 = ox + (c1 + 1) * cellWidth;
int ulY = oy + r * cellHeight + fontAscent + Math.max(0, (cellHeight - (fontAscent + fontDescent)) / 2) + 2;
ulY = Math.min(oy + (r + 1) * cellHeight - 1, ulY);
g2.drawLine(x1, ulY, x2 - 1, ulY);
}
g2.setStroke(oldStroke);
g2.setColor(oldColor);
}
// Draw Graphic Cursor if active
boolean isGraphicCursor = (client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive())
|| (client.getGraphicsPlane() != null && client.getGraphicsPlane().isGraphicCursorAttached());
@@ -1929,6 +2360,13 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
}
private void drawSizingHandle(Graphics2D g2, int cx, int cy) {
g2.setColor(Color.YELLOW);
g2.fillRect(cx - 2, cy - 2, 5, 5);
g2.setColor(Color.BLACK);
g2.drawRect(cx - 2, cy - 2, 5, 5);
}
public void dispose() {
if (blinkTimer != null)
blinkTimer.stop();
@@ -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"));
}
}