Hotspot detection and screen buffer
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 36s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 1m20s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m28s
Release j3270 / Build & Publish Release (push) Successful in 1m39s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 36s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 1m20s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m28s
Release j3270 / Build & Publish Release (push) Successful in 1m39s
This commit is contained in:
@@ -36,6 +36,7 @@ public class ECLSession extends ECLConnection implements ECLConstants {
|
||||
public static final String BLOCK_PASTE = "blockPaste";
|
||||
public static final String PASTE_FIELD_WRAP = "pasteFieldWrap";
|
||||
public static final String PASTE_LINE_WRAP = "pasteLineWrap";
|
||||
public static final String PASTE_WORD_BREAK = "pasteWordBreak";
|
||||
|
||||
private final haus.nightmare.lib3270j.ecl.ECLSession delegate;
|
||||
private final ECLPS ps;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package haus.nightmare.lib3270j.input;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.io.StringReader;
|
||||
import java.io.StringWriter;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
public class HodKeyMapTest {
|
||||
|
||||
@Test
|
||||
public void testModifierPrefixes() {
|
||||
assertEquals('B', HodKeyMap.getModifierPrefix(0));
|
||||
assertEquals('S', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT));
|
||||
assertEquals('C', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_CTRL));
|
||||
assertEquals('D', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL));
|
||||
assertEquals('A', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_ALT));
|
||||
assertEquals('H', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_ALT));
|
||||
assertEquals('I', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT));
|
||||
assertEquals('J', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT));
|
||||
|
||||
assertEquals(0, HodKeyMap.getModifiersFromPrefix('B'));
|
||||
assertEquals(HodKeyMap.MOD_SHIFT, HodKeyMap.getModifiersFromPrefix('S'));
|
||||
assertEquals(HodKeyMap.MOD_CTRL, HodKeyMap.getModifiersFromPrefix('C'));
|
||||
assertEquals(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL, HodKeyMap.getModifiersFromPrefix('D'));
|
||||
assertEquals(HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('A'));
|
||||
assertEquals(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('H'));
|
||||
assertEquals(HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('I'));
|
||||
assertEquals(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('J'));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHashKeyParsingStandardKeys() {
|
||||
// A67 = Alt + C
|
||||
HodKeyMap.KeyEntry ke1 = HodKeyMap.parseHashKey("A67", "[clear]");
|
||||
assertNotNull(ke1);
|
||||
assertEquals(67, ke1.getKeyCode());
|
||||
assertEquals(HodKeyMap.MOD_ALT, ke1.getModifiers());
|
||||
assertEquals(HodKeyMap.LOCATION_STANDARD, ke1.getKeyLocation());
|
||||
assertEquals("alt C", ke1.toKeyStrokeString());
|
||||
assertEquals("[clear]", ke1.getValue());
|
||||
|
||||
// B35 = End
|
||||
HodKeyMap.KeyEntry ke2 = HodKeyMap.parseHashKey("B35", "[eraseeof]");
|
||||
assertNotNull(ke2);
|
||||
assertEquals(35, ke2.getKeyCode());
|
||||
assertEquals(0, ke2.getModifiers());
|
||||
assertEquals("END", ke2.toKeyStrokeString());
|
||||
|
||||
// A82 = Alt + R
|
||||
HodKeyMap.KeyEntry ke3 = HodKeyMap.parseHashKey("A82", "[reset]");
|
||||
assertNotNull(ke3);
|
||||
assertEquals(82, ke3.getKeyCode());
|
||||
assertEquals(HodKeyMap.MOD_ALT, ke3.getModifiers());
|
||||
assertEquals("alt R", ke3.toKeyStrokeString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDuplicateAndNumpadKeys() {
|
||||
// Numpad Down: 225
|
||||
HodKeyMap.KeyEntry keDown = HodKeyMap.parseHashKey("B225", "[down]");
|
||||
assertNotNull(keDown);
|
||||
assertEquals(HodKeyMap.VK_DOWN, keDown.getKeyCode());
|
||||
assertEquals(HodKeyMap.LOCATION_NUMPAD, keDown.getKeyLocation());
|
||||
assertEquals("DOWN", keDown.toKeyStrokeString());
|
||||
|
||||
// Numpad Enter: 0x2000000 + 10 = 33554442
|
||||
HodKeyMap.KeyEntry keEnter = HodKeyMap.parseHashKey("B33554442", "[enter]");
|
||||
assertNotNull(keEnter);
|
||||
assertEquals(HodKeyMap.VK_ENTER, keEnter.getKeyCode());
|
||||
assertEquals(HodKeyMap.LOCATION_NUMPAD, keEnter.getKeyLocation());
|
||||
assertEquals("ENTER", keEnter.toKeyStrokeString());
|
||||
|
||||
// Generation of hash keys
|
||||
assertEquals("B224", HodKeyMap.hashKey(HodKeyMap.VK_UP, 0, HodKeyMap.LOCATION_NUMPAD));
|
||||
assertEquals("B225", HodKeyMap.hashKey(HodKeyMap.VK_DOWN, 0, HodKeyMap.LOCATION_NUMPAD));
|
||||
assertEquals("B226", HodKeyMap.hashKey(HodKeyMap.VK_LEFT, 0, HodKeyMap.LOCATION_NUMPAD));
|
||||
assertEquals("B227", HodKeyMap.hashKey(HodKeyMap.VK_RIGHT, 0, HodKeyMap.LOCATION_NUMPAD));
|
||||
assertEquals("B33554442", HodKeyMap.hashKey(HodKeyMap.VK_ENTER, 0, HodKeyMap.LOCATION_NUMPAD));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testParseHodKmpString() throws Exception {
|
||||
String kmpContent =
|
||||
"[KeyRemap]\n" +
|
||||
"XF:[textlogicaldisp]=[textlogicaldisp]\n" +
|
||||
"A67=[clear]\n" +
|
||||
"autoApply=false\n" +
|
||||
"keyVersion=2\n" +
|
||||
"B35=[eraseeof]\n" +
|
||||
"sessionType=1\n" +
|
||||
"disableKeyBuffer=false\n" +
|
||||
"A82=[reset]\n" +
|
||||
"codePage=037\n";
|
||||
|
||||
Map<String, String> map = HodKeyMap.parseHodKmp(new StringReader(kmpContent));
|
||||
assertEquals("alt C", map.get("CLEAR"));
|
||||
assertEquals("END", map.get("END"));
|
||||
assertEquals("alt R", map.get("ESCAPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testParseStandardKmpFileIfPresent() throws Exception {
|
||||
File f = new File("/home/rudi/Documents/IBM HoD/Standard.kmp");
|
||||
if (f.exists()) {
|
||||
try (FileReader fr = new FileReader(f)) {
|
||||
Map<String, String> map = HodKeyMap.parseHodKmp(fr);
|
||||
assertEquals("alt C", map.get("CLEAR"));
|
||||
assertEquals("END", map.get("END"));
|
||||
assertEquals("alt R", map.get("ESCAPE"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExportHodKmp() throws Exception {
|
||||
Map<String, String> bindings = new LinkedHashMap<>();
|
||||
bindings.put("CLEAR", "alt C");
|
||||
bindings.put("END", "END");
|
||||
bindings.put("ESCAPE", "alt R");
|
||||
|
||||
StringWriter sw = new StringWriter();
|
||||
HodKeyMap.exportHodKmp(sw, bindings);
|
||||
String out = sw.toString();
|
||||
|
||||
assertTrue(out.contains("[KeyRemap]"));
|
||||
assertTrue(out.contains("A67=[clear]"));
|
||||
assertTrue(out.contains("B35=[eraseeof]"));
|
||||
assertTrue(out.contains("A82=[reset]"));
|
||||
|
||||
// Verify roundtrip
|
||||
Map<String, String> parsed = HodKeyMap.parseHodKmp(new StringReader(out));
|
||||
assertEquals("alt C", parsed.get("CLEAR"));
|
||||
assertEquals("END", parsed.get("END"));
|
||||
assertEquals("alt R", parsed.get("ESCAPE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package haus.nightmare.lib3270j.input;
|
||||
|
||||
import haus.nightmare.lib3270j.TerminalModel;
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
|
||||
import haus.nightmare.lib3270j.ecl.ECLPS;
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
public class PasteWordBreakTest {
|
||||
|
||||
private ScreenBuffer screen;
|
||||
private EbcdicTranslator translator;
|
||||
private InputProcessor input;
|
||||
private ECLPS ps;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
translator = new EbcdicTranslator();
|
||||
screen = new ScreenBuffer(TerminalModel.IBM_3278_2, translator);
|
||||
input = new InputProcessor(screen, translator, null);
|
||||
ps = new ECLPS(screen, input, translator);
|
||||
|
||||
screen.erase(false);
|
||||
// Field 1: pos 0 (FA), pos 1..5 unprotected (length 5)
|
||||
// pos 6: FA (unprotected), pos 7..16 unprotected (length 10)
|
||||
// pos 17: FA (protected)
|
||||
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
|
||||
screen.setFieldAttribute(6, (byte) FA_PRINTABLE);
|
||||
screen.setFieldAttribute(17, (byte) (FA_PRINTABLE | FA_PROTECT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPasteWordBreakPreventsSplittingAcrossFields() {
|
||||
// Cursor at pos 1 (start of Field 1)
|
||||
screen.setCursorAddress(1);
|
||||
input.typeCharacter('A');
|
||||
input.typeCharacter('B');
|
||||
input.typeCharacter('C');
|
||||
// Now cursor is at pos 4, remaining space in Field 1 is 2 (pos 4 and 5)
|
||||
|
||||
// Paste "WORLD": length 5, does not fit in 2 positions
|
||||
// With pasteWordBreak=true, it should skip to Field 2 (pos 7) and paste WORLD
|
||||
input.setPasteWordBreak(true);
|
||||
input.pasteText("WORLD", false, false, false, true);
|
||||
|
||||
// Field 1: pos 1='A', 2='B', 3='C' with trailing blanks untouched
|
||||
assertEquals("ABC ", ps.getString(1, 5));
|
||||
|
||||
// Field 2: pos 7..11 should have "WORLD"
|
||||
assertEquals("WORLD", ps.getString(7, 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPasteWithoutWordBreakSplitsWord() {
|
||||
// Cursor at pos 1 (start of Field 1)
|
||||
screen.setCursorAddress(1);
|
||||
input.typeCharacter('A');
|
||||
input.typeCharacter('B');
|
||||
input.typeCharacter('C');
|
||||
// Now cursor is at pos 4, remaining space in Field 1 is 2 (pos 4 and 5)
|
||||
|
||||
// With pasteWordBreak=false, "WO" goes to pos 4..5, and "RLD" spills into Field 2
|
||||
input.setPasteWordBreak(false);
|
||||
input.pasteText("WORLD", false, false, false, false);
|
||||
|
||||
assertEquals("ABCWO", ps.getString(1, 5));
|
||||
|
||||
// Field 2 receives "RLD"
|
||||
assertEquals("RLD", ps.getString(7, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPasteWordBreakHandlesSpaceDelimiterWithoutLeadingSpaceInNextField() {
|
||||
screen.setCursorAddress(1);
|
||||
// Field 1 has length 5. We paste "HELLO WORLD".
|
||||
// "HELLO" takes pos 1..5. The space is a delimiter.
|
||||
// "WORLD" should start at pos 7 with no leading space!
|
||||
input.setPasteWordBreak(true);
|
||||
input.pasteText("HELLO WORLD", false, false, false, true);
|
||||
|
||||
assertEquals("HELLO", ps.getString(1, 5));
|
||||
assertEquals("WORLD", ps.getString(7, 5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package haus.nightmare.lib3270j.screen;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for ScreenHistoryBuffer (Phase 25.1).
|
||||
* Tests rolling 64-frame ring buffer, deduplication, chronological ordering,
|
||||
* text/table extraction, and listener callbacks.
|
||||
*/
|
||||
public class ScreenHistoryBufferTest {
|
||||
|
||||
private ScreenBuffer screenBuffer;
|
||||
private ScreenHistoryBuffer historyBuffer;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
screenBuffer = new ScreenBuffer();
|
||||
historyBuffer = new ScreenHistoryBuffer(64);
|
||||
}
|
||||
|
||||
private void writeString(int row, int col, String text) {
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
screenBuffer.setChar(row, col + i, text.charAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Initial buffer state is empty")
|
||||
public void testInitialState() {
|
||||
assertEquals(0, historyBuffer.size());
|
||||
assertTrue(historyBuffer.isEmpty());
|
||||
assertEquals(64, historyBuffer.getCapacity());
|
||||
assertNull(historyBuffer.getLatest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Captures snapshot with correct dimensions, text, and cursor")
|
||||
public void testSnapshotCapture() {
|
||||
writeString(1, 0, "HELLO");
|
||||
screenBuffer.setCursorAddress(85);
|
||||
|
||||
ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer);
|
||||
assertNotNull(snap);
|
||||
assertEquals(1, historyBuffer.size());
|
||||
assertFalse(historyBuffer.isEmpty());
|
||||
assertEquals(24, snap.getRows());
|
||||
assertEquals(80, snap.getCols());
|
||||
assertEquals(85, snap.getCursorAddress());
|
||||
assertEquals(1, snap.getCursorRow());
|
||||
assertEquals(5, snap.getCursorCol());
|
||||
|
||||
assertEquals('H', snap.getChar(1, 0));
|
||||
assertEquals('E', snap.getChar(1, 1));
|
||||
assertEquals('L', snap.getChar(1, 2));
|
||||
assertEquals('L', snap.getChar(1, 3));
|
||||
assertEquals('O', snap.getChar(1, 4));
|
||||
assertEquals("HELLO", snap.getLine(1).trim());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deduplicates identical consecutive snapshots")
|
||||
public void testDeduplication() {
|
||||
screenBuffer.setChar(0, 0, 'A');
|
||||
ScreenHistoryBuffer.ScreenSnapshot s1 = historyBuffer.snapshot(screenBuffer);
|
||||
assertNotNull(s1);
|
||||
assertEquals(1, historyBuffer.size());
|
||||
|
||||
// Snapshot identical buffer state without changes
|
||||
ScreenHistoryBuffer.ScreenSnapshot s2 = historyBuffer.snapshot(screenBuffer);
|
||||
assertNull(s2, "Consecutive identical snapshot should be suppressed");
|
||||
assertEquals(1, historyBuffer.size());
|
||||
|
||||
// Now modify screen buffer
|
||||
screenBuffer.setChar(0, 1, 'B');
|
||||
ScreenHistoryBuffer.ScreenSnapshot s3 = historyBuffer.snapshot(screenBuffer);
|
||||
assertNotNull(s3, "Snapshot should be captured after buffer modification");
|
||||
assertEquals(2, historyBuffer.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Rolls over correctly when capacity of 64 frames is exceeded")
|
||||
public void testRollingRingBufferOverflow() {
|
||||
// Capture 70 distinct screens
|
||||
for (int i = 0; i < 70; i++) {
|
||||
screenBuffer.clear();
|
||||
screenBuffer.setCursorAddress(0);
|
||||
String str = "SCREEN_" + String.format("%03d", i);
|
||||
writeString(0, 0, str);
|
||||
ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer);
|
||||
assertNotNull(snap);
|
||||
}
|
||||
|
||||
// Buffer size must be capped at 64
|
||||
assertEquals(64, historyBuffer.size());
|
||||
|
||||
// The oldest snapshot (index 0) should be screen 6 (70 - 64 = 6)
|
||||
ScreenHistoryBuffer.ScreenSnapshot oldest = historyBuffer.get(0);
|
||||
assertTrue(oldest.getText().startsWith("SCREEN_006"),
|
||||
"Oldest frame should be SCREEN_006, was: " + oldest.getText().substring(0, 10));
|
||||
|
||||
// The newest snapshot (index 63) should be screen 69
|
||||
ScreenHistoryBuffer.ScreenSnapshot newest = historyBuffer.get(63);
|
||||
assertTrue(newest.getText().startsWith("SCREEN_069"),
|
||||
"Newest frame should be SCREEN_069, was: " + newest.getText().substring(0, 10));
|
||||
|
||||
// getLatest() must match newest
|
||||
assertEquals(newest, historyBuffer.getLatest());
|
||||
|
||||
// Check full sequential list
|
||||
List<ScreenHistoryBuffer.ScreenSnapshot> all = historyBuffer.getSnapshots();
|
||||
assertEquals(64, all.size());
|
||||
for (int i = 0; i < 64; i++) {
|
||||
String expected = "SCREEN_" + String.format("%03d", i + 6);
|
||||
assertTrue(all.get(i).getText().startsWith(expected));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Formatted table trims trailing whitespace per line")
|
||||
public void testFormattedTable() {
|
||||
screenBuffer.setCursorAddress(0);
|
||||
writeString(0, 0, "ABC");
|
||||
|
||||
ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer);
|
||||
assertNotNull(snap);
|
||||
String table = snap.getFormattedTable();
|
||||
String[] lines = table.split("\n", -1);
|
||||
assertEquals(24, lines.length);
|
||||
assertEquals("ABC", lines[0]);
|
||||
assertEquals("", lines[1]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Bounded text range returns exact sub-box")
|
||||
public void testTextRange() {
|
||||
screenBuffer.setCursorAddress(0);
|
||||
writeString(0, 0, "ROW0");
|
||||
writeString(1, 0, "ROW1");
|
||||
|
||||
ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer);
|
||||
assertNotNull(snap);
|
||||
|
||||
String sub = snap.getTextRange(0, 0, 1, 3);
|
||||
assertEquals("ROW0\nROW1", sub);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ScreenHistoryListener receives callbacks on new snapshots")
|
||||
public void testListenerNotification() {
|
||||
AtomicInteger callCount = new AtomicInteger(0);
|
||||
ScreenHistoryBuffer.ScreenHistoryListener listener = (buf, snap) -> callCount.incrementAndGet();
|
||||
|
||||
historyBuffer.addListener(listener);
|
||||
|
||||
screenBuffer.setChar(0, 0, 'X');
|
||||
historyBuffer.snapshot(screenBuffer);
|
||||
assertEquals(1, callCount.get());
|
||||
|
||||
// Duplicate snapshot doesn't fire listener
|
||||
historyBuffer.snapshot(screenBuffer);
|
||||
assertEquals(1, callCount.get());
|
||||
|
||||
// New change fires listener
|
||||
screenBuffer.setChar(0, 1, 'Y');
|
||||
historyBuffer.snapshot(screenBuffer);
|
||||
assertEquals(2, callCount.get());
|
||||
|
||||
historyBuffer.removeListener(listener);
|
||||
screenBuffer.setChar(0, 2, 'Z');
|
||||
historyBuffer.snapshot(screenBuffer);
|
||||
assertEquals(2, callCount.get(), "Removed listener should not receive callbacks");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ScreenBuffer integration automatically records history on notifyScreenUpdate")
|
||||
public void testScreenBufferAutomaticSnapshot() {
|
||||
ScreenHistoryBuffer autoHist = screenBuffer.getHistoryBuffer();
|
||||
assertNotNull(autoHist);
|
||||
int initialSize = autoHist.size();
|
||||
|
||||
screenBuffer.setChar(0, 0, 'T');
|
||||
screenBuffer.notifyScreenUpdate();
|
||||
|
||||
assertEquals(initialSize + 1, autoHist.size());
|
||||
assertTrue(autoHist.getLatest().getText().startsWith("T"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user