Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d8acd713af
|
|||
|
dde5918da8
|
|||
|
03d5ec0fae
|
|||
|
f8d078a23b
|
|||
|
cf0c0999bf
|
|||
|
fac6ac8bfb
|
|||
|
daac11472d
|
|||
|
8d5672c708
|
|||
|
9918c823c2
|
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Make scripts executable
|
||||
run: |
|
||||
chmod +x ./build_all.sh ./test_all.sh ./run.sh
|
||||
chmod +x ./setup.sh ./test_all.sh
|
||||
|
||||
- name: Build & Run Unit Tests
|
||||
run: |
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Make scripts executable
|
||||
run: |
|
||||
chmod +x ./build_all.sh ./test_all.sh ./run.sh
|
||||
chmod +x ./setup.sh ./test_all.sh
|
||||
|
||||
- name: Build & Run Unit Tests
|
||||
run: |
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Make scripts executable
|
||||
run: |
|
||||
chmod +x ./build_all.sh ./test_all.sh ./run.sh
|
||||
chmod +x ./setup.sh ./test_all.sh
|
||||
|
||||
- name: Build & Run Unit Tests
|
||||
run: |
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Make scripts executable
|
||||
run: |
|
||||
chmod +x ./build_all.sh ./test_all.sh ./run.sh
|
||||
chmod +x ./setup.sh ./test_all.sh
|
||||
|
||||
- name: Build & Run Unit Tests
|
||||
run: |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[](https://git.hugfreevikings.wtf/rudi/j3270/actions)
|
||||
[](https://git.hugfreevikings.wtf/rudi/j3270/releases)
|
||||
[-blue.svg)](https://adoptium.net)
|
||||
[]()
|
||||
[]()
|
||||
[](LICENSE)
|
||||
|
||||
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.
|
||||
@@ -12,10 +12,11 @@ An **x3270-aligned and IBM Host On-Demand (HoD) compatible IBM 3270 mainframe te
|
||||
|
||||
## 🚀 Quick Download & Run
|
||||
|
||||
### 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:
|
||||
### Latest Standalone JARs & Maven Artifacts
|
||||
Ready-to-run executable JARs and standalone library packages 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)**
|
||||
- **Standalone Protocol Library (`lib3270j.jar`)**: Built by setup/build scripts and published to Maven (`haus.nightmare.j3270:lib3270j:1.2.4`).
|
||||
- **[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)
|
||||
@@ -61,8 +62,8 @@ java -jar j3270.jar -c config.ini
|
||||
# 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
|
||||
# Launch directly from source setup script
|
||||
./setup.sh --run
|
||||
```
|
||||
|
||||
---
|
||||
@@ -105,7 +106,7 @@ java -jar j3270.jar -d mainframe.example.com
|
||||
- **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).
|
||||
- **40+ EBCDIC Codepages & Euro Variants**: 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), Japanese SBCS (290, 1027), and 10 full Euro variants (1140–1149).
|
||||
- **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.
|
||||
|
||||
@@ -125,7 +126,7 @@ java -jar j3270.jar -d mainframe.example.com
|
||||
- **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).
|
||||
- **`lib3270j`**: Standalone Java protocol and terminal engine with **zero third-party dependencies** (`java.base`, `java.desktop` only). Distributed as standalone JAR (`build/lib3270j.jar`) and published Maven coordinate `haus.nightmare.j3270:lib3270j:1.2.4`.
|
||||
- **`j3270`**: Standalone desktop Swing terminal application.
|
||||
|
||||
---
|
||||
@@ -159,16 +160,12 @@ Self-contained setup scripts automatically locate an installed Java 11+ JDK (sea
|
||||
# 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:
|
||||
### Standalone Testing & CI
|
||||
Run automated tests directly via the standalone test runner:
|
||||
|
||||
```bash
|
||||
# Direct compile and package without diagnostics:
|
||||
sh ./build_all.sh
|
||||
|
||||
# Run all 539+ automated unit tests (~7s):
|
||||
# Run all 851 automated unit tests (~25s):
|
||||
sh ./test_all.sh
|
||||
```
|
||||
|
||||
@@ -186,6 +183,7 @@ All pushes and pull requests are verified across **Java 11, 17, and 21 LTS** run
|
||||
## 📜 Acknowledgements
|
||||
|
||||
- [x3270](https://x3270.miraheze.org/wiki/Main_Page) — Reference C implementation and protocol specifications
|
||||
- IBM Bob — Architectural evaluation, Host On-Demand parity audit, and efficiency analysis
|
||||
- [Antigravity](https://antigravity.google/)
|
||||
- Claude Opus 4.6
|
||||
- Gemini 3.1 Pro
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
allprojects {
|
||||
group = 'haus.nightmare.j3270'
|
||||
version = '0.1.0'
|
||||
version = '1.2.4'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -23,6 +23,11 @@ subprojects {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(Javadoc).configureEach {
|
||||
options.addStringOption('Xdoclint:none', '-quiet')
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "=== Building j3270 Terminal Emulator ==="
|
||||
|
||||
# 1. Locate Java toolchain
|
||||
JAVAC_BIN=""
|
||||
JAR_BIN=""
|
||||
|
||||
if [ -n "$JAVA_HOME" ] && [ -x "$JAVA_HOME/bin/javac" ] && [ -x "$JAVA_HOME/bin/jar" ]; then
|
||||
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
|
||||
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* \
|
||||
/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"
|
||||
JAR_BIN="$JAVA_HOME/bin/jar"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$JAVAC_BIN" ] || [ -z "$JAR_BIN" ]; then
|
||||
echo "ERROR: Java development tools (javac, jar) could not be found."
|
||||
echo "Please install JDK 11+ or set JAVA_HOME."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using Java compiler: $JAVAC_BIN"
|
||||
echo "Using JAR utility: $JAR_BIN"
|
||||
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
rm -rf "$BUILD_DIR/lib3270j" "$BUILD_DIR/j3270" "$BUILD_DIR/MANIFEST.MF"
|
||||
mkdir -p "$BUILD_DIR/lib3270j" "$BUILD_DIR/j3270"
|
||||
|
||||
# 2. Compile lib3270j
|
||||
echo "Compiling lib3270j..."
|
||||
find "$SCRIPT_DIR/lib3270j/src/main/java" -name "*.java" > "$BUILD_DIR/lib_sources.txt"
|
||||
if [ ! -s "$BUILD_DIR/lib_sources.txt" ]; then
|
||||
echo "ERROR: No sources found in lib3270j/src/main/java"
|
||||
exit 1
|
||||
fi
|
||||
"$JAVAC_BIN" -d "$BUILD_DIR/lib3270j" @"$BUILD_DIR/lib_sources.txt"
|
||||
|
||||
# 3. Compile j3270
|
||||
echo "Compiling j3270 application..."
|
||||
find "$SCRIPT_DIR/j3270/src/main/java" -name "*.java" > "$BUILD_DIR/app_sources.txt"
|
||||
if [ ! -s "$BUILD_DIR/app_sources.txt" ]; then
|
||||
echo "ERROR: No sources found in j3270/src/main/java"
|
||||
exit 1
|
||||
fi
|
||||
"$JAVAC_BIN" -cp "$BUILD_DIR/lib3270j" -d "$BUILD_DIR/j3270" @"$BUILD_DIR/app_sources.txt"
|
||||
|
||||
# Clean up temporary source lists
|
||||
rm -f "$BUILD_DIR/lib_sources.txt" "$BUILD_DIR/app_sources.txt"
|
||||
|
||||
# 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
|
||||
|
||||
# 4. Package executable JAR
|
||||
echo "Packaging j3270.jar..."
|
||||
printf "Manifest-Version: 1.0\nMain-Class: haus.nightmare.j3270.J3270App\nImplementation-Title: j3270\nImplementation-Version: 0.1.0\nCreated-By: j3270 build_all.sh\n\n" > "$BUILD_DIR/MANIFEST.MF"
|
||||
|
||||
"$JAR_BIN" cvfm "$BUILD_DIR/j3270.jar" "$BUILD_DIR/MANIFEST.MF" \
|
||||
-C "$BUILD_DIR/lib3270j" . \
|
||||
-C "$BUILD_DIR/j3270" . > /dev/null
|
||||
|
||||
echo "=== Build Complete ==="
|
||||
echo "Executable JAR created: $BUILD_DIR/j3270.jar"
|
||||
ls -lh "$BUILD_DIR/j3270.jar"
|
||||
|
||||
# 5. Compile and run tests if requested
|
||||
if [ "$1" = "test" ] || [ "$1" = "check" ]; then
|
||||
echo "=== Running Tests ==="
|
||||
TEST_BUILD_DIR="$BUILD_DIR/test-classes"
|
||||
mkdir -p "$TEST_BUILD_DIR"
|
||||
JUNIT_CP=$(find "$HOME/.gradle/caches/modules-2/files-2.1/" -name "*.jar" 2>/dev/null | grep -E "junit|opentest|apiguardian" | tr "\n" ":" || true)
|
||||
FULL_CP="$BUILD_DIR/lib3270j:$BUILD_DIR/j3270:$JUNIT_CP"
|
||||
|
||||
find "$SCRIPT_DIR/lib3270j/src/test/java" -name "*.java" > "$BUILD_DIR/test_sources.txt" 2>/dev/null || true
|
||||
if [ -d "$SCRIPT_DIR/j3270/src/test/java" ]; then
|
||||
find "$SCRIPT_DIR/j3270/src/test/java" -name "*.java" >> "$BUILD_DIR/test_sources.txt" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -s "$BUILD_DIR/test_sources.txt" ] && [ -n "$JUNIT_CP" ]; then
|
||||
echo "Compiling tests..."
|
||||
"$JAVAC_BIN" -cp "$FULL_CP" -d "$TEST_BUILD_DIR" @"$BUILD_DIR/test_sources.txt"
|
||||
rm -f "$BUILD_DIR/test_sources.txt"
|
||||
|
||||
echo "Executing tests with JUnit Platform..."
|
||||
cat << 'EOF' > "$BUILD_DIR/TestRunner.java"
|
||||
import org.junit.platform.launcher.*;
|
||||
import org.junit.platform.launcher.core.*;
|
||||
import org.junit.platform.launcher.listeners.*;
|
||||
import org.junit.platform.engine.discovery.*;
|
||||
import static org.junit.platform.engine.discovery.DiscoverySelectors.*;
|
||||
import java.io.PrintWriter;
|
||||
|
||||
public class TestRunner {
|
||||
public static void main(String[] args) {
|
||||
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
|
||||
.selectors(
|
||||
selectPackage("haus.nightmare")
|
||||
)
|
||||
.build();
|
||||
|
||||
Launcher launcher = LauncherFactory.create();
|
||||
SummaryGeneratingListener listener = new SummaryGeneratingListener();
|
||||
launcher.registerTestExecutionListeners(listener);
|
||||
launcher.execute(request);
|
||||
|
||||
TestExecutionSummary summary = listener.getSummary();
|
||||
summary.printTo(new PrintWriter(System.out));
|
||||
summary.printFailuresTo(new PrintWriter(System.err));
|
||||
|
||||
System.exit(summary.getTotalFailureCount() > 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
EOF
|
||||
"$JAVAC_BIN" -cp "$FULL_CP:$TEST_BUILD_DIR" -d "$BUILD_DIR" "$BUILD_DIR/TestRunner.java"
|
||||
JAVA_BIN_EXEC="${JAVA_HOME:+$JAVA_HOME/bin/java}"
|
||||
JAVA_BIN_EXEC="${JAVA_BIN_EXEC:-java}"
|
||||
"$JAVA_BIN_EXEC" -cp "$FULL_CP:$TEST_BUILD_DIR:$BUILD_DIR" TestRunner
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This repository includes Gitea Actions workflows in `.gitea/workflows/build.yaml` to automatically:
|
||||
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`.
|
||||
2. Build standalone executables for each LTS version via `setup.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,12 +98,12 @@ 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 539 unit tests (~7s):
|
||||
# Compile and run all 851 unit tests (~25s):
|
||||
sh ./test_all.sh
|
||||
|
||||
# Build executable standalone JAR:
|
||||
sh ./build_all.sh
|
||||
./setup.sh
|
||||
|
||||
# Run the emulator:
|
||||
sh ./run.sh
|
||||
java -jar build/j3270.jar
|
||||
```
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
android.useAndroidX=true
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
# Copyright © 2015 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.
|
||||
@@ -15,10 +15,12 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
@@ -27,7 +29,7 @@
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
@@ -55,7 +57,7 @@
|
||||
# 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
|
||||
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
@@ -84,7 +86,7 @@ done
|
||||
# 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
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
@@ -112,7 +114,6 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -170,7 +171,6 @@ fi
|
||||
# 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" )
|
||||
|
||||
@@ -203,15 +203,14 @@ fi
|
||||
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,
|
||||
# * DEFAULT_JVM_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 \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
|
||||
Vendored
+24
-34
@@ -13,16 +13,18 @@
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@@ -43,13 +45,13 @@ 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.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -57,36 +59,24 @@ 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.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
: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 %*
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
: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
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
; j3270 Configuration File
|
||||
; Load with: java -jar j3270.jar -c j3270.ini
|
||||
; or: ./run.sh -c j3270.ini
|
||||
|
||||
[appearance]
|
||||
; UI font settings (menus, dialogs, status bar)
|
||||
|
||||
@@ -6,9 +6,11 @@ import haus.nightmare.lib3270j.graphics.GraphicsMode;
|
||||
import haus.nightmare.lib3270j.listener.ConnectionListener;
|
||||
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer;
|
||||
import haus.nightmare.lib3270j.charset.CodePageRegistry;
|
||||
import haus.nightmare.j3270.ui.*;
|
||||
import haus.nightmare.j3270.ft.FileTransfer;
|
||||
import haus.nightmare.j3270.ft.FileTransferDialog;
|
||||
import haus.nightmare.j3270.config.Settings;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
@@ -77,11 +79,18 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
private JCheckBoxMenuItem aplModeItem;
|
||||
private JCheckBoxMenuItem insertModeItem;
|
||||
private JCheckBoxMenuItem fourColorItem;
|
||||
private JCheckBoxMenuItem numLockItem;
|
||||
private JCheckBoxMenuItem autoSkipItem;
|
||||
private JCheckBoxMenuItem insertOffAidItem;
|
||||
private JCheckBoxMenuItem statusBarItem;
|
||||
|
||||
// Connection menu items
|
||||
private JMenuItem disconnectMenuItem;
|
||||
private JMenu hostTypeMenu;
|
||||
private ButtonGroup hostTypeGroup;
|
||||
private JMenu codePageMenu;
|
||||
private ButtonGroup codePageGroup;
|
||||
private JMenu graphicsModeMenu;
|
||||
private ButtonGroup graphicsModeGroup;
|
||||
private JMenu savedHostsMenu;
|
||||
|
||||
public J3270App() {
|
||||
super("j3270 — Java TN3270 Terminal Emulator");
|
||||
setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
|
||||
@@ -104,6 +113,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
if (client != null) {
|
||||
statusBar.updateStatus();
|
||||
syncModeMenuItems();
|
||||
syncConnectionMenuItems();
|
||||
if (!isModalDialogActive() && KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow() == this) {
|
||||
if (!terminalPanel.hasFocus()) {
|
||||
terminalPanel.requestFocusInWindow();
|
||||
@@ -182,7 +192,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
terminalPanel.setStatusBarToggleCallback(this::toggleStatusBar);
|
||||
terminalPanel.addModeChangeListener(this::syncModeMenuItems);
|
||||
|
||||
statusBar.setVisible(haus.nightmare.j3270.config.Settings.getStatusBarVisible());
|
||||
statusBar.setVisible(Settings.getStatusBarVisible());
|
||||
|
||||
getContentPane().setLayout(new BorderLayout());
|
||||
getContentPane().setBackground(Color.BLACK);
|
||||
@@ -195,10 +205,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
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());
|
||||
if (fourColorItem != null) fourColorItem.setSelected(Settings.getFourColorOverride());
|
||||
}
|
||||
|
||||
private void onThemeChanged(UITheme theme) {
|
||||
@@ -216,9 +223,6 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
|
||||
// 1. File menu
|
||||
JMenu fileMenu = createMenu("File");
|
||||
fileMenu.add(createMenuItem("Connect...", KeyEvent.VK_N, this::showConnectDialog));
|
||||
fileMenu.add(createMenuItem("Disconnect", KeyEvent.VK_D, this::disconnect));
|
||||
fileMenu.addSeparator();
|
||||
fileMenu.add(createMenuItem("Close Window", KeyEvent.VK_W, this::closeWindow));
|
||||
fileMenu.addSeparator();
|
||||
fileMenu.add(createMenuItem("Save Screen As...", -1, () -> ScreenExporter.showExportDialog(this, client, terminalPanel)));
|
||||
@@ -239,12 +243,13 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
// 2. Connection menu
|
||||
JMenu connectionMenu = createMenu("Connection");
|
||||
connectionMenu.add(createMenuItem("Connect...", KeyEvent.VK_N, this::showConnectDialog));
|
||||
connectionMenu.add(createMenuItem("Disconnect", KeyEvent.VK_D, this::disconnect));
|
||||
disconnectMenuItem = createMenuItem("Disconnect", KeyEvent.VK_D, this::disconnect);
|
||||
connectionMenu.add(disconnectMenuItem);
|
||||
connectionMenu.addSeparator();
|
||||
|
||||
// Host Type Submenu (CMS/TSO/CICS)
|
||||
JMenu hostTypeMenu = createMenu("Host Type");
|
||||
ButtonGroup hostTypeGroup = new ButtonGroup();
|
||||
hostTypeMenu = createMenu("Host Type");
|
||||
hostTypeGroup = new ButtonGroup();
|
||||
for (haus.nightmare.lib3270j.ft.FTConfig.HostType ht : haus.nightmare.lib3270j.ft.FTConfig.HostType.values()) {
|
||||
JRadioButtonMenuItem htItem = new JRadioButtonMenuItem(ht.name());
|
||||
ThemeManager.styleMenuItem(htItem);
|
||||
@@ -253,16 +258,44 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
hostTypeMenu.add(htItem);
|
||||
}
|
||||
connectionMenu.add(hostTypeMenu);
|
||||
|
||||
// Code Page Submenu
|
||||
codePageMenu = createMenu("Code Page");
|
||||
codePageGroup = new ButtonGroup();
|
||||
for (String cpItem : CodePageRegistry.COMMON_CODE_PAGES) {
|
||||
String cpId = cpItem.split(" -")[0].trim();
|
||||
JRadioButtonMenuItem cpMi = new JRadioButtonMenuItem(cpItem);
|
||||
cpMi.putClientProperty("cpId", cpId);
|
||||
ThemeManager.styleMenuItem(cpMi);
|
||||
codePageGroup.add(cpMi);
|
||||
cpMi.addActionListener(e -> changeCodePage(cpId));
|
||||
codePageMenu.add(cpMi);
|
||||
}
|
||||
connectionMenu.add(codePageMenu);
|
||||
|
||||
// Graphics Mode Submenu
|
||||
graphicsModeMenu = createMenu("Graphics Mode");
|
||||
graphicsModeGroup = new ButtonGroup();
|
||||
for (GraphicsMode gm : GraphicsMode.values()) {
|
||||
JRadioButtonMenuItem gmMi = new JRadioButtonMenuItem(gm.getDescription());
|
||||
gmMi.putClientProperty("mode", gm);
|
||||
ThemeManager.styleMenuItem(gmMi);
|
||||
graphicsModeGroup.add(gmMi);
|
||||
gmMi.addActionListener(e -> changeGraphicsMode(gm));
|
||||
graphicsModeMenu.add(gmMi);
|
||||
}
|
||||
connectionMenu.add(graphicsModeMenu);
|
||||
|
||||
connectionMenu.addSeparator();
|
||||
|
||||
// Saved Connections Submenu
|
||||
JMenu savedHostsMenu = createMenu("Saved Connections");
|
||||
savedHostsMenu = createMenu("Saved Connections");
|
||||
connectionMenu.add(savedHostsMenu);
|
||||
|
||||
connectionMenu.addMenuListener(new javax.swing.event.MenuListener() {
|
||||
@Override
|
||||
public void menuSelected(javax.swing.event.MenuEvent e) {
|
||||
updateConnectionMenu(hostTypeMenu, hostTypeGroup, savedHostsMenu);
|
||||
updateConnectionMenu();
|
||||
}
|
||||
@Override public void menuDeselected(javax.swing.event.MenuEvent e) {}
|
||||
@Override public void menuCanceled(javax.swing.event.MenuEvent e) {}
|
||||
@@ -285,7 +318,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
editMenu.add(createMenuItem("Find Previous", KeyEvent.VK_G, this::findPrevious, true));
|
||||
menuBar.add(editMenu);
|
||||
|
||||
// 3. View menu
|
||||
// 4. View menu
|
||||
JMenu viewMenu = createMenu("View");
|
||||
viewMenu.add(createMenuItem("Font Size +", KeyEvent.VK_EQUALS, () -> adjustFontSize(2), true));
|
||||
viewMenu.add(createMenuItem("Font Size -", KeyEvent.VK_MINUS, () -> adjustFontSize(-2), true));
|
||||
@@ -302,7 +335,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
JRadioButtonMenuItem darkThemeItem = new JRadioButtonMenuItem("Dark Mode", ThemeManager.isDark());
|
||||
ThemeManager.styleMenuItem(darkThemeItem);
|
||||
darkThemeItem.addActionListener(e -> {
|
||||
haus.nightmare.j3270.config.Settings.setJavaUiTheme(UITheme.DARK);
|
||||
Settings.setJavaUiTheme(UITheme.DARK);
|
||||
ThemeManager.setTheme(UITheme.DARK);
|
||||
});
|
||||
themeGroup.add(darkThemeItem);
|
||||
@@ -311,16 +344,24 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
JRadioButtonMenuItem lightThemeItem = new JRadioButtonMenuItem("Light Mode", ThemeManager.isLight());
|
||||
ThemeManager.styleMenuItem(lightThemeItem);
|
||||
lightThemeItem.addActionListener(e -> {
|
||||
haus.nightmare.j3270.config.Settings.setJavaUiTheme(UITheme.LIGHT);
|
||||
Settings.setJavaUiTheme(UITheme.LIGHT);
|
||||
ThemeManager.setTheme(UITheme.LIGHT);
|
||||
});
|
||||
themeGroup.add(lightThemeItem);
|
||||
themeMenu.add(lightThemeItem);
|
||||
|
||||
viewMenu.add(themeMenu);
|
||||
|
||||
fourColorItem = new JCheckBoxMenuItem("Base 4-Color Override", Settings.getFourColorOverride());
|
||||
ThemeManager.styleMenuItem(fourColorItem);
|
||||
fourColorItem.addActionListener(e -> {
|
||||
Settings.setFourColorOverride(fourColorItem.isSelected());
|
||||
terminalPanel.repaint();
|
||||
});
|
||||
viewMenu.add(fourColorItem);
|
||||
viewMenu.addSeparator();
|
||||
|
||||
statusBarItem = new JCheckBoxMenuItem("Status Bar", statusBar != null ? statusBar.isVisible() : haus.nightmare.j3270.config.Settings.getStatusBarVisible());
|
||||
statusBarItem = new JCheckBoxMenuItem("Status Bar", statusBar != null ? statusBar.isVisible() : Settings.getStatusBarVisible());
|
||||
ThemeManager.styleMenuItem(statusBarItem);
|
||||
statusBarItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_B, InputEvent.ALT_DOWN_MASK));
|
||||
statusBarItem.addActionListener(e -> setStatusBarVisible(statusBarItem.isSelected()));
|
||||
@@ -332,7 +373,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
rulerItem.addActionListener(e -> {
|
||||
boolean en = rulerItem.isSelected();
|
||||
terminalPanel.setCrosshairRulerEnabled(en);
|
||||
haus.nightmare.j3270.config.Settings.setCrosshairRuler(en);
|
||||
Settings.setCrosshairRuler(en);
|
||||
});
|
||||
viewMenu.add(rulerItem);
|
||||
|
||||
@@ -342,7 +383,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
ThemeManager.styleMenuItem(blockCursorItem);
|
||||
blockCursorItem.addActionListener(e -> {
|
||||
terminalPanel.setCursorStyle(TerminalPanel.CursorStyle.BLOCK);
|
||||
haus.nightmare.j3270.config.Settings.setCursorStyle("BLOCK");
|
||||
Settings.setCursorStyle("BLOCK");
|
||||
});
|
||||
cursorGroup.add(blockCursorItem);
|
||||
cursorMenu.add(blockCursorItem);
|
||||
@@ -351,64 +392,18 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
ThemeManager.styleMenuItem(underlineCursorItem);
|
||||
underlineCursorItem.addActionListener(e -> {
|
||||
terminalPanel.setCursorStyle(TerminalPanel.CursorStyle.UNDERLINE);
|
||||
haus.nightmare.j3270.config.Settings.setCursorStyle("UNDERLINE");
|
||||
Settings.setCursorStyle("UNDERLINE");
|
||||
});
|
||||
cursorGroup.add(underlineCursorItem);
|
||||
cursorMenu.add(underlineCursorItem);
|
||||
viewMenu.add(cursorMenu);
|
||||
viewMenu.addSeparator();
|
||||
|
||||
// CodePage Submenu
|
||||
JMenu cpMenu = createMenu("Code Page");
|
||||
String[] codePages = {
|
||||
"037 - US / Canada / Brazil",
|
||||
"1047 - IBM Open Systems / z/OS Unix",
|
||||
"500 - International Latin-1",
|
||||
"273 - Germany / Austria",
|
||||
"277 - Denmark / Norway",
|
||||
"278 - Sweden / Finland",
|
||||
"280 - Italy",
|
||||
"284 - Spain / Latin America",
|
||||
"285 - United Kingdom",
|
||||
"297 - France",
|
||||
"870 - Eastern Europe / Latin-2",
|
||||
"871 - Iceland",
|
||||
"875 - Greece (Greek)",
|
||||
"1026 - Turkey (Turkish)",
|
||||
"1140 - US / Canada (Euro \u20AC)",
|
||||
"1141 - Germany / Austria (Euro \u20AC)",
|
||||
"1148 - International (Euro \u20AC)",
|
||||
"930 - Japanese Katakana Mixed DBCS",
|
||||
"939 - Japanese Latin Mixed DBCS",
|
||||
"935 - Simplified Chinese Mixed DBCS",
|
||||
"937 - Traditional Chinese Mixed DBCS",
|
||||
"933 - Korean Mixed DBCS"
|
||||
};
|
||||
for (String cpItem : codePages) {
|
||||
String cpId = cpItem.split(" -")[0].trim();
|
||||
JMenuItem cpMi = new JMenuItem(cpItem);
|
||||
ThemeManager.styleMenuItem(cpMi);
|
||||
cpMi.addActionListener(e -> changeCodePage(cpId));
|
||||
cpMenu.add(cpMi);
|
||||
}
|
||||
viewMenu.add(cpMenu);
|
||||
|
||||
// Graphics Mode Submenu
|
||||
JMenu gfxMenu = createMenu("Graphics Mode");
|
||||
for (GraphicsMode gm : GraphicsMode.values()) {
|
||||
JMenuItem gmMi = new JMenuItem(gm.name());
|
||||
ThemeManager.styleMenuItem(gmMi);
|
||||
gmMi.addActionListener(e -> changeGraphicsMode(gm));
|
||||
gfxMenu.add(gmMi);
|
||||
}
|
||||
viewMenu.add(gfxMenu);
|
||||
|
||||
viewMenu.addSeparator();
|
||||
viewMenu.add(createMenuItem("Screen History...", KeyEvent.VK_H, this::showScreenHistoryDialog, true));
|
||||
viewMenu.add(createMenuItem("Field Inspector...", -1, this::showFieldInspectorDialog));
|
||||
menuBar.add(viewMenu);
|
||||
|
||||
// 4. Modes menu
|
||||
// 5. Modes menu
|
||||
JMenu modesMenu = createMenu("Modes");
|
||||
docModeItem = new JCheckBoxMenuItem("Document Mode (DOC)", terminalPanel.isDocMode());
|
||||
ThemeManager.styleMenuItem(docModeItem);
|
||||
@@ -434,40 +429,6 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
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) {
|
||||
@@ -478,7 +439,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
});
|
||||
menuBar.add(modesMenu);
|
||||
|
||||
// 5. Actions menu
|
||||
// 6. Actions menu
|
||||
JMenu actionsMenu = createMenu("Actions");
|
||||
actionsMenu.add(createMenuItem("Send Enter", KeyEvent.VK_ENTER, () -> {
|
||||
if (client != null && client.getConnectionState().isFullSession()) {
|
||||
@@ -524,11 +485,10 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
actionsMenu.addSeparator();
|
||||
actionsMenu.add(createMenuItem("Run Keystrokes / Script...", -1, this::showScriptDialog));
|
||||
actionsMenu.add(createMenuItem("Macro Runtime (<HASCRIPT>)...", KeyEvent.VK_M, this::showMacroDialog, true));
|
||||
actionsMenu.add(createMenuItem("Screen History...", KeyEvent.VK_H, this::showScreenHistoryDialog, true));
|
||||
actionsMenu.add(createMenuItem("File Transfer...", KeyEvent.VK_T, this::showFileTransferDialog, true));
|
||||
menuBar.add(actionsMenu);
|
||||
|
||||
// 5. Help menu
|
||||
// 7. Help menu
|
||||
JMenu helpMenu = createMenu("Help");
|
||||
helpMenu.add(createMenuItem("Key Mappings", -1, this::showKeyMappings));
|
||||
helpMenu.add(createMenuItem("Mnemonic Keystroke Reference", -1, this::showMnemonicHelp));
|
||||
@@ -536,6 +496,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
helpMenu.add(createMenuItem("About", -1, this::showAbout));
|
||||
menuBar.add(helpMenu);
|
||||
|
||||
syncConnectionMenuItems();
|
||||
setJMenuBar(menuBar);
|
||||
}
|
||||
|
||||
@@ -561,13 +522,15 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
|
||||
// ========== Dialog Openers ==========
|
||||
|
||||
private void showFindDialog() {
|
||||
public void showFindDialog() {
|
||||
if (client == null) {
|
||||
JOptionPane.showMessageDialog(this, "Connect to a host before using Find.", "Not Connected", JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (findDialog == null) {
|
||||
findDialog = new FindDialog(this, client, terminalPanel);
|
||||
} else {
|
||||
findDialog.setClient(client);
|
||||
}
|
||||
findDialog.setVisible(true);
|
||||
}
|
||||
@@ -620,13 +583,15 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
}
|
||||
}
|
||||
|
||||
private void showScriptDialog() {
|
||||
public void showScriptDialog() {
|
||||
if (client == null) {
|
||||
JOptionPane.showMessageDialog(this, "Connect to a host before running scripts.", "Not Connected", JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (scriptDialog == null) {
|
||||
scriptDialog = new ScriptDialog(this, client, terminalPanel);
|
||||
} else {
|
||||
scriptDialog.setClient(client);
|
||||
}
|
||||
scriptDialog.setVisible(true);
|
||||
}
|
||||
@@ -703,19 +668,28 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
private void changeCodePage(String cpId) {
|
||||
if (client != null) {
|
||||
client.setCodePage(cpId);
|
||||
if (client.getConfig() != null) {
|
||||
client.getConfig().setCodePage(cpId);
|
||||
}
|
||||
client.getScreenBuffer().translateToUnicode();
|
||||
terminalPanel.refreshScreen();
|
||||
statusBar.updateStatus();
|
||||
}
|
||||
haus.nightmare.j3270.config.Settings.setCodePage(cpId);
|
||||
Settings.setCodePage(cpId);
|
||||
}
|
||||
|
||||
private void changeGraphicsMode(GraphicsMode mode) {
|
||||
if (isConnected()) {
|
||||
return;
|
||||
}
|
||||
if (client != null && client.getConfig() != null) {
|
||||
client.getConfig().setGraphicsMode(mode);
|
||||
}
|
||||
if (client != null && client.getDataStreamProcessor() != null) {
|
||||
client.getDataStreamProcessor().getQueryReplyBuilder().setGraphicsMode(mode);
|
||||
terminalPanel.repaint();
|
||||
}
|
||||
haus.nightmare.j3270.config.Settings.setGraphicsMode(mode);
|
||||
Settings.setGraphicsMode(mode);
|
||||
}
|
||||
|
||||
private void printScreen() {
|
||||
@@ -814,15 +788,15 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
}
|
||||
|
||||
ConnectDialog dialog = new ConnectDialog(this);
|
||||
String initialHost = lastHost != null && !lastHost.isEmpty() ? lastHost : haus.nightmare.j3270.config.Settings.getAutoConnectHost();
|
||||
int initialPort = lastPort > 0 ? lastPort : haus.nightmare.j3270.config.Settings.getAutoConnectPort();
|
||||
String initialHost = lastHost != null && !lastHost.isEmpty() ? lastHost : Settings.getAutoConnectHost();
|
||||
int initialPort = lastPort > 0 ? lastPort : Settings.getAutoConnectPort();
|
||||
dialog.setInitialHost(initialHost);
|
||||
dialog.setInitialPort(initialPort);
|
||||
dialog.setInitialTls(haus.nightmare.j3270.config.Settings.getAutoConnectTls());
|
||||
dialog.setInitialVerifyCert(haus.nightmare.j3270.config.Settings.getAutoConnectVerifyCert());
|
||||
dialog.setInitialCodePage(haus.nightmare.j3270.config.Settings.getCodePage());
|
||||
dialog.setInitialGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode());
|
||||
dialog.setInitialHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
|
||||
dialog.setInitialTls(Settings.getAutoConnectTls());
|
||||
dialog.setInitialVerifyCert(Settings.getAutoConnectVerifyCert());
|
||||
dialog.setInitialCodePage(Settings.getCodePage());
|
||||
dialog.setInitialGraphicsMode(Settings.getGraphicsMode());
|
||||
dialog.setInitialHostType(Settings.getDefaultHostType());
|
||||
dialog.setVisible(true);
|
||||
|
||||
if (dialog.isConfirmed()) {
|
||||
@@ -835,7 +809,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
|
||||
public void setConnectionHostType(haus.nightmare.lib3270j.ft.FTConfig.HostType ht) {
|
||||
if (ht == null) return;
|
||||
haus.nightmare.j3270.config.Settings.setDefaultHostType(ht.name());
|
||||
Settings.setDefaultHostType(ht.name());
|
||||
if (client != null && client.getConfig() != null) {
|
||||
client.getConfig().setFtHostType(ht);
|
||||
String profileId = client.getConfig().getSavedProfileId();
|
||||
@@ -852,45 +826,103 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
}
|
||||
}
|
||||
|
||||
private void updateConnectionMenu(JMenu hostTypeMenu, ButtonGroup hostTypeGroup, JMenu savedHostsMenu) {
|
||||
haus.nightmare.lib3270j.ft.FTConfig.HostType currentHt = haus.nightmare.lib3270j.ft.FTConfig.HostType.TSO;
|
||||
if (client != null && client.getConfig() != null) {
|
||||
currentHt = client.getConfig().getFtHostType();
|
||||
} else {
|
||||
String def = haus.nightmare.j3270.config.Settings.getDefaultHostType();
|
||||
try {
|
||||
if (def != null) currentHt = haus.nightmare.lib3270j.ft.FTConfig.HostType.valueOf(def.trim().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
public void syncConnectionMenuItems() {
|
||||
boolean connected = isConnected();
|
||||
if (disconnectMenuItem != null) {
|
||||
disconnectMenuItem.setEnabled(connected);
|
||||
}
|
||||
if (graphicsModeMenu != null) {
|
||||
graphicsModeMenu.setEnabled(!connected);
|
||||
graphicsModeMenu.setToolTipText(connected
|
||||
? "Graphics Mode is negotiated at connection time and cannot be changed while connected."
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < hostTypeMenu.getItemCount(); i++) {
|
||||
JMenuItem item = hostTypeMenu.getItem(i);
|
||||
if (item instanceof JRadioButtonMenuItem) {
|
||||
JRadioButtonMenuItem rb = (JRadioButtonMenuItem) item;
|
||||
if (rb.getText().equalsIgnoreCase(currentHt.name())) {
|
||||
rb.setSelected(true);
|
||||
public JMenu getGraphicsModeMenu() {
|
||||
return graphicsModeMenu;
|
||||
}
|
||||
|
||||
public JMenu getCodePageMenu() {
|
||||
return codePageMenu;
|
||||
}
|
||||
|
||||
private void updateConnectionMenu() {
|
||||
syncConnectionMenuItems();
|
||||
|
||||
if (hostTypeMenu != null) {
|
||||
haus.nightmare.lib3270j.ft.FTConfig.HostType currentHt = haus.nightmare.lib3270j.ft.FTConfig.HostType.TSO;
|
||||
if (client != null && client.getConfig() != null) {
|
||||
currentHt = client.getConfig().getFtHostType();
|
||||
} else {
|
||||
String def = Settings.getDefaultHostType();
|
||||
try {
|
||||
if (def != null) currentHt = haus.nightmare.lib3270j.ft.FTConfig.HostType.valueOf(def.trim().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
for (int i = 0; i < hostTypeMenu.getItemCount(); i++) {
|
||||
JMenuItem item = hostTypeMenu.getItem(i);
|
||||
if (item instanceof JRadioButtonMenuItem) {
|
||||
JRadioButtonMenuItem rb = (JRadioButtonMenuItem) item;
|
||||
if (rb.getText().equalsIgnoreCase(currentHt.name())) {
|
||||
rb.setSelected(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
savedHostsMenu.removeAll();
|
||||
java.util.List<haus.nightmare.j3270.storage.SavedHost> savedList = haus.nightmare.j3270.storage.HostStorage.getSavedHosts();
|
||||
if (savedList.isEmpty()) {
|
||||
JMenuItem noneItem = new JMenuItem("(No Saved Connections)");
|
||||
noneItem.setEnabled(false);
|
||||
ThemeManager.styleMenuItem(noneItem);
|
||||
savedHostsMenu.add(noneItem);
|
||||
} else {
|
||||
for (haus.nightmare.j3270.storage.SavedHost sh : savedList) {
|
||||
String label = sh.getName();
|
||||
if (label == null || label.trim().isEmpty()) {
|
||||
label = sh.getHost() + ":" + sh.getPort();
|
||||
if (codePageMenu != null) {
|
||||
String currentCp = (client != null && client.getConfig() != null && client.getConfig().getCodePage() != null)
|
||||
? client.getConfig().getCodePage()
|
||||
: Settings.getCodePage();
|
||||
for (int i = 0; i < codePageMenu.getItemCount(); i++) {
|
||||
JMenuItem item = codePageMenu.getItem(i);
|
||||
if (item instanceof JRadioButtonMenuItem) {
|
||||
JRadioButtonMenuItem rb = (JRadioButtonMenuItem) item;
|
||||
Object id = rb.getClientProperty("cpId");
|
||||
if (id != null && id.toString().equalsIgnoreCase(currentCp)) {
|
||||
rb.setSelected(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (graphicsModeMenu != null) {
|
||||
GraphicsMode currentGfx = (client != null && client.getConfig() != null && client.getConfig().getGraphicsMode() != null)
|
||||
? client.getConfig().getGraphicsMode()
|
||||
: Settings.getGraphicsMode();
|
||||
for (int i = 0; i < graphicsModeMenu.getItemCount(); i++) {
|
||||
JMenuItem item = graphicsModeMenu.getItem(i);
|
||||
if (item instanceof JRadioButtonMenuItem) {
|
||||
JRadioButtonMenuItem rb = (JRadioButtonMenuItem) item;
|
||||
if (rb.getClientProperty("mode") == currentGfx) {
|
||||
rb.setSelected(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHostsMenu != null) {
|
||||
savedHostsMenu.removeAll();
|
||||
java.util.List<haus.nightmare.j3270.storage.SavedHost> savedList = haus.nightmare.j3270.storage.HostStorage.getSavedHosts();
|
||||
if (savedList.isEmpty()) {
|
||||
JMenuItem noneItem = new JMenuItem("(No Saved Connections)");
|
||||
noneItem.setEnabled(false);
|
||||
ThemeManager.styleMenuItem(noneItem);
|
||||
savedHostsMenu.add(noneItem);
|
||||
} else {
|
||||
for (haus.nightmare.j3270.storage.SavedHost sh : savedList) {
|
||||
String label = sh.getName();
|
||||
if (label == null || label.trim().isEmpty()) {
|
||||
label = sh.getHost() + ":" + sh.getPort();
|
||||
}
|
||||
String ht = (sh.getHostType() != null && !sh.getHostType().trim().isEmpty()) ? sh.getHostType().trim() : "TSO";
|
||||
JMenuItem hostItem = new JMenuItem(label + " [" + ht + "]");
|
||||
ThemeManager.styleMenuItem(hostItem);
|
||||
hostItem.addActionListener(ev -> connect(sh.toConnectionConfig(), true));
|
||||
savedHostsMenu.add(hostItem);
|
||||
}
|
||||
String ht = (sh.getHostType() != null && !sh.getHostType().trim().isEmpty()) ? sh.getHostType().trim() : "TSO";
|
||||
JMenuItem hostItem = new JMenuItem(label + " [" + ht + "]");
|
||||
ThemeManager.styleMenuItem(hostItem);
|
||||
hostItem.addActionListener(ev -> connect(sh.toConnectionConfig(), true));
|
||||
savedHostsMenu.add(hostItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -905,6 +937,18 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
if (refreshTimer != null) {
|
||||
refreshTimer.stop();
|
||||
}
|
||||
if (findDialog != null) {
|
||||
findDialog.dispose();
|
||||
findDialog = null;
|
||||
}
|
||||
if (scriptDialog != null) {
|
||||
scriptDialog.dispose();
|
||||
scriptDialog = null;
|
||||
}
|
||||
if (fieldInspectorDialog != null) {
|
||||
fieldInspectorDialog.dispose();
|
||||
fieldInspectorDialog = null;
|
||||
}
|
||||
if (printerSessionDialog != null) {
|
||||
printerSessionDialog.dispose();
|
||||
}
|
||||
@@ -928,8 +972,14 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return client != null && (client.isConnected() ||
|
||||
(client.getConnectionState() != null && client.getConnectionState().isConnected()));
|
||||
if (client == null) {
|
||||
return false;
|
||||
}
|
||||
ConnectionState state = client.getConnectionState();
|
||||
if (state == null || state == ConnectionState.NOT_CONNECTED) {
|
||||
return false;
|
||||
}
|
||||
return client.isConnected() || state.isConnected();
|
||||
}
|
||||
|
||||
public String getConnectedTarget() {
|
||||
@@ -985,6 +1035,18 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
return statusBar;
|
||||
}
|
||||
|
||||
public FindDialog getFindDialog() {
|
||||
return findDialog;
|
||||
}
|
||||
|
||||
public ScriptDialog getScriptDialog() {
|
||||
return scriptDialog;
|
||||
}
|
||||
|
||||
public FieldInspectorDialog getFieldInspectorDialog() {
|
||||
return fieldInspectorDialog;
|
||||
}
|
||||
|
||||
public void connect(ConnectionConfig config) {
|
||||
connect(config, true);
|
||||
}
|
||||
@@ -1004,19 +1066,19 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
lastHost = config.getHost();
|
||||
lastPort = config.getPort();
|
||||
|
||||
haus.nightmare.j3270.config.Settings.setAutoConnectHost(config.getHost());
|
||||
haus.nightmare.j3270.config.Settings.setAutoConnectPort(config.getPort());
|
||||
haus.nightmare.j3270.config.Settings.setAutoConnectTls(config.isUseTls());
|
||||
haus.nightmare.j3270.config.Settings.setAutoConnectVerifyCert(config.isTlsVerifyCert());
|
||||
haus.nightmare.j3270.config.Settings.setCodePage(config.getCodePage());
|
||||
Settings.setAutoConnectHost(config.getHost());
|
||||
Settings.setAutoConnectPort(config.getPort());
|
||||
Settings.setAutoConnectTls(config.isUseTls());
|
||||
Settings.setAutoConnectVerifyCert(config.isTlsVerifyCert());
|
||||
Settings.setCodePage(config.getCodePage());
|
||||
if (config.getHostType() != null) {
|
||||
haus.nightmare.j3270.config.Settings.setDefaultHostType(config.getHostType());
|
||||
Settings.setDefaultHostType(config.getHostType());
|
||||
}
|
||||
if (FileTransferDialog.getLastTransferState() != null && config.isSavedConnection()) {
|
||||
FileTransferDialog.getLastTransferState().hostType = config.getFtHostType();
|
||||
}
|
||||
if (config.getGraphicsMode() != null) {
|
||||
haus.nightmare.j3270.config.Settings.setGraphicsMode(config.getGraphicsMode());
|
||||
Settings.setGraphicsMode(config.getGraphicsMode());
|
||||
}
|
||||
|
||||
Telnet3270Client oldClient = client;
|
||||
@@ -1045,6 +1107,15 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
|
||||
terminalPanel.setClient(client);
|
||||
statusBar.setClient(client, terminalPanel);
|
||||
if (findDialog != null) {
|
||||
findDialog.setClient(client);
|
||||
}
|
||||
if (scriptDialog != null) {
|
||||
scriptDialog.setClient(client);
|
||||
}
|
||||
if (fieldInspectorDialog != null) {
|
||||
fieldInspectorDialog.setClient(client);
|
||||
}
|
||||
|
||||
String tlsIndicator = config.isUseTls() ? (config.isTlsVerifyCert() ? " [TLS]" : " [TLS/Unverified]") : "";
|
||||
setTitle("j3270 — " + config.getHost() + ":" + config.getPort() + tlsIndicator);
|
||||
@@ -1101,6 +1172,15 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
client = null;
|
||||
c.removeConnectionListener(this);
|
||||
c.removeScreenUpdateListener(this);
|
||||
if (findDialog != null) {
|
||||
findDialog.setClient(null);
|
||||
}
|
||||
if (scriptDialog != null) {
|
||||
scriptDialog.setClient(null);
|
||||
}
|
||||
if (fieldInspectorDialog != null) {
|
||||
fieldInspectorDialog.setClient(null);
|
||||
}
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
@@ -1113,6 +1193,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
terminalPanel.setClient(null);
|
||||
statusBar.setClient(null, terminalPanel);
|
||||
syncModeMenuItems();
|
||||
syncConnectionMenuItems();
|
||||
terminalPanel.repaint();
|
||||
setTitle("j3270 — Java TN3270 Terminal Emulator");
|
||||
};
|
||||
@@ -1142,7 +1223,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
if (statusBar != null) {
|
||||
statusBar.setVisible(visible);
|
||||
}
|
||||
haus.nightmare.j3270.config.Settings.setStatusBarVisible(visible);
|
||||
Settings.setStatusBarVisible(visible);
|
||||
if (statusBarItem != null) {
|
||||
statusBarItem.setSelected(visible);
|
||||
}
|
||||
@@ -1159,6 +1240,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) {
|
||||
log.info("Connection state: " + oldState + " -> " + newState);
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
syncConnectionMenuItems();
|
||||
statusBar.updateStatus();
|
||||
terminalPanel.repaint();
|
||||
|
||||
@@ -1166,6 +1248,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
setTitle("j3270 — " + lastHost + ":" + lastPort + " [Reconnecting...]");
|
||||
} else if (newState == ConnectionState.NOT_CONNECTED) {
|
||||
setTitle("j3270 — Java TN3270 Terminal Emulator");
|
||||
syncModeMenuItems();
|
||||
} else if (newState.isFullSession() && !oldState.isFullSession()) {
|
||||
String tlsIndicator = (client != null && client.getConfig().isUseTls()) ?
|
||||
(client.getConfig().isTlsVerifyCert() ? " [TLS]" : " [TLS/Unverified]") : "";
|
||||
@@ -1203,6 +1286,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
if (screenUpdatePending.compareAndSet(false, true)) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
screenUpdatePending.set(false);
|
||||
terminalPanel.onScreenUpdated();
|
||||
if (fileTransfer != null) {
|
||||
fileTransfer.onScreenUpdated();
|
||||
}
|
||||
@@ -1224,6 +1308,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
@Override
|
||||
public void onScreenSizeChanged(int rows, int cols) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
terminalPanel.onScreenSizeChanged(rows, cols);
|
||||
terminalPanel.revalidate();
|
||||
terminalPanel.repaint();
|
||||
statusBar.updateStatus();
|
||||
@@ -1322,20 +1407,32 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
JOptionPane.showMessageDialog(this, sp, "Mnemonic Keystroke Reference", JOptionPane.INFORMATION_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the descriptive text for the About dialog and version reporting.
|
||||
*/
|
||||
public static String getAboutText() {
|
||||
return "j3270 v" + Version.getVersion() + " — Java TN3270 Terminal Emulator\n" +
|
||||
"lib3270j v" + haus.nightmare.lib3270j.Version.getVersion() + "\n\n" +
|
||||
"A pure Java reimplementation of x3270 & IBM Host On-Demand ECL.\n\n" +
|
||||
"Features:\n" +
|
||||
"• TN3270 & TN3270E (RFC 2355, SSCP-LU, NVT)\n" +
|
||||
"• Models 2-5 & Dynamic Geometry\n" +
|
||||
"• IBM 3287/3286 Printer Session Emulation (SCS & LU3)\n" +
|
||||
"• IND$FILE CUT & DFT File Transfer + Host Catalog Query\n" +
|
||||
"• GDDM/GOCA Vector Graphics & Programmed Symbols\n" +
|
||||
"• 40+ Pluggable EBCDIC Codepages & DBCS Support\n" +
|
||||
"• ECL Presentation Space Search, Inspector & Scripting";
|
||||
}
|
||||
|
||||
private void showAbout() {
|
||||
JOptionPane.showMessageDialog(this,
|
||||
"j3270 — Java TN3270 Terminal Emulator\n\n" +
|
||||
"A pure Java reimplementation of x3270 & IBM Host On-Demand ECL.\n" +
|
||||
"lib3270j v0.2.0\n\n" +
|
||||
"Features:\n" +
|
||||
"• TN3270 & TN3270E (RFC 2355, SSCP-LU, NVT)\n" +
|
||||
"• Models 2-5 & Dynamic Geometry\n" +
|
||||
"• IBM 3287/3286 Printer Session Emulation (SCS & LU3)\n" +
|
||||
"• IND$FILE CUT & DFT File Transfer + Host Catalog Query\n" +
|
||||
"• GDDM/GOCA Vector Graphics & Programmed Symbols\n" +
|
||||
"• 22+ Pluggable EBCDIC Codepages & DBCS Support\n" +
|
||||
"• ECL Presentation Space Search, Inspector & Scripting",
|
||||
"About j3270", JOptionPane.INFORMATION_MESSAGE);
|
||||
String aboutText = getAboutText();
|
||||
Image iconImg = AppIcon.getDefaultIcon();
|
||||
if (iconImg != null) {
|
||||
ImageIcon icon = new ImageIcon(iconImg.getScaledInstance(64, 64, Image.SCALE_SMOOTH));
|
||||
JOptionPane.showMessageDialog(this, aboutText, "About j3270", JOptionPane.INFORMATION_MESSAGE, icon);
|
||||
} else {
|
||||
JOptionPane.showMessageDialog(this, aboutText, "About j3270", JOptionPane.INFORMATION_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1441,6 +1538,30 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
}
|
||||
}
|
||||
|
||||
private static void printHelp() {
|
||||
System.out.println("j3270 " + Version.getVersion() + " (lib3270j " + haus.nightmare.lib3270j.Version.getVersion() + ")");
|
||||
System.out.println("Usage: java -jar j3270.jar [options] [host [port [model]]]");
|
||||
System.out.println();
|
||||
System.out.println("Options:");
|
||||
System.out.println(" -v, --version Print version information and exit");
|
||||
System.out.println(" -h, --help Print this help message and exit");
|
||||
System.out.println(" -d, --debug Enable verbose debug logging to j3270.log");
|
||||
System.out.println(" -s, --tls, --ssl Enable TLS/SSL encryption");
|
||||
System.out.println(" -k, --insecure,");
|
||||
System.out.println(" --no-verify-cert Disable TLS certificate verification");
|
||||
System.out.println(" -p, -P, --no-tn3270e Disable TN3270E extended protocol negotiation");
|
||||
System.out.println(" --tn3270e Enforce TN3270E negotiation");
|
||||
System.out.println(" --auto-sys-unlock Auto unlock keyboard on host EOR");
|
||||
System.out.println(" --no-auto-sys-unlock Require explicit Host RESET/unlock");
|
||||
System.out.println(" -ht, --host-type <type> Host type: TSO, VM, CMS, CICS");
|
||||
System.out.println(" -g, --graphics <mode> Graphics mode: NONE, VECTOR, PROGRAMMED_SYMBOLS, FULL");
|
||||
System.out.println(" -c, --config <file> Load INI configuration file");
|
||||
System.out.println(" --proxy <url> Proxy URL (e.g. socks5://host:1080, http://host:8080)");
|
||||
System.out.println(" --profile <name> Connect using saved profile name or ID");
|
||||
System.out.println();
|
||||
System.out.println("Terminal Models: 2 (24x80), 3 (32x80), 4 (43x80, default), 5 (27x132), dynamic (0)");
|
||||
}
|
||||
|
||||
// ========== Main ==========
|
||||
|
||||
public static void main(String[] args) {
|
||||
@@ -1458,7 +1579,13 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
java.util.List<String> remainingArgs = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
String arg = args[i];
|
||||
if ("-d".equals(arg) || "--debug".equals(arg)) {
|
||||
if ("-v".equals(arg) || "--version".equals(arg)) {
|
||||
System.out.println("j3270 " + Version.getVersion() + " (lib3270j " + haus.nightmare.lib3270j.Version.getVersion() + ")");
|
||||
System.exit(0);
|
||||
} else if ("-h".equals(arg) || "--help".equals(arg)) {
|
||||
printHelp();
|
||||
System.exit(0);
|
||||
} else if ("-d".equals(arg) || "--debug".equals(arg)) {
|
||||
debug = true;
|
||||
} else if ("-s".equals(arg) || "--tls".equals(arg) || "--ssl".equals(arg)) {
|
||||
cliTls = true;
|
||||
@@ -1504,7 +1631,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
|
||||
if (configFile != null) {
|
||||
try {
|
||||
haus.nightmare.j3270.config.Settings.loadFromIniFile(configFile);
|
||||
Settings.loadFromIniFile(configFile);
|
||||
} catch (Exception e) {
|
||||
System.err.println("Failed to load config file: " + configFile);
|
||||
System.err.println(e.getMessage());
|
||||
@@ -1517,8 +1644,8 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
} catch (Exception e) {
|
||||
log.fine("Could not set system look and feel");
|
||||
}
|
||||
ThemeManager.applyUIManagerDefaults(haus.nightmare.j3270.config.Settings.getJavaUiTheme());
|
||||
ThemeManager.setTheme(haus.nightmare.j3270.config.Settings.getJavaUiTheme());
|
||||
ThemeManager.applyUIManagerDefaults(Settings.getJavaUiTheme());
|
||||
ThemeManager.setTheme(Settings.getJavaUiTheme());
|
||||
|
||||
System.setProperty("apple.laf.useScreenMenuBar", "true");
|
||||
System.setProperty("apple.awt.application.name", "j3270");
|
||||
@@ -1590,12 +1717,12 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
if (finalAutoSysUnlock != null) {
|
||||
config.setAutoSysUnlock(finalAutoSysUnlock);
|
||||
} else {
|
||||
config.setAutoSysUnlock(haus.nightmare.j3270.config.Settings.getAutoSysUnlock());
|
||||
config.setAutoSysUnlock(Settings.getAutoSysUnlock());
|
||||
}
|
||||
if (finalGraphicsMode != null) {
|
||||
config.setGraphicsMode(finalGraphicsMode);
|
||||
} else {
|
||||
config.setGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode());
|
||||
config.setGraphicsMode(Settings.getGraphicsMode());
|
||||
}
|
||||
if (finalProxyUrl != null) {
|
||||
applyProxyUrl(config, finalProxyUrl);
|
||||
@@ -1603,11 +1730,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
if (finalHostType != null) {
|
||||
config.setHostType(finalHostType);
|
||||
} else {
|
||||
config.setHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
|
||||
config.setHostType(Settings.getDefaultHostType());
|
||||
}
|
||||
app.connect(config);
|
||||
} else {
|
||||
haus.nightmare.j3270.config.Settings.StartupBehavior behavior = haus.nightmare.j3270.config.Settings
|
||||
Settings.StartupBehavior behavior = Settings
|
||||
.getStartupBehavior();
|
||||
switch (behavior) {
|
||||
case SHOW_CONNECT:
|
||||
@@ -1626,22 +1753,22 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
if (finalProxyUrl != null) applyProxyUrl(config, finalProxyUrl);
|
||||
app.connect(config);
|
||||
} else {
|
||||
String host = haus.nightmare.j3270.config.Settings.getAutoConnectHost();
|
||||
int port = haus.nightmare.j3270.config.Settings.getAutoConnectPort();
|
||||
boolean tls = haus.nightmare.j3270.config.Settings.getAutoConnectTls();
|
||||
boolean verify = haus.nightmare.j3270.config.Settings.getAutoConnectVerifyCert();
|
||||
boolean tn3270e = haus.nightmare.j3270.config.Settings.getAutoConnectTn3270e();
|
||||
String host = Settings.getAutoConnectHost();
|
||||
int port = Settings.getAutoConnectPort();
|
||||
boolean tls = Settings.getAutoConnectTls();
|
||||
boolean verify = Settings.getAutoConnectVerifyCert();
|
||||
boolean tn3270e = Settings.getAutoConnectTn3270e();
|
||||
if (host != null && !host.isEmpty()) {
|
||||
ConnectionConfig config = new ConnectionConfig(host, port, TerminalModel.IBM_3279_4, tls);
|
||||
config.setTlsVerifyCert(verify);
|
||||
config.setTn3270eEnabled(finalTn3270e != null ? finalTn3270e : tn3270e);
|
||||
config.setAutoReconnect(haus.nightmare.j3270.config.Settings.getAutoConnectAutoReconnect());
|
||||
config.setReconnectMaxRetries(haus.nightmare.j3270.config.Settings.getAutoConnectReconnectMaxRetries());
|
||||
config.setAutoSysUnlock(finalAutoSysUnlock != null ? finalAutoSysUnlock : haus.nightmare.j3270.config.Settings.getAutoSysUnlock());
|
||||
config.setAutoReconnect(Settings.getAutoConnectAutoReconnect());
|
||||
config.setReconnectMaxRetries(Settings.getAutoConnectReconnectMaxRetries());
|
||||
config.setAutoSysUnlock(finalAutoSysUnlock != null ? finalAutoSysUnlock : Settings.getAutoSysUnlock());
|
||||
if (finalGraphicsMode != null) {
|
||||
config.setGraphicsMode(finalGraphicsMode);
|
||||
} else {
|
||||
config.setGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode());
|
||||
config.setGraphicsMode(Settings.getGraphicsMode());
|
||||
}
|
||||
if (finalProxyUrl != null) {
|
||||
applyProxyUrl(config, finalProxyUrl);
|
||||
@@ -1649,7 +1776,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
|
||||
if (finalHostType != null) {
|
||||
config.setHostType(finalHostType);
|
||||
} else {
|
||||
config.setHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
|
||||
config.setHostType(Settings.getDefaultHostType());
|
||||
}
|
||||
app.connect(config);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package haus.nightmare.j3270;
|
||||
|
||||
/**
|
||||
* Version information for the j3270 application.
|
||||
*/
|
||||
public final class Version {
|
||||
|
||||
/**
|
||||
* Hardcoded fallback release version matching build.gradle and pom coordinates.
|
||||
*/
|
||||
public static final String VERSION = "1.2.4";
|
||||
|
||||
/**
|
||||
* Canonical application name.
|
||||
*/
|
||||
public static final String NAME = "j3270";
|
||||
|
||||
private Version() {}
|
||||
|
||||
/**
|
||||
* Retrieves the runtime version string of the j3270 application.
|
||||
* Inspects the package implementation version first (populated when loaded from a JAR),
|
||||
* and falls back to the compile-time {@link #VERSION} constant if null or blank.
|
||||
*
|
||||
* @return the version string (e.g. "1.2.4")
|
||||
*/
|
||||
public static String getVersion() {
|
||||
Package pkg = Version.class.getPackage();
|
||||
if (pkg != null) {
|
||||
String implVer = pkg.getImplementationVersion();
|
||||
if (implVer != null && !implVer.isBlank()) {
|
||||
return implVer;
|
||||
}
|
||||
}
|
||||
return VERSION;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import haus.nightmare.j3270.storage.HostStorage;
|
||||
import haus.nightmare.j3270.storage.SavedHost;
|
||||
import haus.nightmare.lib3270j.ConnectionConfig;
|
||||
import haus.nightmare.lib3270j.TerminalModel;
|
||||
import haus.nightmare.lib3270j.charset.CodePageRegistry;
|
||||
|
||||
import javax.swing.*;
|
||||
import javax.swing.border.EmptyBorder;
|
||||
@@ -278,51 +279,7 @@ public class ConnectDialog extends JDialog {
|
||||
contentPanel.add(cpLabel, gbc);
|
||||
gbc.gridx = 1;
|
||||
gbc.weightx = 1.0;
|
||||
String[] commonCodePages = {
|
||||
"037 - US / Canada / Brazil",
|
||||
"1047 - IBM Open Systems / z/OS Unix",
|
||||
"500 - International Latin-1",
|
||||
"273 - Germany / Austria",
|
||||
"277 - Denmark / Norway",
|
||||
"278 - Sweden / Finland",
|
||||
"280 - Italy",
|
||||
"284 - Spain / Latin America",
|
||||
"285 - United Kingdom",
|
||||
"297 - France",
|
||||
"870 - Eastern Europe / Latin-2",
|
||||
"871 - Iceland",
|
||||
"875 - Greece (Greek)",
|
||||
"1026 - Turkey (Turkish Latin-5)",
|
||||
"1155 - Turkey (Turkish Latin-5 Euro \u20AC)",
|
||||
"905 - Turkey (Turkish Latin-3)",
|
||||
"420 - Arabic Bilingual",
|
||||
"424 - Hebrew (with Latin)",
|
||||
"803 - Hebrew Character Set",
|
||||
"838 - Thai Extended",
|
||||
"1160 - Thai Euro \u20AC",
|
||||
"1025 - Cyrillic Multilingual",
|
||||
"1123 - Cyrillic Ukraine",
|
||||
"1154 - Cyrillic Euro \u20AC",
|
||||
"880 - Cyrillic (Russian)",
|
||||
"1140 - US / Canada (Euro \u20AC)",
|
||||
"1141 - Germany / Austria (Euro \u20AC)",
|
||||
"1142 - Denmark / Norway (Euro \u20AC)",
|
||||
"1143 - Sweden / Finland (Euro \u20AC)",
|
||||
"1144 - Italy (Euro \u20AC)",
|
||||
"1145 - Spain / Latin America (Euro \u20AC)",
|
||||
"1146 - United Kingdom (Euro \u20AC)",
|
||||
"1147 - France (Euro \u20AC)",
|
||||
"1148 - International (Euro \u20AC)",
|
||||
"1149 - Iceland (Euro \u20AC)",
|
||||
"930 - Japanese Katakana Mixed DBCS",
|
||||
"939 - Japanese Latin Mixed DBCS",
|
||||
"935 - Simplified Chinese Mixed DBCS",
|
||||
"937 - Traditional Chinese Mixed DBCS",
|
||||
"1388 - Simplified Chinese Extended DBCS",
|
||||
"1371 - Traditional Chinese Extended DBCS",
|
||||
"933 - Korean Mixed DBCS"
|
||||
};
|
||||
codePageCombo = new JComboBox<>(commonCodePages);
|
||||
codePageCombo = new JComboBox<>(CodePageRegistry.COMMON_CODE_PAGES);
|
||||
codePageCombo.setFont(ThemeManager.getMonospacedUiFont());
|
||||
ThemeManager.styleComboBox(codePageCombo);
|
||||
setInitialCodePage(haus.nightmare.j3270.config.Settings.getCodePage());
|
||||
|
||||
@@ -21,7 +21,7 @@ import java.util.List;
|
||||
*/
|
||||
public class FieldInspectorDialog extends JDialog implements ScreenUpdateListener {
|
||||
|
||||
private final Telnet3270Client client;
|
||||
private Telnet3270Client client;
|
||||
private final TerminalPanel terminalPanel;
|
||||
|
||||
private DefaultTableModel tableModel;
|
||||
@@ -46,13 +46,39 @@ public class FieldInspectorDialog extends JDialog implements ScreenUpdateListene
|
||||
addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosing(WindowEvent e) {
|
||||
if (client != null) {
|
||||
// Screen update listener removed on close
|
||||
if (FieldInspectorDialog.this.client != null) {
|
||||
FieldInspectorDialog.this.client.removeScreenUpdateListener(FieldInspectorDialog.this);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setClient(Telnet3270Client newClient) {
|
||||
if (this.client != null) {
|
||||
this.client.removeScreenUpdateListener(this);
|
||||
}
|
||||
this.client = newClient;
|
||||
if (this.client != null) {
|
||||
this.client.addScreenUpdateListener(this);
|
||||
refreshFields();
|
||||
} else if (tableModel != null && countLabel != null) {
|
||||
tableModel.setRowCount(0);
|
||||
countLabel.setText("Not connected");
|
||||
}
|
||||
}
|
||||
|
||||
public Telnet3270Client getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
if (client != null) {
|
||||
client.removeScreenUpdateListener(this);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
private void buildUI() {
|
||||
JPanel mainPanel = new JPanel(new BorderLayout(8, 8));
|
||||
mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10));
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.awt.event.KeyEvent;
|
||||
*/
|
||||
public class FindDialog extends JDialog {
|
||||
|
||||
private final Telnet3270Client client;
|
||||
private Telnet3270Client client;
|
||||
private final TerminalPanel terminalPanel;
|
||||
|
||||
private JTextField searchField;
|
||||
@@ -40,6 +40,24 @@ public class FindDialog extends JDialog {
|
||||
setResizable(false);
|
||||
}
|
||||
|
||||
public void setClient(Telnet3270Client client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public Telnet3270Client getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
public void setSearchText(String text) {
|
||||
if (searchField != null) {
|
||||
searchField.setText(text);
|
||||
}
|
||||
}
|
||||
|
||||
public String getSearchText() {
|
||||
return searchField != null ? searchField.getText() : "";
|
||||
}
|
||||
|
||||
private void buildUI() {
|
||||
JPanel mainPanel = new JPanel(new BorderLayout(10, 10));
|
||||
mainPanel.setBorder(new EmptyBorder(12, 14, 12, 14));
|
||||
|
||||
@@ -129,7 +129,7 @@ public class HotspotEngine {
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
// 3. Menu choices: "1.", "2.", "===> 3", "=> 4", "--> 5"
|
||||
// 3. Menu choices: "1.", "2.", "===> 3", "=> 4", "--> 5", ISPF (" 0 Settings", " 1 View", "10 SCLM", " X Exit")
|
||||
private static final Pattern MENU_NUMBER_PATTERN = Pattern.compile(
|
||||
"(?:(?<=\\s)|^)(\\d{1,2})\\.(?:(?=\\s)|$)"
|
||||
);
|
||||
@@ -138,11 +138,18 @@ public class HotspotEngine {
|
||||
"(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)"
|
||||
);
|
||||
|
||||
private static final Pattern ISPF_MENU_PATTERN = Pattern.compile(
|
||||
"(?:^\\s*|(?<=\\s{3,}))(?:(\\d{1,2}(?:\\.\\d{1,2})?)(?:\\s*[-–—]\\s*|\\s+)|([A-Za-z])(?:\\s*[-–—]\\s*|\\s{2,}))([A-Za-z][A-Za-z0-9/_-]*)"
|
||||
);
|
||||
|
||||
// Caching state to avoid rescanning unchanged screens
|
||||
private String cachedScreenText = "";
|
||||
private int cachedRows = 0;
|
||||
private int cachedCols = 0;
|
||||
private List<Hotspot> cachedHotspots = Collections.emptyList();
|
||||
private ScreenBuffer lastScreenBuffer;
|
||||
private long lastSnapshotVersion = -1;
|
||||
private long scanCount = 0;
|
||||
|
||||
public HotspotEngine() {
|
||||
}
|
||||
@@ -174,9 +181,19 @@ public class HotspotEngine {
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
public void invalidateCache() {
|
||||
public synchronized void invalidateCache() {
|
||||
cachedScreenText = "";
|
||||
cachedHotspots = Collections.emptyList();
|
||||
lastScreenBuffer = null;
|
||||
lastSnapshotVersion = -1;
|
||||
}
|
||||
|
||||
public synchronized void markDirty() {
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
public synchronized long getScanCount() {
|
||||
return scanCount;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +203,13 @@ public class HotspotEngine {
|
||||
if (sb == null) return Collections.emptyList();
|
||||
int rows = sb.getDisplayRows();
|
||||
int cols = sb.getDisplayCols();
|
||||
long version = sb.getSnapshotVersion();
|
||||
|
||||
if (sb == lastScreenBuffer && version == lastSnapshotVersion && rows == cachedRows && cols == cachedCols) {
|
||||
return cachedHotspots;
|
||||
}
|
||||
|
||||
scanCount++;
|
||||
|
||||
// Build plain text representation
|
||||
StringBuilder fullText = new StringBuilder(rows * (cols + 1));
|
||||
@@ -204,7 +228,10 @@ public class HotspotEngine {
|
||||
}
|
||||
}
|
||||
|
||||
return scanText(fullText.toString(), rows, cols);
|
||||
List<Hotspot> results = scanText(fullText.toString(), rows, cols);
|
||||
lastScreenBuffer = sb;
|
||||
lastSnapshotVersion = version;
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,6 +373,28 @@ public class HotspotEngine {
|
||||
hotspots.add(h);
|
||||
markOccupied(occupied, startCol, endCol);
|
||||
}
|
||||
|
||||
// Pattern 3c: ISPF menu choices (e.g. " 1 View", " 2 Edit", "10 SCLM", " X Exit", " M More", " 1 - View", " 3.1 Library")
|
||||
Matcher mIspf = ISPF_MENU_PATTERN.matcher(line);
|
||||
while (mIspf.find()) {
|
||||
String choiceStr = mIspf.group(1) != null ? mIspf.group(1) : mIspf.group(2).toUpperCase();
|
||||
String labelStr = mIspf.group(3);
|
||||
int startCol = mIspf.group(1) != null ? mIspf.start(1) : mIspf.start(2);
|
||||
int endCol = mIspf.end(3);
|
||||
if (isRangeOccupied(occupied, startCol, endCol)) continue;
|
||||
|
||||
String action = choiceStr + "[enter]";
|
||||
String tooltip = "Select option " + choiceStr + " (" + labelStr + ")";
|
||||
String display = line.substring(startCol, endCol);
|
||||
|
||||
Hotspot h = new Hotspot(
|
||||
HotspotType.MENU_CHOICE,
|
||||
r, startCol, r, endCol - 1,
|
||||
display, action, tooltip
|
||||
);
|
||||
hotspots.add(h);
|
||||
markOccupied(occupied, startCol, endCol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.nio.charset.StandardCharsets;
|
||||
*/
|
||||
public class ScriptDialog extends JDialog {
|
||||
|
||||
private final Telnet3270Client client;
|
||||
private Telnet3270Client client;
|
||||
private final TerminalPanel terminalPanel;
|
||||
|
||||
private JTextArea scriptArea;
|
||||
@@ -32,6 +32,24 @@ public class ScriptDialog extends JDialog {
|
||||
setLocationRelativeTo(parent);
|
||||
}
|
||||
|
||||
public void setClient(Telnet3270Client client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public Telnet3270Client getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
public void setScriptText(String text) {
|
||||
if (scriptArea != null) {
|
||||
scriptArea.setText(text);
|
||||
}
|
||||
}
|
||||
|
||||
public String getScriptText() {
|
||||
return scriptArea != null ? scriptArea.getText() : "";
|
||||
}
|
||||
|
||||
private void buildUI() {
|
||||
JPanel mainPanel = new JPanel(new BorderLayout(10, 10));
|
||||
mainPanel.setBorder(new EmptyBorder(12, 14, 12, 14));
|
||||
|
||||
@@ -533,7 +533,7 @@ public class SettingsDialog extends JDialog {
|
||||
gbcH.gridy = 2;
|
||||
hotGroup.add(hotspotsPfCheck, gbcH);
|
||||
|
||||
hotspotsMenuCheck = new JCheckBox("Auto-detect Menu choices (\"1.\", \"2.\", \"===> 3\")", Settings.getHotspotsMenuChoicesEnabled());
|
||||
hotspotsMenuCheck = new JCheckBox("Auto-detect Menu choices (ISPF, \"1.\", \"===> 3\")", Settings.getHotspotsMenuChoicesEnabled());
|
||||
ThemeManager.styleCheckBox(hotspotsMenuCheck);
|
||||
gbcH.gridy = 3;
|
||||
hotGroup.add(hotspotsMenuCheck, gbcH);
|
||||
|
||||
@@ -64,6 +64,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
CHAR_STRINGS[i] = String.valueOf((char) i);
|
||||
}
|
||||
}
|
||||
// Reusable single-character buffer for drawing non-ASCII cells (Phase 27.4 / T1-A)
|
||||
private final char[] charScratch = new char[1];
|
||||
public enum CursorStyle {
|
||||
BLOCK,
|
||||
UNDERLINE
|
||||
@@ -110,10 +112,14 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
// ========== Phase 25: Hotspots & Mouse Wheel ==========
|
||||
private final HotspotEngine hotspotEngine = new HotspotEngine();
|
||||
private HotspotEngine.Hotspot hoveredHotspot = null;
|
||||
private static final Color HOTSPOT_HOVER_COLOR = new Color(0, 220, 255);
|
||||
private static final Stroke HOTSPOT_HOVER_STROKE = new BasicStroke(2.0f);
|
||||
private boolean hotspotsEnabled = haus.nightmare.j3270.config.Settings.getHotspotsEnabled();
|
||||
private boolean mouseWheelEnabled = haus.nightmare.j3270.config.Settings.getMouseWheelEnabled();
|
||||
private String mouseWheelUpAction = haus.nightmare.j3270.config.Settings.getMouseWheelUpKey();
|
||||
private String mouseWheelDownAction = haus.nightmare.j3270.config.Settings.getMouseWheelDownKey();
|
||||
private boolean hotspotsDirty = true;
|
||||
private boolean[] selectedCellsScratch = new boolean[0];
|
||||
|
||||
// ========== Mode and Listener State ==========
|
||||
private boolean insertMode = false;
|
||||
@@ -233,7 +239,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
public void mousePressed(MouseEvent e) {
|
||||
Window win = SwingUtilities.getWindowAncestor(TerminalPanel.this);
|
||||
boolean wasWindowInactive = (win != null && (!win.isActive() || !win.isFocused()));
|
||||
boolean wasPanelUnfocused = !hasFocus();
|
||||
boolean wasPanelUnfocused = !java.awt.GraphicsEnvironment.isHeadless() && !hasFocus();
|
||||
isActivationClick = wasWindowInactive || wasPanelUnfocused;
|
||||
|
||||
if (win != null && wasWindowInactive) {
|
||||
@@ -400,7 +406,10 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
int col = (e.getX() - ox) / cellWidth;
|
||||
int row = (e.getY() - oy) / cellHeight;
|
||||
if (row >= 0 && row < rows && col >= 0 && col < cols) {
|
||||
hotspotEngine.scan(sb);
|
||||
if (hotspotsDirty) {
|
||||
hotspotEngine.scan(sb);
|
||||
hotspotsDirty = false;
|
||||
}
|
||||
HotspotEngine.Hotspot h = hotspotEngine.findHotspot(row, col);
|
||||
if (h != null) {
|
||||
if (hoveredHotspot != h) {
|
||||
@@ -515,11 +524,27 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
|
||||
// Phase 25.2: Interactive Hotspot click execution (URLs, PF keys, choices)
|
||||
if (hotspotsEnabled && client.getConnectionState().isFullSession()) {
|
||||
if (hotspotsDirty) {
|
||||
hotspotEngine.scan(sb);
|
||||
hotspotsDirty = false;
|
||||
}
|
||||
HotspotEngine.Hotspot h = hotspotEngine.findHotspot(row, col);
|
||||
if (h != null) {
|
||||
if (h.getType() == HotspotEngine.HotspotType.URL) {
|
||||
openUrl(h.getAction());
|
||||
return;
|
||||
} else if (h.getType() == HotspotEngine.HotspotType.MENU_CHOICE) {
|
||||
// In 3270 / ISPF, clicking on a menu choice hotspot positions the cursor
|
||||
// on a protected screen label. Typing directly into a protected cell triggers
|
||||
// INHIBIT_PROTECTED_FIELD keyboard lock. Menu choices must be submitted via
|
||||
// the command/option line (first unprotected field reachable via [home]).
|
||||
String action = h.getAction();
|
||||
if (!action.startsWith("[home]")) {
|
||||
action = "[home][eraseeof]" + action;
|
||||
}
|
||||
client.getPS().SendKeys(action);
|
||||
refreshScreen();
|
||||
return;
|
||||
} else {
|
||||
client.getPS().SendKeys(h.getAction());
|
||||
refreshScreen();
|
||||
@@ -543,13 +568,12 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
boolean isToggleField = (desEbcdic == 0x6F || desAscii == '?' || desAscii == '\uFF1F' ||
|
||||
desEbcdic == 0x6E || desAscii == '>' || desAscii == '\uFF1E');
|
||||
boolean isEnterField = (desEbcdic == 0x50 || desAscii == '&' || desAscii == '\uFF06');
|
||||
boolean isProtected = haus.nightmare.lib3270j.protocol.DS3270Constants.faIsProtected(fa);
|
||||
|
||||
boolean isDoubleClick = (e.getClickCount() >= 2);
|
||||
// For detectable fields (FA bits 4-5 = 01 or 11):
|
||||
// Double-clicking or invoking mnemonic [cursel] toggles the designator character (? -> >).
|
||||
// If field has immediate select attribute, immediately transmit Cursor Select AID (0x7E).
|
||||
if (lightPenMode || isDoubleClick || isToggleField || isEnterField || !isProtected) {
|
||||
// Automatic mouse selection applies to toggle fields (? -> >) and enter fields (&).
|
||||
// Standard text fields (space/null designator) must NOT trigger light-pen selection
|
||||
// on normal mouse clicks, so users can position cursor and edit text normally (HoD parity).
|
||||
// Hardware light-pen emulation on space designators only fires when lightPenMode is active.
|
||||
if (lightPenMode || isToggleField || isEnterField) {
|
||||
if (client.lightPenSelect(clickAddr)) {
|
||||
refreshScreen();
|
||||
return;
|
||||
@@ -1054,7 +1078,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
popup.show(this, e.getX(), e.getY());
|
||||
}
|
||||
|
||||
private boolean isCellSelected(int row, int col) {
|
||||
public boolean isCellSelected(int row, int col) {
|
||||
if (!hasSelection()) return false;
|
||||
|
||||
int r1 = Math.min(selectionStartRow, selectionEndRow);
|
||||
@@ -1066,7 +1090,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
return row >= r1 && row <= r2 && col >= c1 && col <= c2;
|
||||
} else {
|
||||
int cols = 80;
|
||||
if (client != null) cols = client.getScreenBuffer().getCols();
|
||||
if (client != null && client.getScreenBuffer() != null) cols = client.getScreenBuffer().getDisplayCols();
|
||||
int addr = row * cols + col;
|
||||
int startAddr, endAddr;
|
||||
if (selectionStartRow < selectionEndRow ||
|
||||
@@ -1081,6 +1105,51 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
}
|
||||
}
|
||||
|
||||
public void precomputeSelectionScratch(int rows, int cols) {
|
||||
int totalCells = rows * cols;
|
||||
if (selectedCellsScratch == null || selectedCellsScratch.length < totalCells) {
|
||||
selectedCellsScratch = new boolean[totalCells];
|
||||
}
|
||||
java.util.Arrays.fill(selectedCellsScratch, 0, totalCells, false);
|
||||
if (hasSelection()) {
|
||||
int r1 = Math.min(selectionStartRow, selectionEndRow);
|
||||
int r2 = Math.max(selectionStartRow, selectionEndRow);
|
||||
if (blockSelectMode || trimRectSizingHandles) {
|
||||
int c1 = Math.min(selectionStartCol, selectionEndCol);
|
||||
int c2 = Math.max(selectionStartCol, selectionEndCol);
|
||||
int minR = Math.max(0, r1);
|
||||
int maxR = Math.min(rows - 1, r2);
|
||||
int minC = Math.max(0, c1);
|
||||
int maxC = Math.min(cols - 1, c2);
|
||||
if (minC <= maxC) {
|
||||
for (int r = minR; r <= maxR; r++) {
|
||||
int rowBase = r * cols;
|
||||
java.util.Arrays.fill(selectedCellsScratch, rowBase + minC, rowBase + maxC + 1, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
int startAddr, endAddr;
|
||||
if (selectionStartRow < selectionEndRow ||
|
||||
(selectionStartRow == selectionEndRow && selectionStartCol <= selectionEndCol)) {
|
||||
startAddr = selectionStartRow * cols + selectionStartCol;
|
||||
endAddr = selectionEndRow * cols + selectionEndCol;
|
||||
} else {
|
||||
startAddr = selectionEndRow * cols + selectionEndCol;
|
||||
endAddr = selectionStartRow * cols + selectionStartCol;
|
||||
}
|
||||
int s = Math.max(0, startAddr);
|
||||
int e = Math.min(totalCells - 1, endAddr);
|
||||
if (s <= e) {
|
||||
java.util.Arrays.fill(selectedCellsScratch, s, e + 1, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean[] getSelectedCellsScratch() {
|
||||
return selectedCellsScratch;
|
||||
}
|
||||
|
||||
|
||||
public void packForModel(int modelRows, int modelCols) {
|
||||
if (isDisplayable() && !SwingUtilities.isEventDispatchThread()) {
|
||||
@@ -1687,6 +1756,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
}
|
||||
|
||||
public void applyModeSettings() {
|
||||
hotspotsDirty = true;
|
||||
if (client != null) {
|
||||
ScreenBuffer sb = client.getScreenBuffer();
|
||||
if (sb != null) {
|
||||
@@ -1723,6 +1793,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
}
|
||||
|
||||
public void refreshScreen() {
|
||||
hotspotsDirty = true;
|
||||
if (client != null) {
|
||||
client.getScreenBuffer().updateDisplaySnapshot();
|
||||
}
|
||||
@@ -1740,6 +1811,26 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
}
|
||||
}
|
||||
|
||||
public void onScreenUpdated() {
|
||||
hotspotsDirty = true;
|
||||
}
|
||||
|
||||
public void onScreenSizeChanged() {
|
||||
hotspotsDirty = true;
|
||||
}
|
||||
|
||||
public void onScreenSizeChanged(int rows, int cols) {
|
||||
hotspotsDirty = true;
|
||||
}
|
||||
|
||||
public boolean isHotspotsDirty() {
|
||||
return hotspotsDirty;
|
||||
}
|
||||
|
||||
public void setHotspotsDirty(boolean hotspotsDirty) {
|
||||
this.hotspotsDirty = hotspotsDirty;
|
||||
}
|
||||
|
||||
private void setupFont() {
|
||||
currentFontSize = haus.nightmare.j3270.config.Settings.getFontSize();
|
||||
String fontFamily = haus.nightmare.j3270.config.Settings.getFontFamily();
|
||||
@@ -1880,6 +1971,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
|
||||
public void setClient(Telnet3270Client client) {
|
||||
this.client = client;
|
||||
hotspotsDirty = true;
|
||||
if (client != null) {
|
||||
setupGraphicsPlaneRenderer();
|
||||
updateCellSize();
|
||||
@@ -1918,7 +2010,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
Container top = getTopLevelAncestor();
|
||||
if (top instanceof JFrame) {
|
||||
((JFrame) top).setTitle(title != null && !title.isEmpty() ? ("j3270 - " + title) : "j3270");
|
||||
((JFrame) top).setTitle(title != null && !title.isEmpty() ? ("j3270 — " + title) : "j3270 — Java TN3270 Terminal Emulator");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2076,6 +2168,9 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
g2.fillRect(cx, oy, cellWidth, gridH);
|
||||
}
|
||||
|
||||
// Phase 28.2 / T2-B: Precompute selection scratch lookup once per frame
|
||||
precomputeSelectionScratch(rows, cols);
|
||||
|
||||
// Layer 3: Character / Text Plane
|
||||
byte currentFA = 0;
|
||||
ExtendedAttribute currentFieldEa = null;
|
||||
@@ -2097,7 +2192,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
if (ea.isFieldAttribute()) {
|
||||
currentFA = ea.fa;
|
||||
currentFieldEa = ea;
|
||||
if (isCellSelected(row, col)) {
|
||||
if (selectedCellsScratch[baddr]) {
|
||||
g2.setColor(SELECTION_COLOR);
|
||||
g2.fillRect(x, y, cellWidth, cellHeight);
|
||||
}
|
||||
@@ -2142,7 +2237,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
// Field Outlining (0xC2 / TY_FOUTLINE)
|
||||
drawOutlining(g2, ea, currentFieldEa, baddr, row, col, x, y, cellWidth, cellHeight, rows, cols, sb, fgColor);
|
||||
|
||||
if (isCellSelected(row, col)) {
|
||||
if (selectedCellsScratch[baddr]) {
|
||||
g2.setColor(SELECTION_COLOR);
|
||||
g2.fillRect(x, y, cellWidth, cellHeight);
|
||||
}
|
||||
@@ -2194,7 +2289,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
if (ch < 128) {
|
||||
g2.drawString(CHAR_STRINGS[ch], x, textY);
|
||||
} else {
|
||||
g2.drawString(String.valueOf(ch), x, textY);
|
||||
charScratch[0] = ch;
|
||||
g2.drawChars(charScratch, 0, 1, x, textY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2215,7 +2311,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
}
|
||||
|
||||
// Draw selection highlight (40% alpha blend over cell)
|
||||
if (isCellSelected(row, col)) {
|
||||
if (selectedCellsScratch[baddr]) {
|
||||
g2.setColor(SELECTION_COLOR);
|
||||
g2.fillRect(x, y, cellWidth, cellHeight);
|
||||
}
|
||||
@@ -2261,8 +2357,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
|
||||
if (hotspotsEnabled && hoveredHotspot != null) {
|
||||
Color oldColor = g2.getColor();
|
||||
Stroke oldStroke = g2.getStroke();
|
||||
g2.setColor(new Color(0, 220, 255)); // Bright cyan hotspot underline
|
||||
g2.setStroke(new BasicStroke(2.0f));
|
||||
g2.setColor(HOTSPOT_HOVER_COLOR); // Bright cyan hotspot underline
|
||||
g2.setStroke(HOTSPOT_HOVER_STROKE);
|
||||
|
||||
int hr0 = hoveredHotspot.getStartRow();
|
||||
int hr1 = hoveredHotspot.getEndRow();
|
||||
|
||||
@@ -294,4 +294,35 @@ public class ConnectionConflictTest {
|
||||
assertEquals(haus.nightmare.lib3270j.ConnectionState.NOT_CONNECTED, client.getConnectionState());
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("When not connected, connect does not trigger abort connection dialog for previously closed connection")
|
||||
public void testNotConnectedDoesNotTriggerAbortDialog() throws Exception {
|
||||
J3270App app = createApp();
|
||||
if (app == null) return;
|
||||
|
||||
// Simulate a previously closed connection
|
||||
ConnectionConfig config1 = new ConnectionConfig("host1.test", 23, TerminalModel.IBM_3278_2);
|
||||
app.connect(config1, false);
|
||||
|
||||
Telnet3270Client client = app.getClient();
|
||||
assertNotNull(client);
|
||||
// Force the client's FSM state to NOT_CONNECTED, simulating connection closure
|
||||
client.getTelnetFSM().setConnectionState(haus.nightmare.lib3270j.ConnectionState.NOT_CONNECTED);
|
||||
|
||||
assertFalse(app.isConnected(), "app.isConnected() must return false when client is NOT_CONNECTED");
|
||||
|
||||
boolean[] handlerCalled = new boolean[]{false};
|
||||
app.setConnectChoiceHandler((owner, target) -> {
|
||||
handlerCalled[0] = true;
|
||||
return ConnectChoiceDialog.Choice.CANCEL;
|
||||
});
|
||||
|
||||
// Initiate a new connection
|
||||
ConnectionConfig config2 = new ConnectionConfig("host2.test", 23, TerminalModel.IBM_3278_2);
|
||||
app.connect(config2, true);
|
||||
|
||||
assertFalse(handlerCalled[0], "Connect choice handler must NOT be called when j3270 is not connected");
|
||||
assertEquals("host2.test", app.getClient().getConfig().getHost(), "Must proceed to connect to host2.test in same window");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package haus.nightmare.j3270;
|
||||
|
||||
import haus.nightmare.j3270.ui.TerminalPanel;
|
||||
import haus.nightmare.lib3270j.ConnectionConfig;
|
||||
import haus.nightmare.lib3270j.ConnectionState;
|
||||
import haus.nightmare.lib3270j.Telnet3270Client;
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
|
||||
import haus.nightmare.lib3270j.nvt.NvtProcessor;
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer;
|
||||
import haus.nightmare.lib3270j.telnet.TelnetFSM;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.file.Files;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Verification test suite for Phase 27:
|
||||
* Build Hygiene, Release Versioning & Zero-Risk Pruning (v1.2.4)
|
||||
*/
|
||||
public class Phase27BuildAndRenderTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 27.1 & 27.2: Release version 1.2.4 synchronized across build files and android.useAndroidX removed")
|
||||
public void testVersionSynchronizationAndGradleProperties() throws Exception {
|
||||
File projectRoot = new File(".").getCanonicalFile();
|
||||
if (!new File(projectRoot, "settings.gradle").exists() && projectRoot.getParentFile() != null) {
|
||||
projectRoot = projectRoot.getParentFile();
|
||||
}
|
||||
|
||||
// 1. Verify build.gradle version = '1.2.4'
|
||||
File buildGradle = new File(projectRoot, "build.gradle");
|
||||
assertTrue(buildGradle.exists(), "build.gradle must exist");
|
||||
String buildGradleContent = Files.readString(buildGradle.toPath());
|
||||
assertTrue(buildGradleContent.contains("version = '1.2.4'"), "build.gradle must define version = '1.2.4'");
|
||||
assertFalse(buildGradleContent.contains("0.1.0"), "build.gradle must not contain 0.1.0");
|
||||
|
||||
|
||||
// 3. Verify setup.ps1 Implementation-Version: 1.2.4
|
||||
File setupPs1 = new File(projectRoot, "setup.ps1");
|
||||
assertTrue(setupPs1.exists(), "setup.ps1 must exist");
|
||||
String setupPs1Content = Files.readString(setupPs1.toPath());
|
||||
assertTrue(setupPs1Content.contains("Implementation-Version: 1.2.4"), "setup.ps1 must specify Implementation-Version: 1.2.4");
|
||||
assertFalse(setupPs1Content.contains("Implementation-Version: 0.1.0"), "setup.ps1 must not contain 0.1.0");
|
||||
|
||||
// 4. Verify setup.sh Implementation-Version: 1.2.4
|
||||
File setupSh = new File(projectRoot, "setup.sh");
|
||||
assertTrue(setupSh.exists(), "setup.sh must exist");
|
||||
String setupShContent = Files.readString(setupSh.toPath());
|
||||
assertTrue(setupShContent.contains("Implementation-Version: 1.2.4"), "setup.sh must specify Implementation-Version: 1.2.4");
|
||||
assertFalse(setupShContent.contains("Implementation-Version: 0.1.0"), "setup.sh must not contain 0.1.0");
|
||||
|
||||
// 5. Verify gradle.properties android.useAndroidX removed
|
||||
File gradleProps = new File(projectRoot, "gradle.properties");
|
||||
assertTrue(gradleProps.exists(), "gradle.properties must exist");
|
||||
String gradlePropsContent = Files.readString(gradleProps.toPath());
|
||||
assertFalse(gradlePropsContent.contains("android.useAndroidX"), "gradle.properties must not contain android.useAndroidX");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 27.3: T1-B Static Color & BasicStroke constants promoted in TerminalPanel")
|
||||
public void testHotspotHoverStaticConstants() throws Exception {
|
||||
Field colorField = TerminalPanel.class.getDeclaredField("HOTSPOT_HOVER_COLOR");
|
||||
colorField.setAccessible(true);
|
||||
assertTrue(Modifier.isStatic(colorField.getModifiers()), "HOTSPOT_HOVER_COLOR must be static");
|
||||
assertTrue(Modifier.isFinal(colorField.getModifiers()), "HOTSPOT_HOVER_COLOR must be final");
|
||||
Color hoverColor = (Color) colorField.get(null);
|
||||
assertEquals(new Color(0, 220, 255), hoverColor, "HOTSPOT_HOVER_COLOR must match bright cyan (0, 220, 255)");
|
||||
|
||||
Field strokeField = TerminalPanel.class.getDeclaredField("HOTSPOT_HOVER_STROKE");
|
||||
strokeField.setAccessible(true);
|
||||
assertTrue(Modifier.isStatic(strokeField.getModifiers()), "HOTSPOT_HOVER_STROKE must be static");
|
||||
assertTrue(Modifier.isFinal(strokeField.getModifiers()), "HOTSPOT_HOVER_STROKE must be final");
|
||||
Stroke hoverStroke = (Stroke) strokeField.get(null);
|
||||
assertNotNull(hoverStroke, "HOTSPOT_HOVER_STROKE must not be null");
|
||||
assertTrue(hoverStroke instanceof BasicStroke, "HOTSPOT_HOVER_STROKE must be BasicStroke");
|
||||
assertEquals(2.0f, ((BasicStroke) hoverStroke).getLineWidth(), 0.001f, "HOTSPOT_HOVER_STROKE line width must be 2.0f");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 27.4: T1-A charScratch Buffer exists and non-ASCII render renders without exceptions")
|
||||
public void testCharScratchAndNonAsciiRendering() throws Exception {
|
||||
Field scratchField = TerminalPanel.class.getDeclaredField("charScratch");
|
||||
scratchField.setAccessible(true);
|
||||
assertFalse(Modifier.isStatic(scratchField.getModifiers()), "charScratch should be an instance field confined to EDT");
|
||||
assertTrue(Modifier.isFinal(scratchField.getModifiers()), "charScratch should be final");
|
||||
|
||||
TerminalPanel panel = new TerminalPanel();
|
||||
panel.setSize(800, 600);
|
||||
|
||||
char[] scratch = (char[]) scratchField.get(panel);
|
||||
assertNotNull(scratch, "charScratch must not be null");
|
||||
assertEquals(1, scratch.length, "charScratch must be length 1");
|
||||
|
||||
// Set up client with non-ASCII characters (e.g. U+2500 box drawing, U+00E9 acute e, U+2550 double box)
|
||||
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
|
||||
client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270);
|
||||
ScreenBuffer sb = client.getScreenBuffer();
|
||||
sb.erase(false);
|
||||
|
||||
// Put non-ASCII characters on screen
|
||||
sb.getCell(0).ucs4 = 0x2500; // Box drawing horizontal line
|
||||
sb.getCell(1).ucs4 = 0x00E9; // e with acute
|
||||
sb.getCell(2).ucs4 = 0x2550; // Box drawing double horizontal
|
||||
sb.getCell(3).ucs4 = 0x2308; // APL left ceiling
|
||||
sb.updateDisplaySnapshot();
|
||||
|
||||
panel.setClient(client);
|
||||
|
||||
BufferedImage img = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g2 = img.createGraphics();
|
||||
assertDoesNotThrow(() -> panel.paint(g2), "paint must render non-ASCII cells via drawChars without error");
|
||||
g2.dispose();
|
||||
|
||||
// Check that charScratch was used
|
||||
assertTrue(scratch[0] > 0, "charScratch should have held the last drawn non-ASCII character");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 27.5: T1-C Reusable NVT Single-Byte Buffer dispatches without allocations")
|
||||
public void testReusableNvtSingleByteBuffer() throws Exception {
|
||||
Field scratchField = TelnetFSM.class.getDeclaredField("nvtSingleByteScratch");
|
||||
scratchField.setAccessible(true);
|
||||
assertFalse(Modifier.isStatic(scratchField.getModifiers()), "nvtSingleByteScratch must be an instance field");
|
||||
assertTrue(Modifier.isFinal(scratchField.getModifiers()), "nvtSingleByteScratch must be final");
|
||||
|
||||
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
|
||||
TelnetFSM fsm = client.getTelnetFSM();
|
||||
byte[] expectedScratch = (byte[]) scratchField.get(fsm);
|
||||
assertNotNull(expectedScratch, "nvtSingleByteScratch must not be null");
|
||||
assertEquals(1, expectedScratch.length, "nvtSingleByteScratch must have length 1");
|
||||
|
||||
fsm.setConnectionState(ConnectionState.CONNECTED_NVT);
|
||||
|
||||
AtomicBoolean dispatched = new AtomicBoolean(false);
|
||||
AtomicReference<byte[]> receivedBuffer = new AtomicReference<>();
|
||||
|
||||
NvtProcessor customNvtProcessor = new NvtProcessor(client.getScreenBuffer(), new EbcdicTranslator()) {
|
||||
@Override
|
||||
public void processNVTData(byte[] buffer, int offset, int length) {
|
||||
dispatched.set(true);
|
||||
receivedBuffer.set(buffer);
|
||||
assertEquals(0, offset);
|
||||
assertEquals(1, length);
|
||||
assertEquals((byte) 'Z', buffer[offset]);
|
||||
super.processNVTData(buffer, offset, length);
|
||||
}
|
||||
};
|
||||
|
||||
Field nvtProcField = TelnetFSM.class.getDeclaredField("nvtProcessor");
|
||||
nvtProcField.setAccessible(true);
|
||||
nvtProcField.set(fsm, customNvtProcessor);
|
||||
|
||||
// Feed single byte 'Z' into FSM
|
||||
fsm.feedByte('Z');
|
||||
|
||||
assertTrue(dispatched.get(), "NvtProcessor.processNVTData should have been called");
|
||||
assertSame(expectedScratch, receivedBuffer.get(), "Buffer passed to processNVTData must be the reusable nvtSingleByteScratch array instance");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 27.6: J3270App has Settings import and no in-line FQCN occurrences")
|
||||
public void testJ3270AppSettingsImport() throws Exception {
|
||||
File projectRoot = new File(".").getCanonicalFile();
|
||||
if (!new File(projectRoot, "j3270").exists() && projectRoot.getParentFile() != null) {
|
||||
projectRoot = projectRoot.getParentFile();
|
||||
}
|
||||
File appFile = new File(projectRoot, "j3270/src/main/java/haus/nightmare/j3270/J3270App.java");
|
||||
assertTrue(appFile.exists(), "J3270App.java must exist");
|
||||
String content = Files.readString(appFile.toPath());
|
||||
|
||||
assertTrue(content.contains("import haus.nightmare.j3270.config.Settings;"),
|
||||
"J3270App.java must import haus.nightmare.j3270.config.Settings");
|
||||
|
||||
// The only occurrence of haus.nightmare.j3270.config.Settings should be in the import
|
||||
int firstIndex = content.indexOf("haus.nightmare.j3270.config.Settings");
|
||||
assertTrue(firstIndex >= 0, "Must contain import");
|
||||
int secondIndex = content.indexOf("haus.nightmare.j3270.config.Settings", firstIndex + 1);
|
||||
assertEquals(-1, secondIndex, "There should be no inline occurrences of haus.nightmare.j3270.config.Settings in J3270App");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package haus.nightmare.j3270;
|
||||
|
||||
import haus.nightmare.j3270.ui.FieldInspectorDialog;
|
||||
import haus.nightmare.j3270.ui.FindDialog;
|
||||
import haus.nightmare.j3270.ui.HotspotEngine;
|
||||
import haus.nightmare.j3270.ui.ScriptDialog;
|
||||
import haus.nightmare.j3270.ui.TerminalPanel;
|
||||
import haus.nightmare.lib3270j.ConnectionConfig;
|
||||
import haus.nightmare.lib3270j.ConnectionState;
|
||||
import haus.nightmare.lib3270j.Telnet3270Client;
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.event.MouseMotionListener;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Verification test suite for Phase 28:
|
||||
* UI Responsiveness, Dialog Lifecycle & Event Optimization
|
||||
*/
|
||||
public class Phase28UiOptimizationTest {
|
||||
|
||||
private void setField(Object obj, String fieldName, Object val) throws Exception {
|
||||
Field f = obj.getClass().getDeclaredField(fieldName);
|
||||
f.setAccessible(true);
|
||||
f.set(obj, val);
|
||||
}
|
||||
|
||||
private void writeString(ScreenBuffer sb, int row, int col, String text) {
|
||||
int cols = sb.getDisplayCols();
|
||||
int base = row * cols + col;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
sb.getCell(base + i).ucs4 = text.charAt(i);
|
||||
}
|
||||
sb.updateDisplaySnapshot();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 28.1: T2-A Hotspot Dirty Flagging & Lazy Text Scraping eliminates redundant scans on mouseMoved")
|
||||
public void testRapidMouseMoveEventsScanOnlyOnce() throws Exception {
|
||||
TerminalPanel panel = new TerminalPanel();
|
||||
panel.setSize(800, 600);
|
||||
|
||||
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
|
||||
client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270);
|
||||
ScreenBuffer sb = client.getScreenBuffer();
|
||||
sb.erase(false);
|
||||
|
||||
// Put hotspots on screen: PF keys and URL
|
||||
writeString(sb, 0, 0, "APPLICATION SELECTION MENU");
|
||||
writeString(sb, 2, 0, "PF1=Help PF3=Exit PF12=Cancel");
|
||||
writeString(sb, 4, 0, "Visit http://example.com/3270 for assistance");
|
||||
sb.updateDisplaySnapshot();
|
||||
|
||||
panel.setClient(client);
|
||||
HotspotEngine engine = panel.getHotspotEngine();
|
||||
engine.setUrlEnabled(true);
|
||||
engine.setPfKeyEnabled(true);
|
||||
engine.setMenuChoiceEnabled(true);
|
||||
|
||||
assertTrue(panel.isHotspotsDirty(), "Hotspots should initially be dirty on panel setup");
|
||||
long initialScanCount = engine.getScanCount();
|
||||
|
||||
MouseMotionListener[] motionListeners = panel.getMouseMotionListeners();
|
||||
assertTrue(motionListeners.length > 0, "TerminalPanel must have at least one MouseMotionListener");
|
||||
MouseMotionListener mml = motionListeners[0];
|
||||
|
||||
// Simulate 1,000 rapid mouse move events across the terminal grid
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
int x = 20 + (i % 200);
|
||||
int y = 30 + (i % 150);
|
||||
MouseEvent me = new MouseEvent(panel, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, x, y, 0, false);
|
||||
mml.mouseMoved(me);
|
||||
}
|
||||
|
||||
// After 1,000 moves, hotspotEngine.scan() should only have executed ONCE
|
||||
assertEquals(initialScanCount + 1, engine.getScanCount(),
|
||||
"hotspotEngine.scan() should execute exactly once across 1,000 rapid mouse move events on a clean screen");
|
||||
assertFalse(panel.isHotspotsDirty(), "Hotspots must be marked clean after scanning");
|
||||
|
||||
// Verify hotspots were actually discovered
|
||||
List<HotspotEngine.Hotspot> hotspots = engine.getHotspots();
|
||||
assertFalse(hotspots.isEmpty(), "Hotspots on screen must be found");
|
||||
assertNotNull(engine.findHotspot(2, 0), "Hotspot at row 2 col 0 (PF1) must be found");
|
||||
|
||||
// Direct calls to HotspotEngine.scan(sb) without snapshot version changes must also bypass StringBuilder
|
||||
long beforeDirectCalls = engine.getScanCount();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
List<HotspotEngine.Hotspot> res = engine.scan(sb);
|
||||
assertFalse(res.isEmpty());
|
||||
}
|
||||
assertEquals(beforeDirectCalls, engine.getScanCount(),
|
||||
"Direct calls to HotspotEngine.scan(sb) without snapshot version change must return cachedHotspots");
|
||||
|
||||
// Now simulate screen update
|
||||
writeString(sb, 6, 0, "NEW UPDATE: PF7=Up PF8=Down");
|
||||
panel.onScreenUpdated();
|
||||
assertTrue(panel.isHotspotsDirty(), "onScreenUpdated() must flag hotspotsDirty = true");
|
||||
|
||||
// Simulate another burst of 1,000 rapid mouse moves
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
int x = 30 + (i % 200);
|
||||
int y = 40 + (i % 150);
|
||||
MouseEvent me = new MouseEvent(panel, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, x, y, 0, false);
|
||||
mml.mouseMoved(me);
|
||||
}
|
||||
|
||||
assertEquals(initialScanCount + 2, engine.getScanCount(),
|
||||
"After screen update, exactly one more scan should run across 1,000 mouse moves");
|
||||
assertFalse(panel.isHotspotsDirty(), "Hotspots must be marked clean again after rescan");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 28.2: T2-B Precomputed Selection Scratch Lookup matches isCellSelected exactly across all modes")
|
||||
public void testSelectionBoundsMatchingPrecomputedScratchVsLegacy() throws Exception {
|
||||
TerminalPanel panel = new TerminalPanel();
|
||||
panel.setSize(800, 600);
|
||||
|
||||
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
|
||||
client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270);
|
||||
panel.setClient(client);
|
||||
|
||||
int rows = 24;
|
||||
int cols = 80;
|
||||
|
||||
// Configuration 1: No selection
|
||||
panel.clearSelection();
|
||||
panel.precomputeSelectionScratch(rows, cols);
|
||||
boolean[] scratch = panel.getSelectedCellsScratch();
|
||||
assertEquals(rows * cols, scratch.length);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
int baddr = r * cols + c;
|
||||
assertFalse(scratch[baddr], "No cell should be selected");
|
||||
assertEquals(panel.isCellSelected(r, c), scratch[baddr]);
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration 2: Linear / Stream mode forward selection (spanning multiple rows)
|
||||
setField(panel, "blockSelectMode", false);
|
||||
setField(panel, "trimRectSizingHandles", false);
|
||||
setField(panel, "selectionStartRow", 2);
|
||||
setField(panel, "selectionStartCol", 15);
|
||||
setField(panel, "selectionEndRow", 5);
|
||||
setField(panel, "selectionEndCol", 42);
|
||||
|
||||
panel.precomputeSelectionScratch(rows, cols);
|
||||
scratch = panel.getSelectedCellsScratch();
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
int baddr = r * cols + c;
|
||||
boolean expected = panel.isCellSelected(r, c);
|
||||
assertEquals(expected, scratch[baddr], "Mismatch in linear forward mode at row " + r + ", col " + c);
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration 3: Linear / Stream mode backward selection (drag upwards)
|
||||
setField(panel, "selectionStartRow", 8);
|
||||
setField(panel, "selectionStartCol", 30);
|
||||
setField(panel, "selectionEndRow", 4);
|
||||
setField(panel, "selectionEndCol", 10);
|
||||
|
||||
panel.precomputeSelectionScratch(rows, cols);
|
||||
scratch = panel.getSelectedCellsScratch();
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
int baddr = r * cols + c;
|
||||
boolean expected = panel.isCellSelected(r, c);
|
||||
assertEquals(expected, scratch[baddr], "Mismatch in linear backward mode at row " + r + ", col " + c);
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration 4: Block select mode
|
||||
setField(panel, "blockSelectMode", true);
|
||||
setField(panel, "trimRectSizingHandles", false);
|
||||
setField(panel, "selectionStartRow", 3);
|
||||
setField(panel, "selectionStartCol", 20);
|
||||
setField(panel, "selectionEndRow", 7);
|
||||
setField(panel, "selectionEndCol", 60);
|
||||
|
||||
panel.precomputeSelectionScratch(rows, cols);
|
||||
scratch = panel.getSelectedCellsScratch();
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
int baddr = r * cols + c;
|
||||
boolean expected = panel.isCellSelected(r, c);
|
||||
assertEquals(expected, scratch[baddr], "Mismatch in block select mode at row " + r + ", col " + c);
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration 5: Trim Rect Sizing Handles mode (block geometry)
|
||||
setField(panel, "blockSelectMode", false);
|
||||
setField(panel, "trimRectSizingHandles", true);
|
||||
setField(panel, "selectionStartRow", 10);
|
||||
setField(panel, "selectionStartCol", 5);
|
||||
setField(panel, "selectionEndRow", 15);
|
||||
setField(panel, "selectionEndCol", 25);
|
||||
|
||||
panel.precomputeSelectionScratch(rows, cols);
|
||||
scratch = panel.getSelectedCellsScratch();
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
int baddr = r * cols + c;
|
||||
boolean expected = panel.isCellSelected(r, c);
|
||||
assertEquals(expected, scratch[baddr], "Mismatch in trim handles mode at row " + r + ", col " + c);
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration 6: Model 5 Dynamic Geometry (27 rows x 132 cols)
|
||||
rows = 27;
|
||||
cols = 132;
|
||||
client.getScreenBuffer().setDimensions(rows, cols);
|
||||
client.getScreenBuffer().updateDisplaySnapshot();
|
||||
setField(panel, "selectionStartRow", 1);
|
||||
setField(panel, "selectionStartCol", 1);
|
||||
setField(panel, "selectionEndRow", 25);
|
||||
setField(panel, "selectionEndCol", 130);
|
||||
|
||||
panel.precomputeSelectionScratch(rows, cols);
|
||||
scratch = panel.getSelectedCellsScratch();
|
||||
assertEquals(rows * cols, scratch.length);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
int baddr = r * cols + c;
|
||||
boolean expected = panel.isCellSelected(r, c);
|
||||
assertEquals(expected, scratch[baddr], "Mismatch in Model 5 27x132 at row " + r + ", col " + c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 28.3: Dialog Lifecycle & Reconnection Hardening updates cached dialogs with active client")
|
||||
public void testReconnectCycleFindDialogInteractsWithNewClient() throws Exception {
|
||||
// 1. Verify API contract and reflection for dialog lifecycle methods
|
||||
Method findSetClient = FindDialog.class.getMethod("setClient", Telnet3270Client.class);
|
||||
assertNotNull(findSetClient);
|
||||
Method findGetClient = FindDialog.class.getMethod("getClient");
|
||||
assertEquals(Telnet3270Client.class, findGetClient.getReturnType());
|
||||
|
||||
Method scriptSetClient = ScriptDialog.class.getMethod("setClient", Telnet3270Client.class);
|
||||
assertNotNull(scriptSetClient);
|
||||
Method scriptGetClient = ScriptDialog.class.getMethod("getClient");
|
||||
assertEquals(Telnet3270Client.class, scriptGetClient.getReturnType());
|
||||
|
||||
Method fieldSetClient = FieldInspectorDialog.class.getMethod("setClient", Telnet3270Client.class);
|
||||
assertNotNull(fieldSetClient);
|
||||
Method fieldGetClient = FieldInspectorDialog.class.getMethod("getClient");
|
||||
assertEquals(Telnet3270Client.class, fieldGetClient.getReturnType());
|
||||
|
||||
Method appGetFind = J3270App.class.getMethod("getFindDialog");
|
||||
assertEquals(FindDialog.class, appGetFind.getReturnType());
|
||||
Method appGetScript = J3270App.class.getMethod("getScriptDialog");
|
||||
assertEquals(ScriptDialog.class, appGetScript.getReturnType());
|
||||
Method appGetField = J3270App.class.getMethod("getFieldInspectorDialog");
|
||||
assertEquals(FieldInspectorDialog.class, appGetField.getReturnType());
|
||||
|
||||
// 2. If non-headless environment, perform end-to-end interactive GUI verification
|
||||
try {
|
||||
J3270App app = new J3270App();
|
||||
TerminalPanel panel = app.getTerminalPanel();
|
||||
|
||||
// Session 1: Client 1
|
||||
ConnectionConfig config1 = new ConnectionConfig("host1.example.com", 23);
|
||||
Telnet3270Client client1 = new Telnet3270Client(config1);
|
||||
client1.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270);
|
||||
writeString(client1.getScreenBuffer(), 1, 0, "SYSTEM_SESSION_ALPHA_123");
|
||||
|
||||
panel.setClient(client1);
|
||||
setField(app, "client", client1);
|
||||
|
||||
// Open FindDialog on session 1
|
||||
FindDialog findDialog = new FindDialog(app, client1, panel);
|
||||
assertSame(client1, findDialog.getClient());
|
||||
|
||||
// Search for string on Client 1
|
||||
findDialog.setSearchText("SYSTEM_SESSION_ALPHA_123");
|
||||
findDialog.findNext();
|
||||
int expectedPos1 = 1 * client1.getScreenBuffer().getDisplayCols();
|
||||
assertEquals(expectedPos1, client1.getScreenBuffer().getCursorAddress(),
|
||||
"FindDialog must move cursor to match on client1");
|
||||
|
||||
// Simulate Session Disconnect
|
||||
setField(app, "findDialog", findDialog);
|
||||
app.disconnect(false);
|
||||
assertNull(findDialog.getClient(), "Disconnect must nullify client in cached dialog");
|
||||
|
||||
// Subsequent find attempt while disconnected should report disconnected without errors
|
||||
findDialog.findNext();
|
||||
assertEquals(expectedPos1, client1.getScreenBuffer().getCursorAddress());
|
||||
|
||||
// Session 2: Reconnection to Client 2
|
||||
ConnectionConfig config2 = new ConnectionConfig("host2.example.com", 23);
|
||||
Telnet3270Client client2 = new Telnet3270Client(config2);
|
||||
client2.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270);
|
||||
writeString(client2.getScreenBuffer(), 3, 10, "MAINFRAME_SESSION_BETA_999");
|
||||
|
||||
// Simulate reconnect hook in J3270App
|
||||
findDialog.setClient(client2);
|
||||
assertSame(client2, findDialog.getClient(), "FindDialog must now hold client2");
|
||||
|
||||
// Search for string on Client 2
|
||||
findDialog.setSearchText("MAINFRAME_SESSION_BETA_999");
|
||||
findDialog.findNext();
|
||||
int expectedPos2 = 3 * client2.getScreenBuffer().getDisplayCols() + 10;
|
||||
assertEquals(expectedPos2, client2.getScreenBuffer().getCursorAddress(),
|
||||
"FindDialog must move cursor to match on active client2 after reconnect");
|
||||
|
||||
// Verify search for old client1 string fails on client2
|
||||
int oldCursorPos = client2.getScreenBuffer().getCursorAddress();
|
||||
findDialog.setSearchText("SYSTEM_SESSION_ALPHA_123");
|
||||
findDialog.findNext();
|
||||
assertEquals(oldCursorPos, client2.getScreenBuffer().getCursorAddress(),
|
||||
"Search for stale text should not match on client2");
|
||||
|
||||
// Verify ScriptDialog and FieldInspectorDialog client lifecycle
|
||||
ScriptDialog scriptDialog = new ScriptDialog(app, client1, panel);
|
||||
assertSame(client1, scriptDialog.getClient());
|
||||
scriptDialog.setClient(client2);
|
||||
assertSame(client2, scriptDialog.getClient());
|
||||
|
||||
FieldInspectorDialog fieldInspector = new FieldInspectorDialog(app, client1, panel);
|
||||
assertSame(client1, fieldInspector.getClient());
|
||||
fieldInspector.setClient(client2);
|
||||
assertSame(client2, fieldInspector.getClient());
|
||||
|
||||
// Cleanup
|
||||
fieldInspector.dispose();
|
||||
findDialog.dispose();
|
||||
scriptDialog.dispose();
|
||||
app.closeWindow();
|
||||
} catch (java.awt.HeadlessException ignored) {
|
||||
// Running in headless mode: GUI instantiation safely bypassed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package haus.nightmare.j3270;
|
||||
|
||||
import haus.nightmare.lib3270j.charset.CodePage;
|
||||
import haus.nightmare.lib3270j.charset.CodePageRegistry;
|
||||
import haus.nightmare.lib3270j.graphics.GraphicsMode;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.HeadlessException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Verification test suite for Phase 31:
|
||||
* Help > About Dialogue Version Reporting and j3270 Interface Inconsistency Fixes (v1.2.4).
|
||||
*/
|
||||
public class Phase31AboutAndInterfaceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 31.1: Dedicated Version classes report v1.2.4 single source of truth")
|
||||
public void testVersionClasses() {
|
||||
// lib3270j
|
||||
assertEquals("1.2.4", haus.nightmare.lib3270j.Version.VERSION);
|
||||
assertEquals("lib3270j", haus.nightmare.lib3270j.Version.NAME);
|
||||
assertEquals("1.2.4", haus.nightmare.lib3270j.Version.getVersion());
|
||||
|
||||
// j3270
|
||||
assertEquals("1.2.4", haus.nightmare.j3270.Version.VERSION);
|
||||
assertEquals("j3270", haus.nightmare.j3270.Version.NAME);
|
||||
assertEquals("1.2.4", haus.nightmare.j3270.Version.getVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 31.2: Help > About dialogue text properly reports j3270 v1.2.4 and lib3270j v1.2.4")
|
||||
public void testAboutTextContent() {
|
||||
String aboutText = J3270App.getAboutText();
|
||||
assertNotNull(aboutText, "About text must not be null");
|
||||
|
||||
// Verify version reports
|
||||
assertTrue(aboutText.contains("j3270 v1.2.4"), "About text must report j3270 v1.2.4");
|
||||
assertTrue(aboutText.contains("lib3270j v1.2.4"), "About text must report lib3270j v1.2.4");
|
||||
|
||||
// Verify stale pre-release versions are eradicated
|
||||
assertFalse(aboutText.contains("0.2.0"), "About text must not contain stale version 0.2.0");
|
||||
assertFalse(aboutText.contains("0.1.0"), "About text must not contain stale version 0.1.0");
|
||||
|
||||
// Verify 40+ codepages reporting
|
||||
assertTrue(aboutText.contains("40+ Pluggable EBCDIC Codepages & DBCS Support"),
|
||||
"About text must report 40+ Pluggable EBCDIC Codepages");
|
||||
assertFalse(aboutText.contains("22+"), "About text must not report stale 22+ codepages");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 31.3: CodePageRegistry.COMMON_CODE_PAGES defines 40+ supported codepages that all resolve")
|
||||
public void testCommonCodePagesRegistration() {
|
||||
String[] common = CodePageRegistry.COMMON_CODE_PAGES;
|
||||
assertNotNull(common, "COMMON_CODE_PAGES must not be null");
|
||||
assertTrue(common.length >= 40, "COMMON_CODE_PAGES must define at least 40 entries, found: " + common.length);
|
||||
|
||||
for (String entry : common) {
|
||||
String cpId = entry.split(" -")[0].trim();
|
||||
CodePage cp = CodePageRegistry.resolveCodePage(cpId);
|
||||
assertNotNull(cp, "Codepage identifier '" + cpId + "' from entry '" + entry + "' must resolve in CodePageRegistry");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 31.4: Menu bar consistency: Code Page & Graphics Mode in Connection menu, Screen History in View")
|
||||
public void testMenuBarConsistency() {
|
||||
J3270App app;
|
||||
try {
|
||||
app = new J3270App();
|
||||
} catch (HeadlessException e) {
|
||||
// Headless environment without X11/display
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JMenuBar mb = app.getJMenuBar();
|
||||
assertNotNull(mb, "JMenuBar must exist");
|
||||
|
||||
JMenu viewMenu = null;
|
||||
JMenu actionsMenu = null;
|
||||
JMenu fileMenu = null;
|
||||
JMenu connectionMenu = null;
|
||||
|
||||
for (int i = 0; i < mb.getMenuCount(); i++) {
|
||||
JMenu m = mb.getMenu(i);
|
||||
if (m != null) {
|
||||
if ("View".equals(m.getText())) viewMenu = m;
|
||||
else if ("Actions".equals(m.getText())) actionsMenu = m;
|
||||
else if ("File".equals(m.getText())) fileMenu = m;
|
||||
else if ("Connection".equals(m.getText())) connectionMenu = m;
|
||||
}
|
||||
}
|
||||
|
||||
assertNotNull(viewMenu, "View menu must exist");
|
||||
assertNotNull(actionsMenu, "Actions menu must exist");
|
||||
assertNotNull(fileMenu, "File menu must exist");
|
||||
assertNotNull(connectionMenu, "Connection menu must exist");
|
||||
|
||||
// Verify Connect... and Disconnect are in Connection menu and NOT in File menu
|
||||
boolean connectInConnection = false;
|
||||
boolean disconnectInConnection = false;
|
||||
for (int i = 0; i < connectionMenu.getItemCount(); i++) {
|
||||
JMenuItem item = connectionMenu.getItem(i);
|
||||
if (item != null) {
|
||||
if ("Connect...".equals(item.getText())) connectInConnection = true;
|
||||
if ("Disconnect".equals(item.getText())) disconnectInConnection = true;
|
||||
}
|
||||
}
|
||||
assertTrue(connectInConnection, "Connect... must be in Connection menu");
|
||||
assertTrue(disconnectInConnection, "Disconnect must be in Connection menu");
|
||||
|
||||
boolean connectInFile = false;
|
||||
boolean disconnectInFile = false;
|
||||
for (int i = 0; i < fileMenu.getItemCount(); i++) {
|
||||
JMenuItem item = fileMenu.getItem(i);
|
||||
if (item != null) {
|
||||
if ("Connect...".equals(item.getText())) connectInFile = true;
|
||||
if ("Disconnect".equals(item.getText())) disconnectInFile = true;
|
||||
}
|
||||
}
|
||||
assertFalse(connectInFile, "Connect... must NOT be in File menu");
|
||||
assertFalse(disconnectInFile, "Disconnect must NOT be in File menu");
|
||||
|
||||
// Verify Screen History is in View menu
|
||||
boolean screenHistoryInView = false;
|
||||
for (int i = 0; i < viewMenu.getItemCount(); i++) {
|
||||
JMenuItem item = viewMenu.getItem(i);
|
||||
if (item != null && "Screen History...".equals(item.getText())) {
|
||||
screenHistoryInView = true;
|
||||
}
|
||||
}
|
||||
assertTrue(screenHistoryInView, "Screen History... must be present in View menu");
|
||||
|
||||
// Verify Screen History is NOT duplicated in Actions menu
|
||||
boolean screenHistoryInActions = false;
|
||||
for (int i = 0; i < actionsMenu.getItemCount(); i++) {
|
||||
JMenuItem item = actionsMenu.getItem(i);
|
||||
if (item != null && "Screen History...".equals(item.getText())) {
|
||||
screenHistoryInActions = true;
|
||||
}
|
||||
}
|
||||
assertFalse(screenHistoryInActions, "Screen History... must NOT be duplicated in Actions menu");
|
||||
|
||||
// Verify Code Page and Graphics Mode are in Connection menu, NOT View menu
|
||||
JMenu codePageSubmenu = null;
|
||||
JMenu graphicsModeSubmenu = null;
|
||||
for (int i = 0; i < connectionMenu.getItemCount(); i++) {
|
||||
JMenuItem item = connectionMenu.getItem(i);
|
||||
if (item != null) {
|
||||
if ("Code Page".equals(item.getText()) && item instanceof JMenu) {
|
||||
codePageSubmenu = (JMenu) item;
|
||||
}
|
||||
if ("Graphics Mode".equals(item.getText()) && item instanceof JMenu) {
|
||||
graphicsModeSubmenu = (JMenu) item;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertNotNull(codePageSubmenu, "Connection > Code Page submenu must exist");
|
||||
assertNotNull(graphicsModeSubmenu, "Connection > Graphics Mode submenu must exist");
|
||||
assertEquals(CodePageRegistry.COMMON_CODE_PAGES.length, codePageSubmenu.getItemCount(),
|
||||
"Connection > Code Page submenu must contain all " + CodePageRegistry.COMMON_CODE_PAGES.length + " common codepages");
|
||||
assertEquals(GraphicsMode.values().length, graphicsModeSubmenu.getItemCount(),
|
||||
"Connection > Graphics Mode submenu must contain all " + GraphicsMode.values().length + " graphics modes");
|
||||
|
||||
// Graphics Mode must be enabled when disconnected
|
||||
assertFalse(app.isConnected(), "App should not be connected by default");
|
||||
assertTrue(graphicsModeSubmenu.isEnabled(), "Graphics Mode submenu should be enabled when disconnected");
|
||||
|
||||
// Verify Code Page and Graphics Mode are NOT in View menu
|
||||
for (int i = 0; i < viewMenu.getItemCount(); i++) {
|
||||
JMenuItem item = viewMenu.getItem(i);
|
||||
if (item != null) {
|
||||
assertNotEquals("Code Page", item.getText(), "Code Page must NOT be in View menu");
|
||||
assertNotEquals("Graphics Mode", item.getText(), "Graphics Mode must NOT be in View menu");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Base 4-Color Override is in View menu
|
||||
boolean base4InView = false;
|
||||
for (int i = 0; i < viewMenu.getItemCount(); i++) {
|
||||
JMenuItem item = viewMenu.getItem(i);
|
||||
if (item != null && "Base 4-Color Override".equals(item.getText())) {
|
||||
base4InView = true;
|
||||
}
|
||||
}
|
||||
assertTrue(base4InView, "Base 4-Color Override must be present in View menu");
|
||||
|
||||
// Verify Modes menu contains strictly the 4 interactive operator modes
|
||||
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 must exist");
|
||||
assertEquals(4, modesMenu.getItemCount(), "Modes menu should contain exactly 4 interactive operator modes");
|
||||
assertEquals("Document Mode (DOC)", modesMenu.getItem(0).getText());
|
||||
assertEquals("Word Wrap Mode", modesMenu.getItem(1).getText());
|
||||
assertEquals("APL Keyboard Mode", modesMenu.getItem(2).getText());
|
||||
assertEquals("Insert Mode", modesMenu.getItem(3).getText());
|
||||
} finally {
|
||||
app.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,4 +189,106 @@ public class HotspotEngineTest {
|
||||
List<HotspotEngine.Hotspot> empty = engine.scanText(screen, 1, 80);
|
||||
assertEquals(0, empty.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Detects ISPF primary option menu choices (numbers, two-digit, and letters)")
|
||||
public void testIspfPrimaryOptionMenuDetection() {
|
||||
String screen =
|
||||
" Menu Utilities Compilers Options Status Help\n" +
|
||||
" ──────────────────────────────────────────────────────────────────────────────\n" +
|
||||
" ISPF Primary Option Menu\n" +
|
||||
" Option ===>\n" +
|
||||
" \n" +
|
||||
" 0 Settings Terminal and user parameters User ID . : RUDI\n" +
|
||||
" 1 View Display source data or listings Time. . . : 12:31\n" +
|
||||
" 2 Edit Create or change source data Terminal. : 3278\n" +
|
||||
" 3 Utilities Perform utility functions Screen. . : 1\n" +
|
||||
" 4 Foreground Interactive language processing Language. : ENGLISH\n" +
|
||||
" 5 Batch Submit job for language processing Appl ID . : ISR\n" +
|
||||
" 6 Command Enter TSO or Workstation commands TSO logon : DEVPROC\n" +
|
||||
" 7 Dialog Test Perform dialog testing TSO prefix: RUDI\n" +
|
||||
" 9 IBM Products IBM program development products System ID : VS01\n" +
|
||||
" 10 SCLM SW Configuration Library Manager Release . : ISPF 7.5\n" +
|
||||
" 11 Workplace ISPF Object/Action Workplace\n" +
|
||||
" M More Additional workplace options\n" +
|
||||
" X Exit Terminate ISPF using log and list defaults";
|
||||
|
||||
List<HotspotEngine.Hotspot> hotspots = engine.scanText(screen, 18, 80);
|
||||
|
||||
// 14 total hotspots: 13 ISPF menu choices (0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, M, X) + 1 [ENTER] key on row 11
|
||||
assertEquals(14, hotspots.size());
|
||||
List<HotspotEngine.Hotspot> menuChoices = hotspots.stream()
|
||||
.filter(h -> h.getType() == HotspotEngine.HotspotType.MENU_CHOICE)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
assertEquals(13, menuChoices.size());
|
||||
|
||||
// Option 0
|
||||
HotspotEngine.Hotspot h0 = menuChoices.get(0);
|
||||
assertEquals(HotspotEngine.HotspotType.MENU_CHOICE, h0.getType());
|
||||
assertEquals("0 Settings", h0.getDisplayText());
|
||||
assertEquals("0[enter]", h0.getAction());
|
||||
assertEquals("Select option 0 (Settings)", h0.getTooltip());
|
||||
assertEquals(5, h0.getStartRow());
|
||||
|
||||
// Option 1
|
||||
HotspotEngine.Hotspot h1 = menuChoices.get(1);
|
||||
assertEquals("1 View", h1.getDisplayText());
|
||||
assertEquals("1[enter]", h1.getAction());
|
||||
assertEquals("Select option 1 (View)", h1.getTooltip());
|
||||
assertEquals(6, h1.getStartRow());
|
||||
|
||||
// Option 2
|
||||
HotspotEngine.Hotspot h2 = menuChoices.get(2);
|
||||
assertEquals("2 Edit", h2.getDisplayText());
|
||||
assertEquals("2[enter]", h2.getAction());
|
||||
assertEquals("Select option 2 (Edit)", h2.getTooltip());
|
||||
|
||||
// Option 10 (two-digit, index 9 after 0,1,2,3,4,5,6,7,9)
|
||||
HotspotEngine.Hotspot h10 = menuChoices.get(9);
|
||||
assertEquals("10 SCLM", h10.getDisplayText());
|
||||
assertEquals("10[enter]", h10.getAction());
|
||||
assertEquals("Select option 10 (SCLM)", h10.getTooltip());
|
||||
|
||||
// Option M (letter, index 11)
|
||||
HotspotEngine.Hotspot hM = menuChoices.get(11);
|
||||
assertEquals("M More", hM.getDisplayText());
|
||||
assertEquals("M[enter]", hM.getAction());
|
||||
assertEquals("Select option M (More)", hM.getTooltip());
|
||||
|
||||
// Option X (exit, index 12)
|
||||
HotspotEngine.Hotspot hX = menuChoices.get(12);
|
||||
assertEquals("X Exit", hX.getDisplayText());
|
||||
assertEquals("X[enter]", hX.getAction());
|
||||
assertEquals("Select option X (Exit)", hX.getTooltip());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Detects ISPF dotted sub-options, dashes, and multi-column menus")
|
||||
public void testIspfSubOptionsAndMultiColumn() {
|
||||
String screen =
|
||||
" 3.1 Library - Compress or print data set\n" +
|
||||
" 3.2 DataSet - Allocate, rename, or delete\n" +
|
||||
" 1 - View 2 - Edit";
|
||||
|
||||
List<HotspotEngine.Hotspot> hotspots = engine.scanText(screen, 3, 80);
|
||||
assertEquals(4, hotspots.size());
|
||||
|
||||
HotspotEngine.Hotspot h31 = hotspots.get(0);
|
||||
assertEquals("3.1 Library", h31.getDisplayText());
|
||||
assertEquals("3.1[enter]", h31.getAction());
|
||||
assertEquals("Select option 3.1 (Library)", h31.getTooltip());
|
||||
|
||||
HotspotEngine.Hotspot h32 = hotspots.get(1);
|
||||
assertEquals("3.2 DataSet", h32.getDisplayText());
|
||||
assertEquals("3.2[enter]", h32.getAction());
|
||||
|
||||
HotspotEngine.Hotspot h1 = hotspots.get(2);
|
||||
assertEquals("1 - View", h1.getDisplayText());
|
||||
assertEquals("1[enter]", h1.getAction());
|
||||
|
||||
HotspotEngine.Hotspot h2 = hotspots.get(3);
|
||||
assertEquals("2 - Edit", h2.getDisplayText());
|
||||
assertEquals("2[enter]", h2.getAction());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,4 +119,217 @@ public class TerminalPanelSelectionTest {
|
||||
} catch (HeadlessException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Clicking an unprotected, intensified line (e.g. XEDIT current line) does not transmit AID_SELECT")
|
||||
public void testClickOnUnprotectedIntensifiedFieldDoesNotTriggerAidSelect() {
|
||||
try {
|
||||
TerminalPanel panel = new TerminalPanel();
|
||||
panel.setSize(800, 600);
|
||||
|
||||
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
|
||||
client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270);
|
||||
|
||||
ScreenBuffer sb = client.getScreenBuffer();
|
||||
sb.erase(false);
|
||||
|
||||
// Row 3 (address 240): Unprotected, intensified line with spaces (XEDIT located line)
|
||||
int row3Start = 3 * 80;
|
||||
sb.setCellFA(row3Start, (byte) (FA_PRINTABLE | FA_INT_HIGH_SEL)); // Unprotected, high intensity (detectable)
|
||||
// Space designator and COBOL text
|
||||
String lineText = " IDENTIFICATION DIVISION.";
|
||||
for (int i = 0; i < lineText.length(); i++) {
|
||||
sb.getCell(row3Start + 1 + i).ucs4 = lineText.charAt(i);
|
||||
sb.getCell(row3Start + 1 + i).ec = (byte) (lineText.charAt(i) == ' ' ? 0x40 : 0xC1 + i);
|
||||
}
|
||||
|
||||
panel.setClient(client);
|
||||
|
||||
int cellW = panel.cellWidth > 0 ? panel.cellWidth : 10;
|
||||
int cellH = panel.cellHeight > 0 ? panel.cellHeight : 18;
|
||||
int ox = panel.getRenderOffsetX();
|
||||
int oy = panel.getRenderOffsetY();
|
||||
|
||||
MouseListener[] mouseListeners = panel.getMouseListeners();
|
||||
assertTrue(mouseListeners.length > 0);
|
||||
MouseListener ml = mouseListeners[0];
|
||||
|
||||
// Click at row 3, col 28 (like in j3270.log)
|
||||
int clickX = ox + 28 * cellW + cellW / 2;
|
||||
int clickY = oy + 3 * cellH + cellH / 2;
|
||||
|
||||
MouseEvent pressEvent = new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(),
|
||||
0, clickX, clickY, 1, false, MouseEvent.BUTTON1);
|
||||
ml.mousePressed(pressEvent);
|
||||
|
||||
MouseEvent releaseEvent = new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(),
|
||||
0, clickX, clickY, 1, false, MouseEvent.BUTTON1);
|
||||
ml.mouseReleased(releaseEvent);
|
||||
|
||||
// Cursor must move to clicked address without sending AID_SELECT (AID 0x7E)
|
||||
assertEquals(row3Start + 28, sb.getCursorAddress(), "Cursor must be placed at clicked position");
|
||||
assertEquals(AID_NO, client.getInputProcessor().getLastAid(), "No AID must be transmitted on single click");
|
||||
|
||||
// Text on line must NOT have been modified or cleared
|
||||
assertEquals('I', (char) sb.getCell(row3Start + 8).ucs4);
|
||||
} catch (HeadlessException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Clicking a toggle field (? -> >) toggles the designator without transmitting AID")
|
||||
public void testClickOnToggleField() {
|
||||
try {
|
||||
TerminalPanel panel = new TerminalPanel();
|
||||
panel.setSize(800, 600);
|
||||
|
||||
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
|
||||
client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270);
|
||||
|
||||
ScreenBuffer sb = client.getScreenBuffer();
|
||||
sb.erase(false);
|
||||
|
||||
// Field with ? designator
|
||||
sb.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_NORM_SEL));
|
||||
sb.getCell(1).ucs4 = '?';
|
||||
sb.getCell(1).ec = (byte) 0x6F;
|
||||
|
||||
panel.setClient(client);
|
||||
|
||||
int cellW = panel.cellWidth > 0 ? panel.cellWidth : 10;
|
||||
int cellH = panel.cellHeight > 0 ? panel.cellHeight : 18;
|
||||
int ox = panel.getRenderOffsetX();
|
||||
int oy = panel.getRenderOffsetY();
|
||||
|
||||
MouseListener ml = panel.getMouseListeners()[0];
|
||||
|
||||
int clickX = ox + 1 * cellW + cellW / 2;
|
||||
int clickY = oy + cellH / 2;
|
||||
|
||||
ml.mousePressed(new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(),
|
||||
0, clickX, clickY, 1, false, MouseEvent.BUTTON1));
|
||||
ml.mouseReleased(new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(),
|
||||
0, clickX, clickY, 1, false, MouseEvent.BUTTON1));
|
||||
|
||||
assertEquals('>', (char) sb.getCell(1).ucs4, "Toggle field ? should toggle to > on click");
|
||||
assertEquals(AID_NO, client.getInputProcessor().getLastAid(), "No AID transmitted for deferred toggle field");
|
||||
} catch (HeadlessException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Clicking an unprotected space-designator field when lightPenMode is enabled transmits AID_SELECT")
|
||||
public void testClickInLightPenModeTransmitsAidSelect() {
|
||||
try {
|
||||
TerminalPanel panel = new TerminalPanel();
|
||||
panel.setSize(800, 600);
|
||||
|
||||
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
|
||||
client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270);
|
||||
|
||||
ScreenBuffer sb = client.getScreenBuffer();
|
||||
sb.erase(false);
|
||||
|
||||
// Unprotected, high-intensity field with space designator
|
||||
sb.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_HIGH_SEL));
|
||||
sb.getCell(1).ucs4 = ' ';
|
||||
sb.getCell(1).ec = (byte) 0x40;
|
||||
|
||||
panel.setClient(client);
|
||||
panel.toggleLightPen();
|
||||
assertTrue(panel.isLightPenMode());
|
||||
|
||||
int cellW = panel.cellWidth > 0 ? panel.cellWidth : 10;
|
||||
int cellH = panel.cellHeight > 0 ? panel.cellHeight : 18;
|
||||
int ox = panel.getRenderOffsetX();
|
||||
int oy = panel.getRenderOffsetY();
|
||||
|
||||
MouseListener ml = panel.getMouseListeners()[0];
|
||||
|
||||
int clickX = ox + 1 * cellW + cellW / 2;
|
||||
int clickY = oy + cellH / 2;
|
||||
|
||||
ml.mousePressed(new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(),
|
||||
0, clickX, clickY, 1, false, MouseEvent.BUTTON1));
|
||||
ml.mouseReleased(new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(),
|
||||
0, clickX, clickY, 1, false, MouseEvent.BUTTON1));
|
||||
|
||||
assertEquals(AID_SELECT, client.getInputProcessor().getLastAid(), "AID_SELECT must be transmitted in lightPenMode");
|
||||
} catch (HeadlessException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Clicking an ISPF menu choice navigates to home/option line, types choice, and sends AID_ENTER")
|
||||
public void testClickOnIspfMenuChoiceSubmitsViaHomeAndEnter() {
|
||||
try {
|
||||
TerminalPanel panel = new TerminalPanel();
|
||||
panel.setSize(800, 600);
|
||||
|
||||
Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23));
|
||||
client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270);
|
||||
|
||||
ScreenBuffer sb = client.getScreenBuffer();
|
||||
sb.erase(false);
|
||||
|
||||
// Row 3 (col 0): Protected label "Option ===> "
|
||||
int row3Start = 3 * 80;
|
||||
sb.setCellFA(row3Start, (byte) (FA_PRINTABLE | FA_PROTECT));
|
||||
String promptText = "Option ===> ";
|
||||
for (int i = 0; i < promptText.length(); i++) {
|
||||
sb.getCell(row3Start + 1 + i).ucs4 = promptText.charAt(i);
|
||||
sb.getCell(row3Start + 1 + i).ec = (byte) (0xC1 + i);
|
||||
}
|
||||
|
||||
// Unprotected command input field after "Option ===> " at address 253 (row 3 col 13)
|
||||
int inputFieldAddr = row3Start + 13;
|
||||
sb.setCellFA(inputFieldAddr, (byte) FA_PRINTABLE); // Unprotected!
|
||||
for (int i = 0; i < 20; i++) {
|
||||
sb.getCell(inputFieldAddr + 1 + i).ucs4 = ' ';
|
||||
sb.getCell(inputFieldAddr + 1 + i).ec = (byte) 0x40;
|
||||
}
|
||||
|
||||
// Row 6 (address 480): Protected field with ISPF menu option " 1 View"
|
||||
int row6Start = 6 * 80;
|
||||
sb.setCellFA(row6Start, (byte) (FA_PRINTABLE | FA_PROTECT));
|
||||
String menuText = " 1 View Display source data or listings";
|
||||
for (int i = 0; i < menuText.length(); i++) {
|
||||
sb.getCell(row6Start + 1 + i).ucs4 = menuText.charAt(i);
|
||||
sb.getCell(row6Start + 1 + i).ec = (byte) (menuText.charAt(i) == ' ' ? 0x40 : 0xC1 + i);
|
||||
}
|
||||
|
||||
sb.updateFormatted();
|
||||
sb.updateDisplaySnapshot();
|
||||
|
||||
panel.setClient(client);
|
||||
panel.setHotspotsDirty(true);
|
||||
|
||||
int cellW = panel.cellWidth > 0 ? panel.cellWidth : 10;
|
||||
int cellH = panel.cellHeight > 0 ? panel.cellHeight : 18;
|
||||
int ox = panel.getRenderOffsetX();
|
||||
int oy = panel.getRenderOffsetY();
|
||||
|
||||
MouseListener ml = panel.getMouseListeners()[0];
|
||||
|
||||
// Click at row 6, col 2 (on the "1" in " 1 View")
|
||||
int clickX = ox + 2 * cellW + cellW / 2;
|
||||
int clickY = oy + 6 * cellH + cellH / 2;
|
||||
|
||||
ml.mousePressed(new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(),
|
||||
0, clickX, clickY, 1, false, MouseEvent.BUTTON1));
|
||||
ml.mouseReleased(new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(),
|
||||
0, clickX, clickY, 1, false, MouseEvent.BUTTON1));
|
||||
|
||||
// AID_ENTER must have been transmitted
|
||||
assertEquals(AID_ENTER, client.getInputProcessor().getLastAid(), "Clicking menu choice must transmit AID_ENTER");
|
||||
|
||||
// Keyboard lock should be standard system lock awaiting host response, NOT protected field error
|
||||
assertNotEquals(com.ibm.eNetwork.ECL.ECLConstants.INHIBIT_PROTECTED_FIELD, client.getOIA().getInputInhibited(),
|
||||
"Keyboard must not be locked with protected field violation");
|
||||
|
||||
// Option "1" must have been typed into the unprotected command input field (address inputFieldAddr + 1)
|
||||
assertEquals('1', (char) sb.getCell(inputFieldAddr + 1).ucs4, "Option '1' must be placed on the command/option line");
|
||||
} catch (HeadlessException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'maven-publish'
|
||||
}
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
withJavadocJar()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -17,3 +23,39 @@ jar {
|
||||
'Implementation-Version': archiveVersion
|
||||
}
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
mavenJava(MavenPublication) {
|
||||
from components.java
|
||||
groupId = 'haus.nightmare.j3270'
|
||||
artifactId = 'lib3270j'
|
||||
version = project.version
|
||||
|
||||
pom {
|
||||
name = 'lib3270j'
|
||||
description = 'Pure Java IBM 3270 terminal emulation and protocol library with Host On-Demand (HOD) parity'
|
||||
url = 'https://github.com/rudi/j3270'
|
||||
licenses {
|
||||
license {
|
||||
name = 'The Unlicense'
|
||||
url = 'https://unlicense.org/'
|
||||
distribution = 'repo'
|
||||
}
|
||||
}
|
||||
developers {
|
||||
developer {
|
||||
id = 'rudi'
|
||||
name = 'Rudi'
|
||||
}
|
||||
}
|
||||
scm {
|
||||
connection = 'scm:git:git://github.com/rudi/j3270.git'
|
||||
developerConnection = 'scm:git:ssh://github.com/rudi/j3270.git'
|
||||
url = 'https://github.com/rudi/j3270'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -385,6 +385,9 @@ public class Telnet3270Client {
|
||||
* Check if connected (any state past TCP pending).
|
||||
*/
|
||||
public boolean isConnected() {
|
||||
if (fsm != null && fsm.getConnectionState() == ConnectionState.NOT_CONNECTED) {
|
||||
return false;
|
||||
}
|
||||
return connection != null && connection.isConnected();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package haus.nightmare.lib3270j;
|
||||
|
||||
/**
|
||||
* Version information for lib3270j.
|
||||
*/
|
||||
public final class Version {
|
||||
|
||||
/**
|
||||
* Hardcoded fallback release version matching build.gradle and pom coordinates.
|
||||
*/
|
||||
public static final String VERSION = "1.2.4";
|
||||
|
||||
/**
|
||||
* Canonical library name.
|
||||
*/
|
||||
public static final String NAME = "lib3270j";
|
||||
|
||||
private Version() {}
|
||||
|
||||
/**
|
||||
* Retrieves the runtime version string of lib3270j.
|
||||
* Inspects the package implementation version first (populated when loaded from a JAR),
|
||||
* and falls back to the compile-time {@link #VERSION} constant if null or blank.
|
||||
*
|
||||
* @return the version string (e.g. "1.2.4")
|
||||
*/
|
||||
public static String getVersion() {
|
||||
Package pkg = Version.class.getPackage();
|
||||
if (pkg != null) {
|
||||
String implVer = pkg.getImplementationVersion();
|
||||
if (implVer != null && !implVer.isBlank()) {
|
||||
return implVer;
|
||||
}
|
||||
}
|
||||
return VERSION;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,58 @@ public class CodePageRegistry {
|
||||
private static final Map<String, CodePage> CODE_PAGES = new LinkedHashMap<>();
|
||||
private static final Map<String, String> ALIASES = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Standard human-readable codepage option labels for UI dropdowns and menus.
|
||||
* Covers core SBCS, all 10 Euro variants (1140-1149), Extended SBCS (Arabic,
|
||||
* Hebrew, Thai, Cyrillic, Turkish), and Mixed/Extended DBCS.
|
||||
*/
|
||||
public static final String[] COMMON_CODE_PAGES = {
|
||||
"037 - US / Canada / Brazil",
|
||||
"1047 - IBM Open Systems / z/OS Unix",
|
||||
"500 - International Latin-1",
|
||||
"273 - Germany / Austria",
|
||||
"277 - Denmark / Norway",
|
||||
"278 - Sweden / Finland",
|
||||
"280 - Italy",
|
||||
"284 - Spain / Latin America",
|
||||
"285 - United Kingdom",
|
||||
"297 - France",
|
||||
"870 - Eastern Europe / Latin-2",
|
||||
"871 - Iceland",
|
||||
"875 - Greece (Greek)",
|
||||
"1026 - Turkey (Turkish Latin-5)",
|
||||
"1155 - Turkey (Turkish Latin-5 Euro \u20AC)",
|
||||
"905 - Turkey (Turkish Latin-3)",
|
||||
"420 - Arabic Bilingual",
|
||||
"424 - Hebrew (with Latin)",
|
||||
"803 - Hebrew Character Set",
|
||||
"838 - Thai Extended",
|
||||
"1160 - Thai Euro \u20AC",
|
||||
"1025 - Cyrillic Multilingual",
|
||||
"1123 - Cyrillic Ukraine",
|
||||
"1154 - Cyrillic Euro \u20AC",
|
||||
"880 - Cyrillic (Russian)",
|
||||
"1140 - US / Canada (Euro \u20AC)",
|
||||
"1141 - Germany / Austria (Euro \u20AC)",
|
||||
"1142 - Denmark / Norway (Euro \u20AC)",
|
||||
"1143 - Sweden / Finland (Euro \u20AC)",
|
||||
"1144 - Italy (Euro \u20AC)",
|
||||
"1145 - Spain / Latin America (Euro \u20AC)",
|
||||
"1146 - United Kingdom (Euro \u20AC)",
|
||||
"1147 - France (Euro \u20AC)",
|
||||
"1148 - International (Euro \u20AC)",
|
||||
"1149 - Iceland (Euro \u20AC)",
|
||||
"290 - Japanese Katakana SBCS",
|
||||
"1027 - Japanese Latin SBCS",
|
||||
"930 - Japanese Katakana Mixed DBCS",
|
||||
"939 - Japanese Latin Mixed DBCS",
|
||||
"935 - Simplified Chinese Mixed DBCS",
|
||||
"937 - Traditional Chinese Mixed DBCS",
|
||||
"1388 - Simplified Chinese Extended DBCS",
|
||||
"1371 - Traditional Chinese Extended DBCS",
|
||||
"933 - Korean Mixed DBCS"
|
||||
};
|
||||
|
||||
static {
|
||||
// Register core SBCS codepages
|
||||
register(new Cp037());
|
||||
|
||||
@@ -8,6 +8,12 @@ import java.util.Arrays;
|
||||
* Reusable, resizable byte buffer that provides zero-copy access to its internal
|
||||
* array and supports ByteBuffer slicing to minimize heap allocations during
|
||||
* network stream processing.
|
||||
* <p>
|
||||
* <b>Threading Contract:</b> This class is NOT thread-safe and is designed for
|
||||
* high-throughput single-threaded or thread-confined stream accumulation (such as
|
||||
* Telnet network receiver loops matching IBM Host On-Demand {@code sArray} and
|
||||
* {@code sb_buffer}). External synchronization must be used if instances are shared
|
||||
* across threads.
|
||||
*/
|
||||
public class FastByteBuffer extends OutputStream {
|
||||
|
||||
@@ -31,13 +37,13 @@ public class FastByteBuffer extends OutputStream {
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void write(int b) {
|
||||
public void write(int b) {
|
||||
ensureCapacity(count + 1);
|
||||
buf[count++] = (byte) b;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void write(byte[] b, int off, int len) {
|
||||
public void write(byte[] b, int off, int len) {
|
||||
if (b == null || len <= 0) return;
|
||||
ensureCapacity(count + len);
|
||||
System.arraycopy(b, off, buf, count, len);
|
||||
@@ -48,29 +54,29 @@ public class FastByteBuffer extends OutputStream {
|
||||
* Direct reference to internal buffer array.
|
||||
* Use {@link #size()} to determine active length.
|
||||
*/
|
||||
public synchronized byte[] buffer() {
|
||||
public byte[] buffer() {
|
||||
return buf;
|
||||
}
|
||||
|
||||
public synchronized int size() {
|
||||
public int size() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public synchronized void reset() {
|
||||
public void reset() {
|
||||
count = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a read-only ByteBuffer view wrapping active bytes without copying.
|
||||
*/
|
||||
public synchronized ByteBuffer asByteBuffer() {
|
||||
public ByteBuffer asByteBuffer() {
|
||||
return ByteBuffer.wrap(buf, 0, count).asReadOnlyBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a read-only ByteBuffer slice for a sub-range without copying.
|
||||
*/
|
||||
public synchronized ByteBuffer slice(int offset, int length) {
|
||||
public ByteBuffer slice(int offset, int length) {
|
||||
if (offset < 0 || length < 0 || offset + length > count) {
|
||||
throw new IndexOutOfBoundsException("offset=" + offset + " length=" + length + " size=" + count);
|
||||
}
|
||||
@@ -80,7 +86,7 @@ public class FastByteBuffer extends OutputStream {
|
||||
/**
|
||||
* Produces a copied byte array if an isolated copy is explicitly required.
|
||||
*/
|
||||
public synchronized byte[] toByteArray() {
|
||||
public byte[] toByteArray() {
|
||||
return Arrays.copyOf(buf, count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ package haus.nightmare.lib3270j.graphics;
|
||||
* and clicking the visual menu emitted gy = 224 (missing the [187..200] menu bounding box).</li>
|
||||
* <li><b>Failure Mode 3</b>: Intermediate raster buffering (e.g. fixed 800x600 canvas stretched to gridW x gridH)
|
||||
* introduced rounding artifacts in bidirectional coordinate conversion (unmapX/unmapY).</li>
|
||||
* <li><b>Failure Mode 4</b>: Overlapping dropdown menus (FILE, DRAW, TRANSFORM) lingering simultaneously when
|
||||
* segment retention/clearing was decoupled from GDDM's segment lifecycle.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class GddmCoordinateTransform {
|
||||
|
||||
@@ -444,34 +444,11 @@ public class GraphicsPlane implements PixelBuffer {
|
||||
/**
|
||||
* Total vertical presentation space units.
|
||||
*
|
||||
* <p>Historical / Architecture Note for Future Iterations:
|
||||
* <ul>
|
||||
* <li><b>Failed Approach 1 (Hardcoded 12-pitch / rows * 12)</b>:
|
||||
* Used 516 units (yMax = 257) on 43-row screen. Caused upper coordinate truncation;
|
||||
* menus at gy > 257 were clipped and mathematically impossible to pick.</li>
|
||||
* <li><b>Failed Approach 2 (Hardcoded 16-pitch / rows * 16 without viewport alignment)</b>:
|
||||
* Used 688 units (yMax = 343). Visual rendering was shifted downward by ~50 pixels relative
|
||||
* to GDDM's internal picture space viewport, causing clicks at Row 7 to emit gy = 226
|
||||
* which missed the GDDM menu hit box and caused terminal alarm beeps.</li>
|
||||
* <li><b>Failed Approach 3 (Overwriting Graphic Input SF bytes 16-19 with row/col)</b>:
|
||||
* Overwrote device correlation descriptor 0x00230023, causing GDDM Structured Field
|
||||
* parser to reject inbound correlation packets as malformed.</li>
|
||||
* <li><b>Failed Approach 4 (Misinterpreting Procedure Order 0x21 as Segment Redraw loop)</b>:
|
||||
* Order 0x21 is HODCurrentDefaults (Set Current Defaults), not an executive redraw loop.
|
||||
* Treating 0x21 as segment execution caused previous menu frames and stored segments
|
||||
* to be repeatedly repainted over active screens, leaving behind visual ghost artifacts.</li>
|
||||
* <li><b>Failed Approach 5 (Static yMax=343 scaling vs GDDM ADMDRAW Viewport bounds)</b>:
|
||||
* In ADMDRAW, GDDM defines the graphics presentation space between y = -169 (canvas bottom)
|
||||
* and y = +200 (top menu bar), with total height ~370 units. Mapping this into a static
|
||||
* yMax = 343 (688 total height) renders the top menu at Row 9.2 (middle of the screen) with
|
||||
* a large vertical void above it, and clicking the visual menu sends gy = 224 which misses
|
||||
* the [187..200] menu bounding box. Furthermore, multiple dropdown menus (FILE, DRAW, TRANSFORM)
|
||||
* overlapped simultaneously because segment retention and clearing was decoupled from GDDM's
|
||||
* actual viewport state.</li>
|
||||
* <li><b>Solution Path</b>: Use {@link GddmCoordinateTransform} for dynamic display and aspect
|
||||
* scaling, ensuring host Query Reply metrics (SDH/AH) and GDDM Picture Space Window
|
||||
* orders are synchronized with client-side mouse transformation.</li>
|
||||
* </ul>
|
||||
* <p>Calculated as {@code screenRows * charHeight} matching 3179G presentation
|
||||
* space metrics. Dynamic display and aspect scaling relative to host picture
|
||||
* space and viewport bounds are managed by {@link GddmCoordinateTransform}.
|
||||
*
|
||||
* @return Total presentation space height in units
|
||||
*/
|
||||
public int getTotalHeight() {
|
||||
int rows = screenRows > 0 ? screenRows : 24;
|
||||
|
||||
@@ -110,6 +110,14 @@ public class ExtendedAttribute {
|
||||
*/
|
||||
public char ucs4;
|
||||
|
||||
public ExtendedAttribute() {}
|
||||
|
||||
public ExtendedAttribute(ExtendedAttribute other) {
|
||||
if (other != null) {
|
||||
copyFrom(other);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all attributes. */
|
||||
public void clear() {
|
||||
ec = 0;
|
||||
|
||||
@@ -197,48 +197,99 @@ public class ScreenBuffer {
|
||||
return buffer[addr];
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable snapshot container for tear-free, lock-free screen display.
|
||||
* Published atomically to UI thread / EDT via volatile reference release.
|
||||
*/
|
||||
public static final class DisplaySnapshot {
|
||||
public final ExtendedAttribute[] cells;
|
||||
public final int rows;
|
||||
public final int cols;
|
||||
public final int cursorAddress;
|
||||
public final long version;
|
||||
|
||||
public DisplaySnapshot(ExtendedAttribute[] cells, int rows, int cols, int cursorAddress) {
|
||||
this(cells, rows, cols, cursorAddress, 0L);
|
||||
}
|
||||
|
||||
public DisplaySnapshot(ExtendedAttribute[] cells, int rows, int cols, int cursorAddress, long version) {
|
||||
this.cells = cells;
|
||||
this.rows = rows;
|
||||
this.cols = cols;
|
||||
this.cursorAddress = cursorAddress;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public ExtendedAttribute getCell(int addr) {
|
||||
if (cells == null || addr < 0 || addr >= cells.length) {
|
||||
return null;
|
||||
}
|
||||
return cells[addr];
|
||||
}
|
||||
|
||||
public int getRows() { return rows; }
|
||||
public int getCols() { return cols; }
|
||||
public int getCursorAddress() { return cursorAddress; }
|
||||
public long getVersion() { return version; }
|
||||
}
|
||||
|
||||
private volatile DisplaySnapshot currentSnapshot;
|
||||
private ExtendedAttribute[] displaySnapshot;
|
||||
private int displayRows;
|
||||
private int displayCols;
|
||||
private int displayCursorAddress;
|
||||
private long snapshotVersion = 0;
|
||||
|
||||
/**
|
||||
* Atomically creates a snapshot of the current presentation buffer for tear-free rendering.
|
||||
* Takes ~2 microseconds and eliminates mutual thread contention with the UI thread.
|
||||
* Atomically creates an immutable snapshot of the current presentation buffer for tear-free rendering.
|
||||
* Published via a volatile release fence, eliminating mutual monitor contention with the UI paint loop.
|
||||
*/
|
||||
public synchronized void updateDisplaySnapshot() {
|
||||
int size = rows * cols;
|
||||
if (displaySnapshot == null || displaySnapshot.length < size) {
|
||||
displaySnapshot = new ExtendedAttribute[size];
|
||||
for (int i = 0; i < size; i++) {
|
||||
displaySnapshot[i] = new ExtendedAttribute();
|
||||
}
|
||||
}
|
||||
ExtendedAttribute[] snapshotCells = new ExtendedAttribute[size];
|
||||
for (int i = 0; i < size; i++) {
|
||||
displaySnapshot[i].copyFrom(buffer[i]);
|
||||
ExtendedAttribute ea = new ExtendedAttribute();
|
||||
ea.copyFrom(buffer[i]);
|
||||
snapshotCells[i] = ea;
|
||||
}
|
||||
this.displayRows = rows;
|
||||
this.displayCols = cols;
|
||||
this.displayCursorAddress = cursorAddress;
|
||||
this.snapshotVersion++;
|
||||
this.displaySnapshot = snapshotCells;
|
||||
this.currentSnapshot = new DisplaySnapshot(snapshotCells, rows, cols, cursorAddress, snapshotVersion);
|
||||
}
|
||||
|
||||
public synchronized ExtendedAttribute getDisplayCell(int addr) {
|
||||
if (displaySnapshot == null || addr < 0 || addr >= displayRows * displayCols) {
|
||||
public DisplaySnapshot getDisplaySnapshot() {
|
||||
return currentSnapshot;
|
||||
}
|
||||
|
||||
public long getSnapshotVersion() {
|
||||
DisplaySnapshot snap = this.currentSnapshot;
|
||||
return snap != null ? snap.version : snapshotVersion;
|
||||
}
|
||||
|
||||
public ExtendedAttribute getDisplayCell(int addr) {
|
||||
DisplaySnapshot snap = this.currentSnapshot;
|
||||
if (snap == null || addr < 0 || addr >= snap.cells.length) {
|
||||
return getCell(addr);
|
||||
}
|
||||
return displaySnapshot[addr];
|
||||
return snap.cells[addr];
|
||||
}
|
||||
|
||||
public synchronized int getDisplayRows() {
|
||||
return displayRows > 0 ? displayRows : rows;
|
||||
public int getDisplayRows() {
|
||||
DisplaySnapshot snap = this.currentSnapshot;
|
||||
return (snap != null && snap.rows > 0) ? snap.rows : (displayRows > 0 ? displayRows : rows);
|
||||
}
|
||||
|
||||
public synchronized int getDisplayCols() {
|
||||
return displayCols > 0 ? displayCols : cols;
|
||||
public int getDisplayCols() {
|
||||
DisplaySnapshot snap = this.currentSnapshot;
|
||||
return (snap != null && snap.cols > 0) ? snap.cols : (displayCols > 0 ? displayCols : cols);
|
||||
}
|
||||
|
||||
public synchronized int getDisplayCursorAddress() {
|
||||
return displayRows > 0 ? displayCursorAddress : cursorAddress;
|
||||
public int getDisplayCursorAddress() {
|
||||
DisplaySnapshot snap = this.currentSnapshot;
|
||||
return (snap != null && snap.rows > 0) ? snap.cursorAddress : (displayRows > 0 ? displayCursorAddress : cursorAddress);
|
||||
}
|
||||
|
||||
// ========== Dimension accessors ==========
|
||||
@@ -346,6 +397,10 @@ public class ScreenBuffer {
|
||||
oldAddr = this.cursorAddress;
|
||||
this.cursorAddress = addr;
|
||||
this.displayCursorAddress = addr;
|
||||
DisplaySnapshot snap = this.currentSnapshot;
|
||||
if (snap != null) {
|
||||
this.currentSnapshot = new DisplaySnapshot(snap.cells, snap.rows, snap.cols, addr, snap.version);
|
||||
}
|
||||
}
|
||||
if (oldAddr != addr) {
|
||||
notifyCursorMoved(oldAddr, addr);
|
||||
|
||||
@@ -599,7 +599,7 @@ public class TelnetConnection {
|
||||
* Check if the socket is connected.
|
||||
*/
|
||||
public boolean isConnected() {
|
||||
return socket != null && socket.isConnected() && !socket.isClosed();
|
||||
return running && socket != null && socket.isConnected() && !socket.isClosed();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -654,7 +654,13 @@ public class TelnetConnection {
|
||||
fsm.onError("Network loop error: " + t.getMessage());
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
stopKeepAlive();
|
||||
try {
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ public class TelnetFSM {
|
||||
// 3270 input buffer (accumulated between telnet framing)
|
||||
private final haus.nightmare.lib3270j.datastream.FastByteBuffer ibuf = new haus.nightmare.lib3270j.datastream.FastByteBuffer(32768);
|
||||
|
||||
// Reusable single-byte scratch buffer for plain NVT data dispatch (Phase 27.5 / T1-C)
|
||||
private final byte[] nvtSingleByteScratch = new byte[1];
|
||||
|
||||
// Sub-negotiation buffer
|
||||
private final ByteArrayOutputStream sbbuf = new ByteArrayOutputStream(4096);
|
||||
|
||||
@@ -324,7 +327,8 @@ public class TelnetFSM {
|
||||
boolean isPlainNvt = (connectionState == ConnectionState.CONNECTED_NVT || connectionState == ConnectionState.CONNECTED_NVT_CHAR)
|
||||
&& !(hisOpts[TELOPT_BINARY] && hisOpts[TELOPT_EOR]);
|
||||
if (isPlainNvt) {
|
||||
nvtProcessor.processNVTData(new byte[] { (byte) c }, 0, 1);
|
||||
nvtSingleByteScratch[0] = (byte) c;
|
||||
nvtProcessor.processNVTData(nvtSingleByteScratch, 0, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package haus.nightmare.lib3270j;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Verification test suite for Phase 30:
|
||||
* Library Distribution, Maven Artifacts & Release Tagging (v1.2.4)
|
||||
*/
|
||||
public class Phase30LibraryDistributionTest {
|
||||
|
||||
private File locateProjectRoot() {
|
||||
File current = new File(".").getAbsoluteFile();
|
||||
while (current != null) {
|
||||
if (new File(current, "settings.gradle").exists() || new File(current, "build.gradle").exists()) {
|
||||
if (new File(current, "lib3270j").exists() && new File(current, "j3270").exists()) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
current = current.getParentFile();
|
||||
}
|
||||
return new File(".").getAbsoluteFile();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 30.1: lib3270j/build.gradle configures maven-publish, POM metadata, and artifacts")
|
||||
public void testMavenPublishConfigurationInBuildGradle() throws Exception {
|
||||
File projectRoot = locateProjectRoot();
|
||||
File libGradleFile = new File(projectRoot, "lib3270j/build.gradle");
|
||||
assertTrue(libGradleFile.exists(), "lib3270j/build.gradle must exist");
|
||||
|
||||
String content = Files.readString(libGradleFile.toPath());
|
||||
|
||||
// Plugin validation
|
||||
assertTrue(content.contains("id 'maven-publish'"), "maven-publish plugin must be applied");
|
||||
assertTrue(content.contains("withSourcesJar()"), "withSourcesJar() must be enabled");
|
||||
assertTrue(content.contains("withJavadocJar()"), "withJavadocJar() must be enabled");
|
||||
|
||||
// Publication coordinates
|
||||
assertTrue(content.contains("groupId = 'haus.nightmare.j3270'"), "groupId must be haus.nightmare.j3270");
|
||||
assertTrue(content.contains("artifactId = 'lib3270j'"), "artifactId must be lib3270j");
|
||||
|
||||
// POM metadata
|
||||
assertTrue(content.contains("name = 'lib3270j'"), "POM name must be lib3270j");
|
||||
assertTrue(content.contains("description = 'Pure Java IBM 3270 terminal emulation and protocol library with Host On-Demand (HOD) parity'"),
|
||||
"POM description must describe HOD parity");
|
||||
assertTrue(content.contains("The Unlicense"), "POM must declare The Unlicense");
|
||||
assertFalse(content.contains("Apache License"), "POM must not declare Apache License");
|
||||
assertTrue(content.contains("scm:git:git://github.com/rudi/j3270.git"), "POM must declare SCM connection");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 30.2: Standalone build scripts package lib3270j.jar with correct manifest metadata")
|
||||
public void testBuildScriptsStandaloneLibraryPackaging() throws Exception {
|
||||
File projectRoot = locateProjectRoot();
|
||||
|
||||
|
||||
// 2. setup.sh
|
||||
File setupSh = new File(projectRoot, "setup.sh");
|
||||
assertTrue(setupSh.exists(), "setup.sh must exist");
|
||||
String setupShContent = Files.readString(setupSh.toPath());
|
||||
assertTrue(setupShContent.contains("lib3270j.jar"), "setup.sh must package lib3270j.jar");
|
||||
assertTrue(setupShContent.contains("Implementation-Title: lib3270j"), "setup.sh must set Implementation-Title: lib3270j");
|
||||
assertTrue(setupShContent.contains("Implementation-Version: 1.2.4"), "setup.sh must set Implementation-Version: 1.2.4");
|
||||
|
||||
// 3. setup.ps1
|
||||
File setupPs1 = new File(projectRoot, "setup.ps1");
|
||||
assertTrue(setupPs1.exists(), "setup.ps1 must exist");
|
||||
String setupPs1Content = Files.readString(setupPs1.toPath());
|
||||
assertTrue(setupPs1Content.contains("lib3270j.jar"), "setup.ps1 must package lib3270j.jar");
|
||||
assertTrue(setupPs1Content.contains("Implementation-Title: lib3270j"), "setup.ps1 must set Implementation-Title: lib3270j");
|
||||
assertTrue(setupPs1Content.contains("Implementation-Version: 1.2.4"), "setup.ps1 must set Implementation-Version: 1.2.4");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 30.2: Core subsystem packages and classes are present and loadable")
|
||||
public void testCoreSubsystemsPresent() {
|
||||
// Telnet & Protocol
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.telnet.TelnetFSM"));
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.Telnet3270Client"));
|
||||
|
||||
// Screen Buffer & Concurrency
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.screen.ScreenBuffer"));
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.screen.ScreenBuffer$DisplaySnapshot"));
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.screen.ExtendedAttribute"));
|
||||
|
||||
// Charset & EBCDIC
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.charset.EbcdicTranslator"));
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.charset.CodePage"));
|
||||
|
||||
// GOCA & Graphics
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.graphics.GocaDecoder"));
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.graphics.GraphicsMode"));
|
||||
|
||||
// ECL (standard and HOD compatibility namespaces)
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.ecl.ECLSession"));
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.eNetwork.ECL.ECLSession"));
|
||||
assertDoesNotThrow(() -> Class.forName("com.ibm.eNetwork.ECL.ECLSession"));
|
||||
assertDoesNotThrow(() -> Class.forName("com.ibm.eNetwork.ECL.ECLPS"));
|
||||
assertDoesNotThrow(() -> Class.forName("com.ibm.eNetwork.beans.HOD.Macro"));
|
||||
|
||||
// FastByteBuffer de-synchronized accumulation
|
||||
assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.datastream.FastByteBuffer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 30.2: Maven local published artifacts exist and validate if maven-publish was executed")
|
||||
public void testMavenLocalPublishedArtifacts() throws Exception {
|
||||
String userHome = System.getProperty("user.home");
|
||||
File m2ArtifactDir = new File(userHome, ".m2/repository/haus/nightmare/j3270/lib3270j/1.2.4");
|
||||
if (m2ArtifactDir.exists()) {
|
||||
File pomFile = new File(m2ArtifactDir, "lib3270j-1.2.4.pom");
|
||||
File jarFile = new File(m2ArtifactDir, "lib3270j-1.2.4.jar");
|
||||
File sourcesFile = new File(m2ArtifactDir, "lib3270j-1.2.4-sources.jar");
|
||||
File javadocFile = new File(m2ArtifactDir, "lib3270j-1.2.4-javadoc.jar");
|
||||
|
||||
assertTrue(pomFile.exists() && pomFile.length() > 0, "POM file must exist and be non-empty");
|
||||
assertTrue(jarFile.exists() && jarFile.length() > 0, "JAR file must exist and be non-empty");
|
||||
assertTrue(sourcesFile.exists() && sourcesFile.length() > 0, "Sources JAR must exist and be non-empty");
|
||||
assertTrue(javadocFile.exists() && javadocFile.length() > 0, "Javadoc JAR must exist and be non-empty");
|
||||
|
||||
String pomXml = Files.readString(pomFile.toPath());
|
||||
assertTrue(pomXml.contains("<groupId>haus.nightmare.j3270</groupId>"), "POM must contain groupId");
|
||||
assertTrue(pomXml.contains("<artifactId>lib3270j</artifactId>"), "POM must contain artifactId");
|
||||
assertTrue(pomXml.contains("<version>1.2.4</version>"), "POM must contain version 1.2.4");
|
||||
assertTrue(pomXml.contains("The Unlicense"), "POM must contain Unlicense");
|
||||
assertFalse(pomXml.contains("Apache"), "lib3270j POM must not contain Apache license");
|
||||
assertFalse(pomXml.contains("<dependencies>"), "lib3270j POM must have zero external dependencies");
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package haus.nightmare.lib3270j.screen;
|
||||
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
|
||||
import haus.nightmare.lib3270j.datastream.FastByteBuffer;
|
||||
import haus.nightmare.lib3270j.TerminalModel;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.concurrent.*;
|
||||
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.*;
|
||||
|
||||
/**
|
||||
* Verification test suite for Phase 29:
|
||||
* Lock-Free Concurrency Architecture & Buffer De-Synchronization
|
||||
*/
|
||||
public class Phase29ConcurrencyStressTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 29.1: FastByteBuffer methods have synchronized modifier removed for thread-confined stream accumulation")
|
||||
public void testFastByteBufferMethodsAreUnsynchronized() throws Exception {
|
||||
Class<?> clazz = FastByteBuffer.class;
|
||||
String[] methodNames = {
|
||||
"write", "buffer", "size", "reset", "asByteBuffer", "slice", "toByteArray"
|
||||
};
|
||||
|
||||
for (Method method : clazz.getDeclaredMethods()) {
|
||||
for (String name : methodNames) {
|
||||
if (method.getName().equals(name)) {
|
||||
int mod = method.getModifiers();
|
||||
assertFalse(Modifier.isSynchronized(mod),
|
||||
"FastByteBuffer method '" + method.getName() + "' must NOT have synchronized modifier");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Functional stream test
|
||||
FastByteBuffer fbb = new FastByteBuffer(128);
|
||||
for (int i = 0; i < 5000; i++) {
|
||||
fbb.write(i & 0xFF);
|
||||
}
|
||||
assertEquals(5000, fbb.size());
|
||||
assertEquals((byte) 0, fbb.buffer()[0]);
|
||||
assertEquals((byte) (4999 & 0xFF), fbb.buffer()[4999]);
|
||||
|
||||
ByteBuffer bb = fbb.asByteBuffer();
|
||||
assertEquals(5000, bb.remaining());
|
||||
ByteBuffer sl = fbb.slice(10, 20);
|
||||
assertEquals(20, sl.remaining());
|
||||
|
||||
fbb.reset();
|
||||
assertEquals(0, fbb.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 29.2: 10 worker threads reading getDisplayCell continuously while network thread writes 5,000 updates")
|
||||
public void testConcurrentDisplayCellReadsDuringHighFrequencyUpdates() throws Exception {
|
||||
ScreenBuffer sb = new ScreenBuffer(TerminalModel.IBM_3279_2, new EbcdicTranslator());
|
||||
int size = sb.getRows() * sb.getCols();
|
||||
|
||||
int workerThreadCount = 10;
|
||||
int totalUpdates = 5000;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(workerThreadCount + 1);
|
||||
AtomicBoolean running = new AtomicBoolean(true);
|
||||
AtomicInteger tornCellCount = new AtomicInteger(0);
|
||||
AtomicInteger exceptionCount = new AtomicInteger(0);
|
||||
AtomicLong totalReads = new AtomicLong(0);
|
||||
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch writersDoneLatch = new CountDownLatch(1);
|
||||
|
||||
// Spin 10 worker threads reading getDisplayCell() continuously
|
||||
for (int w = 0; w < workerThreadCount; w++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
while (running.get()) {
|
||||
int rows = sb.getDisplayRows();
|
||||
int cols = sb.getDisplayCols();
|
||||
int curAddr = sb.getDisplayCursorAddress();
|
||||
long version = sb.getSnapshotVersion();
|
||||
|
||||
assertTrue(rows > 0, "Display rows must be positive");
|
||||
assertTrue(cols > 0, "Display cols must be positive");
|
||||
assertTrue(curAddr >= 0, "Display cursor must be non-negative");
|
||||
|
||||
int cellCount = rows * cols;
|
||||
for (int addr = 0; addr < cellCount; addr++) {
|
||||
ExtendedAttribute cell = sb.getDisplayCell(addr);
|
||||
if (cell == null) {
|
||||
exceptionCount.incrementAndGet();
|
||||
continue;
|
||||
}
|
||||
totalReads.incrementAndGet();
|
||||
|
||||
// Validate torn cell state:
|
||||
// In our patterned writer, every cell written in iteration N has:
|
||||
// ucs4 = (char) ('A' + (N % 26))
|
||||
// ec = (byte) (0x10 + (N % 26))
|
||||
// fg = (byte) (0x30 + (N % 26))
|
||||
// bg = (byte) (0x50 + (N % 26))
|
||||
// A torn read occurs if ucs4, ec, fg, or bg don't match the same iteration!
|
||||
if (cell.ucs4 >= 'A' && cell.ucs4 <= 'Z') {
|
||||
int ucsOffset = cell.ucs4 - 'A';
|
||||
int ecOffset = (cell.ec & 0xFF) - 0x10;
|
||||
int fgOffset = (cell.fg & 0xFF) - 0x30;
|
||||
int bgOffset = (cell.bg & 0xFF) - 0x50;
|
||||
|
||||
if (ucsOffset != ecOffset || ucsOffset != fgOffset || ucsOffset != bgOffset) {
|
||||
tornCellCount.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
exceptionCount.incrementAndGet();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Writer task simulating network thread writing 5,000 screen updates
|
||||
Future<?> writerFuture = executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
for (int iter = 0; iter < totalUpdates; iter++) {
|
||||
int mod = iter % 26;
|
||||
char ch = (char) ('A' + mod);
|
||||
byte ec = (byte) (0x10 + mod);
|
||||
byte fg = (byte) (0x30 + mod);
|
||||
byte bg = (byte) (0x50 + mod);
|
||||
|
||||
// Mutate screen buffer
|
||||
ExtendedAttribute[] rawBuf = sb.getBuffer();
|
||||
for (int i = 0; i < size; i++) {
|
||||
ExtendedAttribute cell = rawBuf[i];
|
||||
cell.ucs4 = ch;
|
||||
cell.ec = ec;
|
||||
cell.fg = fg;
|
||||
cell.bg = bg;
|
||||
}
|
||||
sb.setCursorAddress(iter % size);
|
||||
sb.updateDisplaySnapshot();
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
exceptionCount.incrementAndGet();
|
||||
} finally {
|
||||
writersDoneLatch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger start
|
||||
startLatch.countDown();
|
||||
|
||||
// Await completion of 5,000 updates with timeout to verify 0 deadlocks
|
||||
boolean writerFinished = writersDoneLatch.await(15, TimeUnit.SECONDS);
|
||||
assertTrue(writerFinished, "Writer must complete 5,000 updates without deadlock");
|
||||
|
||||
// Stop workers
|
||||
running.set(false);
|
||||
executor.shutdown();
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS), "Worker threads must terminate cleanly");
|
||||
|
||||
// Assert zero errors, zero torn reads, and high-volume lock-free reads verified
|
||||
assertEquals(0, exceptionCount.get(), "Zero exceptions (NPE/AIOOBE) must occur during concurrent execution");
|
||||
assertEquals(0, tornCellCount.get(), "Zero torn cell states must occur during atomic DisplaySnapshot swapping");
|
||||
assertTrue(totalReads.get() > 100_000, "Worker threads must execute high-throughput lock-free reads (actual: " + totalReads.get() + ")");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 29.2: Screen dimension resizing under concurrent reader load produces 0 exceptions")
|
||||
public void testConcurrentReadsDuringScreenResizing() throws Exception {
|
||||
ScreenBuffer sb = new ScreenBuffer(24, 80, 27, 132, new EbcdicTranslator());
|
||||
int workerCount = 8;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(workerCount + 1);
|
||||
AtomicBoolean running = new AtomicBoolean(true);
|
||||
AtomicInteger exceptionCount = new AtomicInteger(0);
|
||||
AtomicLong successfulReads = new AtomicLong(0);
|
||||
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch writerLatch = new CountDownLatch(1);
|
||||
|
||||
for (int w = 0; w < workerCount; w++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
while (running.get()) {
|
||||
ScreenBuffer.DisplaySnapshot snap = sb.getDisplaySnapshot();
|
||||
if (snap == null) {
|
||||
exceptionCount.incrementAndGet();
|
||||
continue;
|
||||
}
|
||||
|
||||
int rows = sb.getDisplayRows();
|
||||
int cols = sb.getDisplayCols();
|
||||
int total = rows * cols;
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
ExtendedAttribute cell = sb.getDisplayCell(i);
|
||||
if (cell != null) {
|
||||
successfulReads.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
exceptionCount.incrementAndGet();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Writer alternates dimensions between 24x80 (1920) and 27x132 (3564)
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
for (int iter = 0; iter < 1000; iter++) {
|
||||
sb.erase((iter % 2) != 0);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
exceptionCount.incrementAndGet();
|
||||
} finally {
|
||||
writerLatch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
startLatch.countDown();
|
||||
assertTrue(writerLatch.await(10, TimeUnit.SECONDS), "Dimension resizing writer must complete without deadlock");
|
||||
|
||||
running.set(false);
|
||||
executor.shutdown();
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
|
||||
|
||||
assertEquals(0, exceptionCount.get(), "Screen resizing under reader load must not throw ArrayIndexOutOfBoundsException or NPE");
|
||||
assertTrue(successfulReads.get() > 50_000, "Concurrent readers must successfully read cells across dimension changes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 29.2: setCursorAddress propagates cursor changes atomically to DisplaySnapshot")
|
||||
public void testCursorUpdatePropagatesToDisplaySnapshot() {
|
||||
ScreenBuffer sb = new ScreenBuffer(TerminalModel.IBM_3279_2, new EbcdicTranslator());
|
||||
sb.erase(false);
|
||||
|
||||
assertEquals(0, sb.getDisplayCursorAddress());
|
||||
|
||||
sb.setCursorAddress(125);
|
||||
assertEquals(125, sb.getDisplayCursorAddress());
|
||||
assertNotNull(sb.getDisplaySnapshot());
|
||||
assertEquals(125, sb.getDisplaySnapshot().cursorAddress);
|
||||
|
||||
sb.setCursorPosition(5, 10);
|
||||
int expected = 5 * sb.getCols() + 10;
|
||||
assertEquals(expected, sb.getDisplayCursorAddress());
|
||||
assertEquals(expected, sb.getDisplaySnapshot().cursorAddress);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package haus.nightmare.lib3270j.telnet;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.parallel.ResourceAccessMode;
|
||||
import org.junit.jupiter.api.parallel.ResourceLock;
|
||||
import org.junit.jupiter.api.parallel.Resources;
|
||||
import haus.nightmare.lib3270j.ConnectionConfig;
|
||||
import haus.nightmare.lib3270j.TerminalModel;
|
||||
|
||||
@@ -102,7 +105,7 @@ public class ProxyConnectionTest {
|
||||
connection.connect();
|
||||
assertTrue(connection.isConnected());
|
||||
|
||||
String req = receivedRequest.get(3, TimeUnit.SECONDS);
|
||||
String req = receivedRequest.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(req.startsWith("CONNECT target.mainframe.org:23 HTTP/1.1"), "Expected CONNECT request");
|
||||
assertTrue(req.contains("Proxy-Authorization: Basic "), "Expected Basic auth in proxy request");
|
||||
|
||||
@@ -156,7 +159,7 @@ public class ProxyConnectionTest {
|
||||
|
||||
connection.connect();
|
||||
assertTrue(connection.isConnected());
|
||||
assertTrue(handshakeDone.await(3, TimeUnit.SECONDS));
|
||||
assertTrue(handshakeDone.await(5, TimeUnit.SECONDS));
|
||||
|
||||
connection.disconnect();
|
||||
}
|
||||
@@ -236,13 +239,14 @@ public class ProxyConnectionTest {
|
||||
|
||||
connection.connect();
|
||||
assertTrue(connection.isConnected());
|
||||
assertTrue(handshakeDone.await(3, TimeUnit.SECONDS));
|
||||
assertTrue(handshakeDone.await(5, TimeUnit.SECONDS));
|
||||
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ_WRITE)
|
||||
public void testSystemPropertiesProxyFallback() throws Exception {
|
||||
try (ServerSocket proxyServer = new ServerSocket(0)) {
|
||||
int proxyPort = proxyServer.getLocalPort();
|
||||
@@ -259,6 +263,9 @@ public class ProxyConnectionTest {
|
||||
out.write("HTTP/1.1 200 OK\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
|
||||
out.flush();
|
||||
serverHandshakeDone.countDown();
|
||||
|
||||
// Keep socket open and drain data until client disconnects
|
||||
while (clientSock.getInputStream().read() != -1) {}
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
serverThread.setDaemon(true);
|
||||
@@ -266,7 +273,11 @@ public class ProxyConnectionTest {
|
||||
|
||||
String origHost = System.getProperty("http.proxyHost");
|
||||
String origPort = System.getProperty("http.proxyPort");
|
||||
String origSocksHost = System.getProperty("socksProxyHost");
|
||||
String origSocksPort = System.getProperty("socksProxyPort");
|
||||
try {
|
||||
System.clearProperty("socksProxyHost");
|
||||
System.clearProperty("socksProxyPort");
|
||||
System.setProperty("http.proxyHost", "127.0.0.1");
|
||||
System.setProperty("http.proxyPort", String.valueOf(proxyPort));
|
||||
|
||||
@@ -278,13 +289,17 @@ public class ProxyConnectionTest {
|
||||
|
||||
connection.connect();
|
||||
assertTrue(connection.isConnected());
|
||||
assertTrue(serverHandshakeDone.await(3, TimeUnit.SECONDS));
|
||||
assertTrue(serverHandshakeDone.await(5, TimeUnit.SECONDS));
|
||||
connection.disconnect();
|
||||
} finally {
|
||||
if (origHost != null) System.setProperty("http.proxyHost", origHost);
|
||||
else System.clearProperty("http.proxyHost");
|
||||
if (origPort != null) System.setProperty("http.proxyPort", origPort);
|
||||
else System.clearProperty("http.proxyPort");
|
||||
if (origSocksHost != null) System.setProperty("socksProxyHost", origSocksHost);
|
||||
else System.clearProperty("socksProxyHost");
|
||||
if (origSocksPort != null) System.setProperty("socksProxyPort", origSocksPort);
|
||||
else System.clearProperty("socksProxyPort");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -335,4 +335,24 @@ public class Mock3270ServerIntegrationTest {
|
||||
}
|
||||
assertTrue(receivedWontOrDontTn3270e, "Server should have received Telnet de-negotiation for TN3270E");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testServerSideDisconnectTransitionsClientToNotConnectedAndClosesSocket() 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);
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// Server drops connection (host disconnect)
|
||||
server.disconnectClient();
|
||||
|
||||
// Wait for client to detect disconnect
|
||||
waitForState(client, ConnectionState.NOT_CONNECTED, 5000);
|
||||
assertEquals(ConnectionState.NOT_CONNECTED, client.getConnectionState());
|
||||
assertFalse(client.isConnected(), "Client must report isConnected() == false after host disconnect");
|
||||
assertNotNull(client.getConnection());
|
||||
assertFalse(client.getConnection().isConnected(), "TelnetConnection must report isConnected() == false after host disconnect");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Launch j3270 - Java TN3270 Terminal Emulator
|
||||
# Usage: ./run.sh [host] [port] [model]
|
||||
# Example: ./run.sh mainframe.example.com 23 4
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
|
||||
# Build if jar is missing or rebuild requested
|
||||
if [ ! -f "$BUILD_DIR/j3270.jar" ]; then
|
||||
"$SCRIPT_DIR/build_all.sh"
|
||||
fi
|
||||
|
||||
exec java -Dapple.laf.useScreenMenuBar=true \
|
||||
-Dapple.awt.application.name=j3270 \
|
||||
-jar "$BUILD_DIR/j3270.jar" "$@"
|
||||
@@ -1,31 +0,0 @@
|
||||
<#
|
||||
.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
|
||||
}
|
||||
@@ -413,11 +413,19 @@ if (Test-Path $libResDir) {
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 7. Package Standalone Executable JAR
|
||||
# 7. Package Standalone JARs
|
||||
# ------------------------------------------------------------------------------
|
||||
Write-LogInfo "Packaging standalone lib3270j library JAR..."
|
||||
$libManifestFile = Join-Path $BuildDir "LIB_MANIFEST.MF"
|
||||
$libManifestText = "Manifest-Version: 1.0`r`nImplementation-Title: lib3270j`r`nImplementation-Version: 1.2.4`r`nCreated-By: j3270 setup.ps1`r`n`r`n"
|
||||
[System.IO.File]::WriteAllText($libManifestFile, $libManifestText)
|
||||
|
||||
$libJarFile = Join-Path $BuildDir "lib3270j.jar"
|
||||
& $detectedJdk.Jar cvfm $libJarFile $libManifestFile -C $LibBuildDir . | Out-Null
|
||||
|
||||
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"
|
||||
$manifestText = "Manifest-Version: 1.0`r`nMain-Class: haus.nightmare.j3270.J3270App`r`nImplementation-Title: j3270`r`nImplementation-Version: 1.2.4`r`nCreated-By: j3270 setup.ps1`r`n`r`n"
|
||||
[System.IO.File]::WriteAllText($manifestFile, $manifestText)
|
||||
|
||||
$jarFile = Join-Path $BuildDir "j3270.jar"
|
||||
@@ -428,10 +436,13 @@ if (-not (Test-Path $jarFile)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$libJarItem = Get-Item $libJarFile
|
||||
$libJarSizeKB = [math]::Round($libJarItem.Length / 1KB, 1)
|
||||
$jarItem = Get-Item $jarFile
|
||||
$jarSizeKB = [math]::Round($jarItem.Length / 1KB, 1)
|
||||
|
||||
Write-Host "`n=== Build Successful ===`n" -ForegroundColor Green
|
||||
Write-LogSuccess "Library JAR created: $libJarFile (${libJarSizeKB} KB)"
|
||||
Write-LogSuccess "Executable JAR created: $jarFile (${jarSizeKB} KB)"
|
||||
|
||||
Write-Host @"
|
||||
|
||||
@@ -369,15 +369,28 @@ if [ -d "$SCRIPT_DIR/lib3270j/src/main/resources" ]; then
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 5. Package Standalone Executable JAR
|
||||
# 5. Package Standalone JARs
|
||||
# ------------------------------------------------------------------------------
|
||||
log_info "Packaging standalone lib3270j library JAR..."
|
||||
|
||||
cat << 'EOF' > "$BUILD_DIR/LIB_MANIFEST.MF"
|
||||
Manifest-Version: 1.0
|
||||
Implementation-Title: lib3270j
|
||||
Implementation-Version: 1.2.4
|
||||
Created-By: j3270 setup.sh
|
||||
|
||||
EOF
|
||||
|
||||
"$JAR_BIN" cvfm "$BUILD_DIR/lib3270j.jar" "$BUILD_DIR/LIB_MANIFEST.MF" \
|
||||
-C "$BUILD_DIR/lib3270j" . > /dev/null
|
||||
|
||||
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
|
||||
Implementation-Version: 1.2.4
|
||||
Created-By: j3270 setup.sh
|
||||
|
||||
EOF
|
||||
@@ -391,14 +404,16 @@ if [ ! -f "$BUILD_DIR/j3270.jar" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
JAR_SIZE=$(ls -lh "$BUILD_DIR/j3270.jar" | awk '{print $5}')
|
||||
LIB_JAR_SIZE=$(du -h "$BUILD_DIR/lib3270j.jar" | awk '{print $1}')
|
||||
JAR_SIZE=$(du -h "$BUILD_DIR/j3270.jar" | awk '{print $1}')
|
||||
printf "\n%b=== Build Successful ===%b\n" "$BOLD" "$RESET"
|
||||
log_success "Library JAR: $BUILD_DIR/lib3270j.jar ($LIB_JAR_SIZE)"
|
||||
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"
|
||||
printf " java -jar \"%s/build/j3270.jar\"\n" "$SCRIPT_DIR"
|
||||
printf " # Or launch via setup script:\n"
|
||||
printf " ./setup.sh --run\n\n"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 6. Run Tests (if requested)
|
||||
@@ -407,8 +422,6 @@ 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
|
||||
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ cd "$SCRIPT_DIR"
|
||||
echo "=== Running j3270 Test Suite ==="
|
||||
|
||||
# 1. First build the core libraries
|
||||
sh "$SCRIPT_DIR/build_all.sh"
|
||||
sh "$SCRIPT_DIR/setup.sh"
|
||||
|
||||
# 2. Locate Java toolchain
|
||||
JAVAC_BIN=""
|
||||
@@ -83,7 +83,7 @@ if [ -f "$JUNIT_JAR" ] && [ -s "$JUNIT_JAR" ]; then
|
||||
rm -f "$BUILD_DIR/test_sources.txt"
|
||||
|
||||
echo "Executing JUnit tests..."
|
||||
"$JAVA_BIN" -Djava.awt.headless=true -jar "$JUNIT_JAR" \
|
||||
"$JAVA_BIN" -Djava.awt.headless=true -jar "$JUNIT_JAR" execute \
|
||||
--class-path "$BUILD_DIR/lib3270j:$BUILD_DIR/j3270:$TEST_BUILD_DIR" \
|
||||
--scan-class-path \
|
||||
--reports-dir="$REPORTS_DIR"
|
||||
|
||||
Reference in New Issue
Block a user