diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java index c375cd1..a73031b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java @@ -39,6 +39,8 @@ public class Telnet3270Client { private final DataStreamProcessor dsProcessor; private final TelnetFSM fsm; private final InputProcessor inputProcessor; + private final haus.nightmare.lib3270j.ecl.ECLPS ps; + private final haus.nightmare.lib3270j.ecl.ECLOIA oia; private TelnetConnection connection; public Telnet3270Client(ConnectionConfig config) { @@ -49,6 +51,8 @@ public class Telnet3270Client { this.dsProcessor.getQueryReplyBuilder().setGraphicsMode(config.getGraphicsMode()); this.fsm = new TelnetFSM(config, screenBuffer, dsProcessor); this.inputProcessor = new InputProcessor(screenBuffer, translator, fsm); + this.ps = new haus.nightmare.lib3270j.ecl.ECLPS(screenBuffer, inputProcessor, translator); + this.oia = new haus.nightmare.lib3270j.ecl.ECLOIA(screenBuffer, inputProcessor, fsm); // Wire up the output sender: DSProcessor -> FSM -> TelnetConnection dsProcessor.setOutputSender(fsm::send3270Data); @@ -125,6 +129,18 @@ public class Telnet3270Client { /** Get the connection config. */ public ConnectionConfig getConfig() { return config; } + /** Get the ECL Presentation Space API. */ + public haus.nightmare.lib3270j.ecl.ECLPS getPS() { return ps; } + + /** Get the ECL Operator Information Area API. */ + public haus.nightmare.lib3270j.ecl.ECLOIA getOIA() { return oia; } + + /** Get the list of all fields currently on screen. */ + public haus.nightmare.lib3270j.ecl.ECLFieldList getFieldList() { return ps.getFieldList(); } + + /** Send IBM ECL bracketed mnemonic keystrokes (e.g. "USER[tab]PASS[enter]"). */ + public void sendKeys(String keys) { inputProcessor.sendKeys(keys); } + /** Set a custom or interactive TLS certificate verifier callback. */ public void setTlsCertificateVerifier(haus.nightmare.lib3270j.tls.TlsCertificateVerifier verifier) { config.setCertificateVerifier(verifier); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLConstants.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLConstants.java new file mode 100644 index 0000000..d579a37 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLConstants.java @@ -0,0 +1,52 @@ +package haus.nightmare.lib3270j.ecl; + +/** + * Common constants for IBM Host On-Demand Emulator Class Library (ECL) emulation. + */ +public interface ECLConstants { + + // Presentation space logical planes + int PLANE_TEXT = 1; // Unicode/EBCDIC characters + int PLANE_COLOR = 2; // Color attributes + int PLANE_HILITE = 3; // Extended highlighting + int PLANE_EXTENDED = 4; // Extended character sets / outlining + int PLANE_FIELD = 5; // Field attribute definition bytes + int PLANE_DBCS = 6; // Double-byte character plane + + // Color definitions (IBM 3279 standard 16-color palette) + char COLOR_NEUTRAL_BLACK = 0; + char COLOR_BLUE = 1; + char COLOR_RED = 2; + char COLOR_PINK = 3; + char COLOR_GREEN = 4; + char COLOR_TURQUOISE = 5; + char COLOR_YELLOW = 6; + char COLOR_NEUTRAL_WHITE = 7; + char COLOR_BLACK = 8; + char COLOR_DEEP_BLUE = 9; + char COLOR_ORANGE = 10; + char COLOR_PURPLE = 11; + char COLOR_PALE_GREEN = 12; + char COLOR_PALE_TURQUOISE= 13; + char COLOR_GREY = 14; + char COLOR_WHITE = 15; + + // Highlighting attributes + char HILITE_DEFAULT = 0x00; + char HILITE_BLINK = 0xF1; + char HILITE_REVERSE = 0xF2; + char HILITE_UNDERSCORE = 0xF4; + + // Search directions + int SEARCH_FORWARD = 1; + int SEARCH_BACKWARD = 2; + + // OIA Input Inhibited Reason Codes + int INHIBIT_NOT_INHIBITED = 0; + int INHIBIT_SYSTEM_LOCK = 1; // X SYSTEM (Waiting for host response) + int INHIBIT_NUMERIC_ONLY = 2; // X NUMERIC (Non-numeric in numeric field) + int INHIBIT_PROTECTED_FIELD = 3; // X PROT (Attempted write into protected field) + int INHIBIT_OVERFLOW = 4; // X > (Insert mode buffer overflow) + int INHIBIT_COMM_CHECK = 5; // X COMM (Communication or socket check) + int INHIBIT_OPERATOR_DUE = 6; // X OP (Operator Intervention Due) +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLField.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLField.java new file mode 100644 index 0000000..d6415af --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLField.java @@ -0,0 +1,148 @@ +package haus.nightmare.lib3270j.ecl; + +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; + +/** + * Represents a discrete 3270 field within an IBM Host On-Demand Presentation Space. + * Provides programmatic inspection and modification of field text and attributes. + */ +public class ECLField { + + private final ECLPS ps; + private final int startPos; // Position of field attribute byte + private final int dataStart; // First data position (startPos + 1 wrapped) + private final int endPos; // Last data position inclusive + private final int length; // Usable data character length + private final byte attribute; // Field attribute byte (FA) + + public ECLField(ECLPS ps, int startPos, int dataStart, int endPos, int length, byte attribute) { + this.ps = ps; + this.startPos = startPos; + this.dataStart = dataStart; + this.endPos = endPos; + this.length = length; + this.attribute = attribute; + } + + /** Buffer address of the field attribute character. */ + public int getStart() { return startPos; } + + /** First buffer address of the field data (start + 1). */ + public int getDataStart() { return dataStart; } + + /** Last buffer address of the field data inclusive. */ + public int getEnd() { return endPos; } + + /** Number of data characters in the field. */ + public int getLength() { return length; } + + public int getStartRow() { + int cols = ps.getCols(); + return cols > 0 ? startPos / cols : 0; + } + + public int getStartCol() { + int cols = ps.getCols(); + return cols > 0 ? startPos % cols : 0; + } + + public int getEndRow() { + int cols = ps.getCols(); + return cols > 0 ? endPos / cols : 0; + } + + public int getEndCol() { + int cols = ps.getCols(); + return cols > 0 ? endPos % cols : 0; + } + + public boolean isModified() { + return faIsModified(attribute & 0xFF); + } + + public boolean isProtected() { + return faIsProtected(attribute & 0xFF); + } + + public boolean isNumeric() { + return faIsNumeric(attribute & 0xFF); + } + + public boolean isHighIntensity() { + return faIsHigh(attribute & 0xFF); + } + + public boolean isHidden() { + return faIsZero(attribute & 0xFF); + } + + public boolean isDisplay() { + return !isHidden(); + } + + public boolean isPenSelectable() { + return faIsSelectable(attribute & 0xFF); + } + + public short getAttribute() { + return (short) (attribute & 0xFF); + } + + /** + * Get the text contents of this field as a String. + */ + public String getText() { + if (length <= 0) return ""; + return ps.getString(dataStart, length); + } + + /** + * Set the text contents of this field. + */ + public void setText(String text) { + if (isProtected() || length <= 0) return; + ps.setText(text, dataStart); + } + + /** + * Get selector light pen type. + * Returns '?' for selectable, '>' for selected, ' ' for space/unselectable. + */ + public char getSelectorPenType() { + if (length <= 0) return ' '; + String t = getText(); + if (!t.isEmpty()) { + char first = t.charAt(0); + if (first == '?' || first == '>') return first; + } + return ' '; + } + + /** + * Actuate lightpen selection on this field ('?' -> '>'). + */ + public void selectField() { + if (isProtected() || length <= 0) return; + String t = getText(); + if (!t.isEmpty() && t.charAt(0) == '?') { + setText(">" + (t.length() > 1 ? t.substring(1) : "")); + } + } + + /** + * Deselect lightpen selection on this field ('>' -> '?'). + */ + public void deSelectField() { + if (isProtected() || length <= 0) return; + String t = getText(); + if (!t.isEmpty() && t.charAt(0) == '>') { + setText("?" + (t.length() > 1 ? t.substring(1) : "")); + } + } + + @Override + public String toString() { + return String.format("ECLField[start=%d, end=%d, len=%d, prot=%b, mod=%b, text=\"%s\"]", + startPos, endPos, length, isProtected(), isModified(), getText()); + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLFieldList.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLFieldList.java new file mode 100644 index 0000000..cd1707d --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLFieldList.java @@ -0,0 +1,152 @@ +package haus.nightmare.lib3270j.ecl; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import haus.nightmare.lib3270j.screen.ExtendedAttribute; + +/** + * Manages the collection of all 3270 fields currently present in the Presentation Space. + * Conforms to IBM ECL ECLFieldList specification. + */ +public class ECLFieldList { + + private final ECLPS ps; + private final ScreenBuffer screen; + private final List fields = new ArrayList<>(); + + public ECLFieldList(ECLPS ps, ScreenBuffer screen) { + this.ps = ps; + this.screen = screen; + refresh(); + } + + /** + * Refresh the field list by scanning the presentation space buffer. + */ + public synchronized void refresh() { + fields.clear(); + if (screen == null || !screen.isFormatted()) { + return; + } + + int rows = screen.getRows(); + int cols = screen.getCols(); + int size = rows * cols; + if (size <= 0) return; + + // Collect all field attribute positions + List faPositions = new ArrayList<>(); + for (int i = 0; i < size; i++) { + ExtendedAttribute ea = screen.getCell(i); + if (ea.isFieldAttribute()) { + faPositions.add(i); + } + } + + if (faPositions.isEmpty()) { + return; + } + + int numFields = faPositions.size(); + for (int i = 0; i < numFields; i++) { + int startPos = faPositions.get(i); + int nextFaPos = faPositions.get((i + 1) % numFields); + + int dataStart = (startPos + 1) % size; + int endPos = (nextFaPos == 0) ? size - 1 : nextFaPos - 1; + + int len; + if (nextFaPos > startPos) { + len = nextFaPos - startPos - 1; + } else { + len = (size - startPos - 1) + nextFaPos; + } + + byte faVal = screen.getCell(startPos).fa; + fields.add(new ECLField(ps, startPos, dataStart, endPos, len, faVal)); + } + } + + public synchronized int getFieldCount() { + return fields.size(); + } + + public synchronized List getFields() { + return Collections.unmodifiableList(new ArrayList<>(fields)); + } + + public synchronized ECLField getFirstField() { + if (fields.isEmpty()) return null; + return fields.get(0); + } + + public synchronized ECLField getNextField(ECLField prev) { + if (prev == null || fields.isEmpty()) return getFirstField(); + int idx = fields.indexOf(prev); + if (idx >= 0 && idx + 1 < fields.size()) { + return fields.get(idx + 1); + } + return null; + } + + /** + * Find the field that contains the specified buffer position. + */ + public synchronized ECLField findField(int pos) { + if (fields.isEmpty() || screen == null) return null; + int size = screen.getRows() * screen.getCols(); + if (size <= 0) return null; + pos = ((pos % size) + size) % size; + + for (ECLField f : fields) { + int start = f.getStart(); + int end = f.getEnd(); + if (start <= end) { + if (pos >= start && pos <= end) return f; + } else { + // Wrapped field + if (pos >= start || pos <= end) return f; + } + } + return null; + } + + /** + * Find the field at the specified 0-indexed row and column. + */ + public ECLField findField(int row, int col) { + if (screen == null) return null; + int cols = screen.getCols(); + return findField(row * cols + col); + } + + /** + * Find field containing the given text string. + */ + public synchronized ECLField findField(String text, int startPos) { + if (text == null || text.isEmpty() || fields.isEmpty()) return null; + int size = screen.getRows() * screen.getCols(); + if (size <= 0) return null; + startPos = ((startPos % size) + size) % size; + + // Find starting index in field list + int startIdx = 0; + for (int i = 0; i < fields.size(); i++) { + if (fields.get(i).getStart() >= startPos) { + startIdx = i; + break; + } + } + + for (int i = startIdx; i < fields.size(); i++) { + ECLField f = fields.get(i); + if (f.getText().contains(text)) { + return f; + } + } + return null; + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLOIA.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLOIA.java new file mode 100644 index 0000000..b676009 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLOIA.java @@ -0,0 +1,143 @@ +package haus.nightmare.lib3270j.ecl; + +import java.util.ArrayList; +import java.util.List; + +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import haus.nightmare.lib3270j.telnet.TelnetFSM; +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; + +/** + * Operator Information Area (OIA) status engine matching IBM Host On-Demand ECL specification. + * Provides synchronization primitives (waitForInput, waitForSysAvailable) and status inspection. + */ +public class ECLOIA implements ECLConstants { + + private final ScreenBuffer screen; + private final InputProcessor inputProcessor; + private final TelnetFSM fsm; + private final List listeners = new ArrayList<>(); + + public interface ECLOIANotify { + void onOIAChanged(ECLOIA oia); + } + + public ECLOIA(ScreenBuffer screen, InputProcessor inputProcessor, TelnetFSM fsm) { + this.screen = screen; + this.inputProcessor = inputProcessor; + this.fsm = fsm; + + if (inputProcessor != null) { + inputProcessor.setLockStateListener(locked -> notifyOIAChanged()); + } + } + + public synchronized void registerOIAEvent(ECLOIANotify listener) { + if (listener != null && !listeners.contains(listener)) { + listeners.add(listener); + } + } + + public synchronized void unregisterOIAEvent(ECLOIANotify listener) { + listeners.remove(listener); + } + + private synchronized void notifyOIAChanged() { + for (ECLOIANotify l : listeners) { + try { + l.onOIAChanged(this); + } catch (Exception ignored) {} + } + } + + public boolean isInsertMode() { + return inputProcessor != null && inputProcessor.isInsertMode(); + } + + public boolean isNumeric() { + if (screen == null || !screen.isFormatted()) return false; + byte fa = screen.getFieldAttributeAt(screen.getCursorAddress()); + return faIsNumeric(fa & 0xFF); + } + + public boolean isAlphanumeric() { + return !isNumeric(); + } + + public boolean isMessageWaiting() { + return false; + } + + public boolean isCommError() { + return fsm != null && fsm.getConnectionState() != null && !fsm.getConnectionState().isConnected(); + } + + /** + * Get the current Input Inhibited code. + * Returns one of INHIBIT_* constants from ECLConstants. + */ + public int getInputInhibited() { + if (isCommError()) { + return INHIBIT_COMM_CHECK; + } + if (inputProcessor != null && inputProcessor.isKeyboardLocked()) { + return INHIBIT_SYSTEM_LOCK; + } + return INHIBIT_NOT_INHIBITED; + } + + /** + * Block until the keyboard becomes unlocked and ready for user input, or timeout expires. + * @return true if keyboard unlocked, false if timeout occurred. + */ + public boolean waitForInput(long timeoutMs) { + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < timeoutMs) { + if (getInputInhibited() == INHIBIT_NOT_INHIBITED) { + return true; + } + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return getInputInhibited() == INHIBIT_NOT_INHIBITED; + } + + /** + * Block until the host system is available. + */ + public boolean waitForSysAvailable(long timeoutMs) { + return waitForInput(timeoutMs); + } + + /** + * Block until application is available. + */ + public boolean waitForAppAvailable(long timeoutMs) { + return waitForInput(timeoutMs); + } + + /** + * Block until any OIA transition occurs. + */ + public boolean waitForTransition(long timeoutMs) { + int initialInhibit = getInputInhibited(); + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < timeoutMs) { + if (getInputInhibited() != initialInhibit) { + return true; + } + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return getInputInhibited() != initialInhibit; + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java new file mode 100644 index 0000000..2c43944 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java @@ -0,0 +1,237 @@ +package haus.nightmare.lib3270j.ecl; + +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ExtendedAttribute; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; + +/** + * Presentation Space (ECLPS) implementation conforming to IBM Host On-Demand ECL specification. + * Provides multi-plane presentation buffer access, string searches, formatted field navigation, + * and automated keystroke streaming. + */ +public class ECLPS implements ECLConstants { + + private final ScreenBuffer screen; + private final InputProcessor inputProcessor; + private final EbcdicTranslator translator; + private final ECLFieldList fieldList; + + public ECLPS(ScreenBuffer screen, InputProcessor inputProcessor, EbcdicTranslator translator) { + this.screen = screen; + this.inputProcessor = inputProcessor; + this.translator = translator; + this.fieldList = new ECLFieldList(this, screen); + } + + public ScreenBuffer getScreenBuffer() { return screen; } + public InputProcessor getInputProcessor() { return inputProcessor; } + public EbcdicTranslator getTranslator() { return translator; } + public ECLFieldList getFieldList() { + fieldList.refresh(); + return fieldList; + } + + public int getSize() { return screen.getRows() * screen.getCols(); } + public int getRows() { return screen.getRows(); } + public int getCols() { return screen.getCols(); } + public int getCursorPos(){ return screen.getCursorAddress(); } + public int getCursorRow(){ return screen.getCursorRow(); } + public int getCursorCol(){ return screen.getCursorCol(); } + + public void setCursorPos(int pos) { + screen.setCursorAddress(pos); + } + + public void setCursorPos(int row, int col) { + int pos = screen.rowColToAddress(row, col); + screen.setCursorAddress(pos); + } + + /** + * Copy presentation data from a specific plane into a destination buffer. + */ + public synchronized int getPlane(int planeType, char[] destBuffer, int start, int length) { + if (destBuffer == null || screen == null || length <= 0) return 0; + int size = screen.getRows() * screen.getCols(); + if (size <= 0) return 0; + + start = ((start % size) + size) % size; + int copyLen = Math.min(length, destBuffer.length); + + for (int i = 0; i < copyLen; i++) { + int addr = (start + i) % size; + ExtendedAttribute ea = screen.getCell(addr); + switch (planeType) { + case PLANE_TEXT: + if (ea.isFieldAttribute()) { + destBuffer[i] = ' '; + } else if (ea.ucs4 != 0) { + destBuffer[i] = (char) ea.ucs4; + } else if (ea.ec != 0) { + destBuffer[i] = translator.ebcdicToUnicode(ea.ec & 0xFF); + } else { + destBuffer[i] = ' '; + } + break; + case PLANE_COLOR: + destBuffer[i] = (char) (ea.fg & 0xFF); + break; + case PLANE_HILITE: + destBuffer[i] = (char) (ea.gr & 0xFF); + break; + case PLANE_EXTENDED: + destBuffer[i] = (char) (ea.cs & 0xFF); + break; + case PLANE_FIELD: + destBuffer[i] = ea.isFieldAttribute() ? (char) (ea.fa & 0xFF) : 0; + break; + default: + destBuffer[i] = ' '; + break; + } + } + return copyLen; + } + + /** + * Get a string of characters from the presentation space starting at address pos. + */ + public synchronized String getString(int pos, int length) { + if (length <= 0) return ""; + char[] buf = new char[length]; + getPlane(PLANE_TEXT, buf, pos, length); + return new String(buf); + } + + /** + * Get a string of characters from the presentation space starting at row/column. + */ + public String getString(int row, int col, int length) { + int pos = screen.rowColToAddress(row, col); + return getString(pos, length); + } + + /** + * Insert text directly into unprotected fields in the presentation space starting at pos. + */ + public synchronized void setText(String text, int pos) { + if (text == null || text.isEmpty() || screen == null) return; + int size = screen.getRows() * screen.getCols(); + if (size <= 0) return; + + pos = ((pos % size) + size) % size; + setCursorPos(pos); + + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + inputProcessor.typeCharacter(ch); + } + } + + /** + * Insert text starting at row and column. + */ + public void setText(String text, int row, int col) { + int pos = screen.rowColToAddress(row, col); + setText(text, pos); + } + + /** + * Search for a string in the presentation space. + * Returns 1-based or 0-based position, or -1 if not found. + */ + public synchronized int searchString(String target, int startRow, int startCol, int dir, boolean ignoreCase) { + if (target == null || target.isEmpty() || screen == null) return -1; + int rows = screen.getRows(); + int cols = screen.getCols(); + int size = rows * cols; + if (size <= 0) return -1; + + int startPos = (startRow * cols + startCol) % size; + char[] fullScreen = new char[size]; + getPlane(PLANE_TEXT, fullScreen, 0, size); + String screenText = new String(fullScreen); + + if (ignoreCase) { + screenText = screenText.toLowerCase(); + target = target.toLowerCase(); + } + + int targetLen = target.length(); + if (dir == SEARCH_FORWARD) { + // Forward search with wrapping + for (int i = 0; i < size; i++) { + int pos = (startPos + i) % size; + if (matchesAt(screenText, target, pos, size)) { + return pos; + } + } + } else { + // Backward search with wrapping + for (int i = 0; i < size; i++) { + int pos = (startPos - i + size) % size; + if (matchesAt(screenText, target, pos, size)) { + return pos; + } + } + } + return -1; + } + + private boolean matchesAt(String screenText, String target, int pos, int size) { + int len = target.length(); + for (int j = 0; j < len; j++) { + int charPos = (pos + j) % size; + if (screenText.charAt(charPos) != target.charAt(j)) { + return false; + } + } + return true; + } + + /** + * Paste text with line wrapping across unprotected fields. + */ + public synchronized int pasteLineWrap(String text, int startPos, int endCol, boolean wordWrap) { + if (text == null || text.isEmpty() || screen == null) return 0; + int cols = screen.getCols(); + int rows = screen.getRows(); + int size = rows * cols; + if (size <= 0) return 0; + + setCursorPos(startPos); + int charsPasted = 0; + String[] lines = text.split("\r?\n"); + + for (int l = 0; l < lines.length; l++) { + String line = lines[l]; + for (int i = 0; i < line.length(); i++) { + int curPos = screen.getCursorAddress(); + int curCol = curPos % cols; + if (endCol > 0 && curCol >= endCol) { + // Advance to next row + int nextRow = (curPos / cols + 1) % rows; + setCursorPos(nextRow * cols); + } + inputProcessor.typeCharacter(line.charAt(i)); + charsPasted++; + } + if (l < lines.length - 1) { + // Newline key between lines + inputProcessor.newline(); + } + } + return charsPasted; + } + + /** + * Send standard IBM ECL mnemonic keystrokes. + */ + public void sendKeys(String keys) { + if (inputProcessor != null) { + inputProcessor.sendKeys(keys); + } + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java index d46b4cf..613912e 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java @@ -863,6 +863,204 @@ public class InputProcessor { return fieldLen; } + /** + * Parse and execute IBM ECL standard mnemonic keystrokes (e.g. "USER[tab]PASS[enter]"). + */ + public void sendKeys(String keys) { + if (keys == null || keys.isEmpty()) return; + + int len = keys.length(); + int i = 0; + while (i < len) { + char c = keys.charAt(i); + if (c == '[') { + int end = keys.indexOf(']', i); + if (end > i) { + String token = keys.substring(i + 1, end).trim().toLowerCase(); + executeMnemonicToken(token); + i = end + 1; + continue; + } + } + typeCharacter(c); + i++; + } + } + + private void executeMnemonicToken(String token) { + switch (token) { + case "enter": + case "return": + sendAid(AID_ENTER); + break; + case "clear": + sendAid(AID_CLEAR); + break; + case "tab": + tab(); + break; + case "backtab": + case "btab": + backTab(); + break; + case "newline": + case "nl": + newline(); + break; + case "home": + cursorHome(); + break; + case "up": + case "curup": + cursorUp(); + break; + case "down": + case "curdown": + cursorDown(); + break; + case "left": + case "curleft": + cursorLeft(); + break; + case "right": + case "curright": + cursorRight(); + break; + case "eraseeof": + case "erase_eof": + eraseEof(); + break; + case "eraseinp": + case "erase_input": + eraseInput(); + break; + case "dup": + dup(); + break; + case "fm": + case "fieldmark": + fieldMark(); + break; + case "attn": + attn(); + break; + case "sysreq": + sysReq(); + break; + case "reset": + reset(); + break; + case "insert": + setInsertMode(!isInsertMode()); + break; + case "delete": + deleteChar(); + break; + case "backspace": + case "bs": + backspace(); + break; + case "wordtab": + processWordTab(true); + break; + case "wordbacktab": + processWordTab(false); + break; + case "deleteword": + processDeleteWord(); + break; + default: + if (token.startsWith("pf")) { + try { + int pfNum = Integer.parseInt(token.substring(2)); + if (pfNum >= 1 && pfNum <= 24) { + sendAid(AID_PF1 + (pfNum - 1)); + } + } catch (NumberFormatException ignored) {} + } else if (token.startsWith("pa")) { + try { + int paNum = Integer.parseInt(token.substring(2)); + if (paNum >= 1 && paNum <= 3) { + sendAid(AID_PA1 + (paNum - 1)); + } + } catch (NumberFormatException ignored) {} + } + break; + } + } + + /** + * Jump cursor to next or previous word boundary. + */ + public void processWordTab(boolean forward) { + if (keyboardLocked || screen == null) return; + int size = screen.getRows() * screen.getCols(); + if (size <= 0) return; + + int cur = screen.getCursorAddress(); + if (forward) { + // Find next whitespace then next non-whitespace + int pos = screen.incrementAddress(cur); + int count = 0; + while (count < size && !isWhitespaceOrNull(pos)) { + pos = screen.incrementAddress(pos); + count++; + } + while (count < size && isWhitespaceOrNull(pos)) { + pos = screen.incrementAddress(pos); + count++; + } + screen.setCursorAddress(pos); + } else { + // Find previous non-whitespace after whitespace + int pos = screen.decrementAddress(cur); + int count = 0; + while (count < size && isWhitespaceOrNull(pos)) { + pos = screen.decrementAddress(pos); + count++; + } + while (count < size && !isWhitespaceOrNull(screen.decrementAddress(pos))) { + pos = screen.decrementAddress(pos); + count++; + } + screen.setCursorAddress(pos); + } + screen.markAllChanged(); + screen.updateDisplaySnapshot(); + } + + /** + * Delete the word starting at the cursor position. + */ + public void processDeleteWord() { + if (keyboardLocked || screen == null) return; + int cur = screen.getCursorAddress(); + if (screen.isFormatted()) { + byte fa = screen.getFieldAttributeAt(cur); + if (faIsProtected(fa & 0xFF)) return; + } + + int count = 0; + int size = screen.getRows() * screen.getCols(); + while (count < size && !isWhitespaceOrNull(screen.getCursorAddress())) { + deleteChar(); + count++; + } + // Also delete trailing spaces + while (count < size && isWhitespaceOrNull(screen.getCursorAddress()) && screen.getCell(screen.getCursorAddress()).ec != 0) { + deleteChar(); + count++; + } + } + + private boolean isWhitespaceOrNull(int pos) { + if (screen == null) return true; + ExtendedAttribute ea = screen.getCell(pos); + if (ea.isFieldAttribute()) return true; + int ec = ea.ec & 0xFF; + return ec == 0 || ec == 0x40; // Null or EBCDIC Space + } + /** * Send a structured field response directly. * Used by DFT mode file transfer. diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLFieldListTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLFieldListTest.java new file mode 100644 index 0000000..ddfc398 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLFieldListTest.java @@ -0,0 +1,97 @@ +package haus.nightmare.lib3270j.ecl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; + +public class ECLFieldListTest { + + 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); + } + + @Test + public void testFieldListDetection() { + // Create 2 fields: pos 0 (unprotected, 9 chars), pos 10 (protected, 19 chars), pos 30 (high intensity) + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.setFieldAttribute(10, (byte) (FA_PRINTABLE | FA_PROTECT)); + screen.setFieldAttribute(30, (byte) (FA_PRINTABLE | FA_INT_HIGH_SEL)); + + ECLFieldList list = ps.getFieldList(); + assertEquals(3, list.getFieldCount()); + + ECLField f1 = list.getFirstField(); + assertNotNull(f1); + assertEquals(0, f1.getStart()); + assertEquals(1, f1.getDataStart()); + assertEquals(9, f1.getEnd()); + assertEquals(9, f1.getLength()); + assertFalse(f1.isProtected()); + + ECLField f2 = list.getNextField(f1); + assertNotNull(f2); + assertEquals(10, f2.getStart()); + assertEquals(11, f2.getDataStart()); + assertEquals(29, f2.getEnd()); + assertEquals(19, f2.getLength()); + assertTrue(f2.isProtected()); + + ECLField f3 = list.getNextField(f2); + assertNotNull(f3); + assertEquals(30, f3.getStart()); + assertTrue(f3.isHighIntensity()); + assertTrue(f3.isPenSelectable()); + + assertNull(list.getNextField(f3)); + } + + @Test + public void testFindFieldByPositionAndRowCol() { + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.setFieldAttribute(80, (byte) (FA_PRINTABLE | FA_PROTECT)); + + ECLFieldList list = ps.getFieldList(); + ECLField found1 = list.findField(40); + assertNotNull(found1); + assertEquals(0, found1.getStart()); + + ECLField found2 = list.findField(1, 10); // Row 1, Col 10 -> addr 90 + assertNotNull(found2); + assertEquals(80, found2.getStart()); + assertTrue(found2.isProtected()); + } + + @Test + public void testLightpenSelectionOnField() { + screen.setFieldAttribute(0, (byte) (FA_PRINTABLE | FA_INT_HIGH_SEL)); + screen.setCell(1, translator.unicodeToEbcdic('?')); + screen.setCell(2, translator.unicodeToEbcdic('O')); + screen.setCell(3, translator.unicodeToEbcdic('K')); + screen.setFieldAttribute(10, (byte) (FA_PRINTABLE | FA_PROTECT)); + screen.translateToUnicode(); + + ECLField field = ps.getFieldList().getFirstField(); + assertEquals('?', field.getSelectorPenType()); + + field.selectField(); + assertEquals('>', field.getSelectorPenType()); + + field.deSelectField(); + assertEquals('?', field.getSelectorPenType()); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLOIATest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLOIATest.java new file mode 100644 index 0000000..65c25e3 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLOIATest.java @@ -0,0 +1,59 @@ +package haus.nightmare.lib3270j.ecl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.atomic.AtomicBoolean; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import static haus.nightmare.lib3270j.ecl.ECLConstants.*; + +public class ECLOIATest { + + private ScreenBuffer screen; + private EbcdicTranslator translator; + private InputProcessor input; + private ECLOIA oia; + + @BeforeEach + public void setup() { + translator = new EbcdicTranslator(); + screen = new ScreenBuffer(TerminalModel.IBM_3278_2, translator); + input = new InputProcessor(screen, translator, null); + oia = new ECLOIA(screen, input, null); + } + + @Test + public void testInhibitStateTransitions() { + assertEquals(INHIBIT_NOT_INHIBITED, oia.getInputInhibited()); + assertTrue(oia.waitForInput(100)); + + input.setKeyboardLocked(true); + assertEquals(INHIBIT_SYSTEM_LOCK, oia.getInputInhibited()); + assertFalse(oia.waitForInput(50)); + + input.setKeyboardLocked(false); + assertEquals(INHIBIT_NOT_INHIBITED, oia.getInputInhibited()); + assertTrue(oia.waitForInput(100)); + } + + @Test + public void testOIAEventListener() { + AtomicBoolean triggered = new AtomicBoolean(false); + oia.registerOIAEvent(changedOia -> triggered.set(true)); + + input.setKeyboardLocked(true); + assertTrue(triggered.get()); + } + + @Test + public void testInsertModeState() { + assertFalse(oia.isInsertMode()); + input.setInsertMode(true); + assertTrue(oia.isInsertMode()); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLPSTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLPSTest.java new file mode 100644 index 0000000..fb12a35 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/ECLPSTest.java @@ -0,0 +1,117 @@ +package haus.nightmare.lib3270j.ecl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import haus.nightmare.lib3270j.telnet.TelnetFSM; +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; + +public class ECLPSTest implements ECLConstants { + + 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); + } + + @Test + public void testGeometry() { + assertEquals(24, ps.getRows()); + assertEquals(80, ps.getCols()); + assertEquals(24 * 80, ps.getSize()); + assertEquals(0, ps.getCursorPos()); + + ps.setCursorPos(10, 20); + assertEquals(10 * 80 + 20, ps.getCursorPos()); + assertEquals(10, ps.getCursorRow()); + assertEquals(20, ps.getCursorCol()); + } + + @Test + public void testPlaneTextExtractionAndSearch() { + // Set some text in buffer + screen.setCell(0, 0xC8); // 'H' + screen.setCell(1, 0xC5); // 'E' + screen.setCell(2, 0xD3); // 'L' + screen.setCell(3, 0xD3); // 'L' + screen.setCell(4, 0xD6); // 'O' + screen.translateToUnicode(); + + assertEquals("HELLO", ps.getString(0, 5)); + + // Search string forward + int pos = ps.searchString("ELL", 0, 0, SEARCH_FORWARD, false); + assertEquals(1, pos); + + // Search string case-insensitive + int posLower = ps.searchString("hello", 0, 0, SEARCH_FORWARD, true); + assertEquals(0, posLower); + + // Search not found + int notFound = ps.searchString("WORLD", 0, 0, SEARCH_FORWARD, false); + assertEquals(-1, notFound); + } + + @Test + public void testMultiPlaneReading() { + screen.setFieldAttribute(0, (byte) (FA_PRINTABLE | FA_PROTECT)); + screen.setCell(1, 0xC1); // 'A' + screen.getCell(1).fg = COLOR_RED; + screen.getCell(1).gr = (byte) HILITE_BLINK; + + char[] textPlane = new char[2]; + char[] colorPlane = new char[2]; + char[] hilitePlane = new char[2]; + char[] fieldPlane = new char[2]; + + ps.getPlane(PLANE_TEXT, textPlane, 0, 2); + ps.getPlane(PLANE_COLOR, colorPlane, 0, 2); + ps.getPlane(PLANE_HILITE, hilitePlane, 0, 2); + ps.getPlane(PLANE_FIELD, fieldPlane, 0, 2); + + assertEquals(' ', textPlane[0]); // FA is space in text plane + assertEquals('A', textPlane[1]); + + assertEquals(0, colorPlane[0]); + assertEquals(COLOR_RED, colorPlane[1]); + + assertEquals(0, hilitePlane[0]); + assertEquals((char) (HILITE_BLINK & 0xFF), hilitePlane[1]); + + assertTrue((fieldPlane[0] & 0xFF) != 0); + assertEquals(0, fieldPlane[1]); + } + + @Test + public void testSendKeysMnemonics() { + // Create an unprotected field at pos 0 + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.setCursorAddress(1); + + ps.sendKeys("IBM[tab]"); + assertEquals('I', screen.getCell(1).ucs4); + assertEquals('B', screen.getCell(2).ucs4); + assertEquals('M', screen.getCell(3).ucs4); + } + + @Test + public void testPasteLineWrap() { + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.setCursorAddress(1); + + int count = ps.pasteLineWrap("ABC\nDEF", 1, 80, false); + assertEquals(6, count); + } +}