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
@@ -92,6 +92,9 @@ public class Telnet3270Client {
ps.notifyAlarm();
}
@Override public void onKeyboardUnlocked() {
if (screenBuffer != null) {
screenBuffer.getHistoryBuffer().snapshot(screenBuffer);
}
ps.notifyKeyUnlocked();
}
});
@@ -658,6 +661,9 @@ public class Telnet3270Client {
public void setWordWrap(boolean b) { if (screenBuffer != null) screenBuffer.setEntryAssistWordWrap(b); if (oia != null) oia.notifyOIAChanged(); }
public void toggleWordWrap() { setWordWrap(!isWordWrap()); }
public boolean isPasteWordBreak() { return inputProcessor != null && inputProcessor.isPasteWordBreak(); }
public void setPasteWordBreak(boolean b) { if (inputProcessor != null) inputProcessor.setPasteWordBreak(b); }
/** APL Keyboard Mode operations. */
public boolean isAplMode() { return inputProcessor != null && inputProcessor.isAplKeyboardMode(); }
public void setAplMode(boolean b) { if (inputProcessor != null) inputProcessor.setAplKeyboardMode(b); }
@@ -705,4 +711,8 @@ public class Telnet3270Client {
public haus.nightmare.lib3270j.graphics.GocaDecoder getGocaDecoder() {
return dsProcessor.getGocaDecoder();
}
public haus.nightmare.lib3270j.screen.ScreenHistoryBuffer getScreenHistoryBuffer() {
return screenBuffer != null ? screenBuffer.getHistoryBuffer() : null;
}
}
@@ -845,13 +845,37 @@ public class ECLPS implements ECLConstants {
public void SetPasteStopAtProtectedLine(boolean val) { setPasteStopAtProtectedLine(val); }
private boolean pasteWordBreak = true;
public boolean isPasteWordBreak() {
if (session != null && session.getProperties() != null) {
String p = session.getProperties().getProperty(ECLSession.PASTE_WORD_BREAK);
if (p != null) return Boolean.parseBoolean(p);
}
return inputProcessor != null ? inputProcessor.isPasteWordBreak() : pasteWordBreak;
}
public boolean IsPasteWordBreak() { return isPasteWordBreak(); }
public void setPasteWordBreak(boolean val) {
this.pasteWordBreak = val;
if (session != null && session.getProperties() != null) {
session.getProperties().setProperty(ECLSession.PASTE_WORD_BREAK, String.valueOf(val));
}
if (inputProcessor != null) {
inputProcessor.setPasteWordBreak(val);
}
}
public void SetPasteWordBreak(boolean val) { setPasteWordBreak(val); }
public synchronized int pasteFromExcel(String text, int row, int col) {
if (text == null || text.isEmpty() || screen == null) return 0;
if (row >= 0 && col >= 0) {
setCursorPos(row, col);
}
if (inputProcessor != null) {
return inputProcessor.pasteText(text, true, isPasteStopAtProtectedLine(), isBlockPaste());
return inputProcessor.pasteText(text, true, isPasteStopAtProtectedLine(), isBlockPaste(), isPasteWordBreak());
}
return 0;
}
@@ -867,7 +891,7 @@ public class ECLPS implements ECLConstants {
if (text == null || text.isEmpty() || screen == null) return 0;
if (inputProcessor != null && (text.contains("\t") || isEnablePasteFromExcel() || isPasteStopAtProtectedLine() || isBlockPaste())) {
setCursorPos(row, col);
return inputProcessor.pasteText(text, isEnablePasteFromExcel(), isPasteStopAtProtectedLine(), isBlockPaste());
return inputProcessor.pasteText(text, isEnablePasteFromExcel(), isPasteStopAtProtectedLine(), isBlockPaste(), isPasteWordBreak());
}
int rows = screen.getRows();
int cols = screen.getCols();
@@ -41,6 +41,7 @@ public class ECLSession {
public static final String BLOCK_PASTE = "blockPaste";
public static final String PASTE_FIELD_WRAP = "pasteFieldWrap";
public static final String PASTE_LINE_WRAP = "pasteLineWrap";
public static final String PASTE_WORD_BREAK = "pasteWordBreak";
public static final String ENTRYASSIST_DOCMODE = "EntryAssist_DOCmode";
public static final String ENTRYASSIST_DOCWORDWRAP = "EntryAssist_DOCwordWrap";
public static final String ENTRYASSIST_STARTCOL = "EntryAssist_startCol";
@@ -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.
* When pasteStopAtProtectedLine is true:
* - Halts or truncates paste if cursor reaches a protected line/boundary or if the current field is full.
*
* @param text text to paste
* @param enablePasteFromExcel whether to parse tabs as field advances and newlines as row advances
* @param pasteStopAtProtectedLine whether to halt paste when encountering protected boundaries
* @return number of characters pasted
*/
public int pasteText(String text, boolean enablePasteFromExcel, boolean pasteStopAtProtectedLine) {
return pasteText(text, enablePasteFromExcel, pasteStopAtProtectedLine, this.blockPaste);
private boolean pasteWordBreak = true;
public boolean isPasteWordBreak() {
return pasteWordBreak;
}
public void setPasteWordBreak(boolean pasteWordBreak) {
this.pasteWordBreak = pasteWordBreak;
}
/**
@@ -483,10 +484,27 @@ public class InputProcessor {
* @param text text to paste
* @param enablePasteFromExcel whether to parse tabs as field advances and newlines as row advances
* @param pasteStopAtProtectedLine whether to halt paste when encountering protected boundaries
* @param blockPaste whether newlines align each line with the initial starting cursor column
* @return number of characters pasted
*/
public int pasteText(String text, boolean enablePasteFromExcel, boolean pasteStopAtProtectedLine) {
return pasteText(text, enablePasteFromExcel, pasteStopAtProtectedLine, this.blockPaste, this.pasteWordBreak);
}
public int pasteText(String text, boolean enablePasteFromExcel, boolean pasteStopAtProtectedLine, boolean blockPaste) {
return pasteText(text, enablePasteFromExcel, pasteStopAtProtectedLine, blockPaste, this.pasteWordBreak);
}
/**
* Pastes text onto the screen with tabular, boundary, block alignment, and word break controls.
*
* @param text text to paste
* @param enablePasteFromExcel whether to parse tabs as field advances and newlines as row advances
* @param pasteStopAtProtectedLine whether to halt paste when encountering protected boundaries
* @param blockPaste whether newlines align each line with the initial starting cursor column
* @param pasteWordBreak whether to prevent splitting words across field boundaries
* @return number of characters pasted
*/
public int pasteText(String text, boolean enablePasteFromExcel, boolean pasteStopAtProtectedLine, boolean blockPaste, boolean pasteWordBreak) {
if (text == null || text.isEmpty() || screen == null || keyboardLocked) {
return 0;
}
@@ -573,9 +591,10 @@ public class InputProcessor {
if (ch >= 0x20 && ch != 0x7F) {
if (screen.isFormatted()) {
int baddr = screen.getCursorAddress();
int totalScreenSize = screen.getRows() * screen.getCols();
ExtendedAttribute ea = screen.getCell(baddr);
if (ea.isFieldAttribute()) {
baddr = (baddr + 1) % (screen.getRows() * screen.getCols());
baddr = (baddr + 1) % totalScreenSize;
}
byte faVal = screen.getFieldAttributeAt(baddr);
if (faIsProtected(faVal & 0xFF)) {
@@ -589,6 +608,56 @@ public class InputProcessor {
}
}
}
if (pasteWordBreak) {
int remaining = screen.getFieldRemainingLength(baddr);
int faAddr = screen.findFieldAttribute(baddr);
int startOfField = (faAddr >= 0) ? (faAddr + 1) % totalScreenSize : 0;
if (ch == ' ') {
// If separating space occurs when current field is full, advance to next field
if (remaining <= 0) {
tab();
i++;
continue;
}
// If cursor just transitioned to start of next field after completing a word,
// don't insert a leading delimiter space into the new field
if (baddr == startOfField && i > 0 && !Character.isWhitespace(text.charAt(i - 1))) {
i++;
continue;
}
} else {
// Check if at the start of a word
boolean isWordStart = (i == 0 || Character.isWhitespace(text.charAt(i - 1)));
if (isWordStart) {
int wordEnd = i;
while (wordEnd < len) {
char wc = text.charAt(wordEnd);
if (Character.isWhitespace(wc) || wc == '\t' || wc == '\r' || wc == '\n') {
break;
}
wordEnd++;
}
int wordLen = wordEnd - i;
// If the word cannot fit in remaining space and we're not at start of field,
// advance to next field to prevent splitting the word
if (wordLen > remaining && baddr != startOfField) {
if (pasteStopAtProtectedLine && isLineProtected(screen.getCursorRow())) {
break;
}
tab();
baddr = screen.getCursorAddress();
if (screen.getCell(baddr).isFieldAttribute()) {
baddr = (baddr + 1) % totalScreenSize;
}
if (faIsProtected(screen.getFieldAttributeAt(baddr) & 0xFF)) {
break;
}
}
}
}
}
}
typeCharacter(ch);
@@ -70,6 +70,13 @@ public class ScreenBuffer {
private final Condition syncCondition = syncLock.newCondition();
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() {
return syncLock;
}
@@ -99,6 +106,7 @@ public class ScreenBuffer {
public void notifyScreenUpdate() {
updateDisplaySnapshot();
historyBuffer.snapshot(this);
signalWaiters();
for (ScreenUpdateListener l : updateListeners) {
try {
@@ -481,6 +489,46 @@ public class ScreenBuffer {
return buffer[fa_addr].fa;
}
/**
* Calculates the remaining characters available in the current field from baddr
* to the next field attribute (or end of unformatted screen).
*/
public synchronized int getFieldRemainingLength(int baddr) {
int size = rows * cols;
if (size <= 0) return 0;
baddr = ((baddr % size) + size) % size;
if (!formatted) {
return size - baddr;
}
if (buffer[baddr].isFieldAttribute()) {
baddr = (baddr + 1) % size;
}
int remaining = 0;
int curr = baddr;
while (!buffer[curr].isFieldAttribute() && remaining < size) {
remaining++;
curr = (curr + 1) % size;
}
return remaining;
}
/**
* Calculates the total character capacity of the field governed by faAddr.
*/
public synchronized int getFieldLength(int faAddr) {
int size = rows * cols;
if (size <= 0) return 0;
if (!formatted || faAddr < 0) return size;
faAddr = ((faAddr % size) + size) % size;
int count = 0;
int curr = (faAddr + 1) % size;
while (!buffer[curr].isFieldAttribute() && count < size) {
count++;
curr = (curr + 1) % size;
}
return count;
}
/**
* Returns the effective foreground color byte for the given buffer address.
* If the cell has an explicit foreground color attribute (0xF0-0xFF), it is returned.
@@ -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;
}
}