commit 64d713a283b7a355ebdc96ceb9ee054eb3bd291d Author: Rudi Date: Tue Apr 14 09:18:03 2026 -0400 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ea0a7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +*.jar diff --git a/.gradle/8.5/checksums/checksums.lock b/.gradle/8.5/checksums/checksums.lock new file mode 100644 index 0000000..a20b0af Binary files /dev/null and b/.gradle/8.5/checksums/checksums.lock differ diff --git a/.gradle/8.5/checksums/md5-checksums.bin b/.gradle/8.5/checksums/md5-checksums.bin new file mode 100644 index 0000000..1a70dfd Binary files /dev/null and b/.gradle/8.5/checksums/md5-checksums.bin differ diff --git a/.gradle/8.5/checksums/sha1-checksums.bin b/.gradle/8.5/checksums/sha1-checksums.bin new file mode 100644 index 0000000..f1e86c1 Binary files /dev/null and b/.gradle/8.5/checksums/sha1-checksums.bin differ diff --git a/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock b/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock new file mode 100644 index 0000000..79fd8ed Binary files /dev/null and b/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock differ diff --git a/.gradle/8.5/dependencies-accessors/gc.properties b/.gradle/8.5/dependencies-accessors/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/8.5/fileChanges/last-build.bin b/.gradle/8.5/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/8.5/fileChanges/last-build.bin differ diff --git a/.gradle/8.5/fileHashes/fileHashes.lock b/.gradle/8.5/fileHashes/fileHashes.lock new file mode 100644 index 0000000..8be93d8 Binary files /dev/null and b/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.5/gc.properties b/.gradle/8.5/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/9.2.0/checksums/checksums.lock b/.gradle/9.2.0/checksums/checksums.lock new file mode 100644 index 0000000..d391b44 Binary files /dev/null and b/.gradle/9.2.0/checksums/checksums.lock differ diff --git a/.gradle/9.2.0/checksums/md5-checksums.bin b/.gradle/9.2.0/checksums/md5-checksums.bin new file mode 100644 index 0000000..80d68af Binary files /dev/null and b/.gradle/9.2.0/checksums/md5-checksums.bin differ diff --git a/.gradle/9.2.0/checksums/sha1-checksums.bin b/.gradle/9.2.0/checksums/sha1-checksums.bin new file mode 100644 index 0000000..77e51ad Binary files /dev/null and b/.gradle/9.2.0/checksums/sha1-checksums.bin differ diff --git a/.gradle/9.2.0/fileChanges/last-build.bin b/.gradle/9.2.0/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/9.2.0/fileChanges/last-build.bin differ diff --git a/.gradle/9.2.0/fileHashes/fileHashes.bin b/.gradle/9.2.0/fileHashes/fileHashes.bin new file mode 100644 index 0000000..7412165 Binary files /dev/null and b/.gradle/9.2.0/fileHashes/fileHashes.bin differ diff --git a/.gradle/9.2.0/fileHashes/fileHashes.lock b/.gradle/9.2.0/fileHashes/fileHashes.lock new file mode 100644 index 0000000..2303a76 Binary files /dev/null and b/.gradle/9.2.0/fileHashes/fileHashes.lock differ diff --git a/.gradle/9.2.0/gc.properties b/.gradle/9.2.0/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..b6cc982 Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..2a7ea30 --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Wed Mar 18 14:12:38 EDT 2026 +gradle.version=8.5 diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfd18c8 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ + +# j3270 + +An x3270 based emulator ported to Java using AI + + +## Acknowledgements + + - [x3270](https://awesomeopensource.com/project/elangosundar/awesome-README-templates) + - [RFC ](https://github.com/matiassingers/awesome-readme) + - [Claude](https://bulldogjob.com/news/449-how-to-write-a-good-readme-for-your-github-project) + - [Antigravity]() + - [Gemini ]() + +## Deployment + +To build your own jar you can read or use run.sh + +```bash + ./run.sh +``` +Or from the jar [with optional config] +```bash +java -jar j3270.jar [-c config.ini] +``` + +## Contributing + +Contributions are welcome! + +I have contributed no code to this project, it was entirely written by LLMs with my guidance only. + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..df820f0 --- /dev/null +++ b/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'java' +} + +allprojects { + group = 'org.j3270' + version = '0.1.0' + + repositories { + mavenCentral() + } +} + +subprojects { + apply plugin: 'java' + + java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + test { + useJUnitPlatform() + } + + dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + } +} diff --git a/build/MANIFEST.MF b/build/MANIFEST.MF new file mode 100644 index 0000000..3cf97e5 --- /dev/null +++ b/build/MANIFEST.MF @@ -0,0 +1,2 @@ +Main-Class: org.pubvm.j3270.J3270App + diff --git a/build/j3270/org/pubvm/j3270/J3270App$1.class b/build/j3270/org/pubvm/j3270/J3270App$1.class new file mode 100644 index 0000000..e8a51ce Binary files /dev/null and b/build/j3270/org/pubvm/j3270/J3270App$1.class differ diff --git a/build/j3270/org/pubvm/j3270/J3270App$2.class b/build/j3270/org/pubvm/j3270/J3270App$2.class new file mode 100644 index 0000000..c31a9ed Binary files /dev/null and b/build/j3270/org/pubvm/j3270/J3270App$2.class differ diff --git a/build/j3270/org/pubvm/j3270/J3270App.class b/build/j3270/org/pubvm/j3270/J3270App.class new file mode 100644 index 0000000..daefe95 Binary files /dev/null and b/build/j3270/org/pubvm/j3270/J3270App.class differ diff --git a/build/j3270/org/pubvm/j3270/config/Settings$StartupBehavior.class b/build/j3270/org/pubvm/j3270/config/Settings$StartupBehavior.class new file mode 100644 index 0000000..cd3bfd2 Binary files /dev/null and b/build/j3270/org/pubvm/j3270/config/Settings$StartupBehavior.class differ diff --git a/build/j3270/org/pubvm/j3270/config/Settings.class b/build/j3270/org/pubvm/j3270/config/Settings.class new file mode 100644 index 0000000..7c1bc03 Binary files /dev/null and b/build/j3270/org/pubvm/j3270/config/Settings.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/ConnectDialog.class b/build/j3270/org/pubvm/j3270/ui/ConnectDialog.class new file mode 100644 index 0000000..e8ba19b Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/ConnectDialog.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$1.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$1.class new file mode 100644 index 0000000..5609b4a Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$1.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$2.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$2.class new file mode 100644 index 0000000..d4c041d Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$2.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$3.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$3.class new file mode 100644 index 0000000..f85b833 Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$3.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog$4.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$4.class new file mode 100644 index 0000000..7480083 Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog$4.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/SettingsDialog.class b/build/j3270/org/pubvm/j3270/ui/SettingsDialog.class new file mode 100644 index 0000000..2720300 Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/SettingsDialog.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/StatusBar$1.class b/build/j3270/org/pubvm/j3270/ui/StatusBar$1.class new file mode 100644 index 0000000..c4929f9 Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/StatusBar$1.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/StatusBar.class b/build/j3270/org/pubvm/j3270/ui/StatusBar.class new file mode 100644 index 0000000..e157413 Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/StatusBar.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/TerminalPanel$1.class b/build/j3270/org/pubvm/j3270/ui/TerminalPanel$1.class new file mode 100644 index 0000000..27b7e2d Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/TerminalPanel$1.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/TerminalPanel$2.class b/build/j3270/org/pubvm/j3270/ui/TerminalPanel$2.class new file mode 100644 index 0000000..2f3341d Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/TerminalPanel$2.class differ diff --git a/build/j3270/org/pubvm/j3270/ui/TerminalPanel.class b/build/j3270/org/pubvm/j3270/ui/TerminalPanel.class new file mode 100644 index 0000000..cc1a8cf Binary files /dev/null and b/build/j3270/org/pubvm/j3270/ui/TerminalPanel.class differ diff --git a/build/lib3270j/org/lib3270j/ConnectionConfig.class b/build/lib3270j/org/lib3270j/ConnectionConfig.class new file mode 100644 index 0000000..c724b82 Binary files /dev/null and b/build/lib3270j/org/lib3270j/ConnectionConfig.class differ diff --git a/build/lib3270j/org/lib3270j/ConnectionState.class b/build/lib3270j/org/lib3270j/ConnectionState.class new file mode 100644 index 0000000..6b99c5d Binary files /dev/null and b/build/lib3270j/org/lib3270j/ConnectionState.class differ diff --git a/build/lib3270j/org/lib3270j/DiagnosticClient$1.class b/build/lib3270j/org/lib3270j/DiagnosticClient$1.class new file mode 100644 index 0000000..f97f102 Binary files /dev/null and b/build/lib3270j/org/lib3270j/DiagnosticClient$1.class differ diff --git a/build/lib3270j/org/lib3270j/DiagnosticClient$2.class b/build/lib3270j/org/lib3270j/DiagnosticClient$2.class new file mode 100644 index 0000000..8ba4e60 Binary files /dev/null and b/build/lib3270j/org/lib3270j/DiagnosticClient$2.class differ diff --git a/build/lib3270j/org/lib3270j/DiagnosticClient.class b/build/lib3270j/org/lib3270j/DiagnosticClient.class new file mode 100644 index 0000000..f4c643d Binary files /dev/null and b/build/lib3270j/org/lib3270j/DiagnosticClient.class differ diff --git a/build/lib3270j/org/lib3270j/Telnet3270Client.class b/build/lib3270j/org/lib3270j/Telnet3270Client.class new file mode 100644 index 0000000..9525283 Binary files /dev/null and b/build/lib3270j/org/lib3270j/Telnet3270Client.class differ diff --git a/build/lib3270j/org/lib3270j/TerminalModel.class b/build/lib3270j/org/lib3270j/TerminalModel.class new file mode 100644 index 0000000..bf03ca1 Binary files /dev/null and b/build/lib3270j/org/lib3270j/TerminalModel.class differ diff --git a/build/lib3270j/org/lib3270j/charset/EbcdicTranslator.class b/build/lib3270j/org/lib3270j/charset/EbcdicTranslator.class new file mode 100644 index 0000000..2469a25 Binary files /dev/null and b/build/lib3270j/org/lib3270j/charset/EbcdicTranslator.class differ diff --git a/build/lib3270j/org/lib3270j/datastream/DataStreamProcessor$OutputSender.class b/build/lib3270j/org/lib3270j/datastream/DataStreamProcessor$OutputSender.class new file mode 100644 index 0000000..cfce635 Binary files /dev/null and b/build/lib3270j/org/lib3270j/datastream/DataStreamProcessor$OutputSender.class differ diff --git a/build/lib3270j/org/lib3270j/datastream/DataStreamProcessor.class b/build/lib3270j/org/lib3270j/datastream/DataStreamProcessor.class new file mode 100644 index 0000000..95f3fc0 Binary files /dev/null and b/build/lib3270j/org/lib3270j/datastream/DataStreamProcessor.class differ diff --git a/build/lib3270j/org/lib3270j/datastream/QueryReplyBuilder.class b/build/lib3270j/org/lib3270j/datastream/QueryReplyBuilder.class new file mode 100644 index 0000000..b5fbdce Binary files /dev/null and b/build/lib3270j/org/lib3270j/datastream/QueryReplyBuilder.class differ diff --git a/build/lib3270j/org/lib3270j/input/InputProcessor.class b/build/lib3270j/org/lib3270j/input/InputProcessor.class new file mode 100644 index 0000000..68143e5 Binary files /dev/null and b/build/lib3270j/org/lib3270j/input/InputProcessor.class differ diff --git a/build/lib3270j/org/lib3270j/listener/ConnectionListener.class b/build/lib3270j/org/lib3270j/listener/ConnectionListener.class new file mode 100644 index 0000000..e556307 Binary files /dev/null and b/build/lib3270j/org/lib3270j/listener/ConnectionListener.class differ diff --git a/build/lib3270j/org/lib3270j/listener/ScreenUpdateListener.class b/build/lib3270j/org/lib3270j/listener/ScreenUpdateListener.class new file mode 100644 index 0000000..706455b Binary files /dev/null and b/build/lib3270j/org/lib3270j/listener/ScreenUpdateListener.class differ diff --git a/build/lib3270j/org/lib3270j/protocol/DS3270Constants.class b/build/lib3270j/org/lib3270j/protocol/DS3270Constants.class new file mode 100644 index 0000000..1fdbea2 Binary files /dev/null and b/build/lib3270j/org/lib3270j/protocol/DS3270Constants.class differ diff --git a/build/lib3270j/org/lib3270j/protocol/TN3270EConstants.class b/build/lib3270j/org/lib3270j/protocol/TN3270EConstants.class new file mode 100644 index 0000000..0de4f69 Binary files /dev/null and b/build/lib3270j/org/lib3270j/protocol/TN3270EConstants.class differ diff --git a/build/lib3270j/org/lib3270j/protocol/TelnetConstants.class b/build/lib3270j/org/lib3270j/protocol/TelnetConstants.class new file mode 100644 index 0000000..f95779f Binary files /dev/null and b/build/lib3270j/org/lib3270j/protocol/TelnetConstants.class differ diff --git a/build/lib3270j/org/lib3270j/screen/ExtendedAttribute.class b/build/lib3270j/org/lib3270j/screen/ExtendedAttribute.class new file mode 100644 index 0000000..63a37cf Binary files /dev/null and b/build/lib3270j/org/lib3270j/screen/ExtendedAttribute.class differ diff --git a/build/lib3270j/org/lib3270j/screen/ScreenBuffer.class b/build/lib3270j/org/lib3270j/screen/ScreenBuffer.class new file mode 100644 index 0000000..f531834 Binary files /dev/null and b/build/lib3270j/org/lib3270j/screen/ScreenBuffer.class differ diff --git a/build/lib3270j/org/lib3270j/telnet/TelnetConnection.class b/build/lib3270j/org/lib3270j/telnet/TelnetConnection.class new file mode 100644 index 0000000..3671d43 Binary files /dev/null and b/build/lib3270j/org/lib3270j/telnet/TelnetConnection.class differ diff --git a/build/lib3270j/org/lib3270j/telnet/TelnetFSM$TN3270ESubmode.class b/build/lib3270j/org/lib3270j/telnet/TelnetFSM$TN3270ESubmode.class new file mode 100644 index 0000000..89f0eb3 Binary files /dev/null and b/build/lib3270j/org/lib3270j/telnet/TelnetFSM$TN3270ESubmode.class differ diff --git a/build/lib3270j/org/lib3270j/telnet/TelnetFSM.class b/build/lib3270j/org/lib3270j/telnet/TelnetFSM.class new file mode 100644 index 0000000..58aed75 Binary files /dev/null and b/build/lib3270j/org/lib3270j/telnet/TelnetFSM.class differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/j3270.ini.sample b/j3270.ini.sample new file mode 100644 index 0000000..9add8f9 --- /dev/null +++ b/j3270.ini.sample @@ -0,0 +1,64 @@ +; j3270 Configuration File +; Load with: java -jar j3270.jar -c j3270.ini +; or: ./run.sh -c j3270.ini + +[appearance] +; fontFamily = IBM 3270 +; fontSize = 18 + +[behavior] +; startupBehavior = AUTO_CONNECT | SHOW_CONNECT | DO_NOTHING +; autoConnectHost = mainframe.example.com +; autoConnectPort = 23 + +[colors] +; Host colors 0-15 (hex RGB) +; 0=Neutral Black 1=Blue 2=Red 3=Pink +; 4=Green 5=Turquoise 6=Yellow 7=Neutral White +; 8=Black 9=Deep Blue 10=Orange 11=Purple +; 12=Pale Green 13=Pale Turq 14=Grey 15=White +; color_0 = #000000 +; color_4 = #00ff00 + +; Monochrome / background colors +; mono_NORMAL = #32cd32 +; mono_INTENSIFY = #ffffff +; mono_PROTECTED = #5078ff +; mono_PROTECTED_HIGH = #ffffff +; mono_BACKGROUND = #0a0a0a + +[keybindings] +; Use comma-separated values for multiple bindings per action. +; Set to UNBOUND to disable an action entirely. +; +; Navigation keys: +; ENTER = ENTER +; TAB = TAB +; shift TAB = shift TAB +; UP = UP +; DOWN = DOWN +; LEFT = LEFT +; RIGHT = RIGHT +; HOME = HOME +; END = END +; PAGE_UP = PAGE_UP +; PAGE_DOWN = PAGE_DOWN +; ESCAPE = ESCAPE +; INSERT = INSERT +; DELETE = DELETE +; BACK_SPACE = BACK_SPACE +; CLEAR = alt C +; +; PF keys (1-24): +; PF1 = F1 +; PF2 = F2 +; ... +; PF12 = F12, shift F12 +; PF13 = shift F1 +; ... +; PF24 = UNBOUND +; +; PA keys: +; PA1 = alt 1 +; PA2 = alt 2 +; PA3 = alt 3 diff --git a/j3270/build.gradle b/j3270/build.gradle new file mode 100644 index 0000000..c204ba4 --- /dev/null +++ b/j3270/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'application' +} + +dependencies { + implementation project(':lib3270j') +} + +application { + mainClass = 'org.j3270.J3270App' +} diff --git a/j3270/src/main/java/org/pubvm/j3270/J3270App.java b/j3270/src/main/java/org/pubvm/j3270/J3270App.java new file mode 100644 index 0000000..618c6ce --- /dev/null +++ b/j3270/src/main/java/org/pubvm/j3270/J3270App.java @@ -0,0 +1,442 @@ +package org.pubvm.j3270; + +import org.lib3270j.*; +import org.lib3270j.listener.ConnectionListener; +import org.lib3270j.listener.ScreenUpdateListener; +import org.pubvm.j3270.ui.ConnectDialog; +import org.pubvm.j3270.ui.StatusBar; +import org.pubvm.j3270.ui.TerminalPanel; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.*; +import java.io.IOException; +import java.util.logging.*; + +/** + * j3270 — Java TN3270 Terminal Emulator. + * Main application class. Creates the main window with terminal display, + * status bar, menu bar, and manages the Telnet3270Client lifecycle. + */ +public class J3270App extends JFrame implements ConnectionListener, ScreenUpdateListener { + + private static final Logger log = Logger.getLogger(J3270App.class.getName()); + + private Telnet3270Client client; + private TerminalPanel terminalPanel; + private StatusBar statusBar; + private Timer refreshTimer; + + // Last connection details for reconnect + private String lastHost = ""; + private int lastPort = 23; + + public J3270App() { + super("j3270 — Java TN3270 Terminal Emulator"); + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + setBackground(new Color(10, 10, 10)); + + buildUI(); + buildMenuBar(); + + pack(); + setLocationRelativeTo(null); + setMinimumSize(new Dimension(640, 400)); + + // Status refresh timer — also ensures focus stays on terminal + refreshTimer = new Timer(100, e -> { + if (client != null) { + statusBar.updateStatus(); + // Keep focus on terminal panel when window is active + if (isActive() && !terminalPanel.hasFocus()) { + terminalPanel.requestFocusInWindow(); + } + } + }); + refreshTimer.start(); + + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + disconnect(); + if (refreshTimer != null) + refreshTimer.stop(); + terminalPanel.dispose(); + } + + @Override + public void windowActivated(WindowEvent e) { + // When window gains focus, push to terminal panel + terminalPanel.requestFocusInWindow(); + } + }); + } + + private void buildUI() { + terminalPanel = new TerminalPanel(); + + // Status bar + statusBar = new StatusBar(); + + // Layout + getContentPane().setLayout(new BorderLayout()); + getContentPane().setBackground(new Color(10, 10, 10)); + getContentPane().add(terminalPanel, BorderLayout.CENTER); + getContentPane().add(statusBar, BorderLayout.SOUTH); + } + + private void buildMenuBar() { + JMenuBar menuBar = new JMenuBar(); + menuBar.setBackground(new Color(30, 30, 30)); + menuBar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, new Color(50, 50, 50))); + + // File menu + JMenu fileMenu = createMenu("File"); + fileMenu.add(createMenuItem("Connect...", KeyEvent.VK_N, this::showConnectDialog)); + fileMenu.add(createMenuItem("Disconnect", KeyEvent.VK_D, this::disconnect)); + fileMenu.addSeparator(); + fileMenu.add(createMenuItem("Settings...", -1, this::showSettingsDialog)); + fileMenu.addSeparator(); + fileMenu.add(createMenuItem("Quit", KeyEvent.VK_Q, () -> { + disconnect(); + System.exit(0); + })); + menuBar.add(fileMenu); + + // View menu + JMenu viewMenu = createMenu("View"); + viewMenu.add(createMenuItem("Font Size +", KeyEvent.VK_EQUALS, () -> adjustFontSize(2))); + viewMenu.add(createMenuItem("Font Size -", KeyEvent.VK_MINUS, () -> adjustFontSize(-2))); + viewMenu.add(createMenuItem("Reset Font", KeyEvent.VK_0, () -> { + terminalPanel.setFontSize(16); + pack(); + })); + menuBar.add(viewMenu); + + // Actions menu + JMenu actionsMenu = createMenu("Actions"); + actionsMenu.add(createMenuItem("Clear", KeyEvent.VK_L, () -> { + if (client != null) + client.sendClear(); + terminalPanel.repaint(); + })); + actionsMenu.add(createMenuItem("Reset", KeyEvent.VK_R, () -> { + if (client != null) + client.reset(); + terminalPanel.repaint(); + })); + menuBar.add(actionsMenu); + + // Help menu + JMenu helpMenu = createMenu("Help"); + helpMenu.add(createMenuItem("Key Mappings", -1, this::showKeyMappings)); + helpMenu.add(createMenuItem("About", -1, this::showAbout)); + menuBar.add(helpMenu); + + setJMenuBar(menuBar); + } + + private JMenu createMenu(String name) { + JMenu menu = new JMenu(name); + menu.setForeground(new Color(200, 200, 200)); + return menu; + } + + private JMenuItem createMenuItem(String text, int acceleratorKey, Runnable action) { + JMenuItem item = new JMenuItem(text); + item.setBackground(new Color(40, 40, 40)); + item.setForeground(new Color(200, 200, 200)); + if (acceleratorKey > 0) { + item.setAccelerator(KeyStroke.getKeyStroke(acceleratorKey, + Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx())); + } + item.addActionListener(e -> action.run()); + return item; + } + + // ========== Connection management ========== + + private void showConnectDialog() { + ConnectDialog dialog = new ConnectDialog(this); + dialog.setInitialHost(lastHost); + dialog.setInitialPort(lastPort); + dialog.setVisible(true); + + if (dialog.isConfirmed()) { + ConnectionConfig config = dialog.getConnectionConfig(); + lastHost = config.getHost(); + lastPort = config.getPort(); + connect(config); + } + } + + private void showSettingsDialog() { + org.pubvm.j3270.ui.SettingsDialog dialog = new org.pubvm.j3270.ui.SettingsDialog(this); + dialog.setVisible(true); + } + + public TerminalPanel getTerminalPanel() { + return terminalPanel; + } + + void connect(ConnectionConfig config) { + lastHost = config.getHost(); + lastPort = config.getPort(); + + // Save for auto-connect + org.pubvm.j3270.config.Settings.setAutoConnectHost(config.getHost()); + org.pubvm.j3270.config.Settings.setAutoConnectPort(config.getPort()); + + // Disconnect existing connection + disconnect(); + + client = new Telnet3270Client(config); + client.addConnectionListener(this); + client.addScreenUpdateListener(this); + + terminalPanel.setClient(client); + statusBar.setClient(client); + + setTitle("j3270 — " + config.getHost() + ":" + config.getPort()); + + // Resize window to match model + terminalPanel.revalidate(); + pack(); + + new Thread(() -> { + try { + client.connect(); + } catch (IOException e) { + log.log(Level.WARNING, "Connection failed", e); + SwingUtilities.invokeLater(() -> { + JOptionPane.showMessageDialog(this, + "Connection failed: " + e.getMessage(), + "Connection Error", JOptionPane.ERROR_MESSAGE); + }); + } + }, "Connect-Thread").start(); + + // Focus the terminal panel + terminalPanel.requestFocusInWindow(); + } + + private void disconnect() { + if (client != null) { + client.disconnect(); + client = null; + terminalPanel.setClient(null); + statusBar.setClient(null); + terminalPanel.repaint(); + setTitle("j3270 — Java TN3270 Terminal Emulator"); + } + } + + private void adjustFontSize(int delta) { + int current = terminalPanel.getFontSize(); + int newSize = Math.max(8, Math.min(36, current + delta)); + terminalPanel.setFontSize(newSize); + pack(); + } + + // ========== ConnectionListener ========== + + @Override + public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) { + log.info("Connection state: " + oldState + " -> " + newState); + SwingUtilities.invokeLater(() -> { + statusBar.updateStatus(); + terminalPanel.repaint(); + + // Auto-resize and focus on first full session + if (newState.isFullSession() && !oldState.isFullSession()) { + terminalPanel.revalidate(); + pack(); + terminalPanel.requestFocusInWindow(); + } + }); + } + + @Override + public void onConnectionError(String message) { + log.warning("Connection error: " + message); + SwingUtilities.invokeLater(statusBar::updateStatus); + } + + @Override + public void onTN3270ENegotiated(String deviceType, String deviceName) { + log.info("TN3270E negotiated: type=" + deviceType + " name=" + deviceName); + SwingUtilities.invokeLater(() -> { + if (deviceName != null) { + setTitle("j3270 — " + lastHost + ":" + lastPort + " [" + deviceName + "]"); + } + }); + } + + // ========== ScreenUpdateListener ========== + + @Override + public void onScreenUpdated() { + SwingUtilities.invokeLater(() -> { + if (client != null) { + client.getInputProcessor().setKeyboardLocked(false); + } + terminalPanel.repaint(); + statusBar.updateStatus(); + }); + } + + @Override + public void onSoundAlarm() { + Toolkit.getDefaultToolkit().beep(); + } + + @Override + public void onScreenSizeChanged(int rows, int cols) { + SwingUtilities.invokeLater(() -> { + terminalPanel.revalidate(); + pack(); + }); + } + + // ========== Help dialogs ========== + + private void showKeyMappings() { + String text = "Key Mappings:\n\n" + + "Enter — Send Enter (AID)\n" + + "F1-F12 — PF1-PF12\n" + + "Shift+F1-F12 — PF13-PF24\n" + + "Alt+1/2/3 — PA1/PA2/PA3\n" + + "Tab — Tab to next field\n" + + "Shift+Tab — Back-tab\n" + + "Arrow keys — Cursor movement\n" + + "Home — Cursor to home\n" + + "End — Erase to end of field\n" + + "Delete — Delete character\n" + + "Backspace — Backspace\n" + + "Insert — Toggle insert mode\n" + + "Escape — Reset\n" + + "PageUp/Down — PF7/PF8\n" + + "Alt+C — Clear\n" + + "Alt+1/2/3 — PA1/PA2/PA3\n" + + "Cmd+D — Disconnect\n" + + "Cmd+Q — Quit\n" + + "Cmd+=/-/0 — Font size +/-/reset"; + + JTextArea area = new JTextArea(text); + area.setEditable(false); + area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + area.setBackground(new Color(30, 30, 30)); + area.setForeground(new Color(200, 200, 200)); + JScrollPane sp = new JScrollPane(area); + sp.setPreferredSize(new Dimension(360, 400)); + JOptionPane.showMessageDialog(this, sp, "Key Mappings", JOptionPane.INFORMATION_MESSAGE); + } + + private void showAbout() { + JOptionPane.showMessageDialog(this, + "j3270 — Java TN3270 Terminal Emulator\n\n" + + "A Java reimplementation of x3270.\n" + + "lib3270j v0.1.0\n\n" + + "Supports: TN3270, TN3270E (RFC 2355)\n" + + "Models: IBM 3278/3279 Models 2-5\n" + + "Colors, Extended Attributes, Query Replies", + "About j3270", JOptionPane.INFORMATION_MESSAGE); + } + + // ========== Main ========== + + public static void main(String[] args) { + boolean debug = false; + String configFile = null; + java.util.List remainingArgs = new java.util.ArrayList<>(); + for (int i = 0; i < args.length; i++) { + if ("--debug".equals(args[i]) || "-d".equals(args[i])) { + debug = true; + } else if (("-c".equals(args[i]) || "--config".equals(args[i])) && i + 1 < args.length) { + configFile = args[++i]; + } else { + remainingArgs.add(args[i]); + } + } + + // Configure logging over the absolute root logger + Logger globalRoot = Logger.getLogger(""); + for (java.util.logging.Handler h : globalRoot.getHandlers()) { + globalRoot.removeHandler(h); + } + globalRoot.setLevel(debug ? Level.ALL : Level.WARNING); + ConsoleHandler handler = new ConsoleHandler(); + handler.setLevel(Level.ALL); + handler.setFormatter(new SimpleFormatter()); + globalRoot.addHandler(handler); + + // Load INI config file if specified + if (configFile != null) { + try { + org.pubvm.j3270.config.Settings.loadFromIniFile(configFile); + } catch (Exception e) { + System.err.println("Failed to load config file: " + configFile); + System.err.println(e.getMessage()); + System.exit(1); + } + } + + // Dark look and feel + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception e) { + log.fine("Could not set system look and feel"); + } + + // Use native macOS menu bar if applicable + System.setProperty("apple.laf.useScreenMenuBar", "true"); + System.setProperty("apple.awt.application.name", "j3270"); + + SwingUtilities.invokeLater(() -> { + J3270App app = new J3270App(); + app.setVisible(true); + + // If host:port given on command line, connect directly + if (!remainingArgs.isEmpty()) { + String host = remainingArgs.get(0); + int port = 23; + if (remainingArgs.size() >= 2) { + try { + port = Integer.parseInt(remainingArgs.get(1)); + } catch (NumberFormatException e) { + } + } + TerminalModel model = TerminalModel.IBM_3279_4; + if (remainingArgs.size() >= 3) { + try { + int modelNum = Integer.parseInt(remainingArgs.get(2)); + model = TerminalModel.forModel(modelNum, true); + } catch (Exception e) { + } + } + ConnectionConfig config = new ConnectionConfig(host, port, model); + app.connect(config); + } else { + org.pubvm.j3270.config.Settings.StartupBehavior behavior = org.pubvm.j3270.config.Settings + .getStartupBehavior(); + switch (behavior) { + case SHOW_CONNECT: + SwingUtilities.invokeLater(app::showConnectDialog); + break; + case AUTO_CONNECT: + String host = org.pubvm.j3270.config.Settings.getAutoConnectHost(); + int port = org.pubvm.j3270.config.Settings.getAutoConnectPort(); + if (host != null && !host.isEmpty()) { + app.connect(new ConnectionConfig(host, port, TerminalModel.IBM_3279_4)); + } else { + SwingUtilities.invokeLater(app::showConnectDialog); + } + break; + case DO_NOTHING: + default: + break; + } + } + }); + } +} diff --git a/j3270/src/main/java/org/pubvm/j3270/config/Settings.java b/j3270/src/main/java/org/pubvm/j3270/config/Settings.java new file mode 100644 index 0000000..4bf43bd --- /dev/null +++ b/j3270/src/main/java/org/pubvm/j3270/config/Settings.java @@ -0,0 +1,220 @@ +package org.pubvm.j3270.config; + +import java.util.prefs.Preferences; +import java.awt.Color; +import java.io.*; +import java.util.logging.Logger; +import java.util.logging.Level; + +public class Settings { + private static final Preferences prefs = Preferences.userNodeForPackage(Settings.class); + private static final Logger log = Logger.getLogger(Settings.class.getName()); + + public enum StartupBehavior { + SHOW_CONNECT, + DO_NOTHING, + AUTO_CONNECT + } + + public static String getFontFamily() { + return prefs.get("fontFamily", "Monospaced"); + } + + public static void setFontFamily(String family) { + prefs.put("fontFamily", family); + } + + public static int getFontSize() { + return prefs.getInt("fontSize", 16); + } + + public static void setFontSize(int size) { + prefs.putInt("fontSize", size); + } + + public static StartupBehavior getStartupBehavior() { + String behaviorStr = prefs.get("startupBehavior", StartupBehavior.SHOW_CONNECT.name()); + try { + return StartupBehavior.valueOf(behaviorStr); + } catch (IllegalArgumentException e) { + return StartupBehavior.SHOW_CONNECT; + } + } + + public static void setStartupBehavior(StartupBehavior behavior) { + prefs.put("startupBehavior", behavior.name()); + } + + public static String getAutoConnectHost() { + return prefs.get("autoConnectHost", ""); + } + + public static void setAutoConnectHost(String host) { + prefs.put("autoConnectHost", host); + } + + public static int getAutoConnectPort() { + return prefs.getInt("autoConnectPort", 23); + } + + public static void setAutoConnectPort(int port) { + prefs.putInt("autoConnectPort", port); + } + + public static Color getColorOverride(int index, Color defaultColor) { + String hex = prefs.get("color_" + index, null); + try { + return hex != null ? Color.decode(hex) : defaultColor; + } catch (NumberFormatException e) { + return defaultColor; + } + } + + public static void setColorOverride(int index, Color color) { + prefs.put("color_" + index, String.format("#%02x%02x%02x", color.getRed(), color.getGreen(), color.getBlue())); + } + + public static Color getMonoColorOverride(String key, Color defaultColor) { + String hex = prefs.get("mono_" + key, null); + try { + return hex != null ? Color.decode(hex) : defaultColor; + } catch (NumberFormatException e) { + return defaultColor; + } + } + + public static void setMonoColorOverride(String key, Color color) { + prefs.put("mono_" + key, String.format("#%02x%02x%02x", color.getRed(), color.getGreen(), color.getBlue())); + } + + public static String getKeyBinding(String action, String defaultBinding) { + return prefs.get("key_" + action, defaultBinding); + } + + public static void setKeyBinding(String action, String binding) { + prefs.put("key_" + action, binding); + } + + /** + * Load settings from an INI-style configuration file. + * Sections: [appearance], [behavior], [colors], [keybindings] + * + * Example file: + *
+     * [appearance]
+     * fontFamily = IBM 3270
+     * fontSize = 18
+     *
+     * [behavior]
+     * startupBehavior = AUTO_CONNECT
+     * autoConnectHost = mainframe.example.com
+     * autoConnectPort = 23
+     *
+     * [colors]
+     * ; Host colors 0-15
+     * color_0 = #000000
+     * color_4 = #00ff00
+     * ; Monochrome / background
+     * mono_NORMAL = #32cd32
+     * mono_BACKGROUND = #0a0a0a
+     *
+     * [keybindings]
+     * ; Comma-separated for multiple bindings
+     * PF12 = F12, shift F12
+     * PF24 = UNBOUND
+     * CLEAR = alt C
+     * 
+ */ + public static void loadFromIniFile(String path) throws IOException { + File file = new File(path); + if (!file.exists()) { + throw new FileNotFoundException("Config file not found: " + path); + } + + log.info("Loading configuration from: " + path); + String currentSection = ""; + + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + String line; + int lineNum = 0; + while ((line = reader.readLine()) != null) { + lineNum++; + line = line.trim(); + + // Skip empty lines and comments + if (line.isEmpty() || line.startsWith(";") || line.startsWith("#")) { + continue; + } + + // Section header + if (line.startsWith("[") && line.endsWith("]")) { + currentSection = line.substring(1, line.length() - 1).toLowerCase().trim(); + continue; + } + + // Key = Value + int eq = line.indexOf('='); + if (eq < 0) { + log.warning("Config line " + lineNum + ": no '=' found, skipping: " + line); + continue; + } + String key = line.substring(0, eq).trim(); + String value = line.substring(eq + 1).trim(); + + try { + applyConfigEntry(currentSection, key, value); + } catch (Exception e) { + log.log(Level.WARNING, "Config line " + lineNum + ": error applying " + key + "=" + value, e); + } + } + } + log.info("Configuration loaded successfully."); + } + + private static void applyConfigEntry(String section, String key, String value) { + switch (section) { + case "appearance": + switch (key) { + case "fontFamily": setFontFamily(value); break; + case "fontSize": setFontSize(Integer.parseInt(value)); break; + default: + log.warning("Unknown appearance key: " + key); + } + break; + + case "behavior": + switch (key) { + case "startupBehavior": + setStartupBehavior(StartupBehavior.valueOf(value.toUpperCase())); + break; + case "autoConnectHost": setAutoConnectHost(value); break; + case "autoConnectPort": setAutoConnectPort(Integer.parseInt(value)); break; + default: + log.warning("Unknown behavior key: " + key); + } + break; + + case "colors": + if (key.startsWith("color_")) { + int index = Integer.parseInt(key.substring(6)); + setColorOverride(index, Color.decode(value)); + } else if (key.startsWith("mono_")) { + String monoKey = key.substring(5); + setMonoColorOverride(monoKey, Color.decode(value)); + } else { + log.warning("Unknown colors key: " + key); + } + break; + + case "keybindings": + setKeyBinding(key, value); + break; + + default: + // Allow bare keys outside any section — treat as raw prefs + log.fine("Setting raw preference: " + key + " = " + value); + prefs.put(key, value); + break; + } + } +} diff --git a/j3270/src/main/java/org/pubvm/j3270/ui/ConnectDialog.java b/j3270/src/main/java/org/pubvm/j3270/ui/ConnectDialog.java new file mode 100644 index 0000000..30ac8b8 --- /dev/null +++ b/j3270/src/main/java/org/pubvm/j3270/ui/ConnectDialog.java @@ -0,0 +1,181 @@ +package org.pubvm.j3270.ui; + +import org.lib3270j.ConnectionConfig; +import org.lib3270j.TerminalModel; + +import javax.swing.*; +import java.awt.*; + +/** + * Connection dialog for entering host, port, model, and LU name. + */ +public class ConnectDialog extends JDialog { + + private JTextField hostField; + private JTextField portField; + private JComboBox modelCombo; + private JTextField luField; + private boolean confirmed; + private ConnectionConfig result; + + public ConnectDialog(Frame parent) { + super(parent, "Connect to Host", true); + buildUI(); + pack(); + setLocationRelativeTo(parent); + setResizable(false); + } + + private void buildUI() { + JPanel mainPanel = new JPanel(new GridBagLayout()); + mainPanel.setBorder(BorderFactory.createEmptyBorder(16, 16, 16, 16)); + mainPanel.setBackground(new Color(30, 30, 30)); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 4, 4, 4); + gbc.fill = GridBagConstraints.HORIZONTAL; + + Font labelFont = new Font(Font.SANS_SERIF, Font.PLAIN, 14); + Color fg = new Color(200, 200, 200); + + // Host + gbc.gridx = 0; + gbc.gridy = 0; + JLabel hostLabel = new JLabel("Host:"); + hostLabel.setForeground(fg); + hostLabel.setFont(labelFont); + mainPanel.add(hostLabel, gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + hostField = createDarkField(20); + mainPanel.add(hostField, gbc); + + // Port + gbc.gridx = 0; + gbc.gridy = 1; + gbc.weightx = 0; + JLabel portLabel = new JLabel("Port:"); + portLabel.setForeground(fg); + portLabel.setFont(labelFont); + mainPanel.add(portLabel, gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + portField = createDarkField(6); + portField.setText("23"); + mainPanel.add(portField, gbc); + + // Model + gbc.gridx = 0; + gbc.gridy = 2; + gbc.weightx = 0; + JLabel modelLabel = new JLabel("Model:"); + modelLabel.setForeground(fg); + modelLabel.setFont(labelFont); + mainPanel.add(modelLabel, gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + modelCombo = new JComboBox<>(TerminalModel.values()); + modelCombo.setSelectedItem(TerminalModel.IBM_3279_4); + modelCombo.setBackground(new Color(45, 45, 45)); + modelCombo.setForeground(fg); + modelCombo.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + mainPanel.add(modelCombo, gbc); + + // LU Name + gbc.gridx = 0; + gbc.gridy = 3; + gbc.weightx = 0; + JLabel luLabel = new JLabel("LU Name:"); + luLabel.setForeground(fg); + luLabel.setFont(labelFont); + mainPanel.add(luLabel, gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + luField = createDarkField(12); + mainPanel.add(luField, gbc); + + // Buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + buttonPanel.setBackground(new Color(30, 30, 30)); + + JButton connectBtn = new JButton("Connect"); + connectBtn.setBackground(new Color(50, 120, 50)); + connectBtn.setForeground(Color.WHITE); + connectBtn.setFont(labelFont); + connectBtn.addActionListener(e -> onConnect()); + + JButton cancelBtn = new JButton("Cancel"); + cancelBtn.setBackground(new Color(60, 60, 60)); + cancelBtn.setForeground(fg); + cancelBtn.setFont(labelFont); + cancelBtn.addActionListener(e -> { + confirmed = false; + dispose(); + }); + + buttonPanel.add(cancelBtn); + buttonPanel.add(connectBtn); + + gbc.gridx = 0; + gbc.gridy = 4; + gbc.gridwidth = 2; + mainPanel.add(buttonPanel, gbc); + + setContentPane(mainPanel); + + // Enter key triggers connect + getRootPane().setDefaultButton(connectBtn); + } + + private JTextField createDarkField(int cols) { + JTextField field = new JTextField(cols); + field.setBackground(new Color(45, 45, 45)); + field.setForeground(new Color(200, 200, 200)); + field.setCaretColor(new Color(200, 200, 200)); + field.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + field.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(new Color(60, 60, 60)), + BorderFactory.createEmptyBorder(4, 6, 4, 6))); + return field; + } + + private void onConnect() { + String host = hostField.getText().trim(); + if (host.isEmpty()) { + hostField.requestFocus(); + return; + } + + int port; + try { + port = Integer.parseInt(portField.getText().trim()); + } catch (NumberFormatException e) { + portField.requestFocus(); + return; + } + + result = new ConnectionConfig(host, port, (TerminalModel) modelCombo.getSelectedItem()); + String lu = luField.getText().trim(); + if (!lu.isEmpty()) { + result.setLuName(lu); + } + confirmed = true; + dispose(); + } + + public boolean isConfirmed() { + return confirmed; + } + + public ConnectionConfig getConnectionConfig() { + return result; + } + + /** Set initial values. */ + public void setInitialHost(String host) { + hostField.setText(host); + } + + public void setInitialPort(int port) { + portField.setText(String.valueOf(port)); + } +} diff --git a/j3270/src/main/java/org/pubvm/j3270/ui/SettingsDialog.java b/j3270/src/main/java/org/pubvm/j3270/ui/SettingsDialog.java new file mode 100644 index 0000000..00be7ae --- /dev/null +++ b/j3270/src/main/java/org/pubvm/j3270/ui/SettingsDialog.java @@ -0,0 +1,490 @@ +package org.pubvm.j3270.ui; + +import javax.swing.*; + +import org.pubvm.j3270.J3270App; +import org.pubvm.j3270.config.Settings; + +import java.awt.*; +import java.awt.event.*; +import java.util.HashMap; +import java.util.Map; +import javax.swing.table.DefaultTableModel; +import javax.swing.border.LineBorder; + +public class SettingsDialog extends JDialog { + + private final J3270App parentApp; + + // Appearance tab + private JComboBox fontBox; + private JSpinner fontSizeSpinner; + + // Behavior tab + private JComboBox startupBehaviorBox; + private JPanel autoConnectPanel; + private JTextField hostField; + private JTextField portField; + + // Advanced tab state tracking + private final Color[] tempHostColors = new Color[16]; + private final Map tempMonoColors = new HashMap<>(); + private final Map tempKeyBindings = new HashMap<>(); + private DefaultTableModel keymapModel; + + public SettingsDialog(J3270App parent) { + super(parent, "Settings", true); + this.parentApp = parent; + + initComponents(); + setSize(550, 450); + setLocationRelativeTo(parent); + } + + private void initComponents() { + JTabbedPane tabbedPane = new JTabbedPane(); + + tabbedPane.addTab("Appearance", createAppearancePanel()); + tabbedPane.addTab("Behavior", createBehaviorPanel()); + tabbedPane.addTab("Advanced", createAdvancedPanel()); + + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton btnOk = new JButton("OK"); + JButton btnApply = new JButton("Apply"); + JButton btnCancel = new JButton("Cancel"); + + btnOk.addActionListener((ActionEvent e) -> { + boolean success = applySettings(); + if (success) { + dispose(); + } + }); + + btnApply.addActionListener((ActionEvent e) -> { + applySettings(); + }); + + btnCancel.addActionListener((ActionEvent e) -> { + dispose(); + }); + + buttonPanel.add(btnApply); + buttonPanel.add(btnCancel); + buttonPanel.add(btnOk); + + getContentPane().add(tabbedPane, BorderLayout.CENTER); + getContentPane().add(buttonPanel, BorderLayout.SOUTH); + } + + private JPanel createAppearancePanel() { + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(10, 10, 10, 10); + gbc.anchor = GridBagConstraints.WEST; + + // Font family + gbc.gridx = 0; + gbc.gridy = 0; + JLabel fontLabel = new JLabel("Terminal Font:"); + panel.add(fontLabel, gbc); + + String[] fonts = GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames(); + fontBox = new JComboBox<>(fonts); + // Find default + String currentFont = Settings.getFontFamily(); + for (int i = 0; i < fonts.length; i++) { + if (fonts[i].equalsIgnoreCase(currentFont)) { + fontBox.setSelectedIndex(i); + break; + } + } + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + panel.add(fontBox, gbc); + + // Font size + gbc.gridx = 0; + gbc.gridy = 1; + gbc.fill = GridBagConstraints.NONE; + JLabel sizeLabel = new JLabel("Font Size:"); + panel.add(sizeLabel, gbc); + + SpinnerNumberModel sizeModel = new SpinnerNumberModel(Settings.getFontSize(), 8, 72, 1); + fontSizeSpinner = new JSpinner(sizeModel); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + panel.add(fontSizeSpinner, gbc); + + // Fill remaining space + gbc.gridy = 2; + gbc.weighty = 1.0; + panel.add(Box.createGlue(), gbc); + + return panel; + } + + private JPanel createBehaviorPanel() { + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(10, 10, 10, 10); + gbc.anchor = GridBagConstraints.WEST; + + gbc.gridx = 0; + gbc.gridy = 0; + JLabel actionLabel = new JLabel("Startup Action:"); + panel.add(actionLabel, gbc); + + startupBehaviorBox = new JComboBox<>(Settings.StartupBehavior.values()); + startupBehaviorBox.setSelectedItem(Settings.getStartupBehavior()); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + panel.add(startupBehaviorBox, gbc); + + // Auto-connect panel + autoConnectPanel = new JPanel(new GridBagLayout()); + GridBagConstraints acGbc = new GridBagConstraints(); + acGbc.insets = new Insets(5, 5, 5, 5); + acGbc.anchor = GridBagConstraints.WEST; + + JLabel hostLabel = new JLabel("Host:"); + acGbc.gridx = 0; + acGbc.gridy = 0; + autoConnectPanel.add(hostLabel, acGbc); + + hostField = new JTextField(Settings.getAutoConnectHost(), 15); + acGbc.gridx = 1; + autoConnectPanel.add(hostField, acGbc); + + JLabel portLabel = new JLabel("Port:"); + acGbc.gridx = 2; + autoConnectPanel.add(portLabel, acGbc); + + portField = new JTextField(String.valueOf(Settings.getAutoConnectPort()), 4); + acGbc.gridx = 3; + autoConnectPanel.add(portField, acGbc); + + gbc.gridx = 0; + gbc.gridy = 1; + gbc.gridwidth = 2; + panel.add(autoConnectPanel, gbc); + + // Toggle visibility + startupBehaviorBox.addActionListener(e -> { + autoConnectPanel.setVisible(startupBehaviorBox.getSelectedItem() == Settings.StartupBehavior.AUTO_CONNECT); + }); + autoConnectPanel.setVisible(Settings.getStartupBehavior() == Settings.StartupBehavior.AUTO_CONNECT); + + // Placeholder for potentially more behavior options below + gbc.gridy = 2; + gbc.weighty = 1.0; + panel.add(Box.createGlue(), gbc); + + return panel; + } + + private JPanel createAdvancedPanel() { + JPanel panel = new JPanel(new BorderLayout()); + + JTabbedPane advancedTabs = new JTabbedPane(); + advancedTabs.addTab("Colors", createColorsPanel()); + advancedTabs.addTab("Keymap", createKeymapPanel()); + + panel.add(advancedTabs, BorderLayout.CENTER); + return panel; + } + + private JPanel createColorsPanel() { + JPanel main = new JPanel(new BorderLayout()); + + // Host colors grid (16 colors) + JPanel hostPanel = new JPanel(new GridLayout(2, 8, 5, 5)); + hostPanel.setBorder(BorderFactory.createTitledBorder("Host Colors")); + + for (int i = 0; i < 16; i++) { + Color init = org.pubvm.j3270.config.Settings.getColorOverride(i, TerminalPanel.DEFAULT_HOST_COLORS[i]); + tempHostColors[i] = init; + JPanel cb = new JPanel(); + cb.setBackground(init); + cb.setPreferredSize(new Dimension(30, 30)); + cb.setBorder(new LineBorder(Color.DARK_GRAY)); + + final int index = i; + cb.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + Color c = JColorChooser.showDialog(main, "Select Color " + index, tempHostColors[index]); + if (c != null) { + tempHostColors[index] = c; + cb.setBackground(c); + } + } + }); + hostPanel.add(cb); + } + + // Mono colors + JPanel monoPanel = new JPanel(new GridLayout(2, 3, 5, 5)); + monoPanel.setBorder(BorderFactory.createTitledBorder("Monochrome / BG Colors")); + + String[] monoKeys = {"NORMAL", "INTENSIFY", "PROTECTED", "PROTECTED_HIGH", "BACKGROUND"}; + Color[] monoDefs = {TerminalPanel.DEFAULT_MONO_NORMAL, TerminalPanel.DEFAULT_MONO_INTENSIFY, + TerminalPanel.DEFAULT_MONO_PROTECTED, TerminalPanel.DEFAULT_MONO_PROTECTED_HIGH, TerminalPanel.DEFAULT_BG_COLOR}; + + for (int i = 0; i < monoKeys.length; i++) { + String mk = monoKeys[i]; + Color init = org.pubvm.j3270.config.Settings.getMonoColorOverride(mk, monoDefs[i]); + tempMonoColors.put(mk, init); + + JPanel p = new JPanel(new BorderLayout()); + p.add(new JLabel(mk, SwingConstants.CENTER), BorderLayout.NORTH); + JPanel cb = new JPanel(); + cb.setBackground(init); + cb.setPreferredSize(new Dimension(40, 40)); + cb.setBorder(new LineBorder(Color.DARK_GRAY)); + + cb.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + Color c = JColorChooser.showDialog(main, "Select " + mk, tempMonoColors.get(mk)); + if (c != null) { + tempMonoColors.put(mk, c); + cb.setBackground(c); + } + } + }); + p.add(cb, BorderLayout.CENTER); + monoPanel.add(p); + } + + JPanel resetPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton btnResetColors = new JButton("Reset to Defaults"); + btnResetColors.addActionListener(e -> { + for (int i=0; i<16; i++) { + tempHostColors[i] = TerminalPanel.DEFAULT_HOST_COLORS[i]; + } + for (int i=0; i> captureKey = (title, callback) -> { + JDialog captureDialog = new JDialog(this, title, true); + captureDialog.setSize(320, 100); + captureDialog.setLocationRelativeTo(this); + JLabel lbl = new JLabel("Press any key combination now...", SwingConstants.CENTER); + captureDialog.add(lbl); + captureDialog.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + int code = e.getKeyCode(); + if (code == KeyEvent.VK_SHIFT || code == KeyEvent.VK_CONTROL || + code == KeyEvent.VK_ALT || code == KeyEvent.VK_META) return; + KeyStroke ks = KeyStroke.getKeyStrokeForEvent(e); + if (ks != null) { + String s = ks.toString().replace("pressed ", ""); + callback.accept(s); + captureDialog.dispose(); + } + } + }); + captureDialog.setVisible(true); + }; + + // Replace Binding — sets the action to exactly one new key + JButton btnReplace = new JButton("Replace Binding"); + btnReplace.addActionListener(e -> { + int row = table.getSelectedRow(); + if (row < 0) return; + String action = (String) keymapModel.getValueAt(row, 0); + captureKey.accept("Press key for " + action, s -> { + tempKeyBindings.put(action, s); + keymapModel.setValueAt(s, row, 1); + }); + }); + btnPanel.add(btnReplace); + + // Add Binding — appends an additional key to the existing binding(s) + JButton btnAdd = new JButton("Add Binding"); + btnAdd.addActionListener(e -> { + int row = table.getSelectedRow(); + if (row < 0) return; + String action = (String) keymapModel.getValueAt(row, 0); + captureKey.accept("Press additional key for " + action, s -> { + String existing = tempKeyBindings.get(action); + String updated; + if (existing == null || existing.isEmpty() || "UNBOUND".equals(existing)) { + updated = s; + } else { + updated = existing + ", " + s; + } + tempKeyBindings.put(action, updated); + keymapModel.setValueAt(updated, row, 1); + }); + }); + btnPanel.add(btnAdd); + + // Remove Last — removes the last comma-separated binding entry + JButton btnRemoveLast = new JButton("Remove Last"); + btnRemoveLast.addActionListener(e -> { + int row = table.getSelectedRow(); + if (row < 0) return; + String action = (String) keymapModel.getValueAt(row, 0); + String existing = tempKeyBindings.get(action); + if (existing == null || "UNBOUND".equals(existing)) return; + int lastComma = existing.lastIndexOf(','); + String updated; + if (lastComma > 0) { + updated = existing.substring(0, lastComma).trim(); + } else { + updated = "UNBOUND"; + } + tempKeyBindings.put(action, updated); + keymapModel.setValueAt(updated, row, 1); + }); + btnPanel.add(btnRemoveLast); + + // Unbind All — clears all bindings for the action + JButton btnUnbind = new JButton("Unbind All"); + btnUnbind.addActionListener(e -> { + int row = table.getSelectedRow(); + if (row < 0) return; + String action = (String) keymapModel.getValueAt(row, 0); + tempKeyBindings.put(action, "UNBOUND"); + keymapModel.setValueAt("UNBOUND", row, 1); + }); + btnPanel.add(btnUnbind); + + // Reset Keymaps — restore all defaults + JButton btnReset = new JButton("Reset All"); + btnReset.addActionListener(e -> { + for (int row = 0; row < keymapModel.getRowCount(); row++) { + String action = (String) keymapModel.getValueAt(row, 0); + String def = getDefaultBinding(action); + tempKeyBindings.put(action, def); + keymapModel.setValueAt(def, row, 1); + } + }); + btnPanel.add(btnReset); + + main.add(new JScrollPane(table), BorderLayout.CENTER); + main.add(btnPanel, BorderLayout.SOUTH); + + return main; + } + + /** Returns the factory-default binding for a given action name. */ + private String getDefaultBinding(String action) { + if (action.startsWith("PF")) { + int n = Integer.parseInt(action.substring(2)); + return n <= 12 ? "F" + n : "shift F" + (n - 12); + } + if (action.startsWith("PA")) return "alt " + action.substring(2); + if (action.equals("CLEAR")) return "alt C"; + return action; // nav keys default to their own name + } + + private boolean applySettings() { + try { + // Apply Appearance + String fontFam = (String) fontBox.getSelectedItem(); + if (fontFam != null) { + Settings.setFontFamily(fontFam); + } + Settings.setFontSize((Integer) fontSizeSpinner.getValue()); + + // Apply Behavior + Settings.StartupBehavior behavior = (Settings.StartupBehavior) startupBehaviorBox.getSelectedItem(); + if (behavior != null) { + Settings.setStartupBehavior(behavior); + } + if (behavior == Settings.StartupBehavior.AUTO_CONNECT) { + Settings.setAutoConnectHost(hostField.getText().trim()); + try { + Settings.setAutoConnectPort(Integer.parseInt(portField.getText().trim())); + } catch (NumberFormatException ignored) { + } + } + + // Propagate visual changes to the app + // Save Colors + for (int i=0; i<16; i++) { + org.pubvm.j3270.config.Settings.setColorOverride(i, tempHostColors[i]); + } + for (Map.Entry entry : tempMonoColors.entrySet()) { + org.pubvm.j3270.config.Settings.setMonoColorOverride(entry.getKey(), entry.getValue()); + } + + // Save Keybindings + for (Map.Entry entry : tempKeyBindings.entrySet()) { + org.pubvm.j3270.config.Settings.setKeyBinding(entry.getKey(), entry.getValue()); + } + + parentApp.getTerminalPanel().reloadSettings(); + + return true; + } catch (Exception e) { + JOptionPane.showMessageDialog(this, "Failed to completely apply settings: " + e.getMessage(), "Error", + JOptionPane.ERROR_MESSAGE); + return false; + } + } +} diff --git a/j3270/src/main/java/org/pubvm/j3270/ui/StatusBar.java b/j3270/src/main/java/org/pubvm/j3270/ui/StatusBar.java new file mode 100644 index 0000000..fa6a038 --- /dev/null +++ b/j3270/src/main/java/org/pubvm/j3270/ui/StatusBar.java @@ -0,0 +1,140 @@ +package org.pubvm.j3270.ui; + +import org.lib3270j.ConnectionState; +import org.lib3270j.Telnet3270Client; +import org.lib3270j.screen.ScreenBuffer; + +import javax.swing.*; +import java.awt.*; + +/** + * Status bar displaying connection state, cursor position, timing, and lock + * status. + * Equivalent to the OIA (Operator Information Area) on a real 3270 terminal. + */ +public class StatusBar extends JPanel { + + private final JLabel connectionStatus; + private final JLabel cursorPosition; + private final JLabel luName; + private final JLabel lockStatus; + private final JLabel modelInfo; + + private Telnet3270Client client; + + // OIA colors + private static final Color OIA_BG = new Color(20, 20, 20); + private static final Color OIA_FG = new Color(50, 205, 50); + private static final Color OIA_DIM = new Color(80, 80, 80); + private static final Color OIA_ALERT = new Color(255, 80, 80); + + public StatusBar() { + setLayout(new BoxLayout(this, BoxLayout.X_AXIS)); + setBackground(OIA_BG); + setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(40, 40, 40))); + setPreferredSize(new Dimension(800, 22)); + + Font oiaFont = new Font(Font.MONOSPACED, Font.PLAIN, 12); + + connectionStatus = createLabel("Not Connected", oiaFont, OIA_DIM); + luName = createLabel("", oiaFont, OIA_FG); + lockStatus = createLabel("", oiaFont, OIA_ALERT); + modelInfo = createLabel("", oiaFont, OIA_DIM); + cursorPosition = createLabel("001/001", oiaFont, OIA_FG); + + add(Box.createHorizontalStrut(6)); + add(connectionStatus); + add(Box.createHorizontalStrut(12)); + add(luName); + add(Box.createHorizontalStrut(12)); + add(lockStatus); + add(Box.createHorizontalGlue()); + add(modelInfo); + add(Box.createHorizontalStrut(12)); + add(cursorPosition); + add(Box.createHorizontalStrut(6)); + } + + private JLabel createLabel(String text, Font font, Color fg) { + JLabel label = new JLabel(text); + label.setFont(font); + label.setForeground(fg); + return label; + } + + public void setClient(Telnet3270Client client) { + this.client = client; + } + + public void updateStatus() { + if (client == null) + return; + + // Connection state + ConnectionState state = client.getConnectionState(); + switch (state) { + case NOT_CONNECTED: + connectionStatus.setText("Not Connected"); + connectionStatus.setForeground(OIA_DIM); + break; + case TCP_PENDING: + case TELNET_PENDING: + connectionStatus.setText("Connecting..."); + connectionStatus.setForeground(OIA_ALERT); + break; + case CONNECTED_3270: + connectionStatus.setText("TN3270"); + connectionStatus.setForeground(OIA_FG); + break; + case CONNECTED_TN3270E: + connectionStatus.setText("TN3270E"); + connectionStatus.setForeground(OIA_FG); + break; + case CONNECTED_SSCP: + connectionStatus.setText("SSCP-LU"); + connectionStatus.setForeground(OIA_FG); + break; + case CONNECTED_NVT: + case CONNECTED_NVT_CHAR: + case CONNECTED_E_NVT: + connectionStatus.setText("NVT"); + connectionStatus.setForeground(OIA_FG); + break; + case CONNECTED_UNBOUND: + connectionStatus.setText("Unbound"); + connectionStatus.setForeground(new Color(255, 255, 80)); + break; + default: + connectionStatus.setText(state.name()); + connectionStatus.setForeground(OIA_DIM); + break; + } + + // LU name + String lu = ""; + if (client.getConnectionState().isTn3270e()) { + // lu would come from FSM + } + luName.setText(lu); + + // Lock status + if (client.getInputProcessor().isKeyboardLocked()) { + lockStatus.setText("X SYSTEM"); + lockStatus.setForeground(OIA_ALERT); + } else if (client.getInputProcessor().isInsertMode()) { + lockStatus.setText("INSERT"); + lockStatus.setForeground(OIA_FG); + } else { + lockStatus.setText(""); + } + + // Model info + modelInfo.setText(client.getConfig().getModel().getTerminalType()); + + // Cursor position + ScreenBuffer sb = client.getScreenBuffer(); + int row = sb.getCursorRow() + 1; + int col = sb.getCursorCol() + 1; + cursorPosition.setText(String.format("%03d/%03d", row, col)); + } +} diff --git a/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java b/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java new file mode 100644 index 0000000..3163aab --- /dev/null +++ b/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java @@ -0,0 +1,581 @@ +package org.pubvm.j3270.ui; + +import org.lib3270j.Telnet3270Client; +import org.lib3270j.screen.ExtendedAttribute; +import org.lib3270j.screen.ScreenBuffer; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.*; + +import static org.lib3270j.protocol.DS3270Constants.*; + +/** + * Custom JPanel that renders the 3270 screen buffer. + * Supports colors, bold, underline, reverse, blink, and all 3278/3279 + * attributes. + */ +public class TerminalPanel extends JPanel { + + private Telnet3270Client client; + + // Font and cell dimensions + private Font terminalFont; + private int cellWidth; + private int cellHeight; + private int fontAscent; + private int fontDescent; + private int padding = 4; + private int currentFontSize = 16; + + // Cursor state + private boolean cursorVisible = true; + private Timer blinkTimer; + + // Host color mapping + private final Color[] hostColors = new Color[16]; + + // 3278 monochrome colors (based on field attributes) + private Color monoNormal; + private Color monoIntensify; + private Color monoProtected; + private Color monoProtectedHigh; + + // Background + private Color bgColor; + + // Default Host color mapping + public static final Color[] DEFAULT_HOST_COLORS = { + new Color(0, 0, 0), // 0: Neutral Black + new Color(80, 120, 255), // 1: Blue + new Color(255, 50, 50), // 2: Red + new Color(255, 130, 180), // 3: Pink + new Color(50, 205, 50), // 4: Green + new Color(64, 224, 208), // 5: Turquoise + new Color(255, 255, 80), // 6: Yellow + new Color(255, 255, 255), // 7: Neutral White + new Color(0, 0, 0), // 8: Black + new Color(30, 60, 180), // 9: Deep Blue + new Color(255, 165, 0), // 10: Orange + new Color(180, 130, 255), // 11: Purple + new Color(144, 238, 144), // 12: Pale Green + new Color(175, 238, 238), // 13: Pale Turquoise + new Color(170, 170, 170), // 14: Grey + new Color(255, 255, 255), // 15: White + }; + + // Default 3278 monochrome colors + public static final Color DEFAULT_MONO_NORMAL = new Color(50, 205, 50); + public static final Color DEFAULT_MONO_INTENSIFY = new Color(255, 255, 255); + public static final Color DEFAULT_MONO_PROTECTED = new Color(80, 120, 255); + public static final Color DEFAULT_MONO_PROTECTED_HIGH = new Color(255, 255, 255); + + // Default Background + public static final Color DEFAULT_BG_COLOR = new Color(10, 10, 10); + + public TerminalPanel() { + setupColors(); + setBackground(bgColor); + setFocusable(true); + setDoubleBuffered(true); + setFocusTraversalKeysEnabled(false); + + // Use key bindings instead of KeyListener for reliable key handling + // This avoids focus/event issues with JScrollPane + setOpaque(true); + setupKeyBindings(); + setupFont(); + setupCursorBlink(); + + // Handle mouse clicks to position cursor and grab focus + addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + requestFocusInWindow(); + if (client != null && client.getConnectionState().isFullSession()) { + int col = (e.getX() - padding) / cellWidth; + int row = (e.getY() - padding) / cellHeight; + ScreenBuffer sb = client.getScreenBuffer(); + if (row >= 0 && row < sb.getRows() && col >= 0 && col < sb.getCols()) { + sb.setCursorAddress(row * sb.getCols() + col); + repaint(); + } + } + } + }); + } + + /** + * Use InputMap/ActionMap (key bindings) instead of KeyListener. + * This works reliably even inside a JScrollPane — the WHEN_FOCUSED + * condition ensures our panel receives all key events when focused. + */ + private void bindKeyToMap(InputMap im, String action, String bindingStr) { + if ("UNBOUND".equals(bindingStr) || bindingStr == null || bindingStr.isEmpty()) { + return; + } + // Support multiple bindings separated by commas + String[] bindings = bindingStr.split(","); + for (String binding : bindings) { + binding = binding.trim(); + if (!binding.isEmpty() && !"UNBOUND".equals(binding)) { + KeyStroke ks = KeyStroke.getKeyStroke(binding); + if (ks != null) { + im.put(ks, "j3270-" + action); + } + } + } + } + + private void setupKeyBindings() { + InputMap im = getInputMap(JComponent.WHEN_FOCUSED); + ActionMap am = getActionMap(); + + im.clear(); + am.clear(); + + // Block the scroll pane from handling Tab, arrows, Page keys + String[] navKeys = { "TAB", "shift TAB", "UP", "DOWN", "LEFT", "RIGHT", + "PAGE_UP", "PAGE_DOWN", "HOME", "END", "ENTER", + "ESCAPE", "INSERT", "DELETE", "BACK_SPACE" }; + for (String key : navKeys) { + String binding = org.pubvm.j3270.config.Settings.getKeyBinding(key, key); + bindKeyToMap(im, key, binding); + } + + // PF keys 1-24 + // PF1-12 default to F1-F12, PF13-24 default to shift F1-shift F12 + for (int i = 1; i <= 24; i++) { + String defaultBinding; + if (i <= 12) { + defaultBinding = "F" + i; + } else { + defaultBinding = "shift F" + (i - 12); + } + String binding = org.pubvm.j3270.config.Settings.getKeyBinding("PF" + i, defaultBinding); + bindKeyToMap(im, "PF" + i, binding); + } + + // PA keys: Alt+1, Alt+2, Alt+3 + String pa1Def = "alt 1"; + String pa2Def = "alt 2"; + String pa3Def = "alt 3"; + bindKeyToMap(im, "PA1", org.pubvm.j3270.config.Settings.getKeyBinding("PA1", pa1Def)); + bindKeyToMap(im, "PA2", org.pubvm.j3270.config.Settings.getKeyBinding("PA2", pa2Def)); + bindKeyToMap(im, "PA3", org.pubvm.j3270.config.Settings.getKeyBinding("PA3", pa3Def)); + + // Clear + bindKeyToMap(im, "CLEAR", org.pubvm.j3270.config.Settings.getKeyBinding("CLEAR", "alt C")); + + // Create actions for all bound keys + am.put("j3270-ENTER", createAction(this::handleEnter)); + am.put("j3270-ESCAPE", createAction(this::handleReset)); + am.put("j3270-TAB", createAction(() -> handleTab(false))); + am.put("j3270-shift TAB", createAction(() -> handleTab(true))); + am.put("j3270-UP", createAction(() -> handleCursor("up"))); + am.put("j3270-DOWN", createAction(() -> handleCursor("down"))); + am.put("j3270-LEFT", createAction(() -> handleCursor("left"))); + am.put("j3270-RIGHT", createAction(() -> handleCursor("right"))); + am.put("j3270-HOME", createAction(() -> handleCursor("home"))); + am.put("j3270-PAGE_UP", createAction(() -> handlePF(7))); + am.put("j3270-PAGE_DOWN", createAction(() -> handlePF(8))); + am.put("j3270-END", createAction(this::handleEraseEOF)); + am.put("j3270-DELETE", createAction(this::handleDelete)); + am.put("j3270-BACK_SPACE", createAction(this::handleBackspace)); + am.put("j3270-INSERT", createAction(this::handleInsert)); + am.put("j3270-CLEAR", createAction(this::handleClear)); + + for (int i = 1; i <= 24; i++) { + final int pf = i; + am.put("j3270-PF" + i, createAction(() -> handlePF(pf))); + } + + am.put("j3270-PA1", createAction(() -> handlePA(1))); + am.put("j3270-PA2", createAction(() -> handlePA(2))); + am.put("j3270-PA3", createAction(() -> handlePA(3))); + + // For printable character input, we override processKeyEvent + enableEvents(AWTEvent.KEY_EVENT_MASK); + } + + @Override + protected void processKeyEvent(KeyEvent e) { + // Handle character typing via processKeyEvent to capture ALL typed chars + if (e.getID() == KeyEvent.KEY_TYPED) { + char ch = e.getKeyChar(); + if (ch >= 0x20 && ch != 0x7F && ch != KeyEvent.CHAR_UNDEFINED + && !e.isControlDown() && !e.isAltDown() && !e.isMetaDown()) { + if (client != null && client.getConnectionState().isFullSession()) { + client.typeCharacter(ch); + refreshScreen(); + e.consume(); + return; + } + } + } + super.processKeyEvent(e); + } + + private AbstractAction createAction(Runnable r) { + return new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + r.run(); + } + }; + } + + // ========== Key action handlers ========== + + private void handleEnter() { + if (client != null && client.getConnectionState().isFullSession()) { + client.sendEnter(); + refreshScreen(); + } + } + + private void handleReset() { + if (client != null) { + client.reset(); + refreshScreen(); + } + } + + private void handleTab(boolean shift) { + if (client != null && client.getConnectionState().isFullSession()) { + if (shift) + client.backTab(); + else + client.tab(); + refreshScreen(); + } + } + + private void handleCursor(String dir) { + if (client != null && client.getConnectionState().isFullSession()) { + switch (dir) { + case "up": + client.cursorUp(); + break; + case "down": + client.cursorDown(); + break; + case "left": + client.cursorLeft(); + break; + case "right": + client.cursorRight(); + break; + case "home": + client.cursorHome(); + break; + } + refreshScreen(); + } + } + + private void handlePF(int n) { + if (client != null && client.getConnectionState().isFullSession()) { + client.sendPF(n); + refreshScreen(); + } + } + + private void handlePA(int n) { + if (client != null && client.getConnectionState().isFullSession()) { + client.sendPA(n); + refreshScreen(); + } + } + + private void handleEraseEOF() { + if (client != null && client.getConnectionState().isFullSession()) { + client.getInputProcessor().eraseEof(); + refreshScreen(); + } + } + + private void handleDelete() { + if (client != null && client.getConnectionState().isFullSession()) { + client.getInputProcessor().deleteChar(); + refreshScreen(); + } + } + + private void handleBackspace() { + if (client != null && client.getConnectionState().isFullSession()) { + client.getInputProcessor().backspace(); + refreshScreen(); + } + } + + private void handleInsert() { + if (client != null) { + var ip = client.getInputProcessor(); + ip.setInsertMode(!ip.isInsertMode()); + refreshScreen(); + } + } + + private void handleClear() { + if (client != null && client.getConnectionState().isFullSession()) { + client.sendClear(); + refreshScreen(); + } + } + + private void refreshScreen() { + repaint(); + // Notify parent to update status bar too + Container parent = getParent(); + while (parent != null) { + if (parent instanceof JFrame) { + parent.repaint(); + break; + } + parent = parent.getParent(); + } + } + + private void setupFont() { + currentFontSize = org.pubvm.j3270.config.Settings.getFontSize(); + String fontFamily = org.pubvm.j3270.config.Settings.getFontFamily(); + + terminalFont = new Font(fontFamily, Font.PLAIN, currentFontSize); + if (terminalFont.getFamily().equals("Dialog") && !fontFamily.equals("Dialog")) { + // Fallback + terminalFont = new Font(Font.MONOSPACED, Font.PLAIN, currentFontSize); + } + updateCellSize(); + } + + private void setupColors() { + for (int i = 0; i < 16; i++) { + hostColors[i] = org.pubvm.j3270.config.Settings.getColorOverride(i, DEFAULT_HOST_COLORS[i]); + } + monoNormal = org.pubvm.j3270.config.Settings.getMonoColorOverride("NORMAL", DEFAULT_MONO_NORMAL); + monoIntensify = org.pubvm.j3270.config.Settings.getMonoColorOverride("INTENSIFY", DEFAULT_MONO_INTENSIFY); + monoProtected = org.pubvm.j3270.config.Settings.getMonoColorOverride("PROTECTED", DEFAULT_MONO_PROTECTED); + monoProtectedHigh = org.pubvm.j3270.config.Settings.getMonoColorOverride("PROTECTED_HIGH", DEFAULT_MONO_PROTECTED_HIGH); + bgColor = org.pubvm.j3270.config.Settings.getMonoColorOverride("BACKGROUND", DEFAULT_BG_COLOR); + setBackground(bgColor); + } + + public void reloadSettings() { + setupColors(); + setupKeyBindings(); + setupFont(); + revalidate(); + repaint(); + } + + public void setFontSize(int size) { + currentFontSize = size; + org.pubvm.j3270.config.Settings.setFontSize(size); + terminalFont = terminalFont.deriveFont((float) size); + updateCellSize(); + revalidate(); + repaint(); + } + + public int getFontSize() { + return currentFontSize; + } + + private void updateCellSize() { + FontMetrics fm = getFontMetrics(terminalFont); + cellWidth = fm.charWidth('M'); + cellHeight = fm.getHeight(); + fontAscent = fm.getAscent(); + fontDescent = fm.getDescent(); + } + + private void setupCursorBlink() { + blinkTimer = new Timer(530, e -> { + cursorVisible = !cursorVisible; + repaint(); + }); + blinkTimer.start(); + } + + public void setClient(Telnet3270Client client) { + this.client = client; + } + + @Override + public Dimension getPreferredSize() { + if (client != null) { + ScreenBuffer sb = client.getScreenBuffer(); + // Always size to the MAXIMUM (alternate) screen dimensions, + // matching how x3270 works — the window shows the full model size + int displayCols = sb.getMaxCols(); + int displayRows = sb.getMaxRows(); + return new Dimension(displayCols * cellWidth + padding * 2, + displayRows * cellHeight + padding * 2); + } + return new Dimension(80 * cellWidth + padding * 2, 24 * cellHeight + padding * 2); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + + if (client == null) + return; + + Graphics2D g2 = (Graphics2D) g; + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); + + // Manually clear the entire graphics bounds to ensure no ghosting on + // resize/clip + g2.setColor(bgColor); + g2.fillRect(0, 0, getWidth(), getHeight()); + + ScreenBuffer sb = client.getScreenBuffer(); + int rows = sb.getRows(); + int cols = sb.getCols(); + boolean isColorModel = client.getConfig().getModel().isColor(); + + // Track current field attribute for monochrome color decisions + byte currentFA = 0; + ExtendedAttribute currentFieldEa = null; + + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + int baddr = row * cols + col; + ExtendedAttribute ea = sb.getCell(baddr); + + int x = padding + col * cellWidth; + int y = padding + row * cellHeight; + + // Determine colors and attributes + Color fgColor; + Color bgColor; + boolean bold = false; + boolean underline = false; + boolean reverse = false; + + if (ea.isFieldAttribute()) { + currentFA = ea.fa; + currentFieldEa = ea; + // Field attributes display as blanks + g2.setColor(this.bgColor); + g2.fillRect(x, y, cellWidth, cellHeight); + continue; + } + + // Determine foreground color + if (isColorModel) { + fgColor = getColorForAttribute(ea, currentFieldEa, currentFA); + bgColor = getBackgroundForAttribute(ea, currentFieldEa); + } else { + fgColor = getMonoColor(currentFA); + bgColor = this.bgColor; + } + + // Graphics rendition + byte gr = ea.gr != 0 ? ea.gr : (currentFieldEa != null ? currentFieldEa.gr : 0); + if (gr != 0) { + if ((gr & GR_INTENSIFY) != 0) + bold = true; + if ((gr & GR_UNDERLINE) != 0) + underline = true; + if ((gr & GR_REVERSE) != 0) + reverse = true; + } + + // Field attribute implicit intensify + if (faIsHigh(currentFA & 0xFF)) + bold = true; + + // Handle invisible fields (zero intensity) + if (faIsZero(currentFA & 0xFF)) { + g2.setColor(this.bgColor); + g2.fillRect(x, y, cellWidth, cellHeight); + continue; + } + + // Apply reverse video + if (reverse) { + Color tmp = fgColor; + fgColor = bgColor; + bgColor = tmp; + } + + // Draw background + g2.setColor(bgColor); + g2.fillRect(x, y, cellWidth, cellHeight); + + // Draw character + char ch = ea.ucs4; + if (ch > 0x20 && ch != 0xFF) { + Font f = bold ? terminalFont.deriveFont(Font.BOLD) : terminalFont; + g2.setFont(f); + g2.setColor(fgColor); + g2.drawString(String.valueOf(ch), x, y + fontAscent); + } + + // Draw underline + if (underline) { + g2.setColor(fgColor); + g2.drawLine(x, y + cellHeight - fontDescent, + x + cellWidth - 1, y + cellHeight - fontDescent); + } + } + } + + // Draw cursor + if (cursorVisible && client.getConnectionState().isFullSession()) { + int curAddr = sb.getCursorAddress(); + int curRow = curAddr / cols; + int curCol = curAddr % cols; + int cx = padding + curCol * cellWidth; + int cy = padding + curRow * cellHeight; + + g2.setColor(new Color(255, 255, 255, 180)); + g2.setXORMode(bgColor); + g2.fillRect(cx, cy, cellWidth, cellHeight); + g2.setPaintMode(); + } + } + + private Color getColorForAttribute(ExtendedAttribute ea, ExtendedAttribute currentFieldEa, byte currentFA) { + int fg = ea.fg != 0 ? (ea.fg & 0xFF) + : (currentFieldEa != null && currentFieldEa.fg != 0 ? (currentFieldEa.fg & 0xFF) : 0); + if (fg >= 0xf0 && fg <= 0xff) { + return hostColors[fg - 0xf0]; + } + if (faIsProtected(currentFA & 0xFF)) { + return faIsHigh(currentFA & 0xFF) ? hostColors[HOST_COLOR_WHITE] : hostColors[HOST_COLOR_BLUE]; + } + return faIsHigh(currentFA & 0xFF) ? hostColors[HOST_COLOR_RED] : hostColors[HOST_COLOR_GREEN]; + } + + private Color getBackgroundForAttribute(ExtendedAttribute ea, ExtendedAttribute currentFieldEa) { + int bg = ea.bg != 0 ? (ea.bg & 0xFF) + : (currentFieldEa != null && currentFieldEa.bg != 0 ? (currentFieldEa.bg & 0xFF) : 0); + if (bg >= 0xf0 && bg <= 0xff) { + int idx = bg - 0xf0; + // Neutral black (0xf0) and black (0xf8) should use window bgColor + // to avoid visible seams between field bg and window bg + if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) { + return bgColor; + } + return hostColors[idx]; + } + return bgColor; + } + + private Color getMonoColor(byte fa) { + if (faIsProtected(fa & 0xFF)) { + return faIsHigh(fa & 0xFF) ? monoProtectedHigh : monoProtected; + } + return faIsHigh(fa & 0xFF) ? monoIntensify : monoNormal; + } + + public void dispose() { + if (blinkTimer != null) + blinkTimer.stop(); + } +} diff --git a/lib3270j/build.gradle b/lib3270j/build.gradle new file mode 100644 index 0000000..164eaac --- /dev/null +++ b/lib3270j/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'java-library' +} + +dependencies { +} + +jar { + manifest { + attributes 'Implementation-Title': 'lib3270j', + 'Implementation-Version': archiveVersion + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/ConnectionConfig.java b/lib3270j/src/main/java/org/lib3270j/ConnectionConfig.java new file mode 100644 index 0000000..303709f --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/ConnectionConfig.java @@ -0,0 +1,67 @@ +package org.lib3270j; + +/** + * Configuration for a 3270 terminal connection. + */ +public class ConnectionConfig { + + private String host; + private int port = 23; + private TerminalModel model = TerminalModel.IBM_3279_4; + private String luName = null; + private boolean extendedDataStream = true; + private boolean useTls = false; + private int connectTimeoutMs = 15000; + private int nopIntervalSeconds = 0; + private String terminalName = null; // override terminal type string + + public ConnectionConfig() {} + + public ConnectionConfig(String host, int port) { + this.host = host; + this.port = port; + } + + public ConnectionConfig(String host, int port, TerminalModel model) { + this.host = host; + this.port = port; + this.model = model; + } + + public String getHost() { return host; } + public void setHost(String host) { this.host = host; } + + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + + public TerminalModel getModel() { return model; } + public void setModel(TerminalModel model) { this.model = model; } + + public String getLuName() { return luName; } + public void setLuName(String luName) { this.luName = luName; } + + public boolean isExtendedDataStream() { return extendedDataStream; } + public void setExtendedDataStream(boolean ext) { this.extendedDataStream = ext; } + + public boolean isUseTls() { return useTls; } + public void setUseTls(boolean useTls) { this.useTls = useTls; } + + public int getConnectTimeoutMs() { return connectTimeoutMs; } + public void setConnectTimeoutMs(int ms) { this.connectTimeoutMs = ms; } + + public int getNopIntervalSeconds() { return nopIntervalSeconds; } + public void setNopIntervalSeconds(int s) { this.nopIntervalSeconds = s; } + + public String getTerminalName() { return terminalName; } + public void setTerminalName(String name) { this.terminalName = name; } + + /** + * Get the effective terminal type string to send during negotiation. + */ + public String getEffectiveTerminalType() { + if (terminalName != null) { + return terminalName; + } + return extendedDataStream ? model.getTerminalType() : model.getBaseTerminalType(); + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/ConnectionState.java b/lib3270j/src/main/java/org/lib3270j/ConnectionState.java new file mode 100644 index 0000000..d8c1199 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/ConnectionState.java @@ -0,0 +1,62 @@ +package org.lib3270j; + +/** + * Connection state machine states. + * Mirrors the cstate enum from globals.h in x3270. + */ +public enum ConnectionState { + NOT_CONNECTED, // No socket, unknown mode + RECONNECTING, // Delay before automatic reconnect + RESOLVING, // Resolving hostname + TCP_PENDING, // Socket connection pending + TLS_PENDING, // TLS negotiation pending + PROXY_PENDING, // Proxy negotiation pending + TELNET_PENDING, // Telnet negotiation pending + CONNECTED_NVT, // Connected in NVT line mode + CONNECTED_NVT_CHAR, // Connected in NVT character-at-a-time mode + CONNECTED_3270, // Connected in RFC 1576 TN3270 mode + CONNECTED_UNBOUND, // Connected in TN3270E mode, unbound + CONNECTED_E_NVT, // Connected in TN3270E NVT mode + CONNECTED_SSCP, // Connected in TN3270E SSCP-LU mode + CONNECTED_TN3270E; // Connected in TN3270E 3270 mode + + /** True if any kind of connection exists (even half-connected). */ + public boolean isConnected() { + return this.ordinal() > NOT_CONNECTED.ordinal(); + } + + /** True if in a half-connected state (resolving through telnet pending). */ + public boolean isHalfConnected() { + return this.ordinal() >= RESOLVING.ordinal() && this.ordinal() < CONNECTED_NVT.ordinal(); + } + + /** True if fully connected (past TCP pending). */ + public boolean isFullyConnected() { + return this.ordinal() > TCP_PENDING.ordinal(); + } + + /** True if in NVT mode (any flavor). */ + public boolean isNvt() { + return this == CONNECTED_NVT || this == CONNECTED_NVT_CHAR || this == CONNECTED_E_NVT; + } + + /** True if in 3270 mode (any flavor). */ + public boolean is3270() { + return this == CONNECTED_3270 || this == CONNECTED_TN3270E || this == CONNECTED_SSCP; + } + + /** True if in SSCP-LU mode. */ + public boolean isSscp() { + return this == CONNECTED_SSCP; + } + + /** True if in TN3270E mode (any submode). */ + public boolean isTn3270e() { + return this.ordinal() >= CONNECTED_UNBOUND.ordinal(); + } + + /** True if in a full data session (NVT or 3270). */ + public boolean isFullSession() { + return isNvt() || is3270(); + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/DiagnosticClient.java b/lib3270j/src/main/java/org/lib3270j/DiagnosticClient.java new file mode 100644 index 0000000..618b9d9 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/DiagnosticClient.java @@ -0,0 +1,174 @@ +package org.lib3270j; + +import org.lib3270j.screen.ScreenBuffer; +import org.lib3270j.screen.ExtendedAttribute; +import org.lib3270j.listener.*; + +import java.util.logging.*; + +/** + * Diagnostic: login as guest1/guest and trace the Welcome → MOTD transition. + */ +public class DiagnosticClient { + + static volatile int screenUpdateCount = 0; + static Telnet3270Client client; + + public static void main(String[] args) throws Exception { + Logger rootLogger = Logger.getLogger("org.lib3270j"); + rootLogger.setLevel(Level.ALL); + Handler handler = new ConsoleHandler(); + handler.setLevel(Level.ALL); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + Logger.getLogger("").setLevel(Level.WARNING); + + String host = args.length >= 1 ? args[0] : "192.168.0.30"; + int port = args.length >= 2 ? Integer.parseInt(args[1]) : 3270; + + ConnectionConfig config = new ConnectionConfig(host, port, TerminalModel.IBM_3279_2); + client = new Telnet3270Client(config); + + client.addConnectionListener(new ConnectionListener() { + @Override + public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) { + System.out.println(">>> STATE: " + oldState + " -> " + newState); + } + @Override + public void onConnectionError(String message) { + System.out.println(">>> ERROR: " + message); + } + @Override + public void onTN3270ENegotiated(String deviceType, String deviceName) { + System.out.println(">>> TN3270E: type=" + deviceType + " name=" + deviceName); + } + }); + + client.addScreenUpdateListener(new ScreenUpdateListener() { + @Override + public void onScreenUpdated() { + screenUpdateCount++; + System.out.println("\n>>> SCREEN UPDATE #" + screenUpdateCount); + dumpScreen(); + client.getInputProcessor().setKeyboardLocked(false); + } + @Override + public void onSoundAlarm() { + System.out.println(">>> ALARM"); + } + @Override + public void onScreenSizeChanged(int rows, int cols) { + System.out.println(">>> SCREEN SIZE: " + rows + "x" + cols); + } + }); + + System.out.println("=== Connecting to " + host + ":" + port + " ==="); + client.connect(); + + // Wait for login screen + waitForUpdates(5000); + + // Type guest1 at login field + System.out.println("\n=== Typing 'guest1' ==="); + typeAndWait("guest1", 2000); + + // Press Enter to submit login + System.out.println("\n=== Pressing Enter (submit login) ==="); + client.sendEnter(); + waitForUpdates(3000); + + // Check if we need to enter password + System.out.println("\n=== Typing password 'guest' ==="); + typeAndWait("guest", 2000); + client.sendEnter(); + waitForUpdates(5000); + + // Now we should see Welcome/allocations screen + // Wait for *** prompt + Thread.sleep(3000); + System.out.println("\n=== Current screen (should be Welcome/allocations with ***) ==="); + dumpScreen(); + + // Press Enter at *** to continue — THIS is where the screen should clear + System.out.println("\n=== Pressing Enter at *** (Welcome → MOTD transition) ==="); + client.sendEnter(); + waitForUpdates(5000); + + // Wait for more updates + Thread.sleep(3000); + System.out.println("\n=== After *** Enter — screen should have been cleared for MOTD ==="); + dumpScreen(); + + // If there's another *** prompt, press Enter again + System.out.println("\n=== Pressing Enter again ==="); + client.sendEnter(); + waitForUpdates(5000); + Thread.sleep(2000); + dumpScreen(); + + // One more Enter + System.out.println("\n=== Pressing Enter one more time ==="); + client.sendEnter(); + waitForUpdates(5000); + Thread.sleep(2000); + dumpScreen(); + + // Let it sit for any more data + Thread.sleep(3000); + + System.out.println("\n=== Final screen ==="); + dumpScreen(); + + client.disconnect(); + System.out.println(">>> Disconnected"); + } + + static void typeAndWait(String text, int waitMs) throws InterruptedException { + for (char c : text.toCharArray()) { + client.typeCharacter(c); + Thread.sleep(30); + } + Thread.sleep(waitMs); + } + + static void waitForUpdates(int timeoutMs) throws InterruptedException { + int start = screenUpdateCount; + long deadline = System.currentTimeMillis() + timeoutMs; + while (screenUpdateCount == start && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (screenUpdateCount == start) { + System.out.println(">>> (no screen update within " + timeoutMs + "ms)"); + } + Thread.sleep(500); + } + + static void dumpScreen() { + ScreenBuffer sb = client.getScreenBuffer(); + int rows = sb.getRows(); + int cols = sb.getCols(); + System.out.println("--- Screen " + rows + "x" + cols + + " cursor=" + sb.getCursorAddress() + + " formatted=" + sb.isFormatted() + + " state=" + client.getConnectionState() + " ---"); + for (int r = 0; r < rows; r++) { + StringBuilder line = new StringBuilder(); + boolean hasContent = false; + for (int c = 0; c < cols; c++) { + ExtendedAttribute ea = sb.getCell(r * cols + c); + if (ea.isFieldAttribute()) { + line.append('|'); + } else if (ea.ucs4 > 0x20 && ea.ucs4 != 0xFF) { + line.append(ea.ucs4); + hasContent = true; + } else { + line.append(' '); + } + } + if (hasContent) { + System.out.println(String.format("%02d: %s", r, line.toString().stripTrailing())); + } + } + System.out.println("---"); + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/org/lib3270j/Telnet3270Client.java new file mode 100644 index 0000000..0d1dc31 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/Telnet3270Client.java @@ -0,0 +1,192 @@ +package org.lib3270j; + +import org.lib3270j.charset.EbcdicTranslator; +import org.lib3270j.datastream.DataStreamProcessor; +import org.lib3270j.input.InputProcessor; +import org.lib3270j.listener.ConnectionListener; +import org.lib3270j.listener.ScreenUpdateListener; +import org.lib3270j.screen.ScreenBuffer; +import org.lib3270j.telnet.TelnetConnection; +import org.lib3270j.telnet.TelnetFSM; + +import java.io.IOException; +import java.util.logging.Logger; + +/** + * Main API entry point for lib3270j. + * + * Provides a high-level interface for connecting to a TN3270 host, + * managing the screen buffer, and handling user input. + * + * Usage: + *
+ *   ConnectionConfig config = new ConnectionConfig("hostname", 23, TerminalModel.IBM_3279_4);
+ *   Telnet3270Client client = new Telnet3270Client(config);
+ *   client.addConnectionListener(myListener);
+ *   client.addScreenUpdateListener(myScreenListener);
+ *   client.connect();
+ *   // ... interact with screen ...
+ *   client.disconnect();
+ * 
+ */ +public class Telnet3270Client { + + private static final Logger log = Logger.getLogger(Telnet3270Client.class.getName()); + + private final ConnectionConfig config; + private final EbcdicTranslator translator; + private final ScreenBuffer screenBuffer; + private final DataStreamProcessor dsProcessor; + private final TelnetFSM fsm; + private final InputProcessor inputProcessor; + private TelnetConnection connection; + + public Telnet3270Client(ConnectionConfig config) { + this.config = config; + this.translator = new EbcdicTranslator(); + this.screenBuffer = new ScreenBuffer(config.getModel(), translator); + this.dsProcessor = new DataStreamProcessor(screenBuffer, translator); + this.fsm = new TelnetFSM(config, screenBuffer, dsProcessor); + this.inputProcessor = new InputProcessor(screenBuffer, translator, fsm); + + // Wire up the output sender: DSProcessor -> FSM -> TelnetConnection + dsProcessor.setOutputSender(fsm::send3270Data); + } + + /** + * Connect to the configured host. + * This method blocks until the TCP connection is established, + * then returns while telnet/TN3270E negotiation continues asynchronously. + */ + public void connect() throws IOException { + log.info("Connecting to " + config.getHost() + ":" + config.getPort() + + " model=" + config.getModel()); + + connection = new TelnetConnection(config, fsm); + fsm.setConnection(connection); + + // Establish TCP connection + connection.connect(); + + // Notify FSM that TCP is connected — begins telnet negotiation + fsm.onConnected(); + } + + /** + * Disconnect from the host. + */ + public void disconnect() { + if (connection != null) { + connection.disconnect(); + connection = null; + } + fsm.onDisconnect(); + } + + /** + * Check if connected (any state past TCP pending). + */ + public boolean isConnected() { + return connection != null && connection.isConnected(); + } + + // ========== Listener management ========== + + public void addConnectionListener(ConnectionListener l) { + fsm.addConnectionListener(l); + } + + public void addScreenUpdateListener(ScreenUpdateListener l) { + fsm.addScreenUpdateListener(l); + dsProcessor.addScreenUpdateListener(l); + } + + // ========== Screen access ========== + + /** Get the screen buffer for rendering. */ + public ScreenBuffer getScreenBuffer() { return screenBuffer; } + + /** Get the EBCDIC translator. */ + public EbcdicTranslator getTranslator() { return translator; } + + /** Get the current connection state. */ + public ConnectionState getConnectionState() { return fsm.getConnectionState(); } + + /** Get the input processor for keyboard operations. */ + public InputProcessor getInputProcessor() { return inputProcessor; } + + /** Get the connection config. */ + public ConnectionConfig getConfig() { return config; } + + // ========== Convenience input methods ========== + + /** Type a character at the cursor position. */ + public void typeCharacter(char ch) { inputProcessor.typeCharacter(ch); } + + /** Type a string at the cursor position. */ + public void typeString(String s) { + for (char ch : s.toCharArray()) { + inputProcessor.typeCharacter(ch); + } + } + + /** Send Enter key. */ + public void sendEnter() { + inputProcessor.sendAid(org.lib3270j.protocol.DS3270Constants.AID_ENTER); + } + + /** Send a PF key (1-24). */ + public void sendPF(int number) { + int aid; + switch (number) { + case 1: aid = 0xf1; break; case 2: aid = 0xf2; break; + case 3: aid = 0xf3; break; case 4: aid = 0xf4; break; + case 5: aid = 0xf5; break; case 6: aid = 0xf6; break; + case 7: aid = 0xf7; break; case 8: aid = 0xf8; break; + case 9: aid = 0xf9; break; case 10: aid = 0x7a; break; + case 11: aid = 0x7b; break; case 12: aid = 0x7c; break; + case 13: aid = 0xc1; break; case 14: aid = 0xc2; break; + case 15: aid = 0xc3; break; case 16: aid = 0xc4; break; + case 17: aid = 0xc5; break; case 18: aid = 0xc6; break; + case 19: aid = 0xc7; break; case 20: aid = 0xc8; break; + case 21: aid = 0xc9; break; case 22: aid = 0x4a; break; + case 23: aid = 0x4b; break; case 24: aid = 0x4c; break; + default: return; + } + inputProcessor.sendAid(aid); + } + + /** Send a PA key (1-3). */ + public void sendPA(int number) { + int aid; + switch (number) { + case 1: aid = 0x6c; break; + case 2: aid = 0x6e; break; + case 3: aid = 0x6b; break; + default: return; + } + inputProcessor.sendAid(aid); + } + + /** Send Clear key. */ + public void sendClear() { + inputProcessor.sendAid(org.lib3270j.protocol.DS3270Constants.AID_CLEAR); + } + + /** Move cursor up. */ + public void cursorUp() { inputProcessor.cursorUp(); } + /** Move cursor down. */ + public void cursorDown() { inputProcessor.cursorDown(); } + /** Move cursor left. */ + public void cursorLeft() { inputProcessor.cursorLeft(); } + /** Move cursor right. */ + public void cursorRight() { inputProcessor.cursorRight(); } + /** Move cursor to home position. */ + public void cursorHome() { inputProcessor.cursorHome(); } + /** Tab to next unprotected field. */ + public void tab() { inputProcessor.tab(); } + /** Back-tab to previous unprotected field. */ + public void backTab() { inputProcessor.backTab(); } + /** Reset (unlock keyboard). */ + public void reset() { inputProcessor.reset(); } +} diff --git a/lib3270j/src/main/java/org/lib3270j/TerminalModel.java b/lib3270j/src/main/java/org/lib3270j/TerminalModel.java new file mode 100644 index 0000000..a2fe139 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/TerminalModel.java @@ -0,0 +1,75 @@ +package org.lib3270j; + +import static org.lib3270j.protocol.DS3270Constants.*; + +/** + * Terminal model definitions for IBM 3278 and 3279 terminals. + * Models 2-5 are supported, each with default (24x80) and alternate screen sizes. + */ +public enum TerminalModel { + IBM_3278_2(2, false, MODEL_2_ROWS, MODEL_2_COLS, MODEL_2_ROWS, MODEL_2_COLS), + IBM_3278_3(3, false, MODEL_2_ROWS, MODEL_2_COLS, MODEL_3_ROWS, MODEL_3_COLS), + IBM_3278_4(4, false, MODEL_2_ROWS, MODEL_2_COLS, MODEL_4_ROWS, MODEL_4_COLS), + IBM_3278_5(5, false, MODEL_2_ROWS, MODEL_2_COLS, MODEL_5_ROWS, MODEL_5_COLS), + IBM_3279_2(2, true, MODEL_2_ROWS, MODEL_2_COLS, MODEL_2_ROWS, MODEL_2_COLS), + IBM_3279_3(3, true, MODEL_2_ROWS, MODEL_2_COLS, MODEL_3_ROWS, MODEL_3_COLS), + IBM_3279_4(4, true, MODEL_2_ROWS, MODEL_2_COLS, MODEL_4_ROWS, MODEL_4_COLS), + IBM_3279_5(5, true, MODEL_2_ROWS, MODEL_2_COLS, MODEL_5_ROWS, MODEL_5_COLS); + + private final int modelNumber; + private final boolean color; + private final int defaultRows; + private final int defaultCols; + private final int alternateRows; + private final int alternateCols; + + TerminalModel(int modelNumber, boolean color, + int defaultRows, int defaultCols, + int alternateRows, int alternateCols) { + this.modelNumber = modelNumber; + this.color = color; + this.defaultRows = defaultRows; + this.defaultCols = defaultCols; + this.alternateRows = alternateRows; + this.alternateCols = alternateCols; + } + + public int getModelNumber() { return modelNumber; } + public boolean isColor() { return color; } + public int getDefaultRows() { return defaultRows; } + public int getDefaultCols() { return defaultCols; } + public int getAlternateRows() { return alternateRows; } + public int getAlternateCols() { return alternateCols; } + + /** + * Returns the terminal type string for TN3270E negotiation. + * e.g., "IBM-3279-4-E" for a color model 4 with extended data stream. + */ + public String getTerminalType() { + return String.format("IBM-327%c-%d-E", color ? '9' : '8', modelNumber); + } + + /** + * Returns the base terminal type without "-E" suffix (for non-extended mode). + */ + public String getBaseTerminalType() { + return String.format("IBM-327%c-%d", color ? '9' : '8', modelNumber); + } + + /** + * Look up a model by number and color mode. + */ + public static TerminalModel forModel(int number, boolean isColor) { + for (TerminalModel m : values()) { + if (m.modelNumber == number && m.color == isColor) { + return m; + } + } + throw new IllegalArgumentException("Unknown model: " + number + " color=" + isColor); + } + + @Override + public String toString() { + return getTerminalType(); + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/charset/EbcdicTranslator.java b/lib3270j/src/main/java/org/lib3270j/charset/EbcdicTranslator.java new file mode 100644 index 0000000..862d0cb --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/charset/EbcdicTranslator.java @@ -0,0 +1,128 @@ +package org.lib3270j.charset; + +/** + * EBCDIC ↔ Unicode translator. + * Default: Code Page 037 (US/Canada EBCDIC). + */ +public class EbcdicTranslator { + + /** + * EBCDIC Code Page 037 to Unicode mapping. + * Index is the EBCDIC byte value (0x00-0xFF), value is the Unicode codepoint. + */ + private static final int[] CP037_TO_UNICODE = { + // 00-0F + 0x0000, 0x0001, 0x0002, 0x0003, 0x009C, 0x0009, 0x0086, 0x007F, + 0x0097, 0x008D, 0x008E, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + // 10-1F + 0x0010, 0x0011, 0x0012, 0x0013, 0x009D, 0x0085, 0x0008, 0x0087, + 0x0018, 0x0019, 0x0092, 0x008F, 0x001C, 0x001D, 0x001E, 0x001F, + // 20-2F + 0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x000A, 0x0017, 0x001B, + 0x0088, 0x0089, 0x008A, 0x008B, 0x008C, 0x0005, 0x0006, 0x0007, + // 30-3F + 0x0090, 0x0091, 0x0016, 0x0093, 0x0094, 0x0095, 0x0096, 0x0004, + 0x0098, 0x0099, 0x009A, 0x009B, 0x0014, 0x0015, 0x009E, 0x001A, + // 40-4F (space, accent chars, punctuation) + 0x0020, 0x00A0, 0x00E2, 0x00E4, 0x00E0, 0x00E1, 0x00E3, 0x00E5, + 0x00E7, 0x00F1, 0x00A2, 0x002E, 0x003C, 0x0028, 0x002B, 0x007C, + // 50-5F + 0x0026, 0x00E9, 0x00EA, 0x00EB, 0x00E8, 0x00ED, 0x00EE, 0x00EF, + 0x00EC, 0x00DF, 0x0021, 0x0024, 0x002A, 0x0029, 0x003B, 0x00AC, + // 60-6F + 0x002D, 0x002F, 0x00C2, 0x00C4, 0x00C0, 0x00C1, 0x00C3, 0x00C5, + 0x00C7, 0x00D1, 0x00A6, 0x002C, 0x0025, 0x005F, 0x003E, 0x003F, + // 70-7F + 0x00F8, 0x00C9, 0x00CA, 0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, + 0x00CC, 0x0060, 0x003A, 0x0023, 0x0040, 0x0027, 0x003D, 0x0022, + // 80-8F (lowercase a-i) + 0x00D8, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, + 0x0068, 0x0069, 0x00AB, 0x00BB, 0x00F0, 0x00FD, 0x00FE, 0x00B1, + // 90-9F (lowercase j-r) + 0x00B0, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, 0x0070, + 0x0071, 0x0072, 0x00AA, 0x00BA, 0x00E6, 0x00B8, 0x00C6, 0x00A4, + // A0-AF (lowercase s-z) + 0x00B5, 0x007E, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, + 0x0079, 0x007A, 0x00A1, 0x00BF, 0x00D0, 0x00DD, 0x00DE, 0x00AE, + // B0-BF + 0x005E, 0x00A3, 0x00A5, 0x00B7, 0x00A9, 0x00A7, 0x00B6, 0x00BC, + 0x00BD, 0x00BE, 0x005B, 0x005D, 0x00AF, 0x00A8, 0x00B4, 0x00D7, + // C0-CF (uppercase A-I) + 0x007B, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, + 0x0048, 0x0049, 0x00AD, 0x00F4, 0x00F6, 0x00F2, 0x00F3, 0x00F5, + // D0-DF (uppercase J-R) + 0x007D, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, 0x0050, + 0x0051, 0x0052, 0x00B9, 0x00FB, 0x00FC, 0x00F9, 0x00FA, 0x00FF, + // E0-EF (uppercase S-Z) + 0x005C, 0x00F7, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, + 0x0059, 0x005A, 0x00B2, 0x00D4, 0x00D6, 0x00D2, 0x00D3, 0x00D5, + // F0-FF (digits 0-9) + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, + 0x0038, 0x0039, 0x00B3, 0x00DB, 0x00DC, 0x00D9, 0x00DA, 0x009F, + }; + + /** + * Unicode to EBCDIC Code Page 037 mapping (for basic Latin + Latin-1). + * Index is the Unicode codepoint (0x00-0xFF), value is the EBCDIC byte (-1 if unmappable). + */ + private static final int[] UNICODE_TO_CP037 = new int[256]; + + static { + // Build reverse mapping + java.util.Arrays.fill(UNICODE_TO_CP037, -1); + for (int i = 0; i < 256; i++) { + int unicode = CP037_TO_UNICODE[i]; + if (unicode < 256) { + UNICODE_TO_CP037[unicode] = i; + } + } + } + + /** + * Translate EBCDIC byte to Unicode character. + */ + public char ebcdicToUnicode(int ebc) { + return (char) CP037_TO_UNICODE[ebc & 0xFF]; + } + + /** + * Translate Unicode character to EBCDIC byte. + * Returns -1 if the character cannot be mapped. + */ + public int unicodeToEbcdic(char unicode) { + if (unicode < 256) { + return UNICODE_TO_CP037[unicode]; + } + return -1; + } + + /** + * Translate Unicode character to EBCDIC, returning EBCDIC space (0x40) if unmappable. + */ + public byte unicodeToEbcdicSafe(char unicode) { + int ebc = unicodeToEbcdic(unicode); + return (byte) (ebc >= 0 ? ebc : 0x40); + } + + /** + * Translate a byte array from EBCDIC to a Unicode string. + */ + public String ebcdicToString(byte[] ebcdic, int offset, int length) { + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(ebcdicToUnicode(ebcdic[offset + i] & 0xFF)); + } + return sb.toString(); + } + + /** + * Translate a Unicode string to EBCDIC byte array. + */ + public byte[] stringToEbcdic(String s) { + byte[] result = new byte[s.length()]; + for (int i = 0; i < s.length(); i++) { + result[i] = unicodeToEbcdicSafe(s.charAt(i)); + } + return result; + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/datastream/DataStreamProcessor.java b/lib3270j/src/main/java/org/lib3270j/datastream/DataStreamProcessor.java new file mode 100644 index 0000000..eac671a --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/datastream/DataStreamProcessor.java @@ -0,0 +1,813 @@ +package org.lib3270j.datastream; + +import org.lib3270j.screen.ScreenBuffer; +import org.lib3270j.screen.ExtendedAttribute; +import org.lib3270j.charset.EbcdicTranslator; +import org.lib3270j.listener.ScreenUpdateListener; +import static org.lib3270j.protocol.DS3270Constants.*; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Logger; + +/** + * Processes inbound 3270 data stream records. + * Handles all 3270 commands: Write, Erase/Write, Read Buffer, WSF, etc. + * Equivalent to process_ds() and ctlr_write() in ctlr.c. + */ +public class DataStreamProcessor { + + private static final Logger log = Logger.getLogger(DataStreamProcessor.class.getName()); + + private final ScreenBuffer screen; + private final EbcdicTranslator translator; + private final QueryReplyBuilder qrBuilder; + private final List screenListeners = new CopyOnWriteArrayList<>(); + + // Output buffer for responses (read buffer, query replies, etc.) + private byte[] outputBuffer; + private int outputPos; + + // Callback for sending data back to host + private OutputSender outputSender; + + /** Functional interface for sending output back through the telnet stack. */ + @FunctionalInterface + public interface OutputSender { + void send3270Data(byte[] data); + } + + public DataStreamProcessor(ScreenBuffer screen, EbcdicTranslator translator) { + this.screen = screen; + this.translator = translator; + this.qrBuilder = new QueryReplyBuilder(screen); + this.outputBuffer = new byte[32768]; + this.outputPos = 0; + } + + public void setOutputSender(OutputSender sender) { + this.outputSender = sender; + } + + public void addScreenUpdateListener(ScreenUpdateListener l) { + screenListeners.add(l); + } + + /** + * Process a 3270 data stream record. + * + * @param data Raw data buffer + * @param offset Offset of the first byte (command byte) + * @param length Number of bytes + * @param keyboardRestore Whether to restore keyboard after processing + */ + public void processRecord(byte[] data, int offset, int length, boolean keyboardRestore) { + if (length == 0) + return; + + int cmd = data[offset] & 0xFF; + log.info(">>> CMD: " + commandName(cmd) + " (0x" + String.format("%02x", cmd) + ") " + length + " bytes"); + + switch (cmd) { + case CMD_W: + case SNA_CMD_W: + processWrite(data, offset, length, false); + break; + case CMD_EW: + case SNA_CMD_EW: + log.info(">>> ERASE/WRITE: clearing screen (default size)"); { + int oldRows = screen.getRows(); + int oldCols = screen.getCols(); + screen.erase(false); + processWrite(data, offset, length, true); + if (oldRows != screen.getRows() || oldCols != screen.getCols()) { + notifyScreenSizeChanged(); + } + } + break; + case CMD_EWA: + case SNA_CMD_EWA: + log.info(">>> ERASE/WRITE ALTERNATE: clearing screen (alt size)"); { + int oldRows = screen.getRows(); + int oldCols = screen.getCols(); + screen.erase(true); + processWrite(data, offset, length, true); + if (oldRows != screen.getRows() || oldCols != screen.getCols()) { + notifyScreenSizeChanged(); + } + } + break; + case CMD_RB: + case SNA_CMD_RB: + processReadBuffer(); + break; + case CMD_RM: + case SNA_CMD_RM: + processReadModified(false); + break; + case CMD_RMA: + case SNA_CMD_RMA: + processReadModified(true); + break; + case CMD_EAU: + case SNA_CMD_EAU: + log.info(">>> EAU: erasing all unprotected fields"); + screen.eraseAllUnprotected(); + break; + case CMD_WSF: + case SNA_CMD_WSF: + processWriteStructuredField(data, offset, length); + break; + case CMD_NOP: + log.info(">>> NOP command"); + break; + default: + log.warning(">>> UNKNOWN 3270 command: " + String.format("0x%02x", cmd)); + break; + } + + // Translate EBCDIC to Unicode for display + screen.translateToUnicode(); + screen.markAllChanged(); + + // Debug: dump non-empty screen lines + if (cmd == SNA_CMD_EWA || cmd == CMD_EWA || cmd == SNA_CMD_EW || cmd == CMD_EW) { + int r = screen.getRows(); + int c = screen.getCols(); + StringBuilder dump = new StringBuilder(); + int linesShown = 0; + for (int row = 0; row < r && linesShown < 5; row++) { + StringBuilder line = new StringBuilder(); + boolean hasContent = false; + for (int col = 0; col < c; col++) { + int addr = row * c + col; + ExtendedAttribute ea = screen.getCell(addr); + byte fa = screen.getFieldAttributeAt(addr); + + if ((fa & org.lib3270j.protocol.DS3270Constants.FA_MASK) == org.lib3270j.protocol.DS3270Constants.FA_INT_ZERO_NSEL) { + if (ea.ucs4 > 0x20 && ea.ucs4 != 0xFF) { + line.append('*'); + hasContent = true; + } else { + line.append(' '); + } + } else if (ea.ucs4 > 0x20 && ea.ucs4 != 0xFF) { + line.append(ea.ucs4); + hasContent = true; + } else { + line.append(' '); + } + } + if (hasContent) { + dump.append(String.format(" %02d: %s\n", row, line.toString().stripTrailing())); + linesShown++; + } + } + if (dump.length() > 0) { + log.info("Screen content after " + commandName(cmd) + ":\n" + dump.toString()); + } + } + } + + /** + * Process SSCP-LU data (TN3270E SSCP-LU mode). + * Data is character data placed starting at cursor position. + * Unlike 3270 mode, SSCP-LU data accumulates — we do NOT clear the screen. + */ + public void processSscpLuData(byte[] data, int offset, int length) { + int size = screen.getRows() * screen.getCols(); + int baddr = screen.getCursorAddress(); + int cols = screen.getCols(); + + for (int i = offset; i < offset + length; i++) { + int c = data[i] & 0xFF; + switch (c) { + case 0x15: // NL (new line) — move to column 0 of next row + baddr = ((baddr / cols) + 1) * cols; + if (baddr >= size) + baddr = 0; + break; + case 0x0d: // CR (carriage return) — move to column 0 of current row + baddr = (baddr / cols) * cols; + break; + case 0x0c: // FF (form feed) — clear screen and home cursor + screen.clear(); + baddr = 0; + break; + case 0x00: // NULL — skip + break; + default: + ExtendedAttribute ea = screen.getCell(baddr); + ea.ec = (byte) c; + ea.ucs4 = translator.ebcdicToUnicode(c); + baddr = (baddr + 1) % size; + break; + } + } + screen.setCursorAddress(baddr); + screen.translateToUnicode(); + screen.markAllChanged(); + } + + // ========== Write processing ========== + + private void processWrite(byte[] data, int offset, int length, boolean eraseFirst) { + if (length < 2) + return; + + // WCC is the second byte + int wcc = data[offset + 1] & 0xFF; + boolean alarm = wccSoundAlarm(wcc); + boolean kbdRestore = wccKeyboardRestore(wcc); + boolean resetMdt = wccResetMDT(wcc); + + log.fine("WCC: " + String.format("0x%02x", wcc) + + " reset=" + wccReset(wcc) + " alarm=" + alarm + " kbdRestore=" + kbdRestore + " resetMdt=" + resetMdt); + + if (resetMdt) { + resetAllMDT(); + } + + // WCC reset bit — reset partition characteristics + if (wccReset(wcc)) { + // Reset all character attributes to defaults + log.fine("WCC reset: clearing default attributes"); + } + + if (eraseFirst) { + screen.clear(); + log.fine("Cleared screen for Erase/Write"); + } + + // Process orders and data starting at byte 2 + int pos = offset + 2; + int end = offset + length; + int baddr = screen.getBufferAddress(); + int size = screen.getRows() * screen.getCols(); + + // Current SA (set attribute) values for character-mode + byte currentFg = 0, currentBg = 0, currentGr = 0, currentCs = 0; + boolean lastWasOrder = false; + + while (pos < end) { + int b = data[pos] & 0xFF; + + switch (b) { + case ORDER_SBA: { // Set Buffer Address + if (pos + 2 >= end) { + pos = end; + break; + } + baddr = decodeAddress(data[pos + 1] & 0xFF, data[pos + 2] & 0xFF); + if (baddr >= size) + baddr = baddr % size; + screen.setBufferAddress(baddr); + log.finest("SBA " + baddr + " (row=" + (baddr / screen.getCols()) + " col=" + + (baddr % screen.getCols()) + ")"); + pos += 3; + lastWasOrder = true; + break; + } + + case ORDER_SF: { // Start Field + if (pos + 1 >= end) { + pos = end; + break; + } + int fa = data[pos + 1] & 0xFF; + ExtendedAttribute ea = screen.getCell(baddr); + ea.clear(); + ea.fa = (byte) (fa & FA_MASK); + // FA position is a display position that shows as blank + ea.ec = 0; + ea.ucs4 = ' '; + currentFg = 0; + currentBg = 0; + currentGr = 0; + currentCs = 0; + screen.setFormatted(true); + baddr = (baddr + 1) % size; + screen.setBufferAddress(baddr); + log.finest("SF fa=" + String.format("0x%02x", fa)); + pos += 2; + lastWasOrder = true; + break; + } + + case ORDER_SFE: { // Start Field Extended + if (pos + 1 >= end) { + pos = end; + break; + } + int nPairs = data[pos + 1] & 0xFF; + if (pos + 2 + nPairs * 2 > end) { + pos = end; + break; + } + + ExtendedAttribute ea = screen.getCell(baddr); + ea.clear(); + ea.ec = 0; + ea.ucs4 = ' '; + currentFg = 0; + currentBg = 0; + currentGr = 0; + currentCs = 0; + + for (int i = 0; i < nPairs; i++) { + int attrType = data[pos + 2 + i * 2] & 0xFF; + int attrValue = data[pos + 2 + i * 2 + 1] & 0xFF; + applyExtendedAttribute(ea, attrType, attrValue); + } + if (ea.fa == 0) { + // If no 3270 FA was among the pairs, set default + ea.fa = (byte) (FA_PRINTABLE); + } + screen.setFormatted(true); + baddr = (baddr + 1) % size; + screen.setBufferAddress(baddr); + pos += 2 + nPairs * 2; + lastWasOrder = true; + break; + } + + case ORDER_SA: { // Set Attribute + if (pos + 2 >= end) { + pos = end; + break; + } + int attrType = data[pos + 1] & 0xFF; + int attrValue = data[pos + 2] & 0xFF; + switch (attrType) { + case XA_FOREGROUND: + currentFg = (byte) attrValue; + break; + case XA_BACKGROUND: + currentBg = (byte) attrValue; + break; + case XA_HIGHLIGHTING: + if (attrValue == XAH_DEFAULT) + currentGr = 0; + else if (attrValue == XAH_BLINK) + currentGr = GR_BLINK; + else if (attrValue == XAH_REVERSE) + currentGr = GR_REVERSE; + else if (attrValue == XAH_UNDERSCORE) + currentGr = GR_UNDERLINE; + else if (attrValue == XAH_INTENSIFY) + currentGr = GR_INTENSIFY; + else if (attrValue == XAH_NORMAL) + currentGr = 0; + break; + case XA_CHARSET: + currentCs = (byte) attrValue; + break; + } + pos += 3; + lastWasOrder = true; + break; + } + + case ORDER_MF: { // Modify Field + if (pos + 1 >= end) { + pos = end; + break; + } + int nPairs = data[pos + 1] & 0xFF; + if (pos + 2 + nPairs * 2 > end) { + pos = end; + break; + } + + // Find the field attribute at or before current position + int faAddr = screen.findFieldAttribute(baddr); + if (faAddr >= 0) { + ExtendedAttribute ea = screen.getCell(faAddr); + for (int i = 0; i < nPairs; i++) { + int attrType = data[pos + 2 + i * 2] & 0xFF; + int attrValue = data[pos + 2 + i * 2 + 1] & 0xFF; + applyExtendedAttribute(ea, attrType, attrValue); + } + } + pos += 2 + nPairs * 2; + lastWasOrder = true; + break; + } + + case ORDER_IC: { // Insert Cursor + screen.setCursorAddress(baddr); + log.finest("IC at " + baddr); + pos += 1; + lastWasOrder = true; + break; + } + + case ORDER_PT: { // Program Tab + // Skip to next unprotected field + baddr = screen.findNextUnprotected(baddr); + screen.setBufferAddress(baddr); + pos += 1; + lastWasOrder = true; + break; + } + + case ORDER_RA: { // Repeat to Address + if (pos + 3 >= end) { + pos = end; + break; + } + int toAddr = decodeAddress(data[pos + 1] & 0xFF, data[pos + 2] & 0xFF); + int fillChar = data[pos + 3] & 0xFF; + if (toAddr >= size) + toAddr = toAddr % size; + + // Handle GE (graphic escape) prefix + byte fillCs = currentCs; + if (pos + 4 < end && fillChar == ORDER_GE) { + fillChar = data[pos + 4] & 0xFF; + fillCs = CS_GE; + pos += 5; + } else { + pos += 4; + } + + // Fill from current position to target + while (baddr != toAddr) { + ExtendedAttribute ea = screen.getCell(baddr); + ea.fa = 0; // Destroy previous field attribute if any + ea.ec = (byte) fillChar; + ea.fg = currentFg; + ea.bg = currentBg; + ea.gr = (byte) currentGr; + ea.cs = fillCs; + baddr = (baddr + 1) % size; + } + screen.setBufferAddress(baddr); + lastWasOrder = true; + break; + } + + case ORDER_EUA: { // Erase Unprotected to Address + if (pos + 2 >= end) { + pos = end; + break; + } + int toAddr = decodeAddress(data[pos + 1] & 0xFF, data[pos + 2] & 0xFF); + if (toAddr >= size) + toAddr = toAddr % size; + + while (baddr != toAddr) { + ExtendedAttribute ea = screen.getCell(baddr); + if (!ea.isFieldAttribute()) { + int faAddr = screen.findFieldAttribute(baddr); + byte faVal = faAddr >= 0 ? screen.getCell(faAddr).fa : 0; + if (!faIsProtected(faVal & 0xFF)) { + ea.ec = 0; + ea.ucs4 = 0; + } + } + baddr = (baddr + 1) % size; + } + screen.setBufferAddress(baddr); + pos += 3; + lastWasOrder = true; + break; + } + + case ORDER_GE: { // Graphic Escape + if (pos + 1 >= end) { + pos = end; + break; + } + int geChar = data[pos + 1] & 0xFF; + ExtendedAttribute ea = screen.getCell(baddr); + ea.fa = 0; // Destroy previous field attribute if any + ea.ec = (byte) geChar; + ea.cs = CS_GE; + ea.fg = currentFg; + ea.bg = currentBg; + ea.gr = (byte) currentGr; + baddr = (baddr + 1) % size; + screen.setBufferAddress(baddr); + pos += 2; + lastWasOrder = true; + break; + } + + default: { + // Regular data byte (EBCDIC character) + // Format control codes display as nulls/blanks per 3270 spec + boolean isFCOrder = (b == FCORDER_NULL || b == FCORDER_FF || + b == FCORDER_CR || b == FCORDER_NL || b == FCORDER_EM || + b == FCORDER_DUP || b == FCORDER_FM || b == FCORDER_SUB || + b == FCORDER_EO); + ExtendedAttribute ea = screen.getCell(baddr); + ea.fa = 0; // Destroy previous field attribute if any + + if (isFCOrder) { + ea.ec = 0; // Display as null/blank + } else { + ea.ec = (byte) b; + } + ea.fg = currentFg; + ea.bg = currentBg; + ea.gr = (byte) currentGr; + ea.cs = currentCs; + + baddr = (baddr + 1) % size; + screen.setBufferAddress(baddr); + pos += 1; + lastWasOrder = false; + break; + } + } + } + + if (alarm) { + for (ScreenUpdateListener l : screenListeners) { + l.onSoundAlarm(); + } + } + + screen.updateFormatted(); + } + + private void applyExtendedAttribute(ExtendedAttribute ea, int type, int value) { + switch (type) { + case XA_3270: + ea.fa = (byte) (value & FA_MASK); + break; + case XA_FOREGROUND: + ea.fg = (byte) value; + break; + case XA_BACKGROUND: + ea.bg = (byte) value; + break; + case XA_HIGHLIGHTING: + if (value == XAH_DEFAULT || value == XAH_NORMAL) + ea.gr = 0; + else if (value == XAH_BLINK) + ea.gr = GR_BLINK; + else if (value == XAH_REVERSE) + ea.gr = GR_REVERSE; + else if (value == XAH_UNDERSCORE) + ea.gr = GR_UNDERLINE; + else if (value == XAH_INTENSIFY) + ea.gr = GR_INTENSIFY; + break; + case XA_CHARSET: + ea.cs = (byte) value; + break; + case XA_VALIDATION: + case XA_OUTLINING: + case XA_INPUT_CONTROL: + // Acknowledged but not visually rendered yet + break; + } + } + + private void resetAllMDT() { + int size = screen.getRows() * screen.getCols(); + for (int i = 0; i < size; i++) { + ExtendedAttribute ea = screen.getCell(i); + if (ea.isFieldAttribute()) { + ea.fa = (byte) (ea.fa & ~FA_MODIFY); + } + } + } + + // ========== Read Buffer ========== + + private void processReadBuffer() { + outputPos = 0; + + int size = screen.getRows() * screen.getCols(); + + // AID byte + outputWrite(AID_NO); + // Cursor address + byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols()); + outputWrite(caddr[0] & 0xFF); + outputWrite(caddr[1] & 0xFF); + + // Buffer contents + for (int i = 0; i < size; i++) { + ExtendedAttribute ea = screen.getCell(i); + if (ea.isFieldAttribute()) { + outputWrite(ORDER_SF); + outputWrite(ea.fa & 0xFF); + } else { + outputWrite(ea.ec & 0xFF); + } + } + + sendOutput(); + } + + // ========== Read Modified ========== + + private void processReadModified(boolean all) { + outputPos = 0; + + int aid = AID_NO; // Last AID + int size = screen.getRows() * screen.getCols(); + + // AID byte + outputWrite(aid); + + // Cursor address + byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols()); + outputWrite(caddr[0] & 0xFF); + outputWrite(caddr[1] & 0xFF); + + if (!screen.isFormatted()) { + // Unformatted: send everything + if (all) { + for (int i = 0; i < size; i++) { + outputWrite(screen.getCell(i).ec & 0xFF); + } + } + } else { + // Formatted: send modified fields + for (int i = 0; i < size; i++) { + ExtendedAttribute ea = screen.getCell(i); + if (ea.isFieldAttribute() && (all || faIsModified(ea.fa & 0xFF))) { + int fieldStart = (i + 1) % size; + // Send SBA for field start + outputWrite(ORDER_SBA); + byte[] addr = encodeAddress(fieldStart, screen.getRows(), screen.getCols()); + outputWrite(addr[0] & 0xFF); + outputWrite(addr[1] & 0xFF); + + // Send field contents until next FA + int pos = fieldStart; + while (pos < size && !screen.getCell(pos).isFieldAttribute()) { + outputWrite(screen.getCell(pos).ec & 0xFF); + pos = (pos + 1) % size; + if (pos == fieldStart) + break; + } + } + } + } + + sendOutput(); + } + + // ========== Write Structured Field ========== + + private void processWriteStructuredField(byte[] data, int offset, int length) { + int pos = offset + 1; // Skip WSF command byte + int end = offset + length; + + while (pos < end) { + // Field length (2 bytes) + if (pos + 2 > end) + break; + int fieldLen = ((data[pos] & 0xFF) << 8) | (data[pos + 1] & 0xFF); + if (fieldLen == 0) + fieldLen = end - pos; + if (fieldLen < 3 || pos + fieldLen > end) + break; + + int sfId = data[pos + 2] & 0xFF; + log.fine("SF id=" + String.format("0x%02x", sfId) + " len=" + fieldLen); + + switch (sfId) { + case SF_READ_PART: + processSFReadPartition(data, pos, fieldLen); + break; + case SF_ERASE_RESET: + if (fieldLen >= 4) { + boolean alt = (data[pos + 3] & 0xFF) == SF_ER_ALT; + screen.erase(alt); + notifyScreenSizeChanged(); + } + break; + case SF_SET_REPLY_MODE: + if (fieldLen >= 5) { + screen.setReplyMode((byte) (data[pos + 4] & 0xFF)); + } + break; + case SF_CREATE_PART: + // Acknowledged — we use implicit partition + break; + case SF_OUTBOUND_DS: + if (fieldLen > 5) { + // Outbound DS contains another 3270 command + processRecord(data, pos + 4, fieldLen - 4, false); + } + break; + default: + log.fine("Unknown SF id: " + String.format("0x%02x", sfId)); + break; + } + + pos += fieldLen; + } + } + + private void processSFReadPartition(byte[] data, int offset, int fieldLen) { + if (fieldLen < 5) + return; + + int partition = data[offset + 3] & 0xFF; + int type = data[offset + 4] & 0xFF; + + // Log the incoming ReadPartition request + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < fieldLen && i < 32; i++) { + sb.append(String.format("%02x ", data[offset + i] & 0xFF)); + } + log.info("ReadPartition raw: " + sb.toString().trim() + + " partition=" + String.format("0x%02x", partition) + + " type=" + String.format("0x%02x", type)); + + switch (type) { + case SF_RP_QUERY: + log.info("ReadPartition Query — sending all query replies"); + sendAllQueryReplies(); + break; + case SF_RP_QLIST: + if (fieldLen >= 6) { + int listType = data[offset + 5] & 0xFF; + log.info("ReadPartition QueryList type=" + String.format("0x%02x", listType)); + if (listType == SF_RPQ_ALL || listType == SF_RPQ_EQUIV) { + sendAllQueryReplies(); + } else if (listType == SF_RPQ_LIST) { + // Send only requested query replies + byte[] requestedCodes = new byte[fieldLen - 6]; + System.arraycopy(data, offset + 6, requestedCodes, 0, requestedCodes.length); + StringBuilder reqSb = new StringBuilder(); + for (byte c : requestedCodes) { + reqSb.append(String.format("%02x ", c & 0xFF)); + } + log.info("Requested QR codes: " + reqSb.toString().trim()); + sendRequestedQueryReplies(requestedCodes); + } + } else { + sendAllQueryReplies(); + } + break; + case SNA_CMD_RMA: + processReadModified(true); + break; + case SNA_CMD_RB: + processReadBuffer(); + break; + case SNA_CMD_RM: + processReadModified(false); + break; + default: + log.fine("Unknown ReadPartition type: " + String.format("0x%02x", type)); + break; + } + } + + private void sendAllQueryReplies() { + byte[] qr = qrBuilder.buildAllQueryReplies(screen.getMaxCols(), screen.getMaxRows(), + screen.getMaxCols() * screen.getMaxRows()); + // Hex dump the query reply for debugging + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < qr.length; i++) { + sb.append(String.format("%02x ", qr[i] & 0xFF)); + if ((i + 1) % 32 == 0) + sb.append("\n "); + } + log.info("Query reply (" + qr.length + " bytes):\n " + sb.toString().trim()); + if (outputSender != null) { + outputSender.send3270Data(qr); + } + } + + private void sendRequestedQueryReplies(byte[] codes) { + // For now, send all — refinement can come later + sendAllQueryReplies(); + } + + // ========== Output helpers ========== + + private void outputWrite(int b) { + if (outputPos >= outputBuffer.length) { + byte[] newBuf = new byte[outputBuffer.length * 2]; + System.arraycopy(outputBuffer, 0, newBuf, 0, outputPos); + outputBuffer = newBuf; + } + outputBuffer[outputPos++] = (byte) b; + } + + private void sendOutput() { + if (outputSender != null && outputPos > 0) { + byte[] data = new byte[outputPos]; + System.arraycopy(outputBuffer, 0, data, 0, outputPos); + outputSender.send3270Data(data); + } + outputPos = 0; + } + + private void notifyScreenSizeChanged() { + for (ScreenUpdateListener l : screenListeners) { + l.onScreenSizeChanged(screen.getRows(), screen.getCols()); + } + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/datastream/QueryReplyBuilder.java b/lib3270j/src/main/java/org/lib3270j/datastream/QueryReplyBuilder.java new file mode 100644 index 0000000..4498b93 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/datastream/QueryReplyBuilder.java @@ -0,0 +1,215 @@ +package org.lib3270j.datastream; + +import java.io.ByteArrayOutputStream; +import java.util.logging.Logger; + +import org.lib3270j.screen.ScreenBuffer; +import static org.lib3270j.protocol.DS3270Constants.*; + +/** + * Builds Query Reply structured fields in response to host Read Partition queries. + * Equivalent to the do_qr_* functions in sf.c. + */ +public class QueryReplyBuilder { + + private static final Logger log = Logger.getLogger(QueryReplyBuilder.class.getName()); + + // Canned values from 3279-2 (matching sf.c) + private static final int SW_3279_2 = 0x09; + private static final int SH_3279_2 = 0x0c; + private static final int Xr_3279_2 = 0x000a02e5; + private static final int Yr_3279_2 = 0x0002006f; + + private final ScreenBuffer screen; + + // Supported query reply codes (must match what we send in summary) + private static final int[] SUPPORTED_QR = { + QR_SUMMARY, // 0x80 — summary must list itself + QR_USABLE_AREA, // 0x81 + QR_ALPHA_PART, // 0x84 + QR_CHARSETS, // 0x85 + QR_COLOR, // 0x86 + QR_HIGHLIGHTING, // 0x87 + QR_REPLY_MODES, // 0x88 + QR_IMP_PART, // 0xa6 + }; + + public QueryReplyBuilder(ScreenBuffer screen) { + this.screen = screen; + } + + /** + * Build all query replies as a single AID_SF + structured field response. + */ + public byte[] buildAllQueryReplies(int maxCols, int maxRows, int bufferSize) { + ByteArrayOutputStream out = new ByteArrayOutputStream(512); + + // AID byte for structured field + out.write(AID_SF); + + // Summary + appendQueryReply(out, QR_SUMMARY, buildSummary()); + + // Usable Area + appendQueryReply(out, QR_USABLE_AREA, buildUsableArea(maxCols, maxRows, bufferSize)); + + // Alpha Partitions + appendQueryReply(out, QR_ALPHA_PART, buildAlphaPartitions(maxRows)); + + // Character Sets + appendQueryReply(out, QR_CHARSETS, buildCharsets()); + + // Color + appendQueryReply(out, QR_COLOR, buildColor()); + + // Highlighting + appendQueryReply(out, QR_HIGHLIGHTING, buildHighlighting()); + + // Reply Modes + appendQueryReply(out, QR_REPLY_MODES, buildReplyModes()); + + // Implicit Partition + appendQueryReply(out, QR_IMP_PART, buildImplicitPartition(maxCols, maxRows)); + + log.info("Built " + out.size() + " bytes of query replies"); + return out.toByteArray(); + } + + private void appendQueryReply(ByteArrayOutputStream out, int code, byte[] data) { + // Length includes the 2-byte length field + SFID_QREPLY + code + data + int len = 4 + data.length; + out.write((len >> 8) & 0xFF); + out.write(len & 0xFF); + out.write(SFID_QREPLY); + out.write(code); + out.write(data, 0, data.length); + } + + private byte[] buildSummary() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (int code : SUPPORTED_QR) { + out.write(code); + } + return out.toByteArray(); + } + + private byte[] buildUsableArea(int maxCols, int maxRows, int bufferSize) { + ByteArrayOutputStream out = new ByteArrayOutputStream(19); + out.write(0x01); // 12/14-bit addressing + out.write(0x00); // no special character features + out.write((maxCols >> 8) & 0xFF); // usable width high + out.write(maxCols & 0xFF); // usable width low + out.write((maxRows >> 8) & 0xFF); // usable height high + out.write(maxRows & 0xFF); // usable height low + out.write(0x01); // units (mm) + // Xr (4 bytes) - canned from 3279-2 + out.write((Xr_3279_2 >> 24) & 0xFF); + out.write((Xr_3279_2 >> 16) & 0xFF); + out.write((Xr_3279_2 >> 8) & 0xFF); + out.write(Xr_3279_2 & 0xFF); + // Yr (4 bytes) - canned from 3279-2 + out.write((Yr_3279_2 >> 24) & 0xFF); + out.write((Yr_3279_2 >> 16) & 0xFF); + out.write((Yr_3279_2 >> 8) & 0xFF); + out.write(Yr_3279_2 & 0xFF); + out.write(SW_3279_2); // AW + out.write(SH_3279_2); // AH + int buf = maxCols * maxRows; + out.write((buf >> 8) & 0xFF); // buffer size high + out.write(buf & 0xFF); // buffer size low + return out.toByteArray(); + } + + private byte[] buildAlphaPartitions(int maxRows) { + int bufSize = screen.getMaxCols() * screen.getMaxRows(); + ByteArrayOutputStream out = new ByteArrayOutputStream(4); + out.write(0x00); // max partitions (1 partition) + out.write((bufSize >> 8) & 0xFF); // total partition storage high + out.write(bufSize & 0xFF); // total partition storage low + out.write(0x00); // no special features + return out.toByteArray(); + } + + private byte[] buildCharsets() { + ByteArrayOutputStream out = new ByteArrayOutputStream(32); + out.write(0x82); // flags: GE, CGCSGID present + out.write(0x00); // more flags + out.write(SW_3279_2); // SDW - default char width + out.write(SH_3279_2); // SDH - default char height + out.write(0x00); // Load PS format types supported: none + out.write(0x00); // Load PS device type (high) + out.write(0x00); // Load PS device type (low) + out.write(0x00); // reserved + out.write(0x07); // DL = 7 bytes per descriptor (non-DBCS) + // Descriptor 1 (SET 0): default character set + out.write(0x00); // SET 0 + out.write(0x10); // FLAGS: non-loadable, single-plane, single-byte, no compare + out.write(0x00); // LCID 0 + out.write(0x02); // CGCSGID (4 bytes) = 0x02b90025 (CGEN|CSET) + out.write(0xb9); + out.write(0x00); + out.write(0x25); + // Descriptor 2 (SET 1): APL/GE character set + out.write(0x01); // SET 1 + out.write(0x00); // FLAGS: non-loadable, single-plane, single-byte, no compare + out.write(0xf1); // LCID 0xf1 + out.write(0x03); // CGCSGID: 3179-style APL2 = 0x03c30136 + out.write(0xc3); + out.write(0x01); + out.write(0x36); + return out.toByteArray(); + } + + private byte[] buildColor() { + ByteArrayOutputStream out = new ByteArrayOutputStream(36); + int colorMax = 16; + out.write(0x00); // no options + out.write(colorMax); // number of colors + out.write(0x00); // default color pair: attribute + out.write(0xf0 + HOST_COLOR_GREEN); // default color: green + for (int i = 0xf1; i < 0xf1 + colorMax - 1; i++) { + out.write(i); // color attribute value + out.write(i); // maps to itself (color mode) + } + return out.toByteArray(); + } + + private byte[] buildHighlighting() { + ByteArrayOutputStream out = new ByteArrayOutputStream(11); + out.write(5); // 5 pairs + out.write(XAH_DEFAULT); out.write(XAH_NORMAL); + out.write(XAH_BLINK); out.write(XAH_BLINK); + out.write(XAH_REVERSE); out.write(XAH_REVERSE); + out.write(XAH_UNDERSCORE); out.write(XAH_UNDERSCORE); + out.write(XAH_INTENSIFY); out.write(XAH_INTENSIFY); + return out.toByteArray(); + } + + private byte[] buildReplyModes() { + return new byte[] { SF_SRM_FIELD, SF_SRM_XFIELD, SF_SRM_CHAR }; + } + + private byte[] buildImplicitPartition(int maxCols, int maxRows) { + ByteArrayOutputStream out = new ByteArrayOutputStream(22); + // Implicit partition sizes, 2 self-defining parameters + + // SDP 1: Default screen size + out.write(0x00); // flags + out.write(0x00); // reserved + out.write(0x0b); // SDP length + out.write(0x01); // SDP type: implicit partition sizes + out.write(0x00); // reserved + // Default + out.write((MODEL_2_COLS >> 8) & 0xFF); + out.write(MODEL_2_COLS & 0xFF); + out.write((MODEL_2_ROWS >> 8) & 0xFF); + out.write(MODEL_2_ROWS & 0xFF); + // Alternate + out.write((maxCols >> 8) & 0xFF); + out.write(maxCols & 0xFF); + out.write((maxRows >> 8) & 0xFF); + out.write(maxRows & 0xFF); + + return out.toByteArray(); + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/input/InputProcessor.java b/lib3270j/src/main/java/org/lib3270j/input/InputProcessor.java new file mode 100644 index 0000000..99d9ea6 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/input/InputProcessor.java @@ -0,0 +1,322 @@ +package org.lib3270j.input; + +import org.lib3270j.screen.ScreenBuffer; +import org.lib3270j.screen.ExtendedAttribute; +import org.lib3270j.charset.EbcdicTranslator; +import org.lib3270j.telnet.TelnetFSM; +import static org.lib3270j.protocol.DS3270Constants.*; + +import java.io.ByteArrayOutputStream; +import java.util.logging.Logger; + +/** + * Handles keyboard input and generates 3270 outbound data streams. + * Manages cursor positioning, character entry, and AID key handling. + */ +public class InputProcessor { + + private static final Logger log = Logger.getLogger(InputProcessor.class.getName()); + + private final ScreenBuffer screen; + private final EbcdicTranslator translator; + private final TelnetFSM fsm; + private int lastAid = AID_NO; + private boolean keyboardLocked; + private boolean insertMode; + + public InputProcessor(ScreenBuffer screen, EbcdicTranslator translator, TelnetFSM fsm) { + this.screen = screen; + this.translator = translator; + this.fsm = fsm; + } + + public boolean isKeyboardLocked() { return keyboardLocked; } + public void setKeyboardLocked(boolean locked) { this.keyboardLocked = locked; } + public boolean isInsertMode() { return insertMode; } + public void setInsertMode(boolean insert) { this.insertMode = insert; } + + /** + * Enter a character at the current cursor position. + */ + public void typeCharacter(char ch) { + if (keyboardLocked) return; + + int baddr = screen.getCursorAddress(); + int size = screen.getRows() * screen.getCols(); + + // Check if cursor is at a field attribute or in a protected field + ExtendedAttribute ea = screen.getCell(baddr); + if (ea.isFieldAttribute()) { + // Move to next position + baddr = (baddr + 1) % size; + ea = screen.getCell(baddr); + } + + byte faVal = screen.getFieldAttributeAt(baddr); + if (faIsProtected(faVal & 0xFF)) { + // Protected field — can't type here + return; + } + + // Translate character to EBCDIC + int ebc = translator.unicodeToEbcdic(ch); + if (ebc < 0) return; + + if (insertMode) { + // Insert mode: shift characters right from cursor to end of field + // Find end of field + int endAddr = baddr; + while (!screen.getCell(screen.incrementAddress(endAddr)).isFieldAttribute()) { + endAddr = screen.incrementAddress(endAddr); + if (endAddr == baddr) break; // wrapped around (unformatted) + } + // Check if last position is non-null (field overflow) + if (screen.getCell(endAddr).ec != 0) { + // Field overflow — can't insert + return; + } + // Shift right from endAddr-1 down to baddr + int dst = endAddr; + while (dst != baddr) { + int src = screen.decrementAddress(dst); + screen.getCell(dst).ec = screen.getCell(src).ec; + screen.getCell(dst).ucs4 = screen.getCell(src).ucs4; + dst = src; + } + } + + // Write character — preserve existing fg/bg/gr/cs attributes + // so the character inherits the field's color scheme + ea = screen.getCell(baddr); + ea.ec = (byte) ebc; + ea.ucs4 = ch; + + // Set MDT on field attribute + int faAddr = screen.findFieldAttribute(baddr); + if (faAddr >= 0) { + ExtendedAttribute faEa = screen.getCell(faAddr); + faEa.fa = (byte) (faEa.fa | FA_MODIFY); + } + + // Advance cursor + baddr = (baddr + 1) % size; + // Skip over field attributes + while (screen.getCell(baddr).isFieldAttribute()) { + baddr = (baddr + 1) % size; + } + screen.setCursorAddress(baddr); + screen.markAllChanged(); + } + + /** + * Send an AID key (Enter, PF1-24, PA1-3, Clear). + */ + public void sendAid(int aidCode) { + if (keyboardLocked && aidCode != AID_CLEAR) return; + + lastAid = aidCode; + keyboardLocked = true; + + if (aidCode == AID_CLEAR) { + screen.clear(); + screen.markAllChanged(); + // Send just the AID + byte[] data = new byte[] { (byte) aidCode }; + sendAidResponse(data); + return; + } + + if (aidCode == AID_PA1 || aidCode == AID_PA2 || aidCode == AID_PA3) { + // PA keys: send AID + cursor address only (no modified data) + byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols()); + byte[] data = new byte[] { (byte) aidCode, caddr[0], caddr[1] }; + sendAidResponse(data); + return; + } + + // Enter, PF keys: send AID + cursor address + modified field data + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(aidCode); + + byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols()); + out.write(caddr[0] & 0xFF); + out.write(caddr[1] & 0xFF); + + if (screen.isFormatted()) { + // Send modified fields with SBA + // Per 3270 Data Stream Architecture: strip trailing NULLs (0x00) from field data + int size = screen.getRows() * screen.getCols(); + for (int i = 0; i < size; i++) { + ExtendedAttribute ea = screen.getCell(i); + if (ea.isFieldAttribute() && faIsModified(ea.fa & 0xFF)) { + if (faIsProtected(ea.fa & 0xFF)) continue; + + int fieldStart = (i + 1) % size; + + // First, collect field data and find last non-null byte + ByteArrayOutputStream fieldData = new ByteArrayOutputStream(); + int pos = fieldStart; + int lastNonNull = -1; + int fieldLen = 0; + while (!screen.getCell(pos).isFieldAttribute()) { + int b = screen.getCell(pos).ec & 0xFF; + fieldData.write(b); + if (b != 0x00) { + lastNonNull = fieldLen; + } + fieldLen++; + pos = (pos + 1) % size; + if (pos == fieldStart) break; + } + + // Only send if there's actual data (strip trailing nulls) + if (lastNonNull >= 0) { + out.write(ORDER_SBA); + byte[] addr = encodeAddress(fieldStart, screen.getRows(), screen.getCols()); + out.write(addr[0] & 0xFF); + out.write(addr[1] & 0xFF); + + // Write only up to the last non-null byte + byte[] allData = fieldData.toByteArray(); + out.write(allData, 0, lastNonNull + 1); + } + } + } + } else { + // Unformatted screen: send all data + int size = screen.getRows() * screen.getCols(); + for (int i = 0; i < size; i++) { + out.write(screen.getCell(i).ec & 0xFF); + } + } + + sendAidResponse(out.toByteArray()); + } + + private void sendAidResponse(byte[] data) { + if (fsm.getConnectionState().isSscp()) { + fsm.sendSscpLuData(data); + } else { + fsm.send3270Data(data); + } + } + + // ========== Cursor movement ========== + + public void cursorUp() { + int addr = screen.getCursorAddress(); + addr -= screen.getCols(); + if (addr < 0) addr += screen.getRows() * screen.getCols(); + screen.setCursorAddress(addr); + } + + public void cursorDown() { + int addr = screen.getCursorAddress(); + addr += screen.getCols(); + if (addr >= screen.getRows() * screen.getCols()) addr -= screen.getRows() * screen.getCols(); + screen.setCursorAddress(addr); + } + + public void cursorLeft() { + int addr = screen.getCursorAddress(); + addr = screen.decrementAddress(addr); + screen.setCursorAddress(addr); + } + + public void cursorRight() { + int addr = screen.getCursorAddress(); + addr = screen.incrementAddress(addr); + screen.setCursorAddress(addr); + } + + public void cursorHome() { + if (screen.isFormatted()) { + screen.setCursorAddress(screen.findNextUnprotected(0)); + } else { + screen.setCursorAddress(0); + } + } + + public void tab() { + int addr = screen.findNextUnprotected(screen.getCursorAddress()); + screen.setCursorAddress(addr); + } + + public void backTab() { + // Find previous unprotected field + int addr = screen.getCursorAddress(); + int size = screen.getRows() * screen.getCols(); + int start = screen.decrementAddress(addr); + addr = start; + do { + addr = screen.decrementAddress(addr); + if (screen.getCell(addr).isFieldAttribute()) { + if (!faIsProtected(screen.getCell(addr).fa & 0xFF)) { + screen.setCursorAddress(screen.incrementAddress(addr)); + return; + } + } + } while (addr != start); + } + + public void eraseEof() { + int addr = screen.getCursorAddress(); + int size = screen.getRows() * screen.getCols(); + byte faVal = screen.getFieldAttributeAt(addr); + if (faIsProtected(faVal & 0xFF)) return; + + // Erase from cursor to end of field + while (!screen.getCell(addr).isFieldAttribute()) { + ExtendedAttribute ea = screen.getCell(addr); + ea.ec = 0; + ea.ucs4 = 0; + addr = screen.incrementAddress(addr); + } + + // Set MDT + int faAddr = screen.findFieldAttribute(screen.getCursorAddress()); + if (faAddr >= 0) { + screen.getCell(faAddr).fa = (byte) (screen.getCell(faAddr).fa | FA_MODIFY); + } + screen.markAllChanged(); + } + + public void deleteChar() { + int addr = screen.getCursorAddress(); + byte faVal = screen.getFieldAttributeAt(addr); + if (faIsProtected(faVal & 0xFF)) return; + + // Shift characters left within the field + int shiftAddr = addr; + int size = screen.getRows() * screen.getCols(); + while (true) { + int next = screen.incrementAddress(shiftAddr); + if (screen.getCell(next).isFieldAttribute()) { + screen.getCell(shiftAddr).ec = 0; + screen.getCell(shiftAddr).ucs4 = 0; + break; + } + screen.getCell(shiftAddr).ec = screen.getCell(next).ec; + screen.getCell(shiftAddr).ucs4 = screen.getCell(next).ucs4; + shiftAddr = next; + } + + int faAddr = screen.findFieldAttribute(addr); + if (faAddr >= 0) { + screen.getCell(faAddr).fa = (byte) (screen.getCell(faAddr).fa | FA_MODIFY); + } + screen.markAllChanged(); + } + + public void backspace() { + if (screen.getCursorAddress() == 0) return; + cursorLeft(); + deleteChar(); + } + + /** Reset (unlock keyboard, cancel insert mode). */ + public void reset() { + keyboardLocked = false; + insertMode = false; + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/listener/ConnectionListener.java b/lib3270j/src/main/java/org/lib3270j/listener/ConnectionListener.java new file mode 100644 index 0000000..7b3eacb --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/listener/ConnectionListener.java @@ -0,0 +1,17 @@ +package org.lib3270j.listener; + +import org.lib3270j.ConnectionState; + +/** + * Listener for connection state changes and errors. + */ +public interface ConnectionListener { + /** Called when the connection state changes. */ + void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState); + + /** Called when a connection error occurs. */ + void onConnectionError(String message); + + /** Called when TN3270E negotiation completes. */ + default void onTN3270ENegotiated(String deviceType, String deviceName) {} +} diff --git a/lib3270j/src/main/java/org/lib3270j/listener/ScreenUpdateListener.java b/lib3270j/src/main/java/org/lib3270j/listener/ScreenUpdateListener.java new file mode 100644 index 0000000..6aaa4b3 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/listener/ScreenUpdateListener.java @@ -0,0 +1,18 @@ +package org.lib3270j.listener; + +/** + * Listener for screen buffer changes. + */ +public interface ScreenUpdateListener { + /** Called when the screen buffer has been updated. */ + void onScreenUpdated(); + + /** Called when the cursor position changes. */ + default void onCursorMoved(int oldAddress, int newAddress) {} + + /** Called when the host sends a sound alarm (WCC bit). */ + default void onSoundAlarm() {} + + /** Called when the screen size changes (erase/write vs erase/write alternate). */ + default void onScreenSizeChanged(int rows, int cols) {} +} diff --git a/lib3270j/src/main/java/org/lib3270j/protocol/DS3270Constants.java b/lib3270j/src/main/java/org/lib3270j/protocol/DS3270Constants.java new file mode 100644 index 0000000..7ba9565 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/protocol/DS3270Constants.java @@ -0,0 +1,376 @@ +package org.lib3270j.protocol; + +/** + * 3270 Data Stream protocol constants. + * Derived from 3270ds.h in x3270. + */ +public final class DS3270Constants { + + private DS3270Constants() {} + + // ========== 3270 Commands ========== + public static final int CMD_W = 0x01; // Write + public static final int CMD_RB = 0x02; // Read Buffer + public static final int CMD_NOP = 0x03; // No-Op + public static final int CMD_EW = 0x05; // Erase/Write + public static final int CMD_RM = 0x06; // Read Modified + public static final int CMD_EWA = 0x0d; // Erase/Write Alternate + public static final int CMD_RMA = 0x0e; // Read Modified All + public static final int CMD_EAU = 0x0f; // Erase All Unprotected + public static final int CMD_WSF = 0x11; // Write Structured Field + + // SNA 3270 Commands + public static final int SNA_CMD_RMA = 0x6e; // Read Modified All + public static final int SNA_CMD_EAU = 0x6f; // Erase All Unprotected + public static final int SNA_CMD_EWA = 0x7e; // Erase/Write Alternate + public static final int SNA_CMD_W = 0xf1; // Write + public static final int SNA_CMD_RB = 0xf2; // Read Buffer + public static final int SNA_CMD_WSF = 0xf3; // Write Structured Field + public static final int SNA_CMD_EW = 0xf5; // Erase/Write + public static final int SNA_CMD_RM = 0xf6; // Read Modified + + // ========== 3270 Orders ========== + public static final int ORDER_PT = 0x05; // Program Tab + public static final int ORDER_GE = 0x08; // Graphic Escape + public static final int ORDER_SBA = 0x11; // Set Buffer Address + public static final int ORDER_EUA = 0x12; // Erase Unprotected to Address + public static final int ORDER_IC = 0x13; // Insert Cursor + public static final int ORDER_SF = 0x1d; // Start Field + public static final int ORDER_SA = 0x28; // Set Attribute + public static final int ORDER_SFE = 0x29; // Start Field Extended + public static final int ORDER_YALE = 0x2b; // Yale sub command + public static final int ORDER_MF = 0x2c; // Modify Field + public static final int ORDER_RA = 0x3c; // Repeat to Address + + // Format control orders + public static final int FCORDER_NULL = 0x00; + public static final int FCORDER_FF = 0x0c; // Form feed + public static final int FCORDER_CR = 0x0d; // Carriage return + public static final int FCORDER_SO = 0x0e; // Shift out (DBCS start) + public static final int FCORDER_SI = 0x0f; // Shift in (DBCS end) + public static final int FCORDER_NL = 0x15; // New line + public static final int FCORDER_EM = 0x19; // End of medium + public static final int FCORDER_LF = 0x25; // Line feed + public static final int FCORDER_DUP = 0x1c; // Duplicate + public static final int FCORDER_FM = 0x1e; // Field mark + public static final int FCORDER_SUB = 0x3f; // Substitute + public static final int FCORDER_EO = 0xff; // Eight ones + + // ========== Field Attributes ========== + public static final int FA_PRINTABLE = 0xc0; + public static final int FA_PROTECT = 0x20; // Protected (1) / Unprotected (0) + public static final int FA_NUMERIC = 0x10; // Numeric (1) / Alphanumeric (0) + public static final int FA_INTENSITY = 0x0c; // Display/selector pen mask + public static final int FA_INT_NORM_NSEL = 0x00; // Normal, non-detect + public static final int FA_INT_NORM_SEL = 0x04; // Normal, detectable + public static final int FA_INT_HIGH_SEL = 0x08; // Intensified, detectable + public static final int FA_INT_ZERO_NSEL = 0x0c; // Non-display, non-detect + public static final int FA_RESERVED = 0x02; + public static final int FA_MODIFY = 0x01; // Modified + + public static final int FA_MASK = FA_PRINTABLE | FA_PROTECT | FA_NUMERIC | FA_INTENSITY | FA_MODIFY; + + public static boolean faIsModified(int fa) { return (fa & FA_MODIFY) != 0; } + public static boolean faIsNumeric(int fa) { return (fa & FA_NUMERIC) != 0; } + public static boolean faIsProtected(int fa) { return (fa & FA_PROTECT) != 0; } + public static boolean faIsSkip(int fa) { return (fa & FA_PROTECT) != 0 && (fa & FA_NUMERIC) != 0; } + public static boolean faIsZero(int fa) { return (fa & FA_INTENSITY) == FA_INT_ZERO_NSEL; } + public static boolean faIsHigh(int fa) { return (fa & FA_INTENSITY) == FA_INT_HIGH_SEL; } + public static boolean faIsNormal(int fa) { + return (fa & FA_INTENSITY) == FA_INT_NORM_NSEL || (fa & FA_INTENSITY) == FA_INT_NORM_SEL; + } + public static boolean faIsSelectable(int fa) { + return (fa & FA_INTENSITY) == FA_INT_NORM_SEL || (fa & FA_INTENSITY) == FA_INT_HIGH_SEL; + } + + // ========== Extended Attributes ========== + public static final int XA_ALL = 0x00; + public static final int XA_3270 = 0xc0; + public static final int XA_VALIDATION = 0xc1; + public static final int XA_OUTLINING = 0xc2; + public static final int XA_HIGHLIGHTING = 0x41; + public static final int XA_FOREGROUND = 0x42; + public static final int XA_CHARSET = 0x43; + public static final int XA_BACKGROUND = 0x45; + public static final int XA_TRANSPARENCY = 0x46; + public static final int XA_INPUT_CONTROL = 0xfe; + + // Highlighting values + public static final int XAH_DEFAULT = 0x00; + public static final int XAH_NORMAL = 0xf0; + public static final int XAH_BLINK = 0xf1; + public static final int XAH_REVERSE = 0xf2; + public static final int XAH_UNDERSCORE = 0xf4; + public static final int XAH_INTENSIFY = 0xf8; + + // Default color + public static final int XAC_DEFAULT = 0x00; + + // Outlining values + public static final int XAO_UNDERLINE = 0x01; + public static final int XAO_RIGHT = 0x02; + public static final int XAO_OVERLINE = 0x04; + public static final int XAO_LEFT = 0x08; + + // Validation values + public static final int XAV_FILL = 0x04; + public static final int XAV_ENTRY = 0x02; + public static final int XAV_TRIGGER = 0x01; + + // Transparency values + public static final int XAT_DEFAULT = 0x00; + public static final int XAT_OR = 0xf0; + public static final int XAT_XOR = 0xf1; + public static final int XAT_OPAQUE = 0xff; + + // Input control + public static final int XAI_DISABLED = 0x00; + public static final int XAI_ENABLED = 0x01; + + // ========== WCC (Write Control Character) ========== + public static final int WCC_RESET_BIT = 0x40; + public static final int WCC_START_PRINTER_BIT = 0x08; + public static final int WCC_SOUND_ALARM_BIT = 0x04; + public static final int WCC_KEYBOARD_RESTORE_BIT = 0x02; + public static final int WCC_RESET_MDT_BIT = 0x01; + + public static boolean wccReset(int wcc) { return (wcc & WCC_RESET_BIT) != 0; } + public static boolean wccStartPrinter(int wcc) { return (wcc & WCC_START_PRINTER_BIT) != 0; } + public static boolean wccSoundAlarm(int wcc) { return (wcc & WCC_SOUND_ALARM_BIT) != 0; } + public static boolean wccKeyboardRestore(int wcc) { return (wcc & WCC_KEYBOARD_RESTORE_BIT) != 0; } + public static boolean wccResetMDT(int wcc) { return (wcc & WCC_RESET_MDT_BIT) != 0; } + + // ========== AID (Attention Identifier) ========== + public static final int AID_NO = 0x60; + public static final int AID_QREPLY = 0x61; + public static final int AID_ENTER = 0x7d; + public static final int AID_PF1 = 0xf1; + public static final int AID_PF2 = 0xf2; + public static final int AID_PF3 = 0xf3; + public static final int AID_PF4 = 0xf4; + public static final int AID_PF5 = 0xf5; + public static final int AID_PF6 = 0xf6; + public static final int AID_PF7 = 0xf7; + public static final int AID_PF8 = 0xf8; + public static final int AID_PF9 = 0xf9; + public static final int AID_PF10 = 0x7a; + public static final int AID_PF11 = 0x7b; + public static final int AID_PF12 = 0x7c; + public static final int AID_PF13 = 0xc1; + public static final int AID_PF14 = 0xc2; + public static final int AID_PF15 = 0xc3; + public static final int AID_PF16 = 0xc4; + public static final int AID_PF17 = 0xc5; + public static final int AID_PF18 = 0xc6; + public static final int AID_PF19 = 0xc7; + public static final int AID_PF20 = 0xc8; + public static final int AID_PF21 = 0xc9; + public static final int AID_PF22 = 0x4a; + public static final int AID_PF23 = 0x4b; + public static final int AID_PF24 = 0x4c; + public static final int AID_OICR = 0xe6; + public static final int AID_MSR_MHS = 0xe7; + public static final int AID_SELECT = 0x7e; + public static final int AID_PA1 = 0x6c; + public static final int AID_PA2 = 0x6e; + public static final int AID_PA3 = 0x6b; + public static final int AID_CLEAR = 0x6d; + public static final int AID_SYSREQ = 0xf0; + public static final int AID_SF = 0x88; + + public static final int SFID_QREPLY = 0x81; + + // ========== Structured Field IDs ========== + public static final int SF_READ_PART = 0x01; + public static final int SF_RP_QUERY = 0x02; + public static final int SF_RP_QLIST = 0x03; + public static final int SF_RPQ_LIST = 0x00; + public static final int SF_RPQ_EQUIV = 0x40; + public static final int SF_RPQ_ALL = 0x80; + public static final int SF_ERASE_RESET = 0x03; + public static final int SF_ER_DEFAULT = 0x00; + public static final int SF_ER_ALT = 0x80; + public static final int SF_SET_REPLY_MODE = 0x09; + public static final int SF_SRM_FIELD = 0x00; + public static final int SF_SRM_XFIELD = 0x01; + public static final int SF_SRM_CHAR = 0x02; + public static final int SF_CREATE_PART = 0x0c; + public static final int SF_OUTBOUND_DS = 0x40; + public static final int SF_TRANSFER_DATA = 0xd0; + + // ========== Query Reply codes ========== + public static final int QR_SUMMARY = 0x80; + public static final int QR_USABLE_AREA = 0x81; + public static final int QR_IMAGE = 0x82; + public static final int QR_TEXT_PART = 0x83; + public static final int QR_ALPHA_PART = 0x84; + public static final int QR_CHARSETS = 0x85; + public static final int QR_COLOR = 0x86; + public static final int QR_HIGHLIGHTING = 0x87; + public static final int QR_REPLY_MODES = 0x88; + public static final int QR_DBCS_ASIA = 0x91; + public static final int QR_DDM = 0x95; + public static final int QR_RPQNAMES = 0xa1; + public static final int QR_IMP_PART = 0xa6; + public static final int QR_NULL = 0xff; + + // ========== Screen model sizes ========== + public static final int MODEL_2_ROWS = 24; + public static final int MODEL_2_COLS = 80; + public static final int MODEL_3_ROWS = 32; + public static final int MODEL_3_COLS = 80; + public static final int MODEL_4_ROWS = 43; + public static final int MODEL_4_COLS = 80; + public static final int MODEL_5_ROWS = 27; + public static final int MODEL_5_COLS = 132; + + public static final int MAX_ROWS_COLS = 0x3fff; + + // ========== Host colors ========== + public static final int HOST_COLOR_NEUTRAL_BLACK = 0; + public static final int HOST_COLOR_BLUE = 1; + public static final int HOST_COLOR_RED = 2; + public static final int HOST_COLOR_PINK = 3; + public static final int HOST_COLOR_GREEN = 4; + public static final int HOST_COLOR_TURQUOISE = 5; + public static final int HOST_COLOR_YELLOW = 6; + public static final int HOST_COLOR_NEUTRAL_WHITE = 7; + public static final int HOST_COLOR_BLACK = 8; + public static final int HOST_COLOR_DEEP_BLUE = 9; + public static final int HOST_COLOR_ORANGE = 10; + public static final int HOST_COLOR_PURPLE = 11; + public static final int HOST_COLOR_PALE_GREEN = 12; + public static final int HOST_COLOR_PALE_TURQUOISE = 13; + public static final int HOST_COLOR_GREY = 14; + public static final int HOST_COLOR_WHITE = 15; + + // ========== Graphics rendition bits ========== + public static final int GR_BLINK = 0x01; + public static final int GR_REVERSE = 0x02; + public static final int GR_UNDERLINE = 0x04; + public static final int GR_INTENSIFY = 0x08; + + // ========== Character set codes ========== + public static final int CS_MASK = 0x03; + public static final int CS_BASE = 0x00; + public static final int CS_APL = 0x01; + public static final int CS_LINEDRAW = 0x02; + public static final int CS_DBCS = 0x03; + public static final int CS_GE = 0x04; + + // ========== BIND definitions ========== + public static final int BIND_RU = 0x31; + public static final int BIND_OFF_MAXRU_SEC = 10; + public static final int BIND_OFF_MAXRU_PRI = 11; + public static final int BIND_OFF_RD = 20; + public static final int BIND_OFF_CD = 21; + public static final int BIND_OFF_RA = 22; + public static final int BIND_OFF_CA = 23; + public static final int BIND_OFF_SSIZE = 24; + public static final int BIND_OFF_PLU_NAME_LEN = 27; + public static final int BIND_PLU_NAME_MAX = 8; + public static final int BIND_OFF_PLU_NAME = 28; + + // BIND dimension flags + public static final int BIND_DIMS_PRESENT = 0x1; + public static final int BIND_DIMS_ALT = 0x2; + public static final int BIND_DIMS_VALID = 0x4; + + // ========== EBCDIC common characters ========== + public static final int EBC_NULL = 0x00; + public static final int EBC_SPACE = 0x40; + public static final int EBC_AMPERSAND = 0x50; + public static final int EBC_MINUS = 0x60; + public static final int EBC_PERIOD = 0x4b; + public static final int EBC_COMMA = 0x6b; + public static final int EBC_DUP = 0x1c; + public static final int EBC_FM = 0x1e; + public static final int EBC_FF = 0x0c; + public static final int EBC_CR = 0x0d; + public static final int EBC_NL = 0x15; + public static final int EBC_EM = 0x19; + public static final int EBC_SUB = 0x3f; + public static final int EBC_EO = 0xff; + + /** + * 6-bit code table for 12-bit buffer address encoding. + */ + public static final int[] CODE_TABLE = { + 0x40, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, + 0x50, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, + 0xD8, 0xD9, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, + 0x60, 0x61, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, + 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, + 0xF8, 0xF9, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, + }; + + /** + * Decode a 2-byte buffer address. + * Handles both 12-bit and 14-bit addressing. + */ + public static int decodeAddress(int b1, int b2) { + if ((b1 & 0xC0) == 0x00) { + // 14-bit format + return ((b1 & 0x3F) << 8) | b2; + } else { + // 12-bit format + return ((b1 & 0x3F) << 6) | (b2 & 0x3F); + } + } + + /** + * Encode a buffer address into a 2-byte array. + * Uses 14-bit format if screen > 4096 positions, otherwise 12-bit. + */ + public static byte[] encodeAddress(int addr, int rows, int cols) { + byte[] result = new byte[2]; + if (rows * cols > 0x1000) { + // 14-bit format + result[0] = (byte) ((addr >> 8) & 0x3F); + result[1] = (byte) (addr & 0xFF); + } else { + // 12-bit format + result[0] = (byte) CODE_TABLE[(addr >> 6) & 0x3F]; + result[1] = (byte) CODE_TABLE[addr & 0x3F]; + } + return result; + } + + /** Check if a byte is a 3270 order. */ + public static boolean isOrder(int b) { + switch (b) { + case ORDER_PT: + case ORDER_GE: + case ORDER_SBA: + case ORDER_EUA: + case ORDER_IC: + case ORDER_SF: + case ORDER_SA: + case ORDER_SFE: + case ORDER_YALE: + case ORDER_MF: + case ORDER_RA: + return true; + default: + return false; + } + } + + /** Get human-readable command name. */ + public static String commandName(int cmd) { + switch (cmd) { + case CMD_W: case SNA_CMD_W: return "Write"; + case CMD_EW: case SNA_CMD_EW: return "EraseWrite"; + case CMD_EWA: case SNA_CMD_EWA: return "EraseWriteAlternate"; + case CMD_RB: case SNA_CMD_RB: return "ReadBuffer"; + case CMD_RM: case SNA_CMD_RM: return "ReadModified"; + case CMD_RMA: case SNA_CMD_RMA: return "ReadModifiedAll"; + case CMD_EAU: case SNA_CMD_EAU: return "EraseAllUnprotected"; + case CMD_WSF: case SNA_CMD_WSF: return "WriteStructuredField"; + case CMD_NOP: return "NoOp"; + default: return String.format("Unknown(0x%02x)", cmd); + } + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/protocol/TN3270EConstants.java b/lib3270j/src/main/java/org/lib3270j/protocol/TN3270EConstants.java new file mode 100644 index 0000000..78ea609 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/protocol/TN3270EConstants.java @@ -0,0 +1,142 @@ +package org.lib3270j.protocol; + +/** + * TN3270E protocol constants per RFC 2355. + * Derived from tn3270e.h in x3270. + */ +public final class TN3270EConstants { + + private TN3270EConstants() {} + + // Negotiation operations + public static final int OP_ASSOCIATE = 0; + public static final int OP_CONNECT = 1; + public static final int OP_DEVICE_TYPE = 2; + public static final int OP_FUNCTIONS = 3; + public static final int OP_IS = 4; + public static final int OP_REASON = 5; + public static final int OP_REJECT = 6; + public static final int OP_REQUEST = 7; + public static final int OP_SEND = 8; + + // Reason codes + public static final int REASON_CONN_PARTNER = 0; + public static final int REASON_DEVICE_IN_USE = 1; + public static final int REASON_INV_ASSOCIATE = 2; + public static final int REASON_INV_DEVICE_NAME = 3; + public static final int REASON_INV_DEVICE_TYPE = 4; + public static final int REASON_TYPE_NAME_ERROR = 5; + public static final int REASON_UNKNOWN_ERROR = 6; + public static final int REASON_UNSUPPORTED_REQ = 7; + + // Function names + public static final int FUNC_BIND_IMAGE = 0; + public static final int FUNC_DATA_STREAM_CTL = 1; + public static final int FUNC_RESPONSES = 2; + public static final int FUNC_SCS_CTL_CODES = 3; + public static final int FUNC_SYSREQ = 4; + public static final int FUNC_CONTENTION_RESOLUTION = 5; + public static final int FUNC_SNA_SENSE = 6; + + // Data type names + public static final int DT_3270_DATA = 0x00; + public static final int DT_SCS_DATA = 0x01; + public static final int DT_RESPONSE = 0x02; + public static final int DT_BIND_IMAGE = 0x03; + public static final int DT_UNBIND = 0x04; + public static final int DT_NVT_DATA = 0x05; + public static final int DT_REQUEST = 0x06; + public static final int DT_SSCP_LU_DATA = 0x07; + public static final int DT_PRINT_EOJ = 0x08; + public static final int DT_BID = 0x09; + + // Request flags + public static final int RQF_ERR_COND_CLEARED = 0x00; + public static final int RQF_SEND_DATA = 0x01; + public static final int RQF_KEYBOARD_RESTORE = 0x02; + public static final int RQF_SIGNAL = 0x04; + + // Response flags (header) + public static final int RSF_NO_RESPONSE = 0x00; + public static final int RSF_ERROR_RESPONSE = 0x01; + public static final int RSF_ALWAYS_RESPONSE = 0x02; + + // Response flags (trailer) + public static final int RSF_POSITIVE_RESPONSE = 0x00; + public static final int RSF_NEGATIVE_RESPONSE = 0x01; + public static final int RSF_SNA_SENSE = 0x02; + + // Positive response data + public static final int POS_DEVICE_END = 0x00; + + // Negative response data + public static final int NEG_COMMAND_REJECT = 0x00; + public static final int NEG_INTERVENTION_REQUIRED = 0x01; + public static final int NEG_OPERATION_CHECK = 0x02; + public static final int NEG_COMPONENT_DISCONNECTED = 0x03; + + // TN3270E header size + public static final int EH_SIZE = 5; + + // UNBIND types + public static final int UNBIND_NORMAL = 0x01; + public static final int UNBIND_BIND_FORTHCOMING = 0x02; + public static final int UNBIND_VR_INOPERATIVE = 0x07; + public static final int UNBIND_RX_INOPERATIVE = 0x08; + public static final int UNBIND_HRESET = 0x09; + public static final int UNBIND_SSCP_GONE = 0x0a; + public static final int UNBIND_VR_DEACTIVATED = 0x0b; + public static final int UNBIND_LU_FAILURE_PERM = 0x0c; + public static final int UNBIND_LU_FAILURE_TEMP = 0x0e; + public static final int UNBIND_CLEANUP = 0x0f; + public static final int UNBIND_BAD_SENSE = 0xfe; + + // Name lookups for tracing + + private static final String[] REASON_NAMES = { + "CONN-PARTNER", "DEVICE-IN-USE", "INV-ASSOCIATE", "INV-NAME", + "INV-DEVICE-TYPE", "TYPE-NAME-ERROR", "UNKNOWN-ERROR", "UNSUPPORTED-REQ" + }; + + private static final String[] FUNCTION_NAMES = { + "BIND-IMAGE", "DATA-STREAM-CTL", "RESPONSES", "SCS-CTL-CODES", + "SYSREQ", "CONTENTION-RESOLUTION", "SNA-SENSE" + }; + + private static final String[] DATA_TYPE_NAMES = { + "3270-DATA", "SCS-DATA", "RESPONSE", "BIND-IMAGE", "UNBIND", + "NVT-DATA", "REQUEST", "SSCP-LU-DATA", "PRINT-EOJ", "BID" + }; + + private static final String[] HRSP_FLAG_NAMES = { + "NO-RESPONSE", "ERROR-RESPONSE", "ALWAYS-RESPONSE" + }; + + public static String reasonName(int code) { + return code >= 0 && code < REASON_NAMES.length ? REASON_NAMES[code] : "??"; + } + + public static String functionName(int code) { + return code >= 0 && code < FUNCTION_NAMES.length ? FUNCTION_NAMES[code] : "??"; + } + + public static String dataTypeName(int code) { + return code >= 0 && code < DATA_TYPE_NAMES.length ? DATA_TYPE_NAMES[code] : "??"; + } + + public static String responseHeaderFlagName(int code) { + return code >= 0 && code < HRSP_FLAG_NAMES.length ? HRSP_FLAG_NAMES[code] : "??"; + } + + /** Format a function set as a human-readable string. */ + public static String functionNames(boolean[] funcs) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < funcs.length && i <= FUNC_SNA_SENSE; i++) { + if (funcs[i]) { + if (sb.length() > 0) sb.append(", "); + sb.append(functionName(i)); + } + } + return sb.toString(); + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/protocol/TelnetConstants.java b/lib3270j/src/main/java/org/lib3270j/protocol/TelnetConstants.java new file mode 100644 index 0000000..476f7e6 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/protocol/TelnetConstants.java @@ -0,0 +1,117 @@ +package org.lib3270j.protocol; + +/** + * Telnet protocol constants from RFC 854 and extensions. + * Derived from arpa_telnet.h in x3270. + */ +public final class TelnetConstants { + + private TelnetConstants() {} + + // Telnet commands + public static final int IAC = 255; // Interpret As Command + public static final int DONT = 254; // You are not to use option + public static final int DO = 253; // Please, you use option + public static final int WONT = 252; // I won't use option + public static final int WILL = 251; // I will use option + public static final int SB = 250; // Interpret as subnegotiation + public static final int GA = 249; // You may reverse the line + public static final int EL = 248; // Erase the current line + public static final int EC = 247; // Erase the current character + public static final int AYT = 246; // Are you there + public static final int AO = 245; // Abort output + public static final int IP = 244; // Interrupt process + public static final int BREAK = 243; // Break + public static final int DM = 242; // Data mark + public static final int NOP = 241; // No operation + public static final int SE = 240; // End sub negotiation + public static final int EOR = 239; // End of record + public static final int SUSP = 237; // Suspend process + public static final int xEOF = 236; // End of file + + // Telnet options + public static final int TELOPT_BINARY = 0; // 8-bit data path + public static final int TELOPT_ECHO = 1; // Echo + public static final int TELOPT_RCP = 2; // Prepare to reconnect + public static final int TELOPT_SGA = 3; // Suppress go ahead + public static final int TELOPT_NAMS = 4; // Approximate message size + public static final int TELOPT_STATUS = 5; // Give status + public static final int TELOPT_TM = 6; // Timing mark + public static final int TELOPT_TTYPE = 24; // Terminal type + public static final int TELOPT_EOR = 25; // End of record + public static final int TELOPT_NAWS = 31; // Window size + public static final int TELOPT_TSPEED = 32; // Terminal speed + public static final int TELOPT_LFLOW = 33; // Remote flow control + public static final int TELOPT_LINEMODE = 34; // Linemode option + public static final int TELOPT_XDISPLOC = 35; // X Display Location + public static final int TELOPT_OLD_ENVIRON = 36; // Old environment variables + public static final int TELOPT_AUTHENTICATION = 37; // Authenticate + public static final int TELOPT_ENCRYPT = 38; // Encryption option + public static final int TELOPT_NEW_ENVIRON = 39; // New environment variables + public static final int TELOPT_TN3270E = 40; // Extended 3270 regime + public static final int TELOPT_STARTTLS = 46; // Start TLS + public static final int TELOPT_EXOPL = 255; // Extended options list + + // Sub-option qualifiers + public static final int TELQUAL_IS = 0; // Option is... + public static final int TELQUAL_SEND = 1; // Send option + public static final int TELQUAL_INFO = 2; // Info + + // New-environ sub-option objects + public static final int TELOBJ_VAR = 0; + public static final int TELOBJ_VALUE = 1; + public static final int TELOBJ_ESC = 2; + public static final int TELOBJ_USERVAR = 3; + + // STARTTLS sub-option + public static final int TLS_FOLLOWS = 1; + + // Standard ports + public static final int TELNET_PORT = 23; + public static final int TELNETS_PORT = 992; + + /** Telnet option name lookup for tracing. */ + public static String optionName(int opt) { + switch (opt) { + case TELOPT_BINARY: return "BINARY"; + case TELOPT_ECHO: return "ECHO"; + case TELOPT_SGA: return "SGA"; + case TELOPT_TTYPE: return "TTYPE"; + case TELOPT_EOR: return "EOR"; + case TELOPT_NAWS: return "NAWS"; + case TELOPT_NEW_ENVIRON: return "NEW-ENVIRON"; + case TELOPT_TN3270E: return "TN3270E"; + case TELOPT_STARTTLS: return "STARTTLS"; + case TELOPT_TSPEED: return "TSPEED"; + case TELOPT_LFLOW: return "LFLOW"; + case TELOPT_LINEMODE: return "LINEMODE"; + case TELOPT_XDISPLOC: return "XDISPLOC"; + case TELOPT_OLD_ENVIRON: return "OLD-ENVIRON"; + default: return "OPT-" + opt; + } + } + + /** Telnet command name lookup for tracing. */ + public static String commandName(int cmd) { + switch (cmd) { + case IAC: return "IAC"; + case DONT: return "DONT"; + case DO: return "DO"; + case WONT: return "WONT"; + case WILL: return "WILL"; + case SB: return "SB"; + case GA: return "GA"; + case EL: return "EL"; + case EC: return "EC"; + case AYT: return "AYT"; + case AO: return "AO"; + case IP: return "IP"; + case BREAK: return "BRK"; + case DM: return "DMARK"; + case NOP: return "NOP"; + case SE: return "SE"; + case EOR: return "EOR"; + default: return "CMD-" + cmd; + } + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/screen/ExtendedAttribute.java b/lib3270j/src/main/java/org/lib3270j/screen/ExtendedAttribute.java new file mode 100644 index 0000000..aaed191 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/screen/ExtendedAttribute.java @@ -0,0 +1,77 @@ +package org.lib3270j.screen; + +/** + * Extended attribute structure for a single screen buffer position. + * Mirrors struct ea from globals.h in x3270. + */ +public class ExtendedAttribute { + + /** EBCDIC character code at this position. */ + public byte ec; + + /** Field attribute byte (non-zero if this position IS a field attribute). */ + public byte fa; + + /** Foreground color (0x00 for default, or 0xf0-0xff for explicit). */ + public byte fg; + + /** Background color (0x00 for default, or 0xf0-0xff for explicit). */ + public byte bg; + + /** + * Graphics rendition bits. + * GR_BLINK=0x01, GR_REVERSE=0x02, GR_UNDERLINE=0x04, GR_INTENSIFY=0x08 + */ + public byte gr; + + /** Character set (CS_BASE=0, CS_APL=1, CS_LINEDRAW=2, CS_DBCS=3; CS_GE=0x04 flag). */ + public byte cs; + + /** Input control (DBCS). */ + public byte ic; + + /** DBCS state. */ + public byte db; + + /** + * Unicode character for display (set by translation from ec, or directly in NVT mode). + */ + public char ucs4; + + /** Clear all attributes. */ + public void clear() { + ec = 0; + fa = 0; + fg = 0; + bg = 0; + gr = 0; + cs = 0; + ic = 0; + db = 0; + ucs4 = 0; + } + + /** Copy all values from another ExtendedAttribute. */ + public void copyFrom(ExtendedAttribute other) { + this.ec = other.ec; + this.fa = other.fa; + this.fg = other.fg; + this.bg = other.bg; + this.gr = other.gr; + this.cs = other.cs; + this.ic = other.ic; + this.db = other.db; + this.ucs4 = other.ucs4; + } + + /** Check if this position is a field attribute. */ + public boolean isFieldAttribute() { + return fa != 0; + } + + @Override + public String toString() { + return String.format("EA[ec=%02x fa=%02x fg=%02x bg=%02x gr=%02x cs=%02x u=%04x]", + ec & 0xFF, fa & 0xFF, fg & 0xFF, bg & 0xFF, gr & 0xFF, cs & 0xFF, (int) ucs4); + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/screen/ScreenBuffer.java b/lib3270j/src/main/java/org/lib3270j/screen/ScreenBuffer.java new file mode 100644 index 0000000..e1cec28 --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/screen/ScreenBuffer.java @@ -0,0 +1,329 @@ +package org.lib3270j.screen; + +import org.lib3270j.TerminalModel; +import org.lib3270j.charset.EbcdicTranslator; +import static org.lib3270j.protocol.DS3270Constants.*; + +/** + * The 3270 screen buffer. + * Manages the display buffer, cursor, field attributes, and screen dimensions. + * Equivalent to ea_buf[] / aea_buf[] and related state in ctlr.c. + */ +public class ScreenBuffer { + + private ExtendedAttribute[] buffer; // Main screen buffer + private ExtendedAttribute[] altBuffer; // Alternate screen buffer + private final ExtendedAttribute defaultFA; // Default field attribute (ea_buf[-1]) + + private int maxRows, maxCols; // Maximum (alternate) dimensions + private int defRows, defCols; // Default dimensions (24x80) + private int altRows, altCols; // Alternate dimensions + private int rows, cols; // Current dimensions + private int cursorAddress; + private int bufferAddress; + private boolean screenAlt; // Using alternate screen? + private boolean formatted; // Screen has at least one field attribute? + private byte replyMode = SF_SRM_FIELD; + + // Change tracking + private boolean screenChanged; + private int firstChanged = -1; + private int lastChanged = -1; + + // Default attribute values + private byte defaultFg = 0x00; + private byte defaultBg = 0x00; + private byte defaultGr = 0x00; + private byte defaultCs = 0x00; + private byte defaultIc = 0x00; + + private final EbcdicTranslator translator; + + public ScreenBuffer(TerminalModel model, EbcdicTranslator translator) { + this.translator = translator; + this.defRows = MODEL_2_ROWS; + this.defCols = MODEL_2_COLS; + this.altRows = model.getAlternateRows(); + this.altCols = model.getAlternateCols(); + this.maxRows = altRows; + this.maxCols = altCols; + this.rows = defRows; + this.cols = defCols; + + // Default field attribute (like ea_buf[-1]) + defaultFA = new ExtendedAttribute(); + defaultFA.fa = (byte) (FA_PRINTABLE | FA_MODIFY); + defaultFA.ic = 1; + + allocateBuffers(); + } + + private void allocateBuffers() { + int size = maxRows * maxCols; + buffer = new ExtendedAttribute[size]; + altBuffer = new ExtendedAttribute[size]; + for (int i = 0; i < size; i++) { + buffer[i] = new ExtendedAttribute(); + altBuffer[i] = new ExtendedAttribute(); + } + cursorAddress = 0; + bufferAddress = 0; + } + + /** Get the current screen buffer. */ + public ExtendedAttribute[] getBuffer() { return buffer; } + + /** Get a cell at the given buffer address. */ + public ExtendedAttribute getCell(int addr) { + if (addr < 0 || addr >= rows * cols) return defaultFA; + return buffer[addr]; + } + + // ========== Dimension accessors ========== + public int getRows() { return rows; } + public int getCols() { return cols; } + public int getMaxRows() { return maxRows; } + public int getMaxCols() { return maxCols; } + public int getDefRows() { return defRows; } + public int getDefCols() { return defCols; } + public int getAltRows() { return altRows; } + public int getAltCols() { return altCols; } + public boolean isScreenAlt() { return screenAlt; } + + /** Update alternate dimensions from BIND image. Re-allocates buffers if needed. */ + public void setAlternateDimensions(int newAltRows, int newAltCols) { + if (newAltRows == altRows && newAltCols == altCols) return; + this.altRows = newAltRows; + this.altCols = newAltCols; + // maxRows/maxCols should be the larger of alt vs current max + if (newAltRows > maxRows || newAltCols > maxCols) { + this.maxRows = Math.max(maxRows, newAltRows); + this.maxCols = Math.max(maxCols, newAltCols); + allocateBuffers(); + } + } + + // ========== Cursor ========== + public int getCursorAddress() { return cursorAddress; } + public void setCursorAddress(int addr) { this.cursorAddress = addr; } + public int getCursorRow() { return cursorAddress / cols; } + public int getCursorCol() { return cursorAddress % cols; } + + public int getBufferAddress() { return bufferAddress; } + public void setBufferAddress(int addr) { this.bufferAddress = addr; } + + public byte getReplyMode() { return replyMode; } + public void setReplyMode(byte mode) { this.replyMode = mode; } + + // ========== Screen erase ========== + + /** + * Perform an erase, optionally using the alternate screen size. + */ + public void erase(boolean alt) { + clear(); + int newRows = alt ? altRows : defRows; + int newCols = alt ? altCols : defCols; + if (alt == screenAlt && rows == newRows && cols == newCols) { + return; + } + rows = newRows; + cols = newCols; + screenAlt = alt; + } + + /** Clear the entire buffer. */ + public void clear() { + for (ExtendedAttribute ea : buffer) { + ea.clear(); + } + cursorAddress = 0; + bufferAddress = 0; + formatted = false; + screenChanged = true; + + defaultFg = 0x00; + defaultBg = 0x00; + defaultGr = 0x00; + defaultCs = 0x00; + defaultIc = 0x00; + replyMode = SF_SRM_FIELD; + } + + /** + * Erase all unprotected fields. + */ + public void eraseAllUnprotected() { + int size = rows * cols; + boolean inUnprotected = false; + + for (int i = 0; i < size; i++) { + ExtendedAttribute ea = buffer[i]; + if (ea.isFieldAttribute()) { + if (!faIsProtected(ea.fa & 0xFF)) { + inUnprotected = true; + // Clear modified bit + ea.fa = (byte) (ea.fa & ~FA_MODIFY); + } else { + inUnprotected = false; + } + } else if (inUnprotected) { + ea.ec = 0; + ea.ucs4 = 0; + ea.cs = 0; + ea.fg = 0; + ea.bg = 0; + ea.gr = 0; + ea.ic = 0; + } + } + + // Move cursor to first unprotected field + cursorAddress = findNextUnprotected(0); + screenChanged = true; + } + + // ========== Field attribute navigation ========== + + /** + * Find the field attribute for a given buffer address. + * Returns -1 if screen is not formatted. + */ + public int findFieldAttribute(int baddr) { + if (!formatted) return -1; + int size = rows * cols; + int start = baddr; + do { + if (buffer[baddr].isFieldAttribute()) { + return baddr; + } + baddr = (baddr > 0) ? baddr - 1 : size - 1; + } while (baddr != start); + return -1; + } + + /** + * Get the field attribute byte for a given position. + */ + public byte getFieldAttributeAt(int baddr) { + int fa_addr = findFieldAttribute(baddr); + if (fa_addr < 0) return defaultFA.fa; + return buffer[fa_addr].fa; + } + + /** + * Find the next unprotected field after the given address. + * Returns 0 if none found. + */ + public int findNextUnprotected(int baddr) { + int size = rows * cols; + int start = baddr; + do { + int next = (baddr + 1) % size; + if (buffer[baddr].isFieldAttribute() + && !faIsProtected(buffer[baddr].fa & 0xFF) + && !buffer[next].isFieldAttribute()) { + return next; + } + baddr = next; + } while (baddr != start); + return 0; + } + + /** Refresh the formatted flag by scanning for any field attributes. */ + public void updateFormatted() { + formatted = false; + int size = rows * cols; + for (int i = 0; i < size; i++) { + if (buffer[i].isFieldAttribute()) { + formatted = true; + return; + } + } + } + + public boolean isFormatted() { return formatted; } + public void setFormatted(boolean f) { this.formatted = f; } + + // ========== Buffer address arithmetic ========== + + /** Increment buffer address (wrapping). */ + public int incrementAddress(int addr) { + return (addr + 1) % (rows * cols); + } + + /** Decrement buffer address (wrapping). */ + public int decrementAddress(int addr) { + return (addr > 0) ? addr - 1 : (rows * cols) - 1; + } + + /** Convert buffer address to row. */ + public int addressToRow(int addr) { return addr / cols; } + + /** Convert buffer address to column. */ + public int addressToCol(int addr) { return addr % cols; } + + /** Convert row/col to buffer address. */ + public int rowColToAddress(int row, int col) { return row * cols + col; } + + // ========== Change tracking ========== + + public boolean isScreenChanged() { return screenChanged; } + public void clearChanged() { screenChanged = false; firstChanged = -1; lastChanged = -1; } + public void markAllChanged() { screenChanged = true; } + + // ========== Setters for model reconfiguration ========== + + public void setDimensions(int maxRows, int maxCols, int defRows, int defCols, + int altRows, int altCols) { + this.maxRows = maxRows; + this.maxCols = maxCols; + this.defRows = defRows; + this.defCols = defCols; + this.altRows = altRows; + this.altCols = altCols; + this.rows = defRows; + this.cols = defCols; + allocateBuffers(); + } + + public void translateToUnicode() { + int size = rows * cols; + for (int i = 0; i < size; i++) { + ExtendedAttribute ea = buffer[i]; + if (!ea.isFieldAttribute()) { + if (ea.ec == 0) { + ea.ucs4 = 0; + } else if (ea.cs == 1 || ea.cs == 0x04) { // CS_APL or CS_GE + ea.ucs4 = getAplGraphic(ea.ec & 0xFF); + } else { + ea.ucs4 = translator.ebcdicToUnicode(ea.ec & 0xFF); + } + } + } + } + + private char getAplGraphic(int ec) { + switch (ec) { + case 0xA2: return '\u2500'; // Horizontal Line 's' -> '─' + case 0x85: return '\u2502'; // Vertical Line 'e' -> '│' + case 0xC5: return '\u250C'; // Top Left 'E' -> '┌' + case 0xD5: return '\u2510'; // Top Right 'N' -> '┐' + case 0xC4: return '\u2514'; // Bottom Left 'D' -> '└' + case 0xD4: return '\u2518'; // Bottom Right 'M' -> '┘' + case 0xC6: return '\u251C'; // T-Junction Left 'F' -> '├' + case 0xD6: return '\u2524'; // T-Junction Right 'O' -> '┤' + case 0xC7: return '\u252C'; // T-Junction Top 'G' -> '┬' + case 0xD7: return '\u2534'; // T-Junction Bottom 'P' -> '┴' + case 0xCB: return '\u253C'; // Cross -> '┼' + default: return translator.ebcdicToUnicode(ec); + } + } + + /** + * Get the default field attribute. + */ + public ExtendedAttribute getDefaultFieldAttribute() { + return defaultFA; + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/telnet/TelnetConnection.java b/lib3270j/src/main/java/org/lib3270j/telnet/TelnetConnection.java new file mode 100644 index 0000000..32b96ff --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/telnet/TelnetConnection.java @@ -0,0 +1,151 @@ +package org.lib3270j.telnet; + +import org.lib3270j.ConnectionState; +import org.lib3270j.ConnectionConfig; +import org.lib3270j.Telnet3270Client; + +import java.io.*; +import java.net.*; +import java.util.logging.Logger; +import java.util.logging.Level; + +import static org.lib3270j.protocol.TelnetConstants.*; + +/** + * Manages the raw TCP socket connection to a TN3270 host. + * Handles connect/disconnect, raw byte I/O, and spawns a reader thread. + */ +public class TelnetConnection { + + private static final Logger log = Logger.getLogger(TelnetConnection.class.getName()); + private static final int READ_BUFFER_SIZE = 32768; + + private Socket socket; + private InputStream inputStream; + private OutputStream outputStream; + private Thread readerThread; + private volatile boolean running; + private final TelnetFSM fsm; + private final ConnectionConfig config; + + public TelnetConnection(ConnectionConfig config, TelnetFSM fsm) { + this.config = config; + this.fsm = fsm; + } + + /** + * Connect to the host. Blocks until connection is established or fails. + */ + public void connect() throws IOException { + log.info("Connecting to " + config.getHost() + ":" + config.getPort()); + socket = new Socket(); + socket.setKeepAlive(true); + socket.setOOBInline(true); + socket.setTcpNoDelay(true); + socket.connect(new InetSocketAddress(config.getHost(), config.getPort()), + config.getConnectTimeoutMs()); + + inputStream = new BufferedInputStream(socket.getInputStream(), READ_BUFFER_SIZE); + outputStream = new BufferedOutputStream(socket.getOutputStream()); + + log.info("Connected to " + socket.getRemoteSocketAddress()); + + running = true; + readerThread = new Thread(this::readLoop, "TN3270-Reader"); + readerThread.setDaemon(true); + readerThread.start(); + } + + /** + * Send raw bytes to the host. + */ + public synchronized void sendRaw(byte[] data) throws IOException { + sendRaw(data, 0, data.length); + } + + /** + * Send raw bytes to the host with offset and length. + */ + public synchronized void sendRaw(byte[] data, int offset, int length) throws IOException { + if (outputStream == null) return; + outputStream.write(data, offset, length); + outputStream.flush(); + if (log.isLoggable(Level.FINE)) { + log.fine("SENT " + length + " bytes: " + formatHex(data, offset, length)); + } + } + + /** + * Disconnect from the host. + */ + public void disconnect() { + running = false; + try { + if (socket != null && !socket.isClosed()) { + socket.shutdownInput(); + socket.shutdownOutput(); + socket.close(); + } + } catch (IOException e) { + log.log(Level.FINE, "Error during disconnect", e); + } + socket = null; + inputStream = null; + outputStream = null; + log.info("Disconnected"); + } + + /** + * Check if the socket is connected. + */ + public boolean isConnected() { + return socket != null && socket.isConnected() && !socket.isClosed(); + } + + /** + * Main reader loop. Reads from socket and feeds bytes to the telnet FSM. + */ + private void readLoop() { + byte[] buf = new byte[READ_BUFFER_SIZE]; + try { + while (running && isConnected()) { + int n = inputStream.read(buf); + if (n < 0) { + log.info("Host disconnected (EOF)"); + fsm.onDisconnect(); + break; + } + if (n > 0) { + if (log.isLoggable(Level.FINE)) { + log.fine("RCVD " + n + " bytes: " + formatHex(buf, 0, n)); + } + for (int i = 0; i < n; i++) { + fsm.feedByte(buf[i] & 0xFF); + } + fsm.endOfNetworkData(); + } + } + } catch (SocketException e) { + if (running) { + log.info("Socket closed: " + e.getMessage()); + fsm.onDisconnect(); + } + } catch (IOException e) { + if (running) { + log.log(Level.WARNING, "Read error", e); + fsm.onError("Read error: " + e.getMessage()); + } + } + } + + /** Format bytes as hex string for logging. */ + static String formatHex(byte[] data, int offset, int length) { + StringBuilder sb = new StringBuilder(length * 3); + for (int i = 0; i < length && i < 128; i++) { + if (i > 0) sb.append(' '); + sb.append(String.format("%02x", data[offset + i] & 0xFF)); + } + if (length > 128) sb.append("..."); + return sb.toString(); + } +} diff --git a/lib3270j/src/main/java/org/lib3270j/telnet/TelnetFSM.java b/lib3270j/src/main/java/org/lib3270j/telnet/TelnetFSM.java new file mode 100644 index 0000000..9a16fbc --- /dev/null +++ b/lib3270j/src/main/java/org/lib3270j/telnet/TelnetFSM.java @@ -0,0 +1,954 @@ +package org.lib3270j.telnet; + +import org.lib3270j.*; +import org.lib3270j.datastream.DataStreamProcessor; +import org.lib3270j.protocol.*; +import org.lib3270j.screen.ScreenBuffer; +import org.lib3270j.listener.*; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Logger; +import java.util.logging.Level; + +import static org.lib3270j.protocol.TelnetConstants.*; +import static org.lib3270j.protocol.TN3270EConstants.*; + +/** + * Telnet finite state machine. + * Processes incoming bytes one at a time, handling telnet commands, + * option negotiation, sub-negotiation, and 3270 data stream framing. + * + * Eight states matching telnet.c: + * TNS_DATA, TNS_IAC, TNS_WILL, TNS_WONT, TNS_DO, TNS_DONT, TNS_SB, TNS_SB_IAC + */ +public class TelnetFSM { + + private static final Logger log = Logger.getLogger(TelnetFSM.class.getName()); + + // Telnet FSM states + private static final int TNS_DATA = 0; + private static final int TNS_IAC = 1; + private static final int TNS_WILL = 2; + private static final int TNS_WONT = 3; + private static final int TNS_DO = 4; + private static final int TNS_DONT = 5; + private static final int TNS_SB = 6; + private static final int TNS_SB_IAC = 7; + + private int state = TNS_DATA; + + // Option state tracking + private final boolean[] myOpts = new boolean[256]; // options we have enabled + private final boolean[] hisOpts = new boolean[256]; // options the host has enabled + + // 3270 input buffer (accumulated between telnet framing) + private final ByteArrayOutputStream ibuf = new ByteArrayOutputStream(32768); + + // Sub-negotiation buffer + private final ByteArrayOutputStream sbbuf = new ByteArrayOutputStream(4096); + + // TN3270E state + private boolean tn3270eNegotiated; + private TN3270ESubmode tn3270eSubmode = TN3270ESubmode.UNBOUND; + private boolean tn3270eBound; + private final boolean[] eFuncs = new boolean[8]; // Negotiated TN3270E functions + private int eXmitSeq; + private int responseRequired = RSF_NO_RESPONSE; + private boolean deferredWillTtype; + + // Connection references + private TelnetConnection connection; + private final ConnectionConfig config; + private final ScreenBuffer screenBuffer; + private final DataStreamProcessor dsProcessor; + private volatile ConnectionState connectionState = ConnectionState.NOT_CONNECTED; + + // Listeners + private final List connectionListeners = new CopyOnWriteArrayList<>(); + private final List screenListeners = new CopyOnWriteArrayList<>(); + + // Connected LU info + private String connectedLu; + private String connectedType; + + enum TN3270ESubmode { UNBOUND, E_3270, E_NVT, E_SSCP } + + public TelnetFSM(ConnectionConfig config, ScreenBuffer screenBuffer, DataStreamProcessor dsProcessor) { + this.config = config; + this.screenBuffer = screenBuffer; + this.dsProcessor = dsProcessor; + } + + public void setConnection(TelnetConnection connection) { + this.connection = connection; + } + + public void addConnectionListener(ConnectionListener l) { connectionListeners.add(l); } + public void addScreenUpdateListener(ScreenUpdateListener l) { screenListeners.add(l); } + + public ConnectionState getConnectionState() { return connectionState; } + public boolean[] getMyOpts() { return myOpts; } + public boolean[] getHisOpts() { return hisOpts; } + + /** + * Called when the TCP connection is established. + * Initialize telnet state and prepare for negotiation. + */ + public void onConnected() { + state = TNS_DATA; + java.util.Arrays.fill(myOpts, false); + java.util.Arrays.fill(hisOpts, false); + java.util.Arrays.fill(eFuncs, false); + tn3270eNegotiated = false; + tn3270eSubmode = TN3270ESubmode.UNBOUND; + tn3270eBound = false; + eXmitSeq = 0; + deferredWillTtype = false; + ibuf.reset(); + sbbuf.reset(); + + // Initial TN3270E function requests + eFuncs[FUNC_BIND_IMAGE] = true; + eFuncs[FUNC_RESPONSES] = true; + eFuncs[FUNC_SYSREQ] = true; + + changeState(ConnectionState.TELNET_PENDING); + } + + /** + * Feed a single byte from the network into the FSM. + */ + public void feedByte(int c) { + switch (state) { + case TNS_DATA: + processData(c); + break; + case TNS_IAC: + processIAC(c); + break; + case TNS_WILL: + processWill(c); + state = TNS_DATA; + break; + case TNS_WONT: + processWont(c); + state = TNS_DATA; + break; + case TNS_DO: + processDo(c); + state = TNS_DATA; + break; + case TNS_DONT: + processDont(c); + state = TNS_DATA; + break; + case TNS_SB: + processSB(c); + break; + case TNS_SB_IAC: + processSBIAC(c); + break; + } + } + + /** Called at the end of a network read batch. */ + public void endOfNetworkData() { + // Opportunity to flush any pending NVT data + } + + // ========== TNS_DATA ========== + + private void processData(int c) { + if (c == IAC) { + state = TNS_IAC; + return; + } + + if (connectionState == ConnectionState.TELNET_PENDING) { + // Got data before any telnet commands — assume NVT mode + changeState(ConnectionState.CONNECTED_NVT); + } + + // Accumulate data for 3270, TN3270E (including SSCP-LU and unbound states) + if (connectionState.is3270() || connectionState.isTn3270e()) { + ibuf.write(c); + } + // NVT data would go to NVT processor (not implemented in initial version) + } + + // ========== TNS_IAC ========== + + private void processIAC(int c) { + switch (c) { + case IAC: // Escaped IAC — literal 0xFF + ibuf.write(c); + state = TNS_DATA; + break; + case EOR: // End of record — process accumulated 3270 data + log.fine("RCVD EOR"); + if (connectionState.is3270() || connectionState.isTn3270e()) { + processEndOfRecord(); + } + ibuf.reset(); + state = TNS_DATA; + break; + case WILL: state = TNS_WILL; break; + case WONT: state = TNS_WONT; break; + case DO: state = TNS_DO; break; + case DONT: state = TNS_DONT; break; + case SB: + sbbuf.reset(); + state = TNS_SB; + break; + case GA: + log.fine("RCVD GA"); + state = TNS_DATA; + break; + case NOP: + log.fine("RCVD NOP"); + state = TNS_DATA; + break; + default: + log.fine("RCVD IAC " + TelnetConstants.commandName(c)); + state = TNS_DATA; + break; + } + } + + // ========== TNS_WILL (host sends WILL option) ========== + + private void processWill(int opt) { + log.info("RCVD WILL " + TelnetConstants.optionName(opt)); + + switch (opt) { + case TELOPT_SGA: + case TELOPT_BINARY: + case TELOPT_EOR: + case TELOPT_ECHO: + if (!hisOpts[opt]) { + hisOpts[opt] = true; + sendCommand(DO, opt); + } + break; + + case TELOPT_TN3270E: + if (!hisOpts[opt]) { + hisOpts[opt] = true; + sendCommand(DO, opt); + } + break; + + default: + sendCommand(DONT, opt); + break; + } + + checkIn3270(); + } + + // ========== TNS_WONT ========== + + private void processWont(int opt) { + log.info("RCVD WONT " + TelnetConstants.optionName(opt)); + if (hisOpts[opt]) { + hisOpts[opt] = false; + sendCommand(DONT, opt); + } + checkIn3270(); + } + + // ========== TNS_DO (host requests we enable option) ========== + + private void processDo(int opt) { + log.info("RCVD DO " + TelnetConstants.optionName(opt)); + + switch (opt) { + case TELOPT_BINARY: + case TELOPT_EOR: + case TELOPT_SGA: + if (!myOpts[opt]) { + myOpts[opt] = true; + sendCommand(WILL, opt); + } + break; + + case TELOPT_TTYPE: + if (!myOpts[opt]) { + myOpts[opt] = true; + if (hisOpts[TELOPT_TN3270E]) { + // Defer TTYPE response until TN3270E negotiation completes + deferredWillTtype = true; + } else { + sendCommand(WILL, opt); + } + } + break; + + case TELOPT_TN3270E: + if (!myOpts[opt]) { + myOpts[opt] = true; + sendCommand(WILL, opt); + // Start TN3270E sub-negotiation: send device type request + sendTN3270EDeviceTypeRequest(); + } + break; + + case TELOPT_NAWS: + if (!myOpts[opt]) { + myOpts[opt] = true; + sendCommand(WILL, opt); + } + sendNaws(); + break; + + case TELOPT_NEW_ENVIRON: + if (!myOpts[opt]) { + myOpts[opt] = true; + sendCommand(WILL, opt); + } + break; + + default: + sendCommand(WONT, opt); + break; + } + + checkIn3270(); + } + + // ========== TNS_DONT ========== + + private void processDont(int opt) { + log.info("RCVD DONT " + TelnetConstants.optionName(opt)); + if (myOpts[opt]) { + myOpts[opt] = false; + sendCommand(WONT, opt); + } + checkIn3270(); + } + + // ========== TNS_SB (accumulating sub-negotiation data) ========== + + private void processSB(int c) { + if (c == IAC) { + state = TNS_SB_IAC; + } else { + sbbuf.write(c); + } + } + + // ========== TNS_SB_IAC (IAC seen during sub-negotiation) ========== + + private void processSBIAC(int c) { + if (c == SE) { + // Sub-negotiation complete + processSubNegotiation(sbbuf.toByteArray()); + state = TNS_DATA; + } else if (c == IAC) { + // Escaped IAC within sub-negotiation + sbbuf.write(IAC); + state = TNS_SB; + } else { + // Shouldn't happen, but recover + log.warning("Unexpected byte " + c + " after IAC in SB"); + state = TNS_DATA; + } + } + + // ========== Sub-negotiation processing ========== + + private void processSubNegotiation(byte[] data) { + if (data.length < 1) return; + + int opt = data[0] & 0xFF; + log.info("RCVD SB " + TelnetConstants.optionName(opt) + " (" + data.length + " bytes)"); + + switch (opt) { + case TELOPT_TTYPE: + handleTTypeSB(data); + break; + case TELOPT_TN3270E: + handleTN3270ESB(data); + break; + case TELOPT_NEW_ENVIRON: + handleNewEnvironSB(data); + break; + default: + log.info("Ignoring SB for option " + opt); + break; + } + } + + // ========== TTYPE sub-negotiation ========== + + private void handleTTypeSB(byte[] data) { + if (data.length >= 2 && data[1] == TELQUAL_SEND) { + // Host asks for terminal type + String termType = config.getEffectiveTerminalType(); + log.info("RCVD SB TTYPE SEND - Responding with: " + termType); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(IAC); + out.write(SB); + out.write(TELOPT_TTYPE); + out.write(TELQUAL_IS); + for (char ch : termType.toCharArray()) { + out.write((byte) ch); + } + out.write(IAC); + out.write(SE); + sendBytes(out.toByteArray()); + log.info("SENT SB TTYPE IS " + termType + " SE"); + } + } + + // ========== NEW_ENVIRON sub-negotiation ========== + + private void handleNewEnvironSB(byte[] data) { + if (data.length >= 2 && data[1] == TELQUAL_SEND) { + log.info("RCVD SB NEW-ENVIRON SEND - Responding with empty IS"); + byte[] response = { (byte) IAC, (byte) SB, (byte) TELOPT_NEW_ENVIRON, + (byte) TELQUAL_IS, (byte) IAC, (byte) SE }; + sendBytes(response); + log.info("SENT SB NEW-ENVIRON IS SE"); + } + } + + // ========== TN3270E sub-negotiation ========== + + private void handleTN3270ESB(byte[] data) { + if (data.length < 2) return; + + int op = data[1] & 0xFF; + log.info("TN3270E SB op=" + op + " (" + tn3270eOpName(op) + ")"); + + switch (op) { + case OP_SEND: + // Host asks us to send device-type request + sendTN3270EDeviceTypeRequest(); + break; + + case OP_DEVICE_TYPE: + handleTN3270EDeviceType(data); + break; + + case OP_FUNCTIONS: + handleTN3270EFunctions(data); + break; + + case OP_IS: + // Could be device-type IS or functions IS, check context + if (data.length >= 3 && data[2] == OP_DEVICE_TYPE) { + handleTN3270EDeviceType(data); + } else if (data.length >= 3 && data[2] == OP_FUNCTIONS) { + handleTN3270EFunctions(data); + } + break; + + default: + log.info("Unhandled TN3270E op: " + op); + break; + } + } + + private void sendTN3270EDeviceTypeRequest() { + String termType = config.getEffectiveTerminalType(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(IAC); + out.write(SB); + out.write(TELOPT_TN3270E); + out.write(OP_DEVICE_TYPE); + out.write(OP_REQUEST); + for (char ch : termType.toCharArray()) { + out.write((byte) ch); + } + // Add LU name if specified + if (config.getLuName() != null && !config.getLuName().isEmpty()) { + out.write(OP_CONNECT); + for (char ch : config.getLuName().toCharArray()) { + out.write((byte) ch); + } + } + out.write(IAC); + out.write(SE); + sendBytes(out.toByteArray()); + log.info("SENT SB TN3270E DEVICE-TYPE REQUEST " + termType + + (config.getLuName() != null ? " CONNECT " + config.getLuName() : "") + " SE"); + } + + private void handleTN3270EDeviceType(byte[] data) { + // Parse: TN3270E DEVICE-TYPE IS CONNECT + // or: TN3270E DEVICE-TYPE REJECT REASON + int pos = 2; // Skip TN3270E and DEVICE-TYPE + + if (pos < data.length && (data[pos] & 0xFF) == OP_IS) { + pos++; + } + + if (pos < data.length && (data[pos] & 0xFF) == OP_REJECT) { + // Rejection + pos++; + int reason = -1; + if (pos < data.length && (data[pos] & 0xFF) == OP_REASON) { + pos++; + if (pos < data.length) { + reason = data[pos] & 0xFF; + } + } + log.warning("TN3270E device-type rejected: " + TN3270EConstants.reasonName(reason)); + + // Fall back to plain TN3270 + myOpts[TELOPT_TN3270E] = false; + hisOpts[TELOPT_TN3270E] = false; + + // Send deferred WILL TTYPE if needed + if (deferredWillTtype) { + sendCommand(WILL, TELOPT_TTYPE); + deferredWillTtype = false; + } + return; + } + + // Parse device type name + StringBuilder deviceType = new StringBuilder(); + while (pos < data.length && (data[pos] & 0xFF) != OP_CONNECT) { + deviceType.append((char) (data[pos] & 0xFF)); + pos++; + } + + // Parse device name (after CONNECT) + String deviceName = null; + if (pos < data.length && (data[pos] & 0xFF) == OP_CONNECT) { + pos++; + StringBuilder name = new StringBuilder(); + while (pos < data.length) { + name.append((char) (data[pos] & 0xFF)); + pos++; + } + deviceName = name.toString(); + } + + connectedType = deviceType.toString(); + connectedLu = deviceName; + log.info("TN3270E device-type IS " + connectedType + + (connectedLu != null ? " CONNECT " + connectedLu : "")); + + // Now send functions request + sendTN3270EFunctionsRequest(); + } + + private void sendTN3270EFunctionsRequest() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(IAC); + out.write(SB); + out.write(TELOPT_TN3270E); + out.write(OP_FUNCTIONS); + out.write(OP_REQUEST); + + StringBuilder funcNames = new StringBuilder(); + for (int i = 0; i < eFuncs.length; i++) { + if (eFuncs[i]) { + out.write(i); + if (funcNames.length() > 0) funcNames.append(" "); + funcNames.append(TN3270EConstants.functionName(i)); + } + } + + out.write(IAC); + out.write(SE); + sendBytes(out.toByteArray()); + log.info("SENT SB TN3270E FUNCTIONS REQUEST " + funcNames + " SE"); + } + + private void handleTN3270EFunctions(byte[] data) { + // Parse: TN3270E FUNCTIONS IS [func...] + int pos = 2; // Skip TN3270E, FUNCTIONS + + if (pos < data.length && (data[pos] & 0xFF) == OP_IS) { + pos++; // Skip IS + } + + // The remaining bytes are the agreed-upon functions + java.util.Arrays.fill(eFuncs, false); + StringBuilder funcNames = new StringBuilder(); + while (pos < data.length) { + int func = data[pos] & 0xFF; + if (func <= FUNC_SNA_SENSE) { + eFuncs[func] = true; + if (funcNames.length() > 0) funcNames.append(" "); + funcNames.append(TN3270EConstants.functionName(func)); + } + pos++; + } + + tn3270eNegotiated = true; + log.info("TN3270E functions IS: " + funcNames); + log.info("TN3270E negotiation complete"); + + // Move to CONNECTED_UNBOUND or CONNECTED_SSCP + changeState(ConnectionState.CONNECTED_UNBOUND); + + // Notify listeners + for (ConnectionListener l : connectionListeners) { + l.onTN3270ENegotiated(connectedType, connectedLu); + } + } + + // ========== End of Record processing ========== + + private void processEndOfRecord() { + byte[] data = ibuf.toByteArray(); + if (data.length == 0) return; + + if (tn3270eNegotiated) { + // TN3270E mode: data starts with 5-byte header + processTN3270ERecord(data); + } else { + // Plain TN3270 mode: data is raw 3270 data stream + dsProcessor.processRecord(data, 0, data.length, true); + notifyScreenUpdate(); + } + } + + private void processTN3270ERecord(byte[] data) { + if (data.length < EH_SIZE) { + log.warning("TN3270E record too short: " + data.length); + return; + } + + int dataType = data[0] & 0xFF; + int requestFlag = data[1] & 0xFF; + int responseFlag = data[2] & 0xFF; + int seqNumber = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF); + + log.fine("TN3270E header: type=" + TN3270EConstants.dataTypeName(dataType) + + " rqf=" + requestFlag + " rsf=" + responseFlag + " seq=" + seqNumber); + + responseRequired = responseFlag; + + switch (dataType) { + case DT_3270_DATA: + if (data.length > EH_SIZE) { + // Transition to 3270 mode + if (connectionState == ConnectionState.CONNECTED_UNBOUND || + connectionState == ConnectionState.CONNECTED_SSCP) { + // Clear screen on transition to 3270 mode from unbound/SSCP + // This ensures old SSCP-LU data or stale content doesn't persist + screenBuffer.erase(false); + changeState(ConnectionState.CONNECTED_TN3270E); + tn3270eSubmode = TN3270ESubmode.E_3270; + } + dsProcessor.processRecord(data, EH_SIZE, data.length - EH_SIZE, true); + notifyScreenUpdate(); + } + // Send positive response if required + if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) { + sendTN3270EPositiveResponse(seqNumber); + } + break; + + case DT_SSCP_LU_DATA: + if (connectionState == ConnectionState.CONNECTED_UNBOUND) { + // Clear screen on first SSCP-LU transition to remove stale data + screenBuffer.clear(); + changeState(ConnectionState.CONNECTED_SSCP); + tn3270eSubmode = TN3270ESubmode.E_SSCP; + } + if (data.length > EH_SIZE) { + dsProcessor.processSscpLuData(data, EH_SIZE, data.length - EH_SIZE); + notifyScreenUpdate(); + } + break; + + case DT_BIND_IMAGE: + tn3270eBound = true; + // Parse BIND image for screen dimensions (SNA BIND format) + { + int bindLen = data.length - EH_SIZE; + StringBuilder bindHex = new StringBuilder(); + for (int bi = EH_SIZE; bi < data.length && bi < EH_SIZE + 40; bi++) { + bindHex.append(String.format("%02x ", data[bi] & 0xFF)); + } + log.info("Received BIND image (" + bindLen + " bytes)" + + " responseFlag=" + responseFlag + " raw: " + bindHex.toString().trim()); + + // SNA BIND RU offsets (from 3270ds.h): + // Byte 20 = RD (default rows), Byte 21 = CD (default cols) + // Byte 22 = RA (alternate rows), Byte 23 = CA (alternate cols) + // Byte 24 = SSIZE (screen size indicator) + final int BIND_OFF_RD = 20, BIND_OFF_CD = 21; + final int BIND_OFF_RA = 22, BIND_OFF_CA = 23; + final int BIND_OFF_SSIZE = 24; + + if (bindLen > BIND_OFF_SSIZE) { + int ssize = data[EH_SIZE + BIND_OFF_SSIZE] & 0xFF; + int bindRd = data[EH_SIZE + BIND_OFF_RD] & 0xFF; + int bindCd = data[EH_SIZE + BIND_OFF_CD] & 0xFF; + int bindRa, bindCa; + + switch (ssize) { + case 0x00: case 0x02: + // Default model 2 dimensions for both default and alt + bindRd = 24; bindCd = 80; + bindRa = 24; bindCa = 80; + break; + case 0x03: + // Default = 24x80, alternate = configured model max + bindRd = 24; bindCd = 80; + bindRa = screenBuffer.getMaxRows(); + bindCa = screenBuffer.getMaxCols(); + break; + case 0x7e: + // Both default and alternate = specified values + bindRa = bindRd; bindCa = bindCd; + break; + case 0x7f: + // Default and alternate are both specified separately + bindRa = data[EH_SIZE + BIND_OFF_RA] & 0xFF; + bindCa = data[EH_SIZE + BIND_OFF_CA] & 0xFF; + break; + default: + // Unknown SSIZE - use model defaults + bindRa = screenBuffer.getMaxRows(); + bindCa = screenBuffer.getMaxCols(); + break; + } + log.info("BIND SSIZE=0x" + String.format("%02x", ssize) + + " default=" + bindRd + "x" + bindCd + + " alt=" + bindRa + "x" + bindCa); + + // Apply dimensions — constrain to model max + int maxR = screenBuffer.getMaxRows(); + int maxC = screenBuffer.getMaxCols(); + if (bindRa > 0 && bindCa > 0 && bindRa <= maxR && bindCa <= maxC) { + screenBuffer.setAlternateDimensions(bindRa, bindCa); + } + } + } + // Clear and reset screen for new session + screenBuffer.erase(false); + changeState(ConnectionState.CONNECTED_TN3270E); + tn3270eSubmode = TN3270ESubmode.E_3270; + notifyScreenUpdate(); + // Send positive response if required (critical for ISPF NEWAPPL) + if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) { + sendTN3270EPositiveResponse(seqNumber); + } + break; + + case DT_UNBIND: + log.info("Received UNBIND responseFlag=" + responseFlag); + tn3270eBound = false; + // Restore alternate dimensions to configured model max (per x3270) + screenBuffer.setAlternateDimensions( + screenBuffer.getMaxRows(), screenBuffer.getMaxCols()); + // Clear screen on UNBIND — essential for ISPF NEWAPPL transitions + screenBuffer.clear(); + // Send positive response BEFORE changing state (host expects it) + if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) { + sendTN3270EPositiveResponse(seqNumber); + } + changeState(ConnectionState.CONNECTED_UNBOUND); + tn3270eSubmode = TN3270ESubmode.UNBOUND; + notifyScreenUpdate(); + break; + + case DT_NVT_DATA: + // NVT data in TN3270E mode + changeState(ConnectionState.CONNECTED_E_NVT); + tn3270eSubmode = TN3270ESubmode.E_NVT; + break; + + case DT_RESPONSE: + log.fine("Received response, seq=" + seqNumber); + break; + + default: + log.info("Unhandled TN3270E data type: " + dataType); + break; + } + } + + private void sendTN3270EPositiveResponse(int seqNumber) { + byte[] resp = new byte[EH_SIZE + 1]; + resp[0] = (byte) DT_RESPONSE; + resp[1] = 0; + resp[2] = (byte) RSF_POSITIVE_RESPONSE; + resp[3] = (byte) ((seqNumber >> 8) & 0xFF); + resp[4] = (byte) (seqNumber & 0xFF); + resp[5] = (byte) POS_DEVICE_END; + + sendRecord(resp); + } + + // ========== Check if we should transition to 3270 mode ========== + + private void checkIn3270() { + if (connectionState != ConnectionState.TELNET_PENDING) return; + + // For TN3270E, we wait for TN3270E negotiation to complete + if (myOpts[TELOPT_TN3270E] && hisOpts[TELOPT_TN3270E]) { + return; // TN3270E in progress + } + + // For plain TN3270: need BINARY and EOR in both directions + if (myOpts[TELOPT_BINARY] && hisOpts[TELOPT_BINARY] && + myOpts[TELOPT_EOR] && hisOpts[TELOPT_EOR]) { + log.info("Transitioning to plain TN3270 mode"); + changeState(ConnectionState.CONNECTED_3270); + } + } + + // ========== Sending helpers ========== + + private void sendCommand(int cmd, int opt) { + byte[] msg = { (byte) IAC, (byte) cmd, (byte) opt }; + sendBytes(msg); + log.info("SENT " + TelnetConstants.commandName(cmd) + " " + TelnetConstants.optionName(opt)); + } + + private void sendNaws() { + int cols = screenBuffer.getMaxCols(); + int rows = screenBuffer.getMaxRows(); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(IAC); + out.write(SB); + out.write(TELOPT_NAWS); + writeNawsValue(out, cols); + writeNawsValue(out, rows); + out.write(IAC); + out.write(SE); + sendBytes(out.toByteArray()); + log.info("SENT SB NAWS " + cols + " " + rows + " SE"); + } + + private void writeNawsValue(ByteArrayOutputStream out, int value) { + int hi = (value >> 8) & 0xFF; + int lo = value & 0xFF; + out.write(hi); + if (hi == IAC) out.write(IAC); + out.write(lo); + if (lo == IAC) out.write(IAC); + } + + /** + * Send a 3270 record (with EOR framing, and TN3270E header if applicable). + */ + public void sendRecord(byte[] data) { + ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + 10); + + // Escape any IAC bytes in the payload + for (byte b : data) { + out.write(b & 0xFF); + if ((b & 0xFF) == IAC) { + out.write(IAC); + } + } + + out.write(IAC); + out.write(EOR); + sendBytes(out.toByteArray()); + } + + /** + * Send a 3270 data record, with TN3270E header if in TN3270E mode. + */ + public void send3270Data(byte[] data) { + if (tn3270eNegotiated) { + ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + EH_SIZE); + // TN3270E header + out.write(DT_3270_DATA); // data type + out.write(0); // request flag + out.write(0); // response flag + out.write((eXmitSeq >> 8) & 0xFF); // seq high + out.write(eXmitSeq & 0xFF); // seq low + eXmitSeq = (eXmitSeq + 1) & 0xFFFF; + // 3270 data + for (byte b : data) { + out.write(b & 0xFF); + } + sendRecord(out.toByteArray()); + } else { + sendRecord(data); + } + } + + /** + * Send SSCP-LU data (in TN3270E SSCP-LU mode). + */ + public void sendSscpLuData(byte[] data) { + if (tn3270eNegotiated) { + ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + EH_SIZE); + out.write(DT_SSCP_LU_DATA); + out.write(0); + out.write(0); + out.write((eXmitSeq >> 8) & 0xFF); + out.write(eXmitSeq & 0xFF); + eXmitSeq = (eXmitSeq + 1) & 0xFFFF; + for (byte b : data) { + out.write(b & 0xFF); + } + sendRecord(out.toByteArray()); + } + } + + private void sendBytes(byte[] data) { + try { + connection.sendRaw(data); + } catch (IOException e) { + log.log(Level.WARNING, "Send error", e); + onError("Send error: " + e.getMessage()); + } + } + + // ========== State management ========== + + private void changeState(ConnectionState newState) { + ConnectionState old = this.connectionState; + this.connectionState = newState; + log.info("State: " + old + " -> " + newState); + for (ConnectionListener l : connectionListeners) { + l.onConnectionStateChanged(old, newState); + } + } + + public void onDisconnect() { + changeState(ConnectionState.NOT_CONNECTED); + } + + public void onError(String message) { + log.warning("Error: " + message); + for (ConnectionListener l : connectionListeners) { + l.onConnectionError(message); + } + } + + private void notifyScreenUpdate() { + for (ScreenUpdateListener l : screenListeners) { + l.onScreenUpdated(); + } + } + + public boolean isTn3270eNegotiated() { return tn3270eNegotiated; } + public String getConnectedLu() { return connectedLu; } + public String getConnectedType() { return connectedType; } + + private static String tn3270eOpName(int op) { + switch (op) { + case OP_ASSOCIATE: return "ASSOCIATE"; + case OP_CONNECT: return "CONNECT"; + case OP_DEVICE_TYPE: return "DEVICE-TYPE"; + case OP_FUNCTIONS: return "FUNCTIONS"; + case OP_IS: return "IS"; + case OP_REASON: return "REASON"; + case OP_REJECT: return "REJECT"; + case OP_REQUEST: return "REQUEST"; + case OP_SEND: return "SEND"; + default: return "OP-" + op; + } + } +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..e877d9f --- /dev/null +++ b/run.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Launch j3270 - Java TN3270 Terminal Emulator +# Usage: ./run.sh [host] [port] [model] +# Example: ./run.sh mainframe.example.com 23 4 + +JAVA_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$JAVA_DIR/build" + +# Always rebuild to pick up changes +echo "Building lib3270j..." +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR/lib3270j" "$BUILD_DIR/j3270" + +# Compile libraries +find "$JAVA_DIR/lib3270j/src" -name "*.java" -print0 | xargs -0 javac -d "$BUILD_DIR/lib3270j" + +echo "Building j3270..." +find "$JAVA_DIR/j3270/src" -name "*.java" -print0 | xargs -0 javac -cp "$BUILD_DIR/lib3270j" -d "$BUILD_DIR/j3270" + +echo "Build complete." + +# --------------------------------------------------------- +# NEW: Package the shareable JAR +# --------------------------------------------------------- +echo "Packaging j3270.jar..." + +# 1. Create the Manifest file indicating the entry point +# Note: The manifest file must end with a new line or carriage return +echo "Main-Class: org.pubvm.j3270.J3270App" > "$BUILD_DIR/MANIFEST.MF" +echo "" >> "$BUILD_DIR/MANIFEST.MF" + +# 2. Build the JAR using both compiled directories +jar cvfm "$BUILD_DIR/j3270.jar" "$BUILD_DIR/MANIFEST.MF" \ + -C "$BUILD_DIR/lib3270j" . \ + -C "$BUILD_DIR/j3270" . + +echo "Executable JAR created at: $BUILD_DIR/j3270.jar" + +# --------------------------------------------------------- +# Run the newly packaged JAR +# --------------------------------------------------------- +exec java -Dapple.laf.useScreenMenuBar=true \ + -Dapple.awt.application.name=j3270 \ + -jar "$BUILD_DIR/j3270.jar" "$@" \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..27331d1 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,4 @@ +rootProject.name = 'j3270-project' + +include 'lib3270j' +include 'j3270'