8 Commits

Author SHA1 Message Date
rudi 59925c2787 Add headless server and setup script
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 34s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 42s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m13s
Release j3270 / Build & Publish Release (push) Successful in 1m8s
2026-09-08 11:38:38 -04:00
rudi f8fc0e4b22 Fix the build
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 25s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 59s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 23s
2026-09-07 20:31:18 -04:00
rudi dbcb0aa1b6 Clear cruft
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 26s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 36s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Failing after 1m1s
2026-09-07 20:16:00 -04:00
rudi 50a4279446 Update README
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 30s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 33s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 24s
2026-09-06 11:31:35 -04:00
rudi 6331e0108b Update tests and Status/UI
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 26s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 1m5s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m10s
2026-09-06 09:53:43 -04:00
rudi 79d493c24d Update builder
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 1m12s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m12s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 4m22s
2026-09-05 14:11:08 -04:00
rudi 74e30d1ead IBM-DYNAMIC tweaking
Build and Test j3270 / Build JAR & Run Tests (push) Successful in 1m25s
2026-09-04 20:58:19 -04:00
rudi cbb310b889 GOCA fixing
Build and Test j3270 / Build JAR & Run Tests (push) Successful in 1m22s
2026-09-04 14:34:01 -04:00
97 changed files with 10808 additions and 807 deletions
+18 -10
View File
@@ -9,8 +9,12 @@ on:
jobs:
build:
name: Build JAR & Run Tests
name: Build JAR & Run Tests (Java ${{ matrix.java }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
java: [ '11', '17', '21' ]
steps:
- name: Configure Dynamic DNS Resolvers
@@ -23,21 +27,21 @@ jobs:
with:
fetch-depth: 0
- name: Set up Java JDK (Temurin 17)
- name: Set up Java JDK (Temurin ${{ matrix.java }})
uses: actions/setup-java@v3
continue-on-error: true
with:
java-version: '17'
java-version: ${{ matrix.java }}
distribution: 'temurin'
- name: Ensure Java Available
run: |
if ! command -v javac >/dev/null 2>&1; then
echo "Installing OpenJDK 17 via package manager..."
echo "Installing OpenJDK ${{ matrix.java }} via package manager..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y openjdk-17-jdk-headless || true
apt-get update && apt-get install -y openjdk-${{ matrix.java }}-jdk-headless || true
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache openjdk17 || true
apk add --no-cache openjdk${{ matrix.java }} || true
fi
fi
echo "Java compiler:"
@@ -53,23 +57,27 @@ jobs:
run: |
sh ./test_all.sh
- name: Prepare Versioned JAR
run: |
cp build/j3270.jar "build/j3270-java${{ matrix.java }}.jar"
- name: Upload j3270 Executable JAR Artifact
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: j3270-executable-jar
path: build/j3270.jar
name: j3270-java${{ matrix.java }}-jar
path: build/j3270-java${{ matrix.java }}.jar
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: test-reports
name: test-reports-java${{ matrix.java }}
path: build/reports/tests/
- name: Report Build Status
if: failure()
run: |
echo "### ❌ Build or Test Failed" >> $GITHUB_STEP_SUMMARY
echo "### ❌ Build or Test Failed for Java ${{ matrix.java }}" >> $GITHUB_STEP_SUMMARY
echo "Please check the runner logs above for details." >> $GITHUB_STEP_SUMMARY
+18 -10
View File
@@ -9,8 +9,12 @@ on:
jobs:
build:
name: Build JAR & Run Tests
name: Build JAR & Run Tests (Java ${{ matrix.java }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
java: [ '11', '17', '21' ]
steps:
- name: Configure Dynamic DNS Resolvers
@@ -23,21 +27,21 @@ jobs:
with:
fetch-depth: 0
- name: Set up Java JDK (Temurin 17)
- name: Set up Java JDK (Temurin ${{ matrix.java }})
uses: actions/setup-java@v3
continue-on-error: true
with:
java-version: '17'
java-version: ${{ matrix.java }}
distribution: 'temurin'
- name: Ensure Java Available
run: |
if ! command -v javac >/dev/null 2>&1; then
echo "Installing OpenJDK 17 via package manager..."
echo "Installing OpenJDK ${{ matrix.java }} via package manager..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y openjdk-17-jdk-headless || true
apt-get update && apt-get install -y openjdk-${{ matrix.java }}-jdk-headless || true
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache openjdk17 || true
apk add --no-cache openjdk${{ matrix.java }} || true
fi
fi
echo "Java compiler:"
@@ -53,23 +57,27 @@ jobs:
run: |
sh ./test_all.sh
- name: Prepare Versioned JAR
run: |
cp build/j3270.jar "build/j3270-java${{ matrix.java }}.jar"
- name: Upload j3270 Executable JAR Artifact
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: j3270-executable-jar
path: build/j3270.jar
name: j3270-java${{ matrix.java }}-jar
path: build/j3270-java${{ matrix.java }}.jar
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: test-reports
name: test-reports-java${{ matrix.java }}
path: build/reports/tests/
- name: Report Build Status
if: failure()
run: |
echo "### ❌ Build or Test Failed" >> $GITHUB_STEP_SUMMARY
echo "### ❌ Build or Test Failed for Java ${{ matrix.java }}" >> $GITHUB_STEP_SUMMARY
echo "Please check the runner logs above for details." >> $GITHUB_STEP_SUMMARY
+2
View File
@@ -1,4 +1,5 @@
j3270.log.*
*.bin
*.txt
*.py
*.pcap
@@ -8,3 +9,4 @@ build/*
*.jar
build
.gradle
*.txt
+123 -30
View File
@@ -2,29 +2,43 @@
[![Build & Test](https://git.hugfreevikings.wtf/rudi/j3270/actions/workflows/build.yaml/badge.svg)](https://git.hugfreevikings.wtf/rudi/j3270/actions)
[![Releases](https://img.shields.io/badge/Releases-Gitea-blue.svg)](https://git.hugfreevikings.wtf/rudi/j3270/releases)
[![Java](https://img.shields.io/badge/Java-11%20%7C%2017%20%7C%2021-blue.svg)](https://adoptium.net)
[![Java](https://img.shields.io/badge/Java-11%20%7C%2017%20%7C%2021%20(LTS)-blue.svg)](https://adoptium.net)
[![Tests](https://img.shields.io/badge/Tests-539%20passed-brightgreen.svg)]()
[![License](https://img.shields.io/badge/License-Unlicense-blue.svg)](LICENSE)
An **x3270-aligned IBM 3270 mainframe terminal emulator** written in pure Java. Designed for high compatibility with IBM z/OS, z/VM, CMS, and TSO systems over TN3270 and TN3270E.
An **x3270-aligned and IBM Host On-Demand (HoD) compatible IBM 3270 mainframe terminal emulator & protocol library** written in pure Java with zero third-party runtime dependencies. Designed for high fidelity and compatibility with IBM z/OS, z/VM, and 3270-based systems over TN3270 and TN3270E.
---
## 🚀 Quick Download & Run
### Latest Standalone JAR
You can download the ready-to-run executable JAR directly from the releases or CI builds:
### Latest Standalone JARs
Ready-to-run executable JARs are built and tested across all three Java LTS baselines (**Java 11, Java 17, and Java 21**) by Gitea Actions:
- **[Download Latest Tagged Release (`j3270.jar`)](https://git.hugfreevikings.wtf/rudi/j3270/releases)**
- **[Latest Nightly CI Artifacts](https://git.hugfreevikings.wtf/rudi/j3270/actions)** (Download under the latest successful workflow run)
- **[Latest Nightly CI Artifacts](https://git.hugfreevikings.wtf/rudi/j3270/actions)**: Download standalone JARs tailored for each LTS runtime under the latest workflow run:
- `j3270-java11-jar` (Java 11 LTS)
- `j3270-java17-jar` (Java 17 LTS — primary Gitea runner baseline)
- `j3270-java21-jar` (Java 21 LTS)
### Java Runtime Requirements
Requires **Java 11 or higher** (LTS versions: **Java 11, 17, 21**; also compatible with Java 24).
The project is tested and verified against:
- **Eclipse Temurin** (Adoptium OpenJDK HotSpot) — used in Gitea Actions CI workflows.
- **IBM Semeru Runtimes** (OpenJ9) — e.g. `11.0.32-sem`, `21.0.6-sem`, and `24.0.2-sem` via SDKMAN.
### Running `j3270`
Requires **Java 11 or higher** (Java 11, 17, 21+):
```bash
# Launch interactive GUI
# Launch interactive Swing GUI
java -jar j3270.jar
# Connect to TLS/SSL mainframe with custom port
# Connect to mainframe with host, port, and terminal model (Models 2-5 or 'dynamic')
java -jar j3270.jar mainframe.example.com 23 4
java -jar j3270.jar mainframe.example.com 23 dynamic
# Connect to TLS/SSL mainframe on custom or standard (992) port
java -jar j3270.jar -s mainframe.example.com 992
# Or using standard x3270 L: prefix
java -jar j3270.jar L:mainframe.example.com:992
@@ -35,12 +49,19 @@ java -jar j3270.jar -P mainframe.example.com 23
java -jar j3270.jar P:mainframe.example.com:23
# Connect with unverified/self-signed certificate verification bypass
java -jar j3270.jar --tls --insecure mainframe.example.com 992
java -jar j3270.jar -s --insecure mainframe.example.com 992
# Launch with custom configuration file
# Enable GDDM/GOCA host graphics explicitly (or disable via --no-graphics)
java -jar j3270.jar -g GOCA mainframe.example.com
java -jar j3270.jar --no-graphics mainframe.example.com
# Launch with custom INI configuration profile
java -jar j3270.jar -c config.ini
# Launch directly from source runner
# Enable protocol and diagnostic debug logging to j3270.log
java -jar j3270.jar -d mainframe.example.com
# Launch directly from source runner script
./run.sh
```
@@ -48,39 +69,108 @@ java -jar j3270.jar -c config.ini
## ✨ Features
- **TN3270 & TN3270E Protocol Support**: RFC 2355 compliant state machine, negotiation, Device-Type query, plain TN3270 fallback, and SSL/TLS encryption.
- **SSL/TLS Security**:
- Encrypted TN3270 over TLS connections on standard port `992` or custom ports.
- Interactive certificate verification prompt for self-signed or untrusted certificates with fingerprint, subject, issuer, and validity inspection.
- Optional unverified/insecure mode for testing and headless scripting.
- **IND$FILE File Transfer**: Full support for both **CUT** and **DFT (DDM)** high-speed structured field transfers with ASCII/binary translation, CRLF handling, and recfm/lrecl formatting for TSO, VM/CMS, and CICS.
- **z/VM & Line-Mode Support**: Proper SSCP-LU and unformatted line handling (`CP TERM CONMODE 3270` supported).
- **APL & Graphic Escape**: Comprehensive box-drawing and math symbol character rendering.
- **Terminal Models**:
### 🌐 Protocol & Telnet Engine
- **TN3270 & TN3270E (RFC 2355)**: Full compliant finite-state machine, Device-Type negotiation, Functions negotiation (`BIND-IMAGE`, `RESPONSES`, `SYSREQ`), plain TN3270 fallback, and NVT (Network Virtual Terminal) mode.
- **SSCP-LU & Unformatted Line Mode**: Native z/VM line-mode support (`CP TERM CONMODE 3270`).
- **Terminal Models & Dynamic Geometry**:
- Model 2 (24x80)
- Model 3 (32x80)
- Model 4 (43x80)
- Model 5 (27x132)
- **Zero-Dependency Core**: Modular architecture separated into:
- `lib3270j`: Reusable Java 3270 protocol, data stream parser, charset translation, and telnet state machine.
- `j3270`: Desktop Swing user interface and session management.
- `IBM-DYNAMIC` (Model 0 dynamic geometry auto-negotiation via Usable Area Query Reply).
- **Enterprise Proxy Support**: SOCKS5 and HTTP CONNECT proxy tunneling with optional authentication.
### 🔒 Security & Encryption
- **Modern TLS 1.2 / 1.3**: Encrypted TN3270 over TLS on standard port `992` or custom ports via JSSE.
- **Interactive Certificate Verification**: Security dialog for untrusted or self-signed certificates displaying SHA-256 fingerprints, subject, issuer, and validity dates with user approval prompt.
- **Headless Insecure Mode**: `--insecure` / `-k` option for scripted environments and test mainframes.
### 🎨 GDDM / GOCA Host Vector Graphics (IBM 3179G)
- **Vector Drawing Orders**: Polylines, relative lines, circular & elliptical arcs, fillets, and multi-polygon area fills with boundary detection and alternate/winding rules.
- **1:1 Color Calibration**: 16-color GOCA palette and base 4-color mapping calibrated 1:1 against IBM Host On-Demand (HoD).
- **Shading, Patterns & Overlays**: Patterned area fills, geometric markers, and uncompressed / run-length bit-image and wallpaper overlays.
- **Vector Symbol Fonts (VSS)**: Stroked vector typography with baseline rotation angle (`GSCA`), shear (`GSCR`), and micro-scale anti-aliased sub-pixel rendering.
- **Programmed Symbols (PSS / PSA)**: Support for single-plane and triple-plane multi-color composite symbols.
- **Interactive Graphics Input**: Mouse click pick correlation mapping display coordinates to host graphics space, 56-byte Graphic Input structured field generation, and immediate/deferred light pen selection.
### 🖨️ IBM 3287/3286 Host Printing Emulation
- **Autonomous Printer Sessions**: TN3270E host print sessions supporting SCS (SNA Character String / LU-Type 1) and DSC/DSE (Data Stream Compatibility / LU-Type 3).
- **Double-Byte DBCS Printing**: Pitch adjustments, character spacing, and grid line formatting (`PrintSCS3270DB`, `PrintPS3270DB`).
- **Flexible Print Output**: Printer Definition Table (PDT) integration, print spooling to local files, or dispatch directly to system print queues.
### 📁 IND$FILE File Transfer & Host Catalog
- **Full Protocol Coverage**: Support for both **CUT** and high-speed **DFT (DDM)** structured field transfers for TSO, VM/CMS, and CICS.
- **Translation Modes**: Automatic ASCII/EBCDIC text translation, CRLF conversions, and transparent binary transfers.
- **Dataset Formatting**: Full control over Record Format (`RECFM` F/V/U), Logical Record Length (`LRECL`), Block Size (`BLKSIZE`), and Space allocation.
- **Host Catalog Browser**: Interactive directory dialog to query, parse, and browse CMS and TSO host dataset catalogs.
### 🌍 Internationalization & Character Sets
- **22+ Single-Byte EBCDIC Codepages**: US/Canada (037), Open Systems / z/OS Unix (1047), International (500, 1148 Euro), UK (285), Germany/Austria (273, 1141 Euro), France (297), Italy (280), Spain (284), Scandinavia (277, 278), Greece (875), Turkey (1026, 1155, 905), Eastern Europe (870), Iceland (871), Cyrillic (1025, 1123, 1154, 880), Hebrew (424, 803), Arabic (420), Thai (838, 1160).
- **Mixed DBCS Support**: Japanese Katakana (930), Japanese Latin (939), Simplified Chinese (935, 1388), Traditional Chinese (937, 1371), and Korean (933) with Shift-In (`0x0E`) / Shift-Out (`0x0F`) state handling.
- **APL & Special Graphics**: APL keyboard mode, box-drawing characters, and Graphic Escape (`GE`) symbol translations.
### 🏛️ IBM Host On-Demand ECL API Alignment
- Reusable public API compliant with IBM Host On-Demand (HoD) v14 specifications under `com.ibm.eNetwork.ECL.*` and `haus.nightmare.lib3270j.ecl.*`.
- Drop-in implementations for `ECLSession`, `ECLPS` (Presentation Space), `ECLOIA` (Operator Information Area), `ECLConnection`, `ECLField`, `ECLFieldList`, `ECLScreenDesc`, `ECLScreenReco`, and `ECLXfer`.
### 🖥️ Desktop User Interface (j3270)
- **Themes**: Modern Dark and Light UI themes with instant switching.
- **Dynamic Font Scaling**: Real-time font size adjustments (`Cmd/Ctrl +`, `Cmd/Ctrl -`, `Cmd/Ctrl 0`) with smooth scaling and guarded window repacking.
- **Screen Search**: Full-screen text search dialog (`Cmd/Ctrl + F`) with forward/backward navigation and case-sensitivity controls.
- **Screen Exporter**: Save screen images and content as plain text, HTML, or PNG.
- **Field Inspector**: Interactive inspection tool displaying 3270 field boundaries, buffer addresses, protection, highlighting, and color attributes.
- **Scripting & Keystroke Macros**: Interactive mnemonic keystroke playback dialog supporting standard x3270 action strings.
- **Word Processing Modes**: Document Mode (DOC), Word Wrap, audible margin bell, and tab stop navigation.
- **Visual Aids**: Crosshair ruler and configurable cursor styles (block cursor, underline cursor with alpha blending).
- **Persistent Profiles**: Configuration profiles and session preferences saved in INI files (`j3270.ini`).
### 📦 Modular Zero-Dependency Core
- **`lib3270j`**: Standalone Java protocol and terminal engine with **zero third-party dependencies** (`java.base`, `java.desktop` only).
- **`j3270`**: Standalone desktop Swing terminal application.
---
## 🛠️ Building from Source
### Standalone Build (No Gradle Required)
The project includes self-contained, portable build scripts:
### Quick Setup & Build for End Users
Self-contained setup scripts automatically locate an installed Java 11+ JDK (searching `JAVA_HOME`, system `PATH`, macOS `java_home`, Homebrew, Linux JVM paths, Windows Registry, standard installation directories, and package managers), compile `lib3270j` and `j3270`, and package the standalone executable JAR (`build/j3270.jar`):
**Linux / macOS (POSIX Shell):**
```bash
# Build executable JAR in 2 seconds:
sh ./build_all.sh
# Setup and build standalone executable JAR:
./setup.sh
# Run all 57 automated unit tests in ~500ms:
sh ./test_all.sh
# Build and immediately launch:
./setup.sh --run
# Clean build and connect to host:
./setup.sh --clean --run -- mainframe.example.com 23 4
```
The resulting standalone JAR is created at `build/j3270.jar`.
**Windows (PowerShell):**
```powershell
# Setup and build standalone executable JAR:
.\setup.ps1
# Build and immediately launch:
.\setup.ps1 -Run
# Clean build and connect to host:
.\setup.ps1 -Clean -Run -- mainframe.example.com 23 4
```
*(Also available as `.\setup.ps`)*
### Standalone Build (Advanced / CI)
The project also provides low-level scripts and optional Gradle support:
```bash
# Direct compile and package without diagnostics:
sh ./build_all.sh
# Run all 539+ automated unit tests (~7s):
sh ./test_all.sh
```
### Gradle Build (Optional)
```bash
@@ -88,6 +178,9 @@ The resulting standalone JAR is created at `build/j3270.jar`.
./gradlew test
```
### Continuous Integration
All pushes and pull requests are verified across **Java 11, 17, and 21 LTS** runners via Gitea Actions (`.gitea/workflows/build.yaml`).
---
## 📜 Acknowledgements
+2
View File
@@ -17,6 +17,7 @@ subprojects {
test {
useJUnitPlatform()
jvmArgs '-Djava.awt.headless=true'
testLogging {
events "passed", "skipped", "failed"
}
@@ -24,5 +25,6 @@ subprojects {
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
}
+23 -7
View File
@@ -14,16 +14,32 @@ if [ -n "$JAVA_HOME" ] && [ -x "$JAVA_HOME/bin/javac" ] && [ -x "$JAVA_HOME/bin/
JAVAC_BIN="$JAVA_HOME/bin/javac"
JAR_BIN="$JAVA_HOME/bin/jar"
elif command -v javac >/dev/null 2>&1 && command -v jar >/dev/null 2>&1; then
JAVAC_BIN="$(command -v javac)"
JAR_BIN="$(command -v jar)"
else
# Check common SDKMAN / macOS / Linux JDK locations
for h in "$HOME/.sdkman/candidates/java/current" \
if javac -version >/dev/null 2>&1; then
JAVAC_BIN="$(command -v javac)"
JAR_BIN="$(command -v jar)"
fi
fi
if [ -z "$JAVAC_BIN" ] || [ -z "$JAR_BIN" ]; then
if [ -x /usr/libexec/java_home ]; then
MAC_JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null || true)"
if [ -n "$MAC_JAVA_HOME" ] && [ -x "$MAC_JAVA_HOME/bin/javac" ] && [ -x "$MAC_JAVA_HOME/bin/jar" ]; then
export JAVA_HOME="$MAC_JAVA_HOME"
JAVAC_BIN="$JAVA_HOME/bin/javac"
JAR_BIN="$JAVA_HOME/bin/jar"
fi
fi
fi
if [ -z "$JAVAC_BIN" ] || [ -z "$JAR_BIN" ]; then
# Check standard system JDK installation locations
for h in /usr/lib/jvm/default-java \
/usr/lib/jvm/java-21-openjdk* \
/usr/lib/jvm/java-17-openjdk* \
/usr/lib/jvm/java-11-openjdk* \
/usr/lib/jvm/default-java \
/Library/Java/JavaVirtualMachines/*/Contents/Home; do
/Library/Java/JavaVirtualMachines/*/Contents/Home \
/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home \
/opt/homebrew/opt/openjdk; do
if [ -d "$h" ] && [ -x "$h/bin/javac" ] && [ -x "$h/bin/jar" ]; then
export JAVA_HOME="$h"
JAVAC_BIN="$JAVA_HOME/bin/javac"
+5 -5
View File
@@ -1,10 +1,10 @@
# Gitea Actions Runner Configuration for j3270
This repository includes Gitea Actions workflows in `.gitea/workflows/build.yaml` to automatically:
1. Compile and run the JUnit 5 test suite via `test_all.sh`.
2. Build the standalone `j3270.jar` executable via `build_all.sh`.
3. Upload `j3270.jar` as a downloadable artifact.
4. Report test results and diagnostics.
1. Compile and run the JUnit 5 test suite across the 3 LTS Java versions (Java 11, 17, and 21) via `test_all.sh`.
2. Build standalone executables for each LTS version via `build_all.sh`.
3. Upload `j3270-java11.jar`, `j3270-java17.jar`, and `j3270-java21.jar` as downloadable artifacts.
4. Report test results and diagnostics per runtime.
---
@@ -98,7 +98,7 @@ This means Docker's bridge network inside the runner container cannot resolve th
You can verify the entire build and test suite locally at any time without external tools:
```bash
# Compile and run all 25 unit tests (130ms):
# Compile and run all 539 unit tests (~7s):
sh ./test_all.sh
# Build executable standalone JAR:
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Vendored
+248 -1
View File
@@ -1,2 +1,249 @@
#!/bin/sh
exec "$(dirname "$0")/build_all.sh" "$@"
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@@ -45,6 +45,17 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
private FieldInspectorDialog fieldInspectorDialog;
private PrinterSessionDialog printerSessionDialog;
// Modes menu items
private JCheckBoxMenuItem docModeItem;
private JCheckBoxMenuItem wordWrapItem;
private JCheckBoxMenuItem aplModeItem;
private JCheckBoxMenuItem insertModeItem;
private JCheckBoxMenuItem fourColorItem;
private JCheckBoxMenuItem numLockItem;
private JCheckBoxMenuItem autoSkipItem;
private JCheckBoxMenuItem insertOffAidItem;
private JCheckBoxMenuItem statusBarItem;
public J3270App() {
super("j3270 — Java TN3270 Terminal Emulator");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
@@ -59,12 +70,15 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
setLocationRelativeTo(null);
setMinimumSize(new Dimension(640, 400));
// Status refresh timer — also ensures focus stays on terminal
// Status refresh timer — also ensures focus stays on terminal (guarding against focus stealing)
refreshTimer = new Timer(100, e -> {
if (client != null) {
statusBar.updateStatus();
if (isActive() && !terminalPanel.hasFocus()) {
terminalPanel.requestFocusInWindow();
syncModeMenuItems();
if (!isAnyChildDialogActive() && KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow() == this) {
if (!terminalPanel.hasFocus()) {
terminalPanel.requestFocusInWindow();
}
}
}
});
@@ -84,14 +98,38 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
@Override
public void windowActivated(WindowEvent e) {
terminalPanel.requestFocusInWindow();
if (!isAnyChildDialogActive()) {
terminalPanel.requestFocusInWindow();
}
}
});
}
private boolean isAnyChildDialogActive() {
Window focusedWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow();
if (focusedWindow != null && focusedWindow != this) {
return true;
}
if (findDialog != null && findDialog.isVisible()) return true;
if (scriptDialog != null && scriptDialog.isVisible()) return true;
if (fieldInspectorDialog != null && fieldInspectorDialog.isVisible()) return true;
if (printerSessionDialog != null && printerSessionDialog.isVisible()) return true;
for (Window owned : getOwnedWindows()) {
if (owned != null && owned.isVisible()) {
return true;
}
}
return false;
}
private void buildUI() {
terminalPanel = new TerminalPanel();
statusBar = new StatusBar();
terminalPanel.setStatusBar(statusBar);
terminalPanel.setStatusBarToggleCallback(this::toggleStatusBar);
terminalPanel.addModeChangeListener(this::syncModeMenuItems);
statusBar.setVisible(haus.nightmare.j3270.config.Settings.getStatusBarVisible());
getContentPane().setLayout(new BorderLayout());
getContentPane().setBackground(Color.BLACK);
@@ -99,6 +137,17 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
getContentPane().add(statusBar, BorderLayout.SOUTH);
}
private void syncModeMenuItems() {
if (docModeItem != null) docModeItem.setSelected(terminalPanel.isDocMode());
if (wordWrapItem != null) wordWrapItem.setSelected(terminalPanel.isWordWrap());
if (aplModeItem != null) aplModeItem.setSelected(terminalPanel.isAplMode());
if (insertModeItem != null) insertModeItem.setSelected(terminalPanel.isInsertMode());
if (fourColorItem != null) fourColorItem.setSelected(haus.nightmare.j3270.config.Settings.getFourColorOverride());
if (numLockItem != null) numLockItem.setSelected(haus.nightmare.j3270.config.Settings.getNumericFieldLock());
if (autoSkipItem != null) autoSkipItem.setSelected(haus.nightmare.j3270.config.Settings.getAutoSkipEnabled());
if (insertOffAidItem != null) insertOffAidItem.setSelected(haus.nightmare.j3270.config.Settings.getInsertOffOnAid());
}
private void onThemeChanged(UITheme theme) {
buildMenuBar();
statusBar.applyTheme(theme);
@@ -178,6 +227,12 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
viewMenu.add(themeMenu);
viewMenu.addSeparator();
statusBarItem = new JCheckBoxMenuItem("Status Bar", statusBar != null ? statusBar.isVisible() : haus.nightmare.j3270.config.Settings.getStatusBarVisible());
ThemeManager.styleMenuItem(statusBarItem);
statusBarItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_B, InputEvent.ALT_DOWN_MASK));
statusBarItem.addActionListener(e -> setStatusBarVisible(statusBarItem.isSelected()));
viewMenu.add(statusBarItem);
JCheckBoxMenuItem rulerItem = new JCheckBoxMenuItem("Crosshair Ruler", terminalPanel.isCrosshairRulerEnabled());
ThemeManager.styleMenuItem(rulerItem);
rulerItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_R, InputEvent.ALT_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK));
@@ -259,7 +314,77 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
viewMenu.add(createMenuItem("Field Inspector...", -1, this::showFieldInspectorDialog));
menuBar.add(viewMenu);
// 4. Actions menu
// 4. Modes menu
JMenu modesMenu = createMenu("Modes");
docModeItem = new JCheckBoxMenuItem("Document Mode (DOC)", terminalPanel.isDocMode());
ThemeManager.styleMenuItem(docModeItem);
docModeItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F1, InputEvent.ALT_DOWN_MASK));
docModeItem.addActionListener(e -> terminalPanel.toggleDocMode());
modesMenu.add(docModeItem);
wordWrapItem = new JCheckBoxMenuItem("Word Wrap Mode", terminalPanel.isWordWrap());
ThemeManager.styleMenuItem(wordWrapItem);
wordWrapItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F2, InputEvent.ALT_DOWN_MASK));
wordWrapItem.addActionListener(e -> terminalPanel.toggleWordWrap());
modesMenu.add(wordWrapItem);
aplModeItem = new JCheckBoxMenuItem("APL Keyboard Mode", terminalPanel.isAplMode());
ThemeManager.styleMenuItem(aplModeItem);
aplModeItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.ALT_DOWN_MASK));
aplModeItem.addActionListener(e -> terminalPanel.toggleAplMode());
modesMenu.add(aplModeItem);
insertModeItem = new JCheckBoxMenuItem("Insert Mode", terminalPanel.isInsertMode());
ThemeManager.styleMenuItem(insertModeItem);
insertModeItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, 0));
insertModeItem.addActionListener(e -> terminalPanel.toggleInsertMode());
modesMenu.add(insertModeItem);
modesMenu.addSeparator();
fourColorItem = new JCheckBoxMenuItem("Base 4-Color Override", haus.nightmare.j3270.config.Settings.getFourColorOverride());
ThemeManager.styleMenuItem(fourColorItem);
fourColorItem.addActionListener(e -> {
haus.nightmare.j3270.config.Settings.setFourColorOverride(fourColorItem.isSelected());
terminalPanel.repaint();
});
modesMenu.add(fourColorItem);
numLockItem = new JCheckBoxMenuItem("Numeric Field Lock", haus.nightmare.j3270.config.Settings.getNumericFieldLock());
ThemeManager.styleMenuItem(numLockItem);
numLockItem.addActionListener(e -> {
haus.nightmare.j3270.config.Settings.setNumericFieldLock(numLockItem.isSelected());
terminalPanel.applyModeSettings();
});
modesMenu.add(numLockItem);
autoSkipItem = new JCheckBoxMenuItem("Auto-Skip Across Fields", haus.nightmare.j3270.config.Settings.getAutoSkipEnabled());
ThemeManager.styleMenuItem(autoSkipItem);
autoSkipItem.addActionListener(e -> {
haus.nightmare.j3270.config.Settings.setAutoSkipEnabled(autoSkipItem.isSelected());
terminalPanel.applyModeSettings();
});
modesMenu.add(autoSkipItem);
insertOffAidItem = new JCheckBoxMenuItem("Reset Insert on AID Key", haus.nightmare.j3270.config.Settings.getInsertOffOnAid());
ThemeManager.styleMenuItem(insertOffAidItem);
insertOffAidItem.addActionListener(e -> {
haus.nightmare.j3270.config.Settings.setInsertOffOnAid(insertOffAidItem.isSelected());
terminalPanel.applyModeSettings();
});
modesMenu.add(insertOffAidItem);
modesMenu.addMenuListener(new javax.swing.event.MenuListener() {
@Override
public void menuSelected(javax.swing.event.MenuEvent e) {
syncModeMenuItems();
}
@Override public void menuDeselected(javax.swing.event.MenuEvent e) {}
@Override public void menuCanceled(javax.swing.event.MenuEvent e) {}
});
menuBar.add(modesMenu);
// 5. Actions menu
JMenu actionsMenu = createMenu("Actions");
actionsMenu.add(createMenuItem("Send Enter", KeyEvent.VK_ENTER, () -> {
if (client != null && client.getConnectionState().isFullSession()) {
@@ -639,12 +764,26 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
fileTransfer.cancel();
fileTransfer = null;
}
client.disconnect();
Telnet3270Client c = client;
client = null;
terminalPanel.setClient(null);
statusBar.setClient(null, terminalPanel);
terminalPanel.repaint();
setTitle("j3270 — Java TN3270 Terminal Emulator");
new Thread(() -> {
try {
c.disconnect();
} catch (Exception ignored) {}
}, "Disconnect-Thread").start();
Runnable uiReset = () -> {
terminalPanel.setClient(null);
statusBar.setClient(null, terminalPanel);
syncModeMenuItems();
terminalPanel.repaint();
setTitle("j3270 — Java TN3270 Terminal Emulator");
};
if (SwingUtilities.isEventDispatchThread()) {
uiReset.run();
} else {
SwingUtilities.invokeLater(uiReset);
}
}
}
@@ -655,6 +794,27 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
terminalPanel.guardedPack();
}
public void toggleStatusBar() {
if (statusBar != null) {
setStatusBarVisible(!statusBar.isVisible());
}
}
public void setStatusBarVisible(boolean visible) {
if (statusBar != null) {
statusBar.setVisible(visible);
}
haus.nightmare.j3270.config.Settings.setStatusBarVisible(visible);
if (statusBarItem != null) {
statusBarItem.setSelected(visible);
}
getContentPane().revalidate();
getContentPane().repaint();
if ((getExtendedState() & Frame.MAXIMIZED_BOTH) == 0) {
terminalPanel.guardedPack();
}
}
// ========== ConnectionListener ==========
@Override
@@ -664,7 +824,16 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
statusBar.updateStatus();
terminalPanel.repaint();
if (newState.isFullSession() && !oldState.isFullSession()) {
if (newState == ConnectionState.RECONNECTING) {
setTitle("j3270 — " + lastHost + ":" + lastPort + " [Reconnecting...]");
} else if (newState == ConnectionState.NOT_CONNECTED) {
setTitle("j3270 — Java TN3270 Terminal Emulator");
} else if (newState.isFullSession() && !oldState.isFullSession()) {
String tlsIndicator = (client != null && client.getConfig().isUseTls()) ?
(client.getConfig().isTlsVerifyCert() ? " [TLS]" : " [TLS/Unverified]") : "";
String dev = (client != null && client.getTelnetFSM() != null && client.getTelnetFSM().getConnectedLu() != null) ?
" [" + client.getTelnetFSM().getConnectedLu() + "]" : "";
setTitle("j3270 — " + lastHost + ":" + lastPort + tlsIndicator + dev);
terminalPanel.guardedPack();
terminalPanel.requestFocusInWindow();
}
@@ -703,6 +872,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
}
terminalPanel.repaint();
statusBar.updateStatus();
syncModeMenuItems();
});
}
}
@@ -720,6 +890,19 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
});
}
@Override
public void onCursorMoved(int oldAddress, int newAddress) {
SwingUtilities.invokeLater(() -> {
terminalPanel.repaint();
statusBar.updateStatus();
});
}
@Override
public void onKeyboardUnlocked() {
SwingUtilities.invokeLater(statusBar::updateStatus);
}
// ========== Help dialogs ==========
private void showKeyMappings() {
@@ -735,7 +918,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
"End — Erase to end of field\n" +
"Delete — Delete character\n" +
"Backspace — Backspace\n" +
"Insert — Toggle insert mode\n" +
"Insert / Help / Alt+I — Toggle insert mode\n" +
"Escape / Alt+R — Reset\n" +
"PageUp/Down — PF7/PF8\n" +
"Alt+C / Alt+K — Clear\n" +
@@ -746,7 +929,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
"Alt+L — Toggle Light Pen\n" +
"Alt+T — File Transfer\n" +
"Alt+Shift+R — Crosshair Ruler\n" +
"Alt+B — Toggle Status Bar\n" +
"Alt+=/-/0 — Font size +/-/reset\n" +
"Cmd/Ctrl+C / Ctrl+Ins — Copy\n" +
"Cmd/Ctrl+V / Shift+Ins — Paste\n" +
"Cmd/Ctrl+A — Select All\n" +
"Cmd/Ctrl+F — Find on Screen\n" +
"Cmd/Ctrl+G / F3— Find Next\n" +
"Cmd/Ctrl+D — Disconnect\n" +
@@ -818,6 +1005,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
boolean cliTls = false;
boolean cliNoVerifyCert = false;
Boolean cliTn3270e = null;
Boolean cliAutoSysUnlock = null;
GraphicsMode cliGraphicsMode = null;
String configFile = null;
@@ -834,6 +1022,10 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
cliTn3270e = false;
} else if ("--tn3270e".equals(arg)) {
cliTn3270e = true;
} else if ("--auto-sys-unlock".equals(arg)) {
cliAutoSysUnlock = true;
} else if ("--no-auto-sys-unlock".equals(arg)) {
cliAutoSysUnlock = false;
} else if (arg.startsWith("--graphics=")) {
cliGraphicsMode = GraphicsMode.fromString(arg.substring(11));
} else if (("--graphics".equals(arg) || "-g".equals(arg)) && i + 1 < args.length && !args[i + 1].startsWith("-")) {
@@ -910,6 +1102,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
final boolean finalTls = cliTls;
final boolean finalNoVerify = cliNoVerifyCert;
final Boolean finalTn3270e = cliTn3270e;
final Boolean finalAutoSysUnlock = cliAutoSysUnlock;
final GraphicsMode finalGraphicsMode = cliGraphicsMode;
SwingUtilities.invokeLater(() -> {
@@ -946,6 +1139,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
if (finalTn3270e != null) {
config.setTn3270eEnabled(finalTn3270e);
}
if (finalAutoSysUnlock != null) {
config.setAutoSysUnlock(finalAutoSysUnlock);
} else {
config.setAutoSysUnlock(haus.nightmare.j3270.config.Settings.getAutoSysUnlock());
}
if (finalGraphicsMode != null) {
config.setGraphicsMode(finalGraphicsMode);
} else {
@@ -969,6 +1167,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
ConnectionConfig config = new ConnectionConfig(host, port, TerminalModel.IBM_3279_4, tls);
config.setTlsVerifyCert(verify);
config.setTn3270eEnabled(finalTn3270e != null ? finalTn3270e : tn3270e);
config.setAutoSysUnlock(finalAutoSysUnlock != null ? finalAutoSysUnlock : haus.nightmare.j3270.config.Settings.getAutoSysUnlock());
if (finalGraphicsMode != null) {
config.setGraphicsMode(finalGraphicsMode);
} else {
@@ -33,6 +33,7 @@ public class Settings {
public static void setFontFamily(String family) {
prefs.put("fontFamily", family);
flushPrefs();
}
public static int getFontSize() {
@@ -41,6 +42,14 @@ public class Settings {
public static void setFontSize(int size) {
prefs.putInt("fontSize", size);
flushPrefs();
}
private static void flushPrefs() {
try {
prefs.flush();
} catch (Exception ignored) {
}
}
public static StartupBehavior getStartupBehavior() {
@@ -96,6 +105,51 @@ public class Settings {
prefs.putBoolean("autoConnectTn3270e", tn3270e);
}
public static boolean getAutoSysUnlock() {
return prefs.getBoolean("autoSysUnlock", true);
}
public static void setAutoSysUnlock(boolean autoSysUnlock) {
prefs.putBoolean("autoSysUnlock", autoSysUnlock);
flushPrefs();
}
public static boolean getAutoConnectKeepAlive() {
return prefs.getBoolean("autoConnectKeepAlive", true);
}
public static void setAutoConnectKeepAlive(boolean keepAlive) {
prefs.putBoolean("autoConnectKeepAlive", keepAlive);
flushPrefs();
}
public static int getAutoConnectKeepAliveInterval() {
return prefs.getInt("autoConnectKeepAliveInterval", 120);
}
public static void setAutoConnectKeepAliveInterval(int interval) {
prefs.putInt("autoConnectKeepAliveInterval", interval);
flushPrefs();
}
public static boolean getAutoConnectAutoReconnect() {
return prefs.getBoolean("autoConnectAutoReconnect", false);
}
public static void setAutoConnectAutoReconnect(boolean autoReconnect) {
prefs.putBoolean("autoConnectAutoReconnect", autoReconnect);
flushPrefs();
}
public static int getAutoConnectReconnectMaxRetries() {
return prefs.getInt("autoConnectReconnectMaxRetries", 5);
}
public static void setAutoConnectReconnectMaxRetries(int retries) {
prefs.putInt("autoConnectReconnectMaxRetries", retries);
flushPrefs();
}
public static haus.nightmare.lib3270j.graphics.GraphicsMode getGraphicsMode() {
String modeStr = prefs.get("graphicsMode", haus.nightmare.lib3270j.graphics.GraphicsMode.BOTH.name());
return haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(modeStr);
@@ -155,8 +209,61 @@ public class Settings {
prefs.put("mono_" + key, String.format("#%02x%02x%02x", color.getRed(), color.getGreen(), color.getBlue()));
}
public static boolean isMac() {
String os = System.getProperty("os.name");
return os != null && os.toLowerCase().contains("mac");
}
public static String getDefaultBinding(String action) {
boolean mac = isMac();
switch (action) {
case "COPY":
return mac ? "meta C, ctrl C, ctrl INSERT, ctrl shift C" : "ctrl C, ctrl INSERT, ctrl shift C";
case "PASTE":
return mac ? "meta V, ctrl V, shift INSERT, ctrl shift V" : "ctrl V, shift INSERT, ctrl shift V";
case "SELECTALL":
return mac ? "meta A, ctrl A" : "ctrl A";
case "INSERT":
return mac ? "INSERT, HELP, alt I" : "INSERT, ctrl I";
case "ERASE_INPUT":
return "alt E";
case "NEWLINE":
return "shift ENTER";
case "DUP":
return "alt D";
case "FIELD_MARK":
return "alt M";
case "ATTN":
return "alt A";
case "SYSREQ":
return "alt S";
case "CURSEL":
return "alt Q";
case "CLEAR":
return "alt C";
case "STATUS_BAR":
return "alt B";
default:
return action;
}
}
public static String getKeyBinding(String action, String defaultBinding) {
return prefs.get("key_" + action, defaultBinding);
String val = prefs.get("key_" + action, null);
if (val == null) {
return defaultBinding;
}
// If stored binding is the legacy single-key "INSERT" default, migrate it to the current platform default
if ("INSERT".equals(action) && "INSERT".equals(val)) {
prefs.put("key_INSERT", defaultBinding);
return defaultBinding;
}
// If stored binding is the legacy or corrupted "alt STE" for PASTE, migrate it to the current platform default
if ("PASTE".equals(action) && ("alt STE".equals(val) || "PASTE".equals(val))) {
prefs.put("key_PASTE", defaultBinding);
return defaultBinding;
}
return val;
}
public static void setKeyBinding(String action, String binding) {
@@ -249,6 +356,26 @@ public class Settings {
prefs.putBoolean("blockSelectMode", block);
}
// ========== Clipboard & Tabular Paste ==========
public static boolean getEnablePasteFromExcel() {
return prefs.getBoolean("enablePasteFromExcel", true);
}
public static void setEnablePasteFromExcel(boolean val) {
prefs.putBoolean("enablePasteFromExcel", val);
flushPrefs();
}
public static boolean getPasteStopAtProtectedLine() {
return prefs.getBoolean("pasteStopAtProtectedLine", false);
}
public static void setPasteStopAtProtectedLine(boolean val) {
prefs.putBoolean("pasteStopAtProtectedLine", val);
flushPrefs();
}
// ========== Crosshair Ruler ==========
public static boolean getCrosshairRuler() {
@@ -259,6 +386,16 @@ public class Settings {
prefs.putBoolean("crosshairRuler", enabled);
}
// ========== Status Bar Visibility ==========
public static boolean getStatusBarVisible() {
return prefs.getBoolean("statusBarVisible", true);
}
public static void setStatusBarVisible(boolean visible) {
prefs.putBoolean("statusBarVisible", visible);
}
// ========== Cursor Style ==========
public static String getCursorStyle() {
@@ -269,6 +406,101 @@ public class Settings {
prefs.put("cursorStyle", style != null ? style.toUpperCase() : "BLOCK");
}
// ========== Entry Assist & Modes ==========
public static boolean getEntryAssistDocMode() {
return prefs.getBoolean("entryAssistDocMode", false);
}
public static void setEntryAssistDocMode(boolean docMode) {
prefs.putBoolean("entryAssistDocMode", docMode);
}
public static boolean getEntryAssistWordWrap() {
return prefs.getBoolean("entryAssistWordWrap", false);
}
public static void setEntryAssistWordWrap(boolean wordWrap) {
prefs.putBoolean("entryAssistWordWrap", wordWrap);
}
public static int getEntryAssistStartCol() {
return prefs.getInt("entryAssistStartCol", 1);
}
public static void setEntryAssistStartCol(int startCol) {
prefs.putInt("entryAssistStartCol", startCol);
}
public static int getEntryAssistEndCol() {
return prefs.getInt("entryAssistEndCol", 80);
}
public static void setEntryAssistEndCol(int endCol) {
prefs.putInt("entryAssistEndCol", endCol);
}
public static boolean getEntryAssistBell() {
return prefs.getBoolean("entryAssistBell", false);
}
public static void setEntryAssistBell(boolean bell) {
prefs.putBoolean("entryAssistBell", bell);
}
public static int getEntryAssistBellCol() {
return prefs.getInt("entryAssistBellCol", 75);
}
public static void setEntryAssistBellCol(int col) {
prefs.putInt("entryAssistBellCol", col);
}
public static String getEntryAssistTabStops() {
return prefs.get("entryAssistTabStops", "5,10,15,20,25,30,35,40,45,50,55,60,65,70,75");
}
public static void setEntryAssistTabStops(String stops) {
prefs.put("entryAssistTabStops", stops != null ? stops : "");
}
public static int[] getEntryAssistTabStopsArray() {
String s = getEntryAssistTabStops();
if (s == null || s.trim().isEmpty()) return new int[0];
String[] parts = s.split(",");
java.util.List<Integer> list = new java.util.ArrayList<>();
for (String p : parts) {
try {
int val = Integer.parseInt(p.trim());
if (val > 0) list.add(val - 1);
} catch (NumberFormatException ignored) {}
}
int[] res = new int[list.size()];
for (int i = 0; i < list.size(); i++) res[i] = list.get(i);
return res;
}
public static boolean getInsertOffOnAid() {
return prefs.getBoolean("insertOffOnAid", true);
}
public static void setInsertOffOnAid(boolean val) {
prefs.putBoolean("insertOffOnAid", val);
}
public static boolean getFourColorOverride() {
return prefs.getBoolean("fourColorOverride", false);
}
public static void setFourColorOverride(boolean val) {
prefs.putBoolean("fourColorOverride", val);
}
public static boolean getNumericFieldLock() {
return prefs.getBoolean("numericFieldLock", true);
}
public static void setNumericFieldLock(boolean val) {
prefs.putBoolean("numericFieldLock", val);
}
public static boolean getAutoSkipEnabled() {
return prefs.getBoolean("autoSkipEnabled", true);
}
public static void setAutoSkipEnabled(boolean val) {
prefs.putBoolean("autoSkipEnabled", val);
}
private static void applyConfigEntry(String section, String key, String value) {
switch (section) {
case "appearance":
@@ -284,6 +516,12 @@ public class Settings {
case "ruler":
setCrosshairRuler(Boolean.parseBoolean(value));
break;
case "statusBarVisible":
case "statusbar":
case "statusBar":
case "showStatusBar":
setStatusBarVisible(Boolean.parseBoolean(value));
break;
case "cursorStyle":
setCursorStyle(value);
break;
@@ -336,11 +574,42 @@ public class Settings {
setDynamicCols(Integer.parseInt(value));
break;
case "blockSelectMode": setBlockSelectMode(Boolean.parseBoolean(value)); break;
case "autoSysUnlock":
case "auto_sys_unlock":
setAutoSysUnlock(Boolean.parseBoolean(value));
break;
case "enablePasteFromExcel":
case "pasteFromExcel":
case "excelPaste":
setEnablePasteFromExcel(Boolean.parseBoolean(value));
break;
case "pasteStopAtProtectedLine":
case "stopAtProtected":
case "pasteStopAtProtected":
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
break;
default:
log.warning("Unknown behavior/connection key: " + key);
}
break;
case "clipboard":
switch (key) {
case "enablePasteFromExcel":
case "pasteFromExcel":
case "excelPaste":
setEnablePasteFromExcel(Boolean.parseBoolean(value));
break;
case "pasteStopAtProtectedLine":
case "stopAtProtected":
case "pasteStopAtProtected":
setPasteStopAtProtectedLine(Boolean.parseBoolean(value));
break;
default:
log.warning("Unknown clipboard key: " + key);
}
break;
case "colors":
if (key.startsWith("color_")) {
int index = Integer.parseInt(key.substring(6));
@@ -365,6 +634,55 @@ public class Settings {
}
break;
case "entryassist":
case "modes":
switch (key) {
case "docMode":
case "entryAssistDocMode":
setEntryAssistDocMode(Boolean.parseBoolean(value));
break;
case "wordWrap":
case "entryAssistWordWrap":
setEntryAssistWordWrap(Boolean.parseBoolean(value));
break;
case "startCol":
case "entryAssistStartCol":
setEntryAssistStartCol(Integer.parseInt(value));
break;
case "endCol":
case "entryAssistEndCol":
setEntryAssistEndCol(Integer.parseInt(value));
break;
case "bell":
case "entryAssistBell":
setEntryAssistBell(Boolean.parseBoolean(value));
break;
case "bellCol":
case "entryAssistBellCol":
setEntryAssistBellCol(Integer.parseInt(value));
break;
case "tabStops":
case "entryAssistTabStops":
setEntryAssistTabStops(value);
break;
case "insertOffOnAid":
setInsertOffOnAid(Boolean.parseBoolean(value));
break;
case "fourColorOverride":
setFourColorOverride(Boolean.parseBoolean(value));
break;
case "numericFieldLock":
setNumericFieldLock(Boolean.parseBoolean(value));
break;
case "autoSkip":
case "autoSkipEnabled":
setAutoSkipEnabled(Boolean.parseBoolean(value));
break;
default:
log.warning("Unknown entryassist/modes key: " + key);
}
break;
default:
// Allow bare keys outside any section — treat as raw prefs
log.fine("Setting raw preference: " + key + " = " + value);
@@ -392,6 +710,7 @@ public class Settings {
w.println("fontFamily = " + getFontFamily());
w.println("fontSize = " + getFontSize());
w.println("crosshairRuler = " + getCrosshairRuler());
w.println("statusBarVisible = " + getStatusBarVisible());
w.println("cursorStyle = " + getCursorStyle());
w.println();
@@ -410,6 +729,28 @@ public class Settings {
w.println("dynamicRows = " + getDynamicRows());
w.println("dynamicCols = " + getDynamicCols());
w.println("blockSelectMode = " + getBlockSelectMode());
w.println("autoSysUnlock = " + getAutoSysUnlock());
w.println("enablePasteFromExcel = " + getEnablePasteFromExcel());
w.println("pasteStopAtProtectedLine = " + getPasteStopAtProtectedLine());
w.println();
// [entryassist]
w.println("[entryassist]");
w.println("docMode = " + getEntryAssistDocMode());
w.println("wordWrap = " + getEntryAssistWordWrap());
w.println("startCol = " + getEntryAssistStartCol());
w.println("endCol = " + getEntryAssistEndCol());
w.println("bell = " + getEntryAssistBell());
w.println("bellCol = " + getEntryAssistBellCol());
w.println("tabStops = " + getEntryAssistTabStops());
w.println();
// [modes]
w.println("[modes]");
w.println("insertOffOnAid = " + getInsertOffOnAid());
w.println("fourColorOverride = " + getFourColorOverride());
w.println("numericFieldLock = " + getNumericFieldLock());
w.println("autoSkip = " + getAutoSkipEnabled());
w.println();
// [graphics]
@@ -438,9 +779,10 @@ public class Settings {
// [keybindings]
w.println("[keybindings]");
// Navigation keys
// Navigation & clipboard keys
String[] navActions = {"ENTER", "TAB", "shift TAB", "UP", "DOWN", "LEFT", "RIGHT",
"HOME", "END", "PAGE_UP", "PAGE_DOWN", "ESCAPE", "INSERT", "DELETE", "BACK_SPACE", "CLEAR"};
"HOME", "END", "PAGE_UP", "PAGE_DOWN", "ESCAPE", "INSERT", "DELETE", "BACK_SPACE", "CLEAR",
"STATUS_BAR", "COPY", "PASTE", "SELECTALL"};
for (String act : navActions) {
String val = prefs.get("key_" + act, null);
if (val != null) {
@@ -12,6 +12,50 @@ import java.io.File;
public class FileTransferDialog extends JDialog {
public static class TransferSessionState {
public FTConfig.HostType hostType = FTConfig.HostType.TSO;
public boolean isSend = false;
public String localFile = "";
public String hostFile = "";
public boolean isAscii = true;
public int mtu = FTConstants.DFT_BUF;
public boolean crFlag = true;
public boolean remapFlag = true;
public boolean append = false;
public boolean overwrite = false;
public String recfm = "";
public String lrecl = "";
public String blksize = "";
public String space = "";
public String options = "";
}
private static TransferSessionState lastTransferState = null;
public static TransferSessionState getLastTransferState() {
return lastTransferState;
}
public static void setLastTransferState(TransferSessionState state) {
lastTransferState = state;
}
public static void resetSessionState() {
lastTransferState = null;
}
public static String formatHostFilename(String localPath, FTConfig.HostType hostType) {
if (localPath == null || localPath.trim().isEmpty()) {
return "";
}
File f = new File(localPath.trim());
String name = f.getName();
if (hostType == FTConfig.HostType.CMS) {
name = name.replace('.', ' ');
}
return name;
}
private final FileTransfer coordinator;
private final Frame owner;
@@ -66,6 +110,16 @@ public class FileTransferDialog extends JDialog {
// Host Type
hostTypeCombo = new JComboBox<>(FTConfig.HostType.values());
hostTypeCombo.addActionListener(e -> {
updateOptionStates();
FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
if (hostType == FTConfig.HostType.CMS) {
String hostText = hostFileField.getText().trim();
if (hostText.contains(".")) {
hostFileField.setText(hostText.replace('.', ' '));
}
}
});
formPanel.add(createRow("Host Environment:", hostTypeCombo));
// Direction
@@ -88,6 +142,15 @@ public class FileTransferDialog extends JDialog {
// Local File
localFileField = new JTextField(20);
localFileField.addFocusListener(new java.awt.event.FocusAdapter() {
@Override
public void focusLost(java.awt.event.FocusEvent e) {
if (hostFileField.getText().trim().isEmpty() && !localFileField.getText().trim().isEmpty()) {
FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
hostFileField.setText(formatHostFilename(localFileField.getText(), hostType));
}
}
});
browseLocalButton = new JButton("Browse...");
ThemeManager.styleButton(browseLocalButton, ThemeManager.ButtonVariant.DEFAULT);
browseLocalButton.addActionListener(e -> browseLocalFile());
@@ -211,13 +274,47 @@ public class FileTransferDialog extends JDialog {
setContentPane(mainPanel);
prefillFromLastTransfer();
ThemeManager.applyThemeToWindow(this);
updateOptionStates();
}
private void prefillFromLastTransfer() {
if (lastTransferState != null) {
if (lastTransferState.hostType != null) {
hostTypeCombo.setSelectedItem(lastTransferState.hostType);
}
sendRadio.setSelected(lastTransferState.isSend);
receiveRadio.setSelected(!lastTransferState.isSend);
if (lastTransferState.localFile != null) {
localFileField.setText(lastTransferState.localFile);
}
if (lastTransferState.hostFile != null) {
hostFileField.setText(lastTransferState.hostFile);
}
asciiRadio.setSelected(lastTransferState.isAscii);
binaryRadio.setSelected(!lastTransferState.isAscii);
if (lastTransferState.mtu > 0) {
mtuCombo.setSelectedItem(lastTransferState.mtu);
}
crCheck.setSelected(lastTransferState.crFlag);
remapCheck.setSelected(lastTransferState.remapFlag);
appendCheck.setSelected(lastTransferState.append);
overwriteCheck.setSelected(lastTransferState.overwrite);
if (lastTransferState.recfm != null) recfmField.setText(lastTransferState.recfm);
if (lastTransferState.lrecl != null) lreclField.setText(lastTransferState.lrecl);
if (lastTransferState.blksize != null) blksizeField.setText(lastTransferState.blksize);
if (lastTransferState.space != null) spaceField.setText(lastTransferState.space);
if (lastTransferState.options != null) optionsField.setText(lastTransferState.options);
}
}
private void updateOptionStates() {
boolean isSend = sendRadio.isSelected();
boolean isAscii = asciiRadio.isSelected();
FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
boolean isTso = (hostType == FTConfig.HostType.TSO);
boolean isCms = (hostType == FTConfig.HostType.CMS);
appendCheck.setEnabled(!isSend);
overwriteCheck.setEnabled(!isSend);
@@ -225,10 +322,11 @@ public class FileTransferDialog extends JDialog {
crCheck.setEnabled(isAscii);
remapCheck.setEnabled(isAscii);
recfmField.setEnabled(isSend);
lreclField.setEnabled(isSend);
blksizeField.setEnabled(isSend);
spaceField.setEnabled(isSend);
recfmField.setEnabled(isSend && isTso);
lreclField.setEnabled(isSend && isTso);
blksizeField.setEnabled(isSend && isTso);
spaceField.setEnabled(isSend && isTso);
optionsField.setEnabled(isCms);
}
private JPanel createRow(String labelText, Component comp) {
@@ -245,9 +343,13 @@ public class FileTransferDialog extends JDialog {
private void browseLocalFile() {
JFileChooser chooser = new JFileChooser();
if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) {
localFileField.setText(chooser.getSelectedFile().getAbsolutePath());
if (hostFileField.getText().trim().isEmpty()) {
hostFileField.setText(chooser.getSelectedFile().getName());
File selected = chooser.getSelectedFile();
localFileField.setText(selected.getAbsolutePath());
FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
boolean shouldAutofill = hostFileField.getText().trim().isEmpty() ||
(lastTransferState != null && hostFileField.getText().trim().equals(lastTransferState.hostFile));
if (shouldAutofill) {
hostFileField.setText(formatHostFilename(selected.getName(), hostType));
}
}
}
@@ -260,10 +362,21 @@ public class FileTransferDialog extends JDialog {
if (dialog.isConfirmed() && dialog.getSelectedHostFile() != null) {
hostFileField.setText(dialog.getSelectedHostFile());
if (localFileField.getText().trim().isEmpty()) {
boolean shouldAutofill = localFileField.getText().trim().isEmpty() ||
(lastTransferState != null && localFileField.getText().trim().equals(lastTransferState.localFile));
if (shouldAutofill) {
String cleanName = dialog.getSelectedHostFile().replace("'", "").replace("\"", "");
int lastDot = cleanName.lastIndexOf('.');
if (lastDot > 0) cleanName = cleanName.substring(lastDot + 1);
if (hostType == FTConfig.HostType.CMS) {
String[] tokens = cleanName.trim().split("\\s+");
if (tokens.length >= 2) {
cleanName = tokens[0].toLowerCase() + "." + tokens[1].toLowerCase();
} else if (tokens.length == 1) {
cleanName = tokens[0].toLowerCase();
}
} else {
int lastDot = cleanName.lastIndexOf('.');
if (lastDot > 0) cleanName = cleanName.substring(lastDot + 1);
}
localFileField.setText(cleanName);
}
}
@@ -298,6 +411,8 @@ public class FileTransferDialog extends JDialog {
config.setSpace(spaceField.getText().trim());
config.setOptions(optionsField.getText().trim());
recordSessionTransfer();
String error = coordinator.startTransfer(config);
if (error != null) {
JOptionPane.showMessageDialog(this, error, "Transfer Error", JOptionPane.ERROR_MESSAGE);
@@ -306,6 +421,45 @@ public class FileTransferDialog extends JDialog {
}
}
public void recordSessionTransfer() {
TransferSessionState state = new TransferSessionState();
state.hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
state.isSend = sendRadio.isSelected();
state.localFile = localFileField.getText().trim();
state.hostFile = hostFileField.getText().trim();
state.isAscii = asciiRadio.isSelected();
Integer mtu = (Integer) mtuCombo.getSelectedItem();
state.mtu = (mtu != null) ? mtu : FTConstants.DFT_BUF;
state.crFlag = crCheck.isSelected();
state.remapFlag = remapCheck.isSelected();
state.append = appendCheck.isSelected();
state.overwrite = overwriteCheck.isSelected();
state.recfm = recfmField.getText().trim();
state.lrecl = lreclField.getText().trim();
state.blksize = blksizeField.getText().trim();
state.space = spaceField.getText().trim();
state.options = optionsField.getText().trim();
lastTransferState = state;
}
public JComboBox<FTConfig.HostType> getHostTypeCombo() { return hostTypeCombo; }
public JRadioButton getSendRadio() { return sendRadio; }
public JRadioButton getReceiveRadio() { return receiveRadio; }
public JTextField getLocalFileField() { return localFileField; }
public JTextField getHostFileField() { return hostFileField; }
public JRadioButton getAsciiRadio() { return asciiRadio; }
public JRadioButton getBinaryRadio() { return binaryRadio; }
public JComboBox<Integer> getMtuCombo() { return mtuCombo; }
public JCheckBox getCrCheck() { return crCheck; }
public JCheckBox getRemapCheck() { return remapCheck; }
public JCheckBox getAppendCheck() { return appendCheck; }
public JCheckBox getOverwriteCheck() { return overwriteCheck; }
public JTextField getRecfmField() { return recfmField; }
public JTextField getLreclField() { return lreclField; }
public JTextField getBlksizeField() { return blksizeField; }
public JTextField getSpaceField() { return spaceField; }
public JTextField getOptionsField() { return optionsField; }
private void applyTheme(Container container) {
Color fg = new Color(200, 200, 200);
Color bg = new Color(40, 40, 40);
@@ -0,0 +1,88 @@
package haus.nightmare.j3270.ui;
import haus.nightmare.lib3270j.graphics.DefaultPixelBuffer;
import haus.nightmare.lib3270j.graphics.PixelBuffer;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.DataBuffer;
import java.awt.image.DataBufferInt;
import java.awt.image.DirectColorModel;
import java.awt.image.Raster;
import java.awt.image.SinglePixelPackedSampleModel;
import java.awt.image.WritableRaster;
/**
* Desktop Swing/AWT bridge for converting between lib3270j PixelBuffer and java.awt BufferedImage.
*/
public final class AwtPixelBufferBridge {
private AwtPixelBufferBridge() {}
/**
* Converts a PixelBuffer to a BufferedImage.
* Uses TYPE_INT_ARGB with backed raster for optimal Java2D hardware acceleration.
*/
public static BufferedImage toBufferedImage(PixelBuffer buffer) {
if (buffer == null) return null;
int w = buffer.getWidth();
int h = buffer.getHeight();
if (w <= 0 || h <= 0) {
return new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
}
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
copyIntoBufferedImage(buffer, img);
return img;
}
/**
* Copies pixels from a PixelBuffer directly into an existing BufferedImage.
*/
public static void copyIntoBufferedImage(PixelBuffer buffer, BufferedImage target) {
if (buffer == null || target == null) return;
int w = Math.min(buffer.getWidth(), target.getWidth());
int h = Math.min(buffer.getHeight(), target.getHeight());
if (w <= 0 || h <= 0) return;
int[] srcPixels = buffer.getPixels();
if (srcPixels == null) return;
if (target.getType() == BufferedImage.TYPE_INT_ARGB || target.getType() == BufferedImage.TYPE_INT_RGB) {
if (target.getRaster().getDataBuffer() instanceof DataBufferInt) {
int[] dstPixels = ((DataBufferInt) target.getRaster().getDataBuffer()).getData();
int srcW = buffer.getWidth();
int dstW = target.getWidth();
if (srcW == dstW && srcW == w && srcPixels.length >= w * h && dstPixels.length >= w * h) {
System.arraycopy(srcPixels, 0, dstPixels, 0, w * h);
return;
}
for (int y = 0; y < h; y++) {
System.arraycopy(srcPixels, y * srcW, dstPixels, y * dstW, w);
}
return;
}
}
// Fallback for custom or incompatible image formats
target.setRGB(0, 0, w, h, srcPixels, 0, buffer.getWidth());
}
/**
* Wraps or converts a java.awt BufferedImage into a PixelBuffer.
*/
public static PixelBuffer toPixelBuffer(BufferedImage img) {
if (img == null) return null;
int w = img.getWidth();
int h = img.getHeight();
int[] pixels;
if ((img.getType() == BufferedImage.TYPE_INT_ARGB || img.getType() == BufferedImage.TYPE_INT_RGB)
&& img.getRaster().getDataBuffer() instanceof DataBufferInt) {
pixels = ((DataBufferInt) img.getRaster().getDataBuffer()).getData();
} else {
pixels = new int[w * h];
img.getRGB(0, 0, w, h, pixels, 0, w);
}
return new DefaultPixelBuffer(w, h, pixels);
}
}
@@ -24,6 +24,8 @@ public class ConnectDialog extends JDialog {
private JCheckBox tlsCheckBox;
private JCheckBox verifyCertCheckBox;
private JCheckBox tn3270eCheckBox;
private JCheckBox keepAliveCheckBox;
private JCheckBox autoReconnectCheckBox;
private boolean confirmed;
private ConnectionConfig result;
@@ -246,6 +248,24 @@ public class ConnectDialog extends JDialog {
tn3270eCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectTn3270e());
mainPanel.add(tn3270eCheckBox, gbc);
// Keep-Alive Checkbox
gbc.gridx = 1;
gbc.gridy = 10;
keepAliveCheckBox = new JCheckBox("Enable Keep-Alive Heartbeat (NOP)");
ThemeManager.styleCheckBox(keepAliveCheckBox);
keepAliveCheckBox.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
keepAliveCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectKeepAlive());
mainPanel.add(keepAliveCheckBox, gbc);
// Auto-Reconnect Checkbox
gbc.gridx = 1;
gbc.gridy = 11;
autoReconnectCheckBox = new JCheckBox("Auto-Reconnect on Disconnect");
ThemeManager.styleCheckBox(autoReconnectCheckBox);
autoReconnectCheckBox.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
autoReconnectCheckBox.setSelected(haus.nightmare.j3270.config.Settings.getAutoConnectAutoReconnect());
mainPanel.add(autoReconnectCheckBox, gbc);
// Buttons
JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 4));
@@ -266,7 +286,7 @@ public class ConnectDialog extends JDialog {
buttonPanel.add(connectBtn);
gbc.gridx = 0;
gbc.gridy = 10;
gbc.gridy = 12;
gbc.gridwidth = 2;
mainPanel.add(buttonPanel, gbc);
@@ -328,6 +348,18 @@ public class ConnectDialog extends JDialog {
result.setUseTls(tlsCheckBox.isSelected());
result.setTlsVerifyCert(verifyCertCheckBox.isSelected());
result.setTn3270eEnabled(tn3270eCheckBox.isSelected());
result.setAutoSysUnlock(haus.nightmare.j3270.config.Settings.getAutoSysUnlock());
boolean ka = keepAliveCheckBox.isSelected();
result.setKeepAliveEnabled(ka);
result.setKeepAliveIntervalSeconds(haus.nightmare.j3270.config.Settings.getAutoConnectKeepAliveInterval());
haus.nightmare.j3270.config.Settings.setAutoConnectKeepAlive(ka);
boolean ar = autoReconnectCheckBox.isSelected();
result.setAutoReconnect(ar);
result.setReconnectMaxRetries(haus.nightmare.j3270.config.Settings.getAutoConnectReconnectMaxRetries());
haus.nightmare.j3270.config.Settings.setAutoConnectAutoReconnect(ar);
confirmed = true;
dispose();
}
@@ -0,0 +1,427 @@
package haus.nightmare.j3270.ui;
import java.io.*;
import java.util.*;
import java.util.logging.Logger;
/**
* Keyboard mapping profile (.kmp) file importer and keybinding utility.
* Parses keyboard mapping profile files, translates key scan codes and mnemonics
* to j3270 action handlers.
*/
public class KeyBindings {
private static final Logger log = Logger.getLogger(KeyBindings.class.getName());
// IBM scan code to standard Java KeyStroke key name mapping
private static final Map<Integer, String> SCAN_CODE_MAP = new HashMap<>();
// Mnemonic to j3270 action mapping
private static final Map<String, String> MNEMONIC_MAP = new HashMap<>();
static {
// Alphanumeric and symbol keys
SCAN_CODE_MAP.put(1, "BACK_QUOTE");
SCAN_CODE_MAP.put(2, "1");
SCAN_CODE_MAP.put(3, "2");
SCAN_CODE_MAP.put(4, "3");
SCAN_CODE_MAP.put(5, "4");
SCAN_CODE_MAP.put(6, "5");
SCAN_CODE_MAP.put(7, "6");
SCAN_CODE_MAP.put(8, "7");
SCAN_CODE_MAP.put(9, "8");
SCAN_CODE_MAP.put(10, "9");
SCAN_CODE_MAP.put(11, "0");
SCAN_CODE_MAP.put(12, "MINUS");
SCAN_CODE_MAP.put(13, "EQUALS");
SCAN_CODE_MAP.put(14, "BACK_SPACE");
SCAN_CODE_MAP.put(15, "TAB");
SCAN_CODE_MAP.put(16, "Q");
SCAN_CODE_MAP.put(17, "W");
SCAN_CODE_MAP.put(18, "E");
SCAN_CODE_MAP.put(19, "R");
SCAN_CODE_MAP.put(20, "T");
SCAN_CODE_MAP.put(21, "Y");
SCAN_CODE_MAP.put(22, "U");
SCAN_CODE_MAP.put(23, "I");
SCAN_CODE_MAP.put(24, "O");
SCAN_CODE_MAP.put(25, "P");
SCAN_CODE_MAP.put(26, "OPEN_BRACKET");
SCAN_CODE_MAP.put(27, "CLOSE_BRACKET");
SCAN_CODE_MAP.put(28, "BACK_SLASH");
SCAN_CODE_MAP.put(29, "CAPS_LOCK");
SCAN_CODE_MAP.put(30, "A");
SCAN_CODE_MAP.put(31, "S");
SCAN_CODE_MAP.put(32, "D");
SCAN_CODE_MAP.put(33, "F");
SCAN_CODE_MAP.put(34, "G");
SCAN_CODE_MAP.put(35, "H");
SCAN_CODE_MAP.put(36, "J");
SCAN_CODE_MAP.put(37, "K");
SCAN_CODE_MAP.put(38, "L");
SCAN_CODE_MAP.put(39, "SEMICOLON");
SCAN_CODE_MAP.put(40, "QUOTE");
SCAN_CODE_MAP.put(41, "BACK_QUOTE");
SCAN_CODE_MAP.put(42, "ENTER");
SCAN_CODE_MAP.put(43, "ENTER");
SCAN_CODE_MAP.put(45, "Z");
SCAN_CODE_MAP.put(46, "X");
SCAN_CODE_MAP.put(47, "C");
SCAN_CODE_MAP.put(48, "V");
SCAN_CODE_MAP.put(49, "B");
SCAN_CODE_MAP.put(50, "N");
SCAN_CODE_MAP.put(51, "M");
SCAN_CODE_MAP.put(52, "COMMA");
SCAN_CODE_MAP.put(53, "PERIOD");
SCAN_CODE_MAP.put(54, "SLASH");
SCAN_CODE_MAP.put(57, "SPACE");
// Editing & Navigation keys
SCAN_CODE_MAP.put(75, "INSERT");
SCAN_CODE_MAP.put(76, "DELETE");
SCAN_CODE_MAP.put(79, "LEFT");
SCAN_CODE_MAP.put(80, "HOME");
SCAN_CODE_MAP.put(81, "END");
SCAN_CODE_MAP.put(83, "UP");
SCAN_CODE_MAP.put(84, "DOWN");
SCAN_CODE_MAP.put(85, "PAGE_UP");
SCAN_CODE_MAP.put(86, "PAGE_DOWN");
SCAN_CODE_MAP.put(89, "RIGHT");
// Numeric Keypad
SCAN_CODE_MAP.put(90, "NUM_LOCK");
SCAN_CODE_MAP.put(91, "NUMPAD7");
SCAN_CODE_MAP.put(92, "NUMPAD4");
SCAN_CODE_MAP.put(93, "NUMPAD1");
SCAN_CODE_MAP.put(95, "DIVIDE");
SCAN_CODE_MAP.put(96, "NUMPAD8");
SCAN_CODE_MAP.put(97, "NUMPAD5");
SCAN_CODE_MAP.put(98, "NUMPAD2");
SCAN_CODE_MAP.put(99, "NUMPAD0");
SCAN_CODE_MAP.put(100, "MULTIPLY");
SCAN_CODE_MAP.put(101, "NUMPAD9");
SCAN_CODE_MAP.put(102, "NUMPAD6");
SCAN_CODE_MAP.put(103, "NUMPAD3");
SCAN_CODE_MAP.put(104, "DECIMAL");
SCAN_CODE_MAP.put(105, "SUBTRACT");
SCAN_CODE_MAP.put(106, "ADD");
SCAN_CODE_MAP.put(108, "ENTER");
// Function keys & Escape
SCAN_CODE_MAP.put(110, "ESCAPE");
SCAN_CODE_MAP.put(112, "F1");
SCAN_CODE_MAP.put(113, "F2");
SCAN_CODE_MAP.put(114, "F3");
SCAN_CODE_MAP.put(115, "F4");
SCAN_CODE_MAP.put(116, "F5");
SCAN_CODE_MAP.put(117, "F6");
SCAN_CODE_MAP.put(118, "F7");
SCAN_CODE_MAP.put(119, "F8");
SCAN_CODE_MAP.put(120, "F9");
SCAN_CODE_MAP.put(121, "F10");
SCAN_CODE_MAP.put(122, "F11");
SCAN_CODE_MAP.put(123, "F12");
// Mnemonics mapping
MNEMONIC_MAP.put("enter", "ENTER");
MNEMONIC_MAP.put("enterreset", "ENTER");
MNEMONIC_MAP.put("newline", "NEWLINE");
MNEMONIC_MAP.put("tab", "TAB");
MNEMONIC_MAP.put("backtab", "shift TAB");
MNEMONIC_MAP.put("reset", "ESCAPE");
MNEMONIC_MAP.put("clear", "CLEAR");
MNEMONIC_MAP.put("eraseeof", "END");
MNEMONIC_MAP.put("eof", "END");
MNEMONIC_MAP.put("erasefld", "ERASE_INPUT");
MNEMONIC_MAP.put("erinp", "ERASE_INPUT");
MNEMONIC_MAP.put("eraseinput", "ERASE_INPUT");
MNEMONIC_MAP.put("dup", "DUP");
MNEMONIC_MAP.put("fieldmark", "FIELD_MARK");
MNEMONIC_MAP.put("fldmark", "FIELD_MARK");
MNEMONIC_MAP.put("fldext", "NEWLINE");
MNEMONIC_MAP.put("field-exit", "NEWLINE");
MNEMONIC_MAP.put("field+", "TAB");
MNEMONIC_MAP.put("field-", "TAB");
MNEMONIC_MAP.put("attn", "ATTN");
MNEMONIC_MAP.put("sysreq", "SYSREQ");
MNEMONIC_MAP.put("cursel", "CURSEL");
MNEMONIC_MAP.put("copy", "COPY");
MNEMONIC_MAP.put("paste", "PASTE");
MNEMONIC_MAP.put("selectall", "SELECTALL");
MNEMONIC_MAP.put("insert", "INSERT");
MNEMONIC_MAP.put("delete", "DELETE");
MNEMONIC_MAP.put("backspace", "BACK_SPACE");
MNEMONIC_MAP.put("home", "HOME");
MNEMONIC_MAP.put("end", "END");
MNEMONIC_MAP.put("up", "UP");
MNEMONIC_MAP.put("down", "DOWN");
MNEMONIC_MAP.put("left", "LEFT");
MNEMONIC_MAP.put("right", "RIGHT");
MNEMONIC_MAP.put("pageup", "PAGE_UP");
MNEMONIC_MAP.put("pagedn", "PAGE_DOWN");
MNEMONIC_MAP.put("pagedown", "PAGE_DOWN");
MNEMONIC_MAP.put("docmode", "DOCMODE");
MNEMONIC_MAP.put("wordwrap", "WORDWRAP");
MNEMONIC_MAP.put("apl", "APL");
MNEMONIC_MAP.put("rule", "CROSSHAIR_RULER");
MNEMONIC_MAP.put("statusbar", "STATUS_BAR");
for (int i = 1; i <= 24; i++) {
MNEMONIC_MAP.put("pf" + i, "PF" + i);
}
for (int i = 1; i <= 3; i++) {
MNEMONIC_MAP.put("pa" + i, "PA" + i);
}
// Shifted PF keys 1-12 = PF13-PF24
for (int i = 1; i <= 12; i++) {
MNEMONIC_MAP.put("spf" + i, "PF" + (i + 12));
}
}
/**
* Parse a .kmp file from a File object.
*
* @param file the .kmp file to parse
* @return Map of j3270 action names to key stroke bindings
* @throws IOException on I/O error
*/
public static Map<String, String> parseKmp(File file) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
return parseKmp(reader);
}
}
/**
* Parse a .kmp file from an InputStream.
*
* @param in the InputStream to parse
* @return Map of j3270 action names to key stroke bindings
* @throws IOException on I/O error
*/
public static Map<String, String> parseKmp(InputStream in) throws IOException {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
return parseKmp(reader);
}
}
/**
* Parse a .kmp file from a Reader.
* Supports both INI-style KEY<n>=action formats and bind statements.
*
* @param reader the reader to parse from
* @return Map of j3270 action names to key stroke bindings
* @throws IOException on I/O error
*/
public static Map<String, String> parseKmp(Reader reader) throws IOException {
BufferedReader br = (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader);
Map<String, List<String>> actionToBindings = new LinkedHashMap<>();
String line;
while ((line = br.readLine()) != null) {
line = line.trim();
// Strip comments
int commentIdx = line.indexOf(';');
if (commentIdx >= 0) {
line = line.substring(0, commentIdx).trim();
}
commentIdx = line.indexOf('#');
if (commentIdx >= 0) {
line = line.substring(0, commentIdx).trim();
}
if (line.isEmpty() || line.startsWith("[")) {
continue; // Skip section headers and blank lines
}
// Syntax 1: bind [mnemonic] KeyCombination OR bind action KeyCombination
if (line.toLowerCase().startsWith("bind ")) {
parseBindStatement(line.substring(5).trim(), actionToBindings);
continue;
}
// Syntax 2: KEY<n>=action OR Modifier-KEY<n>=action OR KeyStroke=[mnemonic]
int eq = line.indexOf('=');
if (eq > 0) {
String left = line.substring(0, eq).trim();
String right = line.substring(eq + 1).trim();
parseKeyAssignment(left, right, actionToBindings);
}
}
// Format into combined comma-separated bindings map
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : actionToBindings.entrySet()) {
String action = entry.getKey();
List<String> bindings = entry.getValue();
if (!bindings.isEmpty()) {
result.put(action, String.join(", ", bindings));
}
}
return result;
}
private static void parseBindStatement(String stmt, Map<String, List<String>> actionToBindings) {
String[] parts = stmt.split("\\s+", 2);
if (parts.length < 2) return;
String target = parts[0].trim();
String keyStrokeStr = parts[1].trim();
String action = resolveMnemonic(target);
if (action == null) {
// Check if parts[1] was the mnemonic instead (e.g. "bind KeyCombination [mnemonic]")
String altAction = resolveMnemonic(keyStrokeStr);
if (altAction != null) {
action = altAction;
keyStrokeStr = target;
}
}
if (action != null) {
String normKeyStroke = normalizeKeyStroke(keyStrokeStr);
if (normKeyStroke != null && !normKeyStroke.isEmpty()) {
addBinding(actionToBindings, action, normKeyStroke);
}
}
}
private static void parseKeyAssignment(String left, String right, Map<String, List<String>> actionToBindings) {
// Case A: left is KEY<n> or Modifier-KEY<n>, right is mnemonic
String action = resolveMnemonic(right);
String keyStroke = null;
if (action != null) {
keyStroke = parseScanCodeEntry(left);
} else {
// Case B: left is mnemonic, right is KeyStroke
action = resolveMnemonic(left);
if (action != null) {
keyStroke = normalizeKeyStroke(right);
} else {
// Case C: left is KeyStroke, right is mnemonic
action = resolveMnemonic(right);
if (action != null) {
keyStroke = normalizeKeyStroke(left);
}
}
}
if (action != null && keyStroke != null && !keyStroke.isEmpty()) {
addBinding(actionToBindings, action, keyStroke);
}
}
/**
* Parse scan code entry like "KEY43", "S-KEY112", "C-KEY43", "CS-KEY85", etc.
*/
public static String parseScanCodeEntry(String entry) {
if (entry == null || entry.isEmpty()) return null;
String upper = entry.toUpperCase().trim();
int keyPos = upper.indexOf("KEY");
if (keyPos < 0) {
return normalizeKeyStroke(entry);
}
String prefix = upper.substring(0, keyPos);
if (prefix.endsWith("-")) {
prefix = prefix.substring(0, prefix.length() - 1);
}
String numPart = upper.substring(keyPos + 3);
int scanCode;
try {
scanCode = Integer.parseInt(numPart.trim());
} catch (NumberFormatException e) {
return null;
}
String baseKey = SCAN_CODE_MAP.get(scanCode);
if (baseKey == null) {
return null;
}
StringBuilder sb = new StringBuilder();
if (prefix.contains("C")) sb.append("ctrl ");
if (prefix.contains("A") || prefix.contains("2")) sb.append("alt ");
if (prefix.contains("S")) sb.append("shift ");
if (prefix.contains("M")) sb.append("meta ");
sb.append(baseKey);
return sb.toString().trim();
}
/**
* Normalize key stroke expression (e.g. "Ctrl+Shift+C" -> "ctrl shift C").
*/
public static String normalizeKeyStroke(String stroke) {
if (stroke == null || stroke.trim().isEmpty()) return null;
String s = stroke.trim();
// Check if it is an IBM scan code
if (s.toUpperCase().contains("KEY")) {
String fromScan = parseScanCodeEntry(s);
if (fromScan != null) return fromScan;
}
// Replace '+' with space
s = s.replace("+", " ");
String[] parts = s.split("\\s+");
StringBuilder mods = new StringBuilder();
String mainKey = null;
for (String p : parts) {
String lp = p.toLowerCase();
if ("ctrl".equals(lp) || "control".equals(lp)) {
mods.append("ctrl ");
} else if ("shift".equals(lp)) {
mods.append("shift ");
} else if ("alt".equals(lp)) {
mods.append("alt ");
} else if ("meta".equals(lp) || "cmd".equals(lp) || "command".equals(lp)) {
mods.append("meta ");
} else {
mainKey = p.toUpperCase();
}
}
if (mainKey == null) return null;
return (mods.toString() + mainKey).trim();
}
/**
* Resolve a mnemonic token (e.g. "[enter]", "enter", "[pf12]", "eraseeof")
* into a j3270 action name.
*/
public static String resolveMnemonic(String token) {
if (token == null) return null;
String clean = token.trim();
if (clean.startsWith("[") && clean.endsWith("]")) {
clean = clean.substring(1, clean.length() - 1);
}
clean = clean.toLowerCase();
String direct = MNEMONIC_MAP.get(clean);
if (direct != null) {
return direct;
}
// Case-insensitive match on existing action names
String upper = clean.toUpperCase();
if (MNEMONIC_MAP.containsValue(upper)) {
return upper;
}
return null;
}
private static void addBinding(Map<String, List<String>> map, String action, String keyStroke) {
List<String> list = map.computeIfAbsent(action, k -> new ArrayList<>());
if (!list.contains(keyStroke)) {
list.add(keyStroke);
}
}
}
@@ -7,6 +7,7 @@ import haus.nightmare.j3270.config.Settings;
import java.awt.*;
import java.awt.event.*;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import javax.swing.table.DefaultTableModel;
@@ -33,6 +34,21 @@ public class SettingsDialog extends JDialog {
private JCheckBox blockSelectCheck;
private JSpinner dynamicRowsSpinner;
private JSpinner dynamicColsSpinner;
private JCheckBox enablePasteFromExcelCheck;
private JCheckBox pasteStopAtProtectedCheck;
// Entry Assist & Modes tab
private JCheckBox docModeCheck;
private JCheckBox wordWrapCheck;
private JSpinner startColSpinner;
private JSpinner endColSpinner;
private JCheckBox bellCheck;
private JSpinner bellColSpinner;
private JTextField tabStopsField;
private JCheckBox insertOffOnAidCheck;
private JCheckBox fourColorOverrideCheck;
private JCheckBox numericFieldLockCheck;
private JCheckBox autoSkipCheck;
// Advanced tab state tracking
private final Color[] tempHostColors = new Color[16];
@@ -45,7 +61,7 @@ public class SettingsDialog extends JDialog {
this.parentApp = parent;
initComponents();
setSize(560, 480);
setSize(600, 520);
setLocationRelativeTo(parent);
}
@@ -55,6 +71,7 @@ public class SettingsDialog extends JDialog {
tabbedPane.addTab("Appearance", createAppearancePanel());
tabbedPane.addTab("Behavior", createBehaviorPanel());
tabbedPane.addTab("Entry Assist & Modes", createEntryAssistPanel());
tabbedPane.addTab("Advanced", createAdvancedPanel());
JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 8));
@@ -133,11 +150,31 @@ public class SettingsDialog extends JDialog {
panel.add(fontLabel, gbc);
String[] fonts = GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames();
fontBox = new JComboBox<>(fonts);
// Find default
java.util.List<String> fontList = new java.util.ArrayList<>();
boolean hasMonospaced = false;
String currentFont = Settings.getFontFamily();
for (int i = 0; i < fonts.length; i++) {
if (fonts[i].equalsIgnoreCase(currentFont)) {
for (String f : fonts) {
if ("Monospaced".equalsIgnoreCase(f)) {
hasMonospaced = true;
}
fontList.add(f);
}
if (!hasMonospaced) {
fontList.add(0, "Monospaced");
}
boolean hasCurrent = false;
for (String f : fontList) {
if (f.equalsIgnoreCase(currentFont)) {
hasCurrent = true;
break;
}
}
if (!hasCurrent && currentFont != null && !currentFont.trim().isEmpty()) {
fontList.add(0, currentFont);
}
fontBox = new JComboBox<>(fontList.toArray(new String[0]));
for (int i = 0; i < fontBox.getItemCount(); i++) {
if (fontBox.getItemAt(i).equalsIgnoreCase(currentFont)) {
fontBox.setSelectedIndex(i);
break;
}
@@ -257,16 +294,123 @@ public class SettingsDialog extends JDialog {
gbc.gridx = 1;
panel.add(dynDimPanel, gbc);
// Placeholder for potentially more behavior options below
// Clipboard & Tabular Paste options
gbc.gridx = 0;
gbc.gridy = 4;
gbc.gridwidth = 2;
enablePasteFromExcelCheck = new JCheckBox("Enable Excel / Tabular Paste (advance with tabs & newlines)", Settings.getEnablePasteFromExcel());
panel.add(enablePasteFromExcelCheck, gbc);
gbc.gridy = 5;
pasteStopAtProtectedCheck = new JCheckBox("Stop Paste at Protected Boundary", Settings.getPasteStopAtProtectedLine());
panel.add(pasteStopAtProtectedCheck, gbc);
gbc.gridy = 6;
gbc.weighty = 1.0;
panel.add(Box.createGlue(), gbc);
return panel;
}
private JPanel createEntryAssistPanel() {
JPanel panel = new JPanel();
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
// Group 1: Entry Assist / Document Mode
JPanel eaGroup = new JPanel(new GridBagLayout());
eaGroup.setBorder(BorderFactory.createTitledBorder("Entry Assist (Document Mode)"));
GridBagConstraints gbc = new GridBagConstraints();
gbc.insets = new Insets(4, 6, 4, 6);
gbc.anchor = GridBagConstraints.WEST;
docModeCheck = new JCheckBox("Enable Document Mode (DOC)", Settings.getEntryAssistDocMode());
gbc.gridx = 0;
gbc.gridy = 0;
gbc.gridwidth = 2;
eaGroup.add(docModeCheck, gbc);
wordWrapCheck = new JCheckBox("Enable Word Wrap (V)", Settings.getEntryAssistWordWrap());
gbc.gridy = 1;
eaGroup.add(wordWrapCheck, gbc);
// Margins
gbc.gridy = 2;
gbc.gridwidth = 1;
eaGroup.add(new JLabel("Margins:"), gbc);
JPanel marginPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
marginPanel.setOpaque(false);
marginPanel.add(new JLabel("Left:"));
startColSpinner = new JSpinner(new SpinnerNumberModel(Settings.getEntryAssistStartCol(), 1, 80, 1));
ThemeManager.styleSpinner(startColSpinner);
marginPanel.add(startColSpinner);
marginPanel.add(new JLabel("Right:"));
endColSpinner = new JSpinner(new SpinnerNumberModel(Settings.getEntryAssistEndCol(), 1, 80, 1));
ThemeManager.styleSpinner(endColSpinner);
marginPanel.add(endColSpinner);
gbc.gridx = 1;
eaGroup.add(marginPanel, gbc);
// End-of-Line Bell
gbc.gridx = 0;
gbc.gridy = 3;
bellCheck = new JCheckBox("Audible EOL Bell at Col:", Settings.getEntryAssistBell());
eaGroup.add(bellCheck, gbc);
bellColSpinner = new JSpinner(new SpinnerNumberModel(Settings.getEntryAssistBellCol(), 1, 80, 1));
ThemeManager.styleSpinner(bellColSpinner);
gbc.gridx = 1;
eaGroup.add(bellColSpinner, gbc);
// Tab Stops
gbc.gridx = 0;
gbc.gridy = 4;
eaGroup.add(new JLabel("Tab Stops:"), gbc);
tabStopsField = new JTextField(Settings.getEntryAssistTabStops(), 20);
tabStopsField.setToolTipText("Comma-separated column numbers (1-80, e.g. 1,9,17,25,33,41,49,57,65,73)");
gbc.gridx = 1;
gbc.fill = GridBagConstraints.HORIZONTAL;
eaGroup.add(tabStopsField, gbc);
panel.add(eaGroup);
panel.add(Box.createVerticalStrut(10));
// Group 2: Terminal Operational Modes
JPanel modesGroup = new JPanel(new GridBagLayout());
modesGroup.setBorder(BorderFactory.createTitledBorder("Terminal Operational Modes"));
GridBagConstraints gbcM = new GridBagConstraints();
gbcM.insets = new Insets(4, 6, 4, 6);
gbcM.anchor = GridBagConstraints.WEST;
gbcM.fill = GridBagConstraints.HORIZONTAL;
gbcM.gridx = 0;
gbcM.weightx = 1.0;
insertOffOnAidCheck = new JCheckBox("Reset Insert mode on AID key (Enter, PF, PA, Clear)", Settings.getInsertOffOnAid());
gbcM.gridy = 0;
modesGroup.add(insertOffOnAidCheck, gbcM);
fourColorOverrideCheck = new JCheckBox("Base 4-Color Override mode (3279 green/white/red/turquoise)", Settings.getFourColorOverride());
gbcM.gridy = 1;
modesGroup.add(fourColorOverrideCheck, gbcM);
numericFieldLockCheck = new JCheckBox("Lock keyboard on non-numeric input in numeric fields (-NUMERIC)", Settings.getNumericFieldLock());
gbcM.gridy = 2;
modesGroup.add(numericFieldLockCheck, gbcM);
autoSkipCheck = new JCheckBox("Auto-Skip to next unprotected field when field is filled", Settings.getAutoSkipEnabled());
gbcM.gridy = 3;
modesGroup.add(autoSkipCheck, gbcM);
panel.add(modesGroup);
panel.add(Box.createVerticalGlue());
return panel;
}
private JPanel createAdvancedPanel() {
JPanel panel = new JPanel(new BorderLayout());
@@ -369,7 +513,9 @@ public class SettingsDialog extends JDialog {
String[] actions = {"ENTER", "TAB", "shift TAB", "UP", "DOWN", "LEFT", "RIGHT",
"HOME", "END", "PAGE_UP", "PAGE_DOWN", "ESCAPE", "INSERT", "DELETE", "BACK_SPACE", "CLEAR",
"ERASE_INPUT", "NEWLINE", "DUP", "FIELD_MARK", "ATTN", "SYSREQ", "CURSEL"};
"STATUS_BAR",
"ERASE_INPUT", "NEWLINE", "DUP", "FIELD_MARK", "ATTN", "SYSREQ", "CURSEL",
"COPY", "PASTE", "SELECTALL"};
keymapModel = new DefaultTableModel(new Object[]{"Action", "Key Binding"}, 0) {
@Override
@@ -380,15 +526,7 @@ public class SettingsDialog extends JDialog {
// Populate table from Settings or Defaults
for(String act : actions) {
String def = act;
if (def.equals("ERASE_INPUT")) def = "alt E";
else if (def.equals("NEWLINE")) def = "shift ENTER";
else if (def.equals("DUP")) def = "alt D";
else if (def.equals("FIELD_MARK")) def = "alt M";
else if (def.equals("ATTN")) def = "alt A";
else if (def.equals("SYSREQ")) def = "alt S";
else if (def.equals("CURSEL")) def = "alt Q";
else if (def.equals("CLEAR")) def = "alt C";
String def = getDefaultBinding(act);
String current = haus.nightmare.j3270.config.Settings.getKeyBinding(act, def);
tempKeyBindings.put(act, current);
keymapModel.addRow(new Object[]{act, current});
@@ -526,6 +664,45 @@ public class SettingsDialog extends JDialog {
});
btnPanel.add(btnReset);
// Import Keymap — import from .kmp file
JButton btnImportKmp = new JButton("Import Keymap...");
ThemeManager.styleButton(btnImportKmp, ThemeManager.ButtonVariant.DEFAULT);
btnImportKmp.addActionListener(e -> {
JFileChooser chooser = new JFileChooser();
chooser.setDialogTitle("Import Keymap Profile");
chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("Keyboard Map Profile (*.kmp)", "kmp"));
int ret = chooser.showOpenDialog(this);
if (ret == JFileChooser.APPROVE_OPTION) {
File f = chooser.getSelectedFile();
try {
Map<String, String> imported = KeyBindings.parseKmp(f);
int updatedCount = 0;
for (Map.Entry<String, String> entry : imported.entrySet()) {
String action = entry.getKey();
String binding = entry.getValue();
tempKeyBindings.put(action, binding);
for (int row = 0; row < keymapModel.getRowCount(); row++) {
if (action.equalsIgnoreCase((String) keymapModel.getValueAt(row, 0))) {
keymapModel.setValueAt(binding, row, 1);
updatedCount++;
break;
}
}
}
JOptionPane.showMessageDialog(this,
"Successfully imported " + updatedCount + " key bindings from " + f.getName(),
"Keymap Imported",
JOptionPane.INFORMATION_MESSAGE);
} catch (Exception ex) {
JOptionPane.showMessageDialog(this,
"Failed to import keymap: " + ex.getMessage(),
"Import Error",
JOptionPane.ERROR_MESSAGE);
}
}
});
btnPanel.add(btnImportKmp);
main.add(new JScrollPane(table), BorderLayout.CENTER);
main.add(btnPanel, BorderLayout.SOUTH);
@@ -534,13 +711,12 @@ public class SettingsDialog extends JDialog {
/** Returns the factory-default binding for a given action name. */
private String getDefaultBinding(String action) {
if (action.startsWith("PF")) {
if (action.matches("PF[0-9]+")) {
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
if (action.matches("PA[1-3]")) return "alt " + action.substring(2);
return haus.nightmare.j3270.config.Settings.getDefaultBinding(action);
}
// ========== Apply Settings ==========
@@ -579,12 +755,36 @@ public class SettingsDialog extends JDialog {
// Block select mode
Settings.setBlockSelectMode(blockSelectCheck.isSelected());
// Clipboard & Tabular Paste options
if (enablePasteFromExcelCheck != null) {
Settings.setEnablePasteFromExcel(enablePasteFromExcelCheck.isSelected());
}
if (pasteStopAtProtectedCheck != null) {
Settings.setPasteStopAtProtectedLine(pasteStopAtProtectedCheck.isSelected());
}
// Default Dynamic screen dimensions
if (dynamicRowsSpinner != null && dynamicColsSpinner != null) {
Settings.setDynamicRows((Integer) dynamicRowsSpinner.getValue());
Settings.setDynamicCols((Integer) dynamicColsSpinner.getValue());
}
// Entry Assist & Modes
if (docModeCheck != null) {
Settings.setEntryAssistDocMode(docModeCheck.isSelected());
Settings.setEntryAssistWordWrap(wordWrapCheck.isSelected());
Settings.setEntryAssistStartCol((Integer) startColSpinner.getValue());
Settings.setEntryAssistEndCol((Integer) endColSpinner.getValue());
Settings.setEntryAssistBell(bellCheck.isSelected());
Settings.setEntryAssistBellCol((Integer) bellColSpinner.getValue());
Settings.setEntryAssistTabStops(tabStopsField.getText().trim());
Settings.setInsertOffOnAid(insertOffOnAidCheck.isSelected());
Settings.setFourColorOverride(fourColorOverrideCheck.isSelected());
Settings.setNumericFieldLock(numericFieldLockCheck.isSelected());
Settings.setAutoSkipEnabled(autoSkipCheck.isSelected());
}
// Propagate visual changes to the app
// Save Colors
for (int i=0; i<16; i++) {
@@ -600,6 +800,9 @@ public class SettingsDialog extends JDialog {
}
parentApp.getTerminalPanel().reloadSettings();
if (parentApp != null && (parentApp.getExtendedState() & Frame.MAXIMIZED_BOTH) == 0) {
parentApp.getTerminalPanel().guardedPack();
}
ThemeManager.applyThemeToWindow(this);
return true;
@@ -18,7 +18,11 @@ public class StatusBar extends JPanel {
private final JLabel tlsStatus;
private final JLabel luName;
private final JLabel lockStatus;
private final JLabel insertStatus;
private final JLabel aplStatus;
private final JLabel fieldTypeStatus;
private final JLabel docModeStatus;
private final JLabel wordWrapStatus;
private final JLabel codePageInfo;
private final JLabel modelInfo;
private final JLabel cursorPosition;
@@ -37,8 +41,40 @@ public class StatusBar extends JPanel {
connectionStatus = createLabel("Not Connected", oiaFont, ThemeManager.getOiaFgDim());
tlsStatus = createLabel("", oiaFont, ThemeManager.getOiaFgNormal());
luName = createLabel("", oiaFont, ThemeManager.getOiaFgNormal());
lockStatus = createLabel("", oiaFont, ThemeManager.getOiaFgAlert());
lockStatus = createLabel("", oiaFont, ThemeManager.getOiaInputInhibited());
insertStatus = createClickableLabel("", oiaFont, ThemeManager.getOiaStatusSysAvail(), "Insert Mode (^ / Insert key) - Click to toggle", () -> {
if (terminalPanel != null) {
terminalPanel.toggleInsertMode();
} else if (client != null) {
client.toggleInsert();
updateStatus();
}
});
aplStatus = createClickableLabel("", oiaFont, ThemeManager.getOiaFgAlert(), "APL Keyboard Mode (Alt+F3) - Click to toggle", () -> {
if (terminalPanel != null) {
terminalPanel.toggleAplMode();
} else if (client != null) {
client.toggleAplMode();
updateStatus();
}
});
fieldTypeStatus = createLabel("", oiaFont, ThemeManager.getOiaFgDim());
docModeStatus = createClickableLabel("", oiaFont, ThemeManager.getOiaStatusSysAvail(), "Entry Assist Document Mode (Alt+F1) - Click to toggle", () -> {
if (terminalPanel != null) {
terminalPanel.toggleDocMode();
} else if (client != null) {
client.toggleDocMode();
updateStatus();
}
});
wordWrapStatus = createClickableLabel("", oiaFont, ThemeManager.getOiaStatusSysAvail(), "Entry Assist Word Wrap (Alt+F2) - Click to toggle", () -> {
if (terminalPanel != null) {
terminalPanel.toggleWordWrap();
} else if (client != null) {
client.toggleWordWrap();
updateStatus();
}
});
codePageInfo = createLabel("", oiaFont, ThemeManager.getOiaFgDim());
modelInfo = createLabel("", oiaFont, ThemeManager.getOiaFgDim());
cursorPosition = createLabel("001/001 [0000]", oiaFont, ThemeManager.getOiaFgNormal());
@@ -51,9 +87,17 @@ public class StatusBar extends JPanel {
add(luName);
add(Box.createHorizontalStrut(12));
add(lockStatus);
add(Box.createHorizontalStrut(8));
add(insertStatus);
add(Box.createHorizontalStrut(8));
add(aplStatus);
add(Box.createHorizontalStrut(10));
add(fieldTypeStatus);
add(Box.createHorizontalGlue());
add(docModeStatus);
add(Box.createHorizontalStrut(8));
add(wordWrapStatus);
add(Box.createHorizontalStrut(12));
add(codePageInfo);
add(Box.createHorizontalStrut(12));
add(modelInfo);
@@ -69,6 +113,23 @@ public class StatusBar extends JPanel {
return label;
}
private JLabel createClickableLabel(String text, Font font, Color fg, String tooltip, Runnable onClick) {
JLabel label = new JLabel(text);
label.setFont(font);
label.setForeground(fg);
label.setToolTipText(tooltip);
label.setCursor(java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR));
label.addMouseListener(new java.awt.event.MouseAdapter() {
@Override
public void mouseClicked(java.awt.event.MouseEvent e) {
if (onClick != null) {
onClick.run();
}
}
});
return label;
}
public void setClient(Telnet3270Client client, TerminalPanel terminalPanel) {
this.client = client;
this.terminalPanel = terminalPanel;
@@ -76,12 +137,20 @@ public class StatusBar extends JPanel {
}
public void applyTheme(UITheme theme) {
if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) {
SwingUtilities.invokeLater(() -> applyTheme(theme));
return;
}
setBackground(ThemeManager.getStatusBarBg(theme));
setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, ThemeManager.getStatusBarBorder(theme)));
updateStatus();
}
public void updateStatus() {
if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) {
SwingUtilities.invokeLater(this::updateStatus);
return;
}
UITheme theme = ThemeManager.getTheme();
if (client == null) {
connectionStatus.setText("Not Connected");
@@ -89,10 +158,16 @@ public class StatusBar extends JPanel {
tlsStatus.setText("");
luName.setText("");
lockStatus.setText("");
lockStatus.setForeground(ThemeManager.getOiaInputInhibited(theme));
insertStatus.setText("");
aplStatus.setText("");
fieldTypeStatus.setText("");
docModeStatus.setText("");
wordWrapStatus.setText("");
codePageInfo.setText("");
modelInfo.setText("");
cursorPosition.setText("001/001");
cursorPosition.setForeground(ThemeManager.getOiaFgNormal(theme));
return;
}
@@ -103,6 +178,10 @@ public class StatusBar extends JPanel {
connectionStatus.setText("Not Connected");
connectionStatus.setForeground(ThemeManager.getOiaFgDim(theme));
break;
case RECONNECTING:
connectionStatus.setText("Reconnecting...");
connectionStatus.setForeground(ThemeManager.getOiaFgAlert(theme));
break;
case TCP_PENDING:
case TELNET_PENDING:
connectionStatus.setText("Connecting...");
@@ -110,25 +189,25 @@ public class StatusBar extends JPanel {
break;
case CONNECTED_3270:
connectionStatus.setText("TN3270");
connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail());
connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme));
break;
case CONNECTED_TN3270E:
connectionStatus.setText("TN3270E");
connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail());
connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme));
break;
case CONNECTED_SSCP:
connectionStatus.setText("SSCP-LU");
connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail());
connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme));
break;
case CONNECTED_NVT:
case CONNECTED_NVT_CHAR:
case CONNECTED_E_NVT:
connectionStatus.setText("NVT");
connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail());
connectionStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme));
break;
case CONNECTED_UNBOUND:
connectionStatus.setText("Unbound");
connectionStatus.setForeground(ThemeManager.getOiaAttention());
connectionStatus.setForeground(ThemeManager.getOiaAttention(theme));
break;
default:
connectionStatus.setText(state.name());
@@ -144,11 +223,11 @@ public class StatusBar extends JPanel {
String protocol = session != null ? session.getProtocol() : "TLS";
if (verified) {
tlsStatus.setText("🔒 TLS");
tlsStatus.setForeground(ThemeManager.getOiaStatusSysAvail());
tlsStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme));
tlsStatus.setToolTipText(protocol + " / " + cipher + " (Verified)");
} else {
tlsStatus.setText("🔓 TLS (Unverified)");
tlsStatus.setForeground(ThemeManager.getOiaAttention());
tlsStatus.setForeground(ThemeManager.getOiaAttention(theme));
tlsStatus.setToolTipText(protocol + " / " + cipher + " (Verification Bypassed)");
}
} else {
@@ -164,50 +243,85 @@ public class StatusBar extends JPanel {
lu = "LU:" + client.getConfig().getLuName();
}
luName.setText(lu);
luName.setForeground(ThemeManager.getOiaStatusSysAvail());
luName.setForeground(ThemeManager.getOiaStatusSysAvail(theme));
// Lock / Inhibit status
int inhibit = client.getOIA().getInputInhibited();
if (inhibit != ECLConstants.INHIBIT_NOT_INHIBITED) {
Color lockFg = ThemeManager.getOiaInputInhibited(); // White (oII)
Color lockFg = ThemeManager.getOiaInputInhibited(theme);
switch (inhibit) {
case ECLConstants.INHIBIT_SYSTEM_LOCK:
lockStatus.setText("X SYSTEM");
lockFg = ThemeManager.getOiaInputInhibited(); // White (oII)
lockFg = ThemeManager.getOiaInputInhibited(theme);
break;
case ECLConstants.INHIBIT_COMM_CHECK:
lockStatus.setText("X COMM");
lockFg = ThemeManager.getOiaCommCheck(); // Red (oEI)
lockFg = ThemeManager.getOiaCommCheck(theme);
break;
case ECLConstants.INHIBIT_NUMERIC_ONLY:
lockStatus.setText("X NUM");
lockFg = ThemeManager.getOiaAttention(); // Yellow (oAI)
lockFg = ThemeManager.getOiaAttention(theme);
break;
case ECLConstants.INHIBIT_PROTECTED_FIELD:
lockStatus.setText("X PROT");
lockFg = ThemeManager.getOiaInputInhibited(); // White (oII)
lockFg = ThemeManager.getOiaInputInhibited(theme);
break;
case ECLConstants.INHIBIT_OVERFLOW:
lockStatus.setText("X >");
lockFg = ThemeManager.getOiaAttention(); // Yellow (oAI)
lockFg = ThemeManager.getOiaAttention(theme);
break;
case ECLConstants.INHIBIT_OPERATOR_DUE:
lockStatus.setText("X OP");
lockFg = ThemeManager.getOiaAttention(); // Yellow (oAI)
lockFg = ThemeManager.getOiaAttention(theme);
break;
default:
lockStatus.setText("X LOCKED");
lockFg = ThemeManager.getOiaInputInhibited(); // White (oII)
lockFg = ThemeManager.getOiaInputInhibited(theme);
break;
}
lockStatus.setForeground(lockFg);
} else if (client.getInputProcessor().isInsertMode()) {
lockStatus.setText("INSERT");
lockStatus.setForeground(ThemeManager.getOiaStatusSysAvail());
} else {
lockStatus.setText("");
}
// Insert Mode Indicator
boolean isInsert = false;
if (terminalPanel != null) {
isInsert = terminalPanel.isInsertMode();
} else if (client.getInputProcessor() != null) {
isInsert = client.getInputProcessor().isInsertMode();
}
if (isInsert) {
insertStatus.setText("^ INS");
insertStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme));
} else {
insertStatus.setText("");
}
// APL Keyboard Mode Indicator
if (client.getInputProcessor() != null && client.getInputProcessor().isAplKeyboardMode()) {
aplStatus.setText("APL");
aplStatus.setForeground(ThemeManager.getOiaFgAlert(theme));
} else {
aplStatus.setText("");
}
// Entry Assist DOC Mode Indicator
if (client.getScreenBuffer() != null && client.getScreenBuffer().isEntryAssistDOCmode()) {
docModeStatus.setText("DOC");
docModeStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme));
} else {
docModeStatus.setText("");
}
// Entry Assist Word Wrap Indicator
if (client.getScreenBuffer() != null && client.getScreenBuffer().isEntryAssistWordWrap()) {
wordWrapStatus.setText("V");
wordWrapStatus.setForeground(ThemeManager.getOiaStatusSysAvail(theme));
} else {
wordWrapStatus.setText("");
}
// Field status: Numeric vs Alphanumeric
if (state.isFullSession() && client.getScreenBuffer().isFormatted()) {
if (client.getOIA().isNumeric()) {
@@ -84,6 +84,33 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
private long lastGraphicsUpdateCount = -1;
private java.awt.image.BufferedImage cachedGraphicsImage = null;
private boolean resizeGuard = false;
private StatusBar statusBar = null;
// ========== Mode and Listener State ==========
private boolean insertMode = false;
public interface ModeChangeListener {
void onModeChanged();
}
private final java.util.List<ModeChangeListener> modeChangeListeners = new java.util.concurrent.CopyOnWriteArrayList<>();
public void addModeChangeListener(ModeChangeListener listener) {
if (listener != null && !modeChangeListeners.contains(listener)) {
modeChangeListeners.add(listener);
}
}
public void removeModeChangeListener(ModeChangeListener listener) {
modeChangeListeners.remove(listener);
}
public void fireModeChanged() {
for (ModeChangeListener l : modeChangeListeners) {
try {
l.onModeChanged();
} catch (Exception ignored) {}
}
}
/**
* Compute the horizontal render offset to center the grid within the panel.
@@ -331,12 +358,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
addMouseListener(mouseHandler);
addMouseMotionListener(mouseHandler);
// Handle resize: auto-fit font to window size
// Handle resize: keep user configured font persistent without auto-fit reset
addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
if (resizeGuard) return;
autoFitFont();
repaint();
}
});
}
@@ -461,13 +488,27 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
try {
String text = (String) Toolkit.getDefaultToolkit().getSystemClipboard()
.getData(DataFlavor.stringFlavor);
if (text != null) {
for (char ch : text.toCharArray()) {
if (ch == '\n' || ch == '\r') {
continue;
}
if (ch >= 0x20 && ch != 0x7F) {
client.typeCharacter(ch);
if (text != null && !text.isEmpty()) {
ScreenBuffer sb = client.getScreenBuffer();
int curPos = sb != null ? sb.getDisplayCursorAddress() : 0;
int cols = sb != null ? sb.getDisplayCols() : 80;
int curRow = cols > 0 ? curPos / cols : 0;
int curCol = cols > 0 ? curPos % cols : 0;
boolean excelPaste = haus.nightmare.j3270.config.Settings.getEnablePasteFromExcel();
boolean stopAtProtected = haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine();
if (client.getInputProcessor() != null && (excelPaste || stopAtProtected || text.contains("\t") || text.contains("\n") || text.contains("\r"))) {
client.getInputProcessor().pasteText(text, excelPaste, stopAtProtected);
} else if (client.getPS() != null) {
client.getPS().pasteString(text, curRow, curCol);
} else {
for (char ch : text.toCharArray()) {
if (ch == '\n' || ch == '\r') {
continue;
}
if (ch >= 0x20 && ch != 0x7F) {
client.typeCharacter(ch);
}
}
}
refreshScreen();
@@ -564,53 +605,20 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
}
// ========== Font auto-resize ==========
private void autoFitFont() {
int panelW = getWidth();
int panelH = getHeight();
if (panelW <= 0 || panelH <= 0) return;
int termCols = 80;
int termRows = 24;
if (client != null) {
ScreenBuffer sb = client.getScreenBuffer();
termCols = sb.getDisplayCols();
termRows = sb.getDisplayRows();
}
int availW = panelW - 2 * padding;
int availH = panelH - 2 * padding;
if (availW <= 0 || availH <= 0) return;
int bestSize = 8;
for (int testSize = 8; testSize <= 72; testSize++) {
Font testFont = new Font(terminalFont.getFamily(), Font.PLAIN, testSize);
FontMetrics fm = getFontMetrics(testFont);
int testCellW = fm.charWidth('M');
int testCellH = fm.getHeight();
if (testCellW * termCols <= availW && testCellH * termRows <= availH) {
bestSize = testSize;
} else {
break;
}
}
if (bestSize != currentFontSize) {
currentFontSize = bestSize;
haus.nightmare.j3270.config.Settings.setFontSize(bestSize);
terminalFont = new Font(terminalFont.getFamily(), Font.PLAIN, bestSize);
updateCellSize();
}
repaint();
}
public void guardedPack() {
if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) {
SwingUtilities.invokeLater(this::guardedPack);
return;
}
resizeGuard = true;
revalidate();
Container top = getTopLevelAncestor();
if (top instanceof java.awt.Window) {
if (top instanceof java.awt.Frame) {
if ((((java.awt.Frame) top).getExtendedState() & java.awt.Frame.MAXIMIZED_BOTH) == 0) {
((java.awt.Window) top).pack();
}
} else if (top instanceof java.awt.Window) {
((java.awt.Window) top).pack();
}
SwingUtilities.invokeLater(() -> resizeGuard = false);
@@ -643,7 +651,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
"PAGE_UP", "PAGE_DOWN", "HOME", "END", "ENTER",
"ESCAPE", "INSERT", "DELETE", "BACK_SPACE" };
for (String key : navKeys) {
String binding = haus.nightmare.j3270.config.Settings.getKeyBinding(key, key);
String defBinding = haus.nightmare.j3270.config.Settings.getDefaultBinding(key);
String binding = haus.nightmare.j3270.config.Settings.getKeyBinding(key, defBinding);
bindKeyToMap(im, key, binding);
}
@@ -671,18 +680,31 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
bindKeyToMap(im, "SYSREQ", haus.nightmare.j3270.config.Settings.getKeyBinding("SYSREQ", "alt S"));
bindKeyToMap(im, "CURSEL", haus.nightmare.j3270.config.Settings.getKeyBinding("CURSEL", "alt Q"));
// Operational Mode keybindings
bindKeyToMap(im, "DOCMODE", haus.nightmare.j3270.config.Settings.getKeyBinding("DOCMODE", "alt F1"));
bindKeyToMap(im, "WORDWRAP", haus.nightmare.j3270.config.Settings.getKeyBinding("WORDWRAP", "alt F2"));
bindKeyToMap(im, "APL", haus.nightmare.j3270.config.Settings.getKeyBinding("APL", "alt F3"));
// Copy/Paste/Lightpen bindings
int shortcutMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx();
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_C, shortcutMask), "j3270-COPY");
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_V, shortcutMask), "j3270-PASTE");
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_A, shortcutMask), "j3270-SELECTALL");
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_L, java.awt.event.InputEvent.ALT_DOWN_MASK), "j3270-LIGHTPEN");
String defCopy = haus.nightmare.j3270.config.Settings.getDefaultBinding("COPY");
String defPaste = haus.nightmare.j3270.config.Settings.getDefaultBinding("PASTE");
String defSelectAll = haus.nightmare.j3270.config.Settings.getDefaultBinding("SELECTALL");
bindKeyToMap(im, "COPY", haus.nightmare.j3270.config.Settings.getKeyBinding("COPY", defCopy));
bindKeyToMap(im, "PASTE", haus.nightmare.j3270.config.Settings.getKeyBinding("PASTE", defPaste));
bindKeyToMap(im, "SELECTALL", haus.nightmare.j3270.config.Settings.getKeyBinding("SELECTALL", defSelectAll));
bindKeyToMap(im, "LIGHTPEN", haus.nightmare.j3270.config.Settings.getKeyBinding("LIGHTPEN", "alt L"));
String defStatusBar = haus.nightmare.j3270.config.Settings.getDefaultBinding("STATUS_BAR");
bindKeyToMap(im, "STATUS_BAR", haus.nightmare.j3270.config.Settings.getKeyBinding("STATUS_BAR", defStatusBar));
// Action map implementations
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-DOCMODE", createAction(this::toggleDocMode));
am.put("j3270-WORDWRAP", createAction(this::toggleWordWrap));
am.put("j3270-APL", createAction(this::toggleAplMode));
am.put("j3270-UP", createAction(() -> handleCursor("up")));
am.put("j3270-DOWN", createAction(() -> handleCursor("down")));
am.put("j3270-LEFT", createAction(() -> handleCursor("left")));
@@ -693,7 +715,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
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-INSERT", createAction(this::toggleInsertMode));
am.put("j3270-CLEAR", createAction(this::handleClear));
am.put("j3270-ERASE_INPUT", createAction(this::handleEraseInput));
am.put("j3270-NEWLINE", createAction(this::handleNewline));
@@ -707,6 +729,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
am.put("j3270-PASTE", createAction(this::pasteClipboard));
am.put("j3270-SELECTALL", createAction(this::selectAll));
am.put("j3270-LIGHTPEN", createAction(this::toggleLightPen));
am.put("j3270-STATUS_BAR", createAction(this::toggleStatusBar));
for (int i = 1; i <= 24; i++) {
final int pf = i;
@@ -821,6 +844,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
} else if (state.isFullSession()) {
client.sendEnter();
refreshScreen();
fireModeChanged();
if (statusBar != null) statusBar.updateStatus();
}
}
}
@@ -837,6 +862,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
clearSelection();
client.reset();
refreshScreen();
fireModeChanged();
if (statusBar != null) statusBar.updateStatus();
}
}
@@ -905,6 +932,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
client.sendPF(n);
refreshScreen();
fireModeChanged();
if (statusBar != null) statusBar.updateStatus();
}
}
@@ -930,6 +959,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
if (client != null && client.getConnectionState().isFullSession()) {
client.sendPA(n);
refreshScreen();
fireModeChanged();
if (statusBar != null) statusBar.updateStatus();
}
}
@@ -973,12 +1004,65 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
}
private void handleInsert() {
if (client != null) {
public void toggleInsertMode() {
if (client != null && client.getInputProcessor() != null) {
var ip = client.getInputProcessor();
ip.setInsertMode(!ip.isInsertMode());
refreshScreen();
insertMode = ip.isInsertMode();
} else {
insertMode = !insertMode;
}
refreshScreen();
if (statusBar != null) statusBar.updateStatus();
fireModeChanged();
}
public boolean isInsertMode() {
if (client != null && client.getInputProcessor() != null) {
return client.getInputProcessor().isInsertMode();
}
return insertMode;
}
public void toggleDocMode() {
if (client != null) {
client.toggleDocMode();
haus.nightmare.j3270.config.Settings.setEntryAssistDocMode(client.isDocMode());
refreshScreen();
if (statusBar != null) statusBar.updateStatus();
fireModeChanged();
}
}
public void toggleWordWrap() {
if (client != null) {
client.toggleWordWrap();
haus.nightmare.j3270.config.Settings.setEntryAssistWordWrap(client.isWordWrap());
refreshScreen();
if (statusBar != null) statusBar.updateStatus();
fireModeChanged();
}
}
public void toggleAplMode() {
if (client != null) {
client.toggleAplMode();
refreshScreen();
if (statusBar != null) statusBar.updateStatus();
fireModeChanged();
}
}
public boolean isDocMode() {
return client != null && client.isDocMode();
}
public boolean isWordWrap() {
return client != null && client.isWordWrap();
}
public boolean isAplMode() {
return client != null && client.isAplMode();
}
private void handleClear() {
@@ -991,6 +1075,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
client.sendClear();
refreshScreen();
fireModeChanged();
if (statusBar != null) statusBar.updateStatus();
}
}
@@ -1046,11 +1132,73 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
}
public void setStatusBar(StatusBar statusBar) {
this.statusBar = statusBar;
}
public StatusBar getStatusBar() {
return statusBar;
}
private Runnable statusBarToggleCallback;
public void setStatusBarToggleCallback(Runnable callback) {
this.statusBarToggleCallback = callback;
}
public void toggleStatusBar() {
if (statusBarToggleCallback != null) {
statusBarToggleCallback.run();
} else if (statusBar != null) {
boolean visible = !statusBar.isVisible();
statusBar.setVisible(visible);
haus.nightmare.j3270.config.Settings.setStatusBarVisible(visible);
guardedPack();
}
}
public void applyModeSettings() {
if (client != null) {
ScreenBuffer sb = client.getScreenBuffer();
if (sb != null) {
sb.setEntryAssistDOCmode(haus.nightmare.j3270.config.Settings.getEntryAssistDocMode());
sb.setEntryAssistWordWrap(haus.nightmare.j3270.config.Settings.getEntryAssistWordWrap());
sb.setLeftMargin(Math.max(0, haus.nightmare.j3270.config.Settings.getEntryAssistStartCol() - 1));
sb.setRightMargin(Math.max(0, haus.nightmare.j3270.config.Settings.getEntryAssistEndCol() - 1));
sb.setWordTabPositions(haus.nightmare.j3270.config.Settings.getEntryAssistTabStopsArray());
}
haus.nightmare.lib3270j.input.InputProcessor ip = client.getInputProcessor();
if (ip != null) {
ip.setBellEnabled(haus.nightmare.j3270.config.Settings.getEntryAssistBell());
ip.setBellColumn(Math.max(0, haus.nightmare.j3270.config.Settings.getEntryAssistBellCol() - 1));
if (ip.getBellListener() == null) {
ip.setBellListener(() -> {
SwingUtilities.invokeLater(() -> Toolkit.getDefaultToolkit().beep());
});
}
ip.setInsertOffOnAid(haus.nightmare.j3270.config.Settings.getInsertOffOnAid());
ip.setNumericFieldLock(haus.nightmare.j3270.config.Settings.getNumericFieldLock());
ip.setAutoSkipEnabled(haus.nightmare.j3270.config.Settings.getAutoSkipEnabled());
}
if (client.getPS() != null) {
client.getPS().setEnablePasteFromExcel(haus.nightmare.j3270.config.Settings.getEnablePasteFromExcel());
client.getPS().setPasteStopAtProtectedLine(haus.nightmare.j3270.config.Settings.getPasteStopAtProtectedLine());
}
}
if (statusBar != null) {
statusBar.updateStatus();
}
repaint();
}
public void refreshScreen() {
if (client != null) {
client.getScreenBuffer().updateDisplaySnapshot();
}
repaint();
if (statusBar != null) {
statusBar.updateStatus();
}
Container parent = getParent();
while (parent != null) {
if (parent instanceof JFrame) {
@@ -1088,6 +1236,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
setupColors();
setupKeyBindings();
setupFont();
applyModeSettings();
blockSelectMode = haus.nightmare.j3270.config.Settings.getBlockSelectMode();
crosshairRulerEnabled = haus.nightmare.j3270.config.Settings.getCrosshairRuler();
String cStyle = haus.nightmare.j3270.config.Settings.getCursorStyle();
@@ -1137,6 +1286,14 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
if (client != null) {
setupGraphicsPlaneRenderer();
updateCellSize();
applyModeSettings();
if (client.getInputProcessor() != null) {
client.getInputProcessor().setInsertMode(insertMode);
client.getInputProcessor().setBellListener(() -> {
SwingUtilities.invokeLater(() -> Toolkit.getDefaultToolkit().beep());
});
}
client.setNvtClipboardHandler(new haus.nightmare.lib3270j.nvt.NvtProcessor.ClipboardHandler() {
@Override
@@ -1169,6 +1326,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
});
});
}
fireModeChanged();
if (statusBar != null) statusBar.updateStatus();
}
private void setupGraphicsPlaneRenderer() {
@@ -1290,8 +1449,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
if (rgb != null && gWidth > 0 && gHeight > 0) {
long currentUpdateCount = client.getGraphicsPlane().getUpdateCount();
if (cachedGraphicsImage == null || currentUpdateCount != lastGraphicsUpdateCount || cachedGraphicsImage.getWidth() != gWidth || cachedGraphicsImage.getHeight() != gHeight) {
cachedGraphicsImage = new java.awt.image.BufferedImage(gWidth, gHeight, java.awt.image.BufferedImage.TYPE_INT_ARGB);
cachedGraphicsImage.setRGB(0, 0, gWidth, gHeight, rgb, 0, gWidth);
cachedGraphicsImage = AwtPixelBufferBridge.toBufferedImage(client.getGraphicsPlane());
lastGraphicsUpdateCount = currentUpdateCount;
}
if (gWidth == gridW && gHeight == gridH) {
@@ -1406,7 +1564,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
haus.nightmare.lib3270j.graphics.ProgramSymbolSet.SymbolSlot slot = client.getProgramSymbolManager().getSymbol(cs, ea.ec & 0xFF);
if (slot != null) {
int symBg = (bgIsExplicit || reverse) ? bgColor.getRGB() : 0;
java.awt.image.BufferedImage img = slot.getScaledImage(cellWidth, cellHeight, fgColor.getRGB(), symBg);
haus.nightmare.lib3270j.graphics.PixelBuffer symPb = slot.getScaledPixelBuffer(cellWidth, cellHeight, fgColor.getRGB(), symBg);
java.awt.image.BufferedImage img = AwtPixelBufferBridge.toBufferedImage(symPb);
if (img != null) {
g2.drawImage(img, x, y, null);
drawnAsPs = true;
@@ -1549,7 +1708,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
public void setHodWallpaper(haus.nightmare.lib3270j.graphics.HODWallpaper wallpaper) {
this.hodWallpaper = wallpaper;
if (wallpaper != null) {
this.wallpaperImage = wallpaper.getHODImage();
Object hodImg = wallpaper.getHODImage();
if (hodImg instanceof Image) {
this.wallpaperImage = (Image) hodImg;
} else if (hodImg instanceof haus.nightmare.lib3270j.graphics.PixelBuffer) {
this.wallpaperImage = AwtPixelBufferBridge.toBufferedImage((haus.nightmare.lib3270j.graphics.PixelBuffer) hodImg);
}
}
repaint();
}
@@ -1564,10 +1728,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
}
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 (!haus.nightmare.j3270.config.Settings.getFourColorOverride()) {
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_TURQUOISE];
@@ -251,11 +251,30 @@ public final class ThemeManager {
public static final Color HOD_OIA_COMM_CHECK_ERROR = new Color(255, 0, 0); // oEI: Red
public static final Color HOD_OIA_BG_BLACK = new Color(0, 0, 0); // oOB: Black
public static Color getOiaStatusSysAvail() { return HOD_OIA_STATUS_SYS_AVAIL; }
public static Color getOiaInputInhibited() { return HOD_OIA_INPUT_INHIBITED; }
public static Color getOiaAttention() { return HOD_OIA_ATTENTION_WARN; }
public static Color getOiaCommCheck() { return HOD_OIA_COMM_CHECK_ERROR; }
public static Color getOiaBackground() { return HOD_OIA_BG_BLACK; }
public static Color getOiaStatusSysAvail() { return getOiaStatusSysAvail(currentTheme); }
public static Color getOiaStatusSysAvail(UITheme t) {
return t == UITheme.DARK ? HOD_OIA_STATUS_SYS_AVAIL : new Color(0, 90, 200);
}
public static Color getOiaInputInhibited() { return getOiaInputInhibited(currentTheme); }
public static Color getOiaInputInhibited(UITheme t) {
return t == UITheme.DARK ? HOD_OIA_INPUT_INHIBITED : new Color(20, 20, 20);
}
public static Color getOiaAttention() { return getOiaAttention(currentTheme); }
public static Color getOiaAttention(UITheme t) {
return t == UITheme.DARK ? HOD_OIA_ATTENTION_WARN : new Color(180, 100, 0);
}
public static Color getOiaCommCheck() { return getOiaCommCheck(currentTheme); }
public static Color getOiaCommCheck(UITheme t) {
return t == UITheme.DARK ? HOD_OIA_COMM_CHECK_ERROR : new Color(190, 20, 20);
}
public static Color getOiaBackground() { return getOiaBackground(currentTheme); }
public static Color getOiaBackground(UITheme t) {
return t == UITheme.DARK ? HOD_OIA_BG_BLACK : getStatusBarBg(t);
}
// =========================================================================
// Button Variants & Color helpers
@@ -647,6 +666,16 @@ public final class ThemeManager {
return;
}
if (comp instanceof StatusBar) {
((StatusBar) comp).applyTheme(theme);
return;
}
if (comp instanceof TerminalPanel) {
comp.repaint();
return;
}
if (comp instanceof JMenuBar) {
styleMenuBar((JMenuBar) comp);
for (int i = 0; i < ((JMenuBar) comp).getMenuCount(); i++) {
@@ -0,0 +1,102 @@
package haus.nightmare.j3270.ft;
import haus.nightmare.lib3270j.ft.FTConfig;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.HeadlessException;
import static org.junit.jupiter.api.Assertions.*;
public class FileTransferSessionAndCmsTest {
@BeforeEach
@AfterEach
public void cleanup() {
FileTransferDialog.resetSessionState();
}
@Test
public void testCmsFilenamePeriodReplacement() {
// Test replacing periods with spaces for CMS
assertEquals("fscms exec", FileTransferDialog.formatHostFilename("fscms.exec", FTConfig.HostType.CMS));
assertEquals("fscms exec", FileTransferDialog.formatHostFilename("/var/tmp/fscms.exec", FTConfig.HostType.CMS));
assertEquals("test script exec", FileTransferDialog.formatHostFilename("test.script.exec", FTConfig.HostType.CMS));
assertEquals("noextension", FileTransferDialog.formatHostFilename("noextension", FTConfig.HostType.CMS));
// TSO and CICS should retain periods
assertEquals("fscms.exec", FileTransferDialog.formatHostFilename("fscms.exec", FTConfig.HostType.TSO));
assertEquals("my.dataset.name", FileTransferDialog.formatHostFilename("/path/to/my.dataset.name", FTConfig.HostType.TSO));
assertEquals("cics.file", FileTransferDialog.formatHostFilename("cics.file", FTConfig.HostType.CICS));
}
@Test
public void testSessionStateRememberedInMemory() {
assertNull(FileTransferDialog.getLastTransferState());
FileTransferDialog.TransferSessionState state = new FileTransferDialog.TransferSessionState();
state.hostType = FTConfig.HostType.CMS;
state.isSend = true;
state.localFile = "/tmp/fscms.exec";
state.hostFile = "fscms exec";
state.isAscii = true;
state.mtu = 8192;
state.crFlag = false;
state.remapFlag = false;
state.append = true;
state.overwrite = true;
state.options = "CLEAR";
FileTransferDialog.setLastTransferState(state);
FileTransferDialog.TransferSessionState retrieved = FileTransferDialog.getLastTransferState();
assertNotNull(retrieved);
assertEquals(FTConfig.HostType.CMS, retrieved.hostType);
assertTrue(retrieved.isSend);
assertEquals("/tmp/fscms.exec", retrieved.localFile);
assertEquals("fscms exec", retrieved.hostFile);
assertTrue(retrieved.isAscii);
assertEquals(8192, retrieved.mtu);
assertFalse(retrieved.crFlag);
assertFalse(retrieved.remapFlag);
assertTrue(retrieved.append);
assertTrue(retrieved.overwrite);
assertEquals("CLEAR", retrieved.options);
}
@Test
public void testDialogPrefillingFromSessionState() {
FileTransferDialog.TransferSessionState state = new FileTransferDialog.TransferSessionState();
state.hostType = FTConfig.HostType.CMS;
state.isSend = true;
state.localFile = "/tmp/fscms.exec";
state.hostFile = "fscms exec";
state.isAscii = false; // Binary
state.mtu = 4096;
state.crFlag = true;
state.remapFlag = true;
state.append = false;
state.overwrite = true;
state.options = "ASCII";
FileTransferDialog.setLastTransferState(state);
try {
FileTransferDialog dialog = new FileTransferDialog(null, null);
assertEquals(FTConfig.HostType.CMS, dialog.getHostTypeCombo().getSelectedItem());
assertTrue(dialog.getSendRadio().isSelected());
assertFalse(dialog.getReceiveRadio().isSelected());
assertEquals("/tmp/fscms.exec", dialog.getLocalFileField().getText());
assertEquals("fscms exec", dialog.getHostFileField().getText());
assertFalse(dialog.getAsciiRadio().isSelected());
assertTrue(dialog.getBinaryRadio().isSelected());
assertEquals(4096, dialog.getMtuCombo().getSelectedItem());
assertTrue(dialog.getOverwriteCheck().isSelected());
assertEquals("ASCII", dialog.getOptionsField().getText());
dialog.dispose();
} catch (HeadlessException e) {
// Headless environment; verified through state retention
}
}
}
@@ -0,0 +1,80 @@
package haus.nightmare.j3270.ui;
import haus.nightmare.j3270.config.Settings;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.Dimension;
import java.awt.HeadlessException;
import java.awt.event.ComponentEvent;
import static org.junit.jupiter.api.Assertions.*;
public class FontSettingsPersistenceTest {
private int originalFontSize;
private String originalFontFamily;
@BeforeEach
public void setup() {
originalFontSize = Settings.getFontSize();
originalFontFamily = Settings.getFontFamily();
}
@AfterEach
public void tearDown() {
Settings.setFontSize(originalFontSize);
Settings.setFontFamily(originalFontFamily);
}
@Test
public void testSettingsPersistence() {
Settings.setFontSize(22);
Settings.setFontFamily("Monospaced");
assertEquals(22, Settings.getFontSize());
assertEquals("Monospaced", Settings.getFontFamily());
Settings.setFontSize(18);
assertEquals(18, Settings.getFontSize());
}
@Test
public void testTerminalPanelResizeDoesNotResetFontSize() {
try {
Settings.setFontSize(24);
Settings.setFontFamily("Monospaced");
TerminalPanel panel = new TerminalPanel();
assertEquals(24, panel.getFontSize());
assertEquals(24, Settings.getFontSize());
// Simulate window / component resize events
panel.setSize(new Dimension(800, 600));
ComponentEvent resizeEvent1 = new ComponentEvent(panel, ComponentEvent.COMPONENT_RESIZED);
for (java.awt.event.ComponentListener cl : panel.getComponentListeners()) {
cl.componentResized(resizeEvent1);
}
// Verify font size and family did NOT reset
assertEquals(24, panel.getFontSize());
assertEquals(24, Settings.getFontSize());
assertEquals("Monospaced", Settings.getFontFamily());
// Simulate another resize to smaller dimension
panel.setSize(new Dimension(400, 300));
ComponentEvent resizeEvent2 = new ComponentEvent(panel, ComponentEvent.COMPONENT_RESIZED);
for (java.awt.event.ComponentListener cl : panel.getComponentListeners()) {
cl.componentResized(resizeEvent2);
}
assertEquals(24, panel.getFontSize());
assertEquals(24, Settings.getFontSize());
assertEquals("Monospaced", Settings.getFontFamily());
panel.dispose();
} catch (HeadlessException e) {
// In headless environment without display, Settings persistence is verified above
}
}
}
@@ -0,0 +1,337 @@
package haus.nightmare.j3270.ui;
import haus.nightmare.j3270.J3270App;
import haus.nightmare.j3270.config.Settings;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.Telnet3270Client;
import org.junit.jupiter.api.Test;
import javax.swing.*;
import java.awt.HeadlessException;
import java.awt.event.ActionEvent;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
public class KeyBindingsTest {
@Test
public void testDefaultBindingsContainRequiredKeys() {
String insertBindings = Settings.getDefaultBinding("INSERT");
assertNotNull(insertBindings);
assertTrue(insertBindings.contains("INSERT"), "Default INSERT binding should contain INSERT key");
if (Settings.isMac()) {
assertTrue(insertBindings.contains("HELP"), "macOS INSERT binding should contain HELP key");
assertTrue(insertBindings.contains("alt I"), "macOS INSERT binding should contain alt I fallback");
} else {
assertTrue(insertBindings.contains("ctrl I"), "Non-macOS INSERT binding should contain ctrl I fallback");
}
String copyBindings = Settings.getDefaultBinding("COPY");
assertNotNull(copyBindings);
assertTrue(copyBindings.contains("ctrl INSERT"), "Default COPY binding should support 3270 Ctrl+Insert");
assertTrue(copyBindings.contains("ctrl C"), "Default COPY binding should support Ctrl+C");
assertTrue(copyBindings.contains("ctrl shift C"), "Default COPY binding should support Ctrl+Shift+C");
if (Settings.isMac()) {
assertTrue(copyBindings.contains("meta C"), "macOS COPY binding should support Cmd+C");
}
String pasteBindings = Settings.getDefaultBinding("PASTE");
assertNotNull(pasteBindings);
assertTrue(pasteBindings.contains("shift INSERT"), "Default PASTE binding should support 3270 Shift+Insert");
assertTrue(pasteBindings.contains("ctrl V"), "Default PASTE binding should support Ctrl+V");
assertTrue(pasteBindings.contains("ctrl shift V"), "Default PASTE binding should support Ctrl+Shift+V");
if (Settings.isMac()) {
assertTrue(pasteBindings.contains("meta V"), "macOS PASTE binding should support Cmd+V");
}
String selectAllBindings = Settings.getDefaultBinding("SELECTALL");
assertNotNull(selectAllBindings);
assertTrue(selectAllBindings.contains("ctrl A"), "Default SELECTALL binding should support Ctrl+A");
if (Settings.isMac()) {
assertTrue(selectAllBindings.contains("meta A"), "macOS SELECTALL binding should support Cmd+A");
}
}
@Test
public void testTerminalPanelInputMapAndAction() {
TerminalPanel panel = new TerminalPanel();
InputMap im = panel.getInputMap(JComponent.WHEN_FOCUSED);
ActionMap am = panel.getActionMap();
// Check INSERT key mappings
KeyStroke ksInsert = KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, 0);
assertEquals("j3270-INSERT", im.get(ksInsert), "VK_INSERT must map to j3270-INSERT");
if (Settings.isMac()) {
KeyStroke ksHelp = KeyStroke.getKeyStroke(KeyEvent.VK_HELP, 0);
assertEquals("j3270-INSERT", im.get(ksHelp), "VK_HELP must map to j3270-INSERT on Mac");
KeyStroke ksAltI = KeyStroke.getKeyStroke("alt I");
assertEquals("j3270-INSERT", im.get(ksAltI), "alt I must map to j3270-INSERT on Mac");
}
// Check COPY key mappings
KeyStroke ksCtrlIns = KeyStroke.getKeyStroke("ctrl INSERT");
assertEquals("j3270-COPY", im.get(ksCtrlIns), "ctrl INSERT must map to j3270-COPY");
KeyStroke ksCtrlC = KeyStroke.getKeyStroke("ctrl C");
assertEquals("j3270-COPY", im.get(ksCtrlC), "ctrl C must map to j3270-COPY");
if (Settings.isMac()) {
KeyStroke ksMetaC = KeyStroke.getKeyStroke("meta C");
assertEquals("j3270-COPY", im.get(ksMetaC), "meta C must map to j3270-COPY on Mac");
}
// Check PASTE key mappings
KeyStroke ksShiftIns = KeyStroke.getKeyStroke("shift INSERT");
assertEquals("j3270-PASTE", im.get(ksShiftIns), "shift INSERT must map to j3270-PASTE");
KeyStroke ksCtrlV = KeyStroke.getKeyStroke("ctrl V");
assertEquals("j3270-PASTE", im.get(ksCtrlV), "ctrl V must map to j3270-PASTE");
if (Settings.isMac()) {
KeyStroke ksMetaV = KeyStroke.getKeyStroke("meta V");
assertEquals("j3270-PASTE", im.get(ksMetaV), "meta V must map to j3270-PASTE on Mac");
}
// Check SELECTALL key mappings
KeyStroke ksCtrlA = KeyStroke.getKeyStroke("ctrl A");
assertEquals("j3270-SELECTALL", im.get(ksCtrlA), "ctrl A must map to j3270-SELECTALL");
if (Settings.isMac()) {
KeyStroke ksMetaA = KeyStroke.getKeyStroke("meta A");
assertEquals("j3270-SELECTALL", im.get(ksMetaA), "meta A must map to j3270-SELECTALL on Mac");
}
// Test insert mode toggle action
assertFalse(panel.isInsertMode(), "Initial insertMode should be false");
Action insertAction = am.get("j3270-INSERT");
assertNotNull(insertAction, "j3270-INSERT action must exist");
AtomicBoolean modeChangedFired = new AtomicBoolean(false);
panel.addModeChangeListener(() -> modeChangedFired.set(true));
insertAction.actionPerformed(new ActionEvent(panel, ActionEvent.ACTION_PERFORMED, ""));
assertTrue(panel.isInsertMode(), "insertMode should be true after invoking action");
assertTrue(modeChangedFired.get(), "ModeChangeListener should have fired");
modeChangedFired.set(false);
insertAction.actionPerformed(new ActionEvent(panel, ActionEvent.ACTION_PERFORMED, ""));
assertFalse(panel.isInsertMode(), "insertMode should be false after invoking action again");
assertTrue(modeChangedFired.get(), "ModeChangeListener should have fired again");
}
@Test
public void testInsertModeSyncWithClient() {
TerminalPanel panel = new TerminalPanel();
panel.toggleInsertMode();
assertTrue(panel.isInsertMode());
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
panel.setClient(client);
// When client is attached, client's inputProcessor should receive current insertMode
assertNotNull(client.getInputProcessor());
assertTrue(client.getInputProcessor().isInsertMode(), "Client inputProcessor should inherit insertMode");
assertTrue(panel.isInsertMode());
// Toggling on panel updates client
panel.toggleInsertMode();
assertFalse(client.getInputProcessor().isInsertMode());
assertFalse(panel.isInsertMode());
panel.toggleInsertMode();
assertTrue(client.getInputProcessor().isInsertMode());
assertTrue(panel.isInsertMode());
}
@Test
public void testJ3270AppModesMenuSync() {
J3270App app;
try {
app = new J3270App();
} catch (HeadlessException e) {
return;
}
try {
JMenuBar mb = app.getJMenuBar();
assertNotNull(mb);
JMenu modesMenu = null;
for (int i = 0; i < mb.getMenuCount(); i++) {
JMenu m = mb.getMenu(i);
if (m != null && "Modes".equals(m.getText())) {
modesMenu = m;
break;
}
}
assertNotNull(modesMenu, "Modes menu should exist");
JCheckBoxMenuItem insertItem = null;
for (int i = 0; i < modesMenu.getItemCount(); i++) {
JMenuItem item = modesMenu.getItem(i);
if (item instanceof JCheckBoxMenuItem && "Insert Mode".equals(item.getText())) {
insertItem = (JCheckBoxMenuItem) item;
break;
}
}
assertNotNull(insertItem, "Insert Mode menu item should exist");
assertFalse(insertItem.isSelected(), "Initial Insert Mode item should not be selected");
// Find terminal panel and toggle
TerminalPanel panel = null;
for (java.awt.Component c : app.getContentPane().getComponents()) {
if (c instanceof TerminalPanel) {
panel = (TerminalPanel) c;
break;
}
}
assertNotNull(panel, "TerminalPanel should be found");
// Toggling via panel updates menu item
panel.toggleInsertMode();
assertTrue(panel.isInsertMode());
assertTrue(insertItem.isSelected(), "Menu item should reflect true insert mode");
panel.toggleInsertMode();
assertFalse(panel.isInsertMode());
assertFalse(insertItem.isSelected(), "Menu item should reflect false insert mode");
// Clicking menu item toggles insert mode on panel
insertItem.doClick();
assertTrue(panel.isInsertMode(), "Panel should be in insert mode after clicking menu item");
assertTrue(insertItem.isSelected(), "Menu item should be selected after click");
insertItem.doClick();
assertFalse(panel.isInsertMode(), "Panel should exit insert mode after clicking menu item");
assertFalse(insertItem.isSelected(), "Menu item should be deselected after click");
} finally {
app.dispose();
}
}
@Test
public void testCopyAndPasteActions() {
try {
java.awt.Toolkit.getDefaultToolkit().getSystemClipboard();
} catch (HeadlessException e) {
return;
}
TerminalPanel panel = new TerminalPanel();
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
panel.setClient(client);
var sb = client.getScreenBuffer();
sb.setChar(0, 0, 'J');
sb.setChar(0, 1, '3');
sb.setChar(0, 2, '2');
sb.setChar(0, 3, '7');
sb.setChar(0, 4, '0');
panel.setSelectionRange(0, 4);
assertTrue(panel.hasSelection());
assertEquals("J3270", panel.getSelectedText());
Action copyAction = panel.getActionMap().get("j3270-COPY");
assertNotNull(copyAction);
copyAction.actionPerformed(new ActionEvent(panel, ActionEvent.ACTION_PERFORMED, ""));
try {
String clip = (String) java.awt.Toolkit.getDefaultToolkit().getSystemClipboard()
.getData(java.awt.datatransfer.DataFlavor.stringFlavor);
assertEquals("J3270", clip);
} catch (Exception e) {
fail("Clipboard read failed: " + e.getMessage());
}
// Test multiline paste handling
try {
java.awt.Toolkit.getDefaultToolkit().getSystemClipboard().setContents(
new java.awt.datatransfer.StringSelection("LINE1\nLINE2"), null);
Action pasteAction = panel.getActionMap().get("j3270-PASTE");
assertNotNull(pasteAction);
pasteAction.actionPerformed(new ActionEvent(panel, ActionEvent.ACTION_PERFORMED, ""));
// Shouldn't throw any exceptions
} catch (Exception e) {
fail("Paste action failed: " + e.getMessage());
}
}
@Test
public void testToggleStatusBar() {
assertEquals("alt B", Settings.getDefaultBinding("STATUS_BAR"));
TerminalPanel panel = new TerminalPanel();
InputMap im = panel.getInputMap(JComponent.WHEN_FOCUSED);
ActionMap am = panel.getActionMap();
KeyStroke ksAltB = KeyStroke.getKeyStroke("alt B");
assertEquals("j3270-STATUS_BAR", im.get(ksAltB), "alt B must map to j3270-STATUS_BAR");
assertNotNull(am.get("j3270-STATUS_BAR"), "j3270-STATUS_BAR action must exist");
J3270App app;
try {
app = new J3270App();
} catch (HeadlessException e) {
return;
}
try {
JMenuBar mb = app.getJMenuBar();
assertNotNull(mb);
JMenu viewMenu = null;
for (int i = 0; i < mb.getMenuCount(); i++) {
JMenu m = mb.getMenu(i);
if (m != null && "View".equals(m.getText())) {
viewMenu = m;
break;
}
}
assertNotNull(viewMenu);
JCheckBoxMenuItem statusBarItem = null;
for (int i = 0; i < viewMenu.getItemCount(); i++) {
JMenuItem item = viewMenu.getItem(i);
if (item instanceof JCheckBoxMenuItem && "Status Bar".equals(item.getText())) {
statusBarItem = (JCheckBoxMenuItem) item;
break;
}
}
assertNotNull(statusBarItem, "Status Bar menu item must exist in View menu");
assertTrue(statusBarItem.isSelected(), "Status bar should be selected by default");
StatusBar bar = null;
for (java.awt.Component c : app.getContentPane().getComponents()) {
if (c instanceof StatusBar) {
bar = (StatusBar) c;
break;
}
}
assertNotNull(bar, "StatusBar component should exist in content pane");
assertTrue(bar.isVisible(), "StatusBar should be visible by default");
// Test toggling via toggleStatusBar()
app.toggleStatusBar();
assertFalse(bar.isVisible(), "StatusBar should be hidden after toggle");
assertFalse(statusBarItem.isSelected(), "Menu item should be unchecked");
assertFalse(Settings.getStatusBarVisible());
app.toggleStatusBar();
assertTrue(bar.isVisible(), "StatusBar should be shown after second toggle");
assertTrue(statusBarItem.isSelected(), "Menu item should be checked");
assertTrue(Settings.getStatusBarVisible());
// Test toggling via menu item click
statusBarItem.doClick();
assertFalse(bar.isVisible(), "StatusBar should be hidden after menu click");
assertFalse(statusBarItem.isSelected(), "Menu item should be unchecked");
statusBarItem.doClick();
assertTrue(bar.isVisible(), "StatusBar should be shown after menu click");
assertTrue(statusBarItem.isSelected(), "Menu item should be checked");
} finally {
app.dispose();
Settings.setStatusBarVisible(true);
}
}
}
@@ -0,0 +1,105 @@
package haus.nightmare.j3270.ui;
import org.junit.jupiter.api.Test;
import java.io.StringReader;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for Phase 13.3: Keyboard Remap (.kmp) File Importer.
* Tests parsing of scan codes, modifier combinations, bind statements,
* mnemonic actions, and comments.
*/
public class KmpKeymapImportTest {
@Test
public void testParseScanCodeAssignments() throws Exception {
String kmpContent =
"# Sample keyboard profile mapping\n" +
"; IBM scan code mappings\n" +
"KEY43=[enter]\n" +
"S-KEY112=[pf13]\n" +
"C-KEY43=[newline]\n" +
"A-KEY110=[reset]\n" +
"KEY15=[tab]\n" +
"S-KEY15=[backtab]\n";
Map<String, String> imported = KeyBindings.parseKmp(new StringReader(kmpContent));
assertNotNull(imported);
// KEY43 is ENTER -> ENTER
assertEquals("ENTER", imported.get("ENTER"));
// S-KEY112 is Shift+F1 -> PF13
assertEquals("shift F1", imported.get("PF13"));
// C-KEY43 is Ctrl+ENTER -> NEWLINE
assertEquals("ctrl ENTER", imported.get("NEWLINE"));
// A-KEY110 is Alt+ESCAPE -> ESCAPE
assertEquals("alt ESCAPE", imported.get("ESCAPE"));
// KEY15 is TAB -> TAB
assertEquals("TAB", imported.get("TAB"));
// S-KEY15 is Shift+TAB -> shift TAB
assertEquals("shift TAB", imported.get("shift TAB"));
}
@Test
public void testParseBindStatements() throws Exception {
String kmpContent =
"bind [enter] Ctrl+Enter\n" +
"bind [pf1] F1\n" +
"bind [pf24] Shift+F12\n" +
"bind [eraseeof] End\n" +
"bind [clear] Pause\n";
Map<String, String> imported = KeyBindings.parseKmp(new StringReader(kmpContent));
assertNotNull(imported);
assertEquals("ctrl ENTER", imported.get("ENTER"));
assertEquals("F1", imported.get("PF1"));
assertEquals("shift F12", imported.get("PF24"));
assertEquals("END", imported.get("END"));
assertEquals("PAUSE", imported.get("CLEAR"));
}
@Test
public void testScanCodeWithMultipleModifiers() {
// C-S-KEY85 -> Ctrl+Shift+PAGE_UP
String res = KeyBindings.parseScanCodeEntry("CS-KEY85");
assertEquals("ctrl shift PAGE_UP", res);
// 2-KEY80 -> Alt+HOME (2- prefix is Alt in scan code syntax)
String altRes = KeyBindings.parseScanCodeEntry("2-KEY80");
assertEquals("alt HOME", altRes);
}
@Test
public void testMnemonicResolution() {
assertEquals("ENTER", KeyBindings.resolveMnemonic("[enter]"));
assertEquals("ENTER", KeyBindings.resolveMnemonic("enterreset"));
assertEquals("NEWLINE", KeyBindings.resolveMnemonic("[newline]"));
assertEquals("NEWLINE", KeyBindings.resolveMnemonic("[field-exit]"));
assertEquals("PF1", KeyBindings.resolveMnemonic("[pf1]"));
assertEquals("PF12", KeyBindings.resolveMnemonic("[pf12]"));
assertEquals("PF13", KeyBindings.resolveMnemonic("[spf1]"));
assertEquals("ESCAPE", KeyBindings.resolveMnemonic("[reset]"));
assertEquals("ERASE_INPUT", KeyBindings.resolveMnemonic("[erasefld]"));
assertEquals("DOCMODE", KeyBindings.resolveMnemonic("[docmode]"));
assertEquals("WORDWRAP", KeyBindings.resolveMnemonic("[wordwrap]"));
}
@Test
public void testCommentsAndBlankLinesIgnored() throws Exception {
String kmpContent =
"[ProfileHeader]\n" +
" \n" +
"# Full line comment\n" +
"; Another comment\n" +
"KEY42=[enter] ; inline comment\n" +
" \n";
Map<String, String> imported = KeyBindings.parseKmp(new StringReader(kmpContent));
assertEquals(1, imported.size());
assertEquals("ENTER", imported.get("ENTER"));
}
}
@@ -63,6 +63,7 @@ public class MenuBarShortcutsTest {
assertAcceleratorUsesAlt(viewItems.get("Font Size +"), KeyEvent.VK_EQUALS);
assertAcceleratorUsesAlt(viewItems.get("Font Size -"), KeyEvent.VK_MINUS);
assertAcceleratorUsesAlt(viewItems.get("Reset Font"), KeyEvent.VK_0);
assertAcceleratorUsesAlt(viewItems.get("Status Bar"), KeyEvent.VK_B);
// Crosshair Ruler must use Alt+Shift+R
JMenuItem rulerItem = viewItems.get("Crosshair Ruler");
@@ -37,6 +37,7 @@ public class Phase1UiOverlayTest {
@Test
@DisplayName("Item 1.5: ThemeManager HoD OIA category colors match specification")
public void testThemeManagerOiaColors() {
ThemeManager.setTheme(UITheme.DARK);
// oSI: Status / System Available -> CUSTOMBLUE (120, 144, 240)
assertEquals(new Color(120, 144, 240), ThemeManager.getOiaStatusSysAvail());
@@ -51,6 +52,15 @@ public class Phase1UiOverlayTest {
// oOB: OIA Separator / Background -> Black (0, 0, 0)
assertEquals(new Color(0, 0, 0), ThemeManager.getOiaBackground());
// Light mode OIA colors should be legible and not white on grey
ThemeManager.setTheme(UITheme.LIGHT);
assertNotEquals(Color.WHITE, ThemeManager.getOiaInputInhibited());
assertEquals(new Color(20, 20, 20), ThemeManager.getOiaInputInhibited());
assertEquals(new Color(0, 90, 200), ThemeManager.getOiaStatusSysAvail());
assertEquals(new Color(180, 100, 0), ThemeManager.getOiaAttention());
assertEquals(new Color(190, 20, 20), ThemeManager.getOiaCommCheck());
assertEquals(ThemeManager.getStatusBarBg(UITheme.LIGHT), ThemeManager.getOiaBackground());
}
@Test
@@ -62,8 +62,33 @@ public class ThemeManagerTest {
// Verify Status Bar colors
Color sbBg = ThemeManager.getStatusBarBg(theme);
Color sbNormal = ThemeManager.getOiaFgNormal(theme);
Color sbInhibited = ThemeManager.getOiaInputInhibited(theme);
Color sbSysAvail = ThemeManager.getOiaStatusSysAvail(theme);
Color sbAttention = ThemeManager.getOiaAttention(theme);
Color sbCommCheck = ThemeManager.getOiaCommCheck(theme);
assertNotNull(sbBg);
assertNotNull(sbNormal);
assertNotNull(sbInhibited);
assertNotNull(sbSysAvail);
assertNotNull(sbAttention);
assertNotNull(sbCommCheck);
// Verify status bar contrast: ensure NO white on grey in light mode
double sbBgLum = (0.299 * sbBg.getRed() + 0.587 * sbBg.getGreen() + 0.114 * sbBg.getBlue());
double sbInhibitedLum = (0.299 * sbInhibited.getRed() + 0.587 * sbInhibited.getGreen() + 0.114 * sbInhibited.getBlue());
double sbInhibitedDiff = Math.abs(sbBgLum - sbInhibitedLum);
assertTrue(sbInhibitedDiff > 120, "Status bar input inhibited (X PROT) contrast in " + theme + " must be > 120 (was " + sbInhibitedDiff + ")");
double sbSysAvailLum = (0.299 * sbSysAvail.getRed() + 0.587 * sbSysAvail.getGreen() + 0.114 * sbSysAvail.getBlue());
assertTrue(Math.abs(sbBgLum - sbSysAvailLum) > 100, "Status bar sys avail contrast in " + theme + " must be > 100");
double sbAttentionLum = (0.299 * sbAttention.getRed() + 0.587 * sbAttention.getGreen() + 0.114 * sbAttention.getBlue());
assertTrue(Math.abs(sbBgLum - sbAttentionLum) > 100, "Status bar attention contrast in " + theme + " must be > 100");
if (theme == UITheme.LIGHT) {
assertNotEquals(Color.WHITE, sbInhibited, "Status bar input inhibited text must not be pure white in light mode");
assertNotEquals(Color.WHITE, sbNormal, "Status bar normal text must not be pure white in light mode");
}
// Verify Button colors
Color btnDefBg = ThemeManager.getButtonBg(ThemeManager.ButtonVariant.DEFAULT, theme);
@@ -218,4 +243,23 @@ public class ThemeManagerTest {
tmpIni.delete();
}
}
@Test
public void testStatusBarLightModeLegibility() {
try {
ThemeManager.setTheme(UITheme.LIGHT);
StatusBar sb = new StatusBar();
sb.applyTheme(UITheme.LIGHT);
assertEquals(ThemeManager.getStatusBarBg(UITheme.LIGHT), sb.getBackground());
assertNotEquals(Color.WHITE, ThemeManager.getOiaInputInhibited());
assertNotEquals(Color.WHITE, ThemeManager.getOiaInputInhibited(UITheme.LIGHT));
// Verify recursive applyTheme does not break StatusBar background
ThemeManager.applyTheme(sb, UITheme.LIGHT);
assertEquals(ThemeManager.getStatusBarBg(UITheme.LIGHT), sb.getBackground());
} catch (HeadlessException e) {
// Handled in headless CI
}
}
}
@@ -58,6 +58,17 @@ public class ConnectionConfig {
private java.util.Map<String, String> environmentVariables = new java.util.LinkedHashMap<>();
private java.util.Map<String, String> userVariables = new java.util.LinkedHashMap<>();
// IBM HoD autoSysUnlock parity
private boolean autoSysUnlock = true;
// Phase 11: Enterprise Connection Resilience & Heartbeat
private boolean keepAliveEnabled = true;
private int keepAliveIntervalSeconds = 120;
private String keepAliveType = "NOP";
private boolean autoReconnect = false;
private int reconnectMaxRetries = 5;
private int tcpUserTimeoutMs = 0;
public ConnectionConfig() {}
public ConnectionConfig(String host, int port) {
@@ -280,6 +291,27 @@ public class ConnectionConfig {
}
}
public boolean isAutoSysUnlock() { return autoSysUnlock; }
public void setAutoSysUnlock(boolean autoSysUnlock) { this.autoSysUnlock = autoSysUnlock; }
public boolean isKeepAliveEnabled() { return keepAliveEnabled; }
public void setKeepAliveEnabled(boolean enabled) { this.keepAliveEnabled = enabled; }
public int getKeepAliveIntervalSeconds() { return keepAliveIntervalSeconds; }
public void setKeepAliveIntervalSeconds(int seconds) { this.keepAliveIntervalSeconds = seconds; }
public String getKeepAliveType() { return keepAliveType; }
public void setKeepAliveType(String type) { this.keepAliveType = (type != null) ? type.trim().toUpperCase() : "NOP"; }
public boolean isAutoReconnect() { return autoReconnect; }
public void setAutoReconnect(boolean autoReconnect) { this.autoReconnect = autoReconnect; }
public int getReconnectMaxRetries() { return reconnectMaxRetries; }
public void setReconnectMaxRetries(int retries) { this.reconnectMaxRetries = Math.max(0, retries); }
public int getTcpUserTimeoutMs() { return tcpUserTimeoutMs; }
public void setTcpUserTimeoutMs(int timeoutMs) { this.tcpUserTimeoutMs = Math.max(0, timeoutMs); }
/**
* Parse a host connection string which may include prefixes for TLS (e.g. "L:host:port", "ssl:host:port", "y:host:port"),
* plain TN3270 (e.g. "P:host:port", "plain:host:port", "non-e:host:port"), proxy flags (e.g. "--proxy=http://proxy:8080 host:23"),
@@ -303,10 +335,21 @@ public class ConnectionConfig {
String pUser = null;
String pPass = null;
boolean keepAlive = true;
boolean autoReconnect = false;
String[] tokens = s.split("\\s+");
StringBuilder remaining = new StringBuilder();
for (String tok : tokens) {
if (tok.startsWith("--proxy=") || tok.startsWith("-proxy=")) {
if (tok.equalsIgnoreCase("--keepalive") || tok.equalsIgnoreCase("-keepalive")) {
keepAlive = true;
} else if (tok.equalsIgnoreCase("--no-keepalive") || tok.equalsIgnoreCase("-no-keepalive")) {
keepAlive = false;
} else if (tok.equalsIgnoreCase("--autoreconnect") || tok.equalsIgnoreCase("-autoreconnect")) {
autoReconnect = true;
} else if (tok.equalsIgnoreCase("--no-autoreconnect") || tok.equalsIgnoreCase("-no-autoreconnect")) {
autoReconnect = false;
} else if (tok.startsWith("--proxy=") || tok.startsWith("-proxy=")) {
String proxyUrl = tok.substring(tok.indexOf('=') + 1).trim();
try {
java.net.URI uri = new java.net.URI(proxyUrl);
@@ -417,6 +460,8 @@ public class ConnectionConfig {
ConnectionConfig config = new ConnectionConfig(host, port, defaultModel != null ? defaultModel : TerminalModel.IBM_3279_4);
config.setUseTls(tls);
config.setTn3270eEnabled(tn3270e);
config.setKeepAliveEnabled(keepAlive);
config.setAutoReconnect(autoReconnect);
if (dynamic) {
config.setDynamicDimensions(dynRows, dynCols);
}
@@ -434,7 +479,7 @@ public class ConnectionConfig {
return terminalName;
}
if (isDynamicModel()) {
return extendedDataStream ? "IBM-DYNAMIC-E" : "IBM-DYNAMIC";
return "IBM-DYNAMIC";
}
return extendedDataStream ? model.getTerminalType() : model.getBaseTerminalType();
}
@@ -44,6 +44,9 @@ public class Telnet3270Client {
private final haus.nightmare.lib3270j.ecl.ECLXfer xfer;
private TelnetConnection connection;
private final java.util.concurrent.atomic.AtomicBoolean reconnecting = new java.util.concurrent.atomic.AtomicBoolean(false);
private volatile Thread reconnectThread;
public Telnet3270Client(ConnectionConfig config) {
this.config = config;
this.translator = new EbcdicTranslator(config.getCodePage());
@@ -58,6 +61,7 @@ public class Telnet3270Client {
this.screenBuffer = new ScreenBuffer(config.getModel(), translator);
}
this.dsProcessor = new DataStreamProcessor(screenBuffer, translator);
this.dsProcessor.setAutoSysUnlock(config.isAutoSysUnlock());
this.dsProcessor.getQueryReplyBuilder().setGraphicsMode(config.getGraphicsMode());
this.fsm = new TelnetFSM(config, screenBuffer, dsProcessor);
this.inputProcessor = new InputProcessor(screenBuffer, translator, fsm);
@@ -87,6 +91,33 @@ public class Telnet3270Client {
@Override public void onSoundAlarm() {
ps.notifyAlarm();
}
@Override public void onKeyboardUnlocked() {
ps.notifyKeyUnlocked();
}
});
// Phase 11: Auto-reconnection and OIA status coordinator
fsm.addConnectionListener(new ConnectionListener() {
@Override
public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) {
if (newState == ConnectionState.RECONNECTING) {
if (oia != null) {
oia.writeToOIA("X RECONNECT");
}
initiateAutoReconnect();
} else if (newState == ConnectionState.NOT_CONNECTED) {
if (oia != null) {
oia.setInputInhibited(haus.nightmare.lib3270j.ecl.ECLOIA.INHIBIT_COMMCHECK);
}
} else if (newState.isFullSession()) {
if (oia != null) {
oia.setInputInhibited(-1);
}
}
}
@Override
public void onConnectionError(String message) {}
});
}
@@ -159,11 +190,146 @@ public class Telnet3270Client {
* Disconnect from the host.
*/
public void disconnect() {
cancelAutoReconnect();
if (connection != null) {
connection.disconnect();
connection = null;
}
fsm.onDisconnect();
fsm.onDisconnect(false);
}
public void cancelAutoReconnect() {
reconnecting.set(false);
if (reconnectThread != null) {
reconnectThread.interrupt();
reconnectThread = null;
}
}
public void initiateAutoReconnect() {
if (config == null || !config.isAutoReconnect()) {
return;
}
if (!reconnecting.compareAndSet(false, true)) {
return;
}
reconnectThread = new Thread(this::runAutoReconnect, "TN3270-AutoReconnect");
reconnectThread.setDaemon(true);
reconnectThread.start();
}
private void runAutoReconnect() {
int maxRetries = (config != null) ? config.getReconnectMaxRetries() : 5;
log.info("Starting automatic reconnection loop (maxRetries=" + maxRetries + ")");
try {
for (int attempt = 1; attempt <= maxRetries && reconnecting.get(); attempt++) {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s
long delaySeconds = Math.min(30, (long) Math.pow(2, attempt - 1));
log.info("Auto-reconnect attempt " + attempt + "/" + maxRetries + " scheduled in " + delaySeconds + "s");
for (int s = 0; s < delaySeconds * 10; s++) {
if (!reconnecting.get() || Thread.currentThread().isInterrupted()) {
log.info("Auto-reconnect cancelled during backoff delay");
return;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
log.info("Auto-reconnect thread interrupted");
return;
}
}
if (!reconnecting.get()) {
return;
}
try {
log.info("Executing auto-reconnect attempt " + attempt + "/" + maxRetries + " to " + config.getHost() + ":" + config.getPort());
if (connection != null) {
connection.disconnect();
connection = null;
}
fsm.resetSessionState();
screenBuffer.erase(false);
connection = new TelnetConnection(config, fsm);
fsm.setConnection(connection);
connection.connect();
fsm.onConnected();
log.info("Auto-reconnect successful on attempt " + attempt);
reconnecting.set(false);
fsm.notifyScreenUpdate();
return;
} catch (IOException e) {
log.warning("Auto-reconnect attempt " + attempt + " failed: " + e.getMessage());
if (attempt < maxRetries && reconnecting.get()) {
fsm.setConnectionState(ConnectionState.RECONNECTING);
}
}
}
// All retries failed
log.warning("All " + maxRetries + " automatic reconnection attempts failed");
reconnecting.set(false);
fsm.onDisconnect(false);
fsm.onError("Automatic reconnection failed after " + maxRetries + " attempts");
} finally {
reconnecting.set(false);
reconnectThread = null;
}
}
public boolean isAutoReconnect() {
return (config != null) && config.isAutoReconnect();
}
public void setAutoReconnect(boolean autoReconnect) {
if (config != null) {
config.setAutoReconnect(autoReconnect);
}
}
public int getReconnectMaxRetries() {
return (config != null) ? config.getReconnectMaxRetries() : 5;
}
public void setReconnectMaxRetries(int retries) {
if (config != null) {
config.setReconnectMaxRetries(retries);
}
}
public boolean isReconnecting() {
return reconnecting.get() || fsm.getConnectionState() == ConnectionState.RECONNECTING;
}
public boolean isKeepAliveEnabled() {
return (config != null) && config.isKeepAliveEnabled();
}
public void setKeepAliveEnabled(boolean enabled) {
if (config != null) {
config.setKeepAliveEnabled(enabled);
}
if (connection != null) {
if (enabled) connection.startKeepAlive();
else connection.stopKeepAlive();
}
}
public int getKeepAliveIntervalSeconds() {
return (config != null) ? config.getKeepAliveIntervalSeconds() : 120;
}
public void setKeepAliveIntervalSeconds(int seconds) {
if (config != null) {
config.setKeepAliveIntervalSeconds(seconds);
}
if (connection != null && config != null && config.isKeepAliveEnabled()) {
connection.startKeepAlive();
}
}
/**
@@ -294,6 +460,23 @@ public class Telnet3270Client {
return fsm;
}
public boolean isAutoSysUnlock() {
return (config != null) ? config.isAutoSysUnlock() : true;
}
public void setAutoSysUnlock(boolean autoSysUnlock) {
if (config != null) {
config.setAutoSysUnlock(autoSysUnlock);
}
if (dsProcessor != null) {
dsProcessor.setAutoSysUnlock(autoSysUnlock);
}
}
public boolean isContentionResolution() {
return fsm != null && fsm.isContentionResolutionNegotiated();
}
/** Send an NVT ASCII character in NVT mode. */
public void sendNVTChar(char c) throws IOException {
fsm.sendNVTChar(c);
@@ -462,8 +645,23 @@ public class Telnet3270Client {
public void processFieldMark() { inputProcessor.processFieldMark(); }
/** Toggle Insert Mode. */
public void toggleInsert() { inputProcessor.setInsertMode(!inputProcessor.isInsertMode()); }
public void processToggleInsert() { inputProcessor.processToggleInsert(); }
public boolean isInsertMode() { return inputProcessor != null && inputProcessor.isInsertMode(); }
public void toggleInsert() { if (inputProcessor != null) inputProcessor.setInsertMode(!inputProcessor.isInsertMode()); }
public void processToggleInsert() { if (inputProcessor != null) inputProcessor.processToggleInsert(); }
/** Document Mode (Entry Assist) operations. */
public boolean isDocMode() { return screenBuffer != null && screenBuffer.isEntryAssistDOCmode(); }
public void setDocMode(boolean b) { if (screenBuffer != null) screenBuffer.setEntryAssistDOCmode(b); if (oia != null) oia.notifyOIAChanged(); }
public void toggleDocMode() { setDocMode(!isDocMode()); }
public boolean isWordWrap() { return screenBuffer != null && screenBuffer.isEntryAssistWordWrap(); }
public void setWordWrap(boolean b) { if (screenBuffer != null) screenBuffer.setEntryAssistWordWrap(b); if (oia != null) oia.notifyOIAChanged(); }
public void toggleWordWrap() { setWordWrap(!isWordWrap()); }
/** APL Keyboard Mode operations. */
public boolean isAplMode() { return inputProcessor != null && inputProcessor.isAplKeyboardMode(); }
public void setAplMode(boolean b) { if (inputProcessor != null) inputProcessor.setAplKeyboardMode(b); }
public void toggleAplMode() { if (inputProcessor != null) inputProcessor.toggleAplKeyboardMode(); }
/** Move word left. */
public void processWordLeft() { inputProcessor.processWordLeft(); }
@@ -46,11 +46,11 @@ public enum TerminalModel {
/**
* Returns the terminal type string for TN3270E negotiation.
* e.g., "IBM-3279-4-E" for a color model 4 with extended data stream,
* or "IBM-DYNAMIC-E" for dynamic model.
* or "IBM-DYNAMIC" for dynamic model.
*/
public String getTerminalType() {
if (modelNumber == 0) {
return "IBM-DYNAMIC-E";
return "IBM-DYNAMIC";
}
return String.format("IBM-327%c-%d-E", color ? '9' : '8', modelNumber);
}
@@ -47,6 +47,13 @@ public class DataStreamProcessor {
private final java.io.ByteArrayOutputStream gocaAccumulator = new java.io.ByteArrayOutputStream();
private int currentGocaSubtype = 0;
// Phase 10: Auto-Unlock & Contention Resolution State
private boolean autoSysUnlock = true;
private boolean contentionResolution = false;
private boolean unlockPending = false;
private boolean unlockSysPending = false;
private boolean rcvdRead = false;
/** Functional interface for sending output back through the telnet stack. */
@FunctionalInterface
public interface OutputSender {
@@ -131,6 +138,21 @@ public class DataStreamProcessor {
screenListeners.remove(l);
}
public boolean isAutoSysUnlock() { return autoSysUnlock; }
public void setAutoSysUnlock(boolean autoSysUnlock) { this.autoSysUnlock = autoSysUnlock; }
public boolean isContentionResolution() { return contentionResolution; }
public void setContentionResolution(boolean cr) { this.contentionResolution = cr; }
public boolean isUnlockPending() { return unlockPending; }
public void setUnlockPending(boolean pending) { this.unlockPending = pending; }
public boolean isUnlockSysPending() { return unlockSysPending; }
public void setUnlockSysPending(boolean pending) { this.unlockSysPending = pending; }
public boolean isRcvdRead() { return rcvdRead; }
public void setRcvdRead(boolean rcvdRead) { this.rcvdRead = rcvdRead; }
/**
* Process a 3270 data stream record.
*
@@ -197,16 +219,19 @@ public class DataStreamProcessor {
break;
case CMD_RB:
case SNA_CMD_RB:
rcvdRead = true;
programSymbolManager.commitStagedSymbols();
processReadBuffer();
break;
case CMD_RM:
case SNA_CMD_RM:
rcvdRead = true;
programSymbolManager.commitStagedSymbols();
processReadModified(false);
break;
case CMD_RMA:
case SNA_CMD_RMA:
rcvdRead = true;
programSymbolManager.commitStagedSymbols();
processReadModified(true);
break;
@@ -235,7 +260,10 @@ public class DataStreamProcessor {
screen.updateDisplaySnapshot();
}
if (keyboardRestore && inputProcessor != null) {
boolean isWriteCmd = (cmd == CMD_W || cmd == SNA_CMD_W ||
cmd == CMD_EW || cmd == SNA_CMD_EW ||
cmd == CMD_EWA || cmd == SNA_CMD_EWA);
if (!isWriteCmd && keyboardRestore && inputProcessor != null && !contentionResolution) {
inputProcessor.setKeyboardLocked(false);
}
@@ -333,10 +361,13 @@ public class DataStreamProcessor {
log.fine("WCC: " + String.format("0x%02x", wcc) +
" reset=" + wccReset(wcc) + " alarm=" + alarm + " kbdRestore=" + kbdRestore + " resetMdt=" + resetMdt);
if (kbdRestore || inputProcessor != null) {
if (inputProcessor != null) {
inputProcessor.setKeyboardLocked(false);
}
if (kbdRestore) {
unlockPending = true;
unlockSysPending = true;
}
if (!contentionResolution && kbdRestore && inputProcessor != null) {
inputProcessor.setKeyboardLocked(false);
}
if (resetMdt) {
@@ -1101,6 +1132,9 @@ public class DataStreamProcessor {
}
private void notifyScreenUpdated() {
if (screen != null) {
screen.notifyScreenUpdate();
}
for (ScreenUpdateListener l : screenListeners) {
l.onScreenUpdated();
}
@@ -1288,6 +1322,9 @@ public class DataStreamProcessor {
byte[] fullStream = gocaAccumulator.toByteArray();
gocaAccumulator.reset();
log.info(String.format("GOCA stream SPAN_LAST assembled: %d bytes", fullStream.length));
try {
java.nio.file.Files.write(java.nio.file.Paths.get("/Users/rudi/Projects/j3270/captured_goca.bin"), fullStream);
} catch (Exception ignored) {}
if (currentGocaSubtype == haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJCNTL_SUB) {
gocaDecoder.processProcedureOrders(fullStream, 0, fullStream.length);
} else {
@@ -1546,7 +1583,11 @@ public class DataStreamProcessor {
log.fine("processWCC: " + String.format("0x%02x", wcc) +
" reset=" + wccReset(wcc) + " alarm=" + alarm + " kbdRestore=" + kbdRestore + " resetMdt=" + resetMdt);
if (kbdRestore && inputProcessor != null) {
if (kbdRestore) {
unlockPending = true;
unlockSysPending = true;
}
if (!contentionResolution && kbdRestore && inputProcessor != null) {
inputProcessor.setKeyboardLocked(false);
}
if (resetMdt) {
@@ -1,7 +1,6 @@
package haus.nightmare.lib3270j.eNetwork.ECL.event;
import java.awt.Image;
import java.awt.Rectangle;
import haus.nightmare.lib3270j.graphics.Rectangle;
/**
* Drop-in IBM Host On-Demand compatible facade for ECLPSGraphicsEvent.
@@ -12,11 +11,11 @@ public class ECLPSGraphicsEvent extends haus.nightmare.lib3270j.ecl.ECLPSGraphic
super(source, id);
}
public ECLPSGraphicsEvent(haus.nightmare.lib3270j.ecl.ECLPS source, int id, Image image) {
public ECLPSGraphicsEvent(haus.nightmare.lib3270j.ecl.ECLPS source, int id, Object image) {
super(source, id, image);
}
public ECLPSGraphicsEvent(haus.nightmare.lib3270j.ecl.ECLPS source, int id, Image image, Rectangle rectangle) {
public ECLPSGraphicsEvent(haus.nightmare.lib3270j.ecl.ECLPS source, int id, Object image, Rectangle rectangle) {
super(source, id, image, rectangle);
}
}
@@ -1,6 +1,6 @@
package haus.nightmare.lib3270j.eNetwork.ECL.hostgraphics;
import java.awt.Color;
import haus.nightmare.lib3270j.graphics.Color;
/**
* Drop-in IBM Host On-Demand compatible facade for FillArea.
@@ -1,12 +1,10 @@
package haus.nightmare.lib3270j.eNetwork.ECL.hostgraphics;
import java.awt.Component;
/**
* Drop-in IBM Host On-Demand compatible facade for HODBitImage.
*/
public class HODBitImage extends haus.nightmare.lib3270j.graphics.HODBitImage {
public HODBitImage(Component comp, int width, int height, byte[] data, int baseColor, int depth, boolean useGraphicColors) {
public HODBitImage(Object comp, int width, int height, byte[] data, int baseColor, int depth, boolean useGraphicColors) {
super(comp, width, height, data, baseColor, depth, useGraphicColors);
}
}
@@ -1,8 +1,7 @@
package haus.nightmare.lib3270j.eNetwork.ECL.hostgraphics;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Rectangle;
import haus.nightmare.lib3270j.graphics.Dimension;
import haus.nightmare.lib3270j.graphics.Rectangle;
/**
* Drop-in IBM Host On-Demand compatible facade for HODPart.
@@ -12,15 +11,15 @@ public class HODPart extends haus.nightmare.lib3270j.graphics.HODPart {
super();
}
public HODPart(Component component) {
public HODPart(Object component) {
super(component);
}
public HODPart(Component component, Dimension dimension) {
public HODPart(Object component, Dimension dimension) {
super(component, dimension);
}
public HODPart(Component component, Rectangle rectangle) {
public HODPart(Object component, Rectangle rectangle) {
super(component, rectangle);
}
@@ -1,7 +1,5 @@
package haus.nightmare.lib3270j.eNetwork.ECL.hostgraphics;
import java.awt.Image;
/**
* Drop-in IBM Host On-Demand compatible facade for HODWallpaper.
*/
@@ -14,7 +12,7 @@ public class HODWallpaper extends haus.nightmare.lib3270j.graphics.HODWallpaper
super(displayMode);
}
public HODWallpaper(Image image, int displayMode) {
public HODWallpaper(Object image, int displayMode) {
super(image, displayMode);
}
}
@@ -1,17 +1,17 @@
package haus.nightmare.lib3270j.ecl;
import java.awt.Color;
import java.awt.Component;
import haus.nightmare.lib3270j.graphics.Color;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Standard implementation of ECLPSGraphicsServices conforming to IBM Host On-Demand ECL.
* Completely decoupled from java.awt.
*/
public class DefaultPSGraphicsServices implements ECLPSGraphicsServices {
private final ECLPS ps;
private Component visualComponent;
private Object visualComponent;
private Color[] colors;
private final List<ECLPSGraphicsListener> listeners = new CopyOnWriteArrayList<>();
@@ -20,11 +20,11 @@ public class DefaultPSGraphicsServices implements ECLPSGraphicsServices {
}
@Override
public void setVisualComponent(Component comp) {
public void setVisualComponent(Object comp) {
this.visualComponent = comp;
}
public Component getVisualComponent() {
public Object getVisualComponent() {
return visualComponent;
}
@@ -27,6 +27,12 @@ public class ECLConnection {
private String luName;
private String workstationId = "";
private boolean ssl = false;
private boolean autoSysUnlock = true;
private boolean keepAlive = true;
private int keepAliveTimeout = 120;
private String keepAliveType = "NOP";
private boolean autoReconnect = false;
private int maxRetry = 5;
private boolean contentionResolution = false;
private boolean luluSession = false;
private boolean isNegCR = false;
@@ -71,12 +77,49 @@ public class ECLConnection {
if (props != null) {
this.properties.putAll(props);
convertData(this.properties);
String asu = this.properties.getProperty(ECLSession.SESSION_AUTO_SYS_UNLOCK);
if (asu != null) {
this.autoSysUnlock = "true".equalsIgnoreCase(asu) || "1".equals(asu);
}
String ka = this.properties.getProperty(ECLSession.SESSION_KEEPALIVE);
if (ka != null) {
this.keepAlive = "true".equalsIgnoreCase(ka) || "1".equals(ka);
}
String kat = this.properties.getProperty(ECLSession.KEY_KEEPALIVE_TIMEOUT);
if (kat != null) {
try { this.keepAliveTimeout = Integer.parseInt(kat.trim()); } catch (NumberFormatException ignored) {}
}
String katyp = this.properties.getProperty(ECLSession.KEY_KEEPALIVE_TYPE);
if (katyp != null) {
this.keepAliveType = katyp;
}
String ar = this.properties.getProperty(ECLSession.SESSION_AUTORECONNECT);
if (ar != null) {
this.autoReconnect = "true".equalsIgnoreCase(ar) || "1".equals(ar);
}
String mr = this.properties.getProperty(ECLSession.SESSION_RECONNECT_RETRIES);
if (mr != null) {
try { this.maxRetry = Integer.parseInt(mr.trim()); } catch (NumberFormatException ignored) {}
}
}
}
public ECLConnection(ECLSession session, Telnet3270Client client) {
this.session = session;
this.client = client;
if (client != null && client.getConfig() != null) {
this.autoSysUnlock = client.getConfig().isAutoSysUnlock();
this.keepAlive = client.getConfig().isKeepAliveEnabled();
this.keepAliveTimeout = client.getConfig().getKeepAliveIntervalSeconds();
this.keepAliveType = client.getConfig().getKeepAliveType();
this.autoReconnect = client.getConfig().isAutoReconnect();
this.maxRetry = client.getConfig().getReconnectMaxRetries();
} else if (session != null) {
this.autoSysUnlock = session.isAutoSysUnlock();
this.keepAlive = session.isKeepAlive();
this.keepAliveTimeout = session.getKeepAliveTimeout();
this.autoReconnect = session.isAutoReconnect();
}
if (client != null) {
client.addConnectionListener(new ConnectionListener() {
@@ -114,6 +157,13 @@ public class ECLConnection {
state, state, "TN3270E Negotiated", deviceType, deviceName);
notifyCommEvent(event);
}
@Override
public void onTN3270EFunctionsNegotiated(boolean[] functions) {
if (functions != null && functions.length > haus.nightmare.lib3270j.protocol.TN3270EConstants.FUNC_CONTENTION_RESOLUTION) {
setContentionResolution(functions[haus.nightmare.lib3270j.protocol.TN3270EConstants.FUNC_CONTENTION_RESOLUTION]);
}
}
});
}
}
@@ -398,17 +448,138 @@ public class ECLConnection {
}
public void setSSL(boolean ssl) { SetSSL(ssl); }
public void setContentionResolution(boolean bl) { this.contentionResolution = bl; }
public void setContentionResolution(boolean bl) {
this.contentionResolution = bl;
if (client != null && client.getTelnetFSM() != null) {
client.getTelnetFSM().setContentionResolutionNegotiated(bl);
}
}
public void SetContentionResolution(boolean bl) { setContentionResolution(bl); }
public boolean getContentionResolution() { return contentionResolution; }
public boolean isContentionResolution() { return contentionResolution; }
public boolean getContentionResolution() {
if (client != null && client.getTelnetFSM() != null && client.getTelnetFSM().isTn3270eNegotiated()) {
return client.getTelnetFSM().isContentionResolutionNegotiated();
}
return contentionResolution;
}
public boolean isContentionResolution() { return getContentionResolution(); }
public boolean GetContentionResolution() { return getContentionResolution(); }
public boolean IsContentionResolution() { return getContentionResolution(); }
public boolean isAutoSysUnlock() {
if (client != null && client.getConfig() != null) {
return client.getConfig().isAutoSysUnlock();
}
String s = properties.getProperty(ECLSession.SESSION_AUTO_SYS_UNLOCK);
if (s != null) {
return "true".equalsIgnoreCase(s) || "1".equals(s);
}
return autoSysUnlock;
}
public boolean IsAutoSysUnlock() { return isAutoSysUnlock(); }
public boolean getAutoSysUnlock() { return isAutoSysUnlock(); }
public boolean GetAutoSysUnlock() { return isAutoSysUnlock(); }
public void setAutoSysUnlock(boolean unlock) {
this.autoSysUnlock = unlock;
properties.setProperty(ECLSession.SESSION_AUTO_SYS_UNLOCK, String.valueOf(unlock));
if (client != null) {
client.setAutoSysUnlock(unlock);
}
}
public void SetAutoSysUnlock(boolean unlock) { setAutoSysUnlock(unlock); }
public boolean isKeepAlive() {
if (client != null && client.getConfig() != null) return client.getConfig().isKeepAliveEnabled();
return keepAlive;
}
public boolean getKeepAlive() { return isKeepAlive(); }
public boolean IsKeepAlive() { return isKeepAlive(); }
public boolean GetKeepAlive() { return isKeepAlive(); }
public void setKeepAlive(boolean ka) {
this.keepAlive = ka;
this.properties.setProperty(ECLSession.SESSION_KEEPALIVE, String.valueOf(ka));
if (client != null) {
client.setKeepAliveEnabled(ka);
}
}
public void SetKeepAlive(boolean ka) { setKeepAlive(ka); }
public int getKeepAliveTimeout() {
if (client != null && client.getConfig() != null) return client.getConfig().getKeepAliveIntervalSeconds();
return keepAliveTimeout;
}
public int GetKeepAliveTimeout() { return getKeepAliveTimeout(); }
public void setKeepAliveTimeout(int timeout) {
this.keepAliveTimeout = timeout;
this.properties.setProperty(ECLSession.KEY_KEEPALIVE_TIMEOUT, String.valueOf(timeout));
if (client != null) {
client.setKeepAliveIntervalSeconds(timeout);
}
}
public void SetKeepAliveTimeout(int timeout) { setKeepAliveTimeout(timeout); }
public String getKeepAliveType() {
if (client != null && client.getConfig() != null) return client.getConfig().getKeepAliveType();
return keepAliveType;
}
public String GetKeepAliveType() { return getKeepAliveType(); }
public void setKeepAliveType(String type) {
this.keepAliveType = type;
this.properties.setProperty(ECLSession.KEY_KEEPALIVE_TYPE, type != null ? type : "");
if (client != null && client.getConfig() != null) {
client.getConfig().setKeepAliveType(type);
}
}
public void SetKeepAliveType(String type) { setKeepAliveType(type); }
public boolean isAutoReconnect() {
if (client != null && client.getConfig() != null) return client.getConfig().isAutoReconnect();
return autoReconnect;
}
public boolean getAutoReconnect() { return isAutoReconnect(); }
public boolean IsAutoReconnect() { return isAutoReconnect(); }
public boolean GetAutoReconnect() { return isAutoReconnect(); }
public void setAutoReconnect(boolean ar) {
this.autoReconnect = ar;
this.properties.setProperty(ECLSession.SESSION_AUTORECONNECT, String.valueOf(ar));
if (client != null) {
client.setAutoReconnect(ar);
}
}
public void SetAutoReconnect(boolean ar) { setAutoReconnect(ar); }
public int getMaxRetry() {
if (client != null && client.getConfig() != null) return client.getConfig().getReconnectMaxRetries();
return maxRetry;
}
public int GetMaxRetry() { return getMaxRetry(); }
public void setMaxRetry(int retries) {
this.maxRetry = retries;
this.properties.setProperty(ECLSession.SESSION_RECONNECT_RETRIES, String.valueOf(retries));
if (client != null && client.getConfig() != null) {
client.getConfig().setReconnectMaxRetries(retries);
}
}
public void SetMaxRetry(int retries) { setMaxRetry(retries); }
public int getReconnectMaxRetries() { return getMaxRetry(); }
public void setReconnectMaxRetries(int retries) { setMaxRetry(retries); }
public void set_LULU_Session(boolean bl) { this.luluSession = bl; }
public boolean is_LULU_Session() { return luluSession; }
public boolean get_LULU_Session() { return luluSession; }
public boolean isNegotiateCResolution() { return isNegCR; }
public void setNegotiatedCResolution(boolean bl) { this.isNegCR = bl; }
public boolean isNegotiateCResolution() {
if (client != null && client.getTelnetFSM() != null) {
return client.getTelnetFSM().isNegotiateContentionResolution();
}
return isNegCR;
}
public void setNegotiatedCResolution(boolean bl) {
this.isNegCR = bl;
if (client != null && client.getTelnetFSM() != null) {
client.getTelnetFSM().setNegotiateContentionResolution(bl);
}
}
public boolean isBIND7FArchitectureViolation() { return isBIND7FArchitectureViolation; }
public void setBIND7FArchitectureViolation(boolean bl) { this.isBIND7FArchitectureViolation = bl; }
@@ -2,6 +2,9 @@ package haus.nightmare.lib3270j.ecl;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
@@ -178,7 +181,20 @@ public class ECLOIA implements ECLConstants {
}
}
private synchronized void notifyOIAChanged() {
private final ReentrantLock oiaLock = new ReentrantLock();
private final Condition oiaCondition = oiaLock.newCondition();
public void signalWaiters() {
oiaLock.lock();
try {
oiaCondition.signalAll();
} finally {
oiaLock.unlock();
}
}
public synchronized void notifyOIAChanged() {
signalWaiters();
ECLOIAEvent event = new ECLOIAEvent(this, ECLOIAEvent.OIA_UPDATE, getInputInhibited(),
getAlphanumericType(), isInsertMode(), getStatusString());
for (haus.nightmare.lib3270j.ecl.ECLOIANotify l : listeners) {
@@ -413,19 +429,28 @@ public class ECLOIA implements ECLConstants {
* @return true if keyboard unlocked, false if timeout occurred.
*/
public boolean waitForInput(long timeoutMs) {
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start < timeoutMs) {
if (getInputInhibited() == INHIBIT_NOT_INHIBITED) {
return true;
long limit = (timeoutMs <= 0) ? 120000L : timeoutMs;
if (getInputInhibited() == INHIBIT_NOT_INHIBITED) {
return true;
}
oiaLock.lock();
try {
long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit);
while (getInputInhibited() != INHIBIT_NOT_INHIBITED) {
if (remainingNanos <= 0) {
return false;
}
try {
remainingNanos = oiaCondition.awaitNanos(remainingNanos);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
return true;
} finally {
oiaLock.unlock();
}
return getInputInhibited() == INHIBIT_NOT_INHIBITED;
}
/**
@@ -458,20 +483,26 @@ public class ECLOIA implements ECLConstants {
* Block until any OIA transition occurs.
*/
public boolean waitForTransition(long timeoutMs) {
long limit = (timeoutMs <= 0) ? 120000L : timeoutMs;
int initialInhibit = getInputInhibited();
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start < timeoutMs) {
if (getInputInhibited() != initialInhibit) {
return true;
}
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
oiaLock.lock();
try {
long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit);
while (getInputInhibited() == initialInhibit) {
if (remainingNanos <= 0) {
return false;
}
try {
remainingNanos = oiaCondition.awaitNanos(remainingNanos);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return true;
} finally {
oiaLock.unlock();
}
return getInputInhibited() != initialInhibit;
}
public boolean WaitForTransition(long timeoutMs) {
@@ -484,6 +515,7 @@ public class ECLOIA implements ECLConstants {
if (isNumeric()) s |= STATE_NUMFIELD;
if (screen != null && screen.isEntryAssistDOCmode()) s |= STATE_DOC_MODE;
if (screen != null && screen.isEntryAssistWordWrap()) s |= STATE_WORDWRAP;
if (isApl()) s |= STATE_APL;
if (isXSystem()) s |= STATE_SYS_LOCK;
if (isXComm()) s |= STATE_COMM_CHECK;
return s;
@@ -494,6 +526,21 @@ public class ECLOIA implements ECLConstants {
public int getStatusFlags() { return GetStatusFlags(); }
public long getStatusFlagsEx() { return GetStatusFlagsEx(); }
public boolean isApl() {
return inputProcessor != null && inputProcessor.isAplKeyboardMode();
}
public boolean IsApl() { return isApl(); }
public boolean isDocMode() {
return screen != null && screen.isEntryAssistDOCmode();
}
public boolean IsDocMode() { return isDocMode(); }
public boolean isWordWrap() {
return screen != null && screen.isEntryAssistWordWrap();
}
public boolean IsWordWrap() { return isWordWrap(); }
public synchronized void setBitmaskState(long flag, boolean on) {
this.previousState = this.state;
if (on) {
@@ -2,8 +2,12 @@ package haus.nightmare.lib3270j.ecl;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import haus.nightmare.lib3270j.screen.ExtendedAttribute;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
/**
@@ -37,6 +41,31 @@ public class ECLPS implements ECLConstants {
private final java.util.Map<ECLPSListener, ECLScreenDesc> descriptorListeners = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.Map<ECLPSListener, Integer> listenerEventTypes = new java.util.concurrent.ConcurrentHashMap<>();
// Synchronization primitives for reactive automation waits (Phase 12)
private final ReentrantLock fallbackLock = new ReentrantLock();
private final Condition fallbackCondition = fallbackLock.newCondition();
public ReentrantLock getSyncLock() {
return (screen != null) ? screen.getSyncLock() : fallbackLock;
}
public Condition getSyncCondition() {
return (screen != null) ? screen.getSyncCondition() : fallbackCondition;
}
public void signalWaiters() {
if (screen != null) {
screen.signalWaiters();
} else {
fallbackLock.lock();
try {
fallbackCondition.signalAll();
} finally {
fallbackLock.unlock();
}
}
}
public ECLPS(ScreenBuffer screen, InputProcessor inputProcessor, EbcdicTranslator translator) {
this.screen = screen;
this.inputProcessor = inputProcessor;
@@ -46,6 +75,27 @@ public class ECLPS implements ECLConstants {
this.bidiServices = new DefaultPSBIDIServices(this);
this.hindiServices = new DefaultPSHindiServices(this);
this.thaiServices = new DefaultPSTHAIServices(this);
if (this.screen != null) {
this.screen.addUpdateListener(new ScreenUpdateListener() {
@Override
public void onScreenUpdated() {
signalWaiters();
}
@Override
public void onCursorMoved(int oldAddress, int newAddress) {
signalWaiters();
}
@Override
public void onScreenSizeChanged(int rows, int cols) {
signalWaiters();
}
@Override
public void onKeyboardUnlocked() {
signalWaiters();
}
});
}
}
public ECLPS(ECLSession session) {
@@ -494,11 +544,71 @@ public class ECLPS implements ECLConstants {
return copyString(sRow, sCol, eRow, eCol);
}
private boolean enablePasteFromExcel = true;
private boolean pasteStopAtProtectedLine = false;
public boolean isEnablePasteFromExcel() {
if (session != null && session.getProperties() != null) {
String p = session.getProperties().getProperty(ECLSession.ENABLE_PASTE_FROM_EXCEL);
if (p != null) return Boolean.parseBoolean(p);
}
return enablePasteFromExcel;
}
public boolean IsEnablePasteFromExcel() { return isEnablePasteFromExcel(); }
public void setEnablePasteFromExcel(boolean val) {
this.enablePasteFromExcel = val;
if (session != null && session.getProperties() != null) {
session.getProperties().setProperty(ECLSession.ENABLE_PASTE_FROM_EXCEL, String.valueOf(val));
}
}
public void SetEnablePasteFromExcel(boolean val) { setEnablePasteFromExcel(val); }
public boolean isPasteStopAtProtectedLine() {
if (session != null && session.getProperties() != null) {
String p = session.getProperties().getProperty(ECLSession.PASTE_STOP_AT_PROTECTED_LINE);
if (p != null) return Boolean.parseBoolean(p);
}
return pasteStopAtProtectedLine;
}
public boolean IsPasteStopAtProtectedLine() { return isPasteStopAtProtectedLine(); }
public void setPasteStopAtProtectedLine(boolean val) {
this.pasteStopAtProtectedLine = val;
if (session != null && session.getProperties() != null) {
session.getProperties().setProperty(ECLSession.PASTE_STOP_AT_PROTECTED_LINE, String.valueOf(val));
}
}
public void SetPasteStopAtProtectedLine(boolean val) { setPasteStopAtProtectedLine(val); }
public synchronized int pasteFromExcel(String text, int row, int col) {
if (text == null || text.isEmpty() || screen == null) return 0;
if (row >= 0 && col >= 0) {
setCursorPos(row, col);
}
if (inputProcessor != null) {
return inputProcessor.pasteText(text, true, isPasteStopAtProtectedLine());
}
return 0;
}
public int PasteFromExcel(String text, int row, int col) {
return pasteFromExcel(text, row, col);
}
/**
* Paste a multi-line rectangular block of text starting at (row, col).
*/
public synchronized int pasteString(String text, int row, int col) {
if (text == null || text.isEmpty() || screen == null) return 0;
if (inputProcessor != null && (text.contains("\t") || isEnablePasteFromExcel() || isPasteStopAtProtectedLine())) {
setCursorPos(row, col);
return inputProcessor.pasteText(text, isEnablePasteFromExcel(), isPasteStopAtProtectedLine());
}
int rows = screen.getRows();
int cols = screen.getCols();
if (rows <= 0 || cols <= 0) return 0;
@@ -532,7 +642,33 @@ public class ECLPS implements ECLConstants {
}
public int pasteRectangular(String text, int row, int col) {
return pasteString(text, row, col);
if (text == null || text.isEmpty() || screen == null) return 0;
int rows = screen.getRows();
int cols = screen.getCols();
if (rows <= 0 || cols <= 0) return 0;
String[] lines = text.split("\r?\n");
int count = 0;
for (int i = 0; i < lines.length; i++) {
int targetRow = (row + i) % rows;
String line = lines[i];
for (int c = 0; c < line.length() && (col + c) < cols; c++) {
int pos = targetRow * cols + (col + c);
if (screen.isFormatted()) {
byte fa = screen.getFieldAttributeAt(pos);
if (faIsProtected(fa & 0xFF) || screen.getCell(pos).isFieldAttribute()) {
continue;
}
}
setCursorPos(pos);
if (inputProcessor != null) {
inputProcessor.typeCharacter(line.charAt(c));
}
count++;
}
}
return count;
}
public int PasteRectangular(String text, int row, int col) {
@@ -848,6 +984,20 @@ public class ECLPS implements ECLConstants {
UnregisterPSEvent(listener);
}
public void dispatchEvent(ECLPSEvent event) {
notifyPSEvent(event);
}
public void notifyKeyUnlocked() {
int r = (screen != null) ? screen.getRows() : 0;
int c = (screen != null) ? screen.getCols() : 0;
int cur = (screen != null) ? screen.getCursorAddress() : 0;
int ring = ringCounter.incrementAndGet();
signalWaiters();
notifyPSEvent(new ECLPSEvent(this, ECLPSEvent.EVENT_KEY_UNLOCKED, HOST_EVENTS, 0, 0,
Math.max(0, r - 1), Math.max(0, c - 1), cur, cur, r, c, false, cursorVisible, ring, false, null));
}
public void notifyPSEvent(ECLPSEvent event) {
for (ECLPSListener l : psListeners) {
ECLScreenDesc desc = descriptorListeners.get(l);
@@ -899,6 +1049,7 @@ public class ECLPS implements ECLConstants {
int c = (screen != null) ? screen.getCols() : 0;
int cur = (screen != null) ? screen.getCursorAddress() : 0;
int ring = ringCounter.incrementAndGet();
signalWaiters();
ECLPSEvent evt = new ECLPSEvent(this, ECLPSEvent.PS_UPDATE, type, startRow, startCol, endRow, endCol,
cur, cur, r, c, full, cursorVisible, ring, startPrinter, null);
notifyPSEvent(evt);
@@ -910,12 +1061,14 @@ public class ECLPS implements ECLConstants {
int row = (c > 0) ? newAddress / c : 0;
int col = (c > 0) ? newAddress % c : 0;
int ring = ringCounter.incrementAndGet();
signalWaiters();
notifyPSEvent(new ECLPSEvent(this, ECLPSEvent.PS_CURSOR, USER_EVENTS, row, col, row, col,
oldAddress, newAddress, r, c, false, cursorVisible, ring, false, null));
}
public void notifyAlarm() {
int ring = ringCounter.incrementAndGet();
signalWaiters();
notifyPSEvent(new ECLPSEvent(this, ECLPSEvent.PS_ALARM, HOST_EVENTS, 0, 0, 0, 0,
0, 0, 0, 0, false, cursorVisible, ring, false, null));
}
@@ -923,6 +1076,7 @@ public class ECLPS implements ECLConstants {
public void notifyScreenResized(int rows, int cols) {
int cur = (screen != null) ? screen.getCursorAddress() : 0;
int ring = ringCounter.incrementAndGet();
signalWaiters();
notifyPSEvent(new ECLPSEvent(this, ECLPSEvent.PS_RESIZE, HOST_EVENTS, 0, 0, rows - 1, cols - 1,
cur, cur, rows, cols, true, cursorVisible, ring, false, null));
}
@@ -1067,20 +1221,30 @@ public class ECLPS implements ECLConstants {
public boolean waitForScreen(ECLScreenDesc desc, long timeoutMs) {
if (desc == null) return true;
long limit = (timeoutMs <= 0) ? 120000L : timeoutMs;
long start = System.currentTimeMillis();
ECLOIA oia = (session != null) ? session.GetOIA() : null;
while (System.currentTimeMillis() - start < limit) {
if (desc.Matches(this, oia)) {
return true;
if (desc.Matches(this, oia)) {
return true;
}
ReentrantLock lock = getSyncLock();
Condition cond = getSyncCondition();
lock.lock();
try {
long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit);
while (!desc.Matches(this, oia)) {
if (remainingNanos <= 0) {
return false;
}
try {
remainingNanos = cond.awaitNanos(remainingNanos);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
try {
Thread.sleep(25);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
return true;
} finally {
lock.unlock();
}
return desc.Matches(this, oia);
}
public boolean WaitForScreen(ECLScreenDesc desc, long timeoutMs) {
@@ -1097,20 +1261,30 @@ public class ECLPS implements ECLConstants {
public boolean waitWhileScreen(ECLScreenDesc desc, long timeoutMs) {
if (desc == null) return true;
long limit = (timeoutMs <= 0) ? 120000L : timeoutMs;
long start = System.currentTimeMillis();
ECLOIA oia = (session != null) ? session.GetOIA() : null;
while (System.currentTimeMillis() - start < limit) {
if (!desc.Matches(this, oia)) {
return true;
if (!desc.Matches(this, oia)) {
return true;
}
ReentrantLock lock = getSyncLock();
Condition cond = getSyncCondition();
lock.lock();
try {
long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit);
while (desc.Matches(this, oia)) {
if (remainingNanos <= 0) {
return false;
}
try {
remainingNanos = cond.awaitNanos(remainingNanos);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
try {
Thread.sleep(25);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
return true;
} finally {
lock.unlock();
}
return !desc.Matches(this, oia);
}
public boolean WaitWhileScreen(ECLScreenDesc desc, long timeoutMs) {
@@ -1121,69 +1295,178 @@ public class ECLPS implements ECLConstants {
* Block until the specified text appears anywhere on the presentation space.
*/
public boolean waitForScreen(String text, long timeoutMs) {
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start < timeoutMs) {
if (searchString(text) >= 0) {
return true;
if (text == null || text.isEmpty()) return true;
long limit = (timeoutMs <= 0) ? 120000L : timeoutMs;
if (searchString(text) >= 0) {
return true;
}
ReentrantLock lock = getSyncLock();
Condition cond = getSyncCondition();
lock.lock();
try {
long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit);
while (searchString(text) < 0) {
if (remainingNanos <= 0) {
return false;
}
try {
remainingNanos = cond.awaitNanos(remainingNanos);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
try {
Thread.sleep(25);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
return true;
} finally {
lock.unlock();
}
return searchString(text) >= 0;
}
public boolean WaitForScreen(String text, long timeoutMs) {
return waitForScreen(text, timeoutMs);
}
public boolean waitForString(String text) {
return waitForScreen(text, -1L);
}
public boolean WaitForString(String text) {
return waitForScreen(text, -1L);
}
public boolean waitForString(String text, long timeoutMs) {
return waitForScreen(text, timeoutMs);
}
public boolean WaitForString(String text, long timeoutMs) {
return waitForScreen(text, timeoutMs);
}
/**
* Block until the specified text appears at the given (row, col) coordinate.
*/
public boolean waitForScreen(String text, int row, int col, long timeoutMs) {
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start < timeoutMs) {
String onScreen = getString(row, col, text.length());
if (text.equals(onScreen)) {
return true;
if (text == null) return true;
long limit = (timeoutMs <= 0) ? 120000L : timeoutMs;
if (text.equals(getString(row, col, text.length()))) {
return true;
}
ReentrantLock lock = getSyncLock();
Condition cond = getSyncCondition();
lock.lock();
try {
long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit);
while (!text.equals(getString(row, col, text.length()))) {
if (remainingNanos <= 0) {
return false;
}
try {
remainingNanos = cond.awaitNanos(remainingNanos);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
try {
Thread.sleep(25);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
return true;
} finally {
lock.unlock();
}
return text.equals(getString(row, col, text.length()));
}
public boolean WaitForScreen(String text, int row, int col, long timeoutMs) {
return waitForScreen(text, row, col, timeoutMs);
}
public boolean waitForString(String text, int row, int col, long timeoutMs) {
return waitForScreen(text, row, col, timeoutMs);
}
public boolean WaitForString(String text, int row, int col, long timeoutMs) {
return waitForScreen(text, row, col, timeoutMs);
}
/**
* Block until the cursor moves to (row, col).
*/
public boolean waitForCursor(int row, int col, long timeoutMs) {
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start < timeoutMs) {
if (getCursorRow() == row && getCursorCol() == col) {
return true;
long limit = (timeoutMs <= 0) ? 120000L : timeoutMs;
if (getCursorRow() == row && getCursorCol() == col) {
return true;
}
ReentrantLock lock = getSyncLock();
Condition cond = getSyncCondition();
lock.lock();
try {
long remainingNanos = TimeUnit.MILLISECONDS.toNanos(limit);
while (getCursorRow() != row || getCursorCol() != col) {
if (remainingNanos <= 0) {
return false;
}
try {
remainingNanos = cond.awaitNanos(remainingNanos);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
try {
Thread.sleep(25);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
return true;
} finally {
lock.unlock();
}
return getCursorRow() == row && getCursorCol() == col;
}
public boolean WaitForCursor(int row, int col, long timeoutMs) {
return waitForCursor(row, col, timeoutMs);
}
protected boolean locked_SYSLOCK = false;
protected boolean locked_TWAIT = false;
public void lockKeyboard() {
lockKeyboard(8);
}
public void lockKeyboard(int reason) {
if (reason == 7) locked_TWAIT = true;
if (reason == 8) {
locked_SYSLOCK = true;
if (session != null && session.getOIA() != null) {
session.getOIA().setDoNotEnter(8, 0);
}
}
if (inputProcessor != null) {
inputProcessor.setKeyboardLocked(true);
}
}
public void unlockKeyboard() {
unlockKeyboard(8);
}
public void unlockKeyboard(int reason) {
if (reason == 7) locked_TWAIT = false;
if (reason == 8) {
locked_SYSLOCK = false;
if (session != null && session.getOIA() != null) {
session.getOIA().clearDoNotEnter();
}
}
if (!locked_TWAIT && !locked_SYSLOCK) {
if (inputProcessor != null) {
inputProcessor.setKeyboardLocked(false);
}
}
}
public boolean islocked_TWAIT() {
return locked_TWAIT;
}
public boolean islocked_SYSLOCK() {
if (locked_SYSLOCK) return true;
if (session != null && session.getOIA() != null && session.getOIA().isXSystem()) {
return true;
}
return false;
}
}
@@ -27,6 +27,7 @@ public class ECLPSEvent extends EventObject {
public static final int EVENT_ALARM = PS_ALARM;
public static final int EVENT_RESIZE = PS_RESIZE;
public static final int EVENT_CLOSE = PS_CLOSE;
public static final int EVENT_KEY_UNLOCKED = PS_UPDATE; // HoD event type 1 for keyboard unlock / update
private final int eventType;
private final int type;
@@ -1,10 +1,11 @@
package haus.nightmare.lib3270j.ecl;
import java.awt.Image;
import java.awt.Rectangle;
import haus.nightmare.lib3270j.graphics.PixelBuffer;
import haus.nightmare.lib3270j.graphics.Rectangle;
/**
* Conforms to IBM Host On-Demand ECLPSGraphicsEvent.
* Decoupled from java.awt.
*/
public class ECLPSGraphicsEvent {
public static final int GRAPHICS_CURSOR_ON = 1;
@@ -14,7 +15,7 @@ public class ECLPSGraphicsEvent {
public static final int GRAPHICS_UPDATED = 5;
private int id;
private Image image;
private Object image;
private Rectangle rect;
private ECLPS source;
@@ -23,13 +24,13 @@ public class ECLPSGraphicsEvent {
this.id = id;
}
public ECLPSGraphicsEvent(ECLPS source, int id, Image image) {
public ECLPSGraphicsEvent(ECLPS source, int id, Object image) {
this.source = source;
this.id = id;
this.image = image;
}
public ECLPSGraphicsEvent(ECLPS source, int id, Image image, Rectangle rectangle) {
public ECLPSGraphicsEvent(ECLPS source, int id, Object image, Rectangle rectangle) {
this.source = source;
this.id = id;
this.image = image;
@@ -46,9 +47,13 @@ public class ECLPSGraphicsEvent {
public int getID() { return this.id; }
public int GetID() { return this.id; }
public void setImage(Image image) { this.image = image; }
public Image getImage() { return this.image; }
public Image GetImage() { return this.image; }
public void setImage(Object image) { this.image = image; }
public Object getImage() { return this.image; }
public Object GetImage() { return this.image; }
public PixelBuffer getPixelBuffer() {
return (this.image instanceof PixelBuffer) ? (PixelBuffer) this.image : null;
}
public void setRectangle(Rectangle rect) { this.rect = rect; }
public Rectangle getRectangle() { return this.rect; }
@@ -1,13 +1,13 @@
package haus.nightmare.lib3270j.ecl;
import java.awt.Color;
import java.awt.Component;
import haus.nightmare.lib3270j.graphics.Color;
/**
* Presentation Space graphics services interface conforming to IBM Host On-Demand ECL.
* Completely decoupled from java.awt.
*/
public interface ECLPSGraphicsServices {
void setVisualComponent(Component comp);
void setVisualComponent(Object comp);
void setGraphicColor(Color[] colors, boolean b);
void mousePressed(int x, int y, int button);
void addGraphicsListener(ECLPSGraphicsListener listener);
@@ -18,7 +18,7 @@ public class ECLSession {
private static final Logger log = Logger.getLogger(ECLSession.class.getName());
// Standard IBM HoD Session Property Keys
// Standard Session Property Keys
public static final String SESSION_HOST = "SESSION_HOST";
public static final String SESSION_PORT = "SESSION_PORT";
public static final String SESSION_CODE_PAGE = "SESSION_CODE_PAGE";
@@ -29,6 +29,24 @@ public class ECLSession {
public static final String SESSION_TN3270E = "SESSION_TN3270E";
public static final String SESSION_WIN_TITLE = "SESSION_WIN_TITLE";
public static final String SESSION_AUTO_CONNECT = "SESSION_AUTO_CONNECT";
public static final String SESSION_AUTO_SYS_UNLOCK = "autoSysUnlock";
public static final String SESSION_KEEPALIVE = "SESSION_KEEPALIVE";
public static final String KEY_KEEPALIVE_TYPE = "keepAliveType";
public static final String KEY_KEEPALIVE_TIMEOUT = "keepAliveTimeout";
public static final String SESSION_AUTORECONNECT = "SESSION_AUTORECONNECT";
public static final String SESSION_RECONNECT_RETRIES = "SESSION_RECONNECT_RETRIES";
public static final String ENABLE_PASTE_FROM_EXCEL = "enablePasteFromExcel";
public static final String PASTE_TAB_OPTIONS = "pasteTabOptions";
public static final String PASTE_STOP_AT_PROTECTED_LINE = "pasteStopAtProtectedLine";
public static final String PASTE_FIELD_WRAP = "pasteFieldWrap";
public static final String PASTE_LINE_WRAP = "pasteLineWrap";
public static final String ENTRYASSIST_DOCMODE = "EntryAssist_DOCmode";
public static final String ENTRYASSIST_DOCWORDWRAP = "EntryAssist_DOCwordWrap";
public static final String ENTRYASSIST_STARTCOL = "EntryAssist_startCol";
public static final String ENTRYASSIST_ENDCOL = "EntryAssist_endCol";
public static final String ENTRYASSIST_BELL = "EntryAssist_bell";
public static final String ENTRYASSIST_BELLCOL = "EntryAssist_bellCol";
public static final String ENTRYASSIST_TABSTOPS = "EntryAssist_tabstops";
private final Telnet3270Client client;
private final ECLConnection connection;
@@ -98,6 +116,9 @@ public class ECLSession {
String tn3270eStr = getProp(props, SESSION_TN3270E, "tn3270e", "TN3270E", "true");
config.setTn3270eEnabled("true".equalsIgnoreCase(tn3270eStr) || "yes".equalsIgnoreCase(tn3270eStr) || "1".equals(tn3270eStr));
String autoSysStr = getProp(props, SESSION_AUTO_SYS_UNLOCK, "autoSysUnlock", "AutoSysUnlock", "true");
config.setAutoSysUnlock("true".equalsIgnoreCase(autoSysStr) || "yes".equalsIgnoreCase(autoSysStr) || "1".equals(autoSysStr));
String certUrl = getProp(props, "certificateURL", "CERTIFICATE_URL", "certificate_url", null);
if (certUrl != null) config.setKeyStorePath(certUrl);
String certPwd = getProp(props, "certificatePassword", "CERTIFICATE_PASSWORD", "certificate_password", null);
@@ -118,6 +139,25 @@ public class ECLSession {
config.setEnabledProtocols(tlsVer);
}
String keepAliveStr = getProp(props, SESSION_KEEPALIVE, "keepAlive", "keepalive", "true");
config.setKeepAliveEnabled("true".equalsIgnoreCase(keepAliveStr) || "yes".equalsIgnoreCase(keepAliveStr) || "1".equals(keepAliveStr));
String kaTimeoutStr = getProp(props, KEY_KEEPALIVE_TIMEOUT, "keepAliveTimeout", "keepalivetimeout", null);
if (kaTimeoutStr != null) {
try { config.setKeepAliveIntervalSeconds(Integer.parseInt(kaTimeoutStr.trim())); } catch (NumberFormatException ignored) {}
}
String kaTypeStr = getProp(props, KEY_KEEPALIVE_TYPE, "keepAliveType", "keepalivetype", null);
if (kaTypeStr != null) config.setKeepAliveType(kaTypeStr);
String autoReconnectStr = getProp(props, SESSION_AUTORECONNECT, "autoReconnect", "autoreconnect", "false");
config.setAutoReconnect("true".equalsIgnoreCase(autoReconnectStr) || "yes".equalsIgnoreCase(autoReconnectStr) || "1".equals(autoReconnectStr));
String retriesStr = getProp(props, SESSION_RECONNECT_RETRIES, "reconnectMaxRetries", "reconnectRetries", null);
if (retriesStr != null) {
try { config.setReconnectMaxRetries(Integer.parseInt(retriesStr.trim())); } catch (NumberFormatException ignored) {}
}
return config;
}
@@ -161,6 +201,7 @@ public class ECLSession {
properties.setProperty(SESSION_SSL, String.valueOf(cfg.isUseTls()));
if (cfg.getLuName() != null) properties.setProperty(SESSION_LU_NAME, cfg.getLuName());
properties.setProperty(SESSION_TN3270E, String.valueOf(cfg.isTn3270eEnabled()));
properties.setProperty(SESSION_AUTO_SYS_UNLOCK, String.valueOf(cfg.isAutoSysUnlock()));
}
}
@@ -371,6 +412,42 @@ public class ECLSession {
}
}
public boolean isKeepAlive() {
return (client != null) ? client.isKeepAliveEnabled() : true;
}
public boolean IsKeepAlive() { return isKeepAlive(); }
public void setKeepAlive(boolean keepAlive) {
this.properties.setProperty(SESSION_KEEPALIVE, String.valueOf(keepAlive));
if (client != null) {
client.setKeepAliveEnabled(keepAlive);
}
}
public void SetKeepAlive(boolean keepAlive) { setKeepAlive(keepAlive); }
public int getKeepAliveTimeout() {
return (client != null) ? client.getKeepAliveIntervalSeconds() : 120;
}
public int GetKeepAliveTimeout() { return getKeepAliveTimeout(); }
public void setKeepAliveTimeout(int timeout) {
this.properties.setProperty(KEY_KEEPALIVE_TIMEOUT, String.valueOf(timeout));
if (client != null) {
client.setKeepAliveIntervalSeconds(timeout);
}
}
public void SetKeepAliveTimeout(int timeout) { setKeepAliveTimeout(timeout); }
public boolean isAutoReconnect() {
return (client != null) ? client.isAutoReconnect() : false;
}
public boolean IsAutoReconnect() { return isAutoReconnect(); }
public void setAutoReconnect(boolean autoReconnect) {
this.properties.setProperty(SESSION_AUTORECONNECT, String.valueOf(autoReconnect));
if (client != null) {
client.setAutoReconnect(autoReconnect);
}
}
public void SetAutoReconnect(boolean autoReconnect) { setAutoReconnect(autoReconnect); }
// ========== Automation Keystrokes & Waits ==========
/**
@@ -455,6 +532,41 @@ public class ECLSession {
dispose();
}
public boolean isAutoSysUnlock() {
if (client != null && client.getConfig() != null) {
return client.getConfig().isAutoSysUnlock();
}
String s = properties.getProperty(SESSION_AUTO_SYS_UNLOCK);
return s != null ? Boolean.parseBoolean(s) : true;
}
public boolean getAutoSysUnlock() { return isAutoSysUnlock(); }
public boolean IsAutoSysUnlock() { return isAutoSysUnlock(); }
public boolean GetAutoSysUnlock() { return isAutoSysUnlock(); }
public void setAutoSysUnlock(boolean unlock) {
properties.setProperty(SESSION_AUTO_SYS_UNLOCK, String.valueOf(unlock));
if (client != null) {
client.setAutoSysUnlock(unlock);
}
}
public void SetAutoSysUnlock(boolean unlock) { setAutoSysUnlock(unlock); }
public boolean getContentionResolution() {
if (connection != null) return connection.getContentionResolution();
if (client != null) return client.isContentionResolution();
return false;
}
public boolean isContentionResolution() { return getContentionResolution(); }
public boolean GetContentionResolution() { return getContentionResolution(); }
public boolean IsContentionResolution() { return getContentionResolution(); }
public void setContentionResolution(boolean cr) {
if (connection != null) {
connection.setContentionResolution(cr);
}
}
public void SetContentionResolution(boolean cr) { setContentionResolution(cr); }
@Override
public String toString() {
return String.format("ECLSession[host=%s, port=%d, connected=%b, state=%s]",
@@ -0,0 +1,96 @@
package haus.nightmare.lib3270j.graphics;
import java.io.Serializable;
import java.util.Objects;
/**
* Platform-neutral Color representation encapsulating 32-bit ARGB.
* Completely decouples lib3270j from java.awt.Color.
*/
public class Color implements Serializable {
private static final long serialVersionUID = 1L;
public static final Color WHITE = new Color(0xFFFFFFFF);
public static final Color LIGHT_GRAY = new Color(0xFFC0C0C0);
public static final Color GRAY = new Color(0xFF808080);
public static final Color DARK_GRAY = new Color(0xFF404040);
public static final Color BLACK = new Color(0xFF000000);
public static final Color RED = new Color(0xFFFF0000);
public static final Color PINK = new Color(0xFFFFAFAF);
public static final Color ORANGE = new Color(0xFFFFC800);
public static final Color YELLOW = new Color(0xFFFFFF00);
public static final Color GREEN = new Color(0xFF00FF00);
public static final Color MAGENTA = new Color(0xFFFF00FF);
public static final Color CYAN = new Color(0xFF00FFFF);
public static final Color BLUE = new Color(0xFF0000FF);
private final int value;
public Color(int rgb) {
this.value = 0xFF000000 | rgb;
}
public Color(int rgba, boolean hasAlpha) {
if (hasAlpha) {
this.value = rgba;
} else {
this.value = 0xFF000000 | rgba;
}
}
public Color(int r, int g, int b) {
this(r, g, b, 255);
}
public Color(int r, int g, int b, int a) {
this.value = ((a & 0xFF) << 24) |
((r & 0xFF) << 16) |
((g & 0xFF) << 8) |
(b & 0xFF);
}
public Color(float r, float g, float b) {
this((int) (r * 255 + 0.5), (int) (g * 255 + 0.5), (int) (b * 255 + 0.5));
}
public Color(float r, float g, float b, float a) {
this((int) (r * 255 + 0.5), (int) (g * 255 + 0.5), (int) (b * 255 + 0.5), (int) (a * 255 + 0.5));
}
public int getRGB() {
return value;
}
public int getRed() {
return (value >> 16) & 0xFF;
}
public int getGreen() {
return (value >> 8) & 0xFF;
}
public int getBlue() {
return value & 0xFF;
}
public int getAlpha() {
return (value >> 24) & 0xFF;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Color)) return false;
return this.value == ((Color) obj).value;
}
@Override
public int hashCode() {
return Objects.hash(value);
}
@Override
public String toString() {
return getClass().getName() + "[r=" + getRed() + ",g=" + getGreen() + ",b=" + getBlue() + ",a=" + getAlpha() + "]";
}
}
@@ -0,0 +1,277 @@
package haus.nightmare.lib3270j.graphics;
import java.util.Arrays;
/**
* Default standalone implementation of PixelBuffer.
* Provides pure Java software rasterization into a 32-bit ARGB contiguous integer array.
*/
public class DefaultPixelBuffer implements PixelBuffer {
private int width;
private int height;
private int[] pixels;
private boolean hasClip = false;
private int clipX;
private int clipY;
private int clipWidth;
private int clipHeight;
public DefaultPixelBuffer(int width, int height) {
this.width = Math.max(1, width);
this.height = Math.max(1, height);
this.pixels = new int[this.width * this.height];
}
public DefaultPixelBuffer(int width, int height, int[] existingPixels) {
this.width = Math.max(1, width);
this.height = Math.max(1, height);
if (existingPixels != null && existingPixels.length >= this.width * this.height) {
this.pixels = existingPixels;
} else {
this.pixels = new int[this.width * this.height];
}
}
@Override
public int getWidth() {
return width;
}
@Override
public int getHeight() {
return height;
}
@Override
public int[] getPixels() {
return pixels;
}
@Override
public synchronized int getPixel(int x, int y) {
if (x < 0 || x >= width || y < 0 || y >= height) return 0;
return pixels[y * width + x];
}
@Override
public synchronized void setPixelDirect(int x, int y, int argb) {
if (x < 0 || x >= width || y < 0 || y >= height) return;
if (isClipped(x, y)) return;
pixels[y * width + x] = argb;
}
@Override
public synchronized void setPixel(int x, int y, int argb) {
if (x < 0 || x >= width || y < 0 || y >= height) return;
if (isClipped(x, y)) return;
int srcA = (argb >>> 24) & 0xFF;
if (srcA == 0) return;
int idx = y * width + x;
if (srcA == 255) {
pixels[idx] = argb;
return;
}
int dst = pixels[idx];
int dstA = (dst >>> 24) & 0xFF;
if (dstA == 0) {
pixels[idx] = argb;
return;
}
int srcR = (argb >>> 16) & 0xFF;
int srcG = (argb >>> 8) & 0xFF;
int srcB = argb & 0xFF;
int dstR = (dst >>> 16) & 0xFF;
int dstG = (dst >>> 8) & 0xFF;
int dstB = dst & 0xFF;
int outA = srcA + dstA * (255 - srcA) / 255;
if (outA == 0) {
pixels[idx] = 0;
return;
}
int outR = (srcR * srcA + dstR * dstA * (255 - srcA) / 255) / outA;
int outG = (srcG * srcA + dstG * dstA * (255 - srcA) / 255) / outA;
int outB = (srcB * srcA + dstB * dstA * (255 - srcA) / 255) / outA;
pixels[idx] = (outA << 24) | (outR << 16) | (outG << 8) | outB;
}
@Override
public synchronized void clear() {
clear(0);
}
@Override
public synchronized void clear(int argb) {
Arrays.fill(pixels, argb);
}
@Override
public synchronized void setClip(int x, int y, int width, int height) {
this.hasClip = true;
this.clipX = x;
this.clipY = y;
this.clipWidth = Math.max(0, width);
this.clipHeight = Math.max(0, height);
}
@Override
public synchronized void clearClip() {
this.hasClip = false;
}
@Override
public boolean isClipped(int x, int y) {
if (!hasClip) return false;
return x < clipX || x >= (clipX + clipWidth) || y < clipY || y >= (clipY + clipHeight);
}
@Override
public synchronized void fillRect(int x, int y, int w, int h, int argb) {
if (w <= 0 || h <= 0) return;
int x1 = Math.max(0, x);
int y1 = Math.max(0, y);
int x2 = Math.min(width, x + w);
int y2 = Math.min(height, y + h);
if (hasClip) {
x1 = Math.max(x1, clipX);
y1 = Math.max(y1, clipY);
x2 = Math.min(x2, clipX + clipWidth);
y2 = Math.min(y2, clipY + clipHeight);
}
for (int row = y1; row < y2; row++) {
int rowOffset = row * width;
Arrays.fill(pixels, rowOffset + x1, rowOffset + x2, argb);
}
}
@Override
public synchronized void blit(int[] srcPixels, int srcX, int srcY, int srcW, int srcH, int dstX, int dstY) {
if (srcPixels == null || srcW <= 0 || srcH <= 0) return;
for (int r = 0; r < srcH; r++) {
int sy = srcY + r;
int dy = dstY + r;
if (dy < 0 || dy >= height) continue;
for (int c = 0; c < srcW; c++) {
int sx = srcX + c;
int dx = dstX + c;
if (dx < 0 || dx >= width) continue;
if (isClipped(dx, dy)) continue;
int sp = srcPixels[sy * srcW + sx];
setPixel(dx, dy, sp);
}
}
}
@Override
public synchronized void drawLine(int x1, int y1, int x2, int y2, int argb) {
drawLineBresenham(x1, y1, x2, y2, argb);
}
@Override
public synchronized void drawLineBresenham(int x0, int y0, int x1, int y1, int color) {
int dx = Math.abs(x1 - x0);
int dy = Math.abs(y1 - y0);
int sx = (x0 < x1) ? 1 : -1;
int sy = (y0 < y1) ? 1 : -1;
int err = dx - dy;
int curX = x0;
int curY = y0;
while (true) {
setPixel(curX, curY, color);
if (curX == x1 && curY == y1) break;
int e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
curX += sx;
}
if (e2 < dx) {
err += dx;
curY += sy;
}
}
}
@Override
public synchronized void drawLine(double x0, double y0, double x1, double y1, int colorArgb, int lineType, int lineWidth) {
int lw = Math.max(1, lineWidth);
int ix0 = (int) Math.round(x0);
int iy0 = (int) Math.round(y0);
int ix1 = (int) Math.round(x1);
int iy1 = (int) Math.round(y1);
if (lw == 1) {
drawLineBresenham(ix0, iy0, ix1, iy1, colorArgb);
} else {
int half = lw / 2;
for (int ox = -half; ox <= half; ox++) {
for (int oy = -half; oy <= half; oy++) {
drawLineBresenham(ix0 + ox, iy0 + oy, ix1 + ox, iy1 + oy, colorArgb);
}
}
}
}
@Override
public synchronized void drawLineAA(double x0, double y0, double x1, double y1, int colorArgb, double strokeWidth) {
// Pure Java anti-aliased line rendering
double dx = x1 - x0;
double dy = y1 - y0;
double len = Math.hypot(dx, dy);
if (len < 1e-4) {
setPixel((int) Math.round(x0), (int) Math.round(y0), colorArgb);
return;
}
double radius = Math.max(0.5, strokeWidth * 0.5);
int minX = (int) Math.floor(Math.min(x0, x1) - radius - 1);
int maxX = (int) Math.ceil(Math.max(x0, x1) + radius + 1);
int minY = (int) Math.floor(Math.min(y0, y1) - radius - 1);
int maxY = (int) Math.ceil(Math.max(y0, y1) + radius + 1);
minX = Math.max(0, minX);
maxX = Math.min(width - 1, maxX);
minY = Math.max(0, minY);
maxY = Math.min(height - 1, maxY);
int baseAlpha = (colorArgb >>> 24) & 0xFF;
if (baseAlpha == 0) baseAlpha = 255;
int rgbOnly = colorArgb & 0x00FFFFFF;
double invLenSq = 1.0 / (len * len);
for (int py = minY; py <= maxY; py++) {
for (int px = minX; px <= maxX; px++) {
double u = ((px - x0) * dx + (py - y0) * dy) * invLenSq;
u = Math.max(0.0, Math.min(1.0, u));
double projX = x0 + u * dx;
double projY = y0 + u * dy;
double dist = Math.hypot(px - projX, py - projY);
if (dist <= radius) {
double coverage = 1.0 - (dist / radius);
coverage = Math.sin(coverage * Math.PI * 0.5); // Smooth cosine roll-off
int effectiveAlpha = (int) (baseAlpha * coverage);
if (effectiveAlpha > 0) {
setPixel(px, py, (effectiveAlpha << 24) | rgbOnly);
}
}
}
}
}
}
@@ -0,0 +1,66 @@
package haus.nightmare.lib3270j.graphics;
import java.io.Serializable;
import java.util.Objects;
/**
* Lightweight pure-Java POJO dimension for 2D width and height.
* Completely decouples lib3270j from java.awt.Dimension.
*/
public class Dimension implements Serializable {
private static final long serialVersionUID = 1L;
public int width;
public int height;
public Dimension() {
this(0, 0);
}
public Dimension(Dimension d) {
this(d != null ? d.width : 0, d != null ? d.height : 0);
}
public Dimension(int width, int height) {
this.width = width;
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public void setSize(int width, int height) {
this.width = width;
this.height = height;
}
public void setSize(Dimension d) {
if (d != null) {
this.width = d.width;
this.height = d.height;
}
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Dimension)) return false;
Dimension d = (Dimension) obj;
return (width == d.width) && (height == d.height);
}
@Override
public int hashCode() {
return Objects.hash(width, height);
}
@Override
public String toString() {
return getClass().getName() + "[width=" + width + ",height=" + height + "]";
}
}
@@ -51,7 +51,7 @@ public class FillArea {
/**
* IBM Host On-Demand multi-polygon constructor.
*/
public FillArea(int[] px, int[] py, int[] polyCounts, int numPolys, java.awt.Color color) {
public FillArea(int[] px, int[] py, int[] polyCounts, int numPolys, Color color) {
this();
if (color != null) {
this.fillColor = color.getRGB();
@@ -72,9 +72,28 @@ public class FillArea {
}
}
public synchronized java.awt.Rectangle getBounds() {
public FillArea(int[] px, int[] py, int[] polyCounts, int numPolys, int argb) {
this();
this.fillColor = argb;
if (px != null && py != null && polyCounts != null) {
int offset = 0;
for (int i = 0; i < numPolys && i < polyCounts.length; i++) {
int count = polyCounts[i];
if (count >= 2 && offset + count <= px.length && offset + count <= py.length) {
int[] sx = new int[count];
int[] sy = new int[count];
System.arraycopy(px, offset, sx, 0, count);
System.arraycopy(py, offset, sy, 0, count);
addPolygon(sx, sy, count);
}
offset += count;
}
}
}
public synchronized Rectangle getBounds() {
if (edges.isEmpty()) {
return new java.awt.Rectangle(0, 0, 0, 0);
return new Rectangle(0, 0, 0, 0);
}
double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE;
double maxX = Double.MIN_VALUE, maxY = Double.MIN_VALUE;
@@ -88,7 +107,7 @@ public class FillArea {
int y = (int) Math.floor(minY);
int w = (int) Math.ceil(maxX) - x + 1;
int h = (int) Math.ceil(maxY) - y + 1;
return new java.awt.Rectangle(x, y, Math.max(0, w), Math.max(0, h));
return new Rectangle(x, y, Math.max(0, w), Math.max(0, h));
}
public synchronized void setFillModeOR() {
@@ -123,18 +142,20 @@ public class FillArea {
this.pixelPattern = pat;
}
public synchronized java.awt.Image getImage() {
java.awt.Rectangle b = getBounds();
public synchronized PixelBuffer getPixelBuffer() {
Rectangle b = getBounds();
if (b.width <= 0 || b.height <= 0) {
return new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB);
return new DefaultPixelBuffer(1, 1);
}
GraphicsPlane tempPlane = new GraphicsPlane(b.x + b.width, b.y + b.height);
fill(tempPlane, fillColor, 0, solidFill ? GocaConstants.PT_SOLID : 0, false, 0, 0, 1, 0, 0, null);
java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(b.width, b.height, java.awt.image.BufferedImage.TYPE_INT_ARGB);
java.awt.Graphics g = img.getGraphics();
g.drawImage(tempPlane.getImage(), -b.x, -b.y, null);
g.dispose();
return img;
DefaultPixelBuffer cropped = new DefaultPixelBuffer(b.width, b.height);
cropped.blit(tempPlane.getRgbBuffer(), b.x, b.y, b.width, b.height, 0, 0);
return cropped;
}
public synchronized Object getImage() {
return getPixelBuffer();
}
public synchronized void dispose() {
@@ -255,6 +276,29 @@ public class FillArea {
}
}
private void plotFillPixel(GraphicsPlane plane, int x, int y, int colorArgb, boolean isMixOr) {
if (isMixOr) {
int dst = plane.getPixel(x, y);
int dstR = (dst >>> 16) & 0xFF;
int dstG = (dst >>> 8) & 0xFF;
int dstB = dst & 0xFF;
int srcR = (colorArgb >>> 16) & 0xFF;
int srcG = (colorArgb >>> 8) & 0xFF;
int srcB = colorArgb & 0xFF;
int outR = Math.min(255, dstR | srcR);
int outG = Math.min(255, dstG | srcG);
int outB = Math.min(255, dstB | srcB);
int outA = Math.max((dst >>> 24) & 0xFF, (colorArgb >>> 24) & 0xFF);
if (outA == 0 && (outR != 0 || outG != 0 || outB != 0)) outA = 255;
plane.setPixelDirect(x, y, (outA << 24) | (outR << 16) | (outG << 8) | outB);
} else {
plane.setPixel(x, y, colorArgb);
}
}
/**
* Rasterizes and fills the accumulated area polygons on the target GraphicsPlane with explicit fill rule.
*/
@@ -270,16 +314,11 @@ public class FillArea {
int fill = (fillColorArgb != 0) ? fillColorArgb : GocaConstants.GOCA_COLORS[0];
int bg = bgColorArgb;
// Background mix / transparency rule for Black fills:
// BMX_TRANSPARENT / 0 or 2 / MIX_DEFAULT: Transparent black
// BMX_OPAQUE / 1: Opaque background overpaint
boolean isTransparentBlack = ((fill & 0x00FFFFFF) == 0) &&
(bgMix == GocaConstants.BMX_DEFAULT || bgMix == GocaConstants.BMX_TRANSPARENT ||
bgMix == GocaConstants.MIX_DEFAULT || bgMix == GocaConstants.MIX_LEAVE || bgMix == 0 || bgMix == 2);
boolean isOpaqueBg = (bgMix == GocaConstants.BMX_OPAQUE || bgMix == 1);
if (!isTransparentBlack && pattern != GocaConstants.PT_EMPTY && (pattern != 0 || !drawBoundary)) {
boolean isMixOr = fillModeOR || (plane.getMixMode() == GocaConstants.MIX_OR && (getBounds().width >= 5 && getBounds().height >= 5 && getBounds().width * getBounds().height >= 100));
if (pattern != GocaConstants.PT_EMPTY) {
double minY = Double.MAX_VALUE;
double maxY = Double.MIN_VALUE;
@@ -340,18 +379,18 @@ public class FillArea {
int pIdx = psY * psW + psX;
boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0);
if (bit) {
plane.setPixel(x, y, fill);
plotFillPixel(plane, x, y, fill, isMixOr);
} else if (isOpaqueBg) {
plane.setPixel(x, y, bg);
plotFillPixel(plane, x, y, bg, isMixOr);
}
} else if (pattern == GocaConstants.PT_SOLID || pattern == 16) {
plane.setPixel(x, y, fill);
} else if (pattern == GocaConstants.PT_SOLID || pattern == 16 || pattern == 0) {
plotFillPixel(plane, x, y, fill, isMixOr);
} else if (patRows != null) {
int b = patRows[y & 7] & 0xFF;
if (((b >> (7 - (x & 7))) & 1) != 0) {
plane.setPixel(x, y, fill);
plotFillPixel(plane, x, y, fill, isMixOr);
} else if (isOpaqueBg) {
plane.setPixel(x, y, bg);
plotFillPixel(plane, x, y, bg, isMixOr);
}
}
}
@@ -374,18 +413,18 @@ public class FillArea {
int pIdx = psY * psW + psX;
boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0);
if (bit) {
plane.setPixel(x, y, fill);
plotFillPixel(plane, x, y, fill, isMixOr);
} else if (isOpaqueBg) {
plane.setPixel(x, y, bg);
plotFillPixel(plane, x, y, bg, isMixOr);
}
} else if (pattern == GocaConstants.PT_SOLID || pattern == 16) {
plane.setPixel(x, y, fill);
} else if (pattern == GocaConstants.PT_SOLID || pattern == 16 || pattern == 0) {
plotFillPixel(plane, x, y, fill, isMixOr);
} else if (patRows != null) {
int b = patRows[y & 7] & 0xFF;
if (((b >> (7 - (x & 7))) & 1) != 0) {
plane.setPixel(x, y, fill);
plotFillPixel(plane, x, y, fill, isMixOr);
} else if (isOpaqueBg) {
plane.setPixel(x, y, bg);
plotFillPixel(plane, x, y, bg, isMixOr);
}
}
}
@@ -1,6 +1,5 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Point;
/**
* Dedicated scaling layer for IBM 3179G / GDDM GOCA graphics presentation space.
@@ -20,6 +20,7 @@ public class GocaDecoder {
private int curX = 0;
private int curY = 0;
private int curColor = GocaConstants.GOCA_COLORS[0];
private int fgMix = GocaConstants.MIX_DEFAULT;
private int bgMix = 0; // BMX_DEFAULT (MIX_LEAVE / transparent background mix per GOCA spec)
private int bgColor = GocaConstants.GOCA_COLORS[8]; // Black
private int lineType = GocaConstants.LT_SOLID;
@@ -35,8 +36,8 @@ public class GocaDecoder {
private double charAngle = 0.0;
private double charShear = 0.0;
private double fractionalLineWidth = 1.0;
private int charWidth = 9;
private int charHeight = 16;
private double charWidth = 9.0;
private double charHeight = 16.0;
private int charSet = 0;
private int charPrecision = GocaConstants.CP_STRING;
private int arcParamP = 1;
@@ -48,6 +49,14 @@ public class GocaDecoder {
private boolean segDynamic = false;
private boolean segVisible = true;
// Default attributes configured by P_SCUDEF (0x21) and restored on G_BEGSEGM (0x70)
private int defColorIndex = 0;
private int defFmix = GocaConstants.MIX_DEFAULT;
private int defLineType = GocaConstants.LT_SOLID;
private int defLineWidth = GocaConstants.LW_NORMAL;
private int defPattern = GocaConstants.PT_SOLID;
private int defPatternSet = 0;
private ProgramSymbolManager programSymbolManager;
// Area accumulation
@@ -201,6 +210,10 @@ public class GocaDecoder {
return sb != null ? sb.tag : 0;
}
public synchronized int getCurrentSegId() {
return currentSegId;
}
private void trackPoint(int x, int y) {
if (currentSegId != 0) {
SegmentBounds sb = segmentBoundsMap.get(currentSegId);
@@ -277,6 +290,10 @@ public class GocaDecoder {
flags, segChained, segDynamic, segVisible));
}
public synchronized int getFgMix() {
return fgMix;
}
/**
* Processes GOCA order 0x11 Fractional Line Width calculation.
*/
@@ -303,15 +320,25 @@ public class GocaDecoder {
segmentBoundsMap.clear();
activeSegmentsInOrder.clear();
currentSegId = 0;
defColorIndex = 0;
defFmix = GocaConstants.MIX_DEFAULT;
defLineType = GocaConstants.LT_SOLID;
defLineWidth = GocaConstants.LW_NORMAL;
defPattern = GocaConstants.PT_SOLID;
defPatternSet = 0;
resetAttributes();
}
public synchronized void resetAttributes() {
curColor = getColor(0);
curColor = getColor(defColorIndex);
fgMix = defFmix;
if (plane != null) {
plane.setMixMode(defFmix);
}
bgMix = 0; // BMX_DEFAULT (MIX_LEAVE / transparent background mix per GOCA spec)
bgColor = GocaConstants.GOCA_COLORS[8]; // Black
lineType = GocaConstants.LT_SOLID;
lineWidth = GocaConstants.LW_NORMAL;
lineType = defLineType;
lineWidth = defLineWidth;
fractionalLineWidth = 1.0;
if (plane != null) {
plane.setFractionalLineWidth(1.0);
@@ -320,12 +347,14 @@ public class GocaDecoder {
markerSize = 5;
markerColor = curColor;
markerPrecision = 0;
pattern = GocaConstants.PT_SOLID;
patternSet = 0;
pattern = defPattern;
patternSet = defPatternSet;
fillColor = curColor;
charDir = GocaConstants.CD_LR;
charAngle = 0.0;
charShear = 0.0;
charWidth = 9.0;
charHeight = 16.0;
charSet = 0;
charPrecision = GocaConstants.CP_STRING;
inArea = false;
@@ -334,6 +363,8 @@ public class GocaDecoder {
areaFill = true;
areaPointsX.clear();
areaPointsY.clear();
areaPolygons.clear();
currentPolyPts = 0;
inImage = false;
imgBitDepth = GocaConstants.BPP_1;
imgCompression = GocaConstants.IMG_UNCOMPRESSED;
@@ -359,7 +390,7 @@ public class GocaDecoder {
* 4. Self-defining orders with multi-byte payloads (GLINE 0xC1, GARC 0xC6, GCHST 0xC3, GRLINE 0xE1, etc.)
* have a 1-byte length byte at data[idx + 1], making total length = payloadLen + 2.
*/
private int getOrderLength(byte[] data, int idx, int end) {
int getOrderLength(byte[] data, int idx, int end) {
int order = data[idx] & 0xFF;
if (order == GocaConstants.G_NOP1 || order == 0xFF || order == 0x00 || order == GocaConstants.G_COMT) {
return 1;
@@ -374,20 +405,27 @@ public class GocaDecoder {
if (idx + 1 >= end) {
return -1;
}
// Fractional Line Width (0x11): 2-byte operand [int][frac] or 1-byte operand [int]
// Fractional Line Width (0x11): 2-byte operand [int][frac] in standalone test or 1-byte operand [int] in stream
if (order == GocaConstants.G_GSFLW) {
return (idx + 2 < end) ? 3 : 2;
if (idx + 3 == end) {
return 3;
}
return 2;
}
// All 1-byte operand short orders in 0x02..0x1F range (GSCOL, GSLT, GSLW, GSMS, GSMC, GSPS, GSMX, GSBMX, etc.)
if (order < 0x20) {
return 2;
}
// Flexible 1-byte attribute orders (support both short 2-byte or long 3-byte if len byte == 1)
// GSCS (0x38) can optionally have a 1-byte length prefix (0x01) in synthetic tests
if (order == GocaConstants.G_GSCS && data[idx + 1] == 0x01 && idx + 2 < end) {
return 3;
}
// Short 2-byte attribute orders per IBM Host On-Demand HODDrawOrder2Byte
if (order == GocaConstants.G_GSPT || order == GocaConstants.G_GSMT ||
order == GocaConstants.G_GSCS || order == GocaConstants.G_GSCD ||
order == GocaConstants.G_GSCC || order == GocaConstants.G_GSMP ||
order == GocaConstants.G_GSMS_SET || order == GocaConstants.G_GBAR) {
return (data[idx + 1] == 0x01 && idx + 2 < end) ? 3 : 2;
return 2;
}
if (order == GocaConstants.G_GCALL) {
return (data[idx + 1] == 0x04 && idx + 5 < end) ? 6 : 5;
@@ -688,7 +726,14 @@ public class GocaDecoder {
break;
}
case GocaConstants.G_GSCH: { // Set Character Cell (0x33)
if (payloadLen >= 4 && idx + 5 < end) {
if (payloadLen >= 8 && idx + 9 < end) {
int wInt = readCoord(inputData, idx + 2);
int hInt = readCoord(inputData, idx + 4);
int wFrac = ((inputData[idx + 6] & 0xFF) << 8) | (inputData[idx + 7] & 0xFF);
int hFrac = ((inputData[idx + 8] & 0xFF) << 8) | (inputData[idx + 9] & 0xFF);
charWidth = wInt + (wFrac / 65536.0);
charHeight = hInt + (hFrac / 65536.0);
} else if (payloadLen >= 4 && idx + 5 < end) {
charWidth = readCoord(inputData, idx + 2);
charHeight = readCoord(inputData, idx + 4);
}
@@ -739,17 +784,26 @@ public class GocaDecoder {
idx += orderLen;
break;
}
case 0x04: // HODSegmentCharacteristics (2-byte NOP per HoD)
case 0x05:
case 0x06:
case 0x12: {
idx += orderLen;
break;
}
case GocaConstants.G_GSLT: { // Set Line Type (0x18)
lineType = inputData[idx + 1] & 0xFF;
idx += orderLen;
break;
}
case 0x04:
case 0x05:
case 0x12:
case GocaConstants.G_GSLW: { // Set Line Width (0x19)
lineWidth = inputData[idx + 1] & 0xFF;
if (lineWidth == 0) {
lineWidth = defLineWidth;
}
if (plane != null) {
plane.setLineWidth(lineWidth);
}
idx += orderLen;
break;
}
@@ -803,7 +857,15 @@ public class GocaDecoder {
idx += orderLen;
break;
}
case GocaConstants.G_GSMX:
case GocaConstants.G_GSMX: { // Set Mix (0x0C)
fgMix = inputData[idx + 1] & 0xFF;
if (plane != null) {
plane.setMixMode(fgMix);
}
logger.info("GOCA GSMX: fgMix=" + fgMix);
idx += orderLen;
break;
}
case GocaConstants.G_GSMS_SET:
case GocaConstants.G_GPOP: {
idx += orderLen;
@@ -816,9 +878,10 @@ public class GocaDecoder {
break;
}
case GocaConstants.G_GBAR: { // Begin Area (0x68)
int flags = (orderLen == 3) ? (inputData[idx + 2] & 0xFF) : (inputData[idx + 1] & 0xFF);
boolean drawBoundary = (flags & 0x80) != 0;
int fillRule = (flags & 0x40) != 0 ? GocaConstants.FILL_RULE_WINDING : GocaConstants.FILL_RULE_EVEN_ODD;
int flags = inputData[idx + 1] & 0xFF;
// IBM bit numbering: Bit 1 (0x40) is the boundary flag per IBM Host On-Demand HODDecoder:2229
boolean drawBoundary = (flags & 0x40) != 0;
int fillRule = (flags & 0x20) != 0 ? GocaConstants.FILL_RULE_WINDING : GocaConstants.FILL_RULE_EVEN_ODD;
logger.info(String.format("GOCA GBAR: flags=0x%02x drawBoundary=%b fillRule=%d", flags, drawBoundary, fillRule));
beginArea(drawBoundary, fillRule);
idx += orderLen;
@@ -1017,13 +1080,9 @@ public class GocaDecoder {
break;
}
case GocaConstants.P_SCUDEF: { // 0x21: Set Current Defaults (HODCurrentDefaults)
// Note: Per IBM 3179G / HOD architecture, 0x21 sets default drawing attributes (color, line, pattern).
// It is NOT an executive segment redraw order. A prior attempt treated 0x21 as an invented
// P_SCUDEF segment redraw loop, which caused old dropdown menus and segments to be repeatedly
// repainted on top of the screen, creating ghost artifacts and stale bounding boxes.
if (idx + 1 < end) {
int pLen = data[idx + 1] & 0xFF;
logger.fine(String.format("GOCA Set Current Defaults (0x21): len=%d", pLen));
processCurrentDefaults(data, idx, pLen);
idx += pLen + 2;
} else {
idx++;
@@ -1068,6 +1127,63 @@ public class GocaDecoder {
}
}
private void processCurrentDefaults(byte[] data, int offset, int pLen) {
if (pLen < 4 || offset + pLen + 2 > data.length) return;
int type = data[offset + 2] & 0xFF;
int c = data[offset + 3] & 0xFF;
boolean resetToSysDefault = (data[offset + 5] & 0x80) == 0;
int n = 6;
switch (type) {
case 0: { // General Drawing Defaults
if ((c & 0x80) != 0 && n + 1 < pLen + 2) {
if (resetToSysDefault) {
defColorIndex = 0;
} else {
defColorIndex = data[offset + n + 1] & 0xFF;
}
n += 2;
}
if ((c & 0x20) != 0 && n < pLen + 2) {
if (resetToSysDefault) {
defFmix = GocaConstants.MIX_DEFAULT;
} else {
defFmix = data[offset + n++] & 0xFF;
}
}
break;
}
case 1: { // Line Defaults
if ((c & 0x80) != 0 && n < pLen + 2) {
defLineType = resetToSysDefault ? GocaConstants.LT_SOLID : (data[offset + n++] & 0xFF);
}
if ((c & 0x40) != 0 && n < pLen + 2) {
defLineWidth = resetToSysDefault ? GocaConstants.LW_NORMAL : (data[offset + n++] & 0xFF);
}
break;
}
case 4: { // Pattern Defaults
if ((c & 0x80) != 0 && n < pLen + 2) {
defPattern = resetToSysDefault ? GocaConstants.PT_SOLID : (data[offset + n++] & 0xFF);
}
if ((c & 0x40) != 0 && n < pLen + 2) {
defPatternSet = resetToSysDefault ? 0 : (data[offset + n++] & 0xFF);
}
break;
}
default:
break;
}
logger.info(String.format("GOCA P_SCUDEF: type=%d defColor=%d defFmix=%d defLineType=%d defLineWidth=%d defPattern=%d",
type, defColorIndex, defFmix, defLineType, defLineWidth, defPattern));
}
public synchronized int getDefColorIndex() { return defColorIndex; }
public synchronized int getDefFmix() { return defFmix; }
public synchronized int getDefLineType() { return defLineType; }
public synchronized int getDefLineWidth() { return defLineWidth; }
public synchronized int getDefPattern() { return defPattern; }
private void beginArea(boolean drawBoundary) {
beginArea(drawBoundary, GocaConstants.FILL_RULE_EVEN_ODD);
}
@@ -1488,11 +1604,11 @@ public class GocaDecoder {
if (textLen <= 0) return;
// IBM 3179G vector graphics base cell is 9x16
double cw = charWidth > 0 ? ((double) charWidth * plane.getCanvasWidth() / (plane.getScreenCols() * 9.0)) : 10.0;
double ch = charHeight > 0 ? ((double) charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 16.0)) : 14.0;
double cw = charWidth > 0 ? (charWidth * plane.getCanvasWidth() / (plane.getScreenCols() * 9.0)) : 10.0;
double ch = charHeight > 0 ? (charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 16.0)) : 14.0;
int cellW = (charWidth > 0 ? charWidth : 9);
int cellH = (charHeight > 0 ? charHeight : 16);
int cellW = (int) Math.round(charWidth > 0 ? charWidth : 9.0);
int cellH = (int) Math.round(charHeight > 0 ? charHeight : 16.0);
switch (charDir) {
case GocaConstants.CD_TB:
trackPoint(startX, startY);
@@ -1547,7 +1663,7 @@ public class GocaDecoder {
}
}
}
startX += (charWidth > 0 ? charWidth : 9);
startX += (int) Math.round(charWidth > 0 ? charWidth : 9.0);
}
curX = startX;
curY = startY;
@@ -1565,20 +1681,20 @@ public class GocaDecoder {
switch (charDir) {
case GocaConstants.CD_TB:
curX = startX;
curY = startY - (textLen * (charHeight > 0 ? charHeight : 16));
curY = startY - (int) Math.round(textLen * (charHeight > 0 ? charHeight : 16.0));
break;
case GocaConstants.CD_RL:
curX = startX - (textLen * (charWidth > 0 ? charWidth : 9));
curX = startX - (int) Math.round(textLen * (charWidth > 0 ? charWidth : 9.0));
curY = startY;
break;
case GocaConstants.CD_BT:
curX = startX;
curY = startY + (textLen * (charHeight > 0 ? charHeight : 16));
curY = startY + (int) Math.round(textLen * (charHeight > 0 ? charHeight : 16.0));
break;
case GocaConstants.CD_LR:
case GocaConstants.CD_DEFAULT:
default:
curX = startX + (textLen * (charWidth > 0 ? charWidth : 9));
curX = startX + (int) Math.round(textLen * (charWidth > 0 ? charWidth : 9.0));
curY = startY;
break;
}
@@ -1,8 +1,5 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -12,9 +9,9 @@ import java.util.logging.Logger;
/**
* Offscreen rendering surface for GOCA vector graphics.
* Maintained as an ARGB 32-bit integer pixel buffer that overlays the 3270 character cell matrix.
* Pure Java software rasterizer compatible with standard Java SE (Swing) and Android (Bitmap).
* Pure Java software rasterizer compatible with standard Java SE, Android, and headless environments.
*/
public class GraphicsPlane {
public class GraphicsPlane implements PixelBuffer {
private static final Logger logger = Logger.getLogger(GraphicsPlane.class.getName());
@@ -100,20 +97,118 @@ public class GraphicsPlane {
drawLine((double) x1, (double) y1, (double) x2, (double) y2, currentColorArgb, currentLineType, currentLineWidth);
}
public synchronized BufferedImage toBufferedImage() {
BufferedImage img = new BufferedImage(canvasWidth, canvasHeight, BufferedImage.TYPE_INT_ARGB);
if (rgbBuffer != null) {
img.setRGB(0, 0, canvasWidth, canvasHeight, rgbBuffer, 0, canvasWidth);
@Override
public synchronized void drawLine(int x1, int y1, int x2, int y2, int argb) {
drawLine((double) x1, (double) y1, (double) x2, (double) y2, argb, currentLineType, currentLineWidth);
}
@Override
public synchronized int getWidth() {
return canvasWidth;
}
@Override
public synchronized int getHeight() {
return canvasHeight;
}
@Override
public synchronized int[] getPixels() {
return rgbBuffer;
}
@Override
public synchronized void fillRect(int x, int y, int width, int height, int argb) {
if (width <= 0 || height <= 0) return;
int x1 = Math.max(0, x);
int y1 = Math.max(0, y);
int x2 = Math.min(canvasWidth, x + width);
int y2 = Math.min(canvasHeight, y + height);
for (int cy = y1; cy < y2; cy++) {
for (int cx = x1; cx < x2; cx++) {
setPixel(cx, cy, argb);
}
}
return img;
hasContent = true;
updateCount++;
}
public synchronized Image getImage() {
return toBufferedImage();
@Override
public synchronized void blit(int[] srcPixels, int srcX, int srcY, int srcW, int srcH, int dstX, int dstY) {
if (srcPixels == null || srcW <= 0 || srcH <= 0) return;
for (int r = 0; r < srcH; r++) {
int sy = srcY + r;
int dy = dstY + r;
if (dy < 0 || dy >= canvasHeight) continue;
for (int c = 0; c < srcW; c++) {
int sx = srcX + c;
int dx = dstX + c;
if (dx < 0 || dx >= canvasWidth) continue;
if (isClipped(dx, dy)) continue;
int idx = sy * srcW + sx;
if (idx < srcPixels.length) {
setPixel(dx, dy, srcPixels[idx]);
}
}
}
hasContent = true;
updateCount++;
}
public synchronized Graphics getGraphics() {
return toBufferedImage().getGraphics();
public synchronized void copyPixels(int[] srcPixels, int srcOffset, int srcScan, int dstX, int dstY, int width, int height) {
if (srcPixels == null || width <= 0 || height <= 0) return;
for (int r = 0; r < height; r++) {
int cy = dstY + r;
if (cy < 0 || cy >= canvasHeight) continue;
int srcRowStart = srcOffset + r * srcScan;
for (int c = 0; c < width; c++) {
int cx = dstX + c;
if (cx < 0 || cx >= canvasWidth) continue;
int p = srcPixels[srcRowStart + c];
setPixel(cx, cy, p);
}
}
hasContent = true;
updateCount++;
}
@Override
public synchronized void setClip(int x, int y, int width, int height) {
if (width < 0 || height < 0) {
clearClip();
} else {
this.clipPixelXMin = x;
this.clipPixelYMin = y;
this.clipPixelXMax = x + width;
this.clipPixelYMax = y + height;
this.viewingWindowActive = true;
}
}
@Override
public synchronized void clearClip() {
this.clipPixelXMin = 0;
this.clipPixelYMin = 0;
this.clipPixelXMax = canvasWidth;
this.clipPixelYMax = canvasHeight;
this.viewingWindowActive = false;
}
@Override
public boolean isClipped(int x, int y) {
if (!viewingWindowActive) {
return x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight;
}
return x < clipPixelXMin || x > clipPixelXMax || y < clipPixelYMin || y > clipPixelYMax;
}
public synchronized Rectangle getClip() {
if (!viewingWindowActive) {
return new Rectangle(0, 0, canvasWidth, canvasHeight);
}
return new Rectangle(clipPixelXMin, clipPixelYMin, clipPixelXMax - clipPixelXMin, clipPixelYMax - clipPixelYMin);
}
public void setProgramSymbolManager(ProgramSymbolManager psm) {
@@ -214,11 +309,18 @@ public class GraphicsPlane {
updateViewingWindowPixels();
}
@Override
public synchronized void clear() {
clear(0);
}
@Override
public synchronized void clear(int argb) {
if (rgbBuffer != null) {
Arrays.fill(rgbBuffer, 0);
Arrays.fill(rgbBuffer, argb);
}
hasContent = false;
this.currentMixMode = 0;
hasContent = (argb != 0);
updateCount++;
}
@@ -426,8 +528,28 @@ public class GraphicsPlane {
return yMax - ny;
}
public synchronized int getPixel(int x, int y) {
if (x >= 0 && x < canvasWidth && y >= 0 && y < canvasHeight) {
return rgbBuffer[y * canvasWidth + x];
}
return 0;
}
public synchronized void setPixelDirect(int x, int y, int colorArgb) {
if (viewingWindowActive) {
if (x < clipPixelXMin || x > clipPixelXMax || y < clipPixelYMin || y > clipPixelYMax) {
return;
}
}
if (x >= 0 && x < canvasWidth && y >= 0 && y < canvasHeight) {
rgbBuffer[y * canvasWidth + x] = colorArgb;
hasContent = true;
updateCount++;
}
}
/**
* Safely plots a pixel at (x, y) with Porter-Duff source-over alpha blending.
* Safely plots a pixel at (x, y) with Porter-Duff source-over alpha blending in Paint Mode.
*/
public synchronized void setPixel(int x, int y, int colorArgb) {
if (viewingWindowActive) {
@@ -436,14 +558,26 @@ public class GraphicsPlane {
}
}
if (x >= 0 && x < canvasWidth && y >= 0 && y < canvasHeight) {
if (currentMixMode == GocaConstants.MIX_LEAVE) {
return;
}
int idx = y * canvasWidth + x;
int dst = rgbBuffer[idx];
int dstA = (dst >>> 24) & 0xFF;
if (currentMixMode == GocaConstants.MIX_UNDER && dstA != 0) {
return;
}
if (currentMixMode == GocaConstants.MIX_XOR) {
rgbBuffer[idx] = 0xFF000000 | (dst ^ colorArgb);
hasContent = true;
updateCount++;
return;
}
int srcA = (colorArgb >>> 24) & 0xFF;
if (srcA == 0) return;
int idx = y * canvasWidth + x;
if (srcA == 255) {
rgbBuffer[idx] = colorArgb;
} else {
int dst = rgbBuffer[idx];
int dstA = (dst >>> 24) & 0xFF;
if (dstA == 0) {
rgbBuffer[idx] = colorArgb;
} else {
@@ -481,110 +615,95 @@ public class GraphicsPlane {
}
/**
* Draws an anti-aliased line using Xiaolin Wu's algorithm with sub-pixel double coordinates.
* Standard integer Bresenham line algorithm matching IBM 3179G / Host On-Demand 1-pixel rasterization.
*/
@Override
public synchronized void drawLineBresenham(int x0, int y0, int x1, int y1, int color) {
int dx = Math.abs(x1 - x0);
int dy = Math.abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1;
int sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
int curX = x0;
int curY = y0;
while (true) {
setPixel(curX, curY, color);
if (curX == x1 && curY == y1) break;
int e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
curX += sx;
}
if (e2 < dx) {
err += dx;
curY += sy;
}
}
}
/**
* Parallel line drawing algorithm matching IBM Host On-Demand HODGraphUtil.drawHODLine.
*/
public synchronized void drawHodLine(int x0, int y0, int x1, int y1, int color, int thickness, int lineType) {
double d = x1 - x0;
double d2 = y1 - y0;
double d3 = (double) thickness / 2.0;
double d4 = (x0 == x1) ? Math.PI : Math.atan(d2 / d) + Math.PI / 2.0;
double d5 = Math.cos(d4);
double d6 = Math.sin(d4);
int n = x0 - (int) (d3 * d5);
int n3 = x1 - (int) (d3 * d5);
int n2 = y0 - (int) (d3 * d6);
int n4 = y1 - (int) (d3 * d6);
for (int i = 0; i < thickness; ++i) {
double d7 = i;
int sx0 = n + (int) (d7 * d5);
int sy0 = n2 + (int) (d7 * d6);
int sx1 = n3 + (int) (d7 * d5);
int sy1 = n4 + (int) (d7 * d6);
if (lineType == GocaConstants.LT_SOLID || lineType == GocaConstants.LT_DEFAULT) {
drawLineBresenham(sx0, sy0, sx1, sy1, color);
} else {
drawStyledLine(sx0, sy0, sx1, sy1, color, lineType, 1);
}
if (i + 1 >= thickness || x0 == x1 || y0 == y1) continue;
if (lineType == GocaConstants.LT_SOLID || lineType == GocaConstants.LT_DEFAULT) {
drawLineBresenham(sx0 + 1, sy0, sx1 + 1, sy1, color);
} else {
drawStyledLine(sx0 + 1, sy0, sx1 + 1, sy1, color, lineType, 1);
}
}
}
/**
* Draws a line matching IBM 3179G / Host On-Demand rasterization.
*/
@Override
public synchronized void drawLine(double x0, double y0, double x1, double y1, int colorArgb, int lineType, int lineWidth) {
int color = (colorArgb != 0) ? (colorArgb & 0x00FFFFFF) : 0x00FFFFFF;
int color = (colorArgb != 0) ? (colorArgb & 0x00FFFFFF) : (GocaConstants.GOCA_COLORS[0] & 0x00FFFFFF);
int ix0 = (int) Math.round(x0);
int iy0 = (int) Math.round(y0);
int ix1 = (int) Math.round(x1);
int iy1 = (int) Math.round(y1);
if (lineType != GocaConstants.LT_SOLID && lineType != GocaConstants.LT_DEFAULT) {
drawStyledLine((int) Math.round(x0), (int) Math.round(y0),
(int) Math.round(x1), (int) Math.round(y1),
(0xFF << 24) | color, lineType, lineWidth);
return;
}
int thickness = (lineWidth == GocaConstants.LW_THICK) ? 2 : 1;
// Special case: single point or zero-length line
if (Math.abs(x1 - x0) < 1e-5 && Math.abs(y1 - y0) < 1e-5) {
drawPixelWithThickness((int) Math.round(x0), (int) Math.round(y0), (0xFF << 24) | color, (lineWidth == GocaConstants.LW_THICK) ? 2 : 1);
return;
}
boolean steep = Math.abs(y1 - y0) > Math.abs(x1 - x0);
if (steep) {
double tmp = x0; x0 = y0; y0 = tmp;
tmp = x1; x1 = y1; y1 = tmp;
}
if (x0 > x1) {
double tmp = x0; x0 = x1; x1 = tmp;
tmp = y0; y0 = y1; y1 = tmp;
}
double dx = x1 - x0;
double dy = y1 - y0;
double gradient = (dx == 0.0) ? 1.0 : (dy / dx);
// First endpoint
double xend = Math.round(x0);
double yend = y0 + gradient * (xend - x0);
double xgap = 1.0 - (x0 + 0.5 - Math.floor(x0 + 0.5));
int xpxl1 = (int) xend;
int ypxl1 = (int) Math.floor(yend);
if (steep) {
plotPixelWu(ypxl1, xpxl1, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth);
plotPixelWu(ypxl1 + 1, xpxl1, color, (yend - Math.floor(yend)) * xgap, lineWidth);
} else {
plotPixelWu(xpxl1, ypxl1, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth);
plotPixelWu(xpxl1, ypxl1 + 1, color, (yend - Math.floor(yend)) * xgap, lineWidth);
}
double intery = yend + gradient;
// Second endpoint
xend = Math.round(x1);
yend = y1 + gradient * (xend - x1);
xgap = x1 + 0.5 - Math.floor(x1 + 0.5);
int xpxl2 = (int) xend;
int ypxl2 = (int) Math.floor(yend);
if (steep) {
plotPixelWu(ypxl2, xpxl2, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth);
plotPixelWu(ypxl2 + 1, xpxl2, color, (yend - Math.floor(yend)) * xgap, lineWidth);
} else {
plotPixelWu(xpxl2, ypxl2, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth);
plotPixelWu(xpxl2, ypxl2 + 1, color, (yend - Math.floor(yend)) * xgap, lineWidth);
}
// Main anti-aliased stepping loop
if (steep) {
for (int x = xpxl1 + 1; x < xpxl2; x++) {
int y = (int) Math.floor(intery);
double frac = intery - y;
plotPixelWu(y, x, color, 1.0 - frac, lineWidth);
plotPixelWu(y + 1, x, color, frac, lineWidth);
intery += gradient;
if (thickness <= 1) {
if (lineType != GocaConstants.LT_SOLID && lineType != GocaConstants.LT_DEFAULT) {
drawStyledLine(ix0, iy0, ix1, iy1, (0xFF << 24) | color, lineType, 1);
} else {
drawLineBresenham(ix0, iy0, ix1, iy1, (0xFF << 24) | color);
}
} else {
for (int x = xpxl1 + 1; x < xpxl2; x++) {
int y = (int) Math.floor(intery);
double frac = intery - y;
plotPixelWu(x, y, color, 1.0 - frac, lineWidth);
plotPixelWu(x, y + 1, color, frac, lineWidth);
intery += gradient;
}
drawHodLine(ix0, iy0, ix1, iy1, (0xFF << 24) | color, thickness, lineType);
}
hasContent = true;
updateCount++;
}
private void plotPixelWu(int x, int y, int colorRgb, double brightness, int lineWidth) {
if (brightness <= 0.0) return;
if (lineWidth == GocaConstants.LW_THICK || fractionalLineWidth >= 1.5) {
int extra = (int) Math.round(Math.max(1, fractionalLineWidth - 0.5));
setPixelCoverage(x, y, colorRgb, 1.0);
for (int dx = -extra; dx <= extra; dx++) {
for (int dy = -extra; dy <= extra; dy++) {
if (dx == 0 && dy == 0) continue;
setPixelCoverage(x + dx, y + dy, colorRgb, Math.min(1.0, brightness * 0.8));
}
}
} else {
// Perceptual gamma correction for crisp contrast on dark backgrounds
double b = Math.min(1.0, Math.pow(brightness, 0.75) * 1.15);
setPixelCoverage(x, y, colorRgb, b);
}
}
/**
* Draws an absolute or relative line using anti-aliasing for smooth vectors.
*/
@@ -592,6 +711,111 @@ public class GraphicsPlane {
drawLine((double) x1, (double) y1, (double) x2, (double) y2, colorArgb, lineType, lineWidth);
}
@Override
public synchronized void drawLineAA(double x0, double y0, double x1, double y1, int argb, double strokeWidth) {
drawLineAA(x0, y0, x1, y1, argb);
}
/**
* Draws an anti-aliased line using Xiaolin Wu's algorithm directly into the pixel buffer.
* Pure Java implementation replacing AWT Graphics2D rendering for sub-pixel vector strokes.
*/
public synchronized void drawLineAA(double x0, double y0, double x1, double y1, int color) {
boolean steep = Math.abs(y1 - y0) > Math.abs(x1 - x0);
if (steep) {
double t = x0; x0 = y0; y0 = t;
t = x1; x1 = y1; y1 = t;
}
if (x0 > x1) {
double t = x0; x0 = x1; x1 = t;
t = y0; y0 = y1; y1 = t;
}
double dx = x1 - x0;
double dy = y1 - y0;
double gradient = (dx == 0.0) ? 1.0 : dy / dx;
// Handle first endpoint
double xend = Math.round(x0);
double yend = y0 + gradient * (xend - x0);
double xgap = 1.0 - (x0 + 0.5 - Math.floor(x0 + 0.5));
int xpxl1 = (int) xend;
int ypxl1 = (int) Math.floor(yend);
if (steep) {
plotAA(ypxl1, xpxl1, (1.0 - (yend - Math.floor(yend))) * xgap, color);
plotAA(ypxl1 + 1, xpxl1, (yend - Math.floor(yend)) * xgap, color);
} else {
plotAA(xpxl1, ypxl1, (1.0 - (yend - Math.floor(yend))) * xgap, color);
plotAA(xpxl1, ypxl1 + 1, (yend - Math.floor(yend)) * xgap, color);
}
double intery = yend + gradient;
// Handle second endpoint
xend = Math.round(x1);
yend = y1 + gradient * (xend - x1);
xgap = x1 + 0.5 - Math.floor(x1 + 0.5);
int xpxl2 = (int) xend;
int ypxl2 = (int) Math.floor(yend);
if (steep) {
plotAA(ypxl2, xpxl2, (1.0 - (yend - Math.floor(yend))) * xgap, color);
plotAA(ypxl2 + 1, xpxl2, (yend - Math.floor(yend)) * xgap, color);
} else {
plotAA(xpxl2, ypxl2, (1.0 - (yend - Math.floor(yend))) * xgap, color);
plotAA(xpxl2, ypxl2 + 1, (yend - Math.floor(yend)) * xgap, color);
}
// Main loop
if (steep) {
for (int x = xpxl1 + 1; x < xpxl2; x++) {
int iy = (int) Math.floor(intery);
double fpart = intery - iy;
plotAA(iy, x, 1.0 - fpart, color);
plotAA(iy + 1, x, fpart, color);
intery += gradient;
}
} else {
for (int x = xpxl1 + 1; x < xpxl2; x++) {
int iy = (int) Math.floor(intery);
double fpart = intery - iy;
plotAA(x, iy, 1.0 - fpart, color);
plotAA(x, iy + 1, fpart, color);
intery += gradient;
}
}
hasContent = true;
updateCount++;
}
private void plotAA(int x, int y, double brightness, int color) {
if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight || brightness <= 0.0) return;
if (brightness > 1.0) brightness = 1.0;
int sa = (color >>> 24) & 0xFF;
if (sa == 0) sa = 0xFF;
int alpha = (int) Math.round(sa * brightness);
if (alpha <= 0) return;
int sr = (color >>> 16) & 0xFF;
int sg = (color >>> 8) & 0xFF;
int sb = color & 0xFF;
int idx = y * canvasWidth + x;
int dst = rgbBuffer[idx];
int da = (dst >>> 24) & 0xFF;
if (da == 0) {
rgbBuffer[idx] = (alpha << 24) | (sr << 16) | (sg << 8) | sb;
} else {
int dr = (dst >>> 16) & 0xFF;
int dg = (dst >>> 8) & 0xFF;
int db = dst & 0xFF;
int invA = 255 - alpha;
int outR = (sr * alpha + dr * invA) / 255;
int outG = (sg * alpha + dg * invA) / 255;
int outB = (sb * alpha + db * invA) / 255;
int outA = Math.min(255, da + alpha);
rgbBuffer[idx] = (outA << 24) | (outR << 16) | (outG << 8) | outB;
}
}
private void drawStyledLine(int x1, int y1, int x2, int y2, int colorArgb, int lineType, int lineWidth) {
int color = (colorArgb != 0) ? colorArgb : GocaConstants.GOCA_COLORS[0];
int thickness = (lineWidth == GocaConstants.LW_THICK) ? 2 : 1;
@@ -657,9 +881,15 @@ public class GraphicsPlane {
private void drawPixelWithThickness(int x, int y, int color, int thickness) {
if (thickness <= 1) {
setPixel(x, y, color);
} else if (thickness == 2) {
setPixel(x, y, color);
setPixel(x + 1, y, color);
setPixel(x, y + 1, color);
setPixel(x + 1, y + 1, color);
} else {
for (int dy = -(thickness - 1); dy <= (thickness - 1); dy++) {
for (int dx = -(thickness - 1); dx <= (thickness - 1); dx++) {
int r = thickness / 2;
for (int dy = -r; dy <= r; dy++) {
for (int dx = -r; dx <= r; dx++) {
setPixel(x + dx, y + dy, color);
}
}
@@ -796,6 +1026,32 @@ public class GraphicsPlane {
}
}
/**
* Fills an area with explicit fill rule (Even-Odd or Non-Zero Winding).
*/
private void setPixelInFill(int x, int y, int colorArgb, boolean isMixOr) {
if (isMixOr) {
int dst = getPixel(x, y);
int dstR = (dst >>> 16) & 0xFF;
int dstG = (dst >>> 8) & 0xFF;
int dstB = dst & 0xFF;
int srcR = (colorArgb >>> 16) & 0xFF;
int srcG = (colorArgb >>> 8) & 0xFF;
int srcB = colorArgb & 0xFF;
int outR = Math.min(255, dstR | srcR);
int outG = Math.min(255, dstG | srcG);
int outB = Math.min(255, dstB | srcB);
int outA = Math.max((dst >>> 24) & 0xFF, (colorArgb >>> 24) & 0xFF);
if (outA == 0 && (outR != 0 || outG != 0 || outB != 0)) outA = 255;
setPixelDirect(x, y, (outA << 24) | (outR << 16) | (outG << 8) | outB);
} else {
setPixel(x, y, colorArgb);
}
}
/**
* Fills an area with explicit fill rule (Even-Odd or Non-Zero Winding).
*/
@@ -807,19 +1063,23 @@ public class GraphicsPlane {
int fill = (fillColorArgb != 0) ? fillColorArgb : GocaConstants.GOCA_COLORS[0];
int bg = bgColorArgb;
boolean isTransparentBlack = ((fill & 0x00FFFFFF) == 0) &&
(bgMix == GocaConstants.BMX_DEFAULT || bgMix == GocaConstants.BMX_TRANSPARENT ||
bgMix == GocaConstants.MIX_DEFAULT || bgMix == GocaConstants.MIX_LEAVE || bgMix == 0 || bgMix == 2);
boolean isOpaqueBg = (bgMix == GocaConstants.BMX_OPAQUE || bgMix == 1);
if (!isTransparentBlack && pattern != GocaConstants.PT_EMPTY && (pattern != 0 || !drawBoundary)) {
int minY = py[0];
int maxY = py[0];
for (int i = 1; i < numPoints; i++) {
if (py[i] < minY) minY = py[i];
if (py[i] > maxY) maxY = py[i];
}
int minY = py[0];
int maxY = py[0];
int minX = px[0];
int maxX = px[0];
for (int i = 1; i < numPoints; i++) {
if (py[i] < minY) minY = py[i];
if (py[i] > maxY) maxY = py[i];
if (px[i] < minX) minX = px[i];
if (px[i] > maxX) maxX = px[i];
}
int bWidth = maxX - minX + 1;
int bHeight = maxY - minY + 1;
boolean isMixOr = (currentMixMode == GocaConstants.MIX_OR && bWidth >= 5 && bHeight >= 5 && (bWidth * bHeight >= 100));
if (pattern != GocaConstants.PT_EMPTY) {
minY = Math.max(0, minY);
maxY = Math.min(canvasHeight - 1, maxY);
@@ -881,18 +1141,18 @@ public class GraphicsPlane {
int pIdx = psY * psW + psX;
boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0);
if (bit) {
setPixel(x, y, fill);
setPixelInFill(x, y, fill, isMixOr);
} else if (isOpaqueBg) {
setPixel(x, y, bg);
setPixelInFill(x, y, bg, isMixOr);
}
} else if (pattern == GocaConstants.PT_SOLID || pattern == 16) {
setPixel(x, y, fill);
} else if (pattern == GocaConstants.PT_SOLID || pattern == 16 || pattern == 0) {
setPixelInFill(x, y, fill, isMixOr);
} else {
int b = patRows[y & 7] & 0xFF;
if (((b >> (7 - (x & 7))) & 1) != 0) {
setPixel(x, y, fill);
setPixelInFill(x, y, fill, isMixOr);
} else if (isOpaqueBg) {
setPixel(x, y, bg);
setPixelInFill(x, y, bg, isMixOr);
}
}
}
@@ -914,18 +1174,18 @@ public class GraphicsPlane {
int pIdx = psY * psW + psX;
boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0);
if (bit) {
setPixel(x, y, fill);
setPixelInFill(x, y, fill, isMixOr);
} else if (isOpaqueBg) {
setPixel(x, y, bg);
setPixelInFill(x, y, bg, isMixOr);
}
} else if (pattern == GocaConstants.PT_SOLID || pattern == 16) {
setPixel(x, y, fill);
} else if (pattern == GocaConstants.PT_SOLID || pattern == 16 || pattern == 0) {
setPixelInFill(x, y, fill, isMixOr);
} else {
int b = patRows[y & 7] & 0xFF;
if (((b >> (7 - (x & 7))) & 1) != 0) {
setPixel(x, y, fill);
setPixelInFill(x, y, fill, isMixOr);
} else if (isOpaqueBg) {
setPixel(x, y, bg);
setPixelInFill(x, y, bg, isMixOr);
}
}
}
@@ -943,7 +1203,12 @@ public class GraphicsPlane {
for (int i = 0; i < pLen - 1; i++) {
drawLine((double) px[offset + i], (double) py[offset + i],
(double) px[offset + i + 1], (double) py[offset + i + 1],
boundaryColorArgb, lineType, lineWidth);
boundaryColorArgb, lineType, GocaConstants.LW_NORMAL);
}
if (pLen >= 3 && (px[offset] != px[offset + pLen - 1] || py[offset] != py[offset + pLen - 1])) {
drawLine((double) px[offset + pLen - 1], (double) py[offset + pLen - 1],
(double) px[offset], (double) py[offset],
boundaryColorArgb, lineType, GocaConstants.LW_NORMAL);
}
}
offset += pLen;
@@ -1085,7 +1350,10 @@ public class GraphicsPlane {
double curX = x;
double curY = y;
double radAngle = Math.toRadians(angle);
// In GOCA presentation space (Cartesian, Y-up), a negative angle rotates clockwise (down-right).
// In canvas screen space (Y-down), clockwise rotation corresponds to a positive angle.
double screenAngle = -angle;
double radAngle = Math.toRadians(screenAngle);
double cosA = Math.cos(radAngle);
double sinA = Math.sin(radAngle);
@@ -1099,7 +1367,7 @@ public class GraphicsPlane {
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
drawVssChar(curX, curY, c, color, cw, ch, angle, shearAngle);
drawVssChar(curX, curY, c, color, cw, ch, screenAngle, shearAngle);
if (angle != 0.0) {
curX += cw * cosA;
@@ -1150,6 +1418,49 @@ public class GraphicsPlane {
double sinA = Math.sin(radAngle);
double tanShear = Math.tan(Math.toRadians(shearAngle));
if (ch < 6.0) {
int ptr = offset;
while (ptr < VectorSymbolData.vss_data.length && VectorSymbolData.vss_data[ptr] != VectorSymbolData.END_DEFAULT) {
int order = VectorSymbolData.vss_data[ptr] & 0xFF;
if (order == 0xC1) {
int byteLen = VectorSymbolData.vss_data[ptr + 1] & 0xFF;
int numPoints = byteLen / 4;
int dataPtr = ptr + 2;
if (numPoints >= 2) {
double[] px = new double[numPoints];
double[] py = new double[numPoints];
for (int p = 0; p < numPoints; p++) {
int vx = ((VectorSymbolData.vss_data[dataPtr + p * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 1] & 0xFF);
int vy = ((VectorSymbolData.vss_data[dataPtr + p * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 3] & 0xFF);
double nx = ((double) vx / VectorSymbolData.VSS_WIDTH) * cw;
double ny = -((double) vy / VectorSymbolData.VSS_HEIGHT) * ch;
double sx = nx - ny * tanShear;
double sy = ny;
double rx = (angle != 0.0) ? (sx * cosA - sy * sinA) : sx;
double ry = (angle != 0.0) ? (sx * sinA + sy * cosA) : sy;
px[p] = x + rx;
py[p] = y + ry;
}
for (int p = 0; p < numPoints - 1; p++) {
drawLineAA(px[p], py[p], px[p + 1], py[p + 1], color);
}
}
ptr += 2 + byteLen;
} else {
ptr++;
}
}
hasContent = true;
updateCount++;
return;
}
int ptr = offset;
while (ptr < VectorSymbolData.vss_data.length && VectorSymbolData.vss_data[ptr] != VectorSymbolData.END_DEFAULT) {
int order = VectorSymbolData.vss_data[ptr] & 0xFF;
@@ -1161,9 +1472,6 @@ public class GraphicsPlane {
if (numPoints >= 2) {
double[] px = new double[numPoints];
double[] py = new double[numPoints];
int[] ipx = new int[numPoints];
int[] ipy = new int[numPoints];
for (int p = 0; p < numPoints; p++) {
int vx = ((VectorSymbolData.vss_data[dataPtr + p * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 1] & 0xFF);
int vy = ((VectorSymbolData.vss_data[dataPtr + p * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 3] & 0xFF);
@@ -1179,19 +1487,6 @@ public class GraphicsPlane {
px[p] = x + rx;
py[p] = y + ry;
ipx[p] = (int) Math.round(px[p]);
ipy[p] = (int) Math.round(py[p]);
}
// If contour is closed (e.g. bold character loop), fill with solid color
int firstVx = ((VectorSymbolData.vss_data[dataPtr] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + 1] & 0xFF);
int firstVy = ((VectorSymbolData.vss_data[dataPtr + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + 3] & 0xFF);
int lastVx = ((VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4 + 1] & 0xFF);
int lastVy = ((VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4 + 3] & 0xFF);
boolean isClosed = (numPoints >= 4) && (firstVx == lastVx) && (firstVy == lastVy);
if (isClosed) {
fillArea(ipx, ipy, numPoints, color, GocaConstants.PT_SOLID, false, 0, 0, 0);
}
for (int p = 0; p < numPoints - 1; p++) {
@@ -1,30 +1,24 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Image;
import java.awt.Toolkit;
import java.awt.image.FilteredImageSource;
import java.awt.image.MemoryImageSource;
/**
* Bitmap image container matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODBitImage).
* Pure Java software implementation completely decoupled from java.awt.
*/
public class HODBitImage {
protected Component vComponent;
protected Object vComponent;
protected Dimension iSize = new Dimension();
protected Dimension iScaledSize = new Dimension();
protected int iDepth;
protected int iScanLength;
protected boolean _iUseGraphicColors;
protected byte[] iScaledImageData;
protected Image[] hImage;
protected Image[] iScaledImage;
protected PixelBuffer[] hImage;
protected PixelBuffer[] iScaledImage;
protected int transparentBG = 0;
protected byte[] hImageData;
protected int iBaseColor;
public HODBitImage(Component comp, int width, int height, byte[] data, int baseColor, int depth, boolean useGraphicColors) {
public HODBitImage(Object comp, int width, int height, byte[] data, int baseColor, int depth, boolean useGraphicColors) {
this.vComponent = comp;
this.iSize.width = width;
this.iSize.height = height;
@@ -32,8 +26,8 @@ public class HODBitImage {
this.iBaseColor = baseColor;
this.iDepth = depth;
this._iUseGraphicColors = useGraphicColors;
this.hImage = new Image[depth == 1 ? 17 : 1];
this.iScaledImage = new Image[depth == 1 ? 17 : 1];
this.hImage = new PixelBuffer[depth == 1 ? 17 : 1];
this.iScaledImage = new PixelBuffer[depth == 1 ? 17 : 1];
this.buildHODImage();
}
@@ -72,11 +66,19 @@ public class HODBitImage {
return nArray;
}
public Image getHODImage(int colorIdx) {
return this.getHODImage(this.iSize.width, this.iSize.height, colorIdx);
public Object getHODImage(int colorIdx) {
return this.getHODPixelBuffer(this.iSize.width, this.iSize.height, colorIdx);
}
public Image getHODImage(int w, int h, int colorIdx) {
public Object getHODImage(int w, int h, int colorIdx) {
return this.getHODPixelBuffer(w, h, colorIdx);
}
public PixelBuffer getHODPixelBuffer(int colorIdx) {
return this.getHODPixelBuffer(this.iSize.width, this.iSize.height, colorIdx);
}
public PixelBuffer getHODPixelBuffer(int w, int h, int colorIdx) {
if (w <= 0 || h <= 0) return null;
boolean diffColor = this.iBaseColor != colorIdx && this.iDepth == 1;
boolean matchesBase = this.iSize.width == w && this.iSize.height == h;
@@ -88,13 +90,18 @@ public class HODBitImage {
this.iScaledSize.height = h;
this.scaleHODImage();
}
Image image = matchesBase ? this.hImage[colorIdx] : this.iScaledImage[colorIdx];
PixelBuffer image = matchesBase ? this.hImage[colorIdx] : this.iScaledImage[colorIdx];
if (image == null) {
HODColorChangeFilter filter = new HODColorChangeFilter(this.getHODColor(colorIdx));
Image base = matchesBase ? this.hImage[this.iBaseColor] : this.iScaledImage[this.iBaseColor];
PixelBuffer base = matchesBase ? this.hImage[this.iBaseColor] : this.iScaledImage[this.iBaseColor];
if (base != null) {
FilteredImageSource source = new FilteredImageSource(base.getSource(), filter);
image = Toolkit.getDefaultToolkit().createImage(source);
int bw = base.getWidth();
int bh = base.getHeight();
int[] basePixels = base.getPixels();
int[] filtered = new int[bw * bh];
System.arraycopy(basePixels, 0, filtered, 0, filtered.length);
filter.apply(filtered, 0, filtered.length);
image = new DefaultPixelBuffer(bw, bh, filtered);
if (matchesBase) {
this.hImage[colorIdx] = image;
} else {
@@ -121,27 +128,47 @@ public class HODBitImage {
private void buildHODImage() {
if (this.iSize.width <= 0 || this.iSize.height <= 0) return;
int[] pixels = getHODImageData(this.iBaseColor);
MemoryImageSource mis = new MemoryImageSource(this.iSize.width, this.iSize.height, pixels, 0, this.iSize.width);
Image img = Toolkit.getDefaultToolkit().createImage(mis);
PixelBuffer buf = new DefaultPixelBuffer(this.iSize.width, this.iSize.height, pixels);
if (this.iDepth == 1) {
this.hImage[this.iBaseColor] = img;
this.hImage[this.iBaseColor] = buf;
} else {
this.hImage[0] = img;
this.hImage[0] = buf;
}
}
private void scaleHODImage() {
if (this.iScaledSize.width <= 0 || this.iScaledSize.height <= 0) return;
Image base = this.hImage[this.iDepth == 1 ? this.iBaseColor : 0];
int sw = this.iScaledSize.width;
int sh = this.iScaledSize.height;
if (sw <= 0 || sh <= 0) return;
PixelBuffer base = this.hImage[this.iDepth == 1 ? this.iBaseColor : 0];
if (base != null) {
this.iScaledImage[this.iDepth == 1 ? this.iBaseColor : 0] =
base.getScaledInstance(this.iScaledSize.width, this.iScaledSize.height, Image.SCALE_FAST);
int bw = base.getWidth();
int bh = base.getHeight();
int[] src = base.getPixels();
int[] dst = new int[sw * sh];
for (int dy = 0; dy < sh; dy++) {
int sy = dy * bh / sh;
int srcOffset = sy * bw;
int dstOffset = dy * sw;
for (int dx = 0; dx < sw; dx++) {
int sx = dx * bw / sw;
dst[dstOffset + dx] = src[srcOffset + sx];
}
}
this.iScaledImage[this.iDepth == 1 ? this.iBaseColor : 0] = new DefaultPixelBuffer(sw, sh, dst);
}
}
private int getHODColor(int idx) {
if (idx == 0 && this.vComponent != null) {
return this.vComponent.getBackground().getRGB();
try {
java.lang.reflect.Method m = this.vComponent.getClass().getMethod("getBackground");
Object bg = m.invoke(this.vComponent);
if (bg != null) {
java.lang.reflect.Method mRgb = bg.getClass().getMethod("getRGB");
return ((Number) mRgb.invoke(bg)).intValue();
}
} catch (Exception ignored) {}
}
return GocaConstants.getGocaColorArgb(idx);
}
@@ -1,7 +1,5 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Point;
import java.awt.Rectangle;
/**
* Bounding box encapsulation matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODBounds).
@@ -1,19 +1,17 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Color;
import java.awt.image.RGBImageFilter;
/**
* Image filter that replaces occurrences of one color with another color.
* Conforms to IBM Host On-Demand HODColorChangeFilter.
* Platform-neutral implementation independent of java.awt.
*/
public class HODColorChangeFilter extends RGBImageFilter {
public class HODColorChangeFilter {
protected boolean canFilterIndexColorModel = true;
private int oldRgb;
private int newRgb;
public HODColorChangeFilter(int newRgb) {
this.canFilterIndexColorModel = true;
this.oldRgb = -1;
this.newRgb = newRgb | 0xFF000000;
}
@@ -23,7 +21,6 @@ public class HODColorChangeFilter extends RGBImageFilter {
}
public HODColorChangeFilter(int oldRgb, int newRgb) {
this.canFilterIndexColorModel = true;
this.oldRgb = oldRgb & 0x00FFFFFF;
this.newRgb = newRgb;
}
@@ -48,7 +45,6 @@ public class HODColorChangeFilter extends RGBImageFilter {
this.newRgb = newRgb;
}
@Override
public int filterRGB(int x, int y, int rgb) {
if (oldRgb == -1) {
if ((rgb & 0xFF000000) != 0) {
@@ -61,4 +57,20 @@ public class HODColorChangeFilter extends RGBImageFilter {
}
return rgb;
}
public void apply(int[] pixels, int offset, int length) {
if (pixels == null) return;
int end = Math.min(pixels.length, offset + length);
for (int i = offset; i < end; i++) {
pixels[i] = filterRGB(0, 0, pixels[i]);
}
}
public void apply(PixelBuffer buffer) {
if (buffer == null) return;
int[] pixels = buffer.getPixels();
if (pixels != null) {
apply(pixels, 0, pixels.length);
}
}
}
@@ -1,16 +1,8 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Point;
import java.awt.Polygon;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
/**
* Headless graphics plane facade matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODGraphicsPlane).
* Pure Java software implementation completely decoupled from java.awt.
*/
public class HODGraphicsPlane {
private final GraphicsPlane delegate;
@@ -40,6 +32,10 @@ public class HODGraphicsPlane {
return delegate;
}
public PixelBuffer getPixelBuffer() {
return delegate;
}
public void resize(Dimension dim, boolean keepContent) {
if (dim != null) {
delegate.setDimensions(dim.width, dim.height);
@@ -52,15 +48,19 @@ public class HODGraphicsPlane {
this.bounds.set(0, 0, delegate.getCanvasWidth(), delegate.getCanvasHeight());
}
public Graphics getHODGraphics() {
return delegate.getGraphics();
public Object getHODGraphics() {
return delegate;
}
public Image getHODImage() {
return delegate.getImage();
public Object getHODImage() {
return delegate;
}
public void setHODTemporaryGraphics(Graphics g) {
public PixelBuffer getHODPixelBuffer() {
return delegate;
}
public void setHODTemporaryGraphics(Object g) {
// No-op or temporary override
}
@@ -116,35 +116,32 @@ public class HODGraphicsPlane {
}
public void drawHODArc(int x, int y, int width, int height, int startAngle, int arcAngle) {
Graphics g = delegate.getGraphics();
if (g != null) {
g.setColor(currentColor);
g.drawArc(x, y, width, height, startAngle, arcAngle);
updateHODBounds(x, y);
updateHODBounds(x + width, y + height);
}
double rx = width / 2.0;
double ry = height / 2.0;
double cx = x + rx;
double cy = y + ry;
delegate.drawArc(cx, cy, rx, ry, startAngle, arcAngle, currentColor.getRGB(), currentLineType, currentLineWidth, false);
updateHODBounds(x, y);
updateHODBounds(x + width, y + height);
}
public void fillHODArc(int x, int y, int width, int height, int startAngle, int arcAngle) {
Graphics g = delegate.getGraphics();
if (g != null) {
g.setColor(currentColor);
g.fillArc(x, y, width, height, startAngle, arcAngle);
updateHODBounds(x, y);
updateHODBounds(x + width, y + height);
}
double rx = width / 2.0;
double ry = height / 2.0;
double cx = x + rx;
double cy = y + ry;
delegate.drawArc(cx, cy, rx, ry, startAngle, arcAngle, currentColor.getRGB(), currentLineType, currentLineWidth, true);
updateHODBounds(x, y);
updateHODBounds(x + width, y + height);
}
public void drawHODImage(HODBitImage bitImage, int x, int y, int w, int h) {
if (bitImage != null) {
Image img = bitImage.getHODImage(w, h, currentColorIndex);
if (img != null) {
Graphics g = delegate.getGraphics();
if (g != null) {
g.drawImage(img, x, y, null);
updateHODBounds(x, y);
updateHODBounds(x + w, y + h);
}
PixelBuffer buf = bitImage.getHODPixelBuffer(w, h, currentColorIndex);
if (buf != null) {
delegate.blit(buf.getPixels(), 0, 0, buf.getWidth(), buf.getHeight(), x, y);
updateHODBounds(x, y);
updateHODBounds(x + w, y + h);
}
}
}
@@ -1,20 +1,15 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Insets;
import java.awt.Rectangle;
import java.io.Serializable;
import java.lang.reflect.Method;
/**
* Visual part container matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODPart).
* Completely decoupled from java.awt.
*/
public class HODPart extends Rectangle implements Serializable {
protected Component hodParent;
protected Font _hodFont;
protected Object hodParent;
protected Object _hodFont;
protected Color foregroundColor;
protected Color backgroundColor;
protected Boolean isTransparent;
@@ -22,33 +17,36 @@ public class HODPart extends Rectangle implements Serializable {
protected HODPart() {}
public HODPart(Component component) {
public HODPart(Object component) {
this();
this.setHODParent(component);
}
public HODPart(Component component, Dimension dimension) {
super(dimension);
public HODPart(Object component, Dimension dimension) {
super(0, 0, dimension != null ? dimension.width : 0, dimension != null ? dimension.height : 0);
this.setHODParent(component);
}
public HODPart(Component component, Rectangle rectangle) {
super(rectangle);
public HODPart(Object component, Rectangle rectangle) {
super(rectangle != null ? rectangle.x : 0, rectangle != null ? rectangle.y : 0,
rectangle != null ? rectangle.width : 0, rectangle != null ? rectangle.height : 0);
this.setHODParent(component);
}
public HODPart(HODPart hODPart) {
this(hODPart.getHODParent(), hODPart.getSize());
this.setHODBackground(hODPart.getHODBackground());
this.setHODForeground(hODPart.getHODForeground());
this.setHODFont(hODPart.getHODFont());
this(hODPart != null ? hODPart.getHODParent() : null, hODPart != null ? hODPart.getSize() : null);
if (hODPart != null) {
this.setHODBackground(hODPart.getHODBackground());
this.setHODForeground(hODPart.getHODForeground());
this.setHODFont(hODPart.getHODFont());
}
}
public Component getHODParent() {
public Object getHODParent() {
return this.hodParent;
}
public void setHODParent(Component component) {
public void setHODParent(Object component) {
if (component != null && !component.equals(this.hodParent)) {
this.hodParent = component;
}
@@ -56,18 +54,28 @@ public class HODPart extends Rectangle implements Serializable {
public void repaint() {
if (this.hodParent != null) {
this.hodParent.repaint(this.x, this.y, this.width, this.height);
try {
Method m = this.hodParent.getClass().getMethod("repaint", int.class, int.class, int.class, int.class);
m.invoke(this.hodParent, this.x, this.y, this.width, this.height);
} catch (Throwable ignored) {}
}
}
public void paint(Component component, Graphics graphics, int x, int y, int w, int h) {
public void paint(Object component, Object graphics, int x, int y, int w, int h) {
this.setBounds(x, y, w, h);
if (Boolean.TRUE.equals(this._visible)) {
this.paintHODView(graphics);
}
}
protected void paintHODView(Graphics graphics) {}
public void paint(PixelBuffer buffer, int x, int y, int w, int h) {
this.setBounds(x, y, w, h);
if (Boolean.TRUE.equals(this._visible)) {
this.paintHODView(buffer);
}
}
protected void paintHODView(Object graphics) {}
public Color getHODBackground() {
return this.backgroundColor;
@@ -85,11 +93,11 @@ public class HODPart extends Rectangle implements Serializable {
this.foregroundColor = color;
}
public Font getHODFont() {
public Object getHODFont() {
return this._hodFont;
}
public void setHODFont(Font font) {
public void setHODFont(Object font) {
this._hodFont = font;
}
@@ -1,12 +1,10 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.image.BufferedImage;
import java.lang.reflect.Method;
/**
* Programmed Symbol Set manager facade matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODProgramSymbolManager).
* Completely decoupled from java.awt.
*/
public class HODProgramSymbolManager {
public static final int MAX_HOD_SLOT = 254;
@@ -51,14 +49,57 @@ public class HODProgramSymbolManager {
delegate.loadProgrammedSymbolSet(bytes, 0, bytes.length);
}
public void drawHODImageCharacter(Graphics g, int lcid, int codepoint, Point pt, int colorIdx, int cellW, int cellH) {
if (g == null || pt == null) return;
public void drawHODImageCharacter(PixelBuffer pb, int lcid, int codepoint, Point pt, int colorIdx, int cellW, int cellH) {
if (pb == null || pt == null) return;
ProgramSymbolSet.SymbolSlot slot = delegate.getSymbol(lcid, codepoint);
if (slot != null) {
int fg = GocaConstants.getGocaColorArgb(colorIdx);
BufferedImage img = slot.getScaledImage(cellW, cellH, fg, 0);
if (img != null) {
g.drawImage(img, pt.x, pt.y, null);
PixelBuffer glyph = slot.getScaledPixelBuffer(cellW, cellH, fg, 0);
if (glyph != null) {
int sw = glyph.getWidth();
int sh = glyph.getHeight();
int[] srcPx = glyph.getPixels();
int[] dstPx = pb.getPixels();
int pw = pb.getWidth();
int ph = pb.getHeight();
for (int r = 0; r < sh; r++) {
int dy = pt.y + r;
if (dy < 0 || dy >= ph) continue;
for (int c = 0; c < sw; c++) {
int dx = pt.x + c;
if (dx < 0 || dx >= pw) continue;
int p = srcPx[r * sw + c];
if ((p & 0xFF000000) != 0) {
dstPx[dy * pw + dx] = p;
}
}
}
}
}
}
public void drawHODImageCharacter(Object g, int lcid, int codepoint, Point pt, int colorIdx, int cellW, int cellH) {
if (g == null || pt == null) return;
if (g instanceof PixelBuffer) {
drawHODImageCharacter((PixelBuffer) g, lcid, codepoint, pt, colorIdx, cellW, cellH);
return;
}
// Fallback for AWT Graphics if passed reflectively
ProgramSymbolSet.SymbolSlot slot = delegate.getSymbol(lcid, codepoint);
if (slot != null) {
int fg = GocaConstants.getGocaColorArgb(colorIdx);
PixelBuffer glyph = slot.getScaledPixelBuffer(cellW, cellH, fg, 0);
if (glyph != null) {
try {
Class<?> biClass = Class.forName("java.awt.image.BufferedImage");
Object bi = biClass.getConstructor(int.class, int.class, int.class)
.newInstance(glyph.getWidth(), glyph.getHeight(), 2); // TYPE_INT_ARGB
Method setRGB = biClass.getMethod("setRGB", int.class, int.class, int.class, int.class, int[].class, int.class, int.class);
setRGB.invoke(bi, 0, 0, glyph.getWidth(), glyph.getHeight(), glyph.getPixels(), 0, glyph.getWidth());
Method drawImg = g.getClass().getMethod("drawImage", Class.forName("java.awt.Image"), int.class, int.class, Class.forName("java.awt.image.ImageObserver"));
drawImg.invoke(g, bi, pt.x, pt.y, null);
} catch (Throwable ignored) {}
}
}
}
@@ -1,7 +1,5 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Point;
import java.awt.Rectangle;
/**
* Coordinate transform adapter matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODTransform).
@@ -1,18 +1,16 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Color;
import java.awt.image.RGBImageFilter;
/**
* Image filter that keys out a specific background color by setting its alpha to 0x00.
* Conforms to IBM Host On-Demand HODTransparentColorFilter.
* Platform-neutral implementation independent of java.awt.
*/
public class HODTransparentColorFilter extends RGBImageFilter {
public class HODTransparentColorFilter {
protected boolean canFilterIndexColorModel = true;
private int transparentRgb;
public HODTransparentColorFilter(int rgb) {
this.canFilterIndexColorModel = true;
this.transparentRgb = rgb & 0x00FFFFFF;
}
@@ -28,11 +26,26 @@ public class HODTransparentColorFilter extends RGBImageFilter {
this.transparentRgb = rgb & 0x00FFFFFF;
}
@Override
public int filterRGB(int x, int y, int rgb) {
if ((rgb & 0x00FFFFFF) == transparentRgb) {
return 0x00000000;
}
return rgb;
}
public void apply(int[] pixels, int offset, int length) {
if (pixels == null) return;
int end = Math.min(pixels.length, offset + length);
for (int i = offset; i < end; i++) {
pixels[i] = filterRGB(0, 0, pixels[i]);
}
}
public void apply(PixelBuffer buffer) {
if (buffer == null) return;
int[] pixels = buffer.getPixels();
if (pixels != null) {
apply(pixels, 0, pixels.length);
}
}
}
@@ -1,15 +1,10 @@
package haus.nightmare.lib3270j.graphics;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Insets;
import java.awt.image.BufferedImage;
import java.lang.reflect.Method;
/**
* Wallpaper background manager matching IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODWallpaper).
* Completely decoupled from java.awt.
*/
public class HODWallpaper extends HODPart {
public static final int HOD_TILE = 0;
@@ -17,8 +12,8 @@ public class HODWallpaper extends HODPart {
public static final int HOD_STRETCH = 2;
private int _display = HOD_CENTER;
private Image rawImage;
private Image backgroundImage;
private Object rawImage;
private Object backgroundImage;
public HODWallpaper() {
this(HOD_CENTER);
@@ -28,7 +23,7 @@ public class HODWallpaper extends HODPart {
this.setDisplay(displayMode);
}
public HODWallpaper(Image image, int displayMode) {
public HODWallpaper(Object image, int displayMode) {
this(displayMode);
this.setImage(image);
}
@@ -46,30 +41,40 @@ public class HODWallpaper extends HODPart {
return this._display;
}
public void setImage(Image image) {
public void setImage(Object image) {
this.rawImage = image;
this.backgroundImage = null;
this.repaint();
}
public Image getHODImage() {
public Object getHODImage() {
return this.rawImage;
}
@Override
public void paint(Component component, Graphics graphics, int x, int y, int w, int h) {
public void paint(Object component, Object graphics, int x, int y, int w, int h) {
this.setBounds(x, y, w, h);
this.setHODParent(component);
super.paint(component, graphics, x, y, w, h);
}
@Override
protected void paintHODView(Graphics graphics) {
Image image = this.getHODImage();
int display = this.getDisplay();
Component component = this.getHODParent();
public void paint(PixelBuffer buffer, int x, int y, int w, int h) {
this.setBounds(x, y, w, h);
super.paint(buffer, x, y, w, h);
}
if (image == null || component == null) {
@Override
protected void paintHODView(Object graphics) {
Object image = this.getHODImage();
int display = this.getDisplay();
if (image == null || graphics == null) {
return;
}
if (graphics instanceof PixelBuffer && image instanceof PixelBuffer) {
paintHODViewBuffer((PixelBuffer) graphics, (PixelBuffer) image);
return;
}
@@ -82,7 +87,83 @@ public class HODWallpaper extends HODPart {
}
}
protected void hodtileImage(Graphics graphics, Image image) {
public void paintHODViewBuffer(PixelBuffer dst, PixelBuffer src) {
int display = this.getDisplay();
Insets insets = this.getInsets();
int availX = this.x + insets.left;
int availY = this.y + insets.top;
int availW = this.width - (insets.left + insets.right);
int availH = this.height - (insets.top + insets.bottom);
if (availW <= 0 || availH <= 0 || src.getWidth() <= 0 || src.getHeight() <= 0) return;
if (display == HOD_CENTER) {
int cx = availX + (availW - src.getWidth()) / 2;
int cy = availY + (availH - src.getHeight()) / 2;
blitBuffer(dst, src, cx, cy);
} else if (display == HOD_TILE) {
int cols = (availW / src.getWidth()) + 1;
int rows = (availH / src.getHeight()) + 1;
int curX = availX;
for (int i = 0; i < cols; i++) {
int curY = availY;
for (int j = 0; j < rows; j++) {
blitBuffer(dst, src, curX, curY);
curY += src.getHeight();
}
curX += src.getWidth();
}
} else if (display == HOD_STRETCH) {
scaleBuffer(dst, src, availX, availY, availW, availH);
}
}
private void blitBuffer(PixelBuffer dst, PixelBuffer src, int dstX, int dstY) {
int sw = src.getWidth();
int sh = src.getHeight();
int dw = dst.getWidth();
int dh = dst.getHeight();
int[] srcPx = src.getPixels();
int[] dstPx = dst.getPixels();
for (int r = 0; r < sh; r++) {
int dy = dstY + r;
if (dy < 0 || dy >= dh) continue;
for (int c = 0; c < sw; c++) {
int dx = dstX + c;
if (dx < 0 || dx >= dw) continue;
int p = srcPx[r * sw + c];
if ((p & 0xFF000000) != 0) {
dstPx[dy * dw + dx] = p;
}
}
}
}
private void scaleBuffer(PixelBuffer dst, PixelBuffer src, int dx, int dy, int dw, int dh) {
int sw = src.getWidth();
int sh = src.getHeight();
int targetW = dst.getWidth();
int targetH = dst.getHeight();
int[] srcPx = src.getPixels();
int[] dstPx = dst.getPixels();
for (int r = 0; r < dh; r++) {
int outY = dy + r;
if (outY < 0 || outY >= targetH) continue;
int sy = r * sh / dh;
for (int c = 0; c < dw; c++) {
int outX = dx + c;
if (outX < 0 || outX >= targetW) continue;
int sx = c * sw / dw;
int p = srcPx[sy * sw + sx];
if ((p & 0xFF000000) != 0) {
dstPx[outY * targetW + outX] = p;
}
}
}
}
protected void hodtileImage(Object graphics, Object image) {
Dimension imgSize = getImageSize(image);
if (imgSize.width <= 0 || imgSize.height <= 0) return;
@@ -99,40 +180,86 @@ public class HODWallpaper extends HODPart {
for (int i = 0; i < cols; i++) {
int curY = startY;
for (int j = 0; j < rows; j++) {
graphics.drawImage(image, curX, curY, this.getHODParent());
invokeDrawImage(graphics, image, curX, curY);
curY += imgSize.height;
}
curX += imgSize.width;
}
}
protected void centerHODImage(Graphics graphics, Image image) {
protected void centerHODImage(Object graphics, Object image) {
Dimension imgSize = getImageSize(image);
if (imgSize.width <= 0 || imgSize.height <= 0) return;
Insets insets = this.getInsets();
int cx = this.x + insets.left + (this.width - imgSize.width) / 2;
int cy = this.y + insets.top + (this.height - imgSize.height) / 2;
graphics.drawImage(image, cx, cy, this.getHODParent());
invokeDrawImage(graphics, image, cx, cy);
}
protected void stretchHODImage(Graphics graphics, Image image) {
protected void stretchHODImage(Object graphics, Object image) {
Insets insets = this.getInsets();
int sx = this.x + insets.left;
int sy = this.y + insets.top;
int sw = this.width - (insets.left + insets.right);
int sh = this.height - (insets.top + insets.bottom);
graphics.drawImage(image, sx, sy, sw, sh, this.getHODParent());
invokeDrawImage(graphics, image, sx, sy, sw, sh);
}
private Dimension getImageSize(Image image) {
private void invokeDrawImage(Object graphics, Object img, int x, int y) {
if (graphics == null || img == null) return;
try {
for (Method m : graphics.getClass().getMethods()) {
if (m.getName().equals("drawImage")) {
Class<?>[] pts = m.getParameterTypes();
if (pts.length == 4 && pts[1] == int.class && pts[2] == int.class) {
m.invoke(graphics, img, x, y, this.getHODParent());
return;
}
}
}
} catch (Throwable ignored) {}
}
private void invokeDrawImage(Object graphics, Object img, int x, int y, int w, int h) {
if (graphics == null || img == null) return;
try {
for (Method m : graphics.getClass().getMethods()) {
if (m.getName().equals("drawImage")) {
Class<?>[] pts = m.getParameterTypes();
if (pts.length == 6 && pts[1] == int.class && pts[2] == int.class && pts[3] == int.class && pts[4] == int.class) {
m.invoke(graphics, img, x, y, w, h, this.getHODParent());
return;
}
}
}
} catch (Throwable ignored) {}
}
private Dimension getImageSize(Object image) {
if (image == null) return new Dimension(0, 0);
Component c = this.getHODParent();
int w = image.getWidth(c);
int h = image.getHeight(c);
if (w <= 0 && image instanceof BufferedImage) {
w = ((BufferedImage) image).getWidth();
h = ((BufferedImage) image).getHeight();
if (image instanceof PixelBuffer) {
PixelBuffer pb = (PixelBuffer) image;
return new Dimension(pb.getWidth(), pb.getHeight());
}
int w = 0;
int h = 0;
try {
Method mw = image.getClass().getMethod("getWidth");
w = ((Number) mw.invoke(image)).intValue();
Method mh = image.getClass().getMethod("getHeight");
h = ((Number) mh.invoke(image)).intValue();
} catch (Throwable ignored) {
try {
for (Method m : image.getClass().getMethods()) {
if (m.getName().equals("getWidth") && m.getParameterCount() == 1) {
w = ((Number) m.invoke(image, this.getHODParent())).intValue();
}
if (m.getName().equals("getHeight") && m.getParameterCount() == 1) {
h = ((Number) m.invoke(image, this.getHODParent())).intValue();
}
}
} catch (Throwable ignored2) {}
}
return new Dimension(Math.max(0, w), Math.max(0, h));
}
@@ -0,0 +1,42 @@
package haus.nightmare.lib3270j.graphics;
import java.io.Serializable;
import java.util.Objects;
/**
* Lightweight pure-Java POJO representing border insets.
* Completely decouples lib3270j from java.awt.Insets.
*/
public class Insets implements Serializable {
private static final long serialVersionUID = 1L;
public int top;
public int left;
public int bottom;
public int right;
public Insets(int top, int left, int bottom, int right) {
this.top = top;
this.left = left;
this.bottom = bottom;
this.right = right;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Insets)) return false;
Insets i = (Insets) obj;
return top == i.top && left == i.left && bottom == i.bottom && right == i.right;
}
@Override
public int hashCode() {
return Objects.hash(top, left, bottom, right);
}
@Override
public String toString() {
return getClass().getName() + "[top=" + top + ",left=" + left + ",bottom=" + bottom + ",right=" + right + "]";
}
}
@@ -0,0 +1,59 @@
package haus.nightmare.lib3270j.graphics;
/**
* Platform-neutral pixel buffer interface for 32-bit ARGB offscreen rasterization.
* Compatible with pure Java SE (Swing), Android (Bitmap), and headless environments.
*/
public interface PixelBuffer {
/** Returns width of the pixel buffer in pixels. */
int getWidth();
/** Returns height of the pixel buffer in pixels. */
int getHeight();
/** Returns contiguous 32-bit ARGB pixel array. */
int[] getPixels();
/** Returns ARGB pixel at specified coordinate, or 0 if out of bounds. */
int getPixel(int x, int y);
/** Sets ARGB pixel at specified coordinate with alpha blending and mix mode. */
void setPixel(int x, int y, int argb);
/** Sets ARGB pixel directly without blending. */
void setPixelDirect(int x, int y, int argb);
/** Clears buffer to fully transparent (0x00000000). */
void clear();
/** Clears buffer to specified ARGB color. */
void clear(int argb);
/** Draws a 1-pixel line using integer Bresenham algorithm. */
void drawLine(int x1, int y1, int x2, int y2, int argb);
/** Draws a 1-pixel line using Bresenham algorithm. */
void drawLineBresenham(int x0, int y0, int x1, int y1, int argb);
/** Draws a stroked line with line type and line width. */
void drawLine(double x0, double y0, double x1, double y1, int argb, int lineType, int lineWidth);
/** Draws an anti-aliased sub-pixel line segment with stroke width. */
void drawLineAA(double x0, double y0, double x1, double y1, int argb, double strokeWidth);
/** Fills a rectangular region with specified ARGB color. */
void fillRect(int x, int y, int width, int height, int argb);
/** Sets clipping rectangle. */
void setClip(int x, int y, int width, int height);
/** Clears clipping rectangle. */
void clearClip();
/** Checks if coordinate falls outside current clipping bounds. */
boolean isClipped(int x, int y);
/** Copies a rectangular block of pixels from source array into this buffer. */
void blit(int[] srcPixels, int srcX, int srcY, int srcW, int srcH, int dstX, int dstY);
}
@@ -0,0 +1,71 @@
package haus.nightmare.lib3270j.graphics;
import java.io.Serializable;
import java.util.Objects;
/**
* Lightweight pure-Java POJO point for 2D presentation coordinates.
* Completely decouples lib3270j from java.awt.Point.
*/
public class Point implements Serializable {
private static final long serialVersionUID = 1L;
public int x;
public int y;
public Point() {
this(0, 0);
}
public Point(Point p) {
this(p != null ? p.x : 0, p != null ? p.y : 0);
}
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public void setLocation(int x, int y) {
this.x = x;
this.y = y;
}
public void setLocation(Point p) {
if (p != null) {
this.x = p.x;
this.y = p.y;
}
}
public void translate(int dx, int dy) {
this.x += dx;
this.y += dy;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Point)) return false;
Point pt = (Point) obj;
return (x == pt.x) && (y == pt.y);
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return getClass().getName() + "[x=" + x + ",y=" + y + "]";
}
}
@@ -3,6 +3,7 @@ package haus.nightmare.lib3270j.graphics;
/**
* Represents a set of up to 191 custom bitmapped symbols (LCID 0x40 - 0xFE).
* Supports both Single-Plane (monochrome) and Triple-Plane (7-color RGB composite).
* Decoupled from java.awt using PixelBuffer.
*/
public class ProgramSymbolSet {
@@ -64,8 +65,8 @@ public class ProgramSymbolSet {
private int[] cachedRgbArray;
private int cachedFgRgb = -1;
private int cachedBgRgb = -1;
private java.awt.image.BufferedImage cachedImage;
private java.awt.image.BufferedImage cachedScaledImage;
private PixelBuffer cachedPixelBuffer;
private PixelBuffer cachedScaledPixelBuffer;
private int cachedTargetW = 0;
private int cachedTargetH = 0;
private int cachedScaledFgRgb = -1;
@@ -103,23 +104,21 @@ public class ProgramSymbolSet {
}
/**
* Returns an image scaled directly to target dimensions (cellWidth x cellHeight).
* Enables unscaled 1:1 hardware blitting in Java2D.
* Returns a PixelBuffer scaled directly to target dimensions (cellWidth x cellHeight).
*/
public synchronized java.awt.image.BufferedImage getScaledImage(int targetW, int targetH, int fgArgb, int bgArgb) {
public synchronized PixelBuffer getScaledPixelBuffer(int targetW, int targetH, int fgArgb, int bgArgb) {
if (targetW <= 0 || targetH <= 0) {
return getImage(fgArgb, bgArgb);
return getPixelBuffer(fgArgb, bgArgb);
}
if (targetW == width && targetH == height) {
return getImage(fgArgb, bgArgb);
return getPixelBuffer(fgArgb, bgArgb);
}
if (cachedScaledImage != null && cachedTargetW == targetW && cachedTargetH == targetH
if (cachedScaledPixelBuffer != null && cachedTargetW == targetW && cachedTargetH == targetH
&& cachedScaledFgRgb == fgArgb && cachedScaledBgRgb == bgArgb) {
return cachedScaledImage;
return cachedScaledPixelBuffer;
}
int[] srcRgb = getRgbPixels(fgArgb, bgArgb);
java.awt.image.BufferedImage scaled = new java.awt.image.BufferedImage(targetW, targetH, java.awt.image.BufferedImage.TYPE_INT_ARGB);
int[] dstRgb = ((java.awt.image.DataBufferInt) scaled.getRaster().getDataBuffer()).getData();
int[] dstRgb = new int[targetW * targetH];
for (int dy = 0; dy < targetH; dy++) {
int sy = dy * height / targetH;
@@ -131,30 +130,36 @@ public class ProgramSymbolSet {
}
}
this.cachedScaledImage = scaled;
this.cachedScaledPixelBuffer = new DefaultPixelBuffer(targetW, targetH, dstRgb);
this.cachedTargetW = targetW;
this.cachedTargetH = targetH;
this.cachedScaledFgRgb = fgArgb;
this.cachedScaledBgRgb = bgArgb;
return scaled;
return this.cachedScaledPixelBuffer;
}
public synchronized PixelBuffer getScaledImage(int targetW, int targetH, int fgArgb, int bgArgb) {
return getScaledPixelBuffer(targetW, targetH, fgArgb, bgArgb);
}
/**
* Computes and returns the cached BufferedImage for this symbol glyph.
* Eliminates per-cell heap allocations during high frame rate rendering.
* Computes and returns the cached PixelBuffer for this symbol glyph.
*/
public synchronized java.awt.image.BufferedImage getImage(int fgArgb, int bgArgb) {
if (cachedImage != null && cachedFgRgb == fgArgb && cachedBgRgb == bgArgb) {
return cachedImage;
public synchronized PixelBuffer getPixelBuffer(int fgArgb, int bgArgb) {
if (cachedPixelBuffer != null && cachedFgRgb == fgArgb && cachedBgRgb == bgArgb) {
return cachedPixelBuffer;
}
int[] rgb = getRgbPixels(fgArgb, bgArgb);
java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(width, height, java.awt.image.BufferedImage.TYPE_INT_ARGB);
int[] imgData = ((java.awt.image.DataBufferInt) img.getRaster().getDataBuffer()).getData();
int[] imgData = new int[rgb.length];
System.arraycopy(rgb, 0, imgData, 0, rgb.length);
this.cachedImage = img;
this.cachedPixelBuffer = new DefaultPixelBuffer(width, height, imgData);
this.cachedFgRgb = fgArgb;
this.cachedBgRgb = bgArgb;
return img;
return this.cachedPixelBuffer;
}
public synchronized PixelBuffer getImage(int fgArgb, int bgArgb) {
return getPixelBuffer(fgArgb, bgArgb);
}
/**
@@ -0,0 +1,171 @@
package haus.nightmare.lib3270j.graphics;
import java.io.Serializable;
import java.util.Objects;
/**
* Lightweight pure-Java POJO rectangle for 2D presentation coordinates.
* Completely decouples lib3270j from java.awt.Rectangle.
*/
public class Rectangle implements Serializable {
private static final long serialVersionUID = 1L;
public int x;
public int y;
public int width;
public int height;
public Rectangle() {
this(0, 0, 0, 0);
}
public Rectangle(Rectangle r) {
this(r != null ? r.x : 0, r != null ? r.y : 0, r != null ? r.width : 0, r != null ? r.height : 0);
}
public Rectangle(int x, int y, int width, int height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public Rectangle(int width, int height) {
this(0, 0, width, height);
}
public Rectangle(Point p, Dimension d) {
this(p != null ? p.x : 0, p != null ? p.y : 0, d != null ? d.width : 0, d != null ? d.height : 0);
}
public Rectangle(Point p) {
this(p != null ? p.x : 0, p != null ? p.y : 0, 0, 0);
}
public Rectangle(Dimension d) {
this(0, 0, d != null ? d.width : 0, d != null ? d.height : 0);
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public void setBounds(int x, int y, int width, int height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public void setBounds(Rectangle r) {
if (r != null) {
setBounds(r.x, r.y, r.width, r.height);
}
}
public Point getLocation() {
return new Point(x, y);
}
public void setLocation(Point p) {
if (p != null) {
this.x = p.x;
this.y = p.y;
}
}
public void setLocation(int x, int y) {
this.x = x;
this.y = y;
}
public Dimension getSize() {
return new Dimension(width, height);
}
public void setSize(Dimension d) {
if (d != null) {
this.width = d.width;
this.height = d.height;
}
}
public void setSize(int width, int height) {
this.width = width;
this.height = height;
}
public boolean contains(int X, int Y) {
int w = this.width;
int h = this.height;
if ((w | h) < 0) return false;
int x = this.x;
int y = this.y;
if (X < x || Y < y) return false;
w += x;
h += y;
return ((w < x || w > X) && (h < y || h > Y));
}
public boolean contains(Point p) {
return p != null && contains(p.x, p.y);
}
public boolean intersects(Rectangle r) {
if (r == null) return false;
int tw = this.width;
int th = this.height;
int rw = r.width;
int rh = r.height;
if (rw <= 0 || rh <= 0 || tw <= 0 || th <= 0) return false;
int tx = this.x;
int ty = this.y;
int rx = r.x;
int ry = r.y;
rw += rx;
rh += ry;
tw += tx;
th += ty;
return ((rw < rx || rw > tx) &&
(rh < ry || rh > ty) &&
(tw < tx || tw > rx) &&
(th < ty || th > ry));
}
public boolean isEmpty() {
return (width <= 0) || (height <= 0);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Rectangle)) return false;
Rectangle r = (Rectangle) obj;
return ((x == r.x) &&
(y == r.y) &&
(width == r.width) &&
(height == r.height));
}
@Override
public int hashCode() {
return Objects.hash(x, y, width, height);
}
@Override
public String toString() {
return getClass().getName() + "[x=" + x + ",y=" + y + ",width=" + width + ",height=" + height + "]";
}
}
@@ -118,7 +118,91 @@ public class InputProcessor {
}
}
public boolean isInsertMode() { return insertMode; }
public void setInsertMode(boolean insert) { this.insertMode = insert; }
public void setInsertMode(boolean insert) {
this.insertMode = insert;
if (oia != null) {
oia.notifyOIAChanged();
}
}
@FunctionalInterface
public interface BellListener {
void onBell();
}
private BellListener bellListener;
private boolean bellEnabled = false;
private int bellColumn = 74; // 0-based index for column 75
private boolean insertOffOnAid = true;
private boolean aplKeyboardMode = false;
private boolean numericFieldLock = true;
private boolean autoSkipEnabled = true;
public void setBellListener(BellListener listener) { this.bellListener = listener; }
public BellListener getBellListener() { return bellListener; }
public boolean isBellEnabled() { return bellEnabled; }
public void setBellEnabled(boolean enabled) { this.bellEnabled = enabled; }
public int getBellColumn() { return bellColumn; }
public void setBellColumn(int col) { this.bellColumn = col; }
public boolean isInsertOffOnAid() { return insertOffOnAid; }
public void setInsertOffOnAid(boolean val) { this.insertOffOnAid = val; }
public boolean isAplKeyboardMode() { return aplKeyboardMode; }
public void setAplKeyboardMode(boolean enabled) {
this.aplKeyboardMode = enabled;
if (oia != null) {
oia.notifyOIAChanged();
}
}
public void toggleAplKeyboardMode() {
setAplKeyboardMode(!aplKeyboardMode);
}
public boolean isNumericFieldLock() { return numericFieldLock; }
public void setNumericFieldLock(boolean lock) { this.numericFieldLock = lock; }
public boolean isAutoSkipEnabled() { return autoSkipEnabled; }
public void setAutoSkipEnabled(boolean enabled) { this.autoSkipEnabled = enabled; }
/**
* Map ASCII key character to IBM 3270 APL / Graphic Escape code point.
*/
private int getAplCodeForChar(char ch) {
char upper = Character.toUpperCase(ch);
switch (upper) {
case 'A': return 0x81; // Alpha
case 'B': return 0x82; // Up tack / decode
case 'C': return 0x83; // Intersection
case 'D': return 0x84; // Floor
case 'E': return 0x85; // Vertical line
case 'F': return 0x87; // Del / Grad
case 'G': return 0x88; // Delta
case 'H': return 0x89; // Iota
case 'I': return 0x8A; // Right arrow
case 'J': return 0x8B; // Quote Quad
case 'K': return 0x8C; // Less than or equal
case 'L': return 0xAD; // [ Bracket left
case 'M': return 0x8E; // × Multiply
case 'N': return 0x8F; // ÷ Divide
case 'O': return 0x90; // Circle Star
case 'P': return 0x91; // Domino / Quad divide
case 'Q': return 0x92; // Down tack / encode
case 'R': return 0x95; // Rho
case 'S': return 0x94; // Ceiling
case 'T': return 0x98; // Epsilon underbar
case 'U': return 0x93; // Union
case 'V': return 0x97; // Not equal
case 'W': return 0x96; // Omega
case 'X': return 0xAC; // Transpose
case 'Y': return 0xA8; // Up arrow / Take
case 'Z': return 0xA9; // Down arrow / Drop
case '-': return 0xA2; // Horizontal line
case '|': return 0x85; // Vertical line
case '+': return 0xCB; // Cross
default: return -1;
}
}
/**
* Enter a character at the current cursor position.
@@ -142,8 +226,8 @@ public class InputProcessor {
int baddr = screen.getCursorAddress();
baddr = ((baddr % size) + size) % size;
// Entry Assist DOC mode / Word Wrap handling
if ((screen.isEntryAssistDOCmode() || screen.isEntryAssistWordWrap()) && !isNvtMode()) {
// Entry Assist Word Wrap handling
if (screen.isEntryAssistWordWrap() && !isNvtMode()) {
int curCol = baddr % screen.getCols();
int endCol = screen.getEntryAssistEndColumn();
int startCol = screen.getEntryAssistStartColumn();
@@ -178,7 +262,7 @@ public class InputProcessor {
}
// Numeric-only field check: digits 0-9, minus (-), period (.), space ( ), DUP, FM
if (faIsNumeric(faVal & 0xFF)) {
if (numericFieldLock && faIsNumeric(faVal & 0xFF)) {
boolean isValidNumeric = (ch >= '0' && ch <= '9') || ch == '-' || ch == '.' || ch == ' '
|| ch == '*' || ch == ';' || ch == (char) FCORDER_DUP || ch == (char) FCORDER_FM;
if (!isValidNumeric) {
@@ -191,8 +275,20 @@ public class InputProcessor {
}
}
// Translate character to EBCDIC
int ebc = translator.unicodeToEbcdic(ch);
// Translate character to EBCDIC (or APL Graphic Escape if in APL Keyboard Mode)
boolean isAplChar = false;
int ebc = -1;
if (aplKeyboardMode) {
int aplCode = getAplCodeForChar(ch);
if (aplCode >= 0) {
ebc = aplCode;
ch = translator.mapAPL(aplCode);
isAplChar = true;
}
}
if (ebc < 0) {
ebc = translator.unicodeToEbcdic(ch);
}
if (ebc < 0) return;
if (insertMode) {
@@ -229,6 +325,9 @@ public class InputProcessor {
ExtendedAttribute ea = screen.getCell(baddr);
ea.ec = (byte) ebc;
ea.ucs4 = ch;
if (isAplChar) {
ea.cs = ExtendedAttribute.CS_GE;
}
// Set MDT on field attribute
if (screen.isFormatted()) {
@@ -244,7 +343,7 @@ public class InputProcessor {
int nextAddr = screen.incrementAddress(baddr);
if (screen.getCell(nextAddr).isFieldAttribute()) {
byte nextFa = screen.getCell(nextAddr).fa;
if (faIsSkip(nextFa & 0xFF)) {
if (autoSkipEnabled && faIsSkip(nextFa & 0xFF)) {
// Auto-skip field (Protected + Numeric): jump to next unprotected field
int skipTarget = screen.findNextUnprotected(nextAddr);
screen.setCursorAddress(skipTarget);
@@ -264,6 +363,28 @@ public class InputProcessor {
screen.setCursorAddress((baddr + 1) % size);
}
// Entry Assist DOC mode: if we typed into or past the right margin, advance to startCol on next row
if (screen != null && screen.isEntryAssistDOCmode() && !screen.isEntryAssistWordWrap() && !isNvtMode()) {
int typedCol = baddr % screen.getCols();
if (typedCol >= screen.getEntryAssistEndColumn()) {
int curRow = baddr / screen.getCols();
int nextRow = (curRow + 1) % screen.getRows();
int targetAddr = nextRow * screen.getCols() + screen.getEntryAssistStartColumn();
if (screen.isFormatted()) {
targetAddr = screen.findNextUnprotected(targetAddr - 1);
}
screen.setCursorAddress(targetAddr);
}
}
// Audible End-of-Line Warning Signal
if (bellEnabled && screen != null) {
int curCol = screen.getCursorCol();
if (curCol == bellColumn && bellListener != null) {
bellListener.onBell();
}
}
screen.markAllChanged();
screen.updateDisplaySnapshot();
}
@@ -291,6 +412,155 @@ public class InputProcessor {
}
}
/**
* Checks if all character locations on the given row are protected.
*/
public boolean isLineProtected(int row) {
if (screen == null || !screen.isFormatted()) return false;
int cols = screen.getCols();
int rows = screen.getRows();
if (row < 0 || row >= rows) return false;
int start = row * cols;
int end = start + cols;
for (int i = start; i < end; i++) {
if (screen.getCell(i).isFieldAttribute()) continue;
byte fa = screen.getFieldAttributeAt(i);
if (!faIsProtected(fa & 0xFF)) {
return false;
}
}
return true;
}
/**
* Paste text into the presentation space.
* When enablePasteFromExcel is true:
* - '\t' triggers a Tab action advancing to the next unprotected input field (SBA).
* - '\n' or '\r\n' triggers a NewLine action advancing to the first unprotected field of the next line.
* When pasteStopAtProtectedLine is true:
* - Halts or truncates paste if cursor reaches a protected line/boundary or if the current field is full.
*
* @param text text to paste
* @param enablePasteFromExcel whether to parse tabs as field advances and newlines as row advances
* @param pasteStopAtProtectedLine whether to halt paste when encountering protected boundaries
* @return number of characters pasted
*/
public int pasteText(String text, boolean enablePasteFromExcel, boolean pasteStopAtProtectedLine) {
if (text == null || text.isEmpty() || screen == null || keyboardLocked) {
return 0;
}
if (isNvtMode()) {
try {
fsm.sendNVTString(text);
return text.length();
} catch (IOException e) {
log.warning("Failed to send NVT paste: " + e.getMessage());
return 0;
}
}
int count = 0;
int len = text.length();
int i = 0;
while (i < len && !keyboardLocked) {
char ch = text.charAt(i);
// Handle newline sequences: \r\n, \r, or \n
if (ch == '\r' || ch == '\n') {
if (ch == '\r' && (i + 1) < len && text.charAt(i + 1) == '\n') {
i++; // skip \n of \r\n
}
if (enablePasteFromExcel) {
int curRow = screen.getCursorRow();
int nextRow = (curRow + 1) % screen.getRows();
if (pasteStopAtProtectedLine && isLineProtected(nextRow)) {
break; // Stop paste when next line is protected
}
newline();
}
i++;
continue;
}
// Handle tab character
if (ch == '\t') {
if (enablePasteFromExcel) {
tab();
int newAddr = screen.getCursorAddress();
if (pasteStopAtProtectedLine && screen.isFormatted()) {
byte fa = screen.getFieldAttributeAt(newAddr);
if (faIsProtected(fa & 0xFF)) {
break;
}
}
}
i++;
continue;
}
// Regular printable character
if (ch >= 0x20 && ch != 0x7F) {
if (screen.isFormatted()) {
int baddr = screen.getCursorAddress();
ExtendedAttribute ea = screen.getCell(baddr);
if (ea.isFieldAttribute()) {
baddr = (baddr + 1) % (screen.getRows() * screen.getCols());
}
byte faVal = screen.getFieldAttributeAt(baddr);
if (faIsProtected(faVal & 0xFF)) {
if (pasteStopAtProtectedLine) {
break; // Stop paste immediately at protected boundary
} else {
tab();
baddr = screen.getCursorAddress();
if (faIsProtected(screen.getFieldAttributeAt(baddr) & 0xFF)) {
break;
}
}
}
}
typeCharacter(ch);
count++;
// If pasteStopAtProtectedLine is enabled, check if the cursor after typing has hit a protected field
if (pasteStopAtProtectedLine && screen.isFormatted() && !keyboardLocked) {
int curAddr = screen.getCursorAddress();
ExtendedAttribute curCell = screen.getCell(curAddr);
if (curCell.isFieldAttribute()) {
byte nextFa = curCell.fa;
if (faIsProtected(nextFa & 0xFF)) {
if (i + 1 < len) {
char nextCh = text.charAt(i + 1);
if (nextCh != '\t' && nextCh != '\r' && nextCh != '\n') {
break;
}
}
}
} else {
byte curFa = screen.getFieldAttributeAt(curAddr);
if (faIsProtected(curFa & 0xFF)) {
if (i + 1 < len) {
char nextCh = text.charAt(i + 1);
if (nextCh != '\t' && nextCh != '\r' && nextCh != '\n') {
break;
}
}
}
}
}
}
i++;
}
screen.markAllChanged();
screen.updateDisplaySnapshot();
return count;
}
/**
* Build inbound 3270 Read Modified data stream (AID + Cursor + SBA + Modified fields).
*/
@@ -523,6 +793,9 @@ public class InputProcessor {
}
if (aidCode == AID_CLEAR) {
if (insertOffOnAid && insertMode) {
setInsertMode(false);
}
screen.clear();
screen.markAllChanged();
if (graphicsPlane != null) {
@@ -538,6 +811,10 @@ public class InputProcessor {
return;
}
if (insertOffOnAid && insertMode) {
setInsertMode(false);
}
lastAid = aidCode;
setKeyboardLocked(true);
@@ -792,6 +1069,10 @@ public class InputProcessor {
}
public void tab() {
if (screen != null && screen.isEntryAssistDOCmode()) {
screen.processWordTab(true);
return;
}
int addr = screen.findNextUnprotected(screen.getCursorAddress());
screen.setCursorAddress(addr);
screen.updateDisplaySnapshot();
@@ -806,6 +1087,10 @@ public class InputProcessor {
}
public void backTab() {
if (screen != null && screen.isEntryAssistDOCmode()) {
screen.processWordTab(false);
return;
}
if (!screen.isFormatted()) return;
int size = screen.getRows() * screen.getCols();
if (size <= 0) return;
@@ -1546,6 +1831,22 @@ public class InputProcessor {
case "lightpen":
processLightPen();
break;
case "docmode":
if (screen != null) {
screen.setEntryAssistDOCmode(!screen.isEntryAssistDOCmode());
if (oia != null) oia.notifyOIAChanged();
}
break;
case "wordwrap":
if (screen != null) {
screen.setEntryAssistWordWrap(!screen.isEntryAssistWordWrap());
if (oia != null) oia.notifyOIAChanged();
}
break;
case "apl":
case "aplmode":
toggleAplKeyboardMode();
break;
default:
if (token.startsWith("pf")) {
try {
@@ -14,4 +14,7 @@ public interface ConnectionListener {
/** Called when TN3270E negotiation completes. */
default void onTN3270ENegotiated(String deviceType, String deviceName) {}
/** Called when TN3270E functions negotiation completes or changes. */
default void onTN3270EFunctionsNegotiated(boolean[] functions) {}
}
@@ -15,4 +15,7 @@ public interface ScreenUpdateListener {
/** Called when the screen size changes (erase/write vs erase/write alternate). */
default void onScreenSizeChanged(int rows, int cols) {}
/** Called when the keyboard is unlocked (e.g. via WCC restore, AUTO_SYS_UNLOCK, or Contention Resolution SDI). */
default void onKeyboardUnlocked() {}
}
@@ -1305,6 +1305,9 @@ public class NvtProcessor {
}
private void notifyScreenUpdated() {
if (screenBuffer != null) {
screenBuffer.notifyScreenUpdate();
}
for (ScreenUpdateListener l : screenListeners) {
l.onScreenUpdated();
}
@@ -5,6 +5,11 @@ import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.ecl.ECLField;
import haus.nightmare.lib3270j.ecl.ECLFieldList;
import haus.nightmare.lib3270j.ecl.ECLPS;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
/**
@@ -60,6 +65,61 @@ public class ScreenBuffer {
private final EbcdicTranslator translator;
private final Object renderLock = new Object();
// Synchronization primitives and screen update listeners (Phase 12)
private final ReentrantLock syncLock = new ReentrantLock();
private final Condition syncCondition = syncLock.newCondition();
private final List<ScreenUpdateListener> updateListeners = new CopyOnWriteArrayList<>();
public ReentrantLock getSyncLock() {
return syncLock;
}
public Condition getSyncCondition() {
return syncCondition;
}
public void addUpdateListener(ScreenUpdateListener l) {
if (l != null && !updateListeners.contains(l)) {
updateListeners.add(l);
}
}
public void removeUpdateListener(ScreenUpdateListener l) {
updateListeners.remove(l);
}
public void signalWaiters() {
syncLock.lock();
try {
syncCondition.signalAll();
} finally {
syncLock.unlock();
}
}
public void notifyScreenUpdate() {
updateDisplaySnapshot();
signalWaiters();
for (ScreenUpdateListener l : updateListeners) {
try {
l.onScreenUpdated();
} catch (Exception ignored) {}
}
}
public void notifyCursorMoved(int oldAddress, int newAddress) {
signalWaiters();
for (ScreenUpdateListener l : updateListeners) {
try {
l.onCursorMoved(oldAddress, newAddress);
} catch (Exception ignored) {}
}
}
public void notifyCursorMoved() {
notifyCursorMoved(cursorAddress, cursorAddress);
}
public Object getRenderLock() {
return renderLock;
}
@@ -272,9 +332,16 @@ public class ScreenBuffer {
// ========== Cursor ==========
public int getCursorAddress() { return cursorAddress; }
public synchronized void setCursorAddress(int addr) {
this.cursorAddress = addr;
this.displayCursorAddress = addr;
public void setCursorAddress(int addr) {
int oldAddr;
synchronized (this) {
oldAddr = this.cursorAddress;
this.cursorAddress = addr;
this.displayCursorAddress = addr;
}
if (oldAddr != addr) {
notifyCursorMoved(oldAddr, addr);
}
}
public synchronized void setCursorPosition(int row, int col) {
int r = Math.max(0, Math.min(row, rows - 1));
@@ -831,19 +898,28 @@ public class ScreenBuffer {
}
public synchronized void setText(String text) {
setText(text, 0);
}
public synchronized void setText(String text, int pos) {
if (text == null) return;
int size = rows * cols;
int len = Math.min(text.length(), size);
if (pos < 0 || pos >= size) return;
int len = Math.min(text.length(), size - pos);
for (int i = 0; i < len; i++) {
char ch = text.charAt(i);
int ebc = translator.unicodeToEbcdic(ch);
buffer[i].ec = (byte) (ebc >= 0 ? ebc : 0);
buffer[i].ucs4 = ch;
buffer[pos + i].ec = (byte) (ebc >= 0 ? ebc : 0);
buffer[pos + i].ucs4 = ch;
}
screenChanged = true;
updateDisplaySnapshot();
}
public void setText(String text, int row, int col) {
setText(text, row * cols + col);
}
public int searchString(String target) {
if (target == null || target.isEmpty()) return -1;
String full = getText();
@@ -1071,12 +1147,16 @@ public class ScreenBuffer {
public void setEntryAssistTabStops(int[] stops) { this.tabStops = stops; }
public void SetEntryAssistTabStops(int[] stops) { setEntryAssistTabStops(stops); }
public void setLeftMargin(int n) { setEntryAssistStartColumn(n); }
public void setRightMargin(int n) { setEntryAssistEndColumn(n); }
public void setWordTabPositions(int[] stops) { setEntryAssistTabStops(stops); }
/**
* Perform Entry Assist word wrap if typing near/past end margin.
* Moves any partial word typed on the current line to the beginning of the next line (docStartCol).
*/
public synchronized boolean handleWordWrap(int curAddr, char typedChar) {
if (!docMode && !wordWrap) return false;
if (!wordWrap) return false;
int size = rows * cols;
if (size <= 0) return false;
curAddr = ((curAddr % size) + size) % size;
@@ -1115,8 +1195,10 @@ public class ScreenBuffer {
ExtendedAttribute[] wordCells = new ExtendedAttribute[wordLen];
for (int i = 0; i < wordLen; i++) {
wordCells[i] = new ExtendedAttribute();
wordCells[i].copyFrom(getCell(wordStartAddr + i));
getCell(wordStartAddr + i).clear();
ExtendedAttribute srcCell = getCell(wordStartAddr + i);
wordCells[i].copyFrom(srcCell);
srcCell.ec = 0;
srcCell.ucs4 = 0;
}
int nextRow = (curRow + 1) % rows;
@@ -6,6 +6,8 @@ import haus.nightmare.lib3270j.Telnet3270Client;
import java.io.*;
import java.net.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Logger;
import java.util.logging.Level;
@@ -28,6 +30,10 @@ public class TelnetConnection {
private final TelnetFSM fsm;
private final ConnectionConfig config;
private final AtomicLong lastActivityTime = new AtomicLong(System.currentTimeMillis());
private ScheduledExecutorService keepAliveExecutor;
private volatile boolean intentionalDisconnect = false;
private javax.net.ssl.SSLSession sslSession;
public TelnetConnection(ConnectionConfig config, TelnetFSM fsm) {
@@ -91,6 +97,7 @@ public class TelnetConnection {
if (config.getSoTimeoutMs() > 0) {
rawSocket.setSoTimeout(config.getSoTimeoutMs());
}
applyExtendedSocketOptions(rawSocket);
rawSocket.connect(new InetSocketAddress(connectHost, connectPort), config.getConnectTimeoutMs());
// Perform proxy handshake if configured
@@ -123,6 +130,7 @@ public class TelnetConnection {
if (config.getSoTimeoutMs() > 0) {
sslSocket.setSoTimeout(config.getSoTimeoutMs());
}
applyExtendedSocketOptions(sslSocket);
applyTlsSocketSettings(sslSocket);
sslSocket.startHandshake();
socket = sslSocket;
@@ -143,10 +151,13 @@ public class TelnetConnection {
log.info("Connected to " + socket.getRemoteSocketAddress());
intentionalDisconnect = false;
lastActivityTime.set(System.currentTimeMillis());
running = true;
readerThread = new Thread(this::readLoop, "TN3270-Reader");
readerThread.setDaemon(true);
readerThread.start();
startKeepAlive();
}
/**
@@ -167,6 +178,7 @@ public class TelnetConnection {
if (config.getSoTimeoutMs() > 0) {
sslSocket.setSoTimeout(config.getSoTimeoutMs());
}
applyExtendedSocketOptions(sslSocket);
applyTlsSocketSettings(sslSocket);
sslSocket.startHandshake();
this.socket = sslSocket;
@@ -200,6 +212,106 @@ public class TelnetConnection {
}
}
private void applyExtendedSocketOptions(Socket s) {
if (s == null) return;
try {
Class<?> extClass = Class.forName("jdk.net.ExtendedSocketOptions");
// TCP_KEEPIDLE, TCP_KEEPINTERVAL, TCP_KEEPCOUNT
if (config != null && config.isSoKeepAlive() && config.getKeepAliveIntervalSeconds() > 0) {
try {
java.lang.reflect.Field fIdle = extClass.getField("TCP_KEEPIDLE");
@SuppressWarnings("unchecked")
SocketOption<Integer> optIdle = (SocketOption<Integer>) fIdle.get(null);
s.setOption(optIdle, config.getKeepAliveIntervalSeconds());
} catch (Throwable ignored) {}
try {
java.lang.reflect.Field fIntv = extClass.getField("TCP_KEEPINTERVAL");
@SuppressWarnings("unchecked")
SocketOption<Integer> optIntv = (SocketOption<Integer>) fIntv.get(null);
s.setOption(optIntv, Math.max(1, Math.min(10, config.getKeepAliveIntervalSeconds())));
} catch (Throwable ignored) {}
try {
java.lang.reflect.Field fCnt = extClass.getField("TCP_KEEPCOUNT");
@SuppressWarnings("unchecked")
SocketOption<Integer> optCnt = (SocketOption<Integer>) fCnt.get(null);
s.setOption(optCnt, 3);
} catch (Throwable ignored) {}
}
// TCP_USER_TIMEOUT
if (config != null && config.getTcpUserTimeoutMs() > 0) {
try {
java.lang.reflect.Field fTimeout = extClass.getField("TCP_USER_TIMEOUT");
@SuppressWarnings("unchecked")
SocketOption<Integer> optTimeout = (SocketOption<Integer>) fTimeout.get(null);
s.setOption(optTimeout, config.getTcpUserTimeoutMs());
} catch (Throwable ignored) {}
}
} catch (Throwable ignored) {
// Extended socket options not supported on this platform/runtime (e.g. macOS/Android)
}
}
public synchronized void startKeepAlive() {
stopKeepAlive();
if (config != null && config.isKeepAliveEnabled() && config.getKeepAliveIntervalSeconds() > 0) {
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "TN3270-KeepAlive");
t.setDaemon(true);
return t;
});
int interval = config.getKeepAliveIntervalSeconds();
long checkPeriod = Math.max(1, Math.min(5, interval));
keepAliveExecutor.scheduleWithFixedDelay(this::checkAndSendKeepAlive, checkPeriod, checkPeriod, TimeUnit.SECONDS);
log.fine("Keep-Alive heartbeat scheduled every " + interval + "s (check every " + checkPeriod + "s)");
}
}
public synchronized void stopKeepAlive() {
if (keepAliveExecutor != null) {
keepAliveExecutor.shutdownNow();
keepAliveExecutor = null;
}
}
private void checkAndSendKeepAlive() {
if (!running || !isConnected()) {
return;
}
long now = System.currentTimeMillis();
long elapsed = now - lastActivityTime.get();
long intervalMs = (config != null ? config.getKeepAliveIntervalSeconds() : 120) * 1000L;
if (elapsed >= intervalMs) {
try {
sendKeepAliveHeartbeat();
} catch (Exception e) {
log.log(Level.WARNING, "Failed to send keep-alive heartbeat", e);
}
}
}
public synchronized void sendKeepAliveHeartbeat() throws IOException {
if (!isConnected() || outputStream == null) {
return;
}
String type = (config != null) ? config.getKeepAliveType() : "NOP";
if ("TIMING-MARK".equalsIgnoreCase(type)) {
log.fine("Transmitting Keep-Alive heartbeat: IAC DO TIMING-MARK");
byte[] tm = new byte[] { (byte) IAC, (byte) DO, (byte) TELOPT_TM };
outputStream.write(tm);
outputStream.flush();
} else {
log.fine("Transmitting Keep-Alive heartbeat: IAC NOP");
byte[] nop = new byte[] { (byte) IAC, (byte) NOP };
outputStream.write(nop);
outputStream.flush();
}
lastActivityTime.set(System.currentTimeMillis());
}
public long getLastActivityTime() {
return lastActivityTime.get();
}
private void establishHttpProxy(Socket s, String targetHost, int targetPort, String user, String pass) throws IOException {
OutputStream out = s.getOutputStream();
InputStream in = s.getInputStream();
@@ -411,6 +523,7 @@ public class TelnetConnection {
if (outputStream == null) return;
outputStream.write(data, offset, length);
outputStream.flush();
lastActivityTime.set(System.currentTimeMillis());
if (log.isLoggable(Level.FINE)) {
log.fine("SENT " + length + " bytes: " + formatHex(data, offset, length));
}
@@ -432,13 +545,16 @@ public class TelnetConnection {
byte[] escaped = out.toByteArray();
outputStream.write(escaped, 0, escaped.length);
outputStream.flush();
lastActivityTime.set(System.currentTimeMillis());
}
/**
* Disconnect from the host.
*/
public void disconnect() {
intentionalDisconnect = true;
running = false;
stopKeepAlive();
try {
if (socket != null && !socket.isClosed()) {
socket.shutdownInput();
@@ -471,10 +587,12 @@ public class TelnetConnection {
int n = inputStream.read(buf);
if (n < 0) {
log.info("Host disconnected (EOF)");
fsm.onDisconnect();
boolean unexpected = !intentionalDisconnect;
fsm.onDisconnect(unexpected);
break;
}
if (n > 0) {
lastActivityTime.set(System.currentTimeMillis());
if (log.isLoggable(Level.FINE)) {
log.fine("RCVD " + n + " bytes: " + formatHex(buf, 0, n));
}
@@ -486,14 +604,23 @@ public class TelnetConnection {
}
}
}
} catch (SocketTimeoutException e) {
if (running && !intentionalDisconnect) {
log.warning("Socket read timeout (" + (config != null ? config.getSoTimeoutMs() : 0) + "ms): " + e.getMessage());
fsm.onDisconnect(true);
}
} catch (SocketException e) {
if (running) {
log.info("Socket closed: " + e.getMessage());
fsm.onDisconnect();
if (running && !intentionalDisconnect) {
log.info("Socket closed unexpectedly: " + e.getMessage());
fsm.onDisconnect(true);
} else if (running) {
fsm.onDisconnect(false);
}
} catch (IOException e) {
if (running) {
log.log(Level.WARNING, "Read error", e);
if (running && !intentionalDisconnect) {
log.log(Level.WARNING, "Read error: " + e.getMessage());
fsm.onDisconnect(true);
} else if (running) {
fsm.onError("Read error: " + e.getMessage());
}
} catch (Throwable t) {
@@ -501,6 +628,8 @@ public class TelnetConnection {
log.log(Level.SEVERE, "Unexpected fatal error in readLoop", t);
fsm.onError("Network loop error: " + t.getMessage());
}
} finally {
stopKeepAlive();
}
}
@@ -75,9 +75,6 @@ public class TelnetFSM {
return list;
}
if (config.isDynamicModel()) {
if (config.isExtendedDataStream()) {
list.add("IBM-DYNAMIC-E");
}
list.add("IBM-DYNAMIC");
list.add("IBM-3279-4-E");
list.add("IBM-3279-4");
@@ -123,6 +120,11 @@ public class TelnetFSM {
private String connectedLu;
private String connectedType;
// Phase 10: Contention Resolution & Auto-Unlock State
private boolean sdi_flag = false;
private boolean kri_flag = false;
private boolean negotiateContentionResolution = true;
public enum TN3270ESubmode { UNBOUND, E_3270, E_NVT, E_SSCP }
public TelnetFSM(ConnectionConfig config, ScreenBuffer screenBuffer, DataStreamProcessor dsProcessor) {
@@ -193,7 +195,7 @@ public class TelnetFSM {
eFuncs[FUNC_SYSREQ] = true;
eFuncs[FUNC_SNA_SENSE] = true;
eFuncs[FUNC_DATA_STREAM_CTL] = true;
eFuncs[FUNC_CONTENTION_RESOLUTION] = true;
eFuncs[FUNC_CONTENTION_RESOLUTION] = negotiateContentionResolution;
statusDisplay(STATUS_CONNECTING, "Connecting to host");
changeState(ConnectionState.TELNET_PENDING);
@@ -954,6 +956,10 @@ public class TelnetFSM {
log.info("TN3270E functions negotiated: " + getNegotiatedFunctionNames());
log.info("TN3270E negotiation complete");
if (dsProcessor != null) {
dsProcessor.setContentionResolution(isContentionResolutionNegotiated());
}
// RFC 2355: If BIND-IMAGE function is not negotiated, the emulation session is considered
// to be bound immediately upon completion of the FUNCTIONS negotiation.
if (eFuncs[FUNC_BIND_IMAGE]) {
@@ -966,6 +972,7 @@ public class TelnetFSM {
// Notify listeners
for (ConnectionListener l : connectionListeners) {
l.onTN3270ENegotiated(connectedType, connectedLu);
l.onTN3270EFunctionsNegotiated(eFuncs);
}
}
@@ -999,9 +1006,59 @@ public class TelnetFSM {
processTN3270ERecord(data);
} else {
// Plain TN3270 mode: data is raw 3270 data stream
dsProcessor.processRecord(data, 0, data.length, true);
if (dsProcessor != null) {
dsProcessor.processRecord(data, 0, data.length, false);
}
notifyScreenUpdate();
}
// Phase 10: Contention Resolution & AUTO_SYS_UNLOCK handling on EOR
if (dsProcessor != null) {
boolean crActive = isContentionResolutionNegotiated();
if (crActive) {
if (this.sdi_flag && !dsProcessor.isRcvdRead()) {
if (dsProcessor.getInputProcessor() != null) {
dsProcessor.getInputProcessor().setKeyboardLocked(false);
}
this.sdi_flag = false;
dsProcessor.setUnlockPending(false);
if (dsProcessor.isUnlockSysPending() || this.kri_flag) {
if (dsProcessor.getInputProcessor() != null && dsProcessor.getInputProcessor().getOIA() != null) {
dsProcessor.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED);
}
}
this.kri_flag = false;
dsProcessor.setUnlockSysPending(false);
notifyKeyboardUnlocked();
}
this.kri_flag = false;
} else {
// Contention Resolution is not active
boolean autoSysUnlock = (config != null) ? config.isAutoSysUnlock() : true;
if (autoSysUnlock && !dsProcessor.isRcvdRead()) {
boolean sysLocked = false;
if (dsProcessor.getInputProcessor() != null) {
if (dsProcessor.getInputProcessor().isKeyboardLocked() ||
(dsProcessor.getInputProcessor().getOIA() != null && dsProcessor.getInputProcessor().getOIA().isXSystem())) {
sysLocked = true;
}
}
if (sysLocked) {
if (dsProcessor.getInputProcessor() != null) {
dsProcessor.getInputProcessor().setKeyboardLocked(false);
if (dsProcessor.getInputProcessor().getOIA() != null) {
dsProcessor.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED);
}
}
dsProcessor.setUnlockSysPending(false);
notifyKeyboardUnlocked();
}
}
}
this.sdi_flag = false;
this.kri_flag = false;
dsProcessor.setRcvdRead(false);
}
}
public void processTn3270eHeader(byte[] data) {
@@ -1019,6 +1076,12 @@ public class TelnetFSM {
int responseFlag = data[2] & 0xFF;
int seqNumber = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF);
this.sdi_flag = (requestFlag & 0x01) != 0;
this.kri_flag = (requestFlag & 0x02) != 0;
if (dsProcessor != null) {
dsProcessor.setContentionResolution(isContentionResolutionNegotiated());
}
log.fine("TN3270E header: type=" + TN3270EConstants.dataTypeName(dataType) +
" rqf=" + requestFlag + " rsf=" + responseFlag + " seq=" + seqNumber);
@@ -1036,7 +1099,7 @@ public class TelnetFSM {
tn3270eSubmode = TN3270ESubmode.E_3270;
}
try {
dsProcessor.processRecord(data, EH_SIZE, data.length - EH_SIZE, true);
dsProcessor.processRecord(data, EH_SIZE, data.length - EH_SIZE, false);
notifyScreenUpdate();
// Send positive response if required
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
@@ -1426,8 +1489,49 @@ public class TelnetFSM {
}
}
public void setConnectionState(ConnectionState newState) {
changeState(newState);
}
public void onDisconnect() {
changeState(ConnectionState.NOT_CONNECTED);
onDisconnect(false);
}
public void onDisconnect(boolean unexpected) {
if (unexpected && config != null && config.isAutoReconnect()) {
log.info("Unexpected connection loss — transitioning to RECONNECTING");
changeState(ConnectionState.RECONNECTING);
} else {
changeState(ConnectionState.NOT_CONNECTED);
}
}
/**
* Cleanly reset Telnet and TN3270E session state prior to reconnecting.
*/
public synchronized void resetSessionState() {
state = TNS_DATA;
java.util.Arrays.fill(myOpts, false);
java.util.Arrays.fill(hisOpts, false);
ibuf.reset();
sbbuf.reset();
tn3270eNegotiated = false;
tn3270eSubmode = TN3270ESubmode.UNBOUND;
tn3270eBound = false;
java.util.Arrays.fill(eFuncs, false);
eXmitSeq = 0;
lastRcvSeq = 0;
lastRespType = 0;
lastRespCode = 0;
responseRequired = RSF_NO_RESPONSE;
deferredWillTtype = false;
tn3270eDeviceTypeSent = false;
ttypeIndex = 0;
luIndex = 0;
connectedLu = null;
connectedType = null;
sdi_flag = false;
kri_flag = false;
}
public void onError(String message) {
@@ -1437,7 +1541,10 @@ public class TelnetFSM {
}
}
private void notifyScreenUpdate() {
public void notifyScreenUpdate() {
if (screenBuffer != null) {
screenBuffer.notifyScreenUpdate();
}
for (ScreenUpdateListener l : screenListeners) {
l.onScreenUpdated();
}
@@ -1813,4 +1920,41 @@ public class TelnetFSM {
public boolean isFunctionNegotiated(int func) {
return func >= 0 && func < eFuncs.length && eFuncs[func];
}
public boolean isContentionResolutionNegotiated() {
return tn3270eNegotiated && eFuncs[FUNC_CONTENTION_RESOLUTION];
}
public boolean isNegotiateContentionResolution() {
return negotiateContentionResolution;
}
public void setNegotiateContentionResolution(boolean neg) {
this.negotiateContentionResolution = neg;
eFuncs[FUNC_CONTENTION_RESOLUTION] = neg;
}
public void setContentionResolutionNegotiated(boolean cr) {
if (cr) {
this.tn3270eNegotiated = true;
}
eFuncs[FUNC_CONTENTION_RESOLUTION] = cr;
if (dsProcessor != null) {
dsProcessor.setContentionResolution(cr);
}
}
public boolean isSdi_flag() { return sdi_flag; }
public void setSdi_flag(boolean sdi) { this.sdi_flag = sdi; }
public boolean isKri_flag() { return kri_flag; }
public void setKri_flag(boolean kri) { this.kri_flag = kri; }
public void notifyKeyboardUnlocked() {
for (ScreenUpdateListener l : screenListeners) {
try {
l.onKeyboardUnlocked();
} catch (Exception ignored) {}
}
}
}
@@ -152,6 +152,14 @@ public class DS3270 {
public boolean suppressClearEvent;
public boolean wsfvalid = true;
// Phase 10: State tracking matching HoD DS3270
protected boolean autoSysUnlock = true;
protected boolean sdi_flag = false;
protected boolean kri_flag = false;
protected boolean unlock_pending = false;
protected boolean unlock_sys_pending = false;
protected boolean rcvdRead = false;
// Underlying lib3270j data stream processor
private final DataStreamProcessor delegate;
private ECLSession session;
@@ -172,9 +180,15 @@ public class DS3270 {
public DS3270(ECLSession session, ECLPS ps) {
this.session = session;
this.ps = ps;
if (session != null) {
this.autoSysUnlock = session.isAutoSysUnlock();
}
ScreenBuffer sb = (ps != null) ? ps.getScreenBuffer() : new ScreenBuffer();
EbcdicTranslator trans = (ps != null) ? ps.getTranslator() : new EbcdicTranslator();
this.delegate = new DataStreamProcessor(sb, trans);
if (session != null) {
this.delegate.setAutoSysUnlock(session.isAutoSysUnlock());
}
if (ps != null && ps.getInputProcessor() != null) {
this.delegate.setInputProcessor(ps.getInputProcessor());
}
@@ -214,8 +228,135 @@ public class DS3270 {
return false;
}
public boolean isAutoSysUnlock() {
return autoSysUnlock;
}
public void setAutoSysUnlock(boolean autoSysUnlock) {
this.autoSysUnlock = autoSysUnlock;
if (delegate != null) {
delegate.setAutoSysUnlock(autoSysUnlock);
}
}
public boolean isSdi_flag() { return sdi_flag; }
public void setSdi_flag(boolean sdi) { this.sdi_flag = sdi; }
public boolean isKri_flag() { return kri_flag; }
public void setKri_flag(boolean kri) { this.kri_flag = kri; }
public boolean isUnlock_pending() { return unlock_pending; }
public void setUnlock_pending(boolean pending) { this.unlock_pending = pending; }
public boolean isUnlock_sys_pending() { return unlock_sys_pending; }
public void setUnlock_sys_pending(boolean pending) { this.unlock_sys_pending = pending; }
public boolean isRcvdRead() {
return rcvdRead || (delegate != null && delegate.isRcvdRead());
}
public void setRcvdRead(boolean rcvd) {
this.rcvdRead = rcvd;
if (delegate != null) delegate.setRcvdRead(rcvd);
}
public void receiveHeaderData(short s, short s2, short s3, int n) {
this.sdi_flag = (s2 & request_bit_SDI) != 0;
this.kri_flag = (s2 & request_bit_KRI) != 0;
}
public void receiveHeader(short s, short s2, short s3, int n) {
receiveHeaderData(s, s2, s3, n);
}
public void endOfRecord() {
log.fine("DS3270 endOfRecord");
boolean crActive = (session != null && session.getContentionResolution());
if (crActive) {
if (this.sdi_flag && !isRcvdRead()) {
if (this.ps != null) {
this.ps.unlockKeyboard(7);
} else if (delegate != null && delegate.getInputProcessor() != null) {
delegate.getInputProcessor().setKeyboardLocked(false);
}
this.sdi_flag = false;
this.unlock_pending = false;
if (this.unlock_sys_pending || this.kri_flag) {
if (this.ps != null) {
this.ps.unlockKeyboard(8);
} else if (delegate != null && delegate.getInputProcessor() != null) {
delegate.getInputProcessor().setKeyboardLocked(false);
if (delegate.getInputProcessor().getOIA() != null) {
delegate.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED);
}
}
}
this.kri_flag = false;
this.unlock_sys_pending = false;
}
if (this.ps != null) {
this.ps.dispatchEvent(new haus.nightmare.lib3270j.ecl.ECLPSEvent(this.ps, haus.nightmare.lib3270j.ecl.ECLPSEvent.EVENT_KEY_UNLOCKED));
}
} else {
// Contention resolution is not active
if (this.unlock_pending && !isRcvdRead()) {
if (this.ps != null) {
this.ps.unlockKeyboard(7);
} else if (delegate != null && delegate.getInputProcessor() != null) {
delegate.getInputProcessor().setKeyboardLocked(false);
}
if (this.autoSysUnlock) {
if (this.ps != null) {
this.ps.unlockKeyboard(8);
} else if (delegate != null && delegate.getInputProcessor() != null) {
delegate.getInputProcessor().setKeyboardLocked(false);
if (delegate.getInputProcessor().getOIA() != null) {
delegate.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED);
}
}
this.unlock_sys_pending = false;
}
this.unlock_pending = false;
}
if (this.unlock_sys_pending && !isRcvdRead()) {
if (this.ps != null) {
this.ps.unlockKeyboard(8);
} else if (delegate != null && delegate.getInputProcessor() != null) {
delegate.getInputProcessor().setKeyboardLocked(false);
if (delegate.getInputProcessor().getOIA() != null) {
delegate.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED);
}
}
this.unlock_sys_pending = false;
}
if (this.autoSysUnlock && !isRcvdRead()) {
boolean sysLocked = false;
if (this.ps != null && this.ps.islocked_SYSLOCK()) {
sysLocked = true;
} else if (delegate != null && delegate.getInputProcessor() != null) {
if (delegate.getInputProcessor().isKeyboardLocked() ||
(delegate.getInputProcessor().getOIA() != null && delegate.getInputProcessor().getOIA().isXSystem())) {
sysLocked = true;
}
}
if (sysLocked) {
if (this.ps != null) {
this.ps.unlockKeyboard(8);
}
if (delegate != null && delegate.getInputProcessor() != null) {
delegate.getInputProcessor().setKeyboardLocked(false);
if (delegate.getInputProcessor().getOIA() != null) {
delegate.getInputProcessor().getOIA().setInputInhibited(haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NOT_INHIBITED);
}
}
this.unlock_sys_pending = false;
}
}
}
this.rcvdRead = false;
if (delegate != null) delegate.setRcvdRead(false);
this.unlock_pending = false;
this.sdi_flag = false;
this.kri_flag = false;
}
public int receiveData(short[] sArray, int off, int len) {
@@ -233,6 +374,10 @@ public class DS3270 {
public void processWCC(short wcc) {
delegate.processWCC(wcc);
if ((wcc & WCC_RESTORE) > 0) {
this.unlock_pending = true;
this.unlock_sys_pending = true;
}
}
public void processSBA(int baddr) {
@@ -0,0 +1,371 @@
package haus.nightmare.lib3270j.datastream;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.ecl.ECLConnection;
import haus.nightmare.lib3270j.ecl.ECLConstants;
import haus.nightmare.lib3270j.ecl.ECLOIA;
import haus.nightmare.lib3270j.ecl.ECLPS;
import haus.nightmare.lib3270j.ecl.ECLPSEvent;
import haus.nightmare.lib3270j.ecl.ECLPSListener;
import haus.nightmare.lib3270j.ecl.ECLSession;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import haus.nightmare.lib3270j.protocol.DS3270Constants;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import haus.nightmare.lib3270j.telnet.TelnetFSM;
import haus.nightmare.lib3270j.tn3270.DS3270;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 10 Test Suite: DataStream Protocol Hardening & AUTO_SYS_UNLOCK Parity.
*
* Verifies:
* 10.1 WCC Keyboard Restore Logic (intermediate writes without restore bit keep keyboard locked).
* 10.2 AUTO_SYS_UNLOCK parity across ConnectionConfig, ECLSession, ECLConnection, DS3270, and TelnetFSM.
* 10.3 Contention Resolution negotiation, SDI/KRI tracking, and ECLPSEvent.EVENT_KEY_UNLOCKED dispatch.
*/
public class Phase10ProtocolHardeningTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private InputProcessor inputProcessor;
private DataStreamProcessor processor;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator);
inputProcessor = new InputProcessor(screen, translator, null);
processor = new DataStreamProcessor(screen, translator);
processor.setInputProcessor(inputProcessor);
}
// =========================================================================
// 10.1 WCC Keyboard Restore Logic
// =========================================================================
@Test
@DisplayName("10.1: Write without WCC restore bit keeps keyboard locked")
public void testWriteWithoutWccRestoreKeepsKeyboardLocked() {
// Initially lock the keyboard
inputProcessor.setKeyboardLocked(true);
assertTrue(inputProcessor.isKeyboardLocked());
// Construct 3270 Write Command (0x01) with WCC = 0xC0 (Reset MDT only, no keyboard restore 0x02)
byte[] record = new byte[] {
(byte) DS3270Constants.CMD_WRITE,
(byte) 0xC0, // WCC: bit 6 set (reset MDT), bit 1 (restore) NOT set
(byte) 0x11, 0x40, 0x40, // SBA to 0
(byte) 0xC1, (byte) 0xC2 // 'A', 'B'
};
processor.processRecord(record, 0, record.length, false);
// Keyboard MUST remain locked because WCC restore bit 0x02 was not set
assertTrue(inputProcessor.isKeyboardLocked(),
"Keyboard must remain locked when WCC does not have restore bit (0x02) set");
}
@Test
@DisplayName("10.1: Write with WCC restore bit unlocks keyboard")
public void testWriteWithWccRestoreUnlocksKeyboard() {
// Initially lock the keyboard
inputProcessor.setKeyboardLocked(true);
assertTrue(inputProcessor.isKeyboardLocked());
// Construct 3270 Write Command (0x01) with WCC = 0xC2 (Reset MDT + Keyboard Restore bit 0x02)
byte[] record = new byte[] {
(byte) DS3270Constants.CMD_WRITE,
(byte) 0xC2, // WCC: restore bit (0x02) set
(byte) 0x11, 0x40, 0x40, // SBA to 0
(byte) 0xC1, (byte) 0xC2 // 'A', 'B'
};
processor.processRecord(record, 0, record.length, false);
// Keyboard MUST now be unlocked
assertFalse(inputProcessor.isKeyboardLocked(),
"Keyboard must be unlocked when WCC has restore bit (0x02) set");
}
@Test
@DisplayName("10.1: Intermediate writes without restore bit maintain keyboard lock across sequence")
public void testIntermediateWritesKeepKeyboardLockedUntilFinalRestore() {
inputProcessor.setKeyboardLocked(true);
assertTrue(inputProcessor.isKeyboardLocked());
// First intermediate write: WCC = 0x00 (no restore)
byte[] write1 = new byte[] {
(byte) DS3270Constants.CMD_WRITE,
(byte) 0x00,
(byte) 0x11, 0x40, 0x40,
(byte) 0xC1
};
processor.processRecord(write1, 0, write1.length, false);
assertTrue(inputProcessor.isKeyboardLocked(), "Write 1 without restore bit must leave keyboard locked");
// Second intermediate write: Erase / Write with WCC = 0x40 (Reset, no restore)
byte[] write2 = new byte[] {
(byte) DS3270Constants.CMD_ERASE_WRITE,
(byte) 0x40,
(byte) 0x11, 0x40, 0x50,
(byte) 0xC2
};
processor.processRecord(write2, 0, write2.length, false);
assertTrue(inputProcessor.isKeyboardLocked(), "Write 2 without restore bit must leave keyboard locked");
// Final write: WCC = 0x42 (Reset + Restore)
byte[] write3 = new byte[] {
(byte) DS3270Constants.CMD_WRITE,
(byte) 0x42,
(byte) 0x11, 0x40, 0x60,
(byte) 0xC3
};
processor.processRecord(write3, 0, write3.length, false);
assertFalse(inputProcessor.isKeyboardLocked(), "Final write with restore bit must unlock keyboard");
}
// =========================================================================
// 10.2 AUTO_SYS_UNLOCK Parity
// =========================================================================
@Test
@DisplayName("10.2: autoSysUnlock configuration property propagation across ECL components")
public void testAutoSysUnlockConfigurationPropagation() {
// Default ConnectionConfig should have autoSysUnlock == true
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
assertTrue(config.isAutoSysUnlock(), "Default autoSysUnlock in ConnectionConfig must be true");
config.setAutoSysUnlock(false);
assertFalse(config.isAutoSysUnlock());
// Telnet3270Client propagation
Telnet3270Client client = new Telnet3270Client(config);
assertFalse(client.isAutoSysUnlock());
// ECLSession propagation
ECLSession session = new ECLSession(client);
assertFalse(session.isAutoSysUnlock());
assertFalse(session.getConnection().isAutoSysUnlock());
// Modify via ECLSession
session.setAutoSysUnlock(true);
assertTrue(session.isAutoSysUnlock());
assertTrue(client.isAutoSysUnlock());
assertTrue(config.isAutoSysUnlock());
// Test ECLSession Properties parsing
Properties props = new Properties();
props.setProperty(ECLSession.SESSION_AUTO_SYS_UNLOCK, "false");
props.setProperty(ECLSession.SESSION_HOST, "mainframe.org");
ECLSession propsSession = new ECLSession(props);
assertFalse(propsSession.isAutoSysUnlock());
assertFalse(propsSession.getConnection().isAutoSysUnlock());
// ECLConnection standalone properties
ECLConnection standaloneConn = new ECLConnection(props);
assertFalse(standaloneConn.isAutoSysUnlock());
}
@Test
@DisplayName("10.2: DS3270 endOfRecord unlocks keyboard and clears OIA when autoSysUnlock is true and in X SYSTEM")
public void testDs3270AutoSysUnlockOnEor() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
config.setAutoSysUnlock(true);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
ECLOIA oia = session.GetOIA();
DS3270 ds = new DS3270(session, ps);
assertTrue(ds.isAutoSysUnlock());
// Set connected state so OIA does not treat it as communication check
client.getTelnetFSM().setConnectionState(haus.nightmare.lib3270j.ConnectionState.CONNECTED_3270);
// Put session in X SYSTEM lock
ps.lockKeyboard(8); // reason 8: SYSLOCK
assertTrue(ps.islocked_SYSLOCK());
assertTrue(client.getInputProcessor().isKeyboardLocked());
assertTrue(oia.isXSystem());
// Trigger endOfRecord() with autoSysUnlock=true
ds.endOfRecord();
// Keyboard must be auto-unlocked and OIA cleared
assertFalse(client.getInputProcessor().isKeyboardLocked(),
"Keyboard should be unlocked on EOR when autoSysUnlock is true");
assertFalse(oia.isXSystem(),
"OIA X SYSTEM lock should be cleared on EOR when autoSysUnlock is true");
}
@Test
@DisplayName("10.2: DS3270 endOfRecord leaves keyboard locked when autoSysUnlock is false and in X SYSTEM")
public void testDs3270AutoSysUnlockDisabledKeepsSystemLock() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
config.setAutoSysUnlock(false);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
DS3270 ds = new DS3270(session, ps);
assertFalse(ds.isAutoSysUnlock());
// Put session in X SYSTEM lock
ps.lockKeyboard(8);
assertTrue(ps.islocked_SYSLOCK());
assertTrue(client.getInputProcessor().isKeyboardLocked());
// Trigger endOfRecord() with autoSysUnlock=false
ds.endOfRecord();
// Keyboard MUST remain locked
assertTrue(client.getInputProcessor().isKeyboardLocked(),
"Keyboard must remain locked on EOR when autoSysUnlock is false");
}
// =========================================================================
// 10.3 Contention Resolution & Pre-Data State Transitions
// =========================================================================
@Test
@DisplayName("10.3: Contention Resolution negotiation flag synchronization with ECLConnection")
public void testContentionResolutionNegotiationSync() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLConnection conn = session.getConnection();
TelnetFSM fsm = client.getTelnetFSM();
assertNotNull(fsm);
assertTrue(fsm.isNegotiateContentionResolution(),
"Default negotiateContentionResolution should be true");
// Test setter on FSM and reflection in ECLConnection
fsm.setContentionResolutionNegotiated(true);
assertTrue(fsm.isContentionResolutionNegotiated(),
"Contention resolution should be negotiated in FSM");
assertTrue(conn.getContentionResolution(),
"ECLConnection should reflect negotiated contention resolution");
assertTrue(session.getContentionResolution(),
"ECLSession should reflect negotiated contention resolution");
}
@Test
@DisplayName("10.3: Contention Resolution SDI flag restores keyboard and fires EVENT_KEY_UNLOCKED on EOR")
public void testContentionResolutionWithSdiRestoresKeyboardAndFiresEvent() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
DS3270 ds = new DS3270(session, ps);
// Enable Contention Resolution on session
session.setContentionResolution(true);
assertTrue(session.getContentionResolution());
// Lock keyboard
client.getInputProcessor().setKeyboardLocked(true);
assertTrue(client.getInputProcessor().isKeyboardLocked());
// Register listener for EVENT_KEY_UNLOCKED
AtomicInteger eventTypeReceived = new AtomicInteger(-1);
AtomicBoolean unlockedEventFired = new AtomicBoolean(false);
ps.RegisterPSEvent(new ECLPSListener() {
@Override
public void PSNotifyEvent(ECLPSEvent event) {
eventTypeReceived.set(event.getEventType());
unlockedEventFired.set(true);
}
});
// Inbound TN3270E header with SDI bit set:
// s2 request flag has bit 0 (0x01) set -> Send Data Indicator (SDI)
short s1 = 0;
short s2 = DS3270.request_bit_SDI; // 1
short s3 = 0;
ds.receiveHeaderData(s1, s2, s3, 0);
assertTrue(ds.isSdi_flag(), "SDI flag should be tracked from TN3270E header");
// Trigger endOfRecord()
ds.endOfRecord();
// Keyboard should be unlocked because SDI was received
assertFalse(client.getInputProcessor().isKeyboardLocked(),
"Keyboard should be restored on EOR when CR is active and SDI is set");
assertFalse(ds.isSdi_flag(), "SDI flag should be reset after EOR");
assertTrue(unlockedEventFired.get(), "ECLPSEvent must be dispatched on keyboard unlock");
assertEquals(ECLPSEvent.EVENT_KEY_UNLOCKED, eventTypeReceived.get(),
"Dispatched event must match EVENT_KEY_UNLOCKED");
}
@Test
@DisplayName("10.3: Contention Resolution WITHOUT SDI flag keeps keyboard locked on EOR")
public void testContentionResolutionWithoutSdiKeepsKeyboardLocked() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
DS3270 ds = new DS3270(session, ps);
session.setContentionResolution(true);
assertTrue(session.getContentionResolution());
// Initially lock keyboard
client.getInputProcessor().setKeyboardLocked(true);
// Inbound TN3270E header WITHOUT SDI flag (s2 = 0)
ds.receiveHeaderData((short) 0, (short) 0, (short) 0, 0);
assertFalse(ds.isSdi_flag());
// Trigger EOR
ds.endOfRecord();
// Keyboard MUST stay locked because host did not grant Send Data Indicator (turn)
assertTrue(client.getInputProcessor().isKeyboardLocked(),
"Keyboard must remain locked on EOR when Contention Resolution is active and SDI is not set");
}
@Test
@DisplayName("10.3: Contention Resolution KRI flag resets system lock pending on EOR")
public void testContentionResolutionKriResetsSystemLock() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
DS3270 ds = new DS3270(session, ps);
session.setContentionResolution(true);
// Put in system lock
ps.lockKeyboard(8);
assertTrue(ps.islocked_SYSLOCK());
// Receive header with KRI flag (bit 1 = 0x02) and SDI flag (bit 0 = 0x01)
short s2 = (short) (DS3270.request_bit_SDI | DS3270.request_bit_KRI);
ds.receiveHeaderData((short) 0, s2, (short) 0, 0);
assertTrue(ds.isSdi_flag());
assertTrue(ds.isKri_flag());
ds.endOfRecord();
// Keyboard restored and system lock reset
assertFalse(client.getInputProcessor().isKeyboardLocked());
assertFalse(ps.islocked_SYSLOCK());
assertFalse(ds.isKri_flag());
}
}
@@ -0,0 +1,397 @@
package haus.nightmare.lib3270j.datastream;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.ConnectionState;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.ecl.ECLConnection;
import haus.nightmare.lib3270j.ecl.ECLSession;
import haus.nightmare.lib3270j.listener.ConnectionListener;
import haus.nightmare.lib3270j.protocol.TelnetConstants;
import haus.nightmare.lib3270j.telnet.TelnetConnection;
import haus.nightmare.lib3270j.telnet.TelnetFSM;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Properties;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 11 Test Suite: Enterprise Connection Resilience & "Dirty" Connection Handling.
*
* Verifies:
* 11.1 Application-Layer Telnet Keep-Alive / Heartbeat Engine (NOP and TIMING-MARK, activity reset, shutdown).
* 11.2 Automatic Reconnection with Exponential Backoff (unexpected disconnect, state transitions, retry loop, recovery, exhaustion).
* 11.3 Transport-Level Socket Tuning & Options (soTimeout, extended socket options, CLI parsing).
* 11.4 IBM HoD ECL Compatibility Facade (SESSION_KEEPALIVE, keepAliveTimeout, SESSION_AUTORECONNECT, ECLConnection methods).
*/
public class Phase11ConnectionResilienceTest {
// =========================================================================
// 11.1 Application-Layer Telnet Keep-Alive / Heartbeat Engine
// =========================================================================
@Test
@DisplayName("11.1: Keep-Alive transmits Telnet IAC NOP (0xFF 0xF1) when idle")
public void testKeepAliveTransmitsIacNop() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setKeepAliveEnabled(true);
config.setKeepAliveIntervalSeconds(1);
config.setKeepAliveType("NOP");
Telnet3270Client client = new Telnet3270Client(config);
CountDownLatch clientConnected = new CountDownLatch(1);
AtomicReference<Socket> serverAccepted = new AtomicReference<>();
Thread serverThread = new Thread(() -> {
try {
Socket s = server.accept();
serverAccepted.set(s);
clientConnected.countDown();
} catch (Exception ignored) {}
});
serverThread.start();
client.connect();
assertTrue(clientConnected.await(5, TimeUnit.SECONDS), "Server did not accept client connection");
Socket s = serverAccepted.get();
assertNotNull(s);
InputStream in = s.getInputStream();
// Explicitly trigger or wait for keepalive heartbeat
client.getConnection().sendKeepAliveHeartbeat();
byte[] buf = new byte[2];
int read = in.read(buf);
assertEquals(2, read);
assertEquals((byte) TelnetConstants.IAC, buf[0]);
assertEquals((byte) TelnetConstants.NOP, buf[1]);
client.disconnect();
}
}
@Test
@DisplayName("11.1: Keep-Alive transmits Telnet IAC DO TIMING-MARK (0xFF 0xFD 0x06)")
public void testKeepAliveTransmitsTimingMark() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setKeepAliveEnabled(true);
config.setKeepAliveIntervalSeconds(1);
config.setKeepAliveType("TIMING-MARK");
Telnet3270Client client = new Telnet3270Client(config);
CountDownLatch clientConnected = new CountDownLatch(1);
AtomicReference<Socket> serverAccepted = new AtomicReference<>();
Thread serverThread = new Thread(() -> {
try {
Socket s = server.accept();
serverAccepted.set(s);
clientConnected.countDown();
} catch (Exception ignored) {}
});
serverThread.start();
client.connect();
assertTrue(clientConnected.await(5, TimeUnit.SECONDS));
Socket s = serverAccepted.get();
assertNotNull(s);
InputStream in = s.getInputStream();
client.getConnection().sendKeepAliveHeartbeat();
byte[] buf = new byte[3];
int read = in.read(buf);
assertEquals(3, read);
assertEquals((byte) TelnetConstants.IAC, buf[0]);
assertEquals((byte) TelnetConstants.DO, buf[1]);
assertEquals((byte) TelnetConstants.TELOPT_TM, buf[2]);
client.disconnect();
}
}
@Test
@DisplayName("11.1: Transmission resets last activity timestamp")
public void testTransmissionResetsActivityTimestamp() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
Telnet3270Client client = new Telnet3270Client(config);
new Thread(() -> {
try {
server.accept();
} catch (Exception ignored) {}
}).start();
client.connect();
TelnetConnection conn = client.getConnection();
long initialTime = conn.getLastActivityTime();
Thread.sleep(15);
conn.sendRaw(new byte[] { 0x01, 0x02 });
long updatedTime = conn.getLastActivityTime();
assertTrue(updatedTime >= initialTime + 10, "Activity timestamp was not updated on sendRaw");
client.disconnect();
}
}
// =========================================================================
// 11.2 Automatic Reconnection with Exponential Backoff
// =========================================================================
@Test
@DisplayName("11.2: Unexpected EOF transitions to RECONNECTING state when autoReconnect is true")
public void testUnexpectedDisconnectTransitionsToReconnecting() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setAutoReconnect(true);
config.setReconnectMaxRetries(3);
Telnet3270Client client = new Telnet3270Client(config);
CopyOnWriteArrayList<ConnectionState> stateHistory = new CopyOnWriteArrayList<>();
CountDownLatch reconnectingLatch = new CountDownLatch(1);
client.addConnectionListener(new ConnectionListener() {
@Override
public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) {
stateHistory.add(newState);
if (newState == ConnectionState.RECONNECTING) {
reconnectingLatch.countDown();
}
}
@Override public void onConnectionError(String message) {}
});
AtomicReference<Socket> acceptedSocket = new AtomicReference<>();
Thread serverThread = new Thread(() -> {
try {
Socket s = server.accept();
acceptedSocket.set(s);
} catch (Exception ignored) {}
});
serverThread.start();
client.connect();
serverThread.join(2000);
assertNotNull(acceptedSocket.get());
// Abruptly sever the connection from the host side (EOF)
acceptedSocket.get().close();
assertTrue(reconnectingLatch.await(4, TimeUnit.SECONDS), "Client did not enter RECONNECTING state on unexpected EOF");
assertTrue(stateHistory.contains(ConnectionState.RECONNECTING), "State history must include RECONNECTING");
assertTrue(client.isReconnecting());
// Check OIA message updated
assertNotNull(client.getOIA());
client.disconnect();
assertFalse(client.isReconnecting());
assertEquals(ConnectionState.NOT_CONNECTED, client.getConnectionState());
}
}
@Test
@DisplayName("11.2: Intentional client.disconnect() transitions directly to NOT_CONNECTED without auto-reconnect")
public void testIntentionalDisconnectDoesNotTriggerAutoReconnect() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setAutoReconnect(true);
config.setReconnectMaxRetries(3);
Telnet3270Client client = new Telnet3270Client(config);
CopyOnWriteArrayList<ConnectionState> stateHistory = new CopyOnWriteArrayList<>();
client.addConnectionListener(new ConnectionListener() {
@Override
public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) {
stateHistory.add(newState);
}
@Override public void onConnectionError(String message) {}
});
new Thread(() -> {
try {
server.accept();
} catch (Exception ignored) {}
}).start();
client.connect();
Thread.sleep(100);
// Explicit client disconnect
client.disconnect();
assertFalse(stateHistory.contains(ConnectionState.RECONNECTING),
"Intentional disconnect should never transition to RECONNECTING");
assertEquals(ConnectionState.NOT_CONNECTED, client.getConnectionState());
}
}
@Test
@DisplayName("11.2: Auto-reconnect retry loop successfully re-establishes connection")
public void testAutoReconnectSuccessfulReconnection() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setAutoReconnect(true);
config.setReconnectMaxRetries(3);
Telnet3270Client client = new Telnet3270Client(config);
CountDownLatch reconnectedLatch = new CountDownLatch(1);
AtomicReference<Socket> firstConn = new AtomicReference<>();
AtomicReference<Socket> secondConn = new AtomicReference<>();
Thread serverThread = new Thread(() -> {
try {
Socket s1 = server.accept();
firstConn.set(s1);
// Close first socket to trigger reconnect
Thread.sleep(50);
s1.close();
// Accept the reconnection attempt
Socket s2 = server.accept();
secondConn.set(s2);
reconnectedLatch.countDown();
} catch (Exception ignored) {}
});
serverThread.start();
client.connect();
// Wait for reconnection to succeed
assertTrue(reconnectedLatch.await(8, TimeUnit.SECONDS), "Server did not receive reconnection attempt");
assertNotNull(secondConn.get());
// Wait for client to complete handshake / notify connected
Thread.sleep(200);
assertTrue(client.isConnected());
client.disconnect();
}
}
// =========================================================================
// 11.3 Transport-Level Socket Tuning & Options
// =========================================================================
@Test
@DisplayName("11.3: Configurable SO_TIMEOUT is applied to TCP socket")
public void testSoTimeoutApplied() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setSoTimeoutMs(750);
Telnet3270Client client = new Telnet3270Client(config);
new Thread(() -> {
try { server.accept(); } catch (Exception ignored) {}
}).start();
client.connect();
assertNotNull(client.getConnection());
// Client socket should have SO_TIMEOUT applied
assertEquals(750, config.getSoTimeoutMs());
client.disconnect();
}
}
@Test
@DisplayName("11.3: parseHostString parses keepalive and autoreconnect flags")
public void testParseHostStringFlags() {
ConnectionConfig c1 = ConnectionConfig.parseHostString("mainframe.corp.com:23 --keepalive --autoreconnect", 23, TerminalModel.IBM_3279_4);
assertTrue(c1.isKeepAliveEnabled());
assertTrue(c1.isAutoReconnect());
assertEquals("mainframe.corp.com", c1.getHost());
assertEquals(23, c1.getPort());
ConnectionConfig c2 = ConnectionConfig.parseHostString("mainframe.corp.com:23 --no-keepalive --no-autoreconnect", 23, TerminalModel.IBM_3279_4);
assertFalse(c2.isKeepAliveEnabled());
assertFalse(c2.isAutoReconnect());
}
// =========================================================================
// 11.4 IBM HoD ECL Compatibility Facade
// =========================================================================
@Test
@DisplayName("11.4: ECLSession parses HoD properties for Keep-Alive and Auto-Reconnect")
public void testEclSessionPropertiesParsing() {
Properties props = new Properties();
props.setProperty(ECLSession.SESSION_HOST, "mvs01.test.net");
props.setProperty(ECLSession.SESSION_PORT, "23");
props.setProperty(ECLSession.SESSION_KEEPALIVE, "true");
props.setProperty(ECLSession.KEY_KEEPALIVE_TIMEOUT, "180");
props.setProperty(ECLSession.KEY_KEEPALIVE_TYPE, "TIMING-MARK");
props.setProperty(ECLSession.SESSION_AUTORECONNECT, "true");
props.setProperty(ECLSession.SESSION_RECONNECT_RETRIES, "8");
ECLSession session = new ECLSession(props);
assertTrue(session.isKeepAlive());
assertEquals(180, session.getKeepAliveTimeout());
assertTrue(session.isAutoReconnect());
ECLConnection conn = session.getConnection();
assertNotNull(conn);
assertTrue(conn.isKeepAlive());
assertEquals(180, conn.getKeepAliveTimeout());
assertEquals("TIMING-MARK", conn.getKeepAliveType());
assertTrue(conn.isAutoReconnect());
assertEquals(8, conn.getMaxRetry());
}
@Test
@DisplayName("11.4: ECLConnection getters and setters synchronize with underlying client")
public void testEclConnectionGettersSetters() {
ConnectionConfig cfg = new ConnectionConfig("localhost", 23);
Telnet3270Client client = new Telnet3270Client(cfg);
ECLSession session = new ECLSession(client);
ECLConnection conn = session.getConnection();
conn.setKeepAlive(false);
assertFalse(conn.isKeepAlive());
assertFalse(client.getConfig().isKeepAliveEnabled());
conn.setKeepAliveTimeout(60);
assertEquals(60, conn.getKeepAliveTimeout());
assertEquals(60, client.getConfig().getKeepAliveIntervalSeconds());
conn.setKeepAliveType("TIMING-MARK");
assertEquals("TIMING-MARK", conn.getKeepAliveType());
assertEquals("TIMING-MARK", client.getConfig().getKeepAliveType());
conn.setAutoReconnect(true);
assertTrue(conn.isAutoReconnect());
assertTrue(client.getConfig().isAutoReconnect());
conn.setMaxRetry(10);
assertEquals(10, conn.getMaxRetry());
assertEquals(10, client.getConfig().getReconnectMaxRetries());
}
}
@@ -0,0 +1,215 @@
package haus.nightmare.lib3270j.ecl;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.protocol.DS3270Constants;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicInteger;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import static org.junit.jupiter.api.Assertions.*;
public class EntryAssistFullModeTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private InputProcessor inputProcessor;
private ECLOIA oia;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3279_2, translator); // 24x80
inputProcessor = new InputProcessor(screen, translator, null);
oia = new ECLOIA(screen, inputProcessor, null);
inputProcessor.setOIA(oia);
}
@Test
public void testDocModeCursorAdvanceWithoutWordWrap() {
// Document Mode ON, Word Wrap OFF
screen.setEntryAssistDOCmode(true);
screen.setEntryAssistWordWrap(false);
screen.setLeftMargin(0); // Col 1 (0-based 0)
screen.setRightMargin(72); // Col 73 (0-based 72)
assertTrue(screen.isEntryAssistDOCmode());
assertFalse(screen.isEntryAssistWordWrap());
assertEquals(0, screen.getEntryAssistStartColumn());
assertEquals(72, screen.getEntryAssistEndColumn());
// Setup open cursor position
screen.setCursorPosition(0, 72); // Row 0, Col 72 (at right margin)
inputProcessor.typeCharacter('X');
// Should advance to next row at left margin (Row 1, Col 0)
assertEquals(1, screen.getCursorRow());
assertEquals(0, screen.getCursorCol());
assertEquals('X', (char) screen.getCell(72).ucs4);
}
@Test
public void testWordWrapTransfersPartialWord() {
// Document Mode ON, Word Wrap ON
screen.setEntryAssistDOCmode(true);
screen.setEntryAssistWordWrap(true);
screen.setLeftMargin(5); // Start col = 5
screen.setRightMargin(20); // End col = 20
// Type "HELLO " starting at col 10
screen.setCursorPosition(0, 10);
for (char c : "HELLO ".toCharArray()) {
inputProcessor.typeCharacter(c);
}
// Now cursor is at col 16. Type "TEST" which crosses col 20
screen.setCursorPosition(0, 18);
inputProcessor.typeCharacter('W');
inputProcessor.typeCharacter('O');
inputProcessor.typeCharacter('R');
inputProcessor.typeCharacter('D'); // At col 21, past end col 20
// Word wrap should have moved "WORD" to next line starting at left margin 5
assertEquals(1, screen.getCursorRow());
assertTrue(screen.getCursorCol() >= 5);
}
@Test
public void testDocModeTabStopsNavigation() {
screen.setEntryAssistDOCmode(true);
screen.setLeftMargin(0);
screen.setRightMargin(79);
screen.setEntryAssistTabStops(new int[]{0, 10, 20, 30});
screen.setCursorPosition(0, 0);
// Word tab forward
screen.processWordTab(true);
assertEquals(10, screen.getCursorCol());
assertEquals(0, screen.getCursorRow());
screen.processWordTab(true);
assertEquals(20, screen.getCursorCol());
// Word back tab
screen.processWordTab(false);
assertEquals(10, screen.getCursorCol());
screen.processWordTab(false);
assertEquals(0, screen.getCursorCol());
}
@Test
public void testAudibleEndOfLineBell() {
inputProcessor.setBellEnabled(true);
inputProcessor.setBellColumn(74);
AtomicInteger bellCount = new AtomicInteger(0);
inputProcessor.setBellListener(bellCount::incrementAndGet);
screen.setCursorPosition(0, 70);
inputProcessor.typeCharacter('A'); // 71
inputProcessor.typeCharacter('B'); // 72
inputProcessor.typeCharacter('C'); // 73
assertEquals(0, bellCount.get());
inputProcessor.typeCharacter('D'); // 74 -> triggers bell
assertEquals(1, bellCount.get());
inputProcessor.typeCharacter('E'); // 75 -> does not retrigger on same line
assertEquals(1, bellCount.get());
// Move to next line at col 73 and type across bell column
screen.setCursorPosition(1, 73);
inputProcessor.typeCharacter('Z'); // advances to 74 -> triggers bell on next line
assertEquals(2, bellCount.get());
}
@Test
public void testInsertModeResetOnAid() {
inputProcessor.setInsertOffOnAid(true);
inputProcessor.setInsertMode(true);
assertTrue(inputProcessor.isInsertMode());
// Send AID_ENTER
inputProcessor.sendAid(AID_ENTER);
assertFalse(inputProcessor.isInsertMode(), "Insert mode should be reset after AID key");
// When insertOffOnAid is disabled
inputProcessor.setInsertOffOnAid(false);
inputProcessor.setInsertMode(true);
inputProcessor.sendAid(AID_ENTER);
assertTrue(inputProcessor.isInsertMode(), "Insert mode should be preserved when insertOffOnAid is false");
}
@Test
public void testAplKeyboardModeAndOia() {
inputProcessor.setAplKeyboardMode(true);
assertTrue(inputProcessor.isAplKeyboardMode());
// OIA status flag includes STATE_APL
long statusEx = oia.GetStatusFlagsEx();
assertEquals(ECLOIA.STATE_APL, statusEx & ECLOIA.STATE_APL);
assertTrue(oia.isApl());
// Test APL translation for key 'a' (alpha -> 0x41 with CS_GE)
screen.setCursorPosition(0, 0);
inputProcessor.typeCharacter('a');
var cell = screen.getCell(0);
assertEquals(CS_GE, cell.cs, "APL character must be stored with CS_GE (Graphic Escape) charset");
// Toggle APL mode off
inputProcessor.toggleAplKeyboardMode();
assertFalse(inputProcessor.isAplKeyboardMode());
assertFalse(oia.isApl());
assertEquals(0, oia.GetStatusFlagsEx() & ECLOIA.STATE_APL);
}
@Test
public void testNumericFieldLockSetting() {
// Field: pos 0 is numeric FA, pos 1..5 unprotected, pos 6 protect FA
screen.setFieldAttribute(0, (byte) FA_NUMERIC);
screen.setFieldAttribute(6, (byte) FA_PROTECT);
screen.setCursorAddress(1);
// Case 1: NumericFieldLock is true
inputProcessor.setNumericFieldLock(true);
inputProcessor.typeCharacter('X'); // invalid numeric character
assertTrue(inputProcessor.isKeyboardLocked(), "Keyboard should be locked on non-numeric char");
assertEquals(ECLConstants.INHIBIT_NUMERIC_ONLY, oia.getInputInhibited());
// Reset keyboard
inputProcessor.reset();
assertFalse(inputProcessor.isKeyboardLocked());
// Case 2: NumericFieldLock is false
inputProcessor.setNumericFieldLock(false);
screen.setCursorAddress(1);
inputProcessor.typeCharacter('X');
assertFalse(inputProcessor.isKeyboardLocked(), "Keyboard should NOT be locked when NumericFieldLock is false");
}
@Test
public void testOiaModeIndicators() {
screen.setEntryAssistDOCmode(true);
screen.setEntryAssistWordWrap(true);
inputProcessor.setAplKeyboardMode(true);
inputProcessor.setInsertMode(true);
long flags = oia.GetStatusFlagsEx();
assertTrue((flags & ECLOIA.STATE_DOC_MODE) != 0);
assertTrue((flags & ECLOIA.STATE_WORDWRAP) != 0);
assertTrue((flags & ECLOIA.STATE_APL) != 0);
assertTrue((flags & ECLOIA.STATE_INSERT) != 0);
assertTrue(oia.isDocMode());
assertTrue(oia.isWordWrap());
assertTrue(oia.isApl());
assertTrue(oia.isInsertMode());
}
}
@@ -0,0 +1,89 @@
package haus.nightmare.lib3270j.ecl;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for Phase 13.2: Interactive Entry Assist Engine.
* Verifies interactive word wrap in DOC mode and audible bell alert
* when typing reaches the configured bell column.
*/
public class InteractiveEntryAssistTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private InputProcessor input;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3278_2, translator);
input = new InputProcessor(screen, translator, null);
}
@Test
public void testAudibleBellTriggersAtConfiguredBellColumn() {
input.setBellEnabled(true);
input.setBellColumn(74); // 0-based column 74 = column 75
AtomicInteger bellCount = new AtomicInteger(0);
input.setBellListener(bellCount::incrementAndGet);
screen.setCursorPosition(0, 72);
input.typeCharacter('A'); // col 73
assertEquals(0, bellCount.get());
input.typeCharacter('B'); // col 74 -> triggers bell
assertEquals(1, bellCount.get());
input.typeCharacter('C'); // col 75 -> remains 1 on current row
assertEquals(1, bellCount.get());
// Moving to next row and typing across bell column triggers bell again
screen.setCursorPosition(1, 73);
input.typeCharacter('X'); // advances to col 74 on row 1
assertEquals(2, bellCount.get());
}
@Test
public void testDocModeWordWrapMovesPartialWordToNextRow() {
screen.setEntryAssistDOCmode(true);
screen.setEntryAssistWordWrap(true);
screen.setLeftMargin(0); // Col 0
screen.setRightMargin(15); // Col 15
assertTrue(screen.isEntryAssistDOCmode());
assertTrue(screen.isEntryAssistWordWrap());
// Type "HELLO " (6 chars) starting at col 0
screen.setCursorPosition(0, 0);
for (char c : "HELLO ".toCharArray()) {
input.typeCharacter(c);
}
assertEquals(0, screen.getCursorRow());
assertEquals(6, screen.getCursorCol());
// Move cursor near right margin: Row 0, Col 13
screen.setCursorPosition(0, 13);
input.typeCharacter('P'); // col 14
input.typeCharacter('A'); // col 15 (at right margin)
input.typeCharacter('R'); // crosses right margin -> triggers word wrap
// After word wrap, the word "PAR" should be moved to Row 1, left margin (Col 0)
assertEquals(1, screen.getCursorRow());
assertTrue(screen.getCursorCol() >= 3);
// Verify content on Row 1
assertEquals('P', (char) screen.getCell(80).ucs4);
assertEquals('A', (char) screen.getCell(81).ucs4);
assertEquals('R', (char) screen.getCell(82).ucs4);
}
}
@@ -0,0 +1,319 @@
package haus.nightmare.lib3270j.ecl;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 12 Test Suite: Reactive Synchronization & Thread-Safety Hardening.
*
* Verifies:
* 12.1 Condition-Based Synchronization for ECL Automation Calls:
* - Sub-millisecond reactive wakeup for waitForScreen(ECLScreenDesc).
* - Reactive wakeup for waitForCursor(row, col).
* - Reactive wakeup for waitForString(text) and waitWhileScreen(ECLScreenDesc).
* - Clean timeout handling without CPU busy-wait spinning.
* - Graceful thread interruption handling.
* - ECLOIA condition-based reactive waitForInput and waitForTransition.
* - ScreenBuffer listener dispatch and cursor change tracking.
* - Concurrency safety with multiple waiters.
*/
public class Phase12SynchronizationTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private InputProcessor inputProcessor;
private ECLPS ps;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator);
inputProcessor = new InputProcessor(screen, translator, null);
ps = new ECLPS(screen, inputProcessor, translator);
}
@Test
@DisplayName("12.1: waitForScreen(ECLScreenDesc) wakes up reactively upon screen update")
public void testReactiveWaitForScreenDesc() throws Exception {
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("TSO/E LOGON", 5, 10);
AtomicBoolean result = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean matched = ps.waitForScreen(desc, 5000);
durationMs.set(System.currentTimeMillis() - t0);
result.set(matched);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50); // Give waiter time to block on condition
// Write matching text at 1-based (5, 10) -> 0-based row 4 col 9
screen.setText("TSO/E LOGON", 4, 9);
screen.notifyScreenUpdate();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(result.get(), "waitForScreen should match");
assertTrue(durationMs.get() < 1500, "Should unblock reactively well before 5000ms timeout (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: waitForCursor(row, col) wakes up reactively upon cursor position change")
public void testReactiveWaitForCursor() throws Exception {
screen.setCursorPosition(0, 0);
AtomicBoolean result = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean matched = ps.waitForCursor(12, 34, 5000);
durationMs.set(System.currentTimeMillis() - t0);
result.set(matched);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
// Move cursor
screen.setCursorPosition(12, 34);
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(result.get(), "waitForCursor should succeed");
assertTrue(durationMs.get() < 1500, "Should unblock reactively upon cursor move (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: waitForString(text) wakes up reactively upon string appearance")
public void testReactiveWaitForString() throws Exception {
AtomicBoolean result = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean matched = ps.waitForString("COMMAND ===>", 5000);
durationMs.set(System.currentTimeMillis() - t0);
result.set(matched);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
screen.setText("COMMAND ===>", 20, 2);
screen.notifyScreenUpdate();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(result.get(), "waitForString should find the text");
assertTrue(durationMs.get() < 1500, "Should unblock reactively (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: waitWhileScreen(desc) wakes up reactively when condition ceases to match")
public void testReactiveWaitWhileScreen() throws Exception {
// Place initial text
screen.setText("HOLDING", 0, 0);
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("HOLDING");
AtomicBoolean result = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean finished = ps.waitWhileScreen(desc, 5000);
durationMs.set(System.currentTimeMillis() - t0);
result.set(finished);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
// Erase screen so "HOLDING" is gone
screen.erase(false);
screen.notifyScreenUpdate();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(result.get(), "waitWhileScreen should return true once condition is gone");
assertTrue(durationMs.get() < 1500, "Should unblock reactively when text erased (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: Clean timeout without false match or spinning")
public void testCleanTimeout() {
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("NON_EXISTENT_STRING");
long t0 = System.currentTimeMillis();
boolean matched = ps.waitForScreen(desc, 150);
long elapsed = System.currentTimeMillis() - t0;
assertFalse(matched, "Should not match non-existent text");
assertTrue(elapsed >= 100, "Should have waited for the full timeout window (elapsed=" + elapsed + "ms)");
}
@Test
@DisplayName("12.1: Graceful thread interruption during wait")
public void testThreadInterruption() throws Exception {
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("NEVER_APPEARS");
AtomicBoolean wasInterrupted = new AtomicBoolean(false);
AtomicBoolean result = new AtomicBoolean(true);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
boolean r = ps.waitForScreen(desc, 10000);
result.set(r);
if (Thread.currentThread().isInterrupted()) {
wasInterrupted.set(true);
}
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
waiter.interrupt();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertFalse(result.get(), "Interrupted wait should return false");
assertTrue(wasInterrupted.get(), "Thread interrupt flag should be preserved");
}
@Test
@DisplayName("12.1: ECLOIA reactive waitForInput and waitForTransition")
public void testECLOIAReactiveWait() throws Exception {
ECLOIA oia = new ECLOIA(screen, inputProcessor, null);
oia.setInputInhibited(ECLOIA.INHIBIT_SYSTEMWAIT);
AtomicBoolean inputReady = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean ready = oia.waitForInput(5000);
durationMs.set(System.currentTimeMillis() - t0);
inputReady.set(ready);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
// Unlock OIA
oia.setInputInhibited(ECLOIA.INHIBIT_NOTINHIBITED);
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(inputReady.get(), "waitForInput should succeed");
assertTrue(durationMs.get() < 1500, "Should unblock reactively upon OIA unlock (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: ScreenBuffer listener dispatch and cursor movement tracking")
public void testScreenBufferListenerDispatch() {
AtomicBoolean screenUpdated = new AtomicBoolean(false);
AtomicInteger cursorOld = new AtomicInteger(-1);
AtomicInteger cursorNew = new AtomicInteger(-1);
ScreenUpdateListener listener = new ScreenUpdateListener() {
@Override
public void onScreenUpdated() {
screenUpdated.set(true);
}
@Override
public void onCursorMoved(int oldAddress, int newAddress) {
cursorOld.set(oldAddress);
cursorNew.set(newAddress);
}
};
screen.addUpdateListener(listener);
screen.notifyScreenUpdate();
assertTrue(screenUpdated.get(), "Listener should receive onScreenUpdated");
screen.setCursorAddress(123);
assertEquals(0, cursorOld.get(), "Old cursor should be 0");
assertEquals(123, cursorNew.get(), "New cursor should be 123");
screen.removeUpdateListener(listener);
screenUpdated.set(false);
screen.notifyScreenUpdate();
assertFalse(screenUpdated.get(), "Removed listener should not receive updates");
}
@Test
@DisplayName("12.1: Concurrent waiters on the same ScreenBuffer")
public void testConcurrentWaiters() throws Exception {
int numWaiters = 5;
CountDownLatch startLatch = new CountDownLatch(numWaiters);
CountDownLatch doneLatch = new CountDownLatch(numWaiters);
AtomicInteger successCount = new AtomicInteger(0);
for (int i = 0; i < numWaiters; i++) {
new Thread(() -> {
startLatch.countDown();
boolean ok = ps.waitForString("BATCH_SIGNAL", 5000);
if (ok) {
successCount.incrementAndGet();
}
doneLatch.countDown();
}).start();
}
assertTrue(startLatch.await(2, TimeUnit.SECONDS));
Thread.sleep(50);
screen.setText("BATCH_SIGNAL", 100);
screen.notifyScreenUpdate();
assertTrue(doneLatch.await(3, TimeUnit.SECONDS));
assertEquals(numWaiters, successCount.get(), "All concurrent waiters should unblock reactively");
}
}
@@ -10,9 +10,9 @@ import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import java.awt.Image;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import haus.nightmare.lib3270j.graphics.DefaultPixelBuffer;
import haus.nightmare.lib3270j.graphics.PixelBuffer;
import haus.nightmare.lib3270j.graphics.Rectangle;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@@ -406,11 +406,12 @@ public class Phase5EclEventTest {
}
// Test with image and rectangle
Image testImage = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
PixelBuffer testImage = new DefaultPixelBuffer(32, 32);
Rectangle rect = new Rectangle(0, 0, 32, 32);
ECLPSGraphicsEvent fullGEvent = new ECLPSGraphicsEvent(ps, ECLPSGraphicsEvent.GRAPHICS_UPDATED, testImage, rect);
assertSame(testImage, fullGEvent.GetImage());
assertSame(testImage, fullGEvent.getImage());
assertSame(testImage, fullGEvent.getPixelBuffer());
assertEquals(rect, fullGEvent.GetRectangle());
assertEquals(rect, fullGEvent.getRectangle());
@@ -0,0 +1,124 @@
package haus.nightmare.lib3270j.ecl;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for Phase 13.1: Excel & Tabular Clipboard Paste.
* Verifies tab-delimited text navigation, newline row advancement,
* and boundary truncation when pasteStopAtProtectedLine is set.
*/
public class TabularPasteTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private InputProcessor input;
private ECLPS ps;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3278_2, translator);
input = new InputProcessor(screen, translator, null);
ps = new ECLPS(screen, input, translator);
}
@Test
public void testExcelPasteTabsAndNewlinesAdvanceAcrossFields() {
// Setup 2 rows with 2 unprotected fields each
// Row 0: pos 0 (unprotected), pos 10 (protected), pos 11 (unprotected), pos 30 (protected)
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
screen.setFieldAttribute(10, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setFieldAttribute(11, (byte) FA_PRINTABLE);
screen.setFieldAttribute(30, (byte) (FA_PRINTABLE | FA_PROTECT));
// Row 1: pos 80 (unprotected), pos 90 (protected), pos 91 (unprotected), pos 110 (protected)
screen.setFieldAttribute(80, (byte) FA_PRINTABLE);
screen.setFieldAttribute(90, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setFieldAttribute(91, (byte) FA_PRINTABLE);
screen.setFieldAttribute(110, (byte) (FA_PRINTABLE | FA_PROTECT));
// Position cursor at first field (pos 1)
screen.setCursorAddress(1);
String tabularData = "ABC\tDEF\r\nGHI\tJKL";
int pasted = input.pasteText(tabularData, true, false);
assertEquals(12, pasted);
// Verify Field 1 (Row 0, Col 1-3)
assertEquals("ABC", ps.getString(1, 3));
// Verify Field 2 (Row 0, Col 12-14)
assertEquals("DEF", ps.getString(12, 3));
// Verify Field 3 (Row 1, Col 1-3 -> pos 81)
assertEquals("GHI", ps.getString(81, 3));
// Verify Field 4 (Row 1, Col 12-14 -> pos 92)
assertEquals("JKL", ps.getString(92, 3));
}
@Test
public void testPasteStopAtProtectedBoundaryHaltsPaste() {
// Field 1: pos 0 (unprotected), pos 5 (protected)
// Data area is pos 1..4 (4 characters)
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
screen.setFieldAttribute(5, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setFieldAttribute(10, (byte) FA_PRINTABLE);
screen.setCursorAddress(1);
// Try to paste 6 characters when only 4 fit
String overflow = "123456";
int pasted = input.pasteText(overflow, false, true);
// Should paste 4 chars and halt at the protected boundary pos 5
assertEquals(4, pasted);
assertEquals("1234", ps.getString(1, 4));
// Field at pos 10 should not be touched
assertEquals(" ", ps.getString(11, 1));
}
@Test
public void testPasteStopAtProtectedLineHaltsAtProtectedRow() {
// Row 0: unprotected field at pos 0
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
// Row 1: entirely protected starting at pos 80
screen.setFieldAttribute(80, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setCursorAddress(1);
String multiRow = "DATA1\r\nDATA2";
int pasted = input.pasteText(multiRow, true, true);
// "DATA1" (5 chars) pasted, then newline sees next line is protected and halts
assertEquals(5, pasted);
assertEquals("DATA1", ps.getString(1, 5));
}
@Test
public void testEclPsPasteFromExcelMethodsAndFlags() {
assertTrue(ps.isEnablePasteFromExcel());
assertFalse(ps.isPasteStopAtProtectedLine());
ps.setEnablePasteFromExcel(false);
assertFalse(ps.isEnablePasteFromExcel());
ps.setPasteStopAtProtectedLine(true);
assertTrue(ps.isPasteStopAtProtectedLine());
// Setup fields
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
screen.setFieldAttribute(10, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setFieldAttribute(11, (byte) FA_PRINTABLE);
screen.setFieldAttribute(30, (byte) (FA_PRINTABLE | FA_PROTECT));
ps.pasteFromExcel("HELLO\tWORLD", 0, 1);
assertEquals("HELLO", ps.getString(1, 5));
assertEquals("WORLD", ps.getString(12, 5));
}
}
@@ -0,0 +1,100 @@
package haus.nightmare.lib3270j.graphics;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* Architectural compliance audit test verifying 100% decoupling of lib3270j from java.awt.
* Ensures headless execution and portability to Android (which lacks java.awt.*).
*/
public class AwtDecouplingAuditTest {
private static final Pattern IMPORT_AWT_PATTERN = Pattern.compile("^\\s*import\\s+java\\.awt\\..*;");
private static final Pattern DIRECT_AWT_USAGE_PATTERN = Pattern.compile("(?<!Class\\.forName\\(\")java\\.awt\\.[A-Za-z0-9_]+");
@Test
@DisplayName("Audit lib3270j/src/main/java for zero java.awt.* imports or references")
public void testZeroAwtDependenciesInMainSource() throws IOException {
File srcMainDir = new File("src/main/java");
if (!srcMainDir.exists()) {
srcMainDir = new File("lib3270j/src/main/java");
}
assertTrue(srcMainDir.exists() && srcMainDir.isDirectory(),
"src/main/java directory must exist at " + srcMainDir.getAbsolutePath());
List<String> violations = new ArrayList<>();
try (Stream<Path> paths = Files.walk(srcMainDir.toPath())) {
List<Path> javaFiles = paths
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.collect(Collectors.toList());
assertFalse(javaFiles.isEmpty(), "Found no java files in " + srcMainDir);
for (Path p : javaFiles) {
List<String> lines = Files.readAllLines(p);
for (int lineNum = 0; lineNum < lines.size(); lineNum++) {
String line = lines.get(lineNum);
String trimmed = line.trim();
// Ignore comment lines
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
continue;
}
if (IMPORT_AWT_PATTERN.matcher(line).find()) {
violations.add(String.format("%s:%d -> %s", p.getFileName(), lineNum + 1, trimmed));
} else if (line.contains("java.awt.") && !line.contains("Class.forName(\"java.awt.") && !line.contains("\"java.awt.")) {
violations.add(String.format("%s:%d [raw type reference] -> %s", p.getFileName(), lineNum + 1, trimmed));
}
}
}
}
if (!violations.isEmpty()) {
fail("Found java.awt dependencies in lib3270j main source:\n" + String.join("\n", violations));
}
}
@Test
@DisplayName("Verify PixelBuffer and software rasterizer functions without AWT")
public void testPixelBufferPureJavaOperations() {
PixelBuffer pb = new DefaultPixelBuffer(100, 100);
assertEquals(100, pb.getWidth());
assertEquals(100, pb.getHeight());
// Fill rect
pb.fillRect(10, 10, 20, 20, 0xFFFF0000);
assertEquals(0xFFFF0000, pb.getPixel(15, 15));
assertEquals(0, pb.getPixel(5, 5));
// Draw line AA
pb.drawLineAA(0, 0, 99, 99, 0xFF00FF00, 1.0);
int diagPixel = pb.getPixel(50, 50);
assertNotEquals(0, diagPixel, "Anti-aliased diagonal line should render pixels");
// Clipping
pb.setClip(20, 20, 10, 10);
assertTrue(pb.isClipped(2, 80));
assertFalse(pb.isClipped(25, 25));
assertEquals(0, pb.getPixel(2, 80));
pb.setPixel(2, 80, 0xFF0000FF);
assertEquals(0, pb.getPixel(2, 80), "Clipped pixel must not be modified");
pb.clearClip();
assertFalse(pb.isClipped(5, 5));
}
}
@@ -96,4 +96,143 @@ public class FillAreaTest {
assertTrue(redCount > 0, "Expected pattern foreground red pixels");
assertTrue(blueCount > 0, "Expected pattern background blue pixels under BMX_OPAQUE");
}
@Test
public void testSolidBlackFillUnbounded() {
GraphicsPlane plane = new GraphicsPlane(100, 100);
FillArea area = new FillArea();
// 40x40 box at (20,20) to (60,60)
area.addPolygon(new int[] { 20, 60, 60, 20 }, new int[] { 20, 20, 60, 60 }, 4);
int black = 0xFF000000;
// Unbounded solid black fill (e.g. ADMOPS central slide canvas or GDDM menu erasure box)
area.fill(plane, black, 0, GocaConstants.PT_SOLID, false, 0,
GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL, GocaConstants.BMX_DEFAULT, 0, null);
assertTrue(plane.hasContent(), "Plane must have content after solid black fill");
int blackCount = 0;
for (int y = 25; y <= 55; y++) {
for (int x = 25; x <= 55; x++) {
if (plane.getRgbBuffer()[y * 100 + x] == black) {
blackCount++;
}
}
}
assertTrue(blackCount > 800, "Solid black fill must rasterize opaque black pixels (0xFF000000)");
}
@Test
public void testAdmopslaIntroScreenWindowTransparency() {
GraphicsPlane plane = new GraphicsPlane(100, 100);
FillArea area = new FillArea();
// Window box at (20,20) to (60,60)
area.addPolygon(new int[] { 20, 60, 60, 20 }, new int[] { 20, 20, 60, 60 }, 4);
int black = 0xFF000000;
int greenBorder = GocaConstants.GOCA_COLORS[0];
// ADMOPSLA intro screen: bounded area (drawBoundary=true) with default pattern 0 (PT_DEFAULT=0)
area.fill(plane, black, 0, 0, true, greenBorder,
GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL, GocaConstants.BMX_DEFAULT, 0, null);
// Solid black fill: interior pixels must be black (0xFF000000)
int blackCount = 0;
for (int y = 25; y <= 55; y++) {
for (int x = 25; x <= 55; x++) {
if (plane.getRgbBuffer()[y * 100 + x] == black) {
blackCount++;
}
}
}
assertEquals((55 - 25 + 1) * (55 - 25 + 1), blackCount,
"Solid black fill interior must be black (0xFF000000)");
// But boundary outline must be drawn!
boolean hasBoundary = false;
for (int x = 20; x <= 60; x++) {
if (plane.getRgbBuffer()[20 * 100 + x] == greenBorder) {
hasBoundary = true;
break;
}
}
assertTrue(hasBoundary, "Window boundary outline must be drawn");
}
@Test
public void testForegroundMixModes() {
GraphicsPlane plane = new GraphicsPlane(10, 10);
int red = 0xFFFF0000;
int green = 0xFF00FF00;
// 1. MIX_LEAVE (3): do not draw
plane.setMixMode(GocaConstants.MIX_LEAVE);
plane.setPixel(5, 5, red);
assertEquals(0, plane.getRgbBuffer()[55], "MIX_LEAVE must not paint destination pixel");
// 2. MIX_OVER (2 / default): draw over
plane.setMixMode(GocaConstants.MIX_OVER);
plane.setPixel(5, 5, red);
// 3. MIX_UNDER (5): underpaint (do not overwrite non-zero destination)
plane.setMixMode(GocaConstants.MIX_UNDER);
plane.setPixel(5, 5, green);
assertEquals(red, plane.getRgbBuffer()[55], "MIX_UNDER must not overwrite existing pixel");
plane.setPixel(6, 6, green);
assertEquals(green, plane.getRgbBuffer()[66], "MIX_UNDER must paint empty pixel");
// 4. MIX_OR (1): In IBM HoD, MIX_OR is applied in area fills >= 100px, while standard operations run in Paint Mode
plane.setMixMode(GocaConstants.MIX_OR);
FillArea areaOr = new FillArea();
areaOr.addPolygon(new int[] { 0, 10, 10, 0 }, new int[] { 0, 0, 10, 10 }, 4);
int black = 0xFF000000;
plane.setPixel(5, 5, red);
areaOr.fill(plane, black, 0, GocaConstants.PT_SOLID, false, 0, 0, 0, 0, 0, null);
assertEquals(red, plane.getRgbBuffer()[55], "MIX_OR with Black must not alter existing pixel");
// Red on Green produces Yellow (0xFFFF00)
plane.setPixel(6, 6, green);
areaOr.fill(plane, red, 0, GocaConstants.PT_SOLID, false, 0, 0, 0, 0, 0, null);
int yellow = 0xFFFFFF00;
assertEquals(yellow, plane.getRgbBuffer()[66], "MIX_OR with Red over Green must produce Yellow");
}
@Test
public void testAdmopslaIntroScreenMixOrSolidBlackPatternTransparency() {
GraphicsPlane plane = new GraphicsPlane(100, 100);
FillArea area = new FillArea();
// Window box at (20,20) to (60,60)
area.addPolygon(new int[] { 20, 60, 60, 20 }, new int[] { 20, 20, 60, 60 }, 4);
// Pre-paint a blue background
int blue = 0xFF7890F0;
for (int y = 20; y <= 60; y++) {
for (int x = 20; x <= 60; x++) {
plane.setPixel(x, y, blue);
}
}
// Real ADMOPSLA sequence: MIX_OR (1), pattern 16 (PT_SOLID), fillColor Black, boundary White
plane.setMixMode(GocaConstants.MIX_OR);
int black = 0xFF000000;
int whiteBorder = GocaConstants.GOCA_COLORS[7]; // White
area.fill(plane, black, 0, GocaConstants.PT_SOLID, true, whiteBorder,
GocaConstants.LT_DOT, GocaConstants.LW_NORMAL, GocaConstants.BMX_DEFAULT, 0, null);
// Interior blue pixels must remain intact under MIX_OR with Black!
for (int y = 25; y <= 55; y++) {
for (int x = 25; x <= 55; x++) {
assertEquals(blue, plane.getRgbBuffer()[y * 100 + x],
"Interior blue pixels must be preserved under MIX_OR with black fill");
}
}
// Boundary outline must be drawn
boolean hasWhite = false;
for (int x = 20; x <= 60; x++) {
if (plane.getRgbBuffer()[20 * 100 + x] == whiteBorder) {
hasWhite = true;
break;
}
}
assertTrue(hasWhite, "White border outline must be drawn under MIX_OR");
}
}
@@ -1,7 +1,6 @@
package haus.nightmare.lib3270j.graphics;
import org.junit.jupiter.api.Test;
import java.awt.Point;
import static org.junit.jupiter.api.Assertions.*;
@@ -2,7 +2,6 @@ package haus.nightmare.lib3270j.graphics;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.awt.Point;
import static org.junit.jupiter.api.Assertions.*;
@@ -360,7 +360,7 @@ public class GocaDecoderTest {
assertTrue(plane.hasContent());
int[] buffer = plane.getRgbBuffer();
boolean hasIntermediateAlpha = false;
boolean allCrispOpaque = true;
int nonZeroPixels = 0;
for (int y = 0; y < 100; y++) {
@@ -371,15 +371,15 @@ public class GocaDecoderTest {
int alpha = (pixel >>> 24) & 0xFF;
int r = (pixel >>> 16) & 0xFF;
assertEquals(255, r, "Red channel must be preserved");
if (alpha > 0 && alpha < 255) {
hasIntermediateAlpha = true;
if (alpha != 255) {
allCrispOpaque = false;
}
}
}
}
assertTrue(nonZeroPixels > 30, "Expected non-zero pixels along the line");
assertTrue(hasIntermediateAlpha, "Expected Xiaolin Wu anti-aliasing to produce fractional alpha coverage");
assertTrue(allCrispOpaque, "Expected crisp integer Bresenham line rendering matching IBM HoD");
}
@Test
@@ -392,21 +392,21 @@ public class GocaDecoderTest {
assertTrue(plane.hasContent());
int[] buffer = plane.getRgbBuffer();
boolean hasIntermediateAlpha = false;
boolean allCrispOpaque = true;
int nonZeroPixels = 0;
for (int p : buffer) {
if (p != 0) {
nonZeroPixels++;
int alpha = (p >>> 24) & 0xFF;
if (alpha > 0 && alpha < 255) {
hasIntermediateAlpha = true;
if (alpha != 255) {
allCrispOpaque = false;
}
}
}
assertTrue(nonZeroPixels > 50, "Expected arc pixels");
assertTrue(hasIntermediateAlpha, "Expected anti-aliased arc edges with smooth alpha");
assertTrue(allCrispOpaque, "Expected crisp arc outline matching IBM HoD");
}
@Test
@@ -419,21 +419,21 @@ public class GocaDecoderTest {
assertTrue(plane.hasContent());
int[] buffer = plane.getRgbBuffer();
boolean hasIntermediateAlpha = false;
boolean allCrispOpaque = true;
int nonZeroPixels = 0;
for (int p : buffer) {
if (p != 0) {
nonZeroPixels++;
int alpha = (p >>> 24) & 0xFF;
if (alpha > 0 && alpha < 255) {
hasIntermediateAlpha = true;
if (alpha != 255) {
allCrispOpaque = false;
}
}
}
assertTrue(nonZeroPixels > 40, "Expected stroked vector text pixels");
assertTrue(hasIntermediateAlpha, "Expected anti-aliased vector text strokes with fractional alpha");
assertTrue(allCrispOpaque, "Expected crisp stroked vector text matching IBM HoD");
}
@Test
@@ -748,10 +748,11 @@ public class GocaDecoderTest {
out.write(0x00); out.write(20); out.write(0x00); out.write(40);
out.write(GocaConstants.G_GEAR);
// 4. Draw White slide boundary frame (Color set to 8/Black before GBAR, Pattern 15/Empty, GSCOL White inside GBAR)
// 4. Draw White slide boundary frame (Color set to 8/Black before GBAR, GSMX MIX_OR, Pattern 16/Solid, GBAR 0x80, GSCOL White inside GBAR)
out.write(GocaConstants.G_GSCOL); out.write(0x08); // Black (background)
out.write(GocaConstants.G_GSPT); out.write(0x0F); // Empty pattern (transparent interior)
out.write(GocaConstants.G_GBAR); out.write(0x80);
out.write(GocaConstants.G_GSPT); out.write(0x10); // Solid pattern (16)
out.write(GocaConstants.G_GSMX); out.write(GocaConstants.MIX_OR); // Mix mode OR (1)
out.write(GocaConstants.G_GBAR); out.write(0x40); // Bounded (flags 0x40 per IBM Host On-Demand)
out.write(GocaConstants.G_GSCOL); out.write(0x07); // White line color
out.write(GocaConstants.G_GSLT); out.write(GocaConstants.LT_DOT); // Dotted line
out.write(GocaConstants.G_GLINE); out.write(0x14); // 5 points
@@ -814,4 +815,229 @@ public class GocaDecoderTest {
}
assertTrue(foundWhite, "Slide border outline must be drawn in White");
}
@Test
public void testGbarBoundaryDetection() {
GraphicsPlane plane = new GraphicsPlane(100, 100);
GocaDecoder decoder = new GocaDecoder(plane);
// 1. GBAR with 0x00 flag -> Unbounded (drawBoundary = false)
ByteArrayOutputStream out1 = new ByteArrayOutputStream();
out1.write(GocaConstants.G_GSCOL); out1.write(0x02); // Red fill
out1.write(GocaConstants.G_GBAR); out1.write(0x00); // Bit 0 clear -> Unbounded
out1.write(GocaConstants.G_GSCOL); out1.write(0x06); // Yellow boundary (if drawn)
out1.write(GocaConstants.G_GLINE); out1.write(0x14); // 5 points = 20 bytes
out1.write(0x00); out1.write(10); out1.write(0x00); out1.write(10);
out1.write(0x00); out1.write(40); out1.write(0x00); out1.write(10);
out1.write(0x00); out1.write(40); out1.write(0x00); out1.write(40);
out1.write(0x00); out1.write(10); out1.write(0x00); out1.write(40);
out1.write(0x00); out1.write(10); out1.write(0x00); out1.write(10);
out1.write(GocaConstants.G_GEAR);
byte[] stream1 = out1.toByteArray();
decoder.decodeStream(stream1, 0, stream1.length);
assertTrue(plane.hasContent(), "Area must be filled");
// Verify yellow boundary line was NOT drawn for 0x00
int yellowArgb = GocaConstants.GOCA_COLORS[6];
boolean foundYellow = false;
for (int p : plane.getRgbBuffer()) {
if (p == yellowArgb) {
foundYellow = true;
break;
}
}
assertFalse(foundYellow, "Area with flag 0x00 must NOT draw boundary strokes");
// 2. GBAR with 0x40 flag -> Bounded (bit 1 set per IBM HoD -> drawBoundary = true)
plane.clear();
ByteArrayOutputStream out2 = new ByteArrayOutputStream();
out2.write(GocaConstants.G_GSCOL); out2.write(0x02); // Red fill
out2.write(GocaConstants.G_GBAR); out2.write(0x40); // Bit 1 set -> Bounded
out2.write(GocaConstants.G_GSCOL); out2.write(0x06); // Yellow boundary
out2.write(GocaConstants.G_GLINE); out2.write(0x14);
out2.write(0x00); out2.write(10); out2.write(0x00); out2.write(10);
out2.write(0x00); out2.write(40); out2.write(0x00); out2.write(10);
out2.write(0x00); out2.write(40); out2.write(0x00); out2.write(40);
out2.write(0x00); out2.write(10); out2.write(0x00); out2.write(40);
out2.write(0x00); out2.write(10); out2.write(0x00); out2.write(10);
out2.write(GocaConstants.G_GEAR);
byte[] stream2 = out2.toByteArray();
decoder.decodeStream(stream2, 0, stream2.length);
foundYellow = false;
for (int p : plane.getRgbBuffer()) {
if (p == yellowArgb) {
foundYellow = true;
break;
}
}
assertTrue(foundYellow, "Area with flag 0x40 (bit 1 set) MUST draw boundary strokes");
// 3. GBAR with 0x80 flag -> Unbounded (bit 0 set only, bit 1 clear -> drawBoundary = false)
plane.clear();
ByteArrayOutputStream out3 = new ByteArrayOutputStream();
out3.write(GocaConstants.G_GSCOL); out3.write(0x02); // Red fill
out3.write(GocaConstants.G_GBAR); out3.write(0x80); // Bit 0 set only -> Unbounded
out3.write(GocaConstants.G_GSCOL); out3.write(0x06); // Yellow boundary (if drawn)
out3.write(GocaConstants.G_GLINE); out3.write(0x14);
out3.write(0x00); out3.write(10); out3.write(0x00); out3.write(10);
out3.write(0x00); out3.write(40); out3.write(0x00); out3.write(10);
out3.write(0x00); out3.write(40); out3.write(0x00); out3.write(40);
out3.write(0x00); out3.write(10); out3.write(0x00); out3.write(40);
out3.write(0x00); out3.write(10); out3.write(0x00); out3.write(10);
out3.write(GocaConstants.G_GEAR);
byte[] stream3 = out3.toByteArray();
decoder.decodeStream(stream3, 0, stream3.length);
foundYellow = false;
for (int p : plane.getRgbBuffer()) {
if (p == yellowArgb) {
foundYellow = true;
break;
}
}
assertFalse(foundYellow, "Area with flag 0x80 must NOT draw boundary strokes");
}
@Test
public void testGsflwStreamingOrderPreservation() {
GraphicsPlane plane = new GraphicsPlane(100, 100);
GocaDecoder decoder = new GocaDecoder(plane);
// Sequence: GSFLW (0x11, 0x01) followed by GLINE (0xC1, 0x08, 2 points)
// Must NOT consume 0xC1 as fractional byte!
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.G_GSCOL); out.write(0x03); // Pink
out.write(GocaConstants.G_GSFLW); out.write(0x01); // 2-byte GSFLW in stream
out.write(GocaConstants.G_GLINE); out.write(0x08); // 2 points
out.write(0x00); out.write(10); out.write(0x00); out.write(10);
out.write(0x00); out.write(30); out.write(0x00); out.write(30);
byte[] stream = out.toByteArray();
decoder.decodeStream(stream, 0, stream.length);
assertEquals(1.0, decoder.getFractionalLineWidth(), 0.001);
assertTrue(plane.hasContent(), "GLINE following GSFLW must be executed successfully");
}
@Test
public void testGsmxForegroundMix() {
GraphicsPlane plane = new GraphicsPlane(100, 100);
GocaDecoder decoder = new GocaDecoder(plane);
// Send GSMX 0x0C with MIX_LEAVE (3)
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.G_GSMX); out.write(GocaConstants.MIX_LEAVE);
byte[] stream = out.toByteArray();
decoder.decodeStream(stream, 0, stream.length);
assertEquals(GocaConstants.MIX_LEAVE, decoder.getFgMix());
assertEquals(GocaConstants.MIX_LEAVE, plane.getMixMode());
}
@Test
public void testScudefAndBeginSegmentDefaultsRestoration() {
GraphicsPlane plane = new GraphicsPlane(100, 100);
GocaDecoder decoder = new GocaDecoder(plane);
// Send P_SCUDEF (0x21): Type 0 (General Drawing), mask 0x80 (color), value 2 (Red)
// Order structure: 0x21, pLen=6, type=0, mask=0x80, reserved=0, flag=0x80 (explicit), val_hi=0x00, val_lo=0x02
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.P_SCUDEF);
out.write(0x06); // pLen = 6
out.write(0x00); // type 0 = General Drawing
out.write(0x80); // mask: color
out.write(0x00); // reserved
out.write(0x80); // explicit value flag (bit 0 set)
out.write(0x00); // color hi
out.write(0x02); // color lo = 2 (Red)
byte[] scudefBytes = out.toByteArray();
decoder.processProcedureOrders(scudefBytes, 0, scudefBytes.length);
assertEquals(2, decoder.getDefColorIndex(), "P_SCUDEF must set default color index to 2");
// Send Begin Segment (0x70) with flag1 & 6 == 0
// BEGSEGM 14 bytes: 0x70, 0x0C, segId(4 bytes), flag0(1 byte), flag1(1 byte), name(6 bytes)
ByteArrayOutputStream segOut = new ByteArrayOutputStream();
segOut.write(GocaConstants.G_BEGSEGM);
segOut.write(0x0C);
segOut.write(0x00); segOut.write(0x00); segOut.write(0x00); segOut.write(0x05); // segId = 5
segOut.write(0x00); // flag0
segOut.write(0x00); // flag1 (& 6 == 0 -> reset to defaults)
for (int i = 0; i < 6; i++) segOut.write(0x00); // name
byte[] segBytes = segOut.toByteArray();
decoder.decodeStream(segBytes, 0, segBytes.length);
// Drawing color must now be restored to default (2 / Red = 0xFFFF0000)
int redColor = GocaConstants.GOCA_COLORS[2];
// Draw a line and verify it renders in Red
ByteArrayOutputStream lineOut = new ByteArrayOutputStream();
lineOut.write(GocaConstants.G_GLINE); lineOut.write(0x08);
lineOut.write(0x00); lineOut.write(10); lineOut.write(0x00); lineOut.write(10);
lineOut.write(0x00); lineOut.write(30); lineOut.write(0x00); lineOut.write(10);
lineOut.write(GocaConstants.G_ENDSEGM);
byte[] lineBytes = lineOut.toByteArray();
decoder.decodeStream(lineBytes, 0, lineBytes.length);
int px = plane.mapX(20);
int py = plane.mapY(10);
assertEquals(redColor, plane.getPixel(px, py), "Segment must inherit restored default color from P_SCUDEF");
}
@Test
public void testGbarFlag0x40BoundaryBit() {
GraphicsPlane plane = new GraphicsPlane(100, 100);
GocaDecoder decoder = new GocaDecoder(plane);
// GBAR with flag 0x40 (Bit 1 per IBM HoD line 2229)
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.G_GSCOL); out.write(0x02); // Red fill
out.write(GocaConstants.G_GBAR); out.write(0x40); // 0x40 -> Bounded
out.write(GocaConstants.G_GSCOL); out.write(0x06); // Yellow boundary
out.write(GocaConstants.G_GLINE); out.write(0x14); // 5 points
out.write(0x00); out.write(10); out.write(0x00); out.write(10);
out.write(0x00); out.write(40); out.write(0x00); out.write(10);
out.write(0x00); out.write(40); out.write(0x00); out.write(40);
out.write(0x00); out.write(10); out.write(0x00); out.write(40);
out.write(0x00); out.write(10); out.write(0x00); out.write(10);
out.write(GocaConstants.G_GEAR);
byte[] stream = out.toByteArray();
decoder.decodeStream(stream, 0, stream.length);
int yellowArgb = GocaConstants.GOCA_COLORS[6];
boolean foundYellow = false;
for (int p : plane.getRgbBuffer()) {
if (p == yellowArgb) {
foundYellow = true;
break;
}
}
assertTrue(foundYellow, "Area with flag 0x40 (HoD boundary bit) MUST draw boundary strokes");
}
@Test
public void testFillAreaBoundaryClosingSegment() {
GraphicsPlane plane = new GraphicsPlane(100, 100);
// Triangle from (20,20) to (80,20) to (50,80) without repeating (20,20)
int[] px = new int[] { 20, 80, 50 };
int[] py = new int[] { 20, 20, 80 };
int green = 0xFF00FF00;
int whiteBorder = 0xFFFFFFFF;
plane.fillArea(px, py, 3, green, GocaConstants.PT_SOLID, true, whiteBorder,
GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL);
// Check the closing edge from (50,80) back to (20,20)
// Midpoint of (50,80) and (20,20) is (35, 50)
int midPixel = plane.getPixel(35, 50);
assertEquals(whiteBorder, midPixel, "Closing boundary segment from last vertex to first vertex must be drawn");
}
}
@@ -7,8 +7,6 @@ import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.awt.Color;
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -159,13 +157,13 @@ public class Phase1ColorCalibrationTest {
public void testItem1_4_FillAreaBackgroundMix() {
GraphicsPlane plane = new GraphicsPlane(10, 10);
// Test transparent black fill: should not paint when BMX_TRANSPARENT
// Test empty/transparent pattern fill: should not paint interior
FillArea fillAreaTrans = new FillArea(GocaConstants.FILL_RULE_EVEN_ODD);
fillAreaTrans.addEdge(0, 0, 10, 0);
fillAreaTrans.addEdge(10, 0, 10, 10);
fillAreaTrans.addEdge(10, 10, 0, 10);
fillAreaTrans.addEdge(0, 10, 0, 0);
fillAreaTrans.fill(plane, 0xFF000000, 0, GocaConstants.PT_SOLID, false, 0, 0, 1,
fillAreaTrans.fill(plane, 0xFF000000, 0, GocaConstants.PT_EMPTY, false, 0, 0, 1,
GocaConstants.BMX_TRANSPARENT, 0xFF0000FF, GocaConstants.FILL_RULE_EVEN_ODD, null);
// Verify plane pixels remain unpainted (transparent / 0)
@@ -4,14 +4,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -201,7 +193,7 @@ public class Phase2GocaEngineTest {
@Test
@DisplayName("Test HODWallpaper Tile, Center, and Stretch")
public void testHODWallpaper() {
BufferedImage img = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
PixelBuffer img = new DefaultPixelBuffer(32, 32);
HODWallpaper wp = new HODWallpaper(img, HODWallpaper.HOD_CENTER);
assertEquals(HODWallpaper.HOD_CENTER, wp.getDisplay());
@@ -211,10 +203,8 @@ public class Phase2GocaEngineTest {
wp.setDisplay(HODWallpaper.HOD_STRETCH);
assertEquals(HODWallpaper.HOD_STRETCH, wp.getDisplay());
BufferedImage canvas = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB);
Graphics g = canvas.getGraphics();
wp.paint(new java.awt.Canvas(), g, 0, 0, 100, 100);
g.dispose();
PixelBuffer canvas = new DefaultPixelBuffer(100, 100);
wp.paint(canvas, 0, 0, 100, 100);
}
@Test
@@ -283,8 +273,9 @@ public class Phase2GocaEngineTest {
assertEquals(101, bounds.width);
assertEquals(101, bounds.height);
Image img = fa.getImage();
Object img = fa.getImage();
assertNotNull(img);
assertNotNull(fa.getPixelBuffer());
fa.dispose();
}
@@ -10,7 +10,7 @@ import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.Point;
import haus.nightmare.lib3270j.graphics.Point;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@@ -0,0 +1,159 @@
package haus.nightmare.lib3270j.integration;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.ConnectionState;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.listener.ConnectionListener;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import static org.junit.jupiter.api.Assertions.*;
/**
* Live integration test verifying headless connection to live MVS host:
* mvs.hugfreevikings.wtf:1023 using guest credentials.
*/
@Tag("integration")
public class LiveHostHeadlessTest {
private static final Logger logger = Logger.getLogger(LiveHostHeadlessTest.class.getName());
private static final String HOST = "mvs.hugfreevikings.wtf";
private static final int PORT = 1023;
@Test
@DisplayName("Connect headless Telnet3270Client to mvs.hugfreevikings.wtf:1023")
public void testLiveMvsConnection() throws Exception {
// Probe host reachability (timeout 3 seconds)
boolean reachable = false;
try (Socket probe = new Socket()) {
probe.connect(new InetSocketAddress(HOST, PORT), 3000);
reachable = true;
} catch (IOException e) {
logger.warning("Live host " + HOST + ":" + PORT + " unreachable: " + e.getMessage());
}
Assumptions.assumeTrue(reachable, "Skipping live test: " + HOST + ":" + PORT + " is not reachable");
ConnectionConfig config = new ConnectionConfig(HOST, PORT, TerminalModel.IBM_3279_2);
config.setConnectTimeoutMs(5000);
config.setSoTimeoutMs(10000);
Telnet3270Client client = new Telnet3270Client(config);
CountDownLatch connectedLatch = new CountDownLatch(1);
CountDownLatch screenLatch = new CountDownLatch(1);
java.util.concurrent.atomic.AtomicReference<String> screenContentRef = new java.util.concurrent.atomic.AtomicReference<>();
client.addConnectionListener(new ConnectionListener() {
@Override
public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) {
if (newState.is3270()) {
connectedLatch.countDown();
}
}
@Override
public void onConnectionError(String message) {}
});
client.addScreenUpdateListener(new ScreenUpdateListener() {
@Override
public void onScreenUpdated() {
ScreenBuffer sb = client.getScreenBuffer();
if (sb != null && sb.isFormatted()) {
String text = sb.getText().trim();
if (!text.isEmpty()) {
screenContentRef.set(text);
screenLatch.countDown();
}
}
}
});
try {
client.connect();
boolean connected = connectedLatch.await(8, TimeUnit.SECONDS);
assertTrue(connected, "Client must establish 3270 connected state");
boolean screenReceived = screenLatch.await(8, TimeUnit.SECONDS);
assertTrue(screenReceived, "Client must receive formatted screen from live MVS host");
String screenContent = screenContentRef.get();
if (screenContent == null || screenContent.isEmpty()) {
ScreenBuffer sb = client.getScreenBuffer();
if (sb != null) {
screenContent = sb.getText().trim();
}
}
assertNotNull(screenContent, "Screen content should not be null");
assertFalse(screenContent.isEmpty(), "Screen buffer text should not be empty");
// Verify headless graphics plane is initialized without AWT
assertNotNull(client.getGraphicsPlane());
assertEquals(720, client.getGraphicsPlane().getWidth());
assertEquals(384, client.getGraphicsPlane().getHeight());
logger.info("Successfully connected to live host. Banner preview: " +
screenContent.substring(0, Math.min(200, screenContent.length())).replaceAll("\\s+", " "));
} finally {
client.disconnect();
}
}
@Test
@DisplayName("Logon to live MVS host with guest account 'headless'")
public void testLiveMvsGuestLogin() throws Exception {
boolean reachable = false;
try (Socket probe = new Socket()) {
probe.connect(new InetSocketAddress(HOST, PORT), 3000);
reachable = true;
} catch (IOException e) {
logger.warning("Live host " + HOST + ":" + PORT + " unreachable: " + e.getMessage());
}
Assumptions.assumeTrue(reachable, "Skipping live test: " + HOST + ":" + PORT + " is not reachable");
ConnectionConfig config = new ConnectionConfig(HOST, PORT, TerminalModel.IBM_3279_2);
config.setConnectTimeoutMs(5000);
config.setSoTimeoutMs(10000);
Telnet3270Client client = new Telnet3270Client(config);
try {
boolean connected = client.connect(10000);
Assumptions.assumeTrue(connected, "Host connection established");
// Wait for initial screen buffer to populate
Thread.sleep(1500);
ScreenBuffer sb = client.getScreenBuffer();
String initialText = sb != null ? sb.getText().trim() : "";
logger.info("Initial screen before logon: " +
initialText.substring(0, Math.min(150, initialText.length())).replaceAll("\\s+", " "));
// Send guest account 'headless'
client.sendKeys("headless[enter]");
Thread.sleep(2000);
String responseText = sb != null ? sb.getText().trim() : "";
logger.info("Screen after typing 'headless': " +
responseText.substring(0, Math.min(150, responseText.length())).replaceAll("\\s+", " "));
assertNotNull(responseText, "Screen response should not be null");
assertFalse(responseText.isEmpty(), "Screen response should not be empty");
} finally {
client.disconnect();
}
}
}
@@ -76,8 +76,9 @@ public class DynamicScreenBufferTest {
assertTrue(model.isDynamic());
assertEquals(0, model.getModelNumber());
assertTrue(model.isColor());
assertEquals("IBM-DYNAMIC-E", model.getTerminalType());
assertEquals("IBM-DYNAMIC", model.getTerminalType());
assertEquals("IBM-DYNAMIC", model.getBaseTerminalType());
assertEquals("IBM-DYNAMIC", model.toString());
ScreenBuffer buffer = new ScreenBuffer(model, translator);
assertEquals(24, buffer.getDefRows());
@@ -65,12 +65,14 @@ public class DynamicTelnetFSMTest {
// Host requests TTYPE: IAC SB TTYPE SEND IAC SE
feedBytes(255, 250, 24, 1, 255, 240);
// Verify sent response is IBM-DYNAMIC-E
// Verify sent response is IBM-DYNAMIC
assertFalse(connection.sentData.isEmpty());
byte[] lastSent = connection.sentData.get(connection.sentData.size() - 1);
String s = new String(lastSent);
assertTrue(s.contains("IBM-DYNAMIC-E") || s.contains("IBM-DYNAMIC"),
assertTrue(s.contains("IBM-DYNAMIC"),
"Expected terminal type negotiation to send IBM-DYNAMIC, got: " + s);
assertFalse(s.contains("IBM-DYNAMIC-E"),
"Terminal type negotiation should not contain IBM-DYNAMIC-E, got: " + s);
}
@Test
@@ -0,0 +1,570 @@
package haus.nightmare.lib3270j.testutil;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.protocol.DS3270Constants;
import haus.nightmare.lib3270j.protocol.TN3270EConstants;
import haus.nightmare.lib3270j.protocol.TelnetConstants;
import java.io.*;
import java.net.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import static haus.nightmare.lib3270j.protocol.TN3270EConstants.*;
import static haus.nightmare.lib3270j.protocol.TelnetConstants.*;
/**
* Lightweight, in-memory mock TN3270E server running on loopback (127.0.0.1).
*
* Implements RFC 2355 Telnet negotiation, Device-Type subnegotiation,
* BIND image dispatch, formatted 3270 data stream framing (Write / Erase-Write),
* Contention Resolution (SDI / KRI), Keep-Alive tracking, and disconnect simulation.
*/
public class Mock3270Server implements AutoCloseable {
private static final Logger log = Logger.getLogger(Mock3270Server.class.getName());
private final ServerSocket serverSocket;
private final int port;
private final EbcdicTranslator translator;
private final ExecutorService executor = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "Mock3270Server-Worker");
t.setDaemon(true);
return t;
});
private volatile boolean running = true;
private volatile Socket currentSocket;
private volatile OutputStream clientOut;
private volatile InputStream clientIn;
private boolean negotiateBindImage = true;
private boolean negotiateContentionResolution = true;
private boolean autoSendBind = true;
private String assignedDeviceType = "IBM-3279-2-E";
private String assignedLu = "MOCKLU01";
private int primaryRows = 24;
private int primaryCols = 80;
private int altRows = 24;
private int altCols = 80;
private int sequenceNumber = 0;
private final List<byte[]> receivedRecords = new CopyOnWriteArrayList<>();
private final AtomicInteger receivedNops = new AtomicInteger(0);
private final AtomicInteger receivedTimingMarks = new AtomicInteger(0);
private volatile CountDownLatch connectionLatch = new CountDownLatch(1);
private volatile CountDownLatch handshakeLatch = new CountDownLatch(1);
private volatile CountDownLatch dataReceivedLatch = new CountDownLatch(1);
public Mock3270Server() throws IOException {
this(0);
}
public Mock3270Server(int requestedPort) throws IOException {
this.serverSocket = new ServerSocket(requestedPort, 10, InetAddress.getByName("127.0.0.1"));
this.port = serverSocket.getLocalPort();
this.translator = new EbcdicTranslator();
executor.submit(this::acceptLoop);
log.info("Mock3270Server started on 127.0.0.1:" + port);
}
public int getPort() {
return port;
}
public void setNegotiateBindImage(boolean negotiate) {
this.negotiateBindImage = negotiate;
}
public void setNegotiateContentionResolution(boolean cr) {
this.negotiateContentionResolution = cr;
}
public void setAutoSendBind(boolean autoSendBind) {
this.autoSendBind = autoSendBind;
}
public void setAssignedDeviceType(String assignedDeviceType) {
this.assignedDeviceType = assignedDeviceType;
}
public void setAssignedLu(String assignedLu) {
this.assignedLu = assignedLu;
}
public void setDimensions(int pRows, int pCols, int aRows, int aCols) {
this.primaryRows = pRows;
this.primaryCols = pCols;
this.altRows = aRows;
this.altCols = aCols;
}
public boolean waitForConnection(long timeout, TimeUnit unit) throws InterruptedException {
if (currentSocket != null && currentSocket.isConnected() && !currentSocket.isClosed()) {
return true;
}
return connectionLatch.await(timeout, unit);
}
public boolean waitForHandshake(long timeout, TimeUnit unit) throws InterruptedException {
return handshakeLatch.await(timeout, unit);
}
public boolean waitForClientData(long timeout, TimeUnit unit) throws InterruptedException {
if (!receivedRecords.isEmpty()) {
return true;
}
return dataReceivedLatch.await(timeout, unit);
}
public List<byte[]> getReceivedRecords() {
return Collections.unmodifiableList(receivedRecords);
}
public byte[] getLastReceivedRecord() {
if (receivedRecords.isEmpty()) return null;
return receivedRecords.get(receivedRecords.size() - 1);
}
public int getReceivedNops() {
return receivedNops.get();
}
public int getReceivedTimingMarks() {
return receivedTimingMarks.get();
}
public void resetDataLatch() {
dataReceivedLatch = new CountDownLatch(1);
}
private void acceptLoop() {
while (running) {
try {
Socket s = serverSocket.accept();
s.setTcpNoDelay(true);
log.info("Mock3270Server: accepted client connection from " + s.getRemoteSocketAddress());
synchronized (this) {
this.currentSocket = s;
this.clientIn = new BufferedInputStream(s.getInputStream());
this.clientOut = new BufferedOutputStream(s.getOutputStream());
}
connectionLatch.countDown();
executor.submit(() -> handleClient(s));
} catch (SocketException e) {
if (!running) break;
} catch (Exception e) {
if (running) {
log.log(Level.WARNING, "Error in acceptLoop", e);
}
}
}
}
private void handleClient(Socket s) {
try {
// 1. Initial Telnet option offer
sendInitialNegotiation();
// 2. Read incoming telnet options and data loop
ByteArrayOutputStream recordBuf = new ByteArrayOutputStream();
ByteArrayOutputStream sbBuf = new ByteArrayOutputStream();
int b;
boolean inIAC = false;
int iacCommand = -1;
boolean inSB = false;
while (running && !s.isClosed() && (b = clientIn.read()) != -1) {
b &= 0xFF;
if (inSB) {
if (inIAC) {
if (b == SE) {
inSB = false;
inIAC = false;
handleSubnegotiation(sbBuf.toByteArray());
sbBuf.reset();
} else if (b == IAC) {
sbBuf.write(IAC);
inIAC = false;
} else {
inIAC = false;
}
} else if (b == IAC) {
inIAC = true;
} else {
sbBuf.write(b);
}
continue;
}
if (inIAC) {
if (iacCommand == -1) {
iacCommand = b;
switch (b) {
case IAC: // Escaped 0xFF in data stream
recordBuf.write(IAC);
inIAC = false;
iacCommand = -1;
break;
case SB:
inSB = true;
inIAC = false;
iacCommand = -1;
sbBuf.reset();
break;
case EOR:
// End of record framing
byte[] fullRecord = recordBuf.toByteArray();
recordBuf.reset();
inIAC = false;
iacCommand = -1;
onRecordReceived(fullRecord);
break;
case NOP:
receivedNops.incrementAndGet();
log.fine("Mock3270Server: RCVD IAC NOP");
inIAC = false;
iacCommand = -1;
break;
case DO:
case DONT:
case WILL:
case WONT:
// Option follows next byte
break;
default:
inIAC = false;
iacCommand = -1;
break;
}
} else {
// 3-byte command: IAC <DO/DONT/WILL/WONT> <OPT>
handleOptionCommand(iacCommand, b);
inIAC = false;
iacCommand = -1;
}
} else if (b == IAC) {
inIAC = true;
iacCommand = -1;
} else {
recordBuf.write(b);
}
}
} catch (SocketException e) {
log.fine("Client socket closed: " + e.getMessage());
} catch (Exception e) {
if (running) {
log.log(Level.WARNING, "Error in handleClient", e);
}
} finally {
cleanupClientSocket(s);
}
}
private synchronized void sendInitialNegotiation() throws IOException {
if (clientOut == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
// Server offers DO TN3270E, DO TRANSMIT-BINARY, WILL TRANSMIT-BINARY, DO EOR, WILL EOR
out.write(new byte[] {
(byte) IAC, (byte) DO, (byte) TELOPT_TN3270E,
(byte) IAC, (byte) DO, (byte) TELOPT_BINARY,
(byte) IAC, (byte) WILL, (byte) TELOPT_BINARY,
(byte) IAC, (byte) DO, (byte) TELOPT_EOR,
(byte) IAC, (byte) WILL, (byte) TELOPT_EOR
});
clientOut.write(out.toByteArray());
clientOut.flush();
log.info("Mock3270Server: sent initial negotiation");
}
private void handleOptionCommand(int cmd, int opt) throws IOException {
log.fine("Mock3270Server: RCVD IAC " + cmd + " opt=" + opt);
if (cmd == WILL && opt == TELOPT_TN3270E) {
// Client accepted TN3270E -> Send SB TN3270E SEND DEVICE-TYPE IAC SE
sendDeviceTypeSend();
} else if (cmd == DO && opt == TELOPT_TM) {
// Client requested Timing Mark keep-alive
receivedTimingMarks.incrementAndGet();
log.fine("Mock3270Server: RCVD IAC DO TIMING-MARK");
synchronized (this) {
if (clientOut != null) {
clientOut.write(new byte[] { (byte) IAC, (byte) WILL, (byte) TELOPT_TM });
clientOut.flush();
}
}
}
}
private synchronized void sendDeviceTypeSend() throws IOException {
if (clientOut == null) return;
byte[] devTypeSend = new byte[] {
(byte) IAC, (byte) SB, (byte) TELOPT_TN3270E,
(byte) OP_SEND, (byte) OP_DEVICE_TYPE,
(byte) IAC, (byte) SE
};
clientOut.write(devTypeSend);
clientOut.flush();
log.info("Mock3270Server: sent SB TN3270E SEND DEVICE-TYPE SE");
}
private void handleSubnegotiation(byte[] sb) throws IOException {
if (sb.length < 2 || (sb[0] & 0xFF) != TELOPT_TN3270E) {
return;
}
int op = sb[1] & 0xFF;
if (op == OP_DEVICE_TYPE) {
// Client sent DEVICE-TYPE REQUEST <type> [CONNECT <lu>]
int pos = 2;
if (pos < sb.length && (sb[pos] & 0xFF) == OP_REQUEST) pos++;
StringBuilder reqType = new StringBuilder();
while (pos < sb.length && (sb[pos] & 0xFF) != OP_CONNECT) {
reqType.append((char) (sb[pos] & 0xFF));
pos++;
}
log.info("Mock3270Server: client requested device-type: " + reqType);
// Respond with SB TN3270E DEVICE-TYPE IS <assignedType> CONNECT <assignedLu> IAC SE
sendDeviceTypeIs(assignedDeviceType, assignedLu);
} else if (op == OP_FUNCTIONS) {
// Client sent FUNCTIONS REQUEST <functions...>
List<Integer> funcs = new ArrayList<>();
for (int i = 3; i < sb.length; i++) {
funcs.add(sb[i] & 0xFF);
}
log.info("Mock3270Server: client requested functions: " + funcs);
// Respond with FUNCTIONS IS
sendFunctionsIs();
if (autoSendBind && negotiateBindImage) {
sendBindImage();
}
handshakeLatch.countDown();
}
}
private synchronized void sendDeviceTypeIs(String type, String lu) throws IOException {
if (clientOut == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(new byte[] { (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, (byte) OP_DEVICE_TYPE, (byte) OP_IS });
out.write(type.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
if (lu != null && !lu.isEmpty()) {
out.write(OP_CONNECT);
out.write(lu.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
}
out.write(new byte[] { (byte) IAC, (byte) SE });
clientOut.write(out.toByteArray());
clientOut.flush();
log.info("Mock3270Server: sent SB TN3270E DEVICE-TYPE IS " + type + " CONNECT " + lu + " SE");
}
private synchronized void sendFunctionsIs() throws IOException {
if (clientOut == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(new byte[] { (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, (byte) OP_FUNCTIONS, (byte) OP_IS });
if (negotiateBindImage) {
out.write(FUNC_BIND_IMAGE);
}
out.write(FUNC_RESPONSES);
if (negotiateContentionResolution) {
out.write(FUNC_CONTENTION_RESOLUTION);
}
out.write(FUNC_SYSREQ);
out.write(new byte[] { (byte) IAC, (byte) SE });
clientOut.write(out.toByteArray());
clientOut.flush();
log.info("Mock3270Server: sent SB TN3270E FUNCTIONS IS SE");
}
public synchronized void sendBindImage() throws IOException {
if (clientOut == null) return;
// BIND payload: 5-byte header + standard 26-byte BIND image
byte[] bindPayload = new byte[26];
bindPayload[20] = (byte) primaryRows;
bindPayload[21] = (byte) primaryCols;
bindPayload[22] = (byte) altRows;
bindPayload[23] = (byte) altCols;
bindPayload[24] = (byte) ((altRows == primaryRows && altCols == primaryCols) ? 0x02 : 0x7F);
sendRecord(DT_BIND_IMAGE, 0, RSF_NO_RESPONSE, bindPayload);
log.info("Mock3270Server: sent BIND image (" + primaryRows + "x" + primaryCols + " / " + altRows + "x" + altCols + ")");
}
public synchronized void sendUnbind(int reason) throws IOException {
sendRecord(DT_UNBIND, 0, RSF_NO_RESPONSE, new byte[] { (byte) reason });
log.info("Mock3270Server: sent UNBIND reason=" + reason);
}
public synchronized void sendRecord(int dataType, int requestFlag, int responseFlag, byte[] payload) throws IOException {
if (clientOut == null) throw new IOException("No client connected");
sequenceNumber = (sequenceNumber + 1) & 0xFFFF;
ByteArrayOutputStream out = new ByteArrayOutputStream(payload.length + 16);
// 5-byte TN3270E header
out.write(dataType);
out.write(requestFlag);
out.write(responseFlag);
out.write((sequenceNumber >> 8) & 0xFF);
out.write(sequenceNumber & 0xFF);
// Escape payload IAC (0xFF -> 0xFF 0xFF)
for (byte b : payload) {
int val = b & 0xFF;
out.write(val);
if (val == IAC) {
out.write(IAC);
}
}
// End of Record framing
out.write(IAC);
out.write(EOR);
clientOut.write(out.toByteArray());
clientOut.flush();
}
public synchronized void sendEraseWrite(int wcc, byte[] data, boolean sdi, boolean kri) throws IOException {
ByteArrayOutputStream payload = new ByteArrayOutputStream(data.length + 2);
payload.write(CMD_ERASE_WRITE); // 0xF5
payload.write(wcc);
payload.write(data);
int rqf = (sdi ? RQF_SEND_DATA : 0) | (kri ? RQF_KEYBOARD_RESTORE : 0);
sendRecord(DT_3270_DATA, rqf, RSF_NO_RESPONSE, payload.toByteArray());
}
public synchronized void sendWrite(int wcc, byte[] data, boolean sdi, boolean kri) throws IOException {
ByteArrayOutputStream payload = new ByteArrayOutputStream(data.length + 2);
payload.write(CMD_WRITE); // 0xF1
payload.write(wcc);
payload.write(data);
int rqf = (sdi ? RQF_SEND_DATA : 0) | (kri ? RQF_KEYBOARD_RESTORE : 0);
sendRecord(DT_3270_DATA, rqf, RSF_NO_RESPONSE, payload.toByteArray());
}
public synchronized void sendFormattedScreen(String text, int row, int col, boolean unlockKeyboard) throws IOException {
ByteArrayOutputStream data = new ByteArrayOutputStream();
int addr = row * primaryCols + col;
byte[] sba = DS3270Constants.encodeAddress(addr, primaryRows, primaryCols);
// SBA to target position
data.write(ORDER_SBA);
data.write(sba[0] & 0xFF);
data.write(sba[1] & 0xFF);
// Start Field (unprotected: 0x40 or 0x41)
data.write(ORDER_SF);
data.write(0x40);
// Write EBCDIC text
for (char c : text.toCharArray()) {
data.write(translator.unicodeToEbcdicSafe(c));
}
// Insert Cursor
data.write(ORDER_IC);
// WCC: 0xC3 (sound alarm + unlock keyboard + reset MDT) or 0xC0 (locked)
int wcc = unlockKeyboard ? 0xC3 : 0xC0;
sendEraseWrite(wcc, data.toByteArray(), true, unlockKeyboard);
}
public synchronized void sendFragmented(byte[] rawData, int chunkSize, long delayMs) throws IOException, InterruptedException {
if (clientOut == null) throw new IOException("No client connected");
int offset = 0;
while (offset < rawData.length) {
int len = Math.min(chunkSize, rawData.length - offset);
clientOut.write(rawData, offset, len);
clientOut.flush();
offset += len;
if (delayMs > 0 && offset < rawData.length) {
Thread.sleep(delayMs);
}
}
}
public synchronized void sendRawBytes(byte[] raw) throws IOException {
if (clientOut == null) throw new IOException("No client connected");
clientOut.write(raw);
clientOut.flush();
}
public synchronized void disconnectClient() {
if (currentSocket != null) {
try {
log.info("Mock3270Server: intentionally disconnecting client");
currentSocket.close();
} catch (Exception ignored) {}
cleanupClientSocket(currentSocket);
}
}
private void onRecordReceived(byte[] record) {
log.info("Mock3270Server: received record length=" + record.length);
receivedRecords.add(record);
dataReceivedLatch.countDown();
}
public int getLastReceivedAID() {
byte[] last = getLastReceivedRecord();
if (last == null || last.length <= EH_SIZE) return -1;
return last[EH_SIZE] & 0xFF;
}
public int getLastReceivedCursorAddress() {
byte[] last = getLastReceivedRecord();
if (last == null || last.length < EH_SIZE + 3) return -1;
int b1 = last[EH_SIZE + 1] & 0xFF;
int b2 = last[EH_SIZE + 2] & 0xFF;
return DS3270Constants.decodeAddress(b1, b2);
}
public String getLastReceivedFieldText() {
byte[] last = getLastReceivedRecord();
if (last == null || last.length <= EH_SIZE + 3) return "";
StringBuilder sb = new StringBuilder();
// Skip header + AID (1) + cursor address (2)
int i = EH_SIZE + 3;
while (i < last.length) {
int b = last[i] & 0xFF;
if (b == ORDER_SBA) {
i += 3;
continue;
}
sb.append(translator.ebcdicToUnicode(b));
i++;
}
return sb.toString();
}
private synchronized void cleanupClientSocket(Socket s) {
if (currentSocket == s) {
currentSocket = null;
clientIn = null;
clientOut = null;
}
try {
s.close();
} catch (Exception ignored) {}
}
@Override
public void close() {
running = false;
try {
serverSocket.close();
} catch (Exception ignored) {}
disconnectClient();
executor.shutdownNow();
log.info("Mock3270Server stopped");
}
}
@@ -0,0 +1,293 @@
package haus.nightmare.lib3270j.testutil;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.ConnectionState;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.ecl.ECLConstants;
import haus.nightmare.lib3270j.ecl.ECLPS;
import haus.nightmare.lib3270j.ecl.ECLScreenDesc;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import static haus.nightmare.lib3270j.protocol.TN3270EConstants.*;
import static haus.nightmare.lib3270j.protocol.TelnetConstants.*;
import static org.junit.jupiter.api.Assertions.*;
@Timeout(value = 30, unit = TimeUnit.SECONDS)
public class Mock3270ServerIntegrationTest {
private Mock3270Server server;
private Telnet3270Client client;
@BeforeEach
public void setup() throws IOException {
server = new Mock3270Server();
}
@AfterEach
public void teardown() {
if (client != null) {
client.disconnect();
client = null;
}
if (server != null) {
server.close();
server = null;
}
}
private void waitForState(Telnet3270Client cl, ConnectionState targetState, long timeoutMs) throws Exception {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
if (cl.getConnectionState() == targetState) {
return;
}
Thread.sleep(25);
}
assertEquals(targetState, cl.getConnectionState());
}
@Test
public void testSuccessfulConnectionAndHandshake() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
config.setConnectTimeoutMs(5000);
client = new Telnet3270Client(config);
boolean connected = client.connect(8000);
assertTrue(connected, "Client should establish connected session");
boolean handshakeDone = server.waitForHandshake(5, TimeUnit.SECONDS);
assertTrue(handshakeDone, "Mock server should complete TN3270E handshake");
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
assertEquals("MOCKLU01", client.getTelnetFSM().getConnectedLu());
}
@Test
public void testSendScreenAndVerifyPresentationSpace() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Server sends formatted greeting screen at row 2, col 10
String banner = "WELCOME TO TN3270E SERVER";
server.sendFormattedScreen(banner, 2, 10, true);
// Wait up to 3s for screen update
ECLPS ps = client.getPS();
boolean matched = false;
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
String text = ps.getString(2, 11, banner.length());
if (banner.equals(text)) {
matched = true;
break;
}
Thread.sleep(20);
}
assertTrue(matched, "Presentation space should contain banner string");
assertFalse(client.getInputProcessor().isKeyboardLocked(), "Keyboard should be unlocked after WCC restore");
}
@Test
public void testClientUserInputAndAidTransmission() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Send screen with unprotected field at row 4, col 10
server.sendFormattedScreen("ENTER DATA: ", 4, 10, true);
Thread.sleep(100);
// Client positions cursor inside unprotected field (col 23) and enters text
client.getPS().setCursorPos(4, 23);
client.sendKeys("TESTDATA[enter]");
// Verify mock server receives AID and field data
assertTrue(server.waitForClientData(5, TimeUnit.SECONDS), "Server must receive client AID submission");
assertEquals(0x7D, server.getLastReceivedAID(), "AID should be ENTER (0x7D)");
assertTrue(server.getLastReceivedFieldText().contains("TESTDATA"),
"Field text should contain submitted characters 'TESTDATA'");
}
@Test
public void testContentionResolutionAndKeyboardLock() throws Exception {
server.setNegotiateContentionResolution(true);
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
client.getTelnetFSM().setNegotiateContentionResolution(true);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// 1. Lock keyboard explicitly (as happens when user presses AID)
client.getInputProcessor().setKeyboardLocked(true);
assertTrue(client.getInputProcessor().isKeyboardLocked());
// Send write with WCC locked (0xC0) and sdi = false
byte[] dummyData = new byte[] { (byte) ORDER_SBA, 0x40, 0x40, (byte) ORDER_SF, 0x60 };
server.sendWrite(0xC0, dummyData, false, false);
Thread.sleep(100);
assertTrue(client.getInputProcessor().isKeyboardLocked(),
"Keyboard must remain locked when SDI is not signaled under contention resolution");
// 2. Unlock via Contention Resolution: send write with SDI flag = true (RQF_SEND_DATA)
server.sendWrite(0xC0, dummyData, true, false);
Thread.sleep(100);
assertFalse(client.getInputProcessor().isKeyboardLocked(),
"Keyboard must unlock upon receiving SDI flag on EOR");
}
@Test
public void testAutoSysUnlockOnEor() throws Exception {
server.setNegotiateContentionResolution(false);
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
config.setAutoSysUnlock(true);
client = new Telnet3270Client(config);
client.getTelnetFSM().setNegotiateContentionResolution(false);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Lock keyboard initially
client.getInputProcessor().setKeyboardLocked(true);
client.getOIA().setInputInhibited(ECLConstants.INHIBIT_SYSTEM_LOCK);
assertTrue(client.getInputProcessor().isKeyboardLocked());
// Host sends a write record without WCC restore, but autoSysUnlock is active on EOR
byte[] dummyData = new byte[] { (byte) ORDER_SBA, 0x40, 0x40, (byte) ORDER_SF, 0x60 };
server.sendWrite(0xC0, dummyData, false, false);
Thread.sleep(100);
assertFalse(client.getInputProcessor().isKeyboardLocked(),
"autoSysUnlock must unlock keyboard on EOR when not in Read command");
assertFalse(client.getOIA().isXSystem(), "OIA X SYSTEM indicator must be cleared");
}
@Test
public void testAutomatedWaitMethods() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("LOGIN_READY");
CountDownLatch waitLatch = new CountDownLatch(1);
AtomicBoolean waitResult = new AtomicBoolean(false);
// Run waitForScreen in background thread
CompletableFuture.runAsync(() -> {
boolean res = client.getPS().waitForScreen(desc, 5000);
waitResult.set(res);
waitLatch.countDown();
});
// Delay 80ms then send matching screen from mock server
Thread.sleep(80);
server.sendFormattedScreen("LOGIN_READY", 1, 9, true);
boolean awakened = waitLatch.await(2000, TimeUnit.MILLISECONDS);
assertTrue(awakened, "waitForScreen must wake reactively upon screen update");
assertTrue(waitResult.get(), "waitForScreen must return true when screen matches descriptor");
}
@Test
public void testKeepAliveHeartbeatEngine() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
config.setKeepAliveEnabled(true);
config.setKeepAliveIntervalSeconds(1);
config.setKeepAliveType("NOP");
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Idle for 2.5 seconds to allow keepalive heartbeat to fire
Thread.sleep(2500);
assertTrue(server.getReceivedNops() >= 1,
"Server must have received at least 1 IAC NOP heartbeat, received: " + server.getReceivedNops());
}
@Test
public void testAutoReconnectOnSocketDisconnect() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
config.setAutoReconnect(true);
config.setReconnectMaxRetries(3);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Disconnect active client connection abruptly
server.disconnectClient();
// Wait for client to detect disconnect and reconnect automatically
waitForState(client, ConnectionState.CONNECTED_TN3270E, 8000);
assertEquals(ConnectionState.CONNECTED_TN3270E, client.getConnectionState());
}
@Test
public void testPacketFragmentation() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
// Build raw 3270 Erase/Write packet with 5-byte header + command + SBA + SF + data + IAC EOR
ByteArrayOutputStream buf = new ByteArrayOutputStream();
// 5-byte header
buf.write(new byte[] { (byte) DT_3270_DATA, 0x00, 0x00, 0x00, 0x01 });
buf.write(CMD_ERASE_WRITE); // 0xF5
buf.write(0xC3); // WCC
buf.write(ORDER_SBA);
buf.write(0x40); buf.write(0x40); // row 0, col 0
buf.write(ORDER_SF);
buf.write(0x40); // unprotected
String text = "FRAGMENTED_TEST";
for (char c : text.toCharArray()) {
buf.write(client.getScreenBuffer().getTranslator().unicodeToEbcdicSafe(c));
}
buf.write((byte) IAC);
buf.write((byte) EOR);
// Send packet in 2-byte fragments with 5ms delays
server.sendFragmented(buf.toByteArray(), 2, 5);
// Verify presentation space renders the entire reconstructed string
boolean matched = false;
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
String read = client.getPS().getString(0, 1, text.length());
if (text.equals(read)) {
matched = true;
break;
}
Thread.sleep(20);
}
assertTrue(matched, "Fragmented stream must be reassembled and displayed correctly");
}
}
+31
View File
@@ -0,0 +1,31 @@
<#
.SYNOPSIS
j3270 Terminal Emulator - Setup and Build Script (PowerShell Entrypoint)
Forwards execution directly to setup.ps1 with all supplied parameters.
.DESCRIPTION
See setup.ps1 for full implementation, parameter definitions, and documentation.
#>
[CmdletBinding()]
param (
[switch]$Run,
[switch]$Test,
[switch]$Clean,
[switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RunArgs
)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
if (-not $scriptDir) {
$scriptDir = Get-Location
}
$ps1Script = Join-Path $scriptDir "setup.ps1"
if (Test-Path $ps1Script) {
& $ps1Script @PSBoundParameters @RunArgs
} else {
Write-Error "setup.ps1 not found in $scriptDir"
exit 1
}
+468
View File
@@ -0,0 +1,468 @@
<#
.SYNOPSIS
j3270 Terminal Emulator - Setup and Build Script (PowerShell)
Detects Java 11+ JDK and builds j3270 standalone executable JAR.
.DESCRIPTION
Searches for a compatible Java Development Kit (JDK 11+ / LTS 11, 17, 21),
verifies compiler (javac) and archiver (jar) toolchains, compiles the lib3270j
protocol core and j3270 Swing desktop application, and packages the standalone
executable JAR file (build/j3270.jar).
.PARAMETER Run
Launch j3270 immediately after successful build.
.PARAMETER Test
Run unit test suite after successful build.
.PARAMETER Clean
Remove previous build artifacts before compiling.
.PARAMETER Help
Display usage help message.
.EXAMPLE
.\setup.ps1
Builds the standalone j3270.jar.
.EXAMPLE
.\setup.ps1 -Clean -Run
Cleans previous build, builds j3270, and immediately launches the application.
.EXAMPLE
.\setup.ps1 -Run -- mainframe.example.com 23 4
Builds and connects to specified host, port, and terminal model.
#>
[CmdletBinding()]
param (
[switch]$Run,
[switch]$Test,
[switch]$Clean,
[switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RunArgs
)
$ErrorActionPreference = "Stop"
# Resolve script root directory
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
if (-not $ScriptDir) {
$ScriptDir = Get-Location
}
Set-Location $ScriptDir
# Helper functions for colored terminal output
function Write-LogInfo($msg) { Write-Host "[INFO] $msg" -ForegroundColor Cyan }
function Write-LogSuccess($msg) { Write-Host "[OK] $msg" -ForegroundColor Green }
function Write-LogWarn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow }
function Write-LogError($msg) { Write-Host "[ERROR] $msg" -ForegroundColor Red }
if ($Help) {
Write-Host @"
j3270 Terminal Emulator - Setup & Build Script (PowerShell)
Usage:
.\setup.ps1 [-Run] [-Test] [-Clean] [-Help] [-- <run-arguments>]
Options:
-Run Build and immediately launch j3270
-Test Run automated unit tests after build
-Clean Remove previous build artifacts before compiling
-Help Show this help message
Examples:
.\setup.ps1
.\setup.ps1 -Clean
.\setup.ps1 -Run
.\setup.ps1 -Run -- mainframe.example.com 23 4
.\setup.ps1 -Test
"@
exit 0
}
Write-Host "`n=== j3270 Setup & Build (PowerShell) ===`n" -ForegroundColor White
# Determine platform
$isWindows = if (Test-Path variable:IsWindows) { $IsWindows } else { $env:OS -eq "Windows_NT" }
$exeSuffix = if ($isWindows) { ".exe" } else { "" }
# ------------------------------------------------------------------------------
# 1. Helper: Parse Major Java Version
# ------------------------------------------------------------------------------
function Get-JavaMajorVersion([string]$verStr) {
if (-not $verStr) { return 0 }
if ($verStr -match 'javac\s+([0-9][0-9._]*)' -or
$verStr -match 'version\s+"?([0-9][0-9._]*)"?' -or
$verStr -match '([0-9]+(\.[0-9]+)*)') {
$token = $matches[1]
if ($token -match '^1\.(\d+)') {
return [int]$matches[1]
}
if ($token -match '^(\d+)') {
return [int]$matches[1]
}
}
return 0
}
# ------------------------------------------------------------------------------
# 2. Helper: Test JDK Candidate Directory
# ------------------------------------------------------------------------------
function Test-JdkCandidate([string]$candHome) {
if (-not $candHome -or -not (Test-Path $candHome)) {
return $null
}
$javacPath = Join-Path $candHome "bin\javac$exeSuffix"
$jarPath = Join-Path $candHome "bin\jar$exeSuffix"
$javaPath = Join-Path $candHome "bin\java$exeSuffix"
if (-not (Test-Path $javacPath) -or -not (Test-Path $jarPath) -or -not (Test-Path $javaPath)) {
return $null
}
try {
$pInfo = New-Object System.Diagnostics.ProcessStartInfo
$pInfo.FileName = $javacPath
$pInfo.Arguments = "-version"
$pInfo.RedirectStandardOutput = $true
$pInfo.RedirectStandardError = $true
$pInfo.UseShellExecute = $false
$pInfo.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($pInfo)
$stdout = $proc.StandardOutput.ReadToEnd()
$stderr = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
if ($proc.ExitCode -eq 0) {
$verOutput = ("$stdout $stderr").Trim()
$majorVer = Get-JavaMajorVersion $verOutput
if ($majorVer -ge 11) {
return [PSCustomObject]@{
Home = $candHome
Javac = $javacPath
Jar = $jarPath
Java = $javaPath
Version = $verOutput
Major = $majorVer
}
}
}
} catch {
# Process execution failed or access denied
}
return $null
}
# ------------------------------------------------------------------------------
# 3. Discover JDK 11+
# ------------------------------------------------------------------------------
Write-LogInfo "Searching for a compatible Java Development Kit (JDK 11+)..."
$detectedJdk = $null
# Strategy A: Check JAVA_HOME environment variable
if ($env:JAVA_HOME) {
$detectedJdk = Test-JdkCandidate $env:JAVA_HOME
if ($detectedJdk) {
Write-LogInfo "Found valid JDK in `$env:JAVA_HOME: $($detectedJdk.Home)"
} else {
Write-LogWarn "`$env:JAVA_HOME is set ($($env:JAVA_HOME)) but is not a valid JDK 11+."
}
}
# Strategy B: Check current system PATH
if (-not $detectedJdk) {
$pathJavac = Get-Command "javac$exeSuffix" -ErrorAction SilentlyContinue
if ($pathJavac) {
try {
$realPath = $pathJavac.Source
$candBin = Split-Path -Parent $realPath
$candHome = Split-Path -Parent $candBin
$detectedJdk = Test-JdkCandidate $candHome
if ($detectedJdk) {
Write-LogInfo "Found valid JDK on PATH: $($detectedJdk.Home)"
}
} catch {}
}
}
# Strategy C: Check Windows Registry (if running on Windows)
if (-not $detectedJdk -and $isWindows) {
$regPaths = @(
"HKLM:\SOFTWARE\JavaSoft\JDK",
"HKLM:\SOFTWARE\JavaSoft\Java Development Kit",
"HKLM:\SOFTWARE\Eclipse Adoptium\JDK",
"HKLM:\SOFTWARE\AdoptOpenJDK\JDK",
"HKLM:\SOFTWARE\Microsoft\JDK",
"HKLM:\SOFTWARE\BellSoft\Liberica JDK",
"HKLM:\SOFTWARE\Zulu\zulu-jdk",
"HKLM:\SOFTWARE\WOW6432Node\JavaSoft\JDK",
"HKLM:\SOFTWARE\WOW6432Node\JavaSoft\Java Development Kit",
"HKLM:\SOFTWARE\WOW6432Node\Eclipse Adoptium\JDK",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\JDK"
)
foreach ($regBase in $regPaths) {
if (Test-Path $regBase) {
$subKeys = Get-ChildItem -Path $regBase -ErrorAction SilentlyContinue
foreach ($key in $subKeys) {
$item = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue
if ($item.JavaHome) {
$detectedJdk = Test-JdkCandidate $item.JavaHome
if ($detectedJdk) { break }
}
if ($item.Path) {
$detectedJdk = Test-JdkCandidate $item.Path
if ($detectedJdk) { break }
}
# Check hotspot subkey used by Adoptium/Microsoft MSI installers
$hotspotKey = Join-Path $key.PSPath "hotspot\MSI"
if (Test-Path $hotspotKey) {
$hs = Get-ItemProperty -Path $hotspotKey -ErrorAction SilentlyContinue
if ($hs.Path) {
$detectedJdk = Test-JdkCandidate $hs.Path
if ($detectedJdk) { break }
}
}
}
if ($detectedJdk) {
Write-LogInfo "Found valid JDK via Windows Registry: $($detectedJdk.Home)"
break
}
}
}
}
# Strategy D: Check Standard Filesystem Locations
if (-not $detectedJdk) {
$fsCandidates = @()
if ($isWindows) {
$searchRoots = @(
"$env:ProgramFiles\Eclipse Adoptium\*",
"$env:ProgramFiles\Java\*",
"$env:ProgramFiles\Microsoft\*",
"$env:ProgramFiles\Amazon Corretto\*",
"$env:ProgramFiles\Zulu\*",
"$env:ProgramFiles\BellSoft\*",
"$env:ProgramFiles\Semeru\*",
"$env:ProgramFiles\RedHat\*",
"${env:ProgramFiles(x86)}\Java\*",
"$env:LOCALAPPDATA\Programs\Eclipse Adoptium\*",
"$env:LOCALAPPDATA\Programs\Common\Oracle\Java\*",
"$env:USERPROFILE\.jdks\*",
"$env:USERPROFILE\.sdkman\candidates\java\*",
"$env:USERPROFILE\scoop\apps\openjdk\current",
"$env:USERPROFILE\scoop\apps\oraclejdk\current",
"$env:USERPROFILE\scoop\apps\temurin\current",
"$env:USERPROFILE\scoop\apps\zulu\current",
"C:\tools\jdk*",
"C:\Java\*"
)
foreach ($pattern in $searchRoots) {
Resolve-Path -Path $pattern -ErrorAction SilentlyContinue | ForEach-Object {
$fsCandidates += $_.Path
}
}
} else {
# POSIX / macOS search roots for pwsh
$searchRoots = @(
"/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home",
"/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home",
"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home",
"/opt/homebrew/opt/openjdk@11/libexec/openjdk.jdk/Contents/Home",
"/opt/homebrew/opt/openjdk*",
"/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home",
"/Library/Java/JavaVirtualMachines/*/Contents/Home",
"/usr/lib/jvm/default-java",
"/usr/lib/jvm/java-21-openjdk*",
"/usr/lib/jvm/java-17-openjdk*",
"/usr/lib/jvm/java-11-openjdk*",
"/usr/lib/jvm/*",
"$env:HOME/.sdkman/candidates/java/current",
"$env:HOME/.sdkman/candidates/java/*",
"$env:HOME/.asdf/installs/java/*"
)
foreach ($pattern in $searchRoots) {
Resolve-Path -Path $pattern -ErrorAction SilentlyContinue | ForEach-Object {
$fsCandidates += $_.Path
}
}
}
foreach ($cand in $fsCandidates) {
$detectedJdk = Test-JdkCandidate $cand
if ($detectedJdk) {
Write-LogInfo "Found valid JDK at: $($detectedJdk.Home)"
break
}
}
}
# If no compatible JDK is found, report error with helpful instructions
if (-not $detectedJdk) {
Write-LogError "No compatible Java Development Kit (JDK 11+) was found on your system."
Write-Host @"
j3270 requires a Java Development Kit (JDK) version 11 or higher (LTS 11, 17, or 21).
Note: A JRE (runtime only) is not sufficient; the compiler (javac) and archiver (jar) are required.
To install JDK 17 on Windows:
Using Windows Package Manager (winget):
winget install EclipseAdoptium.Temurin.17.JDK
Using Chocolatey:
choco install openjdk17
Using Scoop:
scoop install openjdk17
Or download Eclipse Temurin (Adoptium MSI installer):
https://adoptium.net
After installing, restart PowerShell and re-run .\setup.ps1
"@ -ForegroundColor Yellow
exit 1
}
$env:JAVA_HOME = $detectedJdk.Home
Write-LogSuccess "JDK verified: $($detectedJdk.Version)"
Write-LogInfo "Java compiler: $($detectedJdk.Javac)"
Write-LogInfo "JAR packager: $($detectedJdk.Jar)"
Write-LogInfo "Java runtime: $($detectedJdk.Java)"
Write-LogInfo "JAVA_HOME: $($detectedJdk.Home)"
Write-Host ""
# ------------------------------------------------------------------------------
# 4. Preparation & Clean
# ------------------------------------------------------------------------------
$BuildDir = Join-Path $ScriptDir "build"
$LibBuildDir = Join-Path $BuildDir "lib3270j"
$AppBuildDir = Join-Path $BuildDir "j3270"
if ($Clean) {
Write-LogInfo "Cleaning build directory ($BuildDir)..."
Remove-Item -Path $LibBuildDir, $AppBuildDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path (Join-Path $BuildDir "MANIFEST.MF") -Force -ErrorAction SilentlyContinue
Remove-Item -Path (Join-Path $BuildDir "j3270.jar") -Force -ErrorAction SilentlyContinue
}
$null = New-Item -ItemType Directory -Path $LibBuildDir -Force
$null = New-Item -ItemType Directory -Path $AppBuildDir -Force
# ------------------------------------------------------------------------------
# 5. Compile Core Protocol Engine (lib3270j)
# ------------------------------------------------------------------------------
Write-LogInfo "Compiling lib3270j core engine..."
$libSrcDir = Join-Path $ScriptDir "lib3270j\src\main\java"
$libSources = Get-ChildItem -Path $libSrcDir -Filter *.java -Recurse | Select-Object -ExpandProperty FullName
if (-not $libSources -or $libSources.Count -eq 0) {
Write-LogError "No source files found in $libSrcDir"
exit 1
}
$libSourcesFile = Join-Path $BuildDir "lib_sources.txt"
$libSources | ForEach-Object { "`"$_`"" } | Set-Content -Path $libSourcesFile -Encoding UTF8
& $detectedJdk.Javac -d $LibBuildDir "@$libSourcesFile"
if ($LASTEXITCODE -ne 0) {
Write-LogError "Compilation of lib3270j failed with exit code $LASTEXITCODE."
exit $LASTEXITCODE
}
Remove-Item -Path $libSourcesFile -Force -ErrorAction SilentlyContinue
Write-LogSuccess "lib3270j compiled successfully."
# ------------------------------------------------------------------------------
# 6. Compile Desktop Application (j3270)
# ------------------------------------------------------------------------------
Write-LogInfo "Compiling j3270 terminal emulator..."
$appSrcDir = Join-Path $ScriptDir "j3270\src\main\java"
$appSources = Get-ChildItem -Path $appSrcDir -Filter *.java -Recurse | Select-Object -ExpandProperty FullName
if (-not $appSources -or $appSources.Count -eq 0) {
Write-LogError "No source files found in $appSrcDir"
exit 1
}
$appSourcesFile = Join-Path $BuildDir "app_sources.txt"
$appSources | ForEach-Object { "`"$_`"" } | Set-Content -Path $appSourcesFile -Encoding UTF8
& $detectedJdk.Javac -cp $LibBuildDir -d $AppBuildDir "@$appSourcesFile"
if ($LASTEXITCODE -ne 0) {
Write-LogError "Compilation of j3270 failed with exit code $LASTEXITCODE."
exit $LASTEXITCODE
}
Remove-Item -Path $appSourcesFile -Force -ErrorAction SilentlyContinue
Write-LogSuccess "j3270 application compiled successfully."
# Copy any resources if present
$appResDir = Join-Path $ScriptDir "j3270\src\main\resources"
if (Test-Path $appResDir) {
Copy-Item -Path (Join-Path $appResDir "*") -Destination $AppBuildDir -Recurse -Force -ErrorAction SilentlyContinue
}
$libResDir = Join-Path $ScriptDir "lib3270j\src\main\resources"
if (Test-Path $libResDir) {
Copy-Item -Path (Join-Path $libResDir "*") -Destination $LibBuildDir -Recurse -Force -ErrorAction SilentlyContinue
}
# ------------------------------------------------------------------------------
# 7. Package Standalone Executable JAR
# ------------------------------------------------------------------------------
Write-LogInfo "Packaging standalone executable JAR..."
$manifestFile = Join-Path $BuildDir "MANIFEST.MF"
$manifestText = "Manifest-Version: 1.0`r`nMain-Class: haus.nightmare.j3270.J3270App`r`nImplementation-Title: j3270`r`nImplementation-Version: 0.1.0`r`nCreated-By: j3270 setup.ps1`r`n`r`n"
[System.IO.File]::WriteAllText($manifestFile, $manifestText)
$jarFile = Join-Path $BuildDir "j3270.jar"
& $detectedJdk.Jar cvfm $jarFile $manifestFile -C $LibBuildDir . -C $AppBuildDir . | Out-Null
if (-not (Test-Path $jarFile)) {
Write-LogError "Failed to create executable JAR at $jarFile"
exit 1
}
$jarItem = Get-Item $jarFile
$jarSizeKB = [math]::Round($jarItem.Length / 1KB, 1)
Write-Host "`n=== Build Successful ===`n" -ForegroundColor Green
Write-LogSuccess "Executable JAR created: $jarFile (${jarSizeKB} KB)"
Write-Host @"
To run j3270:
java -jar "$jarFile"
# Or with arguments:
java -jar "$jarFile" mainframe.example.com 23 4
"@ -ForegroundColor White
# ------------------------------------------------------------------------------
# 8. Run Tests (if requested)
# ------------------------------------------------------------------------------
if ($Test) {
Write-Host "`n=== Running Unit Tests ===`n" -ForegroundColor White
$testScript = Join-Path $ScriptDir "test_all.sh"
if (Test-Path $testScript) {
& sh $testScript
} else {
Write-LogWarn "No test runner script found at $testScript"
}
}
# ------------------------------------------------------------------------------
# 9. Launch Application (if requested)
# ------------------------------------------------------------------------------
if ($Run) {
Write-LogInfo "Launching j3270..."
if ($isWindows) {
Start-Process -FilePath $detectedJdk.Java -ArgumentList (@("-jar", "`"$jarFile`"") + $RunArgs)
} else {
& $detectedJdk.Java -jar $jarFile @RunArgs
}
}
Executable
+428
View File
@@ -0,0 +1,428 @@
#!/bin/sh
# ==============================================================================
# j3270 Setup Script (POSIX Shell)
# Detects Java 11+ JDK and builds j3270 standalone executable JAR.
#
# Usage:
# ./setup.sh [options] [-- run-arguments]
#
# Options:
# -r, --run Launch j3270 immediately after successful build
# -t, --test Run test suite after build
# -c, --clean Clean build directory before compiling
# -h, --help Show this help message
# ==============================================================================
set -e
# Resolve directory of this script
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
# ANSI color codes (if output is to an interactive terminal)
if [ -t 1 ]; then
BOLD="\033[1m"
GREEN="\033[1;32m"
YELLOW="\033[1;33m"
CYAN="\033[1;36m"
RED="\033[1;31m"
RESET="\033[0m"
else
BOLD=""
GREEN=""
YELLOW=""
CYAN=""
RED=""
RESET=""
fi
log_info() { printf "%b[INFO]%b %s\n" "$CYAN" "$RESET" "$*"; }
log_success() { printf "%b[OK]%b %s\n" "$GREEN" "$RESET" "$*"; }
log_warn() { printf "%b[WARN]%b %s\n" "$YELLOW" "$RESET" "$*"; }
log_error() { printf "%b[ERROR]%b %s\n" "$RED" "$RESET" "$*"; }
show_help() {
cat << EOF
j3270 Terminal Emulator - Setup and Build Script
Usage:
./setup.sh [options] [-- run-arguments]
Options:
-r, --run Build and immediately launch j3270
-t, --test Run automated unit tests after build
-c, --clean Remove previous build artifacts before building
-h, --help Show this help message
Examples:
./setup.sh
./setup.sh --clean
./setup.sh --run
./setup.sh --run -- mainframe.example.com 23 4
./setup.sh --test
EOF
}
# Parse command line flags
DO_RUN=0
DO_TEST=0
DO_CLEAN=0
RUN_ARGS=""
while [ $# -gt 0 ]; do
case "$1" in
-r|--run)
DO_RUN=1
shift
;;
-t|--test)
DO_TEST=1
shift
;;
-c|--clean)
DO_CLEAN=1
shift
;;
-h|--help)
show_help
exit 0
;;
--)
shift
RUN_ARGS="$*"
break
;;
*)
RUN_ARGS="$*"
break
;;
esac
done
printf "\n%b=== j3270 Setup & Build ===%b\n\n" "$BOLD" "$RESET"
# ------------------------------------------------------------------------------
# 1. Java Version & JDK Toolchain Detection
# ------------------------------------------------------------------------------
# Extracts the integer major version from a javac or java version string
# Examples: "javac 11.0.19" -> 11, "javac 17.0.2" -> 17, "javac 1.8.0_292" -> 8
parse_major_version() {
raw_str="$1"
ver_token=$(echo "$raw_str" | sed -n \
-e 's/.*javac *\([0-9][0-9._]*\).*/\1/p' \
-e 's/.*version *"\([0-9][0-9._]*\)".*/\1/p' \
-e 's/.*version *\([0-9][0-9._]*\).*/\1/p' | head -n 1)
if [ -n "$ver_token" ]; then
echo "$ver_token" | sed -e 's/^1\.//' -e 's/[^0-9].*//'
fi
}
# Checks if a given directory contains a valid JDK >= 11
# Sets JAVAC_BIN, JAR_BIN, JAVA_BIN, JAVA_VERSION if valid
test_jdk_candidate() {
cand_home="$1"
cand_javac=""
cand_jar=""
cand_java=""
if [ -n "$cand_home" ] && [ -d "$cand_home" ]; then
if [ -x "$cand_home/bin/javac" ]; then cand_javac="$cand_home/bin/javac"; fi
if [ -x "$cand_home/bin/jar" ]; then cand_jar="$cand_home/bin/jar"; fi
if [ -x "$cand_home/bin/java" ]; then cand_java="$cand_home/bin/java"; fi
fi
if [ -z "$cand_javac" ] || [ -z "$cand_jar" ] || [ -z "$cand_java" ]; then
return 1
fi
# Verify that javac actually executes successfully
if ! "$cand_javac" -version >/dev/null 2>&1; then
return 1
fi
raw_ver=$("$cand_javac" -version 2>&1)
major_num=$(parse_major_version "$raw_ver")
if [ -n "$major_num" ] && [ "$major_num" -ge 11 ] 2>/dev/null; then
JAVAC_BIN="$cand_javac"
JAR_BIN="$cand_jar"
JAVA_BIN="$cand_java"
JAVA_VERSION="$raw_ver"
DETECTED_JAVA_HOME="$cand_home"
return 0
fi
return 1
}
JAVAC_BIN=""
JAR_BIN=""
JAVA_BIN=""
JAVA_VERSION=""
DETECTED_JAVA_HOME=""
log_info "Searching for a compatible Java Development Kit (JDK 11+)..."
# Strategy A: Check user-specified JAVA_HOME
if [ -n "$JAVA_HOME" ]; then
if test_jdk_candidate "$JAVA_HOME"; then
log_info "Found valid JDK in \$JAVA_HOME"
fi
fi
# Strategy B: Check current PATH commands (handling potential macOS /usr/bin stubs)
if [ -z "$JAVAC_BIN" ] && command -v javac >/dev/null 2>&1 && command -v jar >/dev/null 2>&1 && command -v java >/dev/null 2>&1; then
if javac -version >/dev/null 2>&1; then
raw_ver=$(javac -version 2>&1)
major_num=$(parse_major_version "$raw_ver")
if [ -n "$major_num" ] && [ "$major_num" -ge 11 ] 2>/dev/null; then
JAVAC_BIN="$(command -v javac)"
JAR_BIN="$(command -v jar)"
JAVA_BIN="$(command -v java)"
JAVA_VERSION="$raw_ver"
log_info "Found valid JDK on system PATH"
fi
fi
fi
# Strategy C: macOS java_home utility
if [ -z "$JAVAC_BIN" ] && [ -x /usr/libexec/java_home ]; then
for v in 11 17 21 24 ""; do
if [ -n "$v" ]; then
mhome="$(/usr/libexec/java_home -v "$v+" 2>/dev/null || true)"
else
mhome="$(/usr/libexec/java_home 2>/dev/null || true)"
fi
if [ -n "$mhome" ] && test_jdk_candidate "$mhome"; then
log_info "Found macOS JDK via /usr/libexec/java_home: $mhome"
break
fi
done
fi
# Strategy D: Check standard package manager & OS installation directories
if [ -z "$JAVAC_BIN" ]; then
candidate_paths="
/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home
/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
/opt/homebrew/opt/openjdk@11/libexec/openjdk.jdk/Contents/Home
/opt/homebrew/opt/openjdk
/opt/homebrew/opt/openjdk@21
/opt/homebrew/opt/openjdk@17
/opt/homebrew/opt/openjdk@11
/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home
/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
/usr/local/opt/openjdk@11/libexec/openjdk.jdk/Contents/Home
/usr/local/opt/openjdk
/Library/Java/JavaVirtualMachines/*/Contents/Home
$HOME/Library/Java/JavaVirtualMachines/*/Contents/Home
/usr/lib/jvm/default-java
/usr/lib/jvm/java-21-openjdk*
/usr/lib/jvm/java-17-openjdk*
/usr/lib/jvm/java-11-openjdk*
/usr/lib/jvm/temurin-21*
/usr/lib/jvm/temurin-17*
/usr/lib/jvm/temurin-11*
/usr/lib/jvm/semeru-21*
/usr/lib/jvm/semeru-17*
/usr/lib/jvm/semeru-11*
/usr/lib/jvm/zulu-21*
/usr/lib/jvm/zulu-17*
/usr/lib/jvm/zulu-11*
/usr/lib/jvm/*
/usr/java/*
/usr/local/openjdk21
/usr/local/openjdk17
/usr/local/openjdk11
/usr/local/openjdk*
$HOME/.sdkman/candidates/java/current
$HOME/.sdkman/candidates/java/*
$HOME/.asdf/installs/java/*
$HOME/.jenv/versions/*
"
for cand in $candidate_paths; do
if [ -d "$cand" ] && test_jdk_candidate "$cand"; then
log_info "Found compatible JDK at: $cand"
break
fi
done
fi
# If no compatible JDK is found, provide OS-tailored installation help
if [ -z "$JAVAC_BIN" ] || [ -z "$JAR_BIN" ] || [ -z "$JAVA_BIN" ]; then
log_error "No compatible Java Development Kit (JDK 11+) was found on your system."
printf "\n"
printf "j3270 requires a Java Development Kit (JDK) version 11 or higher (LTS 11, 17, or 21).\n"
printf "Note: A JRE (runtime only) is not sufficient; the compiler (javac) and archiver (jar) are required.\n\n"
printf "%bTo install JDK 11+ on your system:%b\n\n" "$BOLD" "$RESET"
UNAME_S="$(uname -s 2>/dev/null || echo "Unknown")"
case "$UNAME_S" in
Darwin)
printf " • Using Homebrew:\n"
printf " brew install openjdk@17\n"
printf " sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk\n\n"
printf " • Or download Eclipse Temurin (Adoptium PKG installer):\n"
printf " https://adoptium.net\n\n"
;;
Linux)
if command -v apt-get >/dev/null 2>&1; then
printf " • Debian / Ubuntu / Linux Mint:\n"
printf " sudo apt update && sudo apt install -y openjdk-17-jdk\n\n"
elif command -v dnf >/dev/null 2>&1; then
printf " • Fedora / RHEL / CentOS / Rocky / AlmaLinux:\n"
printf " sudo dnf install -y java-17-openjdk-devel\n\n"
elif command -v pacman >/dev/null 2>&1; then
printf " • Arch Linux / Manjaro:\n"
printf " sudo pacman -S jdk17-openjdk\n\n"
elif command -v apk >/dev/null 2>&1; then
printf " • Alpine Linux:\n"
printf " sudo apk add openjdk17\n\n"
elif command -v zypper >/dev/null 2>&1; then
printf " • openSUSE:\n"
printf " sudo zypper install -y java-17-openjdk-devel\n\n"
else
printf " • Install JDK 17 via your system package manager or SDKMAN:\n"
printf " curl -s \"https://get.sdkman.io\" | bash\n"
printf " sdk install java 17.0.10-tem\n\n"
fi
;;
FreeBSD|OpenBSD|NetBSD)
printf " • BSD package manager:\n"
printf " pkg install openjdk17\n\n"
;;
*)
printf " • Download Eclipse Temurin JDK:\n"
printf " https://adoptium.net\n\n"
;;
esac
printf "After installing, re-run ./setup.sh\n\n"
exit 1
fi
if [ -n "$DETECTED_JAVA_HOME" ]; then
export JAVA_HOME="$DETECTED_JAVA_HOME"
fi
log_success "JDK verified: $JAVA_VERSION"
log_info "Java compiler: $JAVAC_BIN"
log_info "JAR packager: $JAR_BIN"
log_info "Java runtime: $JAVA_BIN"
if [ -n "$JAVA_HOME" ]; then
log_info "JAVA_HOME: $JAVA_HOME"
fi
printf "\n"
# ------------------------------------------------------------------------------
# 2. Preparation & Clean
# ------------------------------------------------------------------------------
BUILD_DIR="$SCRIPT_DIR/build"
if [ "$DO_CLEAN" -eq 1 ]; then
log_info "Cleaning build directory ($BUILD_DIR)..."
rm -rf "$BUILD_DIR/lib3270j" "$BUILD_DIR/j3270" "$BUILD_DIR/MANIFEST.MF" "$BUILD_DIR/j3270.jar"
fi
mkdir -p "$BUILD_DIR/lib3270j" "$BUILD_DIR/j3270"
# ------------------------------------------------------------------------------
# 3. Compile Core Protocol Engine (lib3270j)
# ------------------------------------------------------------------------------
log_info "Compiling lib3270j core engine..."
find "$SCRIPT_DIR/lib3270j/src/main/java" -name "*.java" | sed 's/^/"/;s/$/"/' > "$BUILD_DIR/lib_sources.txt"
if [ ! -s "$BUILD_DIR/lib_sources.txt" ]; then
log_error "No source files found in lib3270j/src/main/java"
exit 1
fi
"$JAVAC_BIN" -d "$BUILD_DIR/lib3270j" @"$BUILD_DIR/lib_sources.txt"
rm -f "$BUILD_DIR/lib_sources.txt"
log_success "lib3270j compiled successfully."
# ------------------------------------------------------------------------------
# 4. Compile Desktop Application (j3270)
# ------------------------------------------------------------------------------
log_info "Compiling j3270 terminal emulator..."
find "$SCRIPT_DIR/j3270/src/main/java" -name "*.java" | sed 's/^/"/;s/$/"/' > "$BUILD_DIR/app_sources.txt"
if [ ! -s "$BUILD_DIR/app_sources.txt" ]; then
log_error "No source files found in j3270/src/main/java"
exit 1
fi
"$JAVAC_BIN" -cp "$BUILD_DIR/lib3270j" -d "$BUILD_DIR/j3270" @"$BUILD_DIR/app_sources.txt"
rm -f "$BUILD_DIR/app_sources.txt"
log_success "j3270 application compiled successfully."
# Copy any resources if present
if [ -d "$SCRIPT_DIR/j3270/src/main/resources" ]; then
cp -r "$SCRIPT_DIR/j3270/src/main/resources/"* "$BUILD_DIR/j3270/" 2>/dev/null || true
fi
if [ -d "$SCRIPT_DIR/lib3270j/src/main/resources" ]; then
cp -r "$SCRIPT_DIR/lib3270j/src/main/resources/"* "$BUILD_DIR/lib3270j/" 2>/dev/null || true
fi
# ------------------------------------------------------------------------------
# 5. Package Standalone Executable JAR
# ------------------------------------------------------------------------------
log_info "Packaging standalone executable JAR..."
cat << 'EOF' > "$BUILD_DIR/MANIFEST.MF"
Manifest-Version: 1.0
Main-Class: haus.nightmare.j3270.J3270App
Implementation-Title: j3270
Implementation-Version: 0.1.0
Created-By: j3270 setup.sh
EOF
"$JAR_BIN" cvfm "$BUILD_DIR/j3270.jar" "$BUILD_DIR/MANIFEST.MF" \
-C "$BUILD_DIR/lib3270j" . \
-C "$BUILD_DIR/j3270" . > /dev/null
if [ ! -f "$BUILD_DIR/j3270.jar" ]; then
log_error "Failed to create $BUILD_DIR/j3270.jar"
exit 1
fi
JAR_SIZE=$(ls -lh "$BUILD_DIR/j3270.jar" | awk '{print $5}')
printf "\n%b=== Build Successful ===%b\n" "$BOLD" "$RESET"
log_success "Executable JAR: $BUILD_DIR/j3270.jar ($JAR_SIZE)"
printf "\n%bTo run j3270:%b\n" "$BOLD" "$RESET"
printf " ./run.sh\n"
printf " # Or directly:\n"
printf " %s -jar \"%s/build/j3270.jar\"\n\n" "$JAVA_BIN" "$SCRIPT_DIR"
# ------------------------------------------------------------------------------
# 6. Run Tests (if requested)
# ------------------------------------------------------------------------------
if [ "$DO_TEST" -eq 1 ]; then
printf "%b=== Running Unit Tests ===%b\n" "$BOLD" "$RESET"
if [ -x "$SCRIPT_DIR/test_all.sh" ]; then
sh "$SCRIPT_DIR/test_all.sh"
elif [ -x "$SCRIPT_DIR/build_all.sh" ]; then
sh "$SCRIPT_DIR/build_all.sh" test
fi
fi
# ------------------------------------------------------------------------------
# 7. Launch Application (if requested)
# ------------------------------------------------------------------------------
if [ "$DO_RUN" -eq 1 ]; then
log_info "Launching j3270..."
if [ "$(uname -s)" = "Darwin" ]; then
# On macOS, enable native application menu bar
exec "$JAVA_BIN" -Dapple.laf.useScreenMenuBar=true \
-Dapple.awt.application.name=j3270 \
-jar "$BUILD_DIR/j3270.jar" $RUN_ARGS
else
exec "$JAVA_BIN" -jar "$BUILD_DIR/j3270.jar" $RUN_ARGS
fi
fi
+22 -6
View File
@@ -17,15 +17,31 @@ if [ -n "$JAVA_HOME" ] && [ -x "$JAVA_HOME/bin/javac" ] && [ -x "$JAVA_HOME/bin/
JAVAC_BIN="$JAVA_HOME/bin/javac"
JAVA_BIN="$JAVA_HOME/bin/java"
elif command -v javac >/dev/null 2>&1 && command -v java >/dev/null 2>&1; then
JAVAC_BIN="$(command -v javac)"
JAVA_BIN="$(command -v java)"
else
for h in "$HOME/.sdkman/candidates/java/current" \
if javac -version >/dev/null 2>&1; then
JAVAC_BIN="$(command -v javac)"
JAVA_BIN="$(command -v java)"
fi
fi
if [ -z "$JAVAC_BIN" ] || [ -z "$JAVA_BIN" ]; then
if [ -x /usr/libexec/java_home ]; then
MAC_JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null || true)"
if [ -n "$MAC_JAVA_HOME" ] && [ -x "$MAC_JAVA_HOME/bin/javac" ] && [ -x "$MAC_JAVA_HOME/bin/java" ]; then
export JAVA_HOME="$MAC_JAVA_HOME"
JAVAC_BIN="$JAVA_HOME/bin/javac"
JAVA_BIN="$JAVA_HOME/bin/java"
fi
fi
fi
if [ -z "$JAVAC_BIN" ] || [ -z "$JAVA_BIN" ]; then
for h in /usr/lib/jvm/default-java \
/usr/lib/jvm/java-21-openjdk* \
/usr/lib/jvm/java-17-openjdk* \
/usr/lib/jvm/java-11-openjdk* \
/usr/lib/jvm/default-java \
/Library/Java/JavaVirtualMachines/*/Contents/Home; do
/Library/Java/JavaVirtualMachines/*/Contents/Home \
/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home \
/opt/homebrew/opt/openjdk; do
if [ -d "$h" ] && [ -x "$h/bin/javac" ] && [ -x "$h/bin/java" ]; then
export JAVA_HOME="$h"
JAVAC_BIN="$JAVA_HOME/bin/javac"