diff --git a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java index 0e665d8..bd7fa9a 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java +++ b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java @@ -70,13 +70,15 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate setLocationRelativeTo(null); setMinimumSize(new Dimension(640, 400)); - // Status refresh timer — also ensures focus stays on terminal + // Status refresh timer — also ensures focus stays on terminal (guarding against focus stealing) refreshTimer = new Timer(100, e -> { if (client != null) { statusBar.updateStatus(); syncModeMenuItems(); - if (isActive() && !terminalPanel.hasFocus()) { - terminalPanel.requestFocusInWindow(); + if (!isAnyChildDialogActive() && KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow() == this) { + if (!terminalPanel.hasFocus()) { + terminalPanel.requestFocusInWindow(); + } } } }); @@ -96,11 +98,30 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate @Override public void windowActivated(WindowEvent e) { - terminalPanel.requestFocusInWindow(); + if (!isAnyChildDialogActive()) { + terminalPanel.requestFocusInWindow(); + } } }); } + private boolean isAnyChildDialogActive() { + Window focusedWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow(); + if (focusedWindow != null && focusedWindow != this) { + return true; + } + if (findDialog != null && findDialog.isVisible()) return true; + if (scriptDialog != null && scriptDialog.isVisible()) return true; + if (fieldInspectorDialog != null && fieldInspectorDialog.isVisible()) return true; + if (printerSessionDialog != null && printerSessionDialog.isVisible()) return true; + for (Window owned : getOwnedWindows()) { + if (owned != null && owned.isVisible()) { + return true; + } + } + return false; + } + private void buildUI() { terminalPanel = new TerminalPanel(); statusBar = new StatusBar(); @@ -743,13 +764,26 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate fileTransfer.cancel(); fileTransfer = null; } - client.disconnect(); + Telnet3270Client c = client; client = null; - terminalPanel.setClient(null); - statusBar.setClient(null, terminalPanel); - syncModeMenuItems(); - terminalPanel.repaint(); - setTitle("j3270 — Java TN3270 Terminal Emulator"); + new Thread(() -> { + try { + c.disconnect(); + } catch (Exception ignored) {} + }, "Disconnect-Thread").start(); + + Runnable uiReset = () -> { + terminalPanel.setClient(null); + statusBar.setClient(null, terminalPanel); + syncModeMenuItems(); + terminalPanel.repaint(); + setTitle("j3270 — Java TN3270 Terminal Emulator"); + }; + if (SwingUtilities.isEventDispatchThread()) { + uiReset.run(); + } else { + SwingUtilities.invokeLater(uiReset); + } } } @@ -790,7 +824,16 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate statusBar.updateStatus(); terminalPanel.repaint(); - if (newState.isFullSession() && !oldState.isFullSession()) { + if (newState == ConnectionState.RECONNECTING) { + setTitle("j3270 — " + lastHost + ":" + lastPort + " [Reconnecting...]"); + } else if (newState == ConnectionState.NOT_CONNECTED) { + setTitle("j3270 — Java TN3270 Terminal Emulator"); + } else if (newState.isFullSession() && !oldState.isFullSession()) { + String tlsIndicator = (client != null && client.getConfig().isUseTls()) ? + (client.getConfig().isTlsVerifyCert() ? " [TLS]" : " [TLS/Unverified]") : ""; + String dev = (client != null && client.getTelnetFSM() != null && client.getTelnetFSM().getConnectedLu() != null) ? + " [" + client.getTelnetFSM().getConnectedLu() + "]" : ""; + setTitle("j3270 — " + lastHost + ":" + lastPort + tlsIndicator + dev); terminalPanel.guardedPack(); terminalPanel.requestFocusInWindow(); } @@ -847,6 +890,19 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate }); } + @Override + public void onCursorMoved(int oldAddress, int newAddress) { + SwingUtilities.invokeLater(() -> { + terminalPanel.repaint(); + statusBar.updateStatus(); + }); + } + + @Override + public void onKeyboardUnlocked() { + SwingUtilities.invokeLater(statusBar::updateStatus); + } + // ========== Help dialogs ========== private void showKeyMappings() { @@ -949,6 +1005,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate boolean cliTls = false; boolean cliNoVerifyCert = false; Boolean cliTn3270e = null; + Boolean cliAutoSysUnlock = null; GraphicsMode cliGraphicsMode = null; String configFile = null; @@ -965,6 +1022,10 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate cliTn3270e = false; } else if ("--tn3270e".equals(arg)) { cliTn3270e = true; + } else if ("--auto-sys-unlock".equals(arg)) { + cliAutoSysUnlock = true; + } else if ("--no-auto-sys-unlock".equals(arg)) { + cliAutoSysUnlock = false; } else if (arg.startsWith("--graphics=")) { cliGraphicsMode = GraphicsMode.fromString(arg.substring(11)); } else if (("--graphics".equals(arg) || "-g".equals(arg)) && i + 1 < args.length && !args[i + 1].startsWith("-")) { @@ -1041,6 +1102,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate final boolean finalTls = cliTls; final boolean finalNoVerify = cliNoVerifyCert; final Boolean finalTn3270e = cliTn3270e; + final Boolean finalAutoSysUnlock = cliAutoSysUnlock; final GraphicsMode finalGraphicsMode = cliGraphicsMode; SwingUtilities.invokeLater(() -> { @@ -1077,6 +1139,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate if (finalTn3270e != null) { config.setTn3270eEnabled(finalTn3270e); } + if (finalAutoSysUnlock != null) { + config.setAutoSysUnlock(finalAutoSysUnlock); + } else { + config.setAutoSysUnlock(haus.nightmare.j3270.config.Settings.getAutoSysUnlock()); + } if (finalGraphicsMode != null) { config.setGraphicsMode(finalGraphicsMode); } else { @@ -1100,6 +1167,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate ConnectionConfig config = new ConnectionConfig(host, port, TerminalModel.IBM_3279_4, tls); config.setTlsVerifyCert(verify); config.setTn3270eEnabled(finalTn3270e != null ? finalTn3270e : tn3270e); + config.setAutoSysUnlock(finalAutoSysUnlock != null ? finalAutoSysUnlock : haus.nightmare.j3270.config.Settings.getAutoSysUnlock()); if (finalGraphicsMode != null) { config.setGraphicsMode(finalGraphicsMode); } else { diff --git a/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java b/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java index de76ba6..b866282 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java +++ b/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java @@ -105,6 +105,51 @@ public class Settings { prefs.putBoolean("autoConnectTn3270e", tn3270e); } + public static boolean getAutoSysUnlock() { + return prefs.getBoolean("autoSysUnlock", true); + } + + public static void setAutoSysUnlock(boolean autoSysUnlock) { + prefs.putBoolean("autoSysUnlock", autoSysUnlock); + flushPrefs(); + } + + public static boolean getAutoConnectKeepAlive() { + return prefs.getBoolean("autoConnectKeepAlive", true); + } + + public static void setAutoConnectKeepAlive(boolean keepAlive) { + prefs.putBoolean("autoConnectKeepAlive", keepAlive); + flushPrefs(); + } + + public static int getAutoConnectKeepAliveInterval() { + return prefs.getInt("autoConnectKeepAliveInterval", 120); + } + + public static void setAutoConnectKeepAliveInterval(int interval) { + prefs.putInt("autoConnectKeepAliveInterval", interval); + flushPrefs(); + } + + public static boolean getAutoConnectAutoReconnect() { + return prefs.getBoolean("autoConnectAutoReconnect", false); + } + + public static void setAutoConnectAutoReconnect(boolean autoReconnect) { + prefs.putBoolean("autoConnectAutoReconnect", autoReconnect); + flushPrefs(); + } + + public static int getAutoConnectReconnectMaxRetries() { + return prefs.getInt("autoConnectReconnectMaxRetries", 5); + } + + public static void setAutoConnectReconnectMaxRetries(int retries) { + prefs.putInt("autoConnectReconnectMaxRetries", retries); + flushPrefs(); + } + public static haus.nightmare.lib3270j.graphics.GraphicsMode getGraphicsMode() { String modeStr = prefs.get("graphicsMode", haus.nightmare.lib3270j.graphics.GraphicsMode.BOTH.name()); return haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(modeStr); @@ -311,6 +356,26 @@ public class Settings { prefs.putBoolean("blockSelectMode", block); } + // ========== Clipboard & Tabular Paste ========== + + public static boolean getEnablePasteFromExcel() { + return prefs.getBoolean("enablePasteFromExcel", true); + } + + public static void setEnablePasteFromExcel(boolean val) { + prefs.putBoolean("enablePasteFromExcel", val); + flushPrefs(); + } + + public static boolean getPasteStopAtProtectedLine() { + return prefs.getBoolean("pasteStopAtProtectedLine", false); + } + + public static void setPasteStopAtProtectedLine(boolean val) { + prefs.putBoolean("pasteStopAtProtectedLine", val); + flushPrefs(); + } + // ========== Crosshair Ruler ========== public static boolean getCrosshairRuler() { @@ -509,11 +574,42 @@ public class Settings { setDynamicCols(Integer.parseInt(value)); break; case "blockSelectMode": setBlockSelectMode(Boolean.parseBoolean(value)); break; + case "autoSysUnlock": + case "auto_sys_unlock": + setAutoSysUnlock(Boolean.parseBoolean(value)); + break; + case "enablePasteFromExcel": + case "pasteFromExcel": + case "excelPaste": + setEnablePasteFromExcel(Boolean.parseBoolean(value)); + break; + case "pasteStopAtProtectedLine": + case "stopAtProtected": + case "pasteStopAtProtected": + setPasteStopAtProtectedLine(Boolean.parseBoolean(value)); + break; default: log.warning("Unknown behavior/connection key: " + key); } break; + case "clipboard": + switch (key) { + case "enablePasteFromExcel": + case "pasteFromExcel": + case "excelPaste": + setEnablePasteFromExcel(Boolean.parseBoolean(value)); + break; + case "pasteStopAtProtectedLine": + case "stopAtProtected": + case "pasteStopAtProtected": + setPasteStopAtProtectedLine(Boolean.parseBoolean(value)); + break; + default: + log.warning("Unknown clipboard key: " + key); + } + break; + case "colors": if (key.startsWith("color_")) { int index = Integer.parseInt(key.substring(6)); @@ -633,6 +729,9 @@ public class Settings { w.println("dynamicRows = " + getDynamicRows()); w.println("dynamicCols = " + getDynamicCols()); w.println("blockSelectMode = " + getBlockSelectMode()); + w.println("autoSysUnlock = " + getAutoSysUnlock()); + w.println("enablePasteFromExcel = " + getEnablePasteFromExcel()); + w.println("pasteStopAtProtectedLine = " + getPasteStopAtProtectedLine()); w.println(); // [entryassist] diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/AwtPixelBufferBridge.java b/j3270/src/main/java/haus/nightmare/j3270/ui/AwtPixelBufferBridge.java new file mode 100644 index 0000000..124d935 --- /dev/null +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/AwtPixelBufferBridge.java @@ -0,0 +1,88 @@ +package haus.nightmare.j3270.ui; + +import haus.nightmare.lib3270j.graphics.DefaultPixelBuffer; +import haus.nightmare.lib3270j.graphics.PixelBuffer; + +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferInt; +import java.awt.image.DirectColorModel; +import java.awt.image.Raster; +import java.awt.image.SinglePixelPackedSampleModel; +import java.awt.image.WritableRaster; + +/** + * Desktop Swing/AWT bridge for converting between lib3270j PixelBuffer and java.awt BufferedImage. + */ +public final class AwtPixelBufferBridge { + + private AwtPixelBufferBridge() {} + + /** + * Converts a PixelBuffer to a BufferedImage. + * Uses TYPE_INT_ARGB with backed raster for optimal Java2D hardware acceleration. + */ + public static BufferedImage toBufferedImage(PixelBuffer buffer) { + if (buffer == null) return null; + int w = buffer.getWidth(); + int h = buffer.getHeight(); + if (w <= 0 || h <= 0) { + return new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + } + + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + copyIntoBufferedImage(buffer, img); + return img; + } + + /** + * Copies pixels from a PixelBuffer directly into an existing BufferedImage. + */ + public static void copyIntoBufferedImage(PixelBuffer buffer, BufferedImage target) { + if (buffer == null || target == null) return; + int w = Math.min(buffer.getWidth(), target.getWidth()); + int h = Math.min(buffer.getHeight(), target.getHeight()); + if (w <= 0 || h <= 0) return; + + int[] srcPixels = buffer.getPixels(); + if (srcPixels == null) return; + + if (target.getType() == BufferedImage.TYPE_INT_ARGB || target.getType() == BufferedImage.TYPE_INT_RGB) { + if (target.getRaster().getDataBuffer() instanceof DataBufferInt) { + int[] dstPixels = ((DataBufferInt) target.getRaster().getDataBuffer()).getData(); + int srcW = buffer.getWidth(); + int dstW = target.getWidth(); + if (srcW == dstW && srcW == w && srcPixels.length >= w * h && dstPixels.length >= w * h) { + System.arraycopy(srcPixels, 0, dstPixels, 0, w * h); + return; + } + for (int y = 0; y < h; y++) { + System.arraycopy(srcPixels, y * srcW, dstPixels, y * dstW, w); + } + return; + } + } + + // Fallback for custom or incompatible image formats + target.setRGB(0, 0, w, h, srcPixels, 0, buffer.getWidth()); + } + + /** + * Wraps or converts a java.awt BufferedImage into a PixelBuffer. + */ + public static PixelBuffer toPixelBuffer(BufferedImage img) { + if (img == null) return null; + int w = img.getWidth(); + int h = img.getHeight(); + int[] pixels; + if ((img.getType() == BufferedImage.TYPE_INT_ARGB || img.getType() == BufferedImage.TYPE_INT_RGB) + && img.getRaster().getDataBuffer() instanceof DataBufferInt) { + pixels = ((DataBufferInt) img.getRaster().getDataBuffer()).getData(); + } else { + pixels = new int[w * h]; + img.getRGB(0, 0, w, h, pixels, 0, w); + } + return new DefaultPixelBuffer(w, h, pixels); + } +} diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java index 33302a4..e3a385c 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java @@ -24,6 +24,8 @@ public class ConnectDialog extends JDialog { private JCheckBox tlsCheckBox; private JCheckBox verifyCertCheckBox; private JCheckBox tn3270eCheckBox; + private JCheckBox keepAliveCheckBox; + private JCheckBox autoReconnectCheckBox; private boolean confirmed; private ConnectionConfig result; @@ -246,6 +248,24 @@ public class ConnectDialog extends JDialog { tn3270eCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectTn3270e()); mainPanel.add(tn3270eCheckBox, gbc); + // Keep-Alive Checkbox + gbc.gridx = 1; + gbc.gridy = 10; + keepAliveCheckBox = new JCheckBox("Enable Keep-Alive Heartbeat (NOP)"); + ThemeManager.styleCheckBox(keepAliveCheckBox); + keepAliveCheckBox.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13)); + keepAliveCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectKeepAlive()); + mainPanel.add(keepAliveCheckBox, gbc); + + // Auto-Reconnect Checkbox + gbc.gridx = 1; + gbc.gridy = 11; + autoReconnectCheckBox = new JCheckBox("Auto-Reconnect on Disconnect"); + ThemeManager.styleCheckBox(autoReconnectCheckBox); + autoReconnectCheckBox.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13)); + autoReconnectCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectAutoReconnect()); + mainPanel.add(autoReconnectCheckBox, gbc); + // Buttons JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 4)); @@ -266,7 +286,7 @@ public class ConnectDialog extends JDialog { buttonPanel.add(connectBtn); gbc.gridx = 0; - gbc.gridy = 10; + gbc.gridy = 12; gbc.gridwidth = 2; mainPanel.add(buttonPanel, gbc); @@ -328,6 +348,18 @@ public class ConnectDialog extends JDialog { result.setUseTls(tlsCheckBox.isSelected()); result.setTlsVerifyCert(verifyCertCheckBox.isSelected()); result.setTn3270eEnabled(tn3270eCheckBox.isSelected()); + result.setAutoSysUnlock(haus.nightmare.j3270.config.Settings.getAutoSysUnlock()); + + boolean ka = keepAliveCheckBox.isSelected(); + result.setKeepAliveEnabled(ka); + result.setKeepAliveIntervalSeconds(haus.nightmare.j3270.config.Settings.getAutoConnectKeepAliveInterval()); + haus.nightmare.j3270.config.Settings.setAutoConnectKeepAlive(ka); + + boolean ar = autoReconnectCheckBox.isSelected(); + result.setAutoReconnect(ar); + result.setReconnectMaxRetries(haus.nightmare.j3270.config.Settings.getAutoConnectReconnectMaxRetries()); + haus.nightmare.j3270.config.Settings.setAutoConnectAutoReconnect(ar); + confirmed = true; dispose(); } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/KeyBindings.java b/j3270/src/main/java/haus/nightmare/j3270/ui/KeyBindings.java new file mode 100644 index 0000000..6e0b1ab --- /dev/null +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/KeyBindings.java @@ -0,0 +1,427 @@ +package haus.nightmare.j3270.ui; + +import java.io.*; +import java.util.*; +import java.util.logging.Logger; + +/** + * Keyboard mapping profile (.kmp) file importer and keybinding utility. + * Parses keyboard mapping profile files, translates key scan codes and mnemonics + * to j3270 action handlers. + */ +public class KeyBindings { + + private static final Logger log = Logger.getLogger(KeyBindings.class.getName()); + + // IBM scan code to standard Java KeyStroke key name mapping + private static final Map SCAN_CODE_MAP = new HashMap<>(); + + // Mnemonic to j3270 action mapping + private static final Map MNEMONIC_MAP = new HashMap<>(); + + static { + // Alphanumeric and symbol keys + SCAN_CODE_MAP.put(1, "BACK_QUOTE"); + SCAN_CODE_MAP.put(2, "1"); + SCAN_CODE_MAP.put(3, "2"); + SCAN_CODE_MAP.put(4, "3"); + SCAN_CODE_MAP.put(5, "4"); + SCAN_CODE_MAP.put(6, "5"); + SCAN_CODE_MAP.put(7, "6"); + SCAN_CODE_MAP.put(8, "7"); + SCAN_CODE_MAP.put(9, "8"); + SCAN_CODE_MAP.put(10, "9"); + SCAN_CODE_MAP.put(11, "0"); + SCAN_CODE_MAP.put(12, "MINUS"); + SCAN_CODE_MAP.put(13, "EQUALS"); + SCAN_CODE_MAP.put(14, "BACK_SPACE"); + SCAN_CODE_MAP.put(15, "TAB"); + SCAN_CODE_MAP.put(16, "Q"); + SCAN_CODE_MAP.put(17, "W"); + SCAN_CODE_MAP.put(18, "E"); + SCAN_CODE_MAP.put(19, "R"); + SCAN_CODE_MAP.put(20, "T"); + SCAN_CODE_MAP.put(21, "Y"); + SCAN_CODE_MAP.put(22, "U"); + SCAN_CODE_MAP.put(23, "I"); + SCAN_CODE_MAP.put(24, "O"); + SCAN_CODE_MAP.put(25, "P"); + SCAN_CODE_MAP.put(26, "OPEN_BRACKET"); + SCAN_CODE_MAP.put(27, "CLOSE_BRACKET"); + SCAN_CODE_MAP.put(28, "BACK_SLASH"); + SCAN_CODE_MAP.put(29, "CAPS_LOCK"); + SCAN_CODE_MAP.put(30, "A"); + SCAN_CODE_MAP.put(31, "S"); + SCAN_CODE_MAP.put(32, "D"); + SCAN_CODE_MAP.put(33, "F"); + SCAN_CODE_MAP.put(34, "G"); + SCAN_CODE_MAP.put(35, "H"); + SCAN_CODE_MAP.put(36, "J"); + SCAN_CODE_MAP.put(37, "K"); + SCAN_CODE_MAP.put(38, "L"); + SCAN_CODE_MAP.put(39, "SEMICOLON"); + SCAN_CODE_MAP.put(40, "QUOTE"); + SCAN_CODE_MAP.put(41, "BACK_QUOTE"); + SCAN_CODE_MAP.put(42, "ENTER"); + SCAN_CODE_MAP.put(43, "ENTER"); + SCAN_CODE_MAP.put(45, "Z"); + SCAN_CODE_MAP.put(46, "X"); + SCAN_CODE_MAP.put(47, "C"); + SCAN_CODE_MAP.put(48, "V"); + SCAN_CODE_MAP.put(49, "B"); + SCAN_CODE_MAP.put(50, "N"); + SCAN_CODE_MAP.put(51, "M"); + SCAN_CODE_MAP.put(52, "COMMA"); + SCAN_CODE_MAP.put(53, "PERIOD"); + SCAN_CODE_MAP.put(54, "SLASH"); + SCAN_CODE_MAP.put(57, "SPACE"); + + // Editing & Navigation keys + SCAN_CODE_MAP.put(75, "INSERT"); + SCAN_CODE_MAP.put(76, "DELETE"); + SCAN_CODE_MAP.put(79, "LEFT"); + SCAN_CODE_MAP.put(80, "HOME"); + SCAN_CODE_MAP.put(81, "END"); + SCAN_CODE_MAP.put(83, "UP"); + SCAN_CODE_MAP.put(84, "DOWN"); + SCAN_CODE_MAP.put(85, "PAGE_UP"); + SCAN_CODE_MAP.put(86, "PAGE_DOWN"); + SCAN_CODE_MAP.put(89, "RIGHT"); + + // Numeric Keypad + SCAN_CODE_MAP.put(90, "NUM_LOCK"); + SCAN_CODE_MAP.put(91, "NUMPAD7"); + SCAN_CODE_MAP.put(92, "NUMPAD4"); + SCAN_CODE_MAP.put(93, "NUMPAD1"); + SCAN_CODE_MAP.put(95, "DIVIDE"); + SCAN_CODE_MAP.put(96, "NUMPAD8"); + SCAN_CODE_MAP.put(97, "NUMPAD5"); + SCAN_CODE_MAP.put(98, "NUMPAD2"); + SCAN_CODE_MAP.put(99, "NUMPAD0"); + SCAN_CODE_MAP.put(100, "MULTIPLY"); + SCAN_CODE_MAP.put(101, "NUMPAD9"); + SCAN_CODE_MAP.put(102, "NUMPAD6"); + SCAN_CODE_MAP.put(103, "NUMPAD3"); + SCAN_CODE_MAP.put(104, "DECIMAL"); + SCAN_CODE_MAP.put(105, "SUBTRACT"); + SCAN_CODE_MAP.put(106, "ADD"); + SCAN_CODE_MAP.put(108, "ENTER"); + + // Function keys & Escape + SCAN_CODE_MAP.put(110, "ESCAPE"); + SCAN_CODE_MAP.put(112, "F1"); + SCAN_CODE_MAP.put(113, "F2"); + SCAN_CODE_MAP.put(114, "F3"); + SCAN_CODE_MAP.put(115, "F4"); + SCAN_CODE_MAP.put(116, "F5"); + SCAN_CODE_MAP.put(117, "F6"); + SCAN_CODE_MAP.put(118, "F7"); + SCAN_CODE_MAP.put(119, "F8"); + SCAN_CODE_MAP.put(120, "F9"); + SCAN_CODE_MAP.put(121, "F10"); + SCAN_CODE_MAP.put(122, "F11"); + SCAN_CODE_MAP.put(123, "F12"); + + // Mnemonics mapping + MNEMONIC_MAP.put("enter", "ENTER"); + MNEMONIC_MAP.put("enterreset", "ENTER"); + MNEMONIC_MAP.put("newline", "NEWLINE"); + MNEMONIC_MAP.put("tab", "TAB"); + MNEMONIC_MAP.put("backtab", "shift TAB"); + MNEMONIC_MAP.put("reset", "ESCAPE"); + MNEMONIC_MAP.put("clear", "CLEAR"); + MNEMONIC_MAP.put("eraseeof", "END"); + MNEMONIC_MAP.put("eof", "END"); + MNEMONIC_MAP.put("erasefld", "ERASE_INPUT"); + MNEMONIC_MAP.put("erinp", "ERASE_INPUT"); + MNEMONIC_MAP.put("eraseinput", "ERASE_INPUT"); + MNEMONIC_MAP.put("dup", "DUP"); + MNEMONIC_MAP.put("fieldmark", "FIELD_MARK"); + MNEMONIC_MAP.put("fldmark", "FIELD_MARK"); + MNEMONIC_MAP.put("fldext", "NEWLINE"); + MNEMONIC_MAP.put("field-exit", "NEWLINE"); + MNEMONIC_MAP.put("field+", "TAB"); + MNEMONIC_MAP.put("field-", "TAB"); + MNEMONIC_MAP.put("attn", "ATTN"); + MNEMONIC_MAP.put("sysreq", "SYSREQ"); + MNEMONIC_MAP.put("cursel", "CURSEL"); + MNEMONIC_MAP.put("copy", "COPY"); + MNEMONIC_MAP.put("paste", "PASTE"); + MNEMONIC_MAP.put("selectall", "SELECTALL"); + MNEMONIC_MAP.put("insert", "INSERT"); + MNEMONIC_MAP.put("delete", "DELETE"); + MNEMONIC_MAP.put("backspace", "BACK_SPACE"); + MNEMONIC_MAP.put("home", "HOME"); + MNEMONIC_MAP.put("end", "END"); + MNEMONIC_MAP.put("up", "UP"); + MNEMONIC_MAP.put("down", "DOWN"); + MNEMONIC_MAP.put("left", "LEFT"); + MNEMONIC_MAP.put("right", "RIGHT"); + MNEMONIC_MAP.put("pageup", "PAGE_UP"); + MNEMONIC_MAP.put("pagedn", "PAGE_DOWN"); + MNEMONIC_MAP.put("pagedown", "PAGE_DOWN"); + MNEMONIC_MAP.put("docmode", "DOCMODE"); + MNEMONIC_MAP.put("wordwrap", "WORDWRAP"); + MNEMONIC_MAP.put("apl", "APL"); + MNEMONIC_MAP.put("rule", "CROSSHAIR_RULER"); + MNEMONIC_MAP.put("statusbar", "STATUS_BAR"); + + for (int i = 1; i <= 24; i++) { + MNEMONIC_MAP.put("pf" + i, "PF" + i); + } + for (int i = 1; i <= 3; i++) { + MNEMONIC_MAP.put("pa" + i, "PA" + i); + } + // Shifted PF keys 1-12 = PF13-PF24 + for (int i = 1; i <= 12; i++) { + MNEMONIC_MAP.put("spf" + i, "PF" + (i + 12)); + } + } + + /** + * Parse a .kmp file from a File object. + * + * @param file the .kmp file to parse + * @return Map of j3270 action names to key stroke bindings + * @throws IOException on I/O error + */ + public static Map parseKmp(File file) throws IOException { + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + return parseKmp(reader); + } + } + + /** + * Parse a .kmp file from an InputStream. + * + * @param in the InputStream to parse + * @return Map of j3270 action names to key stroke bindings + * @throws IOException on I/O error + */ + public static Map parseKmp(InputStream in) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) { + return parseKmp(reader); + } + } + + /** + * Parse a .kmp file from a Reader. + * Supports both INI-style KEY=action formats and bind statements. + * + * @param reader the reader to parse from + * @return Map of j3270 action names to key stroke bindings + * @throws IOException on I/O error + */ + public static Map parseKmp(Reader reader) throws IOException { + BufferedReader br = (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader); + Map> actionToBindings = new LinkedHashMap<>(); + + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + + // Strip comments + int commentIdx = line.indexOf(';'); + if (commentIdx >= 0) { + line = line.substring(0, commentIdx).trim(); + } + commentIdx = line.indexOf('#'); + if (commentIdx >= 0) { + line = line.substring(0, commentIdx).trim(); + } + + if (line.isEmpty() || line.startsWith("[")) { + continue; // Skip section headers and blank lines + } + + // Syntax 1: bind [mnemonic] KeyCombination OR bind action KeyCombination + if (line.toLowerCase().startsWith("bind ")) { + parseBindStatement(line.substring(5).trim(), actionToBindings); + continue; + } + + // Syntax 2: KEY=action OR Modifier-KEY=action OR KeyStroke=[mnemonic] + int eq = line.indexOf('='); + if (eq > 0) { + String left = line.substring(0, eq).trim(); + String right = line.substring(eq + 1).trim(); + parseKeyAssignment(left, right, actionToBindings); + } + } + + // Format into combined comma-separated bindings map + Map result = new LinkedHashMap<>(); + for (Map.Entry> entry : actionToBindings.entrySet()) { + String action = entry.getKey(); + List bindings = entry.getValue(); + if (!bindings.isEmpty()) { + result.put(action, String.join(", ", bindings)); + } + } + + return result; + } + + private static void parseBindStatement(String stmt, Map> actionToBindings) { + String[] parts = stmt.split("\\s+", 2); + if (parts.length < 2) return; + + String target = parts[0].trim(); + String keyStrokeStr = parts[1].trim(); + + String action = resolveMnemonic(target); + if (action == null) { + // Check if parts[1] was the mnemonic instead (e.g. "bind KeyCombination [mnemonic]") + String altAction = resolveMnemonic(keyStrokeStr); + if (altAction != null) { + action = altAction; + keyStrokeStr = target; + } + } + + if (action != null) { + String normKeyStroke = normalizeKeyStroke(keyStrokeStr); + if (normKeyStroke != null && !normKeyStroke.isEmpty()) { + addBinding(actionToBindings, action, normKeyStroke); + } + } + } + + private static void parseKeyAssignment(String left, String right, Map> actionToBindings) { + // Case A: left is KEY or Modifier-KEY, right is mnemonic + String action = resolveMnemonic(right); + String keyStroke = null; + + if (action != null) { + keyStroke = parseScanCodeEntry(left); + } else { + // Case B: left is mnemonic, right is KeyStroke + action = resolveMnemonic(left); + if (action != null) { + keyStroke = normalizeKeyStroke(right); + } else { + // Case C: left is KeyStroke, right is mnemonic + action = resolveMnemonic(right); + if (action != null) { + keyStroke = normalizeKeyStroke(left); + } + } + } + + if (action != null && keyStroke != null && !keyStroke.isEmpty()) { + addBinding(actionToBindings, action, keyStroke); + } + } + + /** + * Parse scan code entry like "KEY43", "S-KEY112", "C-KEY43", "CS-KEY85", etc. + */ + public static String parseScanCodeEntry(String entry) { + if (entry == null || entry.isEmpty()) return null; + + String upper = entry.toUpperCase().trim(); + int keyPos = upper.indexOf("KEY"); + if (keyPos < 0) { + return normalizeKeyStroke(entry); + } + + String prefix = upper.substring(0, keyPos); + if (prefix.endsWith("-")) { + prefix = prefix.substring(0, prefix.length() - 1); + } + + String numPart = upper.substring(keyPos + 3); + int scanCode; + try { + scanCode = Integer.parseInt(numPart.trim()); + } catch (NumberFormatException e) { + return null; + } + + String baseKey = SCAN_CODE_MAP.get(scanCode); + if (baseKey == null) { + return null; + } + + StringBuilder sb = new StringBuilder(); + if (prefix.contains("C")) sb.append("ctrl "); + if (prefix.contains("A") || prefix.contains("2")) sb.append("alt "); + if (prefix.contains("S")) sb.append("shift "); + if (prefix.contains("M")) sb.append("meta "); + + sb.append(baseKey); + return sb.toString().trim(); + } + + /** + * Normalize key stroke expression (e.g. "Ctrl+Shift+C" -> "ctrl shift C"). + */ + public static String normalizeKeyStroke(String stroke) { + if (stroke == null || stroke.trim().isEmpty()) return null; + String s = stroke.trim(); + + // Check if it is an IBM scan code + if (s.toUpperCase().contains("KEY")) { + String fromScan = parseScanCodeEntry(s); + if (fromScan != null) return fromScan; + } + + // Replace '+' with space + s = s.replace("+", " "); + String[] parts = s.split("\\s+"); + + StringBuilder mods = new StringBuilder(); + String mainKey = null; + + for (String p : parts) { + String lp = p.toLowerCase(); + if ("ctrl".equals(lp) || "control".equals(lp)) { + mods.append("ctrl "); + } else if ("shift".equals(lp)) { + mods.append("shift "); + } else if ("alt".equals(lp)) { + mods.append("alt "); + } else if ("meta".equals(lp) || "cmd".equals(lp) || "command".equals(lp)) { + mods.append("meta "); + } else { + mainKey = p.toUpperCase(); + } + } + + if (mainKey == null) return null; + return (mods.toString() + mainKey).trim(); + } + + /** + * Resolve a mnemonic token (e.g. "[enter]", "enter", "[pf12]", "eraseeof") + * into a j3270 action name. + */ + public static String resolveMnemonic(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(); + + String direct = MNEMONIC_MAP.get(clean); + if (direct != null) { + return direct; + } + + // Case-insensitive match on existing action names + String upper = clean.toUpperCase(); + if (MNEMONIC_MAP.containsValue(upper)) { + return upper; + } + + return null; + } + + private static void addBinding(Map> map, String action, String keyStroke) { + List list = map.computeIfAbsent(action, k -> new ArrayList<>()); + if (!list.contains(keyStroke)) { + list.add(keyStroke); + } + } +} diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java index 0203eba..9bcfb40 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java @@ -7,6 +7,7 @@ import haus.nightmare.j3270.config.Settings; import java.awt.*; import java.awt.event.*; +import java.io.File; import java.util.HashMap; import java.util.Map; import javax.swing.table.DefaultTableModel; @@ -33,6 +34,8 @@ public class SettingsDialog extends JDialog { private JCheckBox blockSelectCheck; private JSpinner dynamicRowsSpinner; private JSpinner dynamicColsSpinner; + private JCheckBox enablePasteFromExcelCheck; + private JCheckBox pasteStopAtProtectedCheck; // Entry Assist & Modes tab private JCheckBox docModeCheck; @@ -291,10 +294,18 @@ public class SettingsDialog extends JDialog { gbc.gridx = 1; panel.add(dynDimPanel, gbc); - // Placeholder for potentially more behavior options below + // Clipboard & Tabular Paste options gbc.gridx = 0; gbc.gridy = 4; gbc.gridwidth = 2; + enablePasteFromExcelCheck = new JCheckBox("Enable Excel / Tabular Paste (advance with tabs & newlines)", Settings.getEnablePasteFromExcel()); + panel.add(enablePasteFromExcelCheck, gbc); + + gbc.gridy = 5; + pasteStopAtProtectedCheck = new JCheckBox("Stop Paste at Protected Boundary", Settings.getPasteStopAtProtectedLine()); + panel.add(pasteStopAtProtectedCheck, gbc); + + gbc.gridy = 6; gbc.weighty = 1.0; panel.add(Box.createGlue(), gbc); @@ -653,6 +664,45 @@ public class SettingsDialog extends JDialog { }); btnPanel.add(btnReset); + // Import Keymap — import from .kmp file + JButton btnImportKmp = new JButton("Import Keymap..."); + ThemeManager.styleButton(btnImportKmp, ThemeManager.ButtonVariant.DEFAULT); + btnImportKmp.addActionListener(e -> { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Import Keymap Profile"); + chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("Keyboard Map Profile (*.kmp)", "kmp")); + int ret = chooser.showOpenDialog(this); + if (ret == JFileChooser.APPROVE_OPTION) { + File f = chooser.getSelectedFile(); + try { + Map imported = KeyBindings.parseKmp(f); + int updatedCount = 0; + for (Map.Entry entry : imported.entrySet()) { + String action = entry.getKey(); + String binding = entry.getValue(); + tempKeyBindings.put(action, binding); + for (int row = 0; row < keymapModel.getRowCount(); row++) { + if (action.equalsIgnoreCase((String) keymapModel.getValueAt(row, 0))) { + keymapModel.setValueAt(binding, row, 1); + updatedCount++; + break; + } + } + } + JOptionPane.showMessageDialog(this, + "Successfully imported " + updatedCount + " key bindings from " + f.getName(), + "Keymap Imported", + JOptionPane.INFORMATION_MESSAGE); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, + "Failed to import keymap: " + ex.getMessage(), + "Import Error", + JOptionPane.ERROR_MESSAGE); + } + } + }); + btnPanel.add(btnImportKmp); + main.add(new JScrollPane(table), BorderLayout.CENTER); main.add(btnPanel, BorderLayout.SOUTH); @@ -705,6 +755,14 @@ public class SettingsDialog extends JDialog { // Block select mode Settings.setBlockSelectMode(blockSelectCheck.isSelected()); + // Clipboard & Tabular Paste options + if (enablePasteFromExcelCheck != null) { + Settings.setEnablePasteFromExcel(enablePasteFromExcelCheck.isSelected()); + } + if (pasteStopAtProtectedCheck != null) { + Settings.setPasteStopAtProtectedLine(pasteStopAtProtectedCheck.isSelected()); + } + // Default Dynamic screen dimensions if (dynamicRowsSpinner != null && dynamicColsSpinner != null) { Settings.setDynamicRows((Integer) dynamicRowsSpinner.getValue()); diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java b/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java index 8bc4bff..f9c20d6 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/StatusBar.java @@ -137,12 +137,20 @@ public class StatusBar extends JPanel { } public void applyTheme(UITheme theme) { + if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(() -> applyTheme(theme)); + return; + } setBackground(ThemeManager.getStatusBarBg(theme)); setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, ThemeManager.getStatusBarBorder(theme))); updateStatus(); } public void updateStatus() { + if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(this::updateStatus); + return; + } UITheme theme = ThemeManager.getTheme(); if (client == null) { connectionStatus.setText("Not Connected"); @@ -170,6 +178,10 @@ public class StatusBar extends JPanel { connectionStatus.setText("Not Connected"); connectionStatus.setForeground(ThemeManager.getOiaFgDim(theme)); break; + case RECONNECTING: + connectionStatus.setText("Reconnecting..."); + connectionStatus.setForeground(ThemeManager.getOiaFgAlert(theme)); + break; case TCP_PENDING: case TELNET_PENDING: connectionStatus.setText("Connecting..."); diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java index e3e5b6d..15663f9 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java @@ -494,8 +494,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { int cols = sb != null ? sb.getDisplayCols() : 80; int curRow = cols > 0 ? curPos / cols : 0; int curCol = cols > 0 ? curPos % cols : 0; + boolean excelPaste = haus.nightmare.j3270.config.Settings.getEnablePasteFromExcel(); + boolean stopAtProtected = haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine(); - if (client.getPS() != null && (text.contains("\n") || text.contains("\r"))) { + if (client.getInputProcessor() != null && (excelPaste || stopAtProtected || text.contains("\t") || text.contains("\n") || text.contains("\r"))) { + client.getInputProcessor().pasteText(text, excelPaste, stopAtProtected); + } else if (client.getPS() != null) { client.getPS().pasteString(text, curRow, curCol); } else { for (char ch : text.toCharArray()) { @@ -603,6 +607,10 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { public void guardedPack() { + if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(this::guardedPack); + return; + } resizeGuard = true; revalidate(); Container top = getTopLevelAncestor(); @@ -1163,10 +1171,19 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { if (ip != null) { ip.setBellEnabled(haus.nightmare.j3270.config.Settings.getEntryAssistBell()); ip.setBellColumn(Math.max(0, haus.nightmare.j3270.config.Settings.getEntryAssistBellCol() - 1)); + if (ip.getBellListener() == null) { + ip.setBellListener(() -> { + SwingUtilities.invokeLater(() -> Toolkit.getDefaultToolkit().beep()); + }); + } ip.setInsertOffOnAid(haus.nightmare.j3270.config.Settings.getInsertOffOnAid()); ip.setNumericFieldLock(haus.nightmare.j3270.config.Settings.getNumericFieldLock()); ip.setAutoSkipEnabled(haus.nightmare.j3270.config.Settings.getAutoSkipEnabled()); } + if (client.getPS() != null) { + client.getPS().setEnablePasteFromExcel(haus.nightmare.j3270.config.Settings.getEnablePasteFromExcel()); + client.getPS().setPasteStopAtProtectedLine(haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine()); + } } if (statusBar != null) { statusBar.updateStatus(); @@ -1432,8 +1449,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { if (rgb != null && gWidth > 0 && gHeight > 0) { long currentUpdateCount = client.getGraphicsPlane().getUpdateCount(); if (cachedGraphicsImage == null || currentUpdateCount != lastGraphicsUpdateCount || cachedGraphicsImage.getWidth() != gWidth || cachedGraphicsImage.getHeight() != gHeight) { - cachedGraphicsImage = new java.awt.image.BufferedImage(gWidth, gHeight, java.awt.image.BufferedImage.TYPE_INT_ARGB); - cachedGraphicsImage.setRGB(0, 0, gWidth, gHeight, rgb, 0, gWidth); + cachedGraphicsImage = AwtPixelBufferBridge.toBufferedImage(client.getGraphicsPlane()); lastGraphicsUpdateCount = currentUpdateCount; } if (gWidth == gridW && gHeight == gridH) { @@ -1548,7 +1564,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { haus.nightmare.lib3270j.graphics.ProgramSymbolSet.SymbolSlot slot = client.getProgramSymbolManager().getSymbol(cs, ea.ec & 0xFF); if (slot != null) { int symBg = (bgIsExplicit || reverse) ? bgColor.getRGB() : 0; - java.awt.image.BufferedImage img = slot.getScaledImage(cellWidth, cellHeight, fgColor.getRGB(), symBg); + haus.nightmare.lib3270j.graphics.PixelBuffer symPb = slot.getScaledPixelBuffer(cellWidth, cellHeight, fgColor.getRGB(), symBg); + java.awt.image.BufferedImage img = AwtPixelBufferBridge.toBufferedImage(symPb); if (img != null) { g2.drawImage(img, x, y, null); drawnAsPs = true; @@ -1691,7 +1708,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { public void setHodWallpaper(haus.nightmare.lib3270j.graphics.HODWallpaper wallpaper) { this.hodWallpaper = wallpaper; if (wallpaper != null) { - this.wallpaperImage = wallpaper.getHODImage(); + Object hodImg = wallpaper.getHODImage(); + if (hodImg instanceof Image) { + this.wallpaperImage = (Image) hodImg; + } else if (hodImg instanceof haus.nightmare.lib3270j.graphics.PixelBuffer) { + this.wallpaperImage = AwtPixelBufferBridge.toBufferedImage((haus.nightmare.lib3270j.graphics.PixelBuffer) hodImg); + } } repaint(); } diff --git a/j3270/src/test/java/haus/nightmare/j3270/ui/KmpKeymapImportTest.java b/j3270/src/test/java/haus/nightmare/j3270/ui/KmpKeymapImportTest.java new file mode 100644 index 0000000..b755905 --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/ui/KmpKeymapImportTest.java @@ -0,0 +1,105 @@ +package haus.nightmare.j3270.ui; + +import org.junit.jupiter.api.Test; + +import java.io.StringReader; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for Phase 13.3: Keyboard Remap (.kmp) File Importer. + * Tests parsing of scan codes, modifier combinations, bind statements, + * mnemonic actions, and comments. + */ +public class KmpKeymapImportTest { + + @Test + public void testParseScanCodeAssignments() throws Exception { + String kmpContent = + "# Sample keyboard profile mapping\n" + + "; IBM scan code mappings\n" + + "KEY43=[enter]\n" + + "S-KEY112=[pf13]\n" + + "C-KEY43=[newline]\n" + + "A-KEY110=[reset]\n" + + "KEY15=[tab]\n" + + "S-KEY15=[backtab]\n"; + + Map imported = KeyBindings.parseKmp(new StringReader(kmpContent)); + assertNotNull(imported); + + // KEY43 is ENTER -> ENTER + assertEquals("ENTER", imported.get("ENTER")); + // S-KEY112 is Shift+F1 -> PF13 + assertEquals("shift F1", imported.get("PF13")); + // C-KEY43 is Ctrl+ENTER -> NEWLINE + assertEquals("ctrl ENTER", imported.get("NEWLINE")); + // A-KEY110 is Alt+ESCAPE -> ESCAPE + assertEquals("alt ESCAPE", imported.get("ESCAPE")); + // KEY15 is TAB -> TAB + assertEquals("TAB", imported.get("TAB")); + // S-KEY15 is Shift+TAB -> shift TAB + assertEquals("shift TAB", imported.get("shift TAB")); + } + + @Test + public void testParseBindStatements() throws Exception { + String kmpContent = + "bind [enter] Ctrl+Enter\n" + + "bind [pf1] F1\n" + + "bind [pf24] Shift+F12\n" + + "bind [eraseeof] End\n" + + "bind [clear] Pause\n"; + + Map imported = KeyBindings.parseKmp(new StringReader(kmpContent)); + assertNotNull(imported); + + assertEquals("ctrl ENTER", imported.get("ENTER")); + assertEquals("F1", imported.get("PF1")); + assertEquals("shift F12", imported.get("PF24")); + assertEquals("END", imported.get("END")); + assertEquals("PAUSE", imported.get("CLEAR")); + } + + @Test + public void testScanCodeWithMultipleModifiers() { + // C-S-KEY85 -> Ctrl+Shift+PAGE_UP + String res = KeyBindings.parseScanCodeEntry("CS-KEY85"); + assertEquals("ctrl shift PAGE_UP", res); + + // 2-KEY80 -> Alt+HOME (2- prefix is Alt in scan code syntax) + String altRes = KeyBindings.parseScanCodeEntry("2-KEY80"); + assertEquals("alt HOME", altRes); + } + + @Test + public void testMnemonicResolution() { + assertEquals("ENTER", KeyBindings.resolveMnemonic("[enter]")); + assertEquals("ENTER", KeyBindings.resolveMnemonic("enterreset")); + assertEquals("NEWLINE", KeyBindings.resolveMnemonic("[newline]")); + assertEquals("NEWLINE", KeyBindings.resolveMnemonic("[field-exit]")); + assertEquals("PF1", KeyBindings.resolveMnemonic("[pf1]")); + assertEquals("PF12", KeyBindings.resolveMnemonic("[pf12]")); + assertEquals("PF13", KeyBindings.resolveMnemonic("[spf1]")); + assertEquals("ESCAPE", KeyBindings.resolveMnemonic("[reset]")); + assertEquals("ERASE_INPUT", KeyBindings.resolveMnemonic("[erasefld]")); + assertEquals("DOCMODE", KeyBindings.resolveMnemonic("[docmode]")); + assertEquals("WORDWRAP", KeyBindings.resolveMnemonic("[wordwrap]")); + } + + @Test + public void testCommentsAndBlankLinesIgnored() throws Exception { + String kmpContent = + "[ProfileHeader]\n" + + " \n" + + "# Full line comment\n" + + "; Another comment\n" + + "KEY42=[enter] ; inline comment\n" + + " \n"; + + Map imported = KeyBindings.parseKmp(new StringReader(kmpContent)); + assertEquals(1, imported.size()); + assertEquals("ENTER", imported.get("ENTER")); + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java index 270e490..b41ac44 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java @@ -58,6 +58,17 @@ public class ConnectionConfig { private java.util.Map environmentVariables = new java.util.LinkedHashMap<>(); private java.util.Map userVariables = new java.util.LinkedHashMap<>(); + // IBM HoD autoSysUnlock parity + private boolean autoSysUnlock = true; + + // Phase 11: Enterprise Connection Resilience & Heartbeat + private boolean keepAliveEnabled = true; + private int keepAliveIntervalSeconds = 120; + private String keepAliveType = "NOP"; + private boolean autoReconnect = false; + private int reconnectMaxRetries = 5; + private int tcpUserTimeoutMs = 0; + public ConnectionConfig() {} public ConnectionConfig(String host, int port) { @@ -280,6 +291,27 @@ public class ConnectionConfig { } } + public boolean isAutoSysUnlock() { return autoSysUnlock; } + public void setAutoSysUnlock(boolean autoSysUnlock) { this.autoSysUnlock = autoSysUnlock; } + + public boolean isKeepAliveEnabled() { return keepAliveEnabled; } + public void setKeepAliveEnabled(boolean enabled) { this.keepAliveEnabled = enabled; } + + public int getKeepAliveIntervalSeconds() { return keepAliveIntervalSeconds; } + public void setKeepAliveIntervalSeconds(int seconds) { this.keepAliveIntervalSeconds = seconds; } + + public String getKeepAliveType() { return keepAliveType; } + public void setKeepAliveType(String type) { this.keepAliveType = (type != null) ? type.trim().toUpperCase() : "NOP"; } + + public boolean isAutoReconnect() { return autoReconnect; } + public void setAutoReconnect(boolean autoReconnect) { this.autoReconnect = autoReconnect; } + + public int getReconnectMaxRetries() { return reconnectMaxRetries; } + public void setReconnectMaxRetries(int retries) { this.reconnectMaxRetries = Math.max(0, retries); } + + public int getTcpUserTimeoutMs() { return tcpUserTimeoutMs; } + public void setTcpUserTimeoutMs(int timeoutMs) { this.tcpUserTimeoutMs = Math.max(0, timeoutMs); } + /** * Parse a host connection string which may include prefixes for TLS (e.g. "L:host:port", "ssl:host:port", "y:host:port"), * plain TN3270 (e.g. "P:host:port", "plain:host:port", "non-e:host:port"), proxy flags (e.g. "--proxy=http://proxy:8080 host:23"), @@ -303,10 +335,21 @@ public class ConnectionConfig { String pUser = null; String pPass = null; + boolean keepAlive = true; + boolean autoReconnect = false; + String[] tokens = s.split("\\s+"); StringBuilder remaining = new StringBuilder(); for (String tok : tokens) { - if (tok.startsWith("--proxy=") || tok.startsWith("-proxy=")) { + if (tok.equalsIgnoreCase("--keepalive") || tok.equalsIgnoreCase("-keepalive")) { + keepAlive = true; + } else if (tok.equalsIgnoreCase("--no-keepalive") || tok.equalsIgnoreCase("-no-keepalive")) { + keepAlive = false; + } else if (tok.equalsIgnoreCase("--autoreconnect") || tok.equalsIgnoreCase("-autoreconnect")) { + autoReconnect = true; + } else if (tok.equalsIgnoreCase("--no-autoreconnect") || tok.equalsIgnoreCase("-no-autoreconnect")) { + autoReconnect = false; + } else if (tok.startsWith("--proxy=") || tok.startsWith("-proxy=")) { String proxyUrl = tok.substring(tok.indexOf('=') + 1).trim(); try { java.net.URI uri = new java.net.URI(proxyUrl); @@ -417,6 +460,8 @@ public class ConnectionConfig { ConnectionConfig config = new ConnectionConfig(host, port, defaultModel != null ? defaultModel : TerminalModel.IBM_3279_4); config.setUseTls(tls); config.setTn3270eEnabled(tn3270e); + config.setKeepAliveEnabled(keepAlive); + config.setAutoReconnect(autoReconnect); if (dynamic) { config.setDynamicDimensions(dynRows, dynCols); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java index 20b5e6d..664e565 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java @@ -44,6 +44,9 @@ public class Telnet3270Client { private final haus.nightmare.lib3270j.ecl.ECLXfer xfer; private TelnetConnection connection; + private final java.util.concurrent.atomic.AtomicBoolean reconnecting = new java.util.concurrent.atomic.AtomicBoolean(false); + private volatile Thread reconnectThread; + public Telnet3270Client(ConnectionConfig config) { this.config = config; this.translator = new EbcdicTranslator(config.getCodePage()); @@ -58,6 +61,7 @@ public class Telnet3270Client { this.screenBuffer = new ScreenBuffer(config.getModel(), translator); } this.dsProcessor = new DataStreamProcessor(screenBuffer, translator); + this.dsProcessor.setAutoSysUnlock(config.isAutoSysUnlock()); this.dsProcessor.getQueryReplyBuilder().setGraphicsMode(config.getGraphicsMode()); this.fsm = new TelnetFSM(config, screenBuffer, dsProcessor); this.inputProcessor = new InputProcessor(screenBuffer, translator, fsm); @@ -87,6 +91,33 @@ public class Telnet3270Client { @Override public void onSoundAlarm() { ps.notifyAlarm(); } + @Override public void onKeyboardUnlocked() { + ps.notifyKeyUnlocked(); + } + }); + + // Phase 11: Auto-reconnection and OIA status coordinator + fsm.addConnectionListener(new ConnectionListener() { + @Override + public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) { + if (newState == ConnectionState.RECONNECTING) { + if (oia != null) { + oia.writeToOIA("X RECONNECT"); + } + initiateAutoReconnect(); + } else if (newState == ConnectionState.NOT_CONNECTED) { + if (oia != null) { + oia.setInputInhibited(haus.nightmare.lib3270j.ecl.ECLOIA.INHIBIT_COMMCHECK); + } + } else if (newState.isFullSession()) { + if (oia != null) { + oia.setInputInhibited(-1); + } + } + } + + @Override + public void onConnectionError(String message) {} }); } @@ -159,11 +190,146 @@ public class Telnet3270Client { * Disconnect from the host. */ public void disconnect() { + cancelAutoReconnect(); if (connection != null) { connection.disconnect(); connection = null; } - fsm.onDisconnect(); + fsm.onDisconnect(false); + } + + public void cancelAutoReconnect() { + reconnecting.set(false); + if (reconnectThread != null) { + reconnectThread.interrupt(); + reconnectThread = null; + } + } + + public void initiateAutoReconnect() { + if (config == null || !config.isAutoReconnect()) { + return; + } + if (!reconnecting.compareAndSet(false, true)) { + return; + } + reconnectThread = new Thread(this::runAutoReconnect, "TN3270-AutoReconnect"); + reconnectThread.setDaemon(true); + reconnectThread.start(); + } + + private void runAutoReconnect() { + int maxRetries = (config != null) ? config.getReconnectMaxRetries() : 5; + log.info("Starting automatic reconnection loop (maxRetries=" + maxRetries + ")"); + try { + for (int attempt = 1; attempt <= maxRetries && reconnecting.get(); attempt++) { + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s + long delaySeconds = Math.min(30, (long) Math.pow(2, attempt - 1)); + log.info("Auto-reconnect attempt " + attempt + "/" + maxRetries + " scheduled in " + delaySeconds + "s"); + + for (int s = 0; s < delaySeconds * 10; s++) { + if (!reconnecting.get() || Thread.currentThread().isInterrupted()) { + log.info("Auto-reconnect cancelled during backoff delay"); + return; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + log.info("Auto-reconnect thread interrupted"); + return; + } + } + + if (!reconnecting.get()) { + return; + } + + try { + log.info("Executing auto-reconnect attempt " + attempt + "/" + maxRetries + " to " + config.getHost() + ":" + config.getPort()); + if (connection != null) { + connection.disconnect(); + connection = null; + } + fsm.resetSessionState(); + screenBuffer.erase(false); + + connection = new TelnetConnection(config, fsm); + fsm.setConnection(connection); + connection.connect(); + fsm.onConnected(); + + log.info("Auto-reconnect successful on attempt " + attempt); + reconnecting.set(false); + fsm.notifyScreenUpdate(); + return; + } catch (IOException e) { + log.warning("Auto-reconnect attempt " + attempt + " failed: " + e.getMessage()); + if (attempt < maxRetries && reconnecting.get()) { + fsm.setConnectionState(ConnectionState.RECONNECTING); + } + } + } + + // All retries failed + log.warning("All " + maxRetries + " automatic reconnection attempts failed"); + reconnecting.set(false); + fsm.onDisconnect(false); + fsm.onError("Automatic reconnection failed after " + maxRetries + " attempts"); + } finally { + reconnecting.set(false); + reconnectThread = null; + } + } + + public boolean isAutoReconnect() { + return (config != null) && config.isAutoReconnect(); + } + + public void setAutoReconnect(boolean autoReconnect) { + if (config != null) { + config.setAutoReconnect(autoReconnect); + } + } + + public int getReconnectMaxRetries() { + return (config != null) ? config.getReconnectMaxRetries() : 5; + } + + public void setReconnectMaxRetries(int retries) { + if (config != null) { + config.setReconnectMaxRetries(retries); + } + } + + public boolean isReconnecting() { + return reconnecting.get() || fsm.getConnectionState() == ConnectionState.RECONNECTING; + } + + public boolean isKeepAliveEnabled() { + return (config != null) && config.isKeepAliveEnabled(); + } + + public void setKeepAliveEnabled(boolean enabled) { + if (config != null) { + config.setKeepAliveEnabled(enabled); + } + if (connection != null) { + if (enabled) connection.startKeepAlive(); + else connection.stopKeepAlive(); + } + } + + public int getKeepAliveIntervalSeconds() { + return (config != null) ? config.getKeepAliveIntervalSeconds() : 120; + } + + public void setKeepAliveIntervalSeconds(int seconds) { + if (config != null) { + config.setKeepAliveIntervalSeconds(seconds); + } + if (connection != null && config != null && config.isKeepAliveEnabled()) { + connection.startKeepAlive(); + } } /** @@ -294,6 +460,23 @@ public class Telnet3270Client { return fsm; } + public boolean isAutoSysUnlock() { + return (config != null) ? config.isAutoSysUnlock() : true; + } + + public void setAutoSysUnlock(boolean autoSysUnlock) { + if (config != null) { + config.setAutoSysUnlock(autoSysUnlock); + } + if (dsProcessor != null) { + dsProcessor.setAutoSysUnlock(autoSysUnlock); + } + } + + public boolean isContentionResolution() { + return fsm != null && fsm.isContentionResolutionNegotiated(); + } + /** Send an NVT ASCII character in NVT mode. */ public void sendNVTChar(char c) throws IOException { fsm.sendNVTChar(c); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java index 514eaee..446229d 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java @@ -47,6 +47,13 @@ public class DataStreamProcessor { private final java.io.ByteArrayOutputStream gocaAccumulator = new java.io.ByteArrayOutputStream(); private int currentGocaSubtype = 0; + // Phase 10: Auto-Unlock & Contention Resolution State + private boolean autoSysUnlock = true; + private boolean contentionResolution = false; + private boolean unlockPending = false; + private boolean unlockSysPending = false; + private boolean rcvdRead = false; + /** Functional interface for sending output back through the telnet stack. */ @FunctionalInterface public interface OutputSender { @@ -131,6 +138,21 @@ public class DataStreamProcessor { screenListeners.remove(l); } + public boolean isAutoSysUnlock() { return autoSysUnlock; } + public void setAutoSysUnlock(boolean autoSysUnlock) { this.autoSysUnlock = autoSysUnlock; } + + public boolean isContentionResolution() { return contentionResolution; } + public void setContentionResolution(boolean cr) { this.contentionResolution = cr; } + + public boolean isUnlockPending() { return unlockPending; } + public void setUnlockPending(boolean pending) { this.unlockPending = pending; } + + public boolean isUnlockSysPending() { return unlockSysPending; } + public void setUnlockSysPending(boolean pending) { this.unlockSysPending = pending; } + + public boolean isRcvdRead() { return rcvdRead; } + public void setRcvdRead(boolean rcvdRead) { this.rcvdRead = rcvdRead; } + /** * Process a 3270 data stream record. * @@ -197,16 +219,19 @@ public class DataStreamProcessor { break; case CMD_RB: case SNA_CMD_RB: + rcvdRead = true; programSymbolManager.commitStagedSymbols(); processReadBuffer(); break; case CMD_RM: case SNA_CMD_RM: + rcvdRead = true; programSymbolManager.commitStagedSymbols(); processReadModified(false); break; case CMD_RMA: case SNA_CMD_RMA: + rcvdRead = true; programSymbolManager.commitStagedSymbols(); processReadModified(true); break; @@ -235,7 +260,10 @@ public class DataStreamProcessor { screen.updateDisplaySnapshot(); } - if (keyboardRestore && inputProcessor != null) { + boolean isWriteCmd = (cmd == CMD_W || cmd == SNA_CMD_W || + cmd == CMD_EW || cmd == SNA_CMD_EW || + cmd == CMD_EWA || cmd == SNA_CMD_EWA); + if (!isWriteCmd && keyboardRestore && inputProcessor != null && !contentionResolution) { inputProcessor.setKeyboardLocked(false); } @@ -333,10 +361,13 @@ public class DataStreamProcessor { log.fine("WCC: " + String.format("0x%02x", wcc) + " reset=" + wccReset(wcc) + " alarm=" + alarm + " kbdRestore=" + kbdRestore + " resetMdt=" + resetMdt); - if (kbdRestore || inputProcessor != null) { - if (inputProcessor != null) { - inputProcessor.setKeyboardLocked(false); - } + if (kbdRestore) { + unlockPending = true; + unlockSysPending = true; + } + + if (!contentionResolution && kbdRestore && inputProcessor != null) { + inputProcessor.setKeyboardLocked(false); } if (resetMdt) { @@ -1101,6 +1132,9 @@ public class DataStreamProcessor { } private void notifyScreenUpdated() { + if (screen != null) { + screen.notifyScreenUpdate(); + } for (ScreenUpdateListener l : screenListeners) { l.onScreenUpdated(); } @@ -1549,7 +1583,11 @@ public class DataStreamProcessor { log.fine("processWCC: " + String.format("0x%02x", wcc) + " reset=" + wccReset(wcc) + " alarm=" + alarm + " kbdRestore=" + kbdRestore + " resetMdt=" + resetMdt); - if (kbdRestore && inputProcessor != null) { + if (kbdRestore) { + unlockPending = true; + unlockSysPending = true; + } + if (!contentionResolution && kbdRestore && inputProcessor != null) { inputProcessor.setKeyboardLocked(false); } if (resetMdt) { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/event/ECLPSGraphicsEvent.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/event/ECLPSGraphicsEvent.java index 6026ae7..a658621 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/event/ECLPSGraphicsEvent.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/event/ECLPSGraphicsEvent.java @@ -1,7 +1,6 @@ package haus.nightmare.lib3270j.eNetwork.ECL.event; -import java.awt.Image; -import java.awt.Rectangle; +import haus.nightmare.lib3270j.graphics.Rectangle; /** * Drop-in IBM Host On-Demand compatible facade for ECLPSGraphicsEvent. @@ -12,11 +11,11 @@ public class ECLPSGraphicsEvent extends haus.nightmare.lib3270j.ecl.ECLPSGraphic super(source, id); } - public ECLPSGraphicsEvent(haus.nightmare.lib3270j.ecl.ECLPS source, int id, Image image) { + public ECLPSGraphicsEvent(haus.nightmare.lib3270j.ecl.ECLPS source, int id, Object image) { super(source, id, image); } - public ECLPSGraphicsEvent(haus.nightmare.lib3270j.ecl.ECLPS source, int id, Image image, Rectangle rectangle) { + public ECLPSGraphicsEvent(haus.nightmare.lib3270j.ecl.ECLPS source, int id, Object image, Rectangle rectangle) { super(source, id, image, rectangle); } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/FillArea.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/FillArea.java index 102f00f..ca11a54 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/FillArea.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/FillArea.java @@ -1,6 +1,6 @@ package haus.nightmare.lib3270j.eNetwork.ECL.hostgraphics; -import java.awt.Color; +import haus.nightmare.lib3270j.graphics.Color; /** * Drop-in IBM Host On-Demand compatible facade for FillArea. diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODBitImage.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODBitImage.java index d120a5c..7bc6855 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODBitImage.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODBitImage.java @@ -1,12 +1,10 @@ package haus.nightmare.lib3270j.eNetwork.ECL.hostgraphics; -import java.awt.Component; - /** * Drop-in IBM Host On-Demand compatible facade for HODBitImage. */ public class HODBitImage extends haus.nightmare.lib3270j.graphics.HODBitImage { - public HODBitImage(Component comp, int width, int height, byte[] data, int baseColor, int depth, boolean useGraphicColors) { + public HODBitImage(Object comp, int width, int height, byte[] data, int baseColor, int depth, boolean useGraphicColors) { super(comp, width, height, data, baseColor, depth, useGraphicColors); } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODPart.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODPart.java index aa2f9eb..0a51fb2 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODPart.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODPart.java @@ -1,8 +1,7 @@ package haus.nightmare.lib3270j.eNetwork.ECL.hostgraphics; -import java.awt.Component; -import java.awt.Dimension; -import java.awt.Rectangle; +import haus.nightmare.lib3270j.graphics.Dimension; +import haus.nightmare.lib3270j.graphics.Rectangle; /** * Drop-in IBM Host On-Demand compatible facade for HODPart. @@ -12,15 +11,15 @@ public class HODPart extends haus.nightmare.lib3270j.graphics.HODPart { super(); } - public HODPart(Component component) { + public HODPart(Object component) { super(component); } - public HODPart(Component component, Dimension dimension) { + public HODPart(Object component, Dimension dimension) { super(component, dimension); } - public HODPart(Component component, Rectangle rectangle) { + public HODPart(Object component, Rectangle rectangle) { super(component, rectangle); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODWallpaper.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODWallpaper.java index 1c0f107..409d818 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODWallpaper.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/eNetwork/ECL/hostgraphics/HODWallpaper.java @@ -1,7 +1,5 @@ package haus.nightmare.lib3270j.eNetwork.ECL.hostgraphics; -import java.awt.Image; - /** * Drop-in IBM Host On-Demand compatible facade for HODWallpaper. */ @@ -14,7 +12,7 @@ public class HODWallpaper extends haus.nightmare.lib3270j.graphics.HODWallpaper super(displayMode); } - public HODWallpaper(Image image, int displayMode) { + public HODWallpaper(Object image, int displayMode) { super(image, displayMode); } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/DefaultPSGraphicsServices.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/DefaultPSGraphicsServices.java index d001ab1..359febc 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/DefaultPSGraphicsServices.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/DefaultPSGraphicsServices.java @@ -1,17 +1,17 @@ package haus.nightmare.lib3270j.ecl; -import java.awt.Color; -import java.awt.Component; +import haus.nightmare.lib3270j.graphics.Color; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * Standard implementation of ECLPSGraphicsServices conforming to IBM Host On-Demand ECL. + * Completely decoupled from java.awt. */ public class DefaultPSGraphicsServices implements ECLPSGraphicsServices { private final ECLPS ps; - private Component visualComponent; + private Object visualComponent; private Color[] colors; private final List listeners = new CopyOnWriteArrayList<>(); @@ -20,11 +20,11 @@ public class DefaultPSGraphicsServices implements ECLPSGraphicsServices { } @Override - public void setVisualComponent(Component comp) { + public void setVisualComponent(Object comp) { this.visualComponent = comp; } - public Component getVisualComponent() { + public Object getVisualComponent() { return visualComponent; } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLConnection.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLConnection.java index ff99799..3e2f3ce 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLConnection.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLConnection.java @@ -27,6 +27,12 @@ public class ECLConnection { private String luName; private String workstationId = ""; private boolean ssl = false; + private boolean autoSysUnlock = true; + private boolean keepAlive = true; + private int keepAliveTimeout = 120; + private String keepAliveType = "NOP"; + private boolean autoReconnect = false; + private int maxRetry = 5; private boolean contentionResolution = false; private boolean luluSession = false; private boolean isNegCR = false; @@ -71,12 +77,49 @@ public class ECLConnection { if (props != null) { this.properties.putAll(props); convertData(this.properties); + String asu = this.properties.getProperty(ECLSession.SESSION_AUTO_SYS_UNLOCK); + if (asu != null) { + this.autoSysUnlock = "true".equalsIgnoreCase(asu) || "1".equals(asu); + } + String ka = this.properties.getProperty(ECLSession.SESSION_KEEPALIVE); + if (ka != null) { + this.keepAlive = "true".equalsIgnoreCase(ka) || "1".equals(ka); + } + String kat = this.properties.getProperty(ECLSession.KEY_KEEPALIVE_TIMEOUT); + if (kat != null) { + try { this.keepAliveTimeout = Integer.parseInt(kat.trim()); } catch (NumberFormatException ignored) {} + } + String katyp = this.properties.getProperty(ECLSession.KEY_KEEPALIVE_TYPE); + if (katyp != null) { + this.keepAliveType = katyp; + } + String ar = this.properties.getProperty(ECLSession.SESSION_AUTORECONNECT); + if (ar != null) { + this.autoReconnect = "true".equalsIgnoreCase(ar) || "1".equals(ar); + } + String mr = this.properties.getProperty(ECLSession.SESSION_RECONNECT_RETRIES); + if (mr != null) { + try { this.maxRetry = Integer.parseInt(mr.trim()); } catch (NumberFormatException ignored) {} + } } } public ECLConnection(ECLSession session, Telnet3270Client client) { this.session = session; this.client = client; + if (client != null && client.getConfig() != null) { + this.autoSysUnlock = client.getConfig().isAutoSysUnlock(); + this.keepAlive = client.getConfig().isKeepAliveEnabled(); + this.keepAliveTimeout = client.getConfig().getKeepAliveIntervalSeconds(); + this.keepAliveType = client.getConfig().getKeepAliveType(); + this.autoReconnect = client.getConfig().isAutoReconnect(); + this.maxRetry = client.getConfig().getReconnectMaxRetries(); + } else if (session != null) { + this.autoSysUnlock = session.isAutoSysUnlock(); + this.keepAlive = session.isKeepAlive(); + this.keepAliveTimeout = session.getKeepAliveTimeout(); + this.autoReconnect = session.isAutoReconnect(); + } if (client != null) { client.addConnectionListener(new ConnectionListener() { @@ -114,6 +157,13 @@ public class ECLConnection { state, state, "TN3270E Negotiated", deviceType, deviceName); notifyCommEvent(event); } + + @Override + public void onTN3270EFunctionsNegotiated(boolean[] functions) { + if (functions != null && functions.length > haus.nightmare.lib3270j.protocol.TN3270EConstants.FUNC_CONTENTION_RESOLUTION) { + setContentionResolution(functions[haus.nightmare.lib3270j.protocol.TN3270EConstants.FUNC_CONTENTION_RESOLUTION]); + } + } }); } } @@ -398,17 +448,138 @@ public class ECLConnection { } public void setSSL(boolean ssl) { SetSSL(ssl); } - public void setContentionResolution(boolean bl) { this.contentionResolution = bl; } + public void setContentionResolution(boolean bl) { + this.contentionResolution = bl; + if (client != null && client.getTelnetFSM() != null) { + client.getTelnetFSM().setContentionResolutionNegotiated(bl); + } + } public void SetContentionResolution(boolean bl) { setContentionResolution(bl); } - public boolean getContentionResolution() { return contentionResolution; } - public boolean isContentionResolution() { return contentionResolution; } + public boolean getContentionResolution() { + if (client != null && client.getTelnetFSM() != null && client.getTelnetFSM().isTn3270eNegotiated()) { + return client.getTelnetFSM().isContentionResolutionNegotiated(); + } + return contentionResolution; + } + public boolean isContentionResolution() { return getContentionResolution(); } + public boolean GetContentionResolution() { return getContentionResolution(); } + public boolean IsContentionResolution() { return getContentionResolution(); } + + public boolean isAutoSysUnlock() { + if (client != null && client.getConfig() != null) { + return client.getConfig().isAutoSysUnlock(); + } + String s = properties.getProperty(ECLSession.SESSION_AUTO_SYS_UNLOCK); + if (s != null) { + return "true".equalsIgnoreCase(s) || "1".equals(s); + } + return autoSysUnlock; + } + public boolean IsAutoSysUnlock() { return isAutoSysUnlock(); } + public boolean getAutoSysUnlock() { return isAutoSysUnlock(); } + public boolean GetAutoSysUnlock() { return isAutoSysUnlock(); } + + public void setAutoSysUnlock(boolean unlock) { + this.autoSysUnlock = unlock; + properties.setProperty(ECLSession.SESSION_AUTO_SYS_UNLOCK, String.valueOf(unlock)); + if (client != null) { + client.setAutoSysUnlock(unlock); + } + } + public void SetAutoSysUnlock(boolean unlock) { setAutoSysUnlock(unlock); } + + public boolean isKeepAlive() { + if (client != null && client.getConfig() != null) return client.getConfig().isKeepAliveEnabled(); + return keepAlive; + } + public boolean getKeepAlive() { return isKeepAlive(); } + public boolean IsKeepAlive() { return isKeepAlive(); } + public boolean GetKeepAlive() { return isKeepAlive(); } + public void setKeepAlive(boolean ka) { + this.keepAlive = ka; + this.properties.setProperty(ECLSession.SESSION_KEEPALIVE, String.valueOf(ka)); + if (client != null) { + client.setKeepAliveEnabled(ka); + } + } + public void SetKeepAlive(boolean ka) { setKeepAlive(ka); } + + public int getKeepAliveTimeout() { + if (client != null && client.getConfig() != null) return client.getConfig().getKeepAliveIntervalSeconds(); + return keepAliveTimeout; + } + public int GetKeepAliveTimeout() { return getKeepAliveTimeout(); } + public void setKeepAliveTimeout(int timeout) { + this.keepAliveTimeout = timeout; + this.properties.setProperty(ECLSession.KEY_KEEPALIVE_TIMEOUT, String.valueOf(timeout)); + if (client != null) { + client.setKeepAliveIntervalSeconds(timeout); + } + } + public void SetKeepAliveTimeout(int timeout) { setKeepAliveTimeout(timeout); } + + public String getKeepAliveType() { + if (client != null && client.getConfig() != null) return client.getConfig().getKeepAliveType(); + return keepAliveType; + } + public String GetKeepAliveType() { return getKeepAliveType(); } + public void setKeepAliveType(String type) { + this.keepAliveType = type; + this.properties.setProperty(ECLSession.KEY_KEEPALIVE_TYPE, type != null ? type : ""); + if (client != null && client.getConfig() != null) { + client.getConfig().setKeepAliveType(type); + } + } + public void SetKeepAliveType(String type) { setKeepAliveType(type); } + + public boolean isAutoReconnect() { + if (client != null && client.getConfig() != null) return client.getConfig().isAutoReconnect(); + return autoReconnect; + } + public boolean getAutoReconnect() { return isAutoReconnect(); } + public boolean IsAutoReconnect() { return isAutoReconnect(); } + public boolean GetAutoReconnect() { return isAutoReconnect(); } + public void setAutoReconnect(boolean ar) { + this.autoReconnect = ar; + this.properties.setProperty(ECLSession.SESSION_AUTORECONNECT, String.valueOf(ar)); + if (client != null) { + client.setAutoReconnect(ar); + } + } + public void SetAutoReconnect(boolean ar) { setAutoReconnect(ar); } + + public int getMaxRetry() { + if (client != null && client.getConfig() != null) return client.getConfig().getReconnectMaxRetries(); + return maxRetry; + } + public int GetMaxRetry() { return getMaxRetry(); } + public void setMaxRetry(int retries) { + this.maxRetry = retries; + this.properties.setProperty(ECLSession.SESSION_RECONNECT_RETRIES, String.valueOf(retries)); + if (client != null && client.getConfig() != null) { + client.getConfig().setReconnectMaxRetries(retries); + } + } + public void SetMaxRetry(int retries) { setMaxRetry(retries); } + public int getReconnectMaxRetries() { return getMaxRetry(); } + public void setReconnectMaxRetries(int retries) { setMaxRetry(retries); } public void set_LULU_Session(boolean bl) { this.luluSession = bl; } public boolean is_LULU_Session() { return luluSession; } public boolean get_LULU_Session() { return luluSession; } - public boolean isNegotiateCResolution() { return isNegCR; } - public void setNegotiatedCResolution(boolean bl) { this.isNegCR = bl; } + public boolean isNegotiateCResolution() { + if (client != null && client.getTelnetFSM() != null) { + return client.getTelnetFSM().isNegotiateContentionResolution(); + } + return isNegCR; + } + public void setNegotiatedCResolution(boolean bl) { + this.isNegCR = bl; + if (client != null && client.getTelnetFSM() != null) { + client.getTelnetFSM().setNegotiateContentionResolution(bl); + } + } public boolean isBIND7FArchitectureViolation() { return isBIND7FArchitectureViolation; } public void setBIND7FArchitectureViolation(boolean bl) { this.isBIND7FArchitectureViolation = bl; } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLOIA.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLOIA.java index bce760e..29a7885 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLOIA.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLOIA.java @@ -2,6 +2,9 @@ package haus.nightmare.lib3270j.ecl; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; import haus.nightmare.lib3270j.input.InputProcessor; import haus.nightmare.lib3270j.screen.ScreenBuffer; @@ -178,7 +181,20 @@ public class ECLOIA implements ECLConstants { } } + private final ReentrantLock oiaLock = new ReentrantLock(); + private final Condition oiaCondition = oiaLock.newCondition(); + + public void signalWaiters() { + oiaLock.lock(); + try { + oiaCondition.signalAll(); + } finally { + oiaLock.unlock(); + } + } + public synchronized void notifyOIAChanged() { + signalWaiters(); ECLOIAEvent event = new ECLOIAEvent(this, ECLOIAEvent.OIA_UPDATE, getInputInhibited(), getAlphanumericType(), isInsertMode(), getStatusString()); for (haus.nightmare.lib3270j.ecl.ECLOIANotify l : listeners) { @@ -413,19 +429,28 @@ public class ECLOIA implements ECLConstants { * @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; + long limit = (timeoutMs <= 0) ? 120000L : timeoutMs; + if (getInputInhibited() == INHIBIT_NOT_INHIBITED) { + return true; + } + oiaLock.lock(); + try { + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit); + while (getInputInhibited() != INHIBIT_NOT_INHIBITED) { + if (remainingNanos <= 0) { + return false; + } + try { + remainingNanos = oiaCondition.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } } - try { - Thread.sleep(20); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } + return true; + } finally { + oiaLock.unlock(); } - return getInputInhibited() == INHIBIT_NOT_INHIBITED; } /** @@ -458,20 +483,26 @@ public class ECLOIA implements ECLConstants { * Block until any OIA transition occurs. */ public boolean waitForTransition(long timeoutMs) { + long limit = (timeoutMs <= 0) ? 120000L : 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; + oiaLock.lock(); + try { + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit); + while (getInputInhibited() == initialInhibit) { + if (remainingNanos <= 0) { + return false; + } + try { + remainingNanos = oiaCondition.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } } + return true; + } finally { + oiaLock.unlock(); } - return getInputInhibited() != initialInhibit; } public boolean WaitForTransition(long timeoutMs) { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java index 1dadc65..f4b13fd 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPS.java @@ -2,8 +2,12 @@ package haus.nightmare.lib3270j.ecl; import haus.nightmare.lib3270j.charset.EbcdicTranslator; import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.listener.ScreenUpdateListener; import haus.nightmare.lib3270j.screen.ExtendedAttribute; import haus.nightmare.lib3270j.screen.ScreenBuffer; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; /** @@ -37,6 +41,31 @@ public class ECLPS implements ECLConstants { private final java.util.Map descriptorListeners = new java.util.concurrent.ConcurrentHashMap<>(); private final java.util.Map listenerEventTypes = new java.util.concurrent.ConcurrentHashMap<>(); + // Synchronization primitives for reactive automation waits (Phase 12) + private final ReentrantLock fallbackLock = new ReentrantLock(); + private final Condition fallbackCondition = fallbackLock.newCondition(); + + public ReentrantLock getSyncLock() { + return (screen != null) ? screen.getSyncLock() : fallbackLock; + } + + public Condition getSyncCondition() { + return (screen != null) ? screen.getSyncCondition() : fallbackCondition; + } + + public void signalWaiters() { + if (screen != null) { + screen.signalWaiters(); + } else { + fallbackLock.lock(); + try { + fallbackCondition.signalAll(); + } finally { + fallbackLock.unlock(); + } + } + } + public ECLPS(ScreenBuffer screen, InputProcessor inputProcessor, EbcdicTranslator translator) { this.screen = screen; this.inputProcessor = inputProcessor; @@ -46,6 +75,27 @@ public class ECLPS implements ECLConstants { this.bidiServices = new DefaultPSBIDIServices(this); this.hindiServices = new DefaultPSHindiServices(this); this.thaiServices = new DefaultPSTHAIServices(this); + + if (this.screen != null) { + this.screen.addUpdateListener(new ScreenUpdateListener() { + @Override + public void onScreenUpdated() { + signalWaiters(); + } + @Override + public void onCursorMoved(int oldAddress, int newAddress) { + signalWaiters(); + } + @Override + public void onScreenSizeChanged(int rows, int cols) { + signalWaiters(); + } + @Override + public void onKeyboardUnlocked() { + signalWaiters(); + } + }); + } } public ECLPS(ECLSession session) { @@ -494,11 +544,71 @@ public class ECLPS implements ECLConstants { return copyString(sRow, sCol, eRow, eCol); } + private boolean enablePasteFromExcel = true; + private boolean pasteStopAtProtectedLine = false; + + public boolean isEnablePasteFromExcel() { + if (session != null && session.getProperties() != null) { + String p = session.getProperties().getProperty(ECLSession.ENABLE_PASTE_FROM_EXCEL); + if (p != null) return Boolean.parseBoolean(p); + } + return enablePasteFromExcel; + } + + public boolean IsEnablePasteFromExcel() { return isEnablePasteFromExcel(); } + + public void setEnablePasteFromExcel(boolean val) { + this.enablePasteFromExcel = val; + if (session != null && session.getProperties() != null) { + session.getProperties().setProperty(ECLSession.ENABLE_PASTE_FROM_EXCEL, String.valueOf(val)); + } + } + + public void SetEnablePasteFromExcel(boolean val) { setEnablePasteFromExcel(val); } + + public boolean isPasteStopAtProtectedLine() { + if (session != null && session.getProperties() != null) { + String p = session.getProperties().getProperty(ECLSession.PASTE_STOP_AT_PROTECTED_LINE); + if (p != null) return Boolean.parseBoolean(p); + } + return pasteStopAtProtectedLine; + } + + public boolean IsPasteStopAtProtectedLine() { return isPasteStopAtProtectedLine(); } + + public void setPasteStopAtProtectedLine(boolean val) { + this.pasteStopAtProtectedLine = val; + if (session != null && session.getProperties() != null) { + session.getProperties().setProperty(ECLSession.PASTE_STOP_AT_PROTECTED_LINE, String.valueOf(val)); + } + } + + public void SetPasteStopAtProtectedLine(boolean val) { setPasteStopAtProtectedLine(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()); + } + return 0; + } + + public int PasteFromExcel(String text, int row, int col) { + return pasteFromExcel(text, row, col); + } + /** * Paste a multi-line rectangular block of text starting at (row, col). */ public synchronized int pasteString(String text, int row, int col) { if (text == null || text.isEmpty() || screen == null) return 0; + if (inputProcessor != null && (text.contains("\t") || isEnablePasteFromExcel() || isPasteStopAtProtectedLine())) { + setCursorPos(row, col); + return inputProcessor.pasteText(text, isEnablePasteFromExcel(), isPasteStopAtProtectedLine()); + } int rows = screen.getRows(); int cols = screen.getCols(); if (rows <= 0 || cols <= 0) return 0; @@ -532,7 +642,33 @@ public class ECLPS implements ECLConstants { } public int pasteRectangular(String text, int row, int col) { - return pasteString(text, row, col); + if (text == null || text.isEmpty() || screen == null) return 0; + int rows = screen.getRows(); + int cols = screen.getCols(); + if (rows <= 0 || cols <= 0) return 0; + + String[] lines = text.split("\r?\n"); + int count = 0; + + for (int i = 0; i < lines.length; i++) { + int targetRow = (row + i) % rows; + String line = lines[i]; + for (int c = 0; c < line.length() && (col + c) < cols; c++) { + int pos = targetRow * cols + (col + c); + if (screen.isFormatted()) { + byte fa = screen.getFieldAttributeAt(pos); + if (faIsProtected(fa & 0xFF) || screen.getCell(pos).isFieldAttribute()) { + continue; + } + } + setCursorPos(pos); + if (inputProcessor != null) { + inputProcessor.typeCharacter(line.charAt(c)); + } + count++; + } + } + return count; } public int PasteRectangular(String text, int row, int col) { @@ -848,6 +984,20 @@ public class ECLPS implements ECLConstants { UnregisterPSEvent(listener); } + public void dispatchEvent(ECLPSEvent event) { + notifyPSEvent(event); + } + + public void notifyKeyUnlocked() { + int r = (screen != null) ? screen.getRows() : 0; + int c = (screen != null) ? screen.getCols() : 0; + int cur = (screen != null) ? screen.getCursorAddress() : 0; + int ring = ringCounter.incrementAndGet(); + signalWaiters(); + notifyPSEvent(new ECLPSEvent(this, ECLPSEvent.EVENT_KEY_UNLOCKED, HOST_EVENTS, 0, 0, + Math.max(0, r - 1), Math.max(0, c - 1), cur, cur, r, c, false, cursorVisible, ring, false, null)); + } + public void notifyPSEvent(ECLPSEvent event) { for (ECLPSListener l : psListeners) { ECLScreenDesc desc = descriptorListeners.get(l); @@ -899,6 +1049,7 @@ public class ECLPS implements ECLConstants { int c = (screen != null) ? screen.getCols() : 0; int cur = (screen != null) ? screen.getCursorAddress() : 0; int ring = ringCounter.incrementAndGet(); + signalWaiters(); ECLPSEvent evt = new ECLPSEvent(this, ECLPSEvent.PS_UPDATE, type, startRow, startCol, endRow, endCol, cur, cur, r, c, full, cursorVisible, ring, startPrinter, null); notifyPSEvent(evt); @@ -910,12 +1061,14 @@ public class ECLPS implements ECLConstants { int row = (c > 0) ? newAddress / c : 0; int col = (c > 0) ? newAddress % c : 0; int ring = ringCounter.incrementAndGet(); + signalWaiters(); notifyPSEvent(new ECLPSEvent(this, ECLPSEvent.PS_CURSOR, USER_EVENTS, row, col, row, col, oldAddress, newAddress, r, c, false, cursorVisible, ring, false, null)); } public void notifyAlarm() { int ring = ringCounter.incrementAndGet(); + signalWaiters(); notifyPSEvent(new ECLPSEvent(this, ECLPSEvent.PS_ALARM, HOST_EVENTS, 0, 0, 0, 0, 0, 0, 0, 0, false, cursorVisible, ring, false, null)); } @@ -923,6 +1076,7 @@ public class ECLPS implements ECLConstants { public void notifyScreenResized(int rows, int cols) { int cur = (screen != null) ? screen.getCursorAddress() : 0; int ring = ringCounter.incrementAndGet(); + signalWaiters(); notifyPSEvent(new ECLPSEvent(this, ECLPSEvent.PS_RESIZE, HOST_EVENTS, 0, 0, rows - 1, cols - 1, cur, cur, rows, cols, true, cursorVisible, ring, false, null)); } @@ -1067,20 +1221,30 @@ public class ECLPS implements ECLConstants { public boolean waitForScreen(ECLScreenDesc desc, long timeoutMs) { if (desc == null) return true; long limit = (timeoutMs <= 0) ? 120000L : timeoutMs; - long start = System.currentTimeMillis(); ECLOIA oia = (session != null) ? session.GetOIA() : null; - while (System.currentTimeMillis() - start < limit) { - if (desc.Matches(this, oia)) { - return true; + if (desc.Matches(this, oia)) { + return true; + } + ReentrantLock lock = getSyncLock(); + Condition cond = getSyncCondition(); + lock.lock(); + try { + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit); + while (!desc.Matches(this, oia)) { + if (remainingNanos <= 0) { + return false; + } + try { + remainingNanos = cond.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } } - try { - Thread.sleep(25); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } + return true; + } finally { + lock.unlock(); } - return desc.Matches(this, oia); } public boolean WaitForScreen(ECLScreenDesc desc, long timeoutMs) { @@ -1097,20 +1261,30 @@ public class ECLPS implements ECLConstants { public boolean waitWhileScreen(ECLScreenDesc desc, long timeoutMs) { if (desc == null) return true; long limit = (timeoutMs <= 0) ? 120000L : timeoutMs; - long start = System.currentTimeMillis(); ECLOIA oia = (session != null) ? session.GetOIA() : null; - while (System.currentTimeMillis() - start < limit) { - if (!desc.Matches(this, oia)) { - return true; + if (!desc.Matches(this, oia)) { + return true; + } + ReentrantLock lock = getSyncLock(); + Condition cond = getSyncCondition(); + lock.lock(); + try { + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit); + while (desc.Matches(this, oia)) { + if (remainingNanos <= 0) { + return false; + } + try { + remainingNanos = cond.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } } - try { - Thread.sleep(25); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } + return true; + } finally { + lock.unlock(); } - return !desc.Matches(this, oia); } public boolean WaitWhileScreen(ECLScreenDesc desc, long timeoutMs) { @@ -1121,69 +1295,178 @@ public class ECLPS implements ECLConstants { * Block until the specified text appears anywhere on the presentation space. */ public boolean waitForScreen(String text, long timeoutMs) { - long start = System.currentTimeMillis(); - while (System.currentTimeMillis() - start < timeoutMs) { - if (searchString(text) >= 0) { - return true; + if (text == null || text.isEmpty()) return true; + long limit = (timeoutMs <= 0) ? 120000L : timeoutMs; + if (searchString(text) >= 0) { + return true; + } + ReentrantLock lock = getSyncLock(); + Condition cond = getSyncCondition(); + lock.lock(); + try { + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit); + while (searchString(text) < 0) { + if (remainingNanos <= 0) { + return false; + } + try { + remainingNanos = cond.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } } - try { - Thread.sleep(25); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } + return true; + } finally { + lock.unlock(); } - return searchString(text) >= 0; } public boolean WaitForScreen(String text, long timeoutMs) { return waitForScreen(text, timeoutMs); } + public boolean waitForString(String text) { + return waitForScreen(text, -1L); + } + + public boolean WaitForString(String text) { + return waitForScreen(text, -1L); + } + + public boolean waitForString(String text, long timeoutMs) { + return waitForScreen(text, timeoutMs); + } + + public boolean WaitForString(String text, long timeoutMs) { + return waitForScreen(text, timeoutMs); + } + /** * Block until the specified text appears at the given (row, col) coordinate. */ public boolean waitForScreen(String text, int row, int col, long timeoutMs) { - long start = System.currentTimeMillis(); - while (System.currentTimeMillis() - start < timeoutMs) { - String onScreen = getString(row, col, text.length()); - if (text.equals(onScreen)) { - return true; + if (text == null) return true; + long limit = (timeoutMs <= 0) ? 120000L : timeoutMs; + if (text.equals(getString(row, col, text.length()))) { + return true; + } + ReentrantLock lock = getSyncLock(); + Condition cond = getSyncCondition(); + lock.lock(); + try { + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit); + while (!text.equals(getString(row, col, text.length()))) { + if (remainingNanos <= 0) { + return false; + } + try { + remainingNanos = cond.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } } - try { - Thread.sleep(25); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } + return true; + } finally { + lock.unlock(); } - return text.equals(getString(row, col, text.length())); } public boolean WaitForScreen(String text, int row, int col, long timeoutMs) { return waitForScreen(text, row, col, timeoutMs); } + public boolean waitForString(String text, int row, int col, long timeoutMs) { + return waitForScreen(text, row, col, timeoutMs); + } + + public boolean WaitForString(String text, int row, int col, long timeoutMs) { + return waitForScreen(text, row, col, timeoutMs); + } + /** * Block until the cursor moves to (row, col). */ public boolean waitForCursor(int row, int col, long timeoutMs) { - long start = System.currentTimeMillis(); - while (System.currentTimeMillis() - start < timeoutMs) { - if (getCursorRow() == row && getCursorCol() == col) { - return true; + long limit = (timeoutMs <= 0) ? 120000L : timeoutMs; + if (getCursorRow() == row && getCursorCol() == col) { + return true; + } + ReentrantLock lock = getSyncLock(); + Condition cond = getSyncCondition(); + lock.lock(); + try { + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit); + while (getCursorRow() != row || getCursorCol() != col) { + if (remainingNanos <= 0) { + return false; + } + try { + remainingNanos = cond.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } } - try { - Thread.sleep(25); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } + return true; + } finally { + lock.unlock(); } - return getCursorRow() == row && getCursorCol() == col; } public boolean WaitForCursor(int row, int col, long timeoutMs) { return waitForCursor(row, col, timeoutMs); } + + protected boolean locked_SYSLOCK = false; + protected boolean locked_TWAIT = false; + + public void lockKeyboard() { + lockKeyboard(8); + } + + public void lockKeyboard(int reason) { + if (reason == 7) locked_TWAIT = true; + if (reason == 8) { + locked_SYSLOCK = true; + if (session != null && session.getOIA() != null) { + session.getOIA().setDoNotEnter(8, 0); + } + } + if (inputProcessor != null) { + inputProcessor.setKeyboardLocked(true); + } + } + + public void unlockKeyboard() { + unlockKeyboard(8); + } + + public void unlockKeyboard(int reason) { + if (reason == 7) locked_TWAIT = false; + if (reason == 8) { + locked_SYSLOCK = false; + if (session != null && session.getOIA() != null) { + session.getOIA().clearDoNotEnter(); + } + } + if (!locked_TWAIT && !locked_SYSLOCK) { + if (inputProcessor != null) { + inputProcessor.setKeyboardLocked(false); + } + } + } + + public boolean islocked_TWAIT() { + return locked_TWAIT; + } + + public boolean islocked_SYSLOCK() { + if (locked_SYSLOCK) return true; + if (session != null && session.getOIA() != null && session.getOIA().isXSystem()) { + return true; + } + return false; + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSEvent.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSEvent.java index a6b5eaf..cbc075a 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSEvent.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSEvent.java @@ -27,6 +27,7 @@ public class ECLPSEvent extends EventObject { public static final int EVENT_ALARM = PS_ALARM; public static final int EVENT_RESIZE = PS_RESIZE; public static final int EVENT_CLOSE = PS_CLOSE; + public static final int EVENT_KEY_UNLOCKED = PS_UPDATE; // HoD event type 1 for keyboard unlock / update private final int eventType; private final int type; diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSGraphicsEvent.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSGraphicsEvent.java index f9a815b..783681f 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSGraphicsEvent.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSGraphicsEvent.java @@ -1,10 +1,11 @@ package haus.nightmare.lib3270j.ecl; -import java.awt.Image; -import java.awt.Rectangle; +import haus.nightmare.lib3270j.graphics.PixelBuffer; +import haus.nightmare.lib3270j.graphics.Rectangle; /** * Conforms to IBM Host On-Demand ECLPSGraphicsEvent. + * Decoupled from java.awt. */ public class ECLPSGraphicsEvent { public static final int GRAPHICS_CURSOR_ON = 1; @@ -14,7 +15,7 @@ public class ECLPSGraphicsEvent { public static final int GRAPHICS_UPDATED = 5; private int id; - private Image image; + private Object image; private Rectangle rect; private ECLPS source; @@ -23,13 +24,13 @@ public class ECLPSGraphicsEvent { this.id = id; } - public ECLPSGraphicsEvent(ECLPS source, int id, Image image) { + public ECLPSGraphicsEvent(ECLPS source, int id, Object image) { this.source = source; this.id = id; this.image = image; } - public ECLPSGraphicsEvent(ECLPS source, int id, Image image, Rectangle rectangle) { + public ECLPSGraphicsEvent(ECLPS source, int id, Object image, Rectangle rectangle) { this.source = source; this.id = id; this.image = image; @@ -46,9 +47,13 @@ public class ECLPSGraphicsEvent { public int getID() { return this.id; } public int GetID() { return this.id; } - public void setImage(Image image) { this.image = image; } - public Image getImage() { return this.image; } - public Image GetImage() { return this.image; } + public void setImage(Object image) { this.image = image; } + public Object getImage() { return this.image; } + public Object GetImage() { return this.image; } + + public PixelBuffer getPixelBuffer() { + return (this.image instanceof PixelBuffer) ? (PixelBuffer) this.image : null; + } public void setRectangle(Rectangle rect) { this.rect = rect; } public Rectangle getRectangle() { return this.rect; } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSGraphicsServices.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSGraphicsServices.java index 902bc96..321727b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSGraphicsServices.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLPSGraphicsServices.java @@ -1,13 +1,13 @@ package haus.nightmare.lib3270j.ecl; -import java.awt.Color; -import java.awt.Component; +import haus.nightmare.lib3270j.graphics.Color; /** * Presentation Space graphics services interface conforming to IBM Host On-Demand ECL. + * Completely decoupled from java.awt. */ public interface ECLPSGraphicsServices { - void setVisualComponent(Component comp); + void setVisualComponent(Object comp); void setGraphicColor(Color[] colors, boolean b); void mousePressed(int x, int y, int button); void addGraphicsListener(ECLPSGraphicsListener listener); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLSession.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLSession.java index 0170259..70a5b97 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLSession.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLSession.java @@ -18,7 +18,7 @@ public class ECLSession { private static final Logger log = Logger.getLogger(ECLSession.class.getName()); - // Standard IBM HoD Session Property Keys + // Standard Session Property Keys public static final String SESSION_HOST = "SESSION_HOST"; public static final String SESSION_PORT = "SESSION_PORT"; public static final String SESSION_CODE_PAGE = "SESSION_CODE_PAGE"; @@ -29,6 +29,24 @@ public class ECLSession { public static final String SESSION_TN3270E = "SESSION_TN3270E"; public static final String SESSION_WIN_TITLE = "SESSION_WIN_TITLE"; public static final String SESSION_AUTO_CONNECT = "SESSION_AUTO_CONNECT"; + public static final String SESSION_AUTO_SYS_UNLOCK = "autoSysUnlock"; + public static final String SESSION_KEEPALIVE = "SESSION_KEEPALIVE"; + public static final String KEY_KEEPALIVE_TYPE = "keepAliveType"; + public static final String KEY_KEEPALIVE_TIMEOUT = "keepAliveTimeout"; + public static final String SESSION_AUTORECONNECT = "SESSION_AUTORECONNECT"; + public static final String SESSION_RECONNECT_RETRIES = "SESSION_RECONNECT_RETRIES"; + public static final String ENABLE_PASTE_FROM_EXCEL = "enablePasteFromExcel"; + public static final String PASTE_TAB_OPTIONS = "pasteTabOptions"; + public static final String PASTE_STOP_AT_PROTECTED_LINE = "pasteStopAtProtectedLine"; + public static final String PASTE_FIELD_WRAP = "pasteFieldWrap"; + public static final String PASTE_LINE_WRAP = "pasteLineWrap"; + public static final String ENTRYASSIST_DOCMODE = "EntryAssist_DOCmode"; + public static final String ENTRYASSIST_DOCWORDWRAP = "EntryAssist_DOCwordWrap"; + public static final String ENTRYASSIST_STARTCOL = "EntryAssist_startCol"; + public static final String ENTRYASSIST_ENDCOL = "EntryAssist_endCol"; + public static final String ENTRYASSIST_BELL = "EntryAssist_bell"; + public static final String ENTRYASSIST_BELLCOL = "EntryAssist_bellCol"; + public static final String ENTRYASSIST_TABSTOPS = "EntryAssist_tabstops"; private final Telnet3270Client client; private final ECLConnection connection; @@ -98,6 +116,9 @@ public class ECLSession { String tn3270eStr = getProp(props, SESSION_TN3270E, "tn3270e", "TN3270E", "true"); config.setTn3270eEnabled("true".equalsIgnoreCase(tn3270eStr) || "yes".equalsIgnoreCase(tn3270eStr) || "1".equals(tn3270eStr)); + String autoSysStr = getProp(props, SESSION_AUTO_SYS_UNLOCK, "autoSysUnlock", "AutoSysUnlock", "true"); + config.setAutoSysUnlock("true".equalsIgnoreCase(autoSysStr) || "yes".equalsIgnoreCase(autoSysStr) || "1".equals(autoSysStr)); + String certUrl = getProp(props, "certificateURL", "CERTIFICATE_URL", "certificate_url", null); if (certUrl != null) config.setKeyStorePath(certUrl); String certPwd = getProp(props, "certificatePassword", "CERTIFICATE_PASSWORD", "certificate_password", null); @@ -118,6 +139,25 @@ public class ECLSession { config.setEnabledProtocols(tlsVer); } + String keepAliveStr = getProp(props, SESSION_KEEPALIVE, "keepAlive", "keepalive", "true"); + config.setKeepAliveEnabled("true".equalsIgnoreCase(keepAliveStr) || "yes".equalsIgnoreCase(keepAliveStr) || "1".equals(keepAliveStr)); + + String kaTimeoutStr = getProp(props, KEY_KEEPALIVE_TIMEOUT, "keepAliveTimeout", "keepalivetimeout", null); + if (kaTimeoutStr != null) { + try { config.setKeepAliveIntervalSeconds(Integer.parseInt(kaTimeoutStr.trim())); } catch (NumberFormatException ignored) {} + } + + String kaTypeStr = getProp(props, KEY_KEEPALIVE_TYPE, "keepAliveType", "keepalivetype", null); + if (kaTypeStr != null) config.setKeepAliveType(kaTypeStr); + + String autoReconnectStr = getProp(props, SESSION_AUTORECONNECT, "autoReconnect", "autoreconnect", "false"); + config.setAutoReconnect("true".equalsIgnoreCase(autoReconnectStr) || "yes".equalsIgnoreCase(autoReconnectStr) || "1".equals(autoReconnectStr)); + + String retriesStr = getProp(props, SESSION_RECONNECT_RETRIES, "reconnectMaxRetries", "reconnectRetries", null); + if (retriesStr != null) { + try { config.setReconnectMaxRetries(Integer.parseInt(retriesStr.trim())); } catch (NumberFormatException ignored) {} + } + return config; } @@ -161,6 +201,7 @@ public class ECLSession { properties.setProperty(SESSION_SSL, String.valueOf(cfg.isUseTls())); if (cfg.getLuName() != null) properties.setProperty(SESSION_LU_NAME, cfg.getLuName()); properties.setProperty(SESSION_TN3270E, String.valueOf(cfg.isTn3270eEnabled())); + properties.setProperty(SESSION_AUTO_SYS_UNLOCK, String.valueOf(cfg.isAutoSysUnlock())); } } @@ -371,6 +412,42 @@ public class ECLSession { } } + public boolean isKeepAlive() { + return (client != null) ? client.isKeepAliveEnabled() : true; + } + public boolean IsKeepAlive() { return isKeepAlive(); } + public void setKeepAlive(boolean keepAlive) { + this.properties.setProperty(SESSION_KEEPALIVE, String.valueOf(keepAlive)); + if (client != null) { + client.setKeepAliveEnabled(keepAlive); + } + } + public void SetKeepAlive(boolean keepAlive) { setKeepAlive(keepAlive); } + + public int getKeepAliveTimeout() { + return (client != null) ? client.getKeepAliveIntervalSeconds() : 120; + } + public int GetKeepAliveTimeout() { return getKeepAliveTimeout(); } + public void setKeepAliveTimeout(int timeout) { + this.properties.setProperty(KEY_KEEPALIVE_TIMEOUT, String.valueOf(timeout)); + if (client != null) { + client.setKeepAliveIntervalSeconds(timeout); + } + } + public void SetKeepAliveTimeout(int timeout) { setKeepAliveTimeout(timeout); } + + public boolean isAutoReconnect() { + return (client != null) ? client.isAutoReconnect() : false; + } + public boolean IsAutoReconnect() { return isAutoReconnect(); } + public void setAutoReconnect(boolean autoReconnect) { + this.properties.setProperty(SESSION_AUTORECONNECT, String.valueOf(autoReconnect)); + if (client != null) { + client.setAutoReconnect(autoReconnect); + } + } + public void SetAutoReconnect(boolean autoReconnect) { setAutoReconnect(autoReconnect); } + // ========== Automation Keystrokes & Waits ========== /** @@ -455,6 +532,41 @@ public class ECLSession { dispose(); } + public boolean isAutoSysUnlock() { + if (client != null && client.getConfig() != null) { + return client.getConfig().isAutoSysUnlock(); + } + String s = properties.getProperty(SESSION_AUTO_SYS_UNLOCK); + return s != null ? Boolean.parseBoolean(s) : true; + } + public boolean getAutoSysUnlock() { return isAutoSysUnlock(); } + public boolean IsAutoSysUnlock() { return isAutoSysUnlock(); } + public boolean GetAutoSysUnlock() { return isAutoSysUnlock(); } + + public void setAutoSysUnlock(boolean unlock) { + properties.setProperty(SESSION_AUTO_SYS_UNLOCK, String.valueOf(unlock)); + if (client != null) { + client.setAutoSysUnlock(unlock); + } + } + public void SetAutoSysUnlock(boolean unlock) { setAutoSysUnlock(unlock); } + + public boolean getContentionResolution() { + if (connection != null) return connection.getContentionResolution(); + if (client != null) return client.isContentionResolution(); + return false; + } + public boolean isContentionResolution() { return getContentionResolution(); } + public boolean GetContentionResolution() { return getContentionResolution(); } + public boolean IsContentionResolution() { return getContentionResolution(); } + + public void setContentionResolution(boolean cr) { + if (connection != null) { + connection.setContentionResolution(cr); + } + } + public void SetContentionResolution(boolean cr) { setContentionResolution(cr); } + @Override public String toString() { return String.format("ECLSession[host=%s, port=%d, connected=%b, state=%s]", diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Color.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Color.java new file mode 100644 index 0000000..b0ace43 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Color.java @@ -0,0 +1,96 @@ +package haus.nightmare.lib3270j.graphics; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Platform-neutral Color representation encapsulating 32-bit ARGB. + * Completely decouples lib3270j from java.awt.Color. + */ +public class Color implements Serializable { + private static final long serialVersionUID = 1L; + + public static final Color WHITE = new Color(0xFFFFFFFF); + public static final Color LIGHT_GRAY = new Color(0xFFC0C0C0); + public static final Color GRAY = new Color(0xFF808080); + public static final Color DARK_GRAY = new Color(0xFF404040); + public static final Color BLACK = new Color(0xFF000000); + public static final Color RED = new Color(0xFFFF0000); + public static final Color PINK = new Color(0xFFFFAFAF); + public static final Color ORANGE = new Color(0xFFFFC800); + public static final Color YELLOW = new Color(0xFFFFFF00); + public static final Color GREEN = new Color(0xFF00FF00); + public static final Color MAGENTA = new Color(0xFFFF00FF); + public static final Color CYAN = new Color(0xFF00FFFF); + public static final Color BLUE = new Color(0xFF0000FF); + + private final int value; + + public Color(int rgb) { + this.value = 0xFF000000 | rgb; + } + + public Color(int rgba, boolean hasAlpha) { + if (hasAlpha) { + this.value = rgba; + } else { + this.value = 0xFF000000 | rgba; + } + } + + public Color(int r, int g, int b) { + this(r, g, b, 255); + } + + public Color(int r, int g, int b, int a) { + this.value = ((a & 0xFF) << 24) | + ((r & 0xFF) << 16) | + ((g & 0xFF) << 8) | + (b & 0xFF); + } + + public Color(float r, float g, float b) { + this((int) (r * 255 + 0.5), (int) (g * 255 + 0.5), (int) (b * 255 + 0.5)); + } + + public Color(float r, float g, float b, float a) { + this((int) (r * 255 + 0.5), (int) (g * 255 + 0.5), (int) (b * 255 + 0.5), (int) (a * 255 + 0.5)); + } + + public int getRGB() { + return value; + } + + public int getRed() { + return (value >> 16) & 0xFF; + } + + public int getGreen() { + return (value >> 8) & 0xFF; + } + + public int getBlue() { + return value & 0xFF; + } + + public int getAlpha() { + return (value >> 24) & 0xFF; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Color)) return false; + return this.value == ((Color) obj).value; + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return getClass().getName() + "[r=" + getRed() + ",g=" + getGreen() + ",b=" + getBlue() + ",a=" + getAlpha() + "]"; + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/DefaultPixelBuffer.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/DefaultPixelBuffer.java new file mode 100644 index 0000000..a2b1442 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/DefaultPixelBuffer.java @@ -0,0 +1,277 @@ +package haus.nightmare.lib3270j.graphics; + +import java.util.Arrays; + +/** + * Default standalone implementation of PixelBuffer. + * Provides pure Java software rasterization into a 32-bit ARGB contiguous integer array. + */ +public class DefaultPixelBuffer implements PixelBuffer { + + private int width; + private int height; + private int[] pixels; + + private boolean hasClip = false; + private int clipX; + private int clipY; + private int clipWidth; + private int clipHeight; + + public DefaultPixelBuffer(int width, int height) { + this.width = Math.max(1, width); + this.height = Math.max(1, height); + this.pixels = new int[this.width * this.height]; + } + + public DefaultPixelBuffer(int width, int height, int[] existingPixels) { + this.width = Math.max(1, width); + this.height = Math.max(1, height); + if (existingPixels != null && existingPixels.length >= this.width * this.height) { + this.pixels = existingPixels; + } else { + this.pixels = new int[this.width * this.height]; + } + } + + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public int[] getPixels() { + return pixels; + } + + @Override + public synchronized int getPixel(int x, int y) { + if (x < 0 || x >= width || y < 0 || y >= height) return 0; + return pixels[y * width + x]; + } + + @Override + public synchronized void setPixelDirect(int x, int y, int argb) { + if (x < 0 || x >= width || y < 0 || y >= height) return; + if (isClipped(x, y)) return; + pixels[y * width + x] = argb; + } + + @Override + public synchronized void setPixel(int x, int y, int argb) { + if (x < 0 || x >= width || y < 0 || y >= height) return; + if (isClipped(x, y)) return; + + int srcA = (argb >>> 24) & 0xFF; + if (srcA == 0) return; + + int idx = y * width + x; + if (srcA == 255) { + pixels[idx] = argb; + return; + } + + int dst = pixels[idx]; + int dstA = (dst >>> 24) & 0xFF; + if (dstA == 0) { + pixels[idx] = argb; + return; + } + + int srcR = (argb >>> 16) & 0xFF; + int srcG = (argb >>> 8) & 0xFF; + int srcB = argb & 0xFF; + + int dstR = (dst >>> 16) & 0xFF; + int dstG = (dst >>> 8) & 0xFF; + int dstB = dst & 0xFF; + + int outA = srcA + dstA * (255 - srcA) / 255; + if (outA == 0) { + pixels[idx] = 0; + return; + } + + int outR = (srcR * srcA + dstR * dstA * (255 - srcA) / 255) / outA; + int outG = (srcG * srcA + dstG * dstA * (255 - srcA) / 255) / outA; + int outB = (srcB * srcA + dstB * dstA * (255 - srcA) / 255) / outA; + + pixels[idx] = (outA << 24) | (outR << 16) | (outG << 8) | outB; + } + + @Override + public synchronized void clear() { + clear(0); + } + + @Override + public synchronized void clear(int argb) { + Arrays.fill(pixels, argb); + } + + @Override + public synchronized void setClip(int x, int y, int width, int height) { + this.hasClip = true; + this.clipX = x; + this.clipY = y; + this.clipWidth = Math.max(0, width); + this.clipHeight = Math.max(0, height); + } + + @Override + public synchronized void clearClip() { + this.hasClip = false; + } + + @Override + public boolean isClipped(int x, int y) { + if (!hasClip) return false; + return x < clipX || x >= (clipX + clipWidth) || y < clipY || y >= (clipY + clipHeight); + } + + @Override + public synchronized void fillRect(int x, int y, int w, int h, int argb) { + if (w <= 0 || h <= 0) return; + int x1 = Math.max(0, x); + int y1 = Math.max(0, y); + int x2 = Math.min(width, x + w); + int y2 = Math.min(height, y + h); + + if (hasClip) { + x1 = Math.max(x1, clipX); + y1 = Math.max(y1, clipY); + x2 = Math.min(x2, clipX + clipWidth); + y2 = Math.min(y2, clipY + clipHeight); + } + + for (int row = y1; row < y2; row++) { + int rowOffset = row * width; + Arrays.fill(pixels, rowOffset + x1, rowOffset + x2, argb); + } + } + + @Override + public synchronized void blit(int[] srcPixels, int srcX, int srcY, int srcW, int srcH, int dstX, int dstY) { + if (srcPixels == null || srcW <= 0 || srcH <= 0) return; + + for (int r = 0; r < srcH; r++) { + int sy = srcY + r; + int dy = dstY + r; + if (dy < 0 || dy >= height) continue; + + for (int c = 0; c < srcW; c++) { + int sx = srcX + c; + int dx = dstX + c; + if (dx < 0 || dx >= width) continue; + if (isClipped(dx, dy)) continue; + + int sp = srcPixels[sy * srcW + sx]; + setPixel(dx, dy, sp); + } + } + } + + @Override + public synchronized void drawLine(int x1, int y1, int x2, int y2, int argb) { + drawLineBresenham(x1, y1, x2, y2, argb); + } + + @Override + public synchronized void drawLineBresenham(int x0, int y0, int x1, int y1, int color) { + int dx = Math.abs(x1 - x0); + int dy = Math.abs(y1 - y0); + int sx = (x0 < x1) ? 1 : -1; + int sy = (y0 < y1) ? 1 : -1; + int err = dx - dy; + + int curX = x0; + int curY = y0; + + while (true) { + setPixel(curX, curY, color); + if (curX == x1 && curY == y1) break; + int e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + curX += sx; + } + if (e2 < dx) { + err += dx; + curY += sy; + } + } + } + + @Override + public synchronized void drawLine(double x0, double y0, double x1, double y1, int colorArgb, int lineType, int lineWidth) { + int lw = Math.max(1, lineWidth); + int ix0 = (int) Math.round(x0); + int iy0 = (int) Math.round(y0); + int ix1 = (int) Math.round(x1); + int iy1 = (int) Math.round(y1); + + if (lw == 1) { + drawLineBresenham(ix0, iy0, ix1, iy1, colorArgb); + } else { + int half = lw / 2; + for (int ox = -half; ox <= half; ox++) { + for (int oy = -half; oy <= half; oy++) { + drawLineBresenham(ix0 + ox, iy0 + oy, ix1 + ox, iy1 + oy, colorArgb); + } + } + } + } + + @Override + public synchronized void drawLineAA(double x0, double y0, double x1, double y1, int colorArgb, double strokeWidth) { + // Pure Java anti-aliased line rendering + double dx = x1 - x0; + double dy = y1 - y0; + double len = Math.hypot(dx, dy); + if (len < 1e-4) { + setPixel((int) Math.round(x0), (int) Math.round(y0), colorArgb); + return; + } + + double radius = Math.max(0.5, strokeWidth * 0.5); + int minX = (int) Math.floor(Math.min(x0, x1) - radius - 1); + int maxX = (int) Math.ceil(Math.max(x0, x1) + radius + 1); + int minY = (int) Math.floor(Math.min(y0, y1) - radius - 1); + int maxY = (int) Math.ceil(Math.max(y0, y1) + radius + 1); + + minX = Math.max(0, minX); + maxX = Math.min(width - 1, maxX); + minY = Math.max(0, minY); + maxY = Math.min(height - 1, maxY); + + int baseAlpha = (colorArgb >>> 24) & 0xFF; + if (baseAlpha == 0) baseAlpha = 255; + int rgbOnly = colorArgb & 0x00FFFFFF; + + double invLenSq = 1.0 / (len * len); + + for (int py = minY; py <= maxY; py++) { + for (int px = minX; px <= maxX; px++) { + double u = ((px - x0) * dx + (py - y0) * dy) * invLenSq; + u = Math.max(0.0, Math.min(1.0, u)); + double projX = x0 + u * dx; + double projY = y0 + u * dy; + double dist = Math.hypot(px - projX, py - projY); + + if (dist <= radius) { + double coverage = 1.0 - (dist / radius); + coverage = Math.sin(coverage * Math.PI * 0.5); // Smooth cosine roll-off + int effectiveAlpha = (int) (baseAlpha * coverage); + if (effectiveAlpha > 0) { + setPixel(px, py, (effectiveAlpha << 24) | rgbOnly); + } + } + } + } + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Dimension.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Dimension.java new file mode 100644 index 0000000..5f6dc25 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Dimension.java @@ -0,0 +1,66 @@ +package haus.nightmare.lib3270j.graphics; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Lightweight pure-Java POJO dimension for 2D width and height. + * Completely decouples lib3270j from java.awt.Dimension. + */ +public class Dimension implements Serializable { + private static final long serialVersionUID = 1L; + + public int width; + public int height; + + public Dimension() { + this(0, 0); + } + + public Dimension(Dimension d) { + this(d != null ? d.width : 0, d != null ? d.height : 0); + } + + public Dimension(int width, int height) { + this.width = width; + this.height = height; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public void setSize(int width, int height) { + this.width = width; + this.height = height; + } + + public void setSize(Dimension d) { + if (d != null) { + this.width = d.width; + this.height = d.height; + } + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Dimension)) return false; + Dimension d = (Dimension) obj; + return (width == d.width) && (height == d.height); + } + + @Override + public int hashCode() { + return Objects.hash(width, height); + } + + @Override + public String toString() { + return getClass().getName() + "[width=" + width + ",height=" + height + "]"; + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FillArea.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FillArea.java index f8613e3..6088cf7 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FillArea.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FillArea.java @@ -51,7 +51,7 @@ public class FillArea { /** * IBM Host On-Demand multi-polygon constructor. */ - public FillArea(int[] px, int[] py, int[] polyCounts, int numPolys, java.awt.Color color) { + public FillArea(int[] px, int[] py, int[] polyCounts, int numPolys, Color color) { this(); if (color != null) { this.fillColor = color.getRGB(); @@ -72,9 +72,28 @@ public class FillArea { } } - public synchronized java.awt.Rectangle getBounds() { + public FillArea(int[] px, int[] py, int[] polyCounts, int numPolys, int argb) { + this(); + this.fillColor = argb; + if (px != null && py != null && polyCounts != null) { + int offset = 0; + for (int i = 0; i < numPolys && i < polyCounts.length; i++) { + int count = polyCounts[i]; + if (count >= 2 && offset + count <= px.length && offset + count <= py.length) { + int[] sx = new int[count]; + int[] sy = new int[count]; + System.arraycopy(px, offset, sx, 0, count); + System.arraycopy(py, offset, sy, 0, count); + addPolygon(sx, sy, count); + } + offset += count; + } + } + } + + public synchronized Rectangle getBounds() { if (edges.isEmpty()) { - return new java.awt.Rectangle(0, 0, 0, 0); + return new Rectangle(0, 0, 0, 0); } double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE; double maxX = Double.MIN_VALUE, maxY = Double.MIN_VALUE; @@ -88,7 +107,7 @@ public class FillArea { int y = (int) Math.floor(minY); int w = (int) Math.ceil(maxX) - x + 1; int h = (int) Math.ceil(maxY) - y + 1; - return new java.awt.Rectangle(x, y, Math.max(0, w), Math.max(0, h)); + return new Rectangle(x, y, Math.max(0, w), Math.max(0, h)); } public synchronized void setFillModeOR() { @@ -123,18 +142,20 @@ public class FillArea { this.pixelPattern = pat; } - public synchronized java.awt.Image getImage() { - java.awt.Rectangle b = getBounds(); + public synchronized PixelBuffer getPixelBuffer() { + Rectangle b = getBounds(); if (b.width <= 0 || b.height <= 0) { - return new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB); + return new DefaultPixelBuffer(1, 1); } GraphicsPlane tempPlane = new GraphicsPlane(b.x + b.width, b.y + b.height); fill(tempPlane, fillColor, 0, solidFill ? GocaConstants.PT_SOLID : 0, false, 0, 0, 1, 0, 0, null); - java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(b.width, b.height, java.awt.image.BufferedImage.TYPE_INT_ARGB); - java.awt.Graphics g = img.getGraphics(); - g.drawImage(tempPlane.getImage(), -b.x, -b.y, null); - g.dispose(); - return img; + DefaultPixelBuffer cropped = new DefaultPixelBuffer(b.width, b.height); + cropped.blit(tempPlane.getRgbBuffer(), b.x, b.y, b.width, b.height, 0, 0); + return cropped; + } + + public synchronized Object getImage() { + return getPixelBuffer(); } public synchronized void dispose() { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java index 3d0392f..33878b9 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java @@ -1,6 +1,5 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Point; /** * Dedicated scaling layer for IBM 3179G / GDDM GOCA graphics presentation space. diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java index a778a36..1357bda 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java @@ -1,19 +1,5 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.BasicStroke; -import java.awt.Color; -import java.awt.Graphics; -import java.awt.Graphics2D; -import java.awt.Image; -import java.awt.RenderingHints; -import java.awt.geom.Path2D; -import java.awt.image.BufferedImage; -import java.awt.image.DataBuffer; -import java.awt.image.DataBufferInt; -import java.awt.image.DirectColorModel; -import java.awt.image.Raster; -import java.awt.image.SinglePixelPackedSampleModel; -import java.awt.image.WritableRaster; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -23,9 +9,9 @@ import java.util.logging.Logger; /** * Offscreen rendering surface for GOCA vector graphics. * Maintained as an ARGB 32-bit integer pixel buffer that overlays the 3270 character cell matrix. - * Pure Java software rasterizer compatible with standard Java SE (Swing) and Android (Bitmap). + * Pure Java software rasterizer compatible with standard Java SE, Android, and headless environments. */ -public class GraphicsPlane { +public class GraphicsPlane implements PixelBuffer { private static final Logger logger = Logger.getLogger(GraphicsPlane.class.getName()); @@ -111,28 +97,118 @@ public class GraphicsPlane { drawLine((double) x1, (double) y1, (double) x2, (double) y2, currentColorArgb, currentLineType, currentLineWidth); } - private BufferedImage canvasImage; + @Override + public synchronized void drawLine(int x1, int y1, int x2, int y2, int argb) { + drawLine((double) x1, (double) y1, (double) x2, (double) y2, argb, currentLineType, currentLineWidth); + } - public synchronized BufferedImage toBufferedImage() { - if (canvasImage == null && rgbBuffer != null && canvasWidth > 0 && canvasHeight > 0) { - DataBufferInt db = new DataBufferInt(rgbBuffer, rgbBuffer.length); - DirectColorModel cm = new DirectColorModel(32, 0x00FF0000, 0x0000FF00, 0x000000FF, 0xFF000000); - WritableRaster raster = Raster.createWritableRaster( - new SinglePixelPackedSampleModel(DataBuffer.TYPE_INT, canvasWidth, canvasHeight, - new int[]{0x00FF0000, 0x0000FF00, 0x000000FF, 0xFF000000}), - db, null - ); - canvasImage = new BufferedImage(cm, raster, false, null); + @Override + public synchronized int getWidth() { + return canvasWidth; + } + + @Override + public synchronized int getHeight() { + return canvasHeight; + } + + @Override + public synchronized int[] getPixels() { + return rgbBuffer; + } + + @Override + public synchronized void fillRect(int x, int y, int width, int height, int argb) { + if (width <= 0 || height <= 0) return; + int x1 = Math.max(0, x); + int y1 = Math.max(0, y); + int x2 = Math.min(canvasWidth, x + width); + int y2 = Math.min(canvasHeight, y + height); + for (int cy = y1; cy < y2; cy++) { + for (int cx = x1; cx < x2; cx++) { + setPixel(cx, cy, argb); + } } - return canvasImage; + hasContent = true; + updateCount++; } - public synchronized Image getImage() { - return toBufferedImage(); + @Override + public synchronized void blit(int[] srcPixels, int srcX, int srcY, int srcW, int srcH, int dstX, int dstY) { + if (srcPixels == null || srcW <= 0 || srcH <= 0) return; + for (int r = 0; r < srcH; r++) { + int sy = srcY + r; + int dy = dstY + r; + if (dy < 0 || dy >= canvasHeight) continue; + + for (int c = 0; c < srcW; c++) { + int sx = srcX + c; + int dx = dstX + c; + if (dx < 0 || dx >= canvasWidth) continue; + if (isClipped(dx, dy)) continue; + + int idx = sy * srcW + sx; + if (idx < srcPixels.length) { + setPixel(dx, dy, srcPixels[idx]); + } + } + } + hasContent = true; + updateCount++; } - public synchronized Graphics getGraphics() { - return toBufferedImage().getGraphics(); + public synchronized void copyPixels(int[] srcPixels, int srcOffset, int srcScan, int dstX, int dstY, int width, int height) { + if (srcPixels == null || width <= 0 || height <= 0) return; + for (int r = 0; r < height; r++) { + int cy = dstY + r; + if (cy < 0 || cy >= canvasHeight) continue; + int srcRowStart = srcOffset + r * srcScan; + for (int c = 0; c < width; c++) { + int cx = dstX + c; + if (cx < 0 || cx >= canvasWidth) continue; + int p = srcPixels[srcRowStart + c]; + setPixel(cx, cy, p); + } + } + hasContent = true; + updateCount++; + } + + @Override + public synchronized void setClip(int x, int y, int width, int height) { + if (width < 0 || height < 0) { + clearClip(); + } else { + this.clipPixelXMin = x; + this.clipPixelYMin = y; + this.clipPixelXMax = x + width; + this.clipPixelYMax = y + height; + this.viewingWindowActive = true; + } + } + + @Override + public synchronized void clearClip() { + this.clipPixelXMin = 0; + this.clipPixelYMin = 0; + this.clipPixelXMax = canvasWidth; + this.clipPixelYMax = canvasHeight; + this.viewingWindowActive = false; + } + + @Override + public boolean isClipped(int x, int y) { + if (!viewingWindowActive) { + return x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight; + } + return x < clipPixelXMin || x > clipPixelXMax || y < clipPixelYMin || y > clipPixelYMax; + } + + public synchronized Rectangle getClip() { + if (!viewingWindowActive) { + return new Rectangle(0, 0, canvasWidth, canvasHeight); + } + return new Rectangle(clipPixelXMin, clipPixelYMin, clipPixelXMax - clipPixelXMin, clipPixelYMax - clipPixelYMin); } public void setProgramSymbolManager(ProgramSymbolManager psm) { @@ -230,16 +306,21 @@ public class GraphicsPlane { this.canvasWidth = w; this.canvasHeight = h; this.rgbBuffer = newBuffer; - this.canvasImage = null; updateViewingWindowPixels(); } + @Override public synchronized void clear() { + clear(0); + } + + @Override + public synchronized void clear(int argb) { if (rgbBuffer != null) { - Arrays.fill(rgbBuffer, 0); + Arrays.fill(rgbBuffer, argb); } this.currentMixMode = 0; - hasContent = false; + hasContent = (argb != 0); updateCount++; } @@ -536,6 +617,7 @@ public class GraphicsPlane { /** * Standard integer Bresenham line algorithm matching IBM 3179G / Host On-Demand 1-pixel rasterization. */ + @Override public synchronized void drawLineBresenham(int x0, int y0, int x1, int y1, int color) { int dx = Math.abs(x1 - x0); int dy = Math.abs(y1 - y0); @@ -598,6 +680,7 @@ public class GraphicsPlane { /** * Draws a line matching IBM 3179G / Host On-Demand rasterization. */ + @Override public synchronized void drawLine(double x0, double y0, double x1, double y1, int colorArgb, int lineType, int lineWidth) { int color = (colorArgb != 0) ? (colorArgb & 0x00FFFFFF) : (GocaConstants.GOCA_COLORS[0] & 0x00FFFFFF); int ix0 = (int) Math.round(x0); @@ -628,6 +711,111 @@ public class GraphicsPlane { drawLine((double) x1, (double) y1, (double) x2, (double) y2, colorArgb, lineType, lineWidth); } + @Override + public synchronized void drawLineAA(double x0, double y0, double x1, double y1, int argb, double strokeWidth) { + drawLineAA(x0, y0, x1, y1, argb); + } + + /** + * Draws an anti-aliased line using Xiaolin Wu's algorithm directly into the pixel buffer. + * Pure Java implementation replacing AWT Graphics2D rendering for sub-pixel vector strokes. + */ + public synchronized void drawLineAA(double x0, double y0, double x1, double y1, int color) { + boolean steep = Math.abs(y1 - y0) > Math.abs(x1 - x0); + if (steep) { + double t = x0; x0 = y0; y0 = t; + t = x1; x1 = y1; y1 = t; + } + if (x0 > x1) { + double t = x0; x0 = x1; x1 = t; + t = y0; y0 = y1; y1 = t; + } + + double dx = x1 - x0; + double dy = y1 - y0; + double gradient = (dx == 0.0) ? 1.0 : dy / dx; + + // Handle first endpoint + double xend = Math.round(x0); + double yend = y0 + gradient * (xend - x0); + double xgap = 1.0 - (x0 + 0.5 - Math.floor(x0 + 0.5)); + int xpxl1 = (int) xend; + int ypxl1 = (int) Math.floor(yend); + if (steep) { + plotAA(ypxl1, xpxl1, (1.0 - (yend - Math.floor(yend))) * xgap, color); + plotAA(ypxl1 + 1, xpxl1, (yend - Math.floor(yend)) * xgap, color); + } else { + plotAA(xpxl1, ypxl1, (1.0 - (yend - Math.floor(yend))) * xgap, color); + plotAA(xpxl1, ypxl1 + 1, (yend - Math.floor(yend)) * xgap, color); + } + double intery = yend + gradient; + + // Handle second endpoint + xend = Math.round(x1); + yend = y1 + gradient * (xend - x1); + xgap = x1 + 0.5 - Math.floor(x1 + 0.5); + int xpxl2 = (int) xend; + int ypxl2 = (int) Math.floor(yend); + if (steep) { + plotAA(ypxl2, xpxl2, (1.0 - (yend - Math.floor(yend))) * xgap, color); + plotAA(ypxl2 + 1, xpxl2, (yend - Math.floor(yend)) * xgap, color); + } else { + plotAA(xpxl2, ypxl2, (1.0 - (yend - Math.floor(yend))) * xgap, color); + plotAA(xpxl2, ypxl2 + 1, (yend - Math.floor(yend)) * xgap, color); + } + + // Main loop + if (steep) { + for (int x = xpxl1 + 1; x < xpxl2; x++) { + int iy = (int) Math.floor(intery); + double fpart = intery - iy; + plotAA(iy, x, 1.0 - fpart, color); + plotAA(iy + 1, x, fpart, color); + intery += gradient; + } + } else { + for (int x = xpxl1 + 1; x < xpxl2; x++) { + int iy = (int) Math.floor(intery); + double fpart = intery - iy; + plotAA(x, iy, 1.0 - fpart, color); + plotAA(x, iy + 1, fpart, color); + intery += gradient; + } + } + hasContent = true; + updateCount++; + } + + private void plotAA(int x, int y, double brightness, int color) { + if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight || brightness <= 0.0) return; + if (brightness > 1.0) brightness = 1.0; + int sa = (color >>> 24) & 0xFF; + if (sa == 0) sa = 0xFF; + int alpha = (int) Math.round(sa * brightness); + if (alpha <= 0) return; + + int sr = (color >>> 16) & 0xFF; + int sg = (color >>> 8) & 0xFF; + int sb = color & 0xFF; + + int idx = y * canvasWidth + x; + int dst = rgbBuffer[idx]; + int da = (dst >>> 24) & 0xFF; + if (da == 0) { + rgbBuffer[idx] = (alpha << 24) | (sr << 16) | (sg << 8) | sb; + } else { + int dr = (dst >>> 16) & 0xFF; + int dg = (dst >>> 8) & 0xFF; + int db = dst & 0xFF; + int invA = 255 - alpha; + int outR = (sr * alpha + dr * invA) / 255; + int outG = (sg * alpha + dg * invA) / 255; + int outB = (sb * alpha + db * invA) / 255; + int outA = Math.min(255, da + alpha); + rgbBuffer[idx] = (outA << 24) | (outR << 16) | (outG << 8) | outB; + } + } + private void drawStyledLine(int x1, int y1, int x2, int y2, int colorArgb, int lineType, int lineWidth) { int color = (colorArgb != 0) ? colorArgb : GocaConstants.GOCA_COLORS[0]; int thickness = (lineWidth == GocaConstants.LW_THICK) ? 2 : 1; @@ -1231,56 +1419,46 @@ public class GraphicsPlane { double tanShear = Math.tan(Math.toRadians(shearAngle)); if (ch < 6.0) { - BufferedImage img = toBufferedImage(); - if (img != null) { - Graphics2D g = img.createGraphics(); - g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); - float strokeW = (float) Math.max(0.5, Math.min(0.75, ch / 5.0)); - g.setStroke(new BasicStroke(strokeW, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); - g.setColor(new Color(color, true)); + int ptr = offset; + while (ptr < VectorSymbolData.vss_data.length && VectorSymbolData.vss_data[ptr] != VectorSymbolData.END_DEFAULT) { + int order = VectorSymbolData.vss_data[ptr] & 0xFF; + if (order == 0xC1) { + int byteLen = VectorSymbolData.vss_data[ptr + 1] & 0xFF; + int numPoints = byteLen / 4; + int dataPtr = ptr + 2; - int ptr = offset; - while (ptr < VectorSymbolData.vss_data.length && VectorSymbolData.vss_data[ptr] != VectorSymbolData.END_DEFAULT) { - int order = VectorSymbolData.vss_data[ptr] & 0xFF; - if (order == 0xC1) { - int byteLen = VectorSymbolData.vss_data[ptr + 1] & 0xFF; - int numPoints = byteLen / 4; - int dataPtr = ptr + 2; + if (numPoints >= 2) { + double[] px = new double[numPoints]; + double[] py = new double[numPoints]; + for (int p = 0; p < numPoints; p++) { + int vx = ((VectorSymbolData.vss_data[dataPtr + p * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 1] & 0xFF); + int vy = ((VectorSymbolData.vss_data[dataPtr + p * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 3] & 0xFF); - if (numPoints >= 2) { - Path2D.Double path = new Path2D.Double(); - for (int p = 0; p < numPoints; p++) { - int vx = ((VectorSymbolData.vss_data[dataPtr + p * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 1] & 0xFF); - int vy = ((VectorSymbolData.vss_data[dataPtr + p * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 3] & 0xFF); + double nx = ((double) vx / VectorSymbolData.VSS_WIDTH) * cw; + double ny = -((double) vy / VectorSymbolData.VSS_HEIGHT) * ch; - double nx = ((double) vx / VectorSymbolData.VSS_WIDTH) * cw; - double ny = -((double) vy / VectorSymbolData.VSS_HEIGHT) * ch; + double sx = nx - ny * tanShear; + double sy = ny; - double sx = nx - ny * tanShear; - double sy = ny; + double rx = (angle != 0.0) ? (sx * cosA - sy * sinA) : sx; + double ry = (angle != 0.0) ? (sx * sinA + sy * cosA) : sy; - double rx = (angle != 0.0) ? (sx * cosA - sy * sinA) : sx; - double ry = (angle != 0.0) ? (sx * sinA + sy * cosA) : sy; + px[p] = x + rx; + py[p] = y + ry; + } - double px = x + rx; - double py = y + ry; - - if (p == 0) path.moveTo(px, py); - else path.lineTo(px, py); - } - g.draw(path); + for (int p = 0; p < numPoints - 1; p++) { + drawLineAA(px[p], py[p], px[p + 1], py[p + 1], color); } - ptr += 2 + byteLen; - } else { - ptr++; } + ptr += 2 + byteLen; + } else { + ptr++; } - g.dispose(); - hasContent = true; - updateCount++; - return; } + hasContent = true; + updateCount++; + return; } int ptr = offset; diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODBitImage.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODBitImage.java index 5a689a7..8278728 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODBitImage.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODBitImage.java @@ -1,30 +1,24 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Component; -import java.awt.Dimension; -import java.awt.Image; -import java.awt.Toolkit; -import java.awt.image.FilteredImageSource; -import java.awt.image.MemoryImageSource; - /** * Bitmap image container matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODBitImage). + * Pure Java software implementation completely decoupled from java.awt. */ public class HODBitImage { - protected Component vComponent; + protected Object vComponent; protected Dimension iSize = new Dimension(); protected Dimension iScaledSize = new Dimension(); protected int iDepth; protected int iScanLength; protected boolean _iUseGraphicColors; protected byte[] iScaledImageData; - protected Image[] hImage; - protected Image[] iScaledImage; + protected PixelBuffer[] hImage; + protected PixelBuffer[] iScaledImage; protected int transparentBG = 0; protected byte[] hImageData; protected int iBaseColor; - public HODBitImage(Component comp, int width, int height, byte[] data, int baseColor, int depth, boolean useGraphicColors) { + public HODBitImage(Object comp, int width, int height, byte[] data, int baseColor, int depth, boolean useGraphicColors) { this.vComponent = comp; this.iSize.width = width; this.iSize.height = height; @@ -32,8 +26,8 @@ public class HODBitImage { this.iBaseColor = baseColor; this.iDepth = depth; this._iUseGraphicColors = useGraphicColors; - this.hImage = new Image[depth == 1 ? 17 : 1]; - this.iScaledImage = new Image[depth == 1 ? 17 : 1]; + this.hImage = new PixelBuffer[depth == 1 ? 17 : 1]; + this.iScaledImage = new PixelBuffer[depth == 1 ? 17 : 1]; this.buildHODImage(); } @@ -72,11 +66,19 @@ public class HODBitImage { return nArray; } - public Image getHODImage(int colorIdx) { - return this.getHODImage(this.iSize.width, this.iSize.height, colorIdx); + public Object getHODImage(int colorIdx) { + return this.getHODPixelBuffer(this.iSize.width, this.iSize.height, colorIdx); } - public Image getHODImage(int w, int h, int colorIdx) { + public Object getHODImage(int w, int h, int colorIdx) { + return this.getHODPixelBuffer(w, h, colorIdx); + } + + public PixelBuffer getHODPixelBuffer(int colorIdx) { + return this.getHODPixelBuffer(this.iSize.width, this.iSize.height, colorIdx); + } + + public PixelBuffer getHODPixelBuffer(int w, int h, int colorIdx) { if (w <= 0 || h <= 0) return null; boolean diffColor = this.iBaseColor != colorIdx && this.iDepth == 1; boolean matchesBase = this.iSize.width == w && this.iSize.height == h; @@ -88,13 +90,18 @@ public class HODBitImage { this.iScaledSize.height = h; this.scaleHODImage(); } - Image image = matchesBase ? this.hImage[colorIdx] : this.iScaledImage[colorIdx]; + PixelBuffer image = matchesBase ? this.hImage[colorIdx] : this.iScaledImage[colorIdx]; if (image == null) { HODColorChangeFilter filter = new HODColorChangeFilter(this.getHODColor(colorIdx)); - Image base = matchesBase ? this.hImage[this.iBaseColor] : this.iScaledImage[this.iBaseColor]; + PixelBuffer base = matchesBase ? this.hImage[this.iBaseColor] : this.iScaledImage[this.iBaseColor]; if (base != null) { - FilteredImageSource source = new FilteredImageSource(base.getSource(), filter); - image = Toolkit.getDefaultToolkit().createImage(source); + int bw = base.getWidth(); + int bh = base.getHeight(); + int[] basePixels = base.getPixels(); + int[] filtered = new int[bw * bh]; + System.arraycopy(basePixels, 0, filtered, 0, filtered.length); + filter.apply(filtered, 0, filtered.length); + image = new DefaultPixelBuffer(bw, bh, filtered); if (matchesBase) { this.hImage[colorIdx] = image; } else { @@ -121,27 +128,47 @@ public class HODBitImage { private void buildHODImage() { if (this.iSize.width <= 0 || this.iSize.height <= 0) return; int[] pixels = getHODImageData(this.iBaseColor); - MemoryImageSource mis = new MemoryImageSource(this.iSize.width, this.iSize.height, pixels, 0, this.iSize.width); - Image img = Toolkit.getDefaultToolkit().createImage(mis); + PixelBuffer buf = new DefaultPixelBuffer(this.iSize.width, this.iSize.height, pixels); if (this.iDepth == 1) { - this.hImage[this.iBaseColor] = img; + this.hImage[this.iBaseColor] = buf; } else { - this.hImage[0] = img; + this.hImage[0] = buf; } } private void scaleHODImage() { - if (this.iScaledSize.width <= 0 || this.iScaledSize.height <= 0) return; - Image base = this.hImage[this.iDepth == 1 ? this.iBaseColor : 0]; + int sw = this.iScaledSize.width; + int sh = this.iScaledSize.height; + if (sw <= 0 || sh <= 0) return; + PixelBuffer base = this.hImage[this.iDepth == 1 ? this.iBaseColor : 0]; if (base != null) { - this.iScaledImage[this.iDepth == 1 ? this.iBaseColor : 0] = - base.getScaledInstance(this.iScaledSize.width, this.iScaledSize.height, Image.SCALE_FAST); + int bw = base.getWidth(); + int bh = base.getHeight(); + int[] src = base.getPixels(); + int[] dst = new int[sw * sh]; + for (int dy = 0; dy < sh; dy++) { + int sy = dy * bh / sh; + int srcOffset = sy * bw; + int dstOffset = dy * sw; + for (int dx = 0; dx < sw; dx++) { + int sx = dx * bw / sw; + dst[dstOffset + dx] = src[srcOffset + sx]; + } + } + this.iScaledImage[this.iDepth == 1 ? this.iBaseColor : 0] = new DefaultPixelBuffer(sw, sh, dst); } } private int getHODColor(int idx) { if (idx == 0 && this.vComponent != null) { - return this.vComponent.getBackground().getRGB(); + try { + java.lang.reflect.Method m = this.vComponent.getClass().getMethod("getBackground"); + Object bg = m.invoke(this.vComponent); + if (bg != null) { + java.lang.reflect.Method mRgb = bg.getClass().getMethod("getRGB"); + return ((Number) mRgb.invoke(bg)).intValue(); + } + } catch (Exception ignored) {} } return GocaConstants.getGocaColorArgb(idx); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODBounds.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODBounds.java index 8d9df0e..50207aa 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODBounds.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODBounds.java @@ -1,7 +1,5 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Point; -import java.awt.Rectangle; /** * Bounding box encapsulation matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODBounds). diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODColorChangeFilter.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODColorChangeFilter.java index 9857e39..97867a3 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODColorChangeFilter.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODColorChangeFilter.java @@ -1,19 +1,17 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Color; -import java.awt.image.RGBImageFilter; - /** * Image filter that replaces occurrences of one color with another color. * Conforms to IBM Host On-Demand HODColorChangeFilter. + * Platform-neutral implementation independent of java.awt. */ -public class HODColorChangeFilter extends RGBImageFilter { +public class HODColorChangeFilter { + protected boolean canFilterIndexColorModel = true; private int oldRgb; private int newRgb; public HODColorChangeFilter(int newRgb) { - this.canFilterIndexColorModel = true; this.oldRgb = -1; this.newRgb = newRgb | 0xFF000000; } @@ -23,7 +21,6 @@ public class HODColorChangeFilter extends RGBImageFilter { } public HODColorChangeFilter(int oldRgb, int newRgb) { - this.canFilterIndexColorModel = true; this.oldRgb = oldRgb & 0x00FFFFFF; this.newRgb = newRgb; } @@ -48,7 +45,6 @@ public class HODColorChangeFilter extends RGBImageFilter { this.newRgb = newRgb; } - @Override public int filterRGB(int x, int y, int rgb) { if (oldRgb == -1) { if ((rgb & 0xFF000000) != 0) { @@ -61,4 +57,20 @@ public class HODColorChangeFilter extends RGBImageFilter { } return rgb; } + + public void apply(int[] pixels, int offset, int length) { + if (pixels == null) return; + int end = Math.min(pixels.length, offset + length); + for (int i = offset; i < end; i++) { + pixels[i] = filterRGB(0, 0, pixels[i]); + } + } + + public void apply(PixelBuffer buffer) { + if (buffer == null) return; + int[] pixels = buffer.getPixels(); + if (pixels != null) { + apply(pixels, 0, pixels.length); + } + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODGraphicsPlane.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODGraphicsPlane.java index d50949b..e34a968 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODGraphicsPlane.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODGraphicsPlane.java @@ -1,16 +1,8 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Color; -import java.awt.Dimension; -import java.awt.Graphics; -import java.awt.Image; -import java.awt.Point; -import java.awt.Polygon; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; - /** * Headless graphics plane facade matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODGraphicsPlane). + * Pure Java software implementation completely decoupled from java.awt. */ public class HODGraphicsPlane { private final GraphicsPlane delegate; @@ -40,6 +32,10 @@ public class HODGraphicsPlane { return delegate; } + public PixelBuffer getPixelBuffer() { + return delegate; + } + public void resize(Dimension dim, boolean keepContent) { if (dim != null) { delegate.setDimensions(dim.width, dim.height); @@ -52,15 +48,19 @@ public class HODGraphicsPlane { this.bounds.set(0, 0, delegate.getCanvasWidth(), delegate.getCanvasHeight()); } - public Graphics getHODGraphics() { - return delegate.getGraphics(); + public Object getHODGraphics() { + return delegate; } - public Image getHODImage() { - return delegate.getImage(); + public Object getHODImage() { + return delegate; } - public void setHODTemporaryGraphics(Graphics g) { + public PixelBuffer getHODPixelBuffer() { + return delegate; + } + + public void setHODTemporaryGraphics(Object g) { // No-op or temporary override } @@ -116,35 +116,32 @@ public class HODGraphicsPlane { } public void drawHODArc(int x, int y, int width, int height, int startAngle, int arcAngle) { - Graphics g = delegate.getGraphics(); - if (g != null) { - g.setColor(currentColor); - g.drawArc(x, y, width, height, startAngle, arcAngle); - updateHODBounds(x, y); - updateHODBounds(x + width, y + height); - } + double rx = width / 2.0; + double ry = height / 2.0; + double cx = x + rx; + double cy = y + ry; + delegate.drawArc(cx, cy, rx, ry, startAngle, arcAngle, currentColor.getRGB(), currentLineType, currentLineWidth, false); + updateHODBounds(x, y); + updateHODBounds(x + width, y + height); } public void fillHODArc(int x, int y, int width, int height, int startAngle, int arcAngle) { - Graphics g = delegate.getGraphics(); - if (g != null) { - g.setColor(currentColor); - g.fillArc(x, y, width, height, startAngle, arcAngle); - updateHODBounds(x, y); - updateHODBounds(x + width, y + height); - } + double rx = width / 2.0; + double ry = height / 2.0; + double cx = x + rx; + double cy = y + ry; + delegate.drawArc(cx, cy, rx, ry, startAngle, arcAngle, currentColor.getRGB(), currentLineType, currentLineWidth, true); + updateHODBounds(x, y); + updateHODBounds(x + width, y + height); } public void drawHODImage(HODBitImage bitImage, int x, int y, int w, int h) { if (bitImage != null) { - Image img = bitImage.getHODImage(w, h, currentColorIndex); - if (img != null) { - Graphics g = delegate.getGraphics(); - if (g != null) { - g.drawImage(img, x, y, null); - updateHODBounds(x, y); - updateHODBounds(x + w, y + h); - } + PixelBuffer buf = bitImage.getHODPixelBuffer(w, h, currentColorIndex); + if (buf != null) { + delegate.blit(buf.getPixels(), 0, 0, buf.getWidth(), buf.getHeight(), x, y); + updateHODBounds(x, y); + updateHODBounds(x + w, y + h); } } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODPart.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODPart.java index d170e74..fb9d4af 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODPart.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODPart.java @@ -1,20 +1,15 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Color; -import java.awt.Component; -import java.awt.Dimension; -import java.awt.Font; -import java.awt.Graphics; -import java.awt.Insets; -import java.awt.Rectangle; import java.io.Serializable; +import java.lang.reflect.Method; /** * Visual part container matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODPart). + * Completely decoupled from java.awt. */ public class HODPart extends Rectangle implements Serializable { - protected Component hodParent; - protected Font _hodFont; + protected Object hodParent; + protected Object _hodFont; protected Color foregroundColor; protected Color backgroundColor; protected Boolean isTransparent; @@ -22,33 +17,36 @@ public class HODPart extends Rectangle implements Serializable { protected HODPart() {} - public HODPart(Component component) { + public HODPart(Object component) { this(); this.setHODParent(component); } - public HODPart(Component component, Dimension dimension) { - super(dimension); + public HODPart(Object component, Dimension dimension) { + super(0, 0, dimension != null ? dimension.width : 0, dimension != null ? dimension.height : 0); this.setHODParent(component); } - public HODPart(Component component, Rectangle rectangle) { - super(rectangle); + public HODPart(Object component, Rectangle rectangle) { + super(rectangle != null ? rectangle.x : 0, rectangle != null ? rectangle.y : 0, + rectangle != null ? rectangle.width : 0, rectangle != null ? rectangle.height : 0); this.setHODParent(component); } public HODPart(HODPart hODPart) { - this(hODPart.getHODParent(), hODPart.getSize()); - this.setHODBackground(hODPart.getHODBackground()); - this.setHODForeground(hODPart.getHODForeground()); - this.setHODFont(hODPart.getHODFont()); + this(hODPart != null ? hODPart.getHODParent() : null, hODPart != null ? hODPart.getSize() : null); + if (hODPart != null) { + this.setHODBackground(hODPart.getHODBackground()); + this.setHODForeground(hODPart.getHODForeground()); + this.setHODFont(hODPart.getHODFont()); + } } - public Component getHODParent() { + public Object getHODParent() { return this.hodParent; } - public void setHODParent(Component component) { + public void setHODParent(Object component) { if (component != null && !component.equals(this.hodParent)) { this.hodParent = component; } @@ -56,18 +54,28 @@ public class HODPart extends Rectangle implements Serializable { public void repaint() { if (this.hodParent != null) { - this.hodParent.repaint(this.x, this.y, this.width, this.height); + try { + Method m = this.hodParent.getClass().getMethod("repaint", int.class, int.class, int.class, int.class); + m.invoke(this.hodParent, this.x, this.y, this.width, this.height); + } catch (Throwable ignored) {} } } - public void paint(Component component, Graphics graphics, int x, int y, int w, int h) { + public void paint(Object component, Object graphics, int x, int y, int w, int h) { this.setBounds(x, y, w, h); if (Boolean.TRUE.equals(this._visible)) { this.paintHODView(graphics); } } - protected void paintHODView(Graphics graphics) {} + public void paint(PixelBuffer buffer, int x, int y, int w, int h) { + this.setBounds(x, y, w, h); + if (Boolean.TRUE.equals(this._visible)) { + this.paintHODView(buffer); + } + } + + protected void paintHODView(Object graphics) {} public Color getHODBackground() { return this.backgroundColor; @@ -85,11 +93,11 @@ public class HODPart extends Rectangle implements Serializable { this.foregroundColor = color; } - public Font getHODFont() { + public Object getHODFont() { return this._hodFont; } - public void setHODFont(Font font) { + public void setHODFont(Object font) { this._hodFont = font; } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODProgramSymbolManager.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODProgramSymbolManager.java index c7a4cea..76477ed 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODProgramSymbolManager.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODProgramSymbolManager.java @@ -1,12 +1,10 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Dimension; -import java.awt.Graphics; -import java.awt.Point; -import java.awt.image.BufferedImage; +import java.lang.reflect.Method; /** * Programmed Symbol Set manager facade matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODProgramSymbolManager). + * Completely decoupled from java.awt. */ public class HODProgramSymbolManager { public static final int MAX_HOD_SLOT = 254; @@ -51,14 +49,57 @@ public class HODProgramSymbolManager { delegate.loadProgrammedSymbolSet(bytes, 0, bytes.length); } - public void drawHODImageCharacter(Graphics g, int lcid, int codepoint, Point pt, int colorIdx, int cellW, int cellH) { - if (g == null || pt == null) return; + public void drawHODImageCharacter(PixelBuffer pb, int lcid, int codepoint, Point pt, int colorIdx, int cellW, int cellH) { + if (pb == null || pt == null) return; ProgramSymbolSet.SymbolSlot slot = delegate.getSymbol(lcid, codepoint); if (slot != null) { int fg = GocaConstants.getGocaColorArgb(colorIdx); - BufferedImage img = slot.getScaledImage(cellW, cellH, fg, 0); - if (img != null) { - g.drawImage(img, pt.x, pt.y, null); + PixelBuffer glyph = slot.getScaledPixelBuffer(cellW, cellH, fg, 0); + if (glyph != null) { + int sw = glyph.getWidth(); + int sh = glyph.getHeight(); + int[] srcPx = glyph.getPixels(); + int[] dstPx = pb.getPixels(); + int pw = pb.getWidth(); + int ph = pb.getHeight(); + for (int r = 0; r < sh; r++) { + int dy = pt.y + r; + if (dy < 0 || dy >= ph) continue; + for (int c = 0; c < sw; c++) { + int dx = pt.x + c; + if (dx < 0 || dx >= pw) continue; + int p = srcPx[r * sw + c]; + if ((p & 0xFF000000) != 0) { + dstPx[dy * pw + dx] = p; + } + } + } + } + } + } + + public void drawHODImageCharacter(Object g, int lcid, int codepoint, Point pt, int colorIdx, int cellW, int cellH) { + if (g == null || pt == null) return; + if (g instanceof PixelBuffer) { + drawHODImageCharacter((PixelBuffer) g, lcid, codepoint, pt, colorIdx, cellW, cellH); + return; + } + // Fallback for AWT Graphics if passed reflectively + ProgramSymbolSet.SymbolSlot slot = delegate.getSymbol(lcid, codepoint); + if (slot != null) { + int fg = GocaConstants.getGocaColorArgb(colorIdx); + PixelBuffer glyph = slot.getScaledPixelBuffer(cellW, cellH, fg, 0); + if (glyph != null) { + try { + Class biClass = Class.forName("java.awt.image.BufferedImage"); + Object bi = biClass.getConstructor(int.class, int.class, int.class) + .newInstance(glyph.getWidth(), glyph.getHeight(), 2); // TYPE_INT_ARGB + Method setRGB = biClass.getMethod("setRGB", int.class, int.class, int.class, int.class, int[].class, int.class, int.class); + setRGB.invoke(bi, 0, 0, glyph.getWidth(), glyph.getHeight(), glyph.getPixels(), 0, glyph.getWidth()); + + Method drawImg = g.getClass().getMethod("drawImage", Class.forName("java.awt.Image"), int.class, int.class, Class.forName("java.awt.image.ImageObserver")); + drawImg.invoke(g, bi, pt.x, pt.y, null); + } catch (Throwable ignored) {} } } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODTransform.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODTransform.java index b0beead..b796a52 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODTransform.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODTransform.java @@ -1,7 +1,5 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Point; -import java.awt.Rectangle; /** * Coordinate transform adapter matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODTransform). diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODTransparentColorFilter.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODTransparentColorFilter.java index e26b11d..07a5585 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODTransparentColorFilter.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODTransparentColorFilter.java @@ -1,18 +1,16 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Color; -import java.awt.image.RGBImageFilter; - /** * Image filter that keys out a specific background color by setting its alpha to 0x00. * Conforms to IBM Host On-Demand HODTransparentColorFilter. + * Platform-neutral implementation independent of java.awt. */ -public class HODTransparentColorFilter extends RGBImageFilter { +public class HODTransparentColorFilter { + protected boolean canFilterIndexColorModel = true; private int transparentRgb; public HODTransparentColorFilter(int rgb) { - this.canFilterIndexColorModel = true; this.transparentRgb = rgb & 0x00FFFFFF; } @@ -28,11 +26,26 @@ public class HODTransparentColorFilter extends RGBImageFilter { this.transparentRgb = rgb & 0x00FFFFFF; } - @Override public int filterRGB(int x, int y, int rgb) { if ((rgb & 0x00FFFFFF) == transparentRgb) { return 0x00000000; } return rgb; } + + public void apply(int[] pixels, int offset, int length) { + if (pixels == null) return; + int end = Math.min(pixels.length, offset + length); + for (int i = offset; i < end; i++) { + pixels[i] = filterRGB(0, 0, pixels[i]); + } + } + + public void apply(PixelBuffer buffer) { + if (buffer == null) return; + int[] pixels = buffer.getPixels(); + if (pixels != null) { + apply(pixels, 0, pixels.length); + } + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODWallpaper.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODWallpaper.java index 7194aa4..501bacc 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODWallpaper.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODWallpaper.java @@ -1,15 +1,10 @@ package haus.nightmare.lib3270j.graphics; -import java.awt.Color; -import java.awt.Component; -import java.awt.Dimension; -import java.awt.Graphics; -import java.awt.Image; -import java.awt.Insets; -import java.awt.image.BufferedImage; +import java.lang.reflect.Method; /** * Wallpaper background manager matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODWallpaper). + * Completely decoupled from java.awt. */ public class HODWallpaper extends HODPart { public static final int HOD_TILE = 0; @@ -17,8 +12,8 @@ public class HODWallpaper extends HODPart { public static final int HOD_STRETCH = 2; private int _display = HOD_CENTER; - private Image rawImage; - private Image backgroundImage; + private Object rawImage; + private Object backgroundImage; public HODWallpaper() { this(HOD_CENTER); @@ -28,7 +23,7 @@ public class HODWallpaper extends HODPart { this.setDisplay(displayMode); } - public HODWallpaper(Image image, int displayMode) { + public HODWallpaper(Object image, int displayMode) { this(displayMode); this.setImage(image); } @@ -46,30 +41,40 @@ public class HODWallpaper extends HODPart { return this._display; } - public void setImage(Image image) { + public void setImage(Object image) { this.rawImage = image; this.backgroundImage = null; this.repaint(); } - public Image getHODImage() { + public Object getHODImage() { return this.rawImage; } @Override - public void paint(Component component, Graphics graphics, int x, int y, int w, int h) { + public void paint(Object component, Object graphics, int x, int y, int w, int h) { this.setBounds(x, y, w, h); this.setHODParent(component); super.paint(component, graphics, x, y, w, h); } @Override - protected void paintHODView(Graphics graphics) { - Image image = this.getHODImage(); - int display = this.getDisplay(); - Component component = this.getHODParent(); + public void paint(PixelBuffer buffer, int x, int y, int w, int h) { + this.setBounds(x, y, w, h); + super.paint(buffer, x, y, w, h); + } - if (image == null || component == null) { + @Override + protected void paintHODView(Object graphics) { + Object image = this.getHODImage(); + int display = this.getDisplay(); + + if (image == null || graphics == null) { + return; + } + + if (graphics instanceof PixelBuffer && image instanceof PixelBuffer) { + paintHODViewBuffer((PixelBuffer) graphics, (PixelBuffer) image); return; } @@ -82,7 +87,83 @@ public class HODWallpaper extends HODPart { } } - protected void hodtileImage(Graphics graphics, Image image) { + public void paintHODViewBuffer(PixelBuffer dst, PixelBuffer src) { + int display = this.getDisplay(); + Insets insets = this.getInsets(); + int availX = this.x + insets.left; + int availY = this.y + insets.top; + int availW = this.width - (insets.left + insets.right); + int availH = this.height - (insets.top + insets.bottom); + if (availW <= 0 || availH <= 0 || src.getWidth() <= 0 || src.getHeight() <= 0) return; + + if (display == HOD_CENTER) { + int cx = availX + (availW - src.getWidth()) / 2; + int cy = availY + (availH - src.getHeight()) / 2; + blitBuffer(dst, src, cx, cy); + } else if (display == HOD_TILE) { + int cols = (availW / src.getWidth()) + 1; + int rows = (availH / src.getHeight()) + 1; + int curX = availX; + for (int i = 0; i < cols; i++) { + int curY = availY; + for (int j = 0; j < rows; j++) { + blitBuffer(dst, src, curX, curY); + curY += src.getHeight(); + } + curX += src.getWidth(); + } + } else if (display == HOD_STRETCH) { + scaleBuffer(dst, src, availX, availY, availW, availH); + } + } + + private void blitBuffer(PixelBuffer dst, PixelBuffer src, int dstX, int dstY) { + int sw = src.getWidth(); + int sh = src.getHeight(); + int dw = dst.getWidth(); + int dh = dst.getHeight(); + int[] srcPx = src.getPixels(); + int[] dstPx = dst.getPixels(); + + for (int r = 0; r < sh; r++) { + int dy = dstY + r; + if (dy < 0 || dy >= dh) continue; + for (int c = 0; c < sw; c++) { + int dx = dstX + c; + if (dx < 0 || dx >= dw) continue; + int p = srcPx[r * sw + c]; + if ((p & 0xFF000000) != 0) { + dstPx[dy * dw + dx] = p; + } + } + } + } + + private void scaleBuffer(PixelBuffer dst, PixelBuffer src, int dx, int dy, int dw, int dh) { + int sw = src.getWidth(); + int sh = src.getHeight(); + int targetW = dst.getWidth(); + int targetH = dst.getHeight(); + int[] srcPx = src.getPixels(); + int[] dstPx = dst.getPixels(); + + for (int r = 0; r < dh; r++) { + int outY = dy + r; + if (outY < 0 || outY >= targetH) continue; + int sy = r * sh / dh; + for (int c = 0; c < dw; c++) { + int outX = dx + c; + if (outX < 0 || outX >= targetW) continue; + int sx = c * sw / dw; + int p = srcPx[sy * sw + sx]; + if ((p & 0xFF000000) != 0) { + dstPx[outY * targetW + outX] = p; + } + } + } + } + + protected void hodtileImage(Object graphics, Object image) { Dimension imgSize = getImageSize(image); if (imgSize.width <= 0 || imgSize.height <= 0) return; @@ -99,40 +180,86 @@ public class HODWallpaper extends HODPart { for (int i = 0; i < cols; i++) { int curY = startY; for (int j = 0; j < rows; j++) { - graphics.drawImage(image, curX, curY, this.getHODParent()); + invokeDrawImage(graphics, image, curX, curY); curY += imgSize.height; } curX += imgSize.width; } } - protected void centerHODImage(Graphics graphics, Image image) { + protected void centerHODImage(Object graphics, Object image) { Dimension imgSize = getImageSize(image); if (imgSize.width <= 0 || imgSize.height <= 0) return; Insets insets = this.getInsets(); int cx = this.x + insets.left + (this.width - imgSize.width) / 2; int cy = this.y + insets.top + (this.height - imgSize.height) / 2; - graphics.drawImage(image, cx, cy, this.getHODParent()); + invokeDrawImage(graphics, image, cx, cy); } - protected void stretchHODImage(Graphics graphics, Image image) { + protected void stretchHODImage(Object graphics, Object image) { Insets insets = this.getInsets(); int sx = this.x + insets.left; int sy = this.y + insets.top; int sw = this.width - (insets.left + insets.right); int sh = this.height - (insets.top + insets.bottom); - graphics.drawImage(image, sx, sy, sw, sh, this.getHODParent()); + invokeDrawImage(graphics, image, sx, sy, sw, sh); } - private Dimension getImageSize(Image image) { + private void invokeDrawImage(Object graphics, Object img, int x, int y) { + if (graphics == null || img == null) return; + try { + for (Method m : graphics.getClass().getMethods()) { + if (m.getName().equals("drawImage")) { + Class[] pts = m.getParameterTypes(); + if (pts.length == 4 && pts[1] == int.class && pts[2] == int.class) { + m.invoke(graphics, img, x, y, this.getHODParent()); + return; + } + } + } + } catch (Throwable ignored) {} + } + + private void invokeDrawImage(Object graphics, Object img, int x, int y, int w, int h) { + if (graphics == null || img == null) return; + try { + for (Method m : graphics.getClass().getMethods()) { + if (m.getName().equals("drawImage")) { + Class[] pts = m.getParameterTypes(); + if (pts.length == 6 && pts[1] == int.class && pts[2] == int.class && pts[3] == int.class && pts[4] == int.class) { + m.invoke(graphics, img, x, y, w, h, this.getHODParent()); + return; + } + } + } + } catch (Throwable ignored) {} + } + + private Dimension getImageSize(Object image) { if (image == null) return new Dimension(0, 0); - Component c = this.getHODParent(); - int w = image.getWidth(c); - int h = image.getHeight(c); - if (w <= 0 && image instanceof BufferedImage) { - w = ((BufferedImage) image).getWidth(); - h = ((BufferedImage) image).getHeight(); + if (image instanceof PixelBuffer) { + PixelBuffer pb = (PixelBuffer) image; + return new Dimension(pb.getWidth(), pb.getHeight()); + } + int w = 0; + int h = 0; + try { + Method mw = image.getClass().getMethod("getWidth"); + w = ((Number) mw.invoke(image)).intValue(); + Method mh = image.getClass().getMethod("getHeight"); + h = ((Number) mh.invoke(image)).intValue(); + } catch (Throwable ignored) { + try { + for (Method m : image.getClass().getMethods()) { + if (m.getName().equals("getWidth") && m.getParameterCount() == 1) { + w = ((Number) m.invoke(image, this.getHODParent())).intValue(); + } + if (m.getName().equals("getHeight") && m.getParameterCount() == 1) { + h = ((Number) m.invoke(image, this.getHODParent())).intValue(); + } + } + } catch (Throwable ignored2) {} } return new Dimension(Math.max(0, w), Math.max(0, h)); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Insets.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Insets.java new file mode 100644 index 0000000..1f51eeb --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Insets.java @@ -0,0 +1,42 @@ +package haus.nightmare.lib3270j.graphics; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Lightweight pure-Java POJO representing border insets. + * Completely decouples lib3270j from java.awt.Insets. + */ +public class Insets implements Serializable { + private static final long serialVersionUID = 1L; + + public int top; + public int left; + public int bottom; + public int right; + + public Insets(int top, int left, int bottom, int right) { + this.top = top; + this.left = left; + this.bottom = bottom; + this.right = right; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Insets)) return false; + Insets i = (Insets) obj; + return top == i.top && left == i.left && bottom == i.bottom && right == i.right; + } + + @Override + public int hashCode() { + return Objects.hash(top, left, bottom, right); + } + + @Override + public String toString() { + return getClass().getName() + "[top=" + top + ",left=" + left + ",bottom=" + bottom + ",right=" + right + "]"; + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/PixelBuffer.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/PixelBuffer.java new file mode 100644 index 0000000..73aa2f8 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/PixelBuffer.java @@ -0,0 +1,59 @@ +package haus.nightmare.lib3270j.graphics; + +/** + * Platform-neutral pixel buffer interface for 32-bit ARGB offscreen rasterization. + * Compatible with pure Java SE (Swing), Android (Bitmap), and headless environments. + */ +public interface PixelBuffer { + + /** Returns width of the pixel buffer in pixels. */ + int getWidth(); + + /** Returns height of the pixel buffer in pixels. */ + int getHeight(); + + /** Returns contiguous 32-bit ARGB pixel array. */ + int[] getPixels(); + + /** Returns ARGB pixel at specified coordinate, or 0 if out of bounds. */ + int getPixel(int x, int y); + + /** Sets ARGB pixel at specified coordinate with alpha blending and mix mode. */ + void setPixel(int x, int y, int argb); + + /** Sets ARGB pixel directly without blending. */ + void setPixelDirect(int x, int y, int argb); + + /** Clears buffer to fully transparent (0x00000000). */ + void clear(); + + /** Clears buffer to specified ARGB color. */ + void clear(int argb); + + /** Draws a 1-pixel line using integer Bresenham algorithm. */ + void drawLine(int x1, int y1, int x2, int y2, int argb); + + /** Draws a 1-pixel line using Bresenham algorithm. */ + void drawLineBresenham(int x0, int y0, int x1, int y1, int argb); + + /** Draws a stroked line with line type and line width. */ + void drawLine(double x0, double y0, double x1, double y1, int argb, int lineType, int lineWidth); + + /** Draws an anti-aliased sub-pixel line segment with stroke width. */ + void drawLineAA(double x0, double y0, double x1, double y1, int argb, double strokeWidth); + + /** Fills a rectangular region with specified ARGB color. */ + void fillRect(int x, int y, int width, int height, int argb); + + /** Sets clipping rectangle. */ + void setClip(int x, int y, int width, int height); + + /** Clears clipping rectangle. */ + void clearClip(); + + /** Checks if coordinate falls outside current clipping bounds. */ + boolean isClipped(int x, int y); + + /** Copies a rectangular block of pixels from source array into this buffer. */ + void blit(int[] srcPixels, int srcX, int srcY, int srcW, int srcH, int dstX, int dstY); +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Point.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Point.java new file mode 100644 index 0000000..54825eb --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Point.java @@ -0,0 +1,71 @@ +package haus.nightmare.lib3270j.graphics; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Lightweight pure-Java POJO point for 2D presentation coordinates. + * Completely decouples lib3270j from java.awt.Point. + */ +public class Point implements Serializable { + private static final long serialVersionUID = 1L; + + public int x; + public int y; + + public Point() { + this(0, 0); + } + + public Point(Point p) { + this(p != null ? p.x : 0, p != null ? p.y : 0); + } + + public Point(int x, int y) { + this.x = x; + this.y = y; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public void setLocation(int x, int y) { + this.x = x; + this.y = y; + } + + public void setLocation(Point p) { + if (p != null) { + this.x = p.x; + this.y = p.y; + } + } + + public void translate(int dx, int dy) { + this.x += dx; + this.y += dy; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Point)) return false; + Point pt = (Point) obj; + return (x == pt.x) && (y == pt.y); + } + + @Override + public int hashCode() { + return Objects.hash(x, y); + } + + @Override + public String toString() { + return getClass().getName() + "[x=" + x + ",y=" + y + "]"; + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolSet.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolSet.java index 0100b43..85e7b3d 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolSet.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolSet.java @@ -3,6 +3,7 @@ package haus.nightmare.lib3270j.graphics; /** * Represents a set of up to 191 custom bitmapped symbols (LCID 0x40 - 0xFE). * Supports both Single-Plane (monochrome) and Triple-Plane (7-color RGB composite). + * Decoupled from java.awt using PixelBuffer. */ public class ProgramSymbolSet { @@ -64,8 +65,8 @@ public class ProgramSymbolSet { private int[] cachedRgbArray; private int cachedFgRgb = -1; private int cachedBgRgb = -1; - private java.awt.image.BufferedImage cachedImage; - private java.awt.image.BufferedImage cachedScaledImage; + private PixelBuffer cachedPixelBuffer; + private PixelBuffer cachedScaledPixelBuffer; private int cachedTargetW = 0; private int cachedTargetH = 0; private int cachedScaledFgRgb = -1; @@ -103,23 +104,21 @@ public class ProgramSymbolSet { } /** - * Returns an image scaled directly to target dimensions (cellWidth x cellHeight). - * Enables unscaled 1:1 hardware blitting in Java2D. + * Returns a PixelBuffer scaled directly to target dimensions (cellWidth x cellHeight). */ - public synchronized java.awt.image.BufferedImage getScaledImage(int targetW, int targetH, int fgArgb, int bgArgb) { + public synchronized PixelBuffer getScaledPixelBuffer(int targetW, int targetH, int fgArgb, int bgArgb) { if (targetW <= 0 || targetH <= 0) { - return getImage(fgArgb, bgArgb); + return getPixelBuffer(fgArgb, bgArgb); } if (targetW == width && targetH == height) { - return getImage(fgArgb, bgArgb); + return getPixelBuffer(fgArgb, bgArgb); } - if (cachedScaledImage != null && cachedTargetW == targetW && cachedTargetH == targetH + if (cachedScaledPixelBuffer != null && cachedTargetW == targetW && cachedTargetH == targetH && cachedScaledFgRgb == fgArgb && cachedScaledBgRgb == bgArgb) { - return cachedScaledImage; + return cachedScaledPixelBuffer; } int[] srcRgb = getRgbPixels(fgArgb, bgArgb); - java.awt.image.BufferedImage scaled = new java.awt.image.BufferedImage(targetW, targetH, java.awt.image.BufferedImage.TYPE_INT_ARGB); - int[] dstRgb = ((java.awt.image.DataBufferInt) scaled.getRaster().getDataBuffer()).getData(); + int[] dstRgb = new int[targetW * targetH]; for (int dy = 0; dy < targetH; dy++) { int sy = dy * height / targetH; @@ -131,30 +130,36 @@ public class ProgramSymbolSet { } } - this.cachedScaledImage = scaled; + this.cachedScaledPixelBuffer = new DefaultPixelBuffer(targetW, targetH, dstRgb); this.cachedTargetW = targetW; this.cachedTargetH = targetH; this.cachedScaledFgRgb = fgArgb; this.cachedScaledBgRgb = bgArgb; - return scaled; + return this.cachedScaledPixelBuffer; + } + + public synchronized PixelBuffer getScaledImage(int targetW, int targetH, int fgArgb, int bgArgb) { + return getScaledPixelBuffer(targetW, targetH, fgArgb, bgArgb); } /** - * Computes and returns the cached BufferedImage for this symbol glyph. - * Eliminates per-cell heap allocations during high frame rate rendering. + * Computes and returns the cached PixelBuffer for this symbol glyph. */ - public synchronized java.awt.image.BufferedImage getImage(int fgArgb, int bgArgb) { - if (cachedImage != null && cachedFgRgb == fgArgb && cachedBgRgb == bgArgb) { - return cachedImage; + public synchronized PixelBuffer getPixelBuffer(int fgArgb, int bgArgb) { + if (cachedPixelBuffer != null && cachedFgRgb == fgArgb && cachedBgRgb == bgArgb) { + return cachedPixelBuffer; } int[] rgb = getRgbPixels(fgArgb, bgArgb); - java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(width, height, java.awt.image.BufferedImage.TYPE_INT_ARGB); - int[] imgData = ((java.awt.image.DataBufferInt) img.getRaster().getDataBuffer()).getData(); + int[] imgData = new int[rgb.length]; System.arraycopy(rgb, 0, imgData, 0, rgb.length); - this.cachedImage = img; + this.cachedPixelBuffer = new DefaultPixelBuffer(width, height, imgData); this.cachedFgRgb = fgArgb; this.cachedBgRgb = bgArgb; - return img; + return this.cachedPixelBuffer; + } + + public synchronized PixelBuffer getImage(int fgArgb, int bgArgb) { + return getPixelBuffer(fgArgb, bgArgb); } /** diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Rectangle.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Rectangle.java new file mode 100644 index 0000000..161aac5 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/Rectangle.java @@ -0,0 +1,171 @@ +package haus.nightmare.lib3270j.graphics; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Lightweight pure-Java POJO rectangle for 2D presentation coordinates. + * Completely decouples lib3270j from java.awt.Rectangle. + */ +public class Rectangle implements Serializable { + private static final long serialVersionUID = 1L; + + public int x; + public int y; + public int width; + public int height; + + public Rectangle() { + this(0, 0, 0, 0); + } + + public Rectangle(Rectangle r) { + this(r != null ? r.x : 0, r != null ? r.y : 0, r != null ? r.width : 0, r != null ? r.height : 0); + } + + public Rectangle(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + public Rectangle(int width, int height) { + this(0, 0, width, height); + } + + public Rectangle(Point p, Dimension d) { + this(p != null ? p.x : 0, p != null ? p.y : 0, d != null ? d.width : 0, d != null ? d.height : 0); + } + + public Rectangle(Point p) { + this(p != null ? p.x : 0, p != null ? p.y : 0, 0, 0); + } + + public Rectangle(Dimension d) { + this(0, 0, d != null ? d.width : 0, d != null ? d.height : 0); + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public void setBounds(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + public void setBounds(Rectangle r) { + if (r != null) { + setBounds(r.x, r.y, r.width, r.height); + } + } + + public Point getLocation() { + return new Point(x, y); + } + + public void setLocation(Point p) { + if (p != null) { + this.x = p.x; + this.y = p.y; + } + } + + public void setLocation(int x, int y) { + this.x = x; + this.y = y; + } + + public Dimension getSize() { + return new Dimension(width, height); + } + + public void setSize(Dimension d) { + if (d != null) { + this.width = d.width; + this.height = d.height; + } + } + + public void setSize(int width, int height) { + this.width = width; + this.height = height; + } + + public boolean contains(int X, int Y) { + int w = this.width; + int h = this.height; + if ((w | h) < 0) return false; + int x = this.x; + int y = this.y; + if (X < x || Y < y) return false; + w += x; + h += y; + return ((w < x || w > X) && (h < y || h > Y)); + } + + public boolean contains(Point p) { + return p != null && contains(p.x, p.y); + } + + public boolean intersects(Rectangle r) { + if (r == null) return false; + int tw = this.width; + int th = this.height; + int rw = r.width; + int rh = r.height; + if (rw <= 0 || rh <= 0 || tw <= 0 || th <= 0) return false; + int tx = this.x; + int ty = this.y; + int rx = r.x; + int ry = r.y; + rw += rx; + rh += ry; + tw += tx; + th += ty; + return ((rw < rx || rw > tx) && + (rh < ry || rh > ty) && + (tw < tx || tw > rx) && + (th < ty || th > ry)); + } + + public boolean isEmpty() { + return (width <= 0) || (height <= 0); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Rectangle)) return false; + Rectangle r = (Rectangle) obj; + return ((x == r.x) && + (y == r.y) && + (width == r.width) && + (height == r.height)); + } + + @Override + public int hashCode() { + return Objects.hash(x, y, width, height); + } + + @Override + public String toString() { + return getClass().getName() + "[x=" + x + ",y=" + y + ",width=" + width + ",height=" + height + "]"; + } +} 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 4d0c84b..556ffcb 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java @@ -412,6 +412,155 @@ public class InputProcessor { } } + /** + * Checks if all character locations on the given row are protected. + */ + public boolean isLineProtected(int row) { + if (screen == null || !screen.isFormatted()) return false; + int cols = screen.getCols(); + int rows = screen.getRows(); + if (row < 0 || row >= rows) return false; + int start = row * cols; + int end = start + cols; + for (int i = start; i < end; i++) { + if (screen.getCell(i).isFieldAttribute()) continue; + byte fa = screen.getFieldAttributeAt(i); + if (!faIsProtected(fa & 0xFF)) { + return false; + } + } + return true; + } + + /** + * Paste text into the presentation space. + * When enablePasteFromExcel is true: + * - '\t' triggers a Tab action advancing to the next unprotected input field (SBA). + * - '\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) { + if (text == null || text.isEmpty() || screen == null || keyboardLocked) { + return 0; + } + + if (isNvtMode()) { + try { + fsm.sendNVTString(text); + return text.length(); + } catch (IOException e) { + log.warning("Failed to send NVT paste: " + e.getMessage()); + return 0; + } + } + + int count = 0; + int len = text.length(); + int i = 0; + + while (i < len && !keyboardLocked) { + char ch = text.charAt(i); + + // Handle newline sequences: \r\n, \r, or \n + if (ch == '\r' || ch == '\n') { + if (ch == '\r' && (i + 1) < len && text.charAt(i + 1) == '\n') { + i++; // skip \n of \r\n + } + if (enablePasteFromExcel) { + int curRow = screen.getCursorRow(); + int nextRow = (curRow + 1) % screen.getRows(); + if (pasteStopAtProtectedLine && isLineProtected(nextRow)) { + break; // Stop paste when next line is protected + } + newline(); + } + i++; + continue; + } + + // Handle tab character + if (ch == '\t') { + if (enablePasteFromExcel) { + tab(); + int newAddr = screen.getCursorAddress(); + if (pasteStopAtProtectedLine && screen.isFormatted()) { + byte fa = screen.getFieldAttributeAt(newAddr); + if (faIsProtected(fa & 0xFF)) { + break; + } + } + } + i++; + continue; + } + + // Regular printable character + if (ch >= 0x20 && ch != 0x7F) { + if (screen.isFormatted()) { + int baddr = screen.getCursorAddress(); + ExtendedAttribute ea = screen.getCell(baddr); + if (ea.isFieldAttribute()) { + baddr = (baddr + 1) % (screen.getRows() * screen.getCols()); + } + byte faVal = screen.getFieldAttributeAt(baddr); + if (faIsProtected(faVal & 0xFF)) { + if (pasteStopAtProtectedLine) { + break; // Stop paste immediately at protected boundary + } else { + tab(); + baddr = screen.getCursorAddress(); + if (faIsProtected(screen.getFieldAttributeAt(baddr) & 0xFF)) { + break; + } + } + } + } + + typeCharacter(ch); + count++; + + // If pasteStopAtProtectedLine is enabled, check if the cursor after typing has hit a protected field + if (pasteStopAtProtectedLine && screen.isFormatted() && !keyboardLocked) { + int curAddr = screen.getCursorAddress(); + ExtendedAttribute curCell = screen.getCell(curAddr); + if (curCell.isFieldAttribute()) { + byte nextFa = curCell.fa; + if (faIsProtected(nextFa & 0xFF)) { + if (i + 1 < len) { + char nextCh = text.charAt(i + 1); + if (nextCh != '\t' && nextCh != '\r' && nextCh != '\n') { + break; + } + } + } + } else { + byte curFa = screen.getFieldAttributeAt(curAddr); + if (faIsProtected(curFa & 0xFF)) { + if (i + 1 < len) { + char nextCh = text.charAt(i + 1); + if (nextCh != '\t' && nextCh != '\r' && nextCh != '\n') { + break; + } + } + } + } + } + } + + i++; + } + + screen.markAllChanged(); + screen.updateDisplaySnapshot(); + return count; + } + /** * Build inbound 3270 Read Modified data stream (AID + Cursor + SBA + Modified fields). */ diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/ConnectionListener.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/ConnectionListener.java index 3189f19..142c4d5 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/ConnectionListener.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/ConnectionListener.java @@ -14,4 +14,7 @@ public interface ConnectionListener { /** Called when TN3270E negotiation completes. */ default void onTN3270ENegotiated(String deviceType, String deviceName) {} + + /** Called when TN3270E functions negotiation completes or changes. */ + default void onTN3270EFunctionsNegotiated(boolean[] functions) {} } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/ScreenUpdateListener.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/ScreenUpdateListener.java index 9a078d1..086663d 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/ScreenUpdateListener.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/listener/ScreenUpdateListener.java @@ -15,4 +15,7 @@ public interface ScreenUpdateListener { /** Called when the screen size changes (erase/write vs erase/write alternate). */ default void onScreenSizeChanged(int rows, int cols) {} + + /** Called when the keyboard is unlocked (e.g. via WCC restore, AUTO_SYS_UNLOCK, or Contention Resolution SDI). */ + default void onKeyboardUnlocked() {} } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java index b874f34..a966e0b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java @@ -1305,6 +1305,9 @@ public class NvtProcessor { } private void notifyScreenUpdated() { + if (screenBuffer != null) { + screenBuffer.notifyScreenUpdate(); + } for (ScreenUpdateListener l : screenListeners) { l.onScreenUpdated(); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java index d670bde..a98d1f5 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java @@ -5,6 +5,11 @@ import haus.nightmare.lib3270j.charset.EbcdicTranslator; import haus.nightmare.lib3270j.ecl.ECLField; import haus.nightmare.lib3270j.ecl.ECLFieldList; import haus.nightmare.lib3270j.ecl.ECLPS; +import haus.nightmare.lib3270j.listener.ScreenUpdateListener; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; /** @@ -60,6 +65,61 @@ public class ScreenBuffer { private final EbcdicTranslator translator; private final Object renderLock = new Object(); + // Synchronization primitives and screen update listeners (Phase 12) + private final ReentrantLock syncLock = new ReentrantLock(); + private final Condition syncCondition = syncLock.newCondition(); + private final List updateListeners = new CopyOnWriteArrayList<>(); + + public ReentrantLock getSyncLock() { + return syncLock; + } + + public Condition getSyncCondition() { + return syncCondition; + } + + public void addUpdateListener(ScreenUpdateListener l) { + if (l != null && !updateListeners.contains(l)) { + updateListeners.add(l); + } + } + + public void removeUpdateListener(ScreenUpdateListener l) { + updateListeners.remove(l); + } + + public void signalWaiters() { + syncLock.lock(); + try { + syncCondition.signalAll(); + } finally { + syncLock.unlock(); + } + } + + public void notifyScreenUpdate() { + updateDisplaySnapshot(); + signalWaiters(); + for (ScreenUpdateListener l : updateListeners) { + try { + l.onScreenUpdated(); + } catch (Exception ignored) {} + } + } + + public void notifyCursorMoved(int oldAddress, int newAddress) { + signalWaiters(); + for (ScreenUpdateListener l : updateListeners) { + try { + l.onCursorMoved(oldAddress, newAddress); + } catch (Exception ignored) {} + } + } + + public void notifyCursorMoved() { + notifyCursorMoved(cursorAddress, cursorAddress); + } + public Object getRenderLock() { return renderLock; } @@ -272,9 +332,16 @@ public class ScreenBuffer { // ========== Cursor ========== public int getCursorAddress() { return cursorAddress; } - public synchronized void setCursorAddress(int addr) { - this.cursorAddress = addr; - this.displayCursorAddress = addr; + public void setCursorAddress(int addr) { + int oldAddr; + synchronized (this) { + oldAddr = this.cursorAddress; + this.cursorAddress = addr; + this.displayCursorAddress = addr; + } + if (oldAddr != addr) { + notifyCursorMoved(oldAddr, addr); + } } public synchronized void setCursorPosition(int row, int col) { int r = Math.max(0, Math.min(row, rows - 1)); @@ -831,19 +898,28 @@ public class ScreenBuffer { } public synchronized void setText(String text) { + setText(text, 0); + } + + public synchronized void setText(String text, int pos) { if (text == null) return; int size = rows * cols; - int len = Math.min(text.length(), size); + if (pos < 0 || pos >= size) return; + int len = Math.min(text.length(), size - pos); for (int i = 0; i < len; i++) { char ch = text.charAt(i); int ebc = translator.unicodeToEbcdic(ch); - buffer[i].ec = (byte) (ebc >= 0 ? ebc : 0); - buffer[i].ucs4 = ch; + buffer[pos + i].ec = (byte) (ebc >= 0 ? ebc : 0); + buffer[pos + i].ucs4 = ch; } screenChanged = true; updateDisplaySnapshot(); } + public void setText(String text, int row, int col) { + setText(text, row * cols + col); + } + public int searchString(String target) { if (target == null || target.isEmpty()) return -1; String full = getText(); @@ -1119,8 +1195,10 @@ public class ScreenBuffer { ExtendedAttribute[] wordCells = new ExtendedAttribute[wordLen]; for (int i = 0; i < wordLen; i++) { wordCells[i] = new ExtendedAttribute(); - wordCells[i].copyFrom(getCell(wordStartAddr + i)); - getCell(wordStartAddr + i).clear(); + ExtendedAttribute srcCell = getCell(wordStartAddr + i); + wordCells[i].copyFrom(srcCell); + srcCell.ec = 0; + srcCell.ucs4 = 0; } int nextRow = (curRow + 1) % rows; diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java index 66b2ac9..d73635e 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java @@ -6,6 +6,8 @@ import haus.nightmare.lib3270j.Telnet3270Client; import java.io.*; import java.net.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Logger; import java.util.logging.Level; @@ -28,6 +30,10 @@ public class TelnetConnection { private final TelnetFSM fsm; private final ConnectionConfig config; + private final AtomicLong lastActivityTime = new AtomicLong(System.currentTimeMillis()); + private ScheduledExecutorService keepAliveExecutor; + private volatile boolean intentionalDisconnect = false; + private javax.net.ssl.SSLSession sslSession; public TelnetConnection(ConnectionConfig config, TelnetFSM fsm) { @@ -91,6 +97,7 @@ public class TelnetConnection { if (config.getSoTimeoutMs() > 0) { rawSocket.setSoTimeout(config.getSoTimeoutMs()); } + applyExtendedSocketOptions(rawSocket); rawSocket.connect(new InetSocketAddress(connectHost, connectPort), config.getConnectTimeoutMs()); // Perform proxy handshake if configured @@ -123,6 +130,7 @@ public class TelnetConnection { if (config.getSoTimeoutMs() > 0) { sslSocket.setSoTimeout(config.getSoTimeoutMs()); } + applyExtendedSocketOptions(sslSocket); applyTlsSocketSettings(sslSocket); sslSocket.startHandshake(); socket = sslSocket; @@ -143,10 +151,13 @@ public class TelnetConnection { log.info("Connected to " + socket.getRemoteSocketAddress()); + intentionalDisconnect = false; + lastActivityTime.set(System.currentTimeMillis()); running = true; readerThread = new Thread(this::readLoop, "TN3270-Reader"); readerThread.setDaemon(true); readerThread.start(); + startKeepAlive(); } /** @@ -167,6 +178,7 @@ public class TelnetConnection { if (config.getSoTimeoutMs() > 0) { sslSocket.setSoTimeout(config.getSoTimeoutMs()); } + applyExtendedSocketOptions(sslSocket); applyTlsSocketSettings(sslSocket); sslSocket.startHandshake(); this.socket = sslSocket; @@ -200,6 +212,106 @@ public class TelnetConnection { } } + private void applyExtendedSocketOptions(Socket s) { + if (s == null) return; + try { + Class extClass = Class.forName("jdk.net.ExtendedSocketOptions"); + // TCP_KEEPIDLE, TCP_KEEPINTERVAL, TCP_KEEPCOUNT + if (config != null && config.isSoKeepAlive() && config.getKeepAliveIntervalSeconds() > 0) { + try { + java.lang.reflect.Field fIdle = extClass.getField("TCP_KEEPIDLE"); + @SuppressWarnings("unchecked") + SocketOption optIdle = (SocketOption) fIdle.get(null); + s.setOption(optIdle, config.getKeepAliveIntervalSeconds()); + } catch (Throwable ignored) {} + try { + java.lang.reflect.Field fIntv = extClass.getField("TCP_KEEPINTERVAL"); + @SuppressWarnings("unchecked") + SocketOption optIntv = (SocketOption) fIntv.get(null); + s.setOption(optIntv, Math.max(1, Math.min(10, config.getKeepAliveIntervalSeconds()))); + } catch (Throwable ignored) {} + try { + java.lang.reflect.Field fCnt = extClass.getField("TCP_KEEPCOUNT"); + @SuppressWarnings("unchecked") + SocketOption optCnt = (SocketOption) fCnt.get(null); + s.setOption(optCnt, 3); + } catch (Throwable ignored) {} + } + // TCP_USER_TIMEOUT + if (config != null && config.getTcpUserTimeoutMs() > 0) { + try { + java.lang.reflect.Field fTimeout = extClass.getField("TCP_USER_TIMEOUT"); + @SuppressWarnings("unchecked") + SocketOption optTimeout = (SocketOption) fTimeout.get(null); + s.setOption(optTimeout, config.getTcpUserTimeoutMs()); + } catch (Throwable ignored) {} + } + } catch (Throwable ignored) { + // Extended socket options not supported on this platform/runtime (e.g. macOS/Android) + } + } + + public synchronized void startKeepAlive() { + stopKeepAlive(); + if (config != null && config.isKeepAliveEnabled() && config.getKeepAliveIntervalSeconds() > 0) { + keepAliveExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "TN3270-KeepAlive"); + t.setDaemon(true); + return t; + }); + int interval = config.getKeepAliveIntervalSeconds(); + long checkPeriod = Math.max(1, Math.min(5, interval)); + keepAliveExecutor.scheduleWithFixedDelay(this::checkAndSendKeepAlive, checkPeriod, checkPeriod, TimeUnit.SECONDS); + log.fine("Keep-Alive heartbeat scheduled every " + interval + "s (check every " + checkPeriod + "s)"); + } + } + + public synchronized void stopKeepAlive() { + if (keepAliveExecutor != null) { + keepAliveExecutor.shutdownNow(); + keepAliveExecutor = null; + } + } + + private void checkAndSendKeepAlive() { + if (!running || !isConnected()) { + return; + } + long now = System.currentTimeMillis(); + long elapsed = now - lastActivityTime.get(); + long intervalMs = (config != null ? config.getKeepAliveIntervalSeconds() : 120) * 1000L; + if (elapsed >= intervalMs) { + try { + sendKeepAliveHeartbeat(); + } catch (Exception e) { + log.log(Level.WARNING, "Failed to send keep-alive heartbeat", e); + } + } + } + + public synchronized void sendKeepAliveHeartbeat() throws IOException { + if (!isConnected() || outputStream == null) { + return; + } + String type = (config != null) ? config.getKeepAliveType() : "NOP"; + if ("TIMING-MARK".equalsIgnoreCase(type)) { + log.fine("Transmitting Keep-Alive heartbeat: IAC DO TIMING-MARK"); + byte[] tm = new byte[] { (byte) IAC, (byte) DO, (byte) TELOPT_TM }; + outputStream.write(tm); + outputStream.flush(); + } else { + log.fine("Transmitting Keep-Alive heartbeat: IAC NOP"); + byte[] nop = new byte[] { (byte) IAC, (byte) NOP }; + outputStream.write(nop); + outputStream.flush(); + } + lastActivityTime.set(System.currentTimeMillis()); + } + + public long getLastActivityTime() { + return lastActivityTime.get(); + } + private void establishHttpProxy(Socket s, String targetHost, int targetPort, String user, String pass) throws IOException { OutputStream out = s.getOutputStream(); InputStream in = s.getInputStream(); @@ -411,6 +523,7 @@ public class TelnetConnection { if (outputStream == null) return; outputStream.write(data, offset, length); outputStream.flush(); + lastActivityTime.set(System.currentTimeMillis()); if (log.isLoggable(Level.FINE)) { log.fine("SENT " + length + " bytes: " + formatHex(data, offset, length)); } @@ -432,13 +545,16 @@ public class TelnetConnection { byte[] escaped = out.toByteArray(); outputStream.write(escaped, 0, escaped.length); outputStream.flush(); + lastActivityTime.set(System.currentTimeMillis()); } /** * Disconnect from the host. */ public void disconnect() { + intentionalDisconnect = true; running = false; + stopKeepAlive(); try { if (socket != null && !socket.isClosed()) { socket.shutdownInput(); @@ -471,10 +587,12 @@ public class TelnetConnection { int n = inputStream.read(buf); if (n < 0) { log.info("Host disconnected (EOF)"); - fsm.onDisconnect(); + boolean unexpected = !intentionalDisconnect; + fsm.onDisconnect(unexpected); break; } if (n > 0) { + lastActivityTime.set(System.currentTimeMillis()); if (log.isLoggable(Level.FINE)) { log.fine("RCVD " + n + " bytes: " + formatHex(buf, 0, n)); } @@ -486,14 +604,23 @@ public class TelnetConnection { } } } + } catch (SocketTimeoutException e) { + if (running && !intentionalDisconnect) { + log.warning("Socket read timeout (" + (config != null ? config.getSoTimeoutMs() : 0) + "ms): " + e.getMessage()); + fsm.onDisconnect(true); + } } catch (SocketException e) { - if (running) { - log.info("Socket closed: " + e.getMessage()); - fsm.onDisconnect(); + if (running && !intentionalDisconnect) { + log.info("Socket closed unexpectedly: " + e.getMessage()); + fsm.onDisconnect(true); + } else if (running) { + fsm.onDisconnect(false); } } catch (IOException e) { - if (running) { - log.log(Level.WARNING, "Read error", e); + if (running && !intentionalDisconnect) { + log.log(Level.WARNING, "Read error: " + e.getMessage()); + fsm.onDisconnect(true); + } else if (running) { fsm.onError("Read error: " + e.getMessage()); } } catch (Throwable t) { @@ -501,6 +628,8 @@ public class TelnetConnection { log.log(Level.SEVERE, "Unexpected fatal error in readLoop", t); fsm.onError("Network loop error: " + t.getMessage()); } + } finally { + stopKeepAlive(); } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java index 7f90c0f..81abb04 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java @@ -120,6 +120,11 @@ public class TelnetFSM { private String connectedLu; private String connectedType; + // Phase 10: Contention Resolution & Auto-Unlock State + private boolean sdi_flag = false; + private boolean kri_flag = false; + private boolean negotiateContentionResolution = true; + public enum TN3270ESubmode { UNBOUND, E_3270, E_NVT, E_SSCP } public TelnetFSM(ConnectionConfig config, ScreenBuffer screenBuffer, DataStreamProcessor dsProcessor) { @@ -190,7 +195,7 @@ public class TelnetFSM { eFuncs[FUNC_SYSREQ] = true; eFuncs[FUNC_SNA_SENSE] = true; eFuncs[FUNC_DATA_STREAM_CTL] = true; - eFuncs[FUNC_CONTENTION_RESOLUTION] = true; + eFuncs[FUNC_CONTENTION_RESOLUTION] = negotiateContentionResolution; statusDisplay(STATUS_CONNECTING, "Connecting to host"); changeState(ConnectionState.TELNET_PENDING); @@ -951,6 +956,10 @@ public class TelnetFSM { log.info("TN3270E functions negotiated: " + getNegotiatedFunctionNames()); log.info("TN3270E negotiation complete"); + if (dsProcessor != null) { + dsProcessor.setContentionResolution(isContentionResolutionNegotiated()); + } + // RFC 2355: If BIND-IMAGE function is not negotiated, the emulation session is considered // to be bound immediately upon completion of the FUNCTIONS negotiation. if (eFuncs[FUNC_BIND_IMAGE]) { @@ -963,6 +972,7 @@ public class TelnetFSM { // Notify listeners for (ConnectionListener l : connectionListeners) { l.onTN3270ENegotiated(connectedType, connectedLu); + l.onTN3270EFunctionsNegotiated(eFuncs); } } @@ -996,9 +1006,59 @@ public class TelnetFSM { processTN3270ERecord(data); } else { // Plain TN3270 mode: data is raw 3270 data stream - dsProcessor.processRecord(data, 0, data.length, true); + if (dsProcessor != null) { + dsProcessor.processRecord(data, 0, data.length, false); + } notifyScreenUpdate(); } + + // Phase 10: Contention Resolution & AUTO_SYS_UNLOCK handling on EOR + if (dsProcessor != null) { + boolean crActive = isContentionResolutionNegotiated(); + if (crActive) { + if (this.sdi_flag && !dsProcessor.isRcvdRead()) { + if (dsProcessor.getInputProcessor() != null) { + dsProcessor.getInputProcessor().setKeyboardLocked(false); + } + this.sdi_flag = false; + dsProcessor.setUnlockPending(false); + if (dsProcessor.isUnlockSysPending() || this.kri_flag) { + if (dsProcessor.getInputProcessor() != null && dsProcessor.getInputProcessor().getOIA() != null) { + dsProcessor.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED); + } + } + this.kri_flag = false; + dsProcessor.setUnlockSysPending(false); + notifyKeyboardUnlocked(); + } + this.kri_flag = false; + } else { + // Contention Resolution is not active + boolean autoSysUnlock = (config != null) ? config.isAutoSysUnlock() : true; + if (autoSysUnlock && !dsProcessor.isRcvdRead()) { + boolean sysLocked = false; + if (dsProcessor.getInputProcessor() != null) { + if (dsProcessor.getInputProcessor().isKeyboardLocked() || + (dsProcessor.getInputProcessor().getOIA() != null && dsProcessor.getInputProcessor().getOIA().isXSystem())) { + sysLocked = true; + } + } + if (sysLocked) { + if (dsProcessor.getInputProcessor() != null) { + dsProcessor.getInputProcessor().setKeyboardLocked(false); + if (dsProcessor.getInputProcessor().getOIA() != null) { + dsProcessor.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED); + } + } + dsProcessor.setUnlockSysPending(false); + notifyKeyboardUnlocked(); + } + } + } + this.sdi_flag = false; + this.kri_flag = false; + dsProcessor.setRcvdRead(false); + } } public void processTn3270eHeader(byte[] data) { @@ -1016,6 +1076,12 @@ public class TelnetFSM { int responseFlag = data[2] & 0xFF; int seqNumber = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF); + this.sdi_flag = (requestFlag & 0x01) != 0; + this.kri_flag = (requestFlag & 0x02) != 0; + if (dsProcessor != null) { + dsProcessor.setContentionResolution(isContentionResolutionNegotiated()); + } + log.fine("TN3270E header: type=" + TN3270EConstants.dataTypeName(dataType) + " rqf=" + requestFlag + " rsf=" + responseFlag + " seq=" + seqNumber); @@ -1033,7 +1099,7 @@ public class TelnetFSM { tn3270eSubmode = TN3270ESubmode.E_3270; } try { - dsProcessor.processRecord(data, EH_SIZE, data.length - EH_SIZE, true); + dsProcessor.processRecord(data, EH_SIZE, data.length - EH_SIZE, false); notifyScreenUpdate(); // Send positive response if required if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) { @@ -1423,8 +1489,49 @@ public class TelnetFSM { } } + public void setConnectionState(ConnectionState newState) { + changeState(newState); + } + public void onDisconnect() { - changeState(ConnectionState.NOT_CONNECTED); + onDisconnect(false); + } + + public void onDisconnect(boolean unexpected) { + if (unexpected && config != null && config.isAutoReconnect()) { + log.info("Unexpected connection loss — transitioning to RECONNECTING"); + changeState(ConnectionState.RECONNECTING); + } else { + changeState(ConnectionState.NOT_CONNECTED); + } + } + + /** + * Cleanly reset Telnet and TN3270E session state prior to reconnecting. + */ + public synchronized void resetSessionState() { + state = TNS_DATA; + java.util.Arrays.fill(myOpts, false); + java.util.Arrays.fill(hisOpts, false); + ibuf.reset(); + sbbuf.reset(); + tn3270eNegotiated = false; + tn3270eSubmode = TN3270ESubmode.UNBOUND; + tn3270eBound = false; + java.util.Arrays.fill(eFuncs, false); + eXmitSeq = 0; + lastRcvSeq = 0; + lastRespType = 0; + lastRespCode = 0; + responseRequired = RSF_NO_RESPONSE; + deferredWillTtype = false; + tn3270eDeviceTypeSent = false; + ttypeIndex = 0; + luIndex = 0; + connectedLu = null; + connectedType = null; + sdi_flag = false; + kri_flag = false; } public void onError(String message) { @@ -1434,7 +1541,10 @@ public class TelnetFSM { } } - private void notifyScreenUpdate() { + public void notifyScreenUpdate() { + if (screenBuffer != null) { + screenBuffer.notifyScreenUpdate(); + } for (ScreenUpdateListener l : screenListeners) { l.onScreenUpdated(); } @@ -1810,4 +1920,41 @@ public class TelnetFSM { public boolean isFunctionNegotiated(int func) { return func >= 0 && func < eFuncs.length && eFuncs[func]; } + + public boolean isContentionResolutionNegotiated() { + return tn3270eNegotiated && eFuncs[FUNC_CONTENTION_RESOLUTION]; + } + + public boolean isNegotiateContentionResolution() { + return negotiateContentionResolution; + } + + public void setNegotiateContentionResolution(boolean neg) { + this.negotiateContentionResolution = neg; + eFuncs[FUNC_CONTENTION_RESOLUTION] = neg; + } + + public void setContentionResolutionNegotiated(boolean cr) { + if (cr) { + this.tn3270eNegotiated = true; + } + eFuncs[FUNC_CONTENTION_RESOLUTION] = cr; + if (dsProcessor != null) { + dsProcessor.setContentionResolution(cr); + } + } + + public boolean isSdi_flag() { return sdi_flag; } + public void setSdi_flag(boolean sdi) { this.sdi_flag = sdi; } + + public boolean isKri_flag() { return kri_flag; } + public void setKri_flag(boolean kri) { this.kri_flag = kri; } + + public void notifyKeyboardUnlocked() { + for (ScreenUpdateListener l : screenListeners) { + try { + l.onKeyboardUnlocked(); + } catch (Exception ignored) {} + } + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/tn3270/DS3270.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/tn3270/DS3270.java index 97484d1..a6d2844 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/tn3270/DS3270.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/tn3270/DS3270.java @@ -152,6 +152,14 @@ public class DS3270 { public boolean suppressClearEvent; public boolean wsfvalid = true; + // Phase 10: State tracking matching HoD DS3270 + protected boolean autoSysUnlock = true; + protected boolean sdi_flag = false; + protected boolean kri_flag = false; + protected boolean unlock_pending = false; + protected boolean unlock_sys_pending = false; + protected boolean rcvdRead = false; + // Underlying lib3270j data stream processor private final DataStreamProcessor delegate; private ECLSession session; @@ -172,9 +180,15 @@ public class DS3270 { public DS3270(ECLSession session, ECLPS ps) { this.session = session; this.ps = ps; + if (session != null) { + this.autoSysUnlock = session.isAutoSysUnlock(); + } ScreenBuffer sb = (ps != null) ? ps.getScreenBuffer() : new ScreenBuffer(); EbcdicTranslator trans = (ps != null) ? ps.getTranslator() : new EbcdicTranslator(); this.delegate = new DataStreamProcessor(sb, trans); + if (session != null) { + this.delegate.setAutoSysUnlock(session.isAutoSysUnlock()); + } if (ps != null && ps.getInputProcessor() != null) { this.delegate.setInputProcessor(ps.getInputProcessor()); } @@ -214,8 +228,135 @@ public class DS3270 { return false; } + public boolean isAutoSysUnlock() { + return autoSysUnlock; + } + + public void setAutoSysUnlock(boolean autoSysUnlock) { + this.autoSysUnlock = autoSysUnlock; + if (delegate != null) { + delegate.setAutoSysUnlock(autoSysUnlock); + } + } + + public boolean isSdi_flag() { return sdi_flag; } + public void setSdi_flag(boolean sdi) { this.sdi_flag = sdi; } + + public boolean isKri_flag() { return kri_flag; } + public void setKri_flag(boolean kri) { this.kri_flag = kri; } + + public boolean isUnlock_pending() { return unlock_pending; } + public void setUnlock_pending(boolean pending) { this.unlock_pending = pending; } + + public boolean isUnlock_sys_pending() { return unlock_sys_pending; } + public void setUnlock_sys_pending(boolean pending) { this.unlock_sys_pending = pending; } + + public boolean isRcvdRead() { + return rcvdRead || (delegate != null && delegate.isRcvdRead()); + } + public void setRcvdRead(boolean rcvd) { + this.rcvdRead = rcvd; + if (delegate != null) delegate.setRcvdRead(rcvd); + } + + public void receiveHeaderData(short s, short s2, short s3, int n) { + this.sdi_flag = (s2 & request_bit_SDI) != 0; + this.kri_flag = (s2 & request_bit_KRI) != 0; + } + + public void receiveHeader(short s, short s2, short s3, int n) { + receiveHeaderData(s, s2, s3, n); + } + public void endOfRecord() { log.fine("DS3270 endOfRecord"); + boolean crActive = (session != null && session.getContentionResolution()); + if (crActive) { + if (this.sdi_flag && !isRcvdRead()) { + if (this.ps != null) { + this.ps.unlockKeyboard(7); + } else if (delegate != null && delegate.getInputProcessor() != null) { + delegate.getInputProcessor().setKeyboardLocked(false); + } + this.sdi_flag = false; + this.unlock_pending = false; + if (this.unlock_sys_pending || this.kri_flag) { + if (this.ps != null) { + this.ps.unlockKeyboard(8); + } else if (delegate != null && delegate.getInputProcessor() != null) { + delegate.getInputProcessor().setKeyboardLocked(false); + if (delegate.getInputProcessor().getOIA() != null) { + delegate.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED); + } + } + } + this.kri_flag = false; + this.unlock_sys_pending = false; + } + if (this.ps != null) { + this.ps.dispatchEvent(new haus.nightmare.lib3270j.ecl.ECLPSEvent(this.ps, haus.nightmare.lib3270j.ecl.ECLPSEvent.EVENT_KEY_UNLOCKED)); + } + } else { + // Contention resolution is not active + if (this.unlock_pending && !isRcvdRead()) { + if (this.ps != null) { + this.ps.unlockKeyboard(7); + } else if (delegate != null && delegate.getInputProcessor() != null) { + delegate.getInputProcessor().setKeyboardLocked(false); + } + if (this.autoSysUnlock) { + if (this.ps != null) { + this.ps.unlockKeyboard(8); + } else if (delegate != null && delegate.getInputProcessor() != null) { + delegate.getInputProcessor().setKeyboardLocked(false); + if (delegate.getInputProcessor().getOIA() != null) { + delegate.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED); + } + } + this.unlock_sys_pending = false; + } + this.unlock_pending = false; + } + if (this.unlock_sys_pending && !isRcvdRead()) { + if (this.ps != null) { + this.ps.unlockKeyboard(8); + } else if (delegate != null && delegate.getInputProcessor() != null) { + delegate.getInputProcessor().setKeyboardLocked(false); + if (delegate.getInputProcessor().getOIA() != null) { + delegate.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED); + } + } + this.unlock_sys_pending = false; + } + if (this.autoSysUnlock && !isRcvdRead()) { + boolean sysLocked = false; + if (this.ps != null && this.ps.islocked_SYSLOCK()) { + sysLocked = true; + } else if (delegate != null && delegate.getInputProcessor() != null) { + if (delegate.getInputProcessor().isKeyboardLocked() || + (delegate.getInputProcessor().getOIA() != null && delegate.getInputProcessor().getOIA().isXSystem())) { + sysLocked = true; + } + } + if (sysLocked) { + if (this.ps != null) { + this.ps.unlockKeyboard(8); + } + if (delegate != null && delegate.getInputProcessor() != null) { + delegate.getInputProcessor().setKeyboardLocked(false); + if (delegate.getInputProcessor().getOIA() != null) { + delegate.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED); + } + } + this.unlock_sys_pending = false; + } + } + } + this.rcvdRead = false; + if (delegate != null) delegate.setRcvdRead(false); + this.unlock_pending = false; + this.sdi_flag = false; + this.kri_flag = false; } public int receiveData(short[] sArray, int off, int len) { @@ -233,6 +374,10 @@ public class DS3270 { public void processWCC(short wcc) { delegate.processWCC(wcc); + if ((wcc & WCC_RESTORE) > 0) { + this.unlock_pending = true; + this.unlock_sys_pending = true; + } } public void processSBA(int baddr) { diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/Phase10ProtocolHardeningTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/Phase10ProtocolHardeningTest.java new file mode 100644 index 0000000..8228538 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/Phase10ProtocolHardeningTest.java @@ -0,0 +1,371 @@ +package haus.nightmare.lib3270j.datastream; + +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.ecl.ECLConnection; +import haus.nightmare.lib3270j.ecl.ECLConstants; +import haus.nightmare.lib3270j.ecl.ECLOIA; +import haus.nightmare.lib3270j.ecl.ECLPS; +import haus.nightmare.lib3270j.ecl.ECLPSEvent; +import haus.nightmare.lib3270j.ecl.ECLPSListener; +import haus.nightmare.lib3270j.ecl.ECLSession; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.listener.ScreenUpdateListener; +import haus.nightmare.lib3270j.protocol.DS3270Constants; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import haus.nightmare.lib3270j.telnet.TelnetFSM; +import haus.nightmare.lib3270j.tn3270.DS3270; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Properties; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 10 Test Suite: DataStream Protocol Hardening & AUTO_SYS_UNLOCK Parity. + * + * Verifies: + * 10.1 WCC Keyboard Restore Logic (intermediate writes without restore bit keep keyboard locked). + * 10.2 AUTO_SYS_UNLOCK parity across ConnectionConfig, ECLSession, ECLConnection, DS3270, and TelnetFSM. + * 10.3 Contention Resolution negotiation, SDI/KRI tracking, and ECLPSEvent.EVENT_KEY_UNLOCKED dispatch. + */ +public class Phase10ProtocolHardeningTest { + + private ScreenBuffer screen; + private EbcdicTranslator translator; + private InputProcessor inputProcessor; + private DataStreamProcessor processor; + + @BeforeEach + public void setUp() { + translator = new EbcdicTranslator(); + screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator); + inputProcessor = new InputProcessor(screen, translator, null); + processor = new DataStreamProcessor(screen, translator); + processor.setInputProcessor(inputProcessor); + } + + // ========================================================================= + // 10.1 WCC Keyboard Restore Logic + // ========================================================================= + + @Test + @DisplayName("10.1: Write without WCC restore bit keeps keyboard locked") + public void testWriteWithoutWccRestoreKeepsKeyboardLocked() { + // Initially lock the keyboard + inputProcessor.setKeyboardLocked(true); + assertTrue(inputProcessor.isKeyboardLocked()); + + // Construct 3270 Write Command (0x01) with WCC = 0xC0 (Reset MDT only, no keyboard restore 0x02) + byte[] record = new byte[] { + (byte) DS3270Constants.CMD_WRITE, + (byte) 0xC0, // WCC: bit 6 set (reset MDT), bit 1 (restore) NOT set + (byte) 0x11, 0x40, 0x40, // SBA to 0 + (byte) 0xC1, (byte) 0xC2 // 'A', 'B' + }; + + processor.processRecord(record, 0, record.length, false); + + // Keyboard MUST remain locked because WCC restore bit 0x02 was not set + assertTrue(inputProcessor.isKeyboardLocked(), + "Keyboard must remain locked when WCC does not have restore bit (0x02) set"); + } + + @Test + @DisplayName("10.1: Write with WCC restore bit unlocks keyboard") + public void testWriteWithWccRestoreUnlocksKeyboard() { + // Initially lock the keyboard + inputProcessor.setKeyboardLocked(true); + assertTrue(inputProcessor.isKeyboardLocked()); + + // Construct 3270 Write Command (0x01) with WCC = 0xC2 (Reset MDT + Keyboard Restore bit 0x02) + byte[] record = new byte[] { + (byte) DS3270Constants.CMD_WRITE, + (byte) 0xC2, // WCC: restore bit (0x02) set + (byte) 0x11, 0x40, 0x40, // SBA to 0 + (byte) 0xC1, (byte) 0xC2 // 'A', 'B' + }; + + processor.processRecord(record, 0, record.length, false); + + // Keyboard MUST now be unlocked + assertFalse(inputProcessor.isKeyboardLocked(), + "Keyboard must be unlocked when WCC has restore bit (0x02) set"); + } + + @Test + @DisplayName("10.1: Intermediate writes without restore bit maintain keyboard lock across sequence") + public void testIntermediateWritesKeepKeyboardLockedUntilFinalRestore() { + inputProcessor.setKeyboardLocked(true); + assertTrue(inputProcessor.isKeyboardLocked()); + + // First intermediate write: WCC = 0x00 (no restore) + byte[] write1 = new byte[] { + (byte) DS3270Constants.CMD_WRITE, + (byte) 0x00, + (byte) 0x11, 0x40, 0x40, + (byte) 0xC1 + }; + processor.processRecord(write1, 0, write1.length, false); + assertTrue(inputProcessor.isKeyboardLocked(), "Write 1 without restore bit must leave keyboard locked"); + + // Second intermediate write: Erase / Write with WCC = 0x40 (Reset, no restore) + byte[] write2 = new byte[] { + (byte) DS3270Constants.CMD_ERASE_WRITE, + (byte) 0x40, + (byte) 0x11, 0x40, 0x50, + (byte) 0xC2 + }; + processor.processRecord(write2, 0, write2.length, false); + assertTrue(inputProcessor.isKeyboardLocked(), "Write 2 without restore bit must leave keyboard locked"); + + // Final write: WCC = 0x42 (Reset + Restore) + byte[] write3 = new byte[] { + (byte) DS3270Constants.CMD_WRITE, + (byte) 0x42, + (byte) 0x11, 0x40, 0x60, + (byte) 0xC3 + }; + processor.processRecord(write3, 0, write3.length, false); + assertFalse(inputProcessor.isKeyboardLocked(), "Final write with restore bit must unlock keyboard"); + } + + // ========================================================================= + // 10.2 AUTO_SYS_UNLOCK Parity + // ========================================================================= + + @Test + @DisplayName("10.2: autoSysUnlock configuration property propagation across ECL components") + public void testAutoSysUnlockConfigurationPropagation() { + // Default ConnectionConfig should have autoSysUnlock == true + ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + assertTrue(config.isAutoSysUnlock(), "Default autoSysUnlock in ConnectionConfig must be true"); + + config.setAutoSysUnlock(false); + assertFalse(config.isAutoSysUnlock()); + + // Telnet3270Client propagation + Telnet3270Client client = new Telnet3270Client(config); + assertFalse(client.isAutoSysUnlock()); + + // ECLSession propagation + ECLSession session = new ECLSession(client); + assertFalse(session.isAutoSysUnlock()); + assertFalse(session.getConnection().isAutoSysUnlock()); + + // Modify via ECLSession + session.setAutoSysUnlock(true); + assertTrue(session.isAutoSysUnlock()); + assertTrue(client.isAutoSysUnlock()); + assertTrue(config.isAutoSysUnlock()); + + // Test ECLSession Properties parsing + Properties props = new Properties(); + props.setProperty(ECLSession.SESSION_AUTO_SYS_UNLOCK, "false"); + props.setProperty(ECLSession.SESSION_HOST, "mainframe.org"); + ECLSession propsSession = new ECLSession(props); + assertFalse(propsSession.isAutoSysUnlock()); + assertFalse(propsSession.getConnection().isAutoSysUnlock()); + + // ECLConnection standalone properties + ECLConnection standaloneConn = new ECLConnection(props); + assertFalse(standaloneConn.isAutoSysUnlock()); + } + + @Test + @DisplayName("10.2: DS3270 endOfRecord unlocks keyboard and clears OIA when autoSysUnlock is true and in X SYSTEM") + public void testDs3270AutoSysUnlockOnEor() { + ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + config.setAutoSysUnlock(true); + Telnet3270Client client = new Telnet3270Client(config); + ECLSession session = new ECLSession(client); + ECLPS ps = session.GetPS(); + ECLOIA oia = session.GetOIA(); + + DS3270 ds = new DS3270(session, ps); + assertTrue(ds.isAutoSysUnlock()); + + // Set connected state so OIA does not treat it as communication check + client.getTelnetFSM().setConnectionState(haus.nightmare.lib3270j.ConnectionState.CONNECTED_3270); + + // Put session in X SYSTEM lock + ps.lockKeyboard(8); // reason 8: SYSLOCK + assertTrue(ps.islocked_SYSLOCK()); + assertTrue(client.getInputProcessor().isKeyboardLocked()); + assertTrue(oia.isXSystem()); + + // Trigger endOfRecord() with autoSysUnlock=true + ds.endOfRecord(); + + // Keyboard must be auto-unlocked and OIA cleared + assertFalse(client.getInputProcessor().isKeyboardLocked(), + "Keyboard should be unlocked on EOR when autoSysUnlock is true"); + assertFalse(oia.isXSystem(), + "OIA X SYSTEM lock should be cleared on EOR when autoSysUnlock is true"); + } + + @Test + @DisplayName("10.2: DS3270 endOfRecord leaves keyboard locked when autoSysUnlock is false and in X SYSTEM") + public void testDs3270AutoSysUnlockDisabledKeepsSystemLock() { + ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + config.setAutoSysUnlock(false); + Telnet3270Client client = new Telnet3270Client(config); + ECLSession session = new ECLSession(client); + ECLPS ps = session.GetPS(); + + DS3270 ds = new DS3270(session, ps); + assertFalse(ds.isAutoSysUnlock()); + + // Put session in X SYSTEM lock + ps.lockKeyboard(8); + assertTrue(ps.islocked_SYSLOCK()); + assertTrue(client.getInputProcessor().isKeyboardLocked()); + + // Trigger endOfRecord() with autoSysUnlock=false + ds.endOfRecord(); + + // Keyboard MUST remain locked + assertTrue(client.getInputProcessor().isKeyboardLocked(), + "Keyboard must remain locked on EOR when autoSysUnlock is false"); + } + + // ========================================================================= + // 10.3 Contention Resolution & Pre-Data State Transitions + // ========================================================================= + + @Test + @DisplayName("10.3: Contention Resolution negotiation flag synchronization with ECLConnection") + public void testContentionResolutionNegotiationSync() { + ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + Telnet3270Client client = new Telnet3270Client(config); + ECLSession session = new ECLSession(client); + ECLConnection conn = session.getConnection(); + TelnetFSM fsm = client.getTelnetFSM(); + + assertNotNull(fsm); + assertTrue(fsm.isNegotiateContentionResolution(), + "Default negotiateContentionResolution should be true"); + + // Test setter on FSM and reflection in ECLConnection + fsm.setContentionResolutionNegotiated(true); + + assertTrue(fsm.isContentionResolutionNegotiated(), + "Contention resolution should be negotiated in FSM"); + assertTrue(conn.getContentionResolution(), + "ECLConnection should reflect negotiated contention resolution"); + assertTrue(session.getContentionResolution(), + "ECLSession should reflect negotiated contention resolution"); + } + + @Test + @DisplayName("10.3: Contention Resolution SDI flag restores keyboard and fires EVENT_KEY_UNLOCKED on EOR") + public void testContentionResolutionWithSdiRestoresKeyboardAndFiresEvent() { + ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + Telnet3270Client client = new Telnet3270Client(config); + ECLSession session = new ECLSession(client); + ECLPS ps = session.GetPS(); + DS3270 ds = new DS3270(session, ps); + + // Enable Contention Resolution on session + session.setContentionResolution(true); + assertTrue(session.getContentionResolution()); + + // Lock keyboard + client.getInputProcessor().setKeyboardLocked(true); + assertTrue(client.getInputProcessor().isKeyboardLocked()); + + // Register listener for EVENT_KEY_UNLOCKED + AtomicInteger eventTypeReceived = new AtomicInteger(-1); + AtomicBoolean unlockedEventFired = new AtomicBoolean(false); + ps.RegisterPSEvent(new ECLPSListener() { + @Override + public void PSNotifyEvent(ECLPSEvent event) { + eventTypeReceived.set(event.getEventType()); + unlockedEventFired.set(true); + } + }); + + // Inbound TN3270E header with SDI bit set: + // s2 request flag has bit 0 (0x01) set -> Send Data Indicator (SDI) + short s1 = 0; + short s2 = DS3270.request_bit_SDI; // 1 + short s3 = 0; + ds.receiveHeaderData(s1, s2, s3, 0); + + assertTrue(ds.isSdi_flag(), "SDI flag should be tracked from TN3270E header"); + + // Trigger endOfRecord() + ds.endOfRecord(); + + // Keyboard should be unlocked because SDI was received + assertFalse(client.getInputProcessor().isKeyboardLocked(), + "Keyboard should be restored on EOR when CR is active and SDI is set"); + assertFalse(ds.isSdi_flag(), "SDI flag should be reset after EOR"); + assertTrue(unlockedEventFired.get(), "ECLPSEvent must be dispatched on keyboard unlock"); + assertEquals(ECLPSEvent.EVENT_KEY_UNLOCKED, eventTypeReceived.get(), + "Dispatched event must match EVENT_KEY_UNLOCKED"); + } + + @Test + @DisplayName("10.3: Contention Resolution WITHOUT SDI flag keeps keyboard locked on EOR") + public void testContentionResolutionWithoutSdiKeepsKeyboardLocked() { + ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + Telnet3270Client client = new Telnet3270Client(config); + ECLSession session = new ECLSession(client); + ECLPS ps = session.GetPS(); + DS3270 ds = new DS3270(session, ps); + + session.setContentionResolution(true); + assertTrue(session.getContentionResolution()); + + // Initially lock keyboard + client.getInputProcessor().setKeyboardLocked(true); + + // Inbound TN3270E header WITHOUT SDI flag (s2 = 0) + ds.receiveHeaderData((short) 0, (short) 0, (short) 0, 0); + assertFalse(ds.isSdi_flag()); + + // Trigger EOR + ds.endOfRecord(); + + // Keyboard MUST stay locked because host did not grant Send Data Indicator (turn) + assertTrue(client.getInputProcessor().isKeyboardLocked(), + "Keyboard must remain locked on EOR when Contention Resolution is active and SDI is not set"); + } + + @Test + @DisplayName("10.3: Contention Resolution KRI flag resets system lock pending on EOR") + public void testContentionResolutionKriResetsSystemLock() { + ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + Telnet3270Client client = new Telnet3270Client(config); + ECLSession session = new ECLSession(client); + ECLPS ps = session.GetPS(); + DS3270 ds = new DS3270(session, ps); + + session.setContentionResolution(true); + + // Put in system lock + ps.lockKeyboard(8); + assertTrue(ps.islocked_SYSLOCK()); + + // Receive header with KRI flag (bit 1 = 0x02) and SDI flag (bit 0 = 0x01) + short s2 = (short) (DS3270.request_bit_SDI | DS3270.request_bit_KRI); + ds.receiveHeaderData((short) 0, s2, (short) 0, 0); + + assertTrue(ds.isSdi_flag()); + assertTrue(ds.isKri_flag()); + + ds.endOfRecord(); + + // Keyboard restored and system lock reset + assertFalse(client.getInputProcessor().isKeyboardLocked()); + assertFalse(ps.islocked_SYSLOCK()); + assertFalse(ds.isKri_flag()); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/Phase11ConnectionResilienceTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/Phase11ConnectionResilienceTest.java new file mode 100644 index 0000000..37b1573 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/Phase11ConnectionResilienceTest.java @@ -0,0 +1,397 @@ +package haus.nightmare.lib3270j.datastream; + +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.ConnectionState; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.ecl.ECLConnection; +import haus.nightmare.lib3270j.ecl.ECLSession; +import haus.nightmare.lib3270j.listener.ConnectionListener; +import haus.nightmare.lib3270j.protocol.TelnetConstants; +import haus.nightmare.lib3270j.telnet.TelnetConnection; +import haus.nightmare.lib3270j.telnet.TelnetFSM; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.Properties; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 11 Test Suite: Enterprise Connection Resilience & "Dirty" Connection Handling. + * + * Verifies: + * 11.1 Application-Layer Telnet Keep-Alive / Heartbeat Engine (NOP and TIMING-MARK, activity reset, shutdown). + * 11.2 Automatic Reconnection with Exponential Backoff (unexpected disconnect, state transitions, retry loop, recovery, exhaustion). + * 11.3 Transport-Level Socket Tuning & Options (soTimeout, extended socket options, CLI parsing). + * 11.4 IBM HoD ECL Compatibility Facade (SESSION_KEEPALIVE, keepAliveTimeout, SESSION_AUTORECONNECT, ECLConnection methods). + */ +public class Phase11ConnectionResilienceTest { + + // ========================================================================= + // 11.1 Application-Layer Telnet Keep-Alive / Heartbeat Engine + // ========================================================================= + + @Test + @DisplayName("11.1: Keep-Alive transmits Telnet IAC NOP (0xFF 0xF1) when idle") + public void testKeepAliveTransmitsIacNop() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + int port = server.getLocalPort(); + ConnectionConfig config = new ConnectionConfig("127.0.0.1", port); + config.setKeepAliveEnabled(true); + config.setKeepAliveIntervalSeconds(1); + config.setKeepAliveType("NOP"); + + Telnet3270Client client = new Telnet3270Client(config); + CountDownLatch clientConnected = new CountDownLatch(1); + + AtomicReference serverAccepted = new AtomicReference<>(); + Thread serverThread = new Thread(() -> { + try { + Socket s = server.accept(); + serverAccepted.set(s); + clientConnected.countDown(); + } catch (Exception ignored) {} + }); + serverThread.start(); + + client.connect(); + assertTrue(clientConnected.await(5, TimeUnit.SECONDS), "Server did not accept client connection"); + + Socket s = serverAccepted.get(); + assertNotNull(s); + InputStream in = s.getInputStream(); + + // Explicitly trigger or wait for keepalive heartbeat + client.getConnection().sendKeepAliveHeartbeat(); + + byte[] buf = new byte[2]; + int read = in.read(buf); + assertEquals(2, read); + assertEquals((byte) TelnetConstants.IAC, buf[0]); + assertEquals((byte) TelnetConstants.NOP, buf[1]); + + client.disconnect(); + } + } + + @Test + @DisplayName("11.1: Keep-Alive transmits Telnet IAC DO TIMING-MARK (0xFF 0xFD 0x06)") + public void testKeepAliveTransmitsTimingMark() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + int port = server.getLocalPort(); + ConnectionConfig config = new ConnectionConfig("127.0.0.1", port); + config.setKeepAliveEnabled(true); + config.setKeepAliveIntervalSeconds(1); + config.setKeepAliveType("TIMING-MARK"); + + Telnet3270Client client = new Telnet3270Client(config); + CountDownLatch clientConnected = new CountDownLatch(1); + + AtomicReference serverAccepted = new AtomicReference<>(); + Thread serverThread = new Thread(() -> { + try { + Socket s = server.accept(); + serverAccepted.set(s); + clientConnected.countDown(); + } catch (Exception ignored) {} + }); + serverThread.start(); + + client.connect(); + assertTrue(clientConnected.await(5, TimeUnit.SECONDS)); + + Socket s = serverAccepted.get(); + assertNotNull(s); + InputStream in = s.getInputStream(); + + client.getConnection().sendKeepAliveHeartbeat(); + + byte[] buf = new byte[3]; + int read = in.read(buf); + assertEquals(3, read); + assertEquals((byte) TelnetConstants.IAC, buf[0]); + assertEquals((byte) TelnetConstants.DO, buf[1]); + assertEquals((byte) TelnetConstants.TELOPT_TM, buf[2]); + + client.disconnect(); + } + } + + @Test + @DisplayName("11.1: Transmission resets last activity timestamp") + public void testTransmissionResetsActivityTimestamp() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + int port = server.getLocalPort(); + ConnectionConfig config = new ConnectionConfig("127.0.0.1", port); + Telnet3270Client client = new Telnet3270Client(config); + + new Thread(() -> { + try { + server.accept(); + } catch (Exception ignored) {} + }).start(); + + client.connect(); + TelnetConnection conn = client.getConnection(); + long initialTime = conn.getLastActivityTime(); + + Thread.sleep(15); + conn.sendRaw(new byte[] { 0x01, 0x02 }); + + long updatedTime = conn.getLastActivityTime(); + assertTrue(updatedTime >= initialTime + 10, "Activity timestamp was not updated on sendRaw"); + + client.disconnect(); + } + } + + // ========================================================================= + // 11.2 Automatic Reconnection with Exponential Backoff + // ========================================================================= + + @Test + @DisplayName("11.2: Unexpected EOF transitions to RECONNECTING state when autoReconnect is true") + public void testUnexpectedDisconnectTransitionsToReconnecting() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + int port = server.getLocalPort(); + ConnectionConfig config = new ConnectionConfig("127.0.0.1", port); + config.setAutoReconnect(true); + config.setReconnectMaxRetries(3); + + Telnet3270Client client = new Telnet3270Client(config); + CopyOnWriteArrayList stateHistory = new CopyOnWriteArrayList<>(); + CountDownLatch reconnectingLatch = new CountDownLatch(1); + + client.addConnectionListener(new ConnectionListener() { + @Override + public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) { + stateHistory.add(newState); + if (newState == ConnectionState.RECONNECTING) { + reconnectingLatch.countDown(); + } + } + @Override public void onConnectionError(String message) {} + }); + + AtomicReference acceptedSocket = new AtomicReference<>(); + Thread serverThread = new Thread(() -> { + try { + Socket s = server.accept(); + acceptedSocket.set(s); + } catch (Exception ignored) {} + }); + serverThread.start(); + + client.connect(); + serverThread.join(2000); + + assertNotNull(acceptedSocket.get()); + + // Abruptly sever the connection from the host side (EOF) + acceptedSocket.get().close(); + + assertTrue(reconnectingLatch.await(4, TimeUnit.SECONDS), "Client did not enter RECONNECTING state on unexpected EOF"); + assertTrue(stateHistory.contains(ConnectionState.RECONNECTING), "State history must include RECONNECTING"); + assertTrue(client.isReconnecting()); + + // Check OIA message updated + assertNotNull(client.getOIA()); + + client.disconnect(); + assertFalse(client.isReconnecting()); + assertEquals(ConnectionState.NOT_CONNECTED, client.getConnectionState()); + } + } + + @Test + @DisplayName("11.2: Intentional client.disconnect() transitions directly to NOT_CONNECTED without auto-reconnect") + public void testIntentionalDisconnectDoesNotTriggerAutoReconnect() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + int port = server.getLocalPort(); + ConnectionConfig config = new ConnectionConfig("127.0.0.1", port); + config.setAutoReconnect(true); + config.setReconnectMaxRetries(3); + + Telnet3270Client client = new Telnet3270Client(config); + CopyOnWriteArrayList stateHistory = new CopyOnWriteArrayList<>(); + + client.addConnectionListener(new ConnectionListener() { + @Override + public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) { + stateHistory.add(newState); + } + @Override public void onConnectionError(String message) {} + }); + + new Thread(() -> { + try { + server.accept(); + } catch (Exception ignored) {} + }).start(); + + client.connect(); + Thread.sleep(100); + + // Explicit client disconnect + client.disconnect(); + + assertFalse(stateHistory.contains(ConnectionState.RECONNECTING), + "Intentional disconnect should never transition to RECONNECTING"); + assertEquals(ConnectionState.NOT_CONNECTED, client.getConnectionState()); + } + } + + @Test + @DisplayName("11.2: Auto-reconnect retry loop successfully re-establishes connection") + public void testAutoReconnectSuccessfulReconnection() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + int port = server.getLocalPort(); + ConnectionConfig config = new ConnectionConfig("127.0.0.1", port); + config.setAutoReconnect(true); + config.setReconnectMaxRetries(3); + + Telnet3270Client client = new Telnet3270Client(config); + CountDownLatch reconnectedLatch = new CountDownLatch(1); + + AtomicReference firstConn = new AtomicReference<>(); + AtomicReference secondConn = new AtomicReference<>(); + + Thread serverThread = new Thread(() -> { + try { + Socket s1 = server.accept(); + firstConn.set(s1); + // Close first socket to trigger reconnect + Thread.sleep(50); + s1.close(); + + // Accept the reconnection attempt + Socket s2 = server.accept(); + secondConn.set(s2); + reconnectedLatch.countDown(); + } catch (Exception ignored) {} + }); + serverThread.start(); + + client.connect(); + + // Wait for reconnection to succeed + assertTrue(reconnectedLatch.await(8, TimeUnit.SECONDS), "Server did not receive reconnection attempt"); + assertNotNull(secondConn.get()); + + // Wait for client to complete handshake / notify connected + Thread.sleep(200); + assertTrue(client.isConnected()); + + client.disconnect(); + } + } + + // ========================================================================= + // 11.3 Transport-Level Socket Tuning & Options + // ========================================================================= + + @Test + @DisplayName("11.3: Configurable SO_TIMEOUT is applied to TCP socket") + public void testSoTimeoutApplied() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + int port = server.getLocalPort(); + ConnectionConfig config = new ConnectionConfig("127.0.0.1", port); + config.setSoTimeoutMs(750); + + Telnet3270Client client = new Telnet3270Client(config); + new Thread(() -> { + try { server.accept(); } catch (Exception ignored) {} + }).start(); + + client.connect(); + assertNotNull(client.getConnection()); + // Client socket should have SO_TIMEOUT applied + assertEquals(750, config.getSoTimeoutMs()); + + client.disconnect(); + } + } + + @Test + @DisplayName("11.3: parseHostString parses keepalive and autoreconnect flags") + public void testParseHostStringFlags() { + ConnectionConfig c1 = ConnectionConfig.parseHostString("mainframe.corp.com:23 --keepalive --autoreconnect", 23, TerminalModel.IBM_3279_4); + assertTrue(c1.isKeepAliveEnabled()); + assertTrue(c1.isAutoReconnect()); + assertEquals("mainframe.corp.com", c1.getHost()); + assertEquals(23, c1.getPort()); + + ConnectionConfig c2 = ConnectionConfig.parseHostString("mainframe.corp.com:23 --no-keepalive --no-autoreconnect", 23, TerminalModel.IBM_3279_4); + assertFalse(c2.isKeepAliveEnabled()); + assertFalse(c2.isAutoReconnect()); + } + + // ========================================================================= + // 11.4 IBM HoD ECL Compatibility Facade + // ========================================================================= + + @Test + @DisplayName("11.4: ECLSession parses HoD properties for Keep-Alive and Auto-Reconnect") + public void testEclSessionPropertiesParsing() { + Properties props = new Properties(); + props.setProperty(ECLSession.SESSION_HOST, "mvs01.test.net"); + props.setProperty(ECLSession.SESSION_PORT, "23"); + props.setProperty(ECLSession.SESSION_KEEPALIVE, "true"); + props.setProperty(ECLSession.KEY_KEEPALIVE_TIMEOUT, "180"); + props.setProperty(ECLSession.KEY_KEEPALIVE_TYPE, "TIMING-MARK"); + props.setProperty(ECLSession.SESSION_AUTORECONNECT, "true"); + props.setProperty(ECLSession.SESSION_RECONNECT_RETRIES, "8"); + + ECLSession session = new ECLSession(props); + assertTrue(session.isKeepAlive()); + assertEquals(180, session.getKeepAliveTimeout()); + assertTrue(session.isAutoReconnect()); + + ECLConnection conn = session.getConnection(); + assertNotNull(conn); + assertTrue(conn.isKeepAlive()); + assertEquals(180, conn.getKeepAliveTimeout()); + assertEquals("TIMING-MARK", conn.getKeepAliveType()); + assertTrue(conn.isAutoReconnect()); + assertEquals(8, conn.getMaxRetry()); + } + + @Test + @DisplayName("11.4: ECLConnection getters and setters synchronize with underlying client") + public void testEclConnectionGettersSetters() { + ConnectionConfig cfg = new ConnectionConfig("localhost", 23); + Telnet3270Client client = new Telnet3270Client(cfg); + ECLSession session = new ECLSession(client); + ECLConnection conn = session.getConnection(); + + conn.setKeepAlive(false); + assertFalse(conn.isKeepAlive()); + assertFalse(client.getConfig().isKeepAliveEnabled()); + + conn.setKeepAliveTimeout(60); + assertEquals(60, conn.getKeepAliveTimeout()); + assertEquals(60, client.getConfig().getKeepAliveIntervalSeconds()); + + conn.setKeepAliveType("TIMING-MARK"); + assertEquals("TIMING-MARK", conn.getKeepAliveType()); + assertEquals("TIMING-MARK", client.getConfig().getKeepAliveType()); + + conn.setAutoReconnect(true); + assertTrue(conn.isAutoReconnect()); + assertTrue(client.getConfig().isAutoReconnect()); + + conn.setMaxRetry(10); + assertEquals(10, conn.getMaxRetry()); + assertEquals(10, client.getConfig().getReconnectMaxRetries()); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/InteractiveEntryAssistTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/InteractiveEntryAssistTest.java new file mode 100644 index 0000000..d993de8 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/InteractiveEntryAssistTest.java @@ -0,0 +1,89 @@ +package haus.nightmare.lib3270j.ecl; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for Phase 13.2: Interactive Entry Assist Engine. + * Verifies interactive word wrap in DOC mode and audible bell alert + * when typing reaches the configured bell column. + */ +public class InteractiveEntryAssistTest { + + private ScreenBuffer screen; + private EbcdicTranslator translator; + private InputProcessor input; + + @BeforeEach + public void setUp() { + translator = new EbcdicTranslator(); + screen = new ScreenBuffer(TerminalModel.IBM_3278_2, translator); + input = new InputProcessor(screen, translator, null); + } + + @Test + public void testAudibleBellTriggersAtConfiguredBellColumn() { + input.setBellEnabled(true); + input.setBellColumn(74); // 0-based column 74 = column 75 + + AtomicInteger bellCount = new AtomicInteger(0); + input.setBellListener(bellCount::incrementAndGet); + + screen.setCursorPosition(0, 72); + input.typeCharacter('A'); // col 73 + assertEquals(0, bellCount.get()); + + input.typeCharacter('B'); // col 74 -> triggers bell + assertEquals(1, bellCount.get()); + + input.typeCharacter('C'); // col 75 -> remains 1 on current row + assertEquals(1, bellCount.get()); + + // Moving to next row and typing across bell column triggers bell again + screen.setCursorPosition(1, 73); + input.typeCharacter('X'); // advances to col 74 on row 1 + assertEquals(2, bellCount.get()); + } + + @Test + public void testDocModeWordWrapMovesPartialWordToNextRow() { + screen.setEntryAssistDOCmode(true); + screen.setEntryAssistWordWrap(true); + screen.setLeftMargin(0); // Col 0 + screen.setRightMargin(15); // Col 15 + + assertTrue(screen.isEntryAssistDOCmode()); + assertTrue(screen.isEntryAssistWordWrap()); + + // Type "HELLO " (6 chars) starting at col 0 + screen.setCursorPosition(0, 0); + for (char c : "HELLO ".toCharArray()) { + input.typeCharacter(c); + } + assertEquals(0, screen.getCursorRow()); + assertEquals(6, screen.getCursorCol()); + + // Move cursor near right margin: Row 0, Col 13 + screen.setCursorPosition(0, 13); + input.typeCharacter('P'); // col 14 + input.typeCharacter('A'); // col 15 (at right margin) + input.typeCharacter('R'); // crosses right margin -> triggers word wrap + + // After word wrap, the word "PAR" should be moved to Row 1, left margin (Col 0) + assertEquals(1, screen.getCursorRow()); + assertTrue(screen.getCursorCol() >= 3); + + // Verify content on Row 1 + assertEquals('P', (char) screen.getCell(80).ucs4); + assertEquals('A', (char) screen.getCell(81).ucs4); + assertEquals('R', (char) screen.getCell(82).ucs4); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/Phase12SynchronizationTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/Phase12SynchronizationTest.java new file mode 100644 index 0000000..e310d90 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/Phase12SynchronizationTest.java @@ -0,0 +1,319 @@ +package haus.nightmare.lib3270j.ecl; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.listener.ScreenUpdateListener; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 12 Test Suite: Reactive Synchronization & Thread-Safety Hardening. + * + * Verifies: + * 12.1 Condition-Based Synchronization for ECL Automation Calls: + * - Sub-millisecond reactive wakeup for waitForScreen(ECLScreenDesc). + * - Reactive wakeup for waitForCursor(row, col). + * - Reactive wakeup for waitForString(text) and waitWhileScreen(ECLScreenDesc). + * - Clean timeout handling without CPU busy-wait spinning. + * - Graceful thread interruption handling. + * - ECLOIA condition-based reactive waitForInput and waitForTransition. + * - ScreenBuffer listener dispatch and cursor change tracking. + * - Concurrency safety with multiple waiters. + */ +public class Phase12SynchronizationTest { + + private ScreenBuffer screen; + private EbcdicTranslator translator; + private InputProcessor inputProcessor; + private ECLPS ps; + + @BeforeEach + public void setUp() { + translator = new EbcdicTranslator(); + screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator); + inputProcessor = new InputProcessor(screen, translator, null); + ps = new ECLPS(screen, inputProcessor, translator); + } + + @Test + @DisplayName("12.1: waitForScreen(ECLScreenDesc) wakes up reactively upon screen update") + public void testReactiveWaitForScreenDesc() throws Exception { + ECLScreenDesc desc = new ECLScreenDesc(); + desc.addString("TSO/E LOGON", 5, 10); + + AtomicBoolean result = new AtomicBoolean(false); + AtomicLong durationMs = new AtomicLong(-1); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(1); + + Thread waiter = new Thread(() -> { + started.countDown(); + long t0 = System.currentTimeMillis(); + boolean matched = ps.waitForScreen(desc, 5000); + durationMs.set(System.currentTimeMillis() - t0); + result.set(matched); + done.countDown(); + }); + waiter.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + Thread.sleep(50); // Give waiter time to block on condition + + // Write matching text at 1-based (5, 10) -> 0-based row 4 col 9 + screen.setText("TSO/E LOGON", 4, 9); + screen.notifyScreenUpdate(); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertTrue(result.get(), "waitForScreen should match"); + assertTrue(durationMs.get() < 1500, "Should unblock reactively well before 5000ms timeout (took " + durationMs.get() + "ms)"); + } + + @Test + @DisplayName("12.1: waitForCursor(row, col) wakes up reactively upon cursor position change") + public void testReactiveWaitForCursor() throws Exception { + screen.setCursorPosition(0, 0); + + AtomicBoolean result = new AtomicBoolean(false); + AtomicLong durationMs = new AtomicLong(-1); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(1); + + Thread waiter = new Thread(() -> { + started.countDown(); + long t0 = System.currentTimeMillis(); + boolean matched = ps.waitForCursor(12, 34, 5000); + durationMs.set(System.currentTimeMillis() - t0); + result.set(matched); + done.countDown(); + }); + waiter.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + Thread.sleep(50); + + // Move cursor + screen.setCursorPosition(12, 34); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertTrue(result.get(), "waitForCursor should succeed"); + assertTrue(durationMs.get() < 1500, "Should unblock reactively upon cursor move (took " + durationMs.get() + "ms)"); + } + + @Test + @DisplayName("12.1: waitForString(text) wakes up reactively upon string appearance") + public void testReactiveWaitForString() throws Exception { + AtomicBoolean result = new AtomicBoolean(false); + AtomicLong durationMs = new AtomicLong(-1); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(1); + + Thread waiter = new Thread(() -> { + started.countDown(); + long t0 = System.currentTimeMillis(); + boolean matched = ps.waitForString("COMMAND ===>", 5000); + durationMs.set(System.currentTimeMillis() - t0); + result.set(matched); + done.countDown(); + }); + waiter.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + Thread.sleep(50); + + screen.setText("COMMAND ===>", 20, 2); + screen.notifyScreenUpdate(); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertTrue(result.get(), "waitForString should find the text"); + assertTrue(durationMs.get() < 1500, "Should unblock reactively (took " + durationMs.get() + "ms)"); + } + + @Test + @DisplayName("12.1: waitWhileScreen(desc) wakes up reactively when condition ceases to match") + public void testReactiveWaitWhileScreen() throws Exception { + // Place initial text + screen.setText("HOLDING", 0, 0); + + ECLScreenDesc desc = new ECLScreenDesc(); + desc.addString("HOLDING"); + + AtomicBoolean result = new AtomicBoolean(false); + AtomicLong durationMs = new AtomicLong(-1); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(1); + + Thread waiter = new Thread(() -> { + started.countDown(); + long t0 = System.currentTimeMillis(); + boolean finished = ps.waitWhileScreen(desc, 5000); + durationMs.set(System.currentTimeMillis() - t0); + result.set(finished); + done.countDown(); + }); + waiter.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + Thread.sleep(50); + + // Erase screen so "HOLDING" is gone + screen.erase(false); + screen.notifyScreenUpdate(); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertTrue(result.get(), "waitWhileScreen should return true once condition is gone"); + assertTrue(durationMs.get() < 1500, "Should unblock reactively when text erased (took " + durationMs.get() + "ms)"); + } + + @Test + @DisplayName("12.1: Clean timeout without false match or spinning") + public void testCleanTimeout() { + ECLScreenDesc desc = new ECLScreenDesc(); + desc.addString("NON_EXISTENT_STRING"); + + long t0 = System.currentTimeMillis(); + boolean matched = ps.waitForScreen(desc, 150); + long elapsed = System.currentTimeMillis() - t0; + + assertFalse(matched, "Should not match non-existent text"); + assertTrue(elapsed >= 100, "Should have waited for the full timeout window (elapsed=" + elapsed + "ms)"); + } + + @Test + @DisplayName("12.1: Graceful thread interruption during wait") + public void testThreadInterruption() throws Exception { + ECLScreenDesc desc = new ECLScreenDesc(); + desc.addString("NEVER_APPEARS"); + + AtomicBoolean wasInterrupted = new AtomicBoolean(false); + AtomicBoolean result = new AtomicBoolean(true); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(1); + + Thread waiter = new Thread(() -> { + started.countDown(); + boolean r = ps.waitForScreen(desc, 10000); + result.set(r); + if (Thread.currentThread().isInterrupted()) { + wasInterrupted.set(true); + } + done.countDown(); + }); + waiter.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + Thread.sleep(50); + + waiter.interrupt(); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertFalse(result.get(), "Interrupted wait should return false"); + assertTrue(wasInterrupted.get(), "Thread interrupt flag should be preserved"); + } + + @Test + @DisplayName("12.1: ECLOIA reactive waitForInput and waitForTransition") + public void testECLOIAReactiveWait() throws Exception { + ECLOIA oia = new ECLOIA(screen, inputProcessor, null); + oia.setInputInhibited(ECLOIA.INHIBIT_SYSTEMWAIT); + + AtomicBoolean inputReady = new AtomicBoolean(false); + AtomicLong durationMs = new AtomicLong(-1); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(1); + + Thread waiter = new Thread(() -> { + started.countDown(); + long t0 = System.currentTimeMillis(); + boolean ready = oia.waitForInput(5000); + durationMs.set(System.currentTimeMillis() - t0); + inputReady.set(ready); + done.countDown(); + }); + waiter.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + Thread.sleep(50); + + // Unlock OIA + oia.setInputInhibited(ECLOIA.INHIBIT_NOTINHIBITED); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertTrue(inputReady.get(), "waitForInput should succeed"); + assertTrue(durationMs.get() < 1500, "Should unblock reactively upon OIA unlock (took " + durationMs.get() + "ms)"); + } + + @Test + @DisplayName("12.1: ScreenBuffer listener dispatch and cursor movement tracking") + public void testScreenBufferListenerDispatch() { + AtomicBoolean screenUpdated = new AtomicBoolean(false); + AtomicInteger cursorOld = new AtomicInteger(-1); + AtomicInteger cursorNew = new AtomicInteger(-1); + + ScreenUpdateListener listener = new ScreenUpdateListener() { + @Override + public void onScreenUpdated() { + screenUpdated.set(true); + } + + @Override + public void onCursorMoved(int oldAddress, int newAddress) { + cursorOld.set(oldAddress); + cursorNew.set(newAddress); + } + }; + + screen.addUpdateListener(listener); + + screen.notifyScreenUpdate(); + assertTrue(screenUpdated.get(), "Listener should receive onScreenUpdated"); + + screen.setCursorAddress(123); + assertEquals(0, cursorOld.get(), "Old cursor should be 0"); + assertEquals(123, cursorNew.get(), "New cursor should be 123"); + + screen.removeUpdateListener(listener); + screenUpdated.set(false); + screen.notifyScreenUpdate(); + assertFalse(screenUpdated.get(), "Removed listener should not receive updates"); + } + + @Test + @DisplayName("12.1: Concurrent waiters on the same ScreenBuffer") + public void testConcurrentWaiters() throws Exception { + int numWaiters = 5; + CountDownLatch startLatch = new CountDownLatch(numWaiters); + CountDownLatch doneLatch = new CountDownLatch(numWaiters); + AtomicInteger successCount = new AtomicInteger(0); + + for (int i = 0; i < numWaiters; i++) { + new Thread(() -> { + startLatch.countDown(); + boolean ok = ps.waitForString("BATCH_SIGNAL", 5000); + if (ok) { + successCount.incrementAndGet(); + } + doneLatch.countDown(); + }).start(); + } + + assertTrue(startLatch.await(2, TimeUnit.SECONDS)); + Thread.sleep(50); + + screen.setText("BATCH_SIGNAL", 100); + screen.notifyScreenUpdate(); + + assertTrue(doneLatch.await(3, TimeUnit.SECONDS)); + assertEquals(numWaiters, successCount.get(), "All concurrent waiters should unblock reactively"); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/Phase5EclEventTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/Phase5EclEventTest.java index 5c2d026..215f10f 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/Phase5EclEventTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/Phase5EclEventTest.java @@ -10,9 +10,9 @@ import haus.nightmare.lib3270j.charset.EbcdicTranslator; import haus.nightmare.lib3270j.input.InputProcessor; import haus.nightmare.lib3270j.screen.ScreenBuffer; -import java.awt.Image; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; +import haus.nightmare.lib3270j.graphics.DefaultPixelBuffer; +import haus.nightmare.lib3270j.graphics.PixelBuffer; +import haus.nightmare.lib3270j.graphics.Rectangle; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -406,11 +406,12 @@ public class Phase5EclEventTest { } // Test with image and rectangle - Image testImage = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB); + PixelBuffer testImage = new DefaultPixelBuffer(32, 32); Rectangle rect = new Rectangle(0, 0, 32, 32); ECLPSGraphicsEvent fullGEvent = new ECLPSGraphicsEvent(ps, ECLPSGraphicsEvent.GRAPHICS_UPDATED, testImage, rect); assertSame(testImage, fullGEvent.GetImage()); assertSame(testImage, fullGEvent.getImage()); + assertSame(testImage, fullGEvent.getPixelBuffer()); assertEquals(rect, fullGEvent.GetRectangle()); assertEquals(rect, fullGEvent.getRectangle()); diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/TabularPasteTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/TabularPasteTest.java new file mode 100644 index 0000000..c123fbd --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ecl/TabularPasteTest.java @@ -0,0 +1,124 @@ +package haus.nightmare.lib3270j.ecl; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +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.*; + +/** + * Unit tests for Phase 13.1: Excel & Tabular Clipboard Paste. + * Verifies tab-delimited text navigation, newline row advancement, + * and boundary truncation when pasteStopAtProtectedLine is set. + */ +public class TabularPasteTest { + + 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 testExcelPasteTabsAndNewlinesAdvanceAcrossFields() { + // Setup 2 rows with 2 unprotected fields each + // Row 0: pos 0 (unprotected), pos 10 (protected), pos 11 (unprotected), pos 30 (protected) + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.setFieldAttribute(10, (byte) (FA_PRINTABLE | FA_PROTECT)); + screen.setFieldAttribute(11, (byte) FA_PRINTABLE); + screen.setFieldAttribute(30, (byte) (FA_PRINTABLE | FA_PROTECT)); + + // Row 1: pos 80 (unprotected), pos 90 (protected), pos 91 (unprotected), pos 110 (protected) + screen.setFieldAttribute(80, (byte) FA_PRINTABLE); + screen.setFieldAttribute(90, (byte) (FA_PRINTABLE | FA_PROTECT)); + screen.setFieldAttribute(91, (byte) FA_PRINTABLE); + screen.setFieldAttribute(110, (byte) (FA_PRINTABLE | FA_PROTECT)); + + // Position cursor at first field (pos 1) + screen.setCursorAddress(1); + + String tabularData = "ABC\tDEF\r\nGHI\tJKL"; + int pasted = input.pasteText(tabularData, true, false); + assertEquals(12, pasted); + + // Verify Field 1 (Row 0, Col 1-3) + assertEquals("ABC", ps.getString(1, 3)); + // Verify Field 2 (Row 0, Col 12-14) + assertEquals("DEF", ps.getString(12, 3)); + // Verify Field 3 (Row 1, Col 1-3 -> pos 81) + assertEquals("GHI", ps.getString(81, 3)); + // Verify Field 4 (Row 1, Col 12-14 -> pos 92) + assertEquals("JKL", ps.getString(92, 3)); + } + + @Test + public void testPasteStopAtProtectedBoundaryHaltsPaste() { + // Field 1: pos 0 (unprotected), pos 5 (protected) + // Data area is pos 1..4 (4 characters) + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.setFieldAttribute(5, (byte) (FA_PRINTABLE | FA_PROTECT)); + screen.setFieldAttribute(10, (byte) FA_PRINTABLE); + + screen.setCursorAddress(1); + + // Try to paste 6 characters when only 4 fit + String overflow = "123456"; + int pasted = input.pasteText(overflow, false, true); + + // Should paste 4 chars and halt at the protected boundary pos 5 + assertEquals(4, pasted); + assertEquals("1234", ps.getString(1, 4)); + // Field at pos 10 should not be touched + assertEquals(" ", ps.getString(11, 1)); + } + + @Test + public void testPasteStopAtProtectedLineHaltsAtProtectedRow() { + // Row 0: unprotected field at pos 0 + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + // Row 1: entirely protected starting at pos 80 + screen.setFieldAttribute(80, (byte) (FA_PRINTABLE | FA_PROTECT)); + + screen.setCursorAddress(1); + + String multiRow = "DATA1\r\nDATA2"; + int pasted = input.pasteText(multiRow, true, true); + + // "DATA1" (5 chars) pasted, then newline sees next line is protected and halts + assertEquals(5, pasted); + assertEquals("DATA1", ps.getString(1, 5)); + } + + @Test + public void testEclPsPasteFromExcelMethodsAndFlags() { + assertTrue(ps.isEnablePasteFromExcel()); + assertFalse(ps.isPasteStopAtProtectedLine()); + + ps.setEnablePasteFromExcel(false); + assertFalse(ps.isEnablePasteFromExcel()); + + ps.setPasteStopAtProtectedLine(true); + assertTrue(ps.isPasteStopAtProtectedLine()); + + // Setup fields + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.setFieldAttribute(10, (byte) (FA_PRINTABLE | FA_PROTECT)); + screen.setFieldAttribute(11, (byte) FA_PRINTABLE); + screen.setFieldAttribute(30, (byte) (FA_PRINTABLE | FA_PROTECT)); + + ps.pasteFromExcel("HELLO\tWORLD", 0, 1); + assertEquals("HELLO", ps.getString(1, 5)); + assertEquals("WORLD", ps.getString(12, 5)); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/AwtDecouplingAuditTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/AwtDecouplingAuditTest.java new file mode 100644 index 0000000..c614fbd --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/AwtDecouplingAuditTest.java @@ -0,0 +1,100 @@ +package haus.nightmare.lib3270j.graphics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Architectural compliance audit test verifying 100% decoupling of lib3270j from java.awt. + * Ensures headless execution and portability to Android (which lacks java.awt.*). + */ +public class AwtDecouplingAuditTest { + + private static final Pattern IMPORT_AWT_PATTERN = Pattern.compile("^\\s*import\\s+java\\.awt\\..*;"); + private static final Pattern DIRECT_AWT_USAGE_PATTERN = Pattern.compile("(? violations = new ArrayList<>(); + + try (Stream paths = Files.walk(srcMainDir.toPath())) { + List javaFiles = paths + .filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".java")) + .collect(Collectors.toList()); + + assertFalse(javaFiles.isEmpty(), "Found no java files in " + srcMainDir); + + for (Path p : javaFiles) { + List lines = Files.readAllLines(p); + for (int lineNum = 0; lineNum < lines.size(); lineNum++) { + String line = lines.get(lineNum); + String trimmed = line.trim(); + + // Ignore comment lines + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { + continue; + } + + if (IMPORT_AWT_PATTERN.matcher(line).find()) { + violations.add(String.format("%s:%d -> %s", p.getFileName(), lineNum + 1, trimmed)); + } else if (line.contains("java.awt.") && !line.contains("Class.forName(\"java.awt.") && !line.contains("\"java.awt.")) { + violations.add(String.format("%s:%d [raw type reference] -> %s", p.getFileName(), lineNum + 1, trimmed)); + } + } + } + } + + if (!violations.isEmpty()) { + fail("Found java.awt dependencies in lib3270j main source:\n" + String.join("\n", violations)); + } + } + + @Test + @DisplayName("Verify PixelBuffer and software rasterizer functions without AWT") + public void testPixelBufferPureJavaOperations() { + PixelBuffer pb = new DefaultPixelBuffer(100, 100); + assertEquals(100, pb.getWidth()); + assertEquals(100, pb.getHeight()); + + // Fill rect + pb.fillRect(10, 10, 20, 20, 0xFFFF0000); + assertEquals(0xFFFF0000, pb.getPixel(15, 15)); + assertEquals(0, pb.getPixel(5, 5)); + + // Draw line AA + pb.drawLineAA(0, 0, 99, 99, 0xFF00FF00, 1.0); + int diagPixel = pb.getPixel(50, 50); + assertNotEquals(0, diagPixel, "Anti-aliased diagonal line should render pixels"); + + // Clipping + pb.setClip(20, 20, 10, 10); + assertTrue(pb.isClipped(2, 80)); + assertFalse(pb.isClipped(25, 25)); + assertEquals(0, pb.getPixel(2, 80)); + pb.setPixel(2, 80, 0xFF0000FF); + assertEquals(0, pb.getPixel(2, 80), "Clipped pixel must not be modified"); + + pb.clearClip(); + assertFalse(pb.isClipped(5, 5)); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java index 0d63f99..7e31a07 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java @@ -1,7 +1,6 @@ package haus.nightmare.lib3270j.graphics; import org.junit.jupiter.api.Test; -import java.awt.Point; import static org.junit.jupiter.api.Assertions.*; diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderPhase5Test.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderPhase5Test.java index 8eed8fa..58e512f 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderPhase5Test.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderPhase5Test.java @@ -2,7 +2,6 @@ package haus.nightmare.lib3270j.graphics; import org.junit.jupiter.api.Test; import java.io.ByteArrayOutputStream; -import java.awt.Point; import static org.junit.jupiter.api.Assertions.*; diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase1ColorCalibrationTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase1ColorCalibrationTest.java index f8a4f16..5b05224 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase1ColorCalibrationTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase1ColorCalibrationTest.java @@ -7,8 +7,6 @@ import haus.nightmare.lib3270j.screen.ScreenBuffer; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.awt.Color; - import static org.junit.jupiter.api.Assertions.*; /** diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase2GocaEngineTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase2GocaEngineTest.java index abc02aa..934f1e8 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase2GocaEngineTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase2GocaEngineTest.java @@ -4,14 +4,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.awt.Color; -import java.awt.Dimension; -import java.awt.Graphics; -import java.awt.Image; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; - import static org.junit.jupiter.api.Assertions.*; /** @@ -201,7 +193,7 @@ public class Phase2GocaEngineTest { @Test @DisplayName("Test HODWallpaper Tile, Center, and Stretch") public void testHODWallpaper() { - BufferedImage img = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB); + PixelBuffer img = new DefaultPixelBuffer(32, 32); HODWallpaper wp = new HODWallpaper(img, HODWallpaper.HOD_CENTER); assertEquals(HODWallpaper.HOD_CENTER, wp.getDisplay()); @@ -211,10 +203,8 @@ public class Phase2GocaEngineTest { wp.setDisplay(HODWallpaper.HOD_STRETCH); assertEquals(HODWallpaper.HOD_STRETCH, wp.getDisplay()); - BufferedImage canvas = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB); - Graphics g = canvas.getGraphics(); - wp.paint(new java.awt.Canvas(), g, 0, 0, 100, 100); - g.dispose(); + PixelBuffer canvas = new DefaultPixelBuffer(100, 100); + wp.paint(canvas, 0, 0, 100, 100); } @Test @@ -283,8 +273,9 @@ public class Phase2GocaEngineTest { assertEquals(101, bounds.width); assertEquals(101, bounds.height); - Image img = fa.getImage(); + Object img = fa.getImage(); assertNotNull(img); + assertNotNull(fa.getPixelBuffer()); fa.dispose(); } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/Phase3InteractiveSelectionTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/Phase3InteractiveSelectionTest.java index e987cb7..1167ee4 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/Phase3InteractiveSelectionTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/Phase3InteractiveSelectionTest.java @@ -10,7 +10,7 @@ import haus.nightmare.lib3270j.screen.ScreenBuffer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.awt.Point; +import haus.nightmare.lib3270j.graphics.Point; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/integration/LiveHostHeadlessTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/integration/LiveHostHeadlessTest.java new file mode 100644 index 0000000..f17f6ac --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/integration/LiveHostHeadlessTest.java @@ -0,0 +1,111 @@ +package haus.nightmare.lib3270j.integration; + +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.ConnectionState; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.listener.ConnectionListener; +import haus.nightmare.lib3270j.listener.ScreenUpdateListener; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Live integration test verifying headless connection to live MVS host: + * mvs.hugfreevikings.wtf:1023 using guest credentials. + */ +@Tag("integration") +public class LiveHostHeadlessTest { + + private static final Logger logger = Logger.getLogger(LiveHostHeadlessTest.class.getName()); + private static final String HOST = "mvs.hugfreevikings.wtf"; + private static final int PORT = 1023; + + @Test + @DisplayName("Connect headless Telnet3270Client to mvs.hugfreevikings.wtf:1023") + public void testLiveMvsConnection() throws Exception { + // Probe host reachability (timeout 3 seconds) + boolean reachable = false; + try (Socket probe = new Socket()) { + probe.connect(new InetSocketAddress(HOST, PORT), 3000); + reachable = true; + } catch (IOException e) { + logger.warning("Live host " + HOST + ":" + PORT + " unreachable: " + e.getMessage()); + } + + Assumptions.assumeTrue(reachable, "Skipping live test: " + HOST + ":" + PORT + " is not reachable"); + + ConnectionConfig config = new ConnectionConfig(HOST, PORT, TerminalModel.IBM_3279_2); + config.setConnectTimeoutMs(5000); + config.setSoTimeoutMs(10000); + + Telnet3270Client client = new Telnet3270Client(config); + + CountDownLatch connectedLatch = new CountDownLatch(1); + CountDownLatch screenLatch = new CountDownLatch(1); + + client.addConnectionListener(new ConnectionListener() { + @Override + public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) { + if (newState == ConnectionState.CONNECTED_3270) { + connectedLatch.countDown(); + } + } + + @Override + public void onConnectionError(String message) {} + }); + + client.addScreenUpdateListener(new ScreenUpdateListener() { + @Override + public void onScreenUpdated() { + ScreenBuffer sb = client.getScreenBuffer(); + if (sb != null && sb.isFormatted()) { + screenLatch.countDown(); + } + } + }); + + try { + client.connect(); + + boolean connected = connectedLatch.await(5, TimeUnit.SECONDS); + assertTrue(connected, "Client must establish CONNECTED_3270 state"); + + boolean screenReceived = screenLatch.await(5, TimeUnit.SECONDS); + assertTrue(screenReceived, "Client must receive formatted screen from live MVS host"); + + ScreenBuffer sb = client.getScreenBuffer(); + assertNotNull(sb); + + StringBuilder fullText = new StringBuilder(); + for (int i = 0; i < sb.getSize(); i++) { + char c = sb.getCell(i).ucs4; + fullText.append(c > ' ' ? c : ' '); + } + String screenContent = fullText.toString().trim(); + assertFalse(screenContent.isEmpty(), "Screen buffer text should not be empty"); + + // Verify headless graphics plane is initialized without AWT + assertNotNull(client.getGraphicsPlane()); + assertEquals(720, client.getGraphicsPlane().getWidth()); + assertEquals(384, client.getGraphicsPlane().getHeight()); + + logger.info("Successfully connected to live host. Banner preview: " + + screenContent.substring(0, Math.min(200, screenContent.length())).replaceAll("\\s+", " ")); + } finally { + client.disconnect(); + } + } +}