Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8608babb36
|
|||
|
1f98c0d70f
|
|||
|
3843e0fd13
|
|||
|
4754732642
|
|||
|
79ce0a7dc6
|
|||
|
fe185bd1f0
|
|||
|
ee44759668
|
|||
|
69ebe6bfb5
|
|||
|
63d14bd2d9
|
|||
|
377626013d
|
|||
|
bf5eefd190
|
|||
|
c941fd3a2a
|
|||
|
b71f0f5ba4
|
|||
|
b81a244bb9
|
|||
|
351379b5b3
|
|||
|
eba1406cbb
|
|||
|
88e3ffcbe9
|
|||
|
f389e626ea
|
|||
|
0eaab32e2f
|
|||
|
da289730db
|
|||
|
f77f51474c
|
|||
|
e82d01003b
|
|||
|
31b85f491c
|
|||
|
9f96555c9d
|
|||
|
a7cc4b9d50
|
|||
|
600b626e94
|
|||
|
18c4203c0d
|
|||
|
f6d69bf0ff
|
|||
|
aa008664bc
|
|||
|
8344b9f4f7
|
|||
|
052a6619ae
|
|||
|
2a22a636b6
|
|||
|
d3c149fe4a
|
|||
|
8c8e1f17db
|
|||
|
17f8cae925
|
|||
|
9bf91f5c49
|
|||
|
cf58b5c72c
|
|||
|
96ee5ebce1
|
|||
|
3a7811543d
|
|||
|
c767953c69
|
|||
|
60315098e2
|
|||
|
4b112d47f4
|
@@ -36,11 +36,19 @@ jobs:
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Make scripts executable
|
||||
run: |
|
||||
chmod +x ./gradlew ./build.sh
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
./gradlew testDebugUnitTest --no-daemon
|
||||
env:
|
||||
J3270_DIR: ${{ github.workspace }}/j3270
|
||||
|
||||
- name: Build Android APK
|
||||
run: |
|
||||
./gradlew assembleDebug --no-daemon
|
||||
|
||||
@@ -34,6 +34,8 @@ jobs:
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Make scripts executable
|
||||
run: |
|
||||
|
||||
@@ -9,3 +9,5 @@ captures/
|
||||
.cxx
|
||||
*.jar
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
*.txt
|
||||
*.log*
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
@@ -1,9 +1,8 @@
|
||||
# a3270
|
||||
|
||||
[](https://git.hugfreevikings.wtf/rudi/a3270/actions)
|
||||
[](https://git.hugfreevikings.wtf/rudi/a3270/releases)
|
||||
[](https://developer.android.com)
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
|
||||
An **x3270-aligned IBM 3270 mainframe terminal emulator for Android**, written in Kotlin using **Jetpack Compose** and powered by the `lib3270j` protocol engine from [j3270](https://git.hugfreevikings.wtf/rudi/j3270). Designed for mobile access to IBM z/OS, z/VM, CMS, and TSO systems over TN3270 and TN3270E.
|
||||
|
||||
@@ -35,20 +34,70 @@ adb install -r a3270.apk
|
||||
|
||||
---
|
||||
|
||||
## 📱 Compatibility & System Requirements
|
||||
|
||||
- **Minimum Android Version**: Android 7.0 (Nougat) — **API Level 24** (`minSdk 24`)
|
||||
- **Target Android Version**: Android 14 — **API Level 34** (`targetSdk 34`)
|
||||
- **Compile SDK**: **API Level 34** (`compileSdk 34`)
|
||||
- **Architecture**: Universal — pure Kotlin/Java running on ART, fully compatible with all CPU ABIs (`arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`)
|
||||
- **Form Factors**: Adaptive UI scaling optimized for smartphones, foldables, tablets, and Chromebooks (Android runtime)
|
||||
- **Input Devices**: Full touch-screen support, native virtual keyboards, and external physical/Bluetooth keyboards
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Modern Jetpack Compose UI**: Responsive, touch-optimized 3270 terminal screen rendering.
|
||||
- **TN3270 & TN3270E Protocol Support**: RFC 2355 compliant state machine, negotiation, Device-Type query, and SSL/TLS encryption via `lib3270j`.
|
||||
- **On-Screen Function Key Bar**: Quick-access touch bar for PF1–PF24, PA1–PA3, Enter, Clear, Reset, and Attn keys.
|
||||
- **IND$FILE File Transfer**: Full mobile UI for **CUT** and **DFT (DDM)** high-speed structured field file transfers with ASCII/binary translation, CRLF handling, and recfm/lrecl formatting for TSO, VM/CMS, and CICS.
|
||||
- **OIA Status Line**: Real-time Operator Information Area displaying connection state, keyboard lock, insert mode, cursor row/col, and LU name.
|
||||
- **Host Profile Storage**: Save and manage multiple mainframe connection profiles with custom ports, models, and SSL/TLS options.
|
||||
- **Background Session Persistence**: Android foreground service keeps your terminal session alive when multitasking.
|
||||
- **Terminal Models**:
|
||||
- Model 2 (24x80)
|
||||
- Model 3 (32x80)
|
||||
- Model 4 (43x80)
|
||||
- Model 5 (27x132)
|
||||
### 🖥️ Terminal Emulation & Protocol
|
||||
- **RFC 2355 TN3270 & TN3270E Engine**: Robust state machine, Option Negotiation, Device-Type Query, and LU name specification powered by `lib3270j`.
|
||||
- **Standard & Dynamic Terminal Models**:
|
||||
- Model 2 (24 × 80)
|
||||
- Model 3 (32 × 80)
|
||||
- Model 4 (43 × 80)
|
||||
- Model 5 (27 × 132)
|
||||
- **Dynamic Screen Dimensions**: Configurable custom geometry (rows & columns up to 255 × 255).
|
||||
- **EBCDIC Code Pages**: Full character translation across international code pages (037, 1047, 500, etc.).
|
||||
|
||||
### 📊 Graphics & Peripherals
|
||||
- **GDDM & GOCA Graphics**: Native rendering of Programmed Symbols (PS) and GOCA vector graphics directly in the Compose canvas.
|
||||
- **Light Pen Simulation**: Interactive touch support to simulate light pen selection and graphic cursor pick correlation.
|
||||
- **IBM 3287 Printer Emulation**: Integrated printer session support for spool/report capture (via associated display LU or dedicated printer LU) with direct printing through Android PrintManager (PDF export / system print).
|
||||
|
||||
### 📁 IND$FILE File Transfer
|
||||
- Full mobile UI supporting both **CUT** and **DFT (DDM)** high-speed structured field transfers.
|
||||
- Compatible with **TSO**, **VM/CMS**, and **CICS** environments.
|
||||
- ASCII/binary translation, CRLF conversions, and configurable record format (`recfm`), record length (`lrecl`), and allocation units.
|
||||
|
||||
### 🔍 Developer & Productivity Tools
|
||||
- **Field Inspector**: Live interactive inspector displaying start/end addresses, length, protection, numeric status, MDT (Modified Data Tag), display intensity, and extended highlighting/color, with one-tap "Jump to Field".
|
||||
- **Screen Search / Find**: On-screen text search with case sensitivity, forward/backward navigation, and visual match highlighting.
|
||||
- **Screen Exporter**: Export the current screen buffer to **Plain Text**, formatted **HTML**, or **PNG image**, with one-tap clipboard copy and Android system share sheet integration.
|
||||
- **Script & Macro Automation**: Built-in script runner supporting automated keystroke and wait sequences (e.g. `[enter]`, `[wait]`, `[tab]`) for login macros and repetitive tasks.
|
||||
|
||||
### ✍️ Entry Assist & Typing Modes
|
||||
- **Document (DOC) Mode**: Configurable left/right margins, custom tab stops, and end-of-line bell warning.
|
||||
- **Word Wrap**: Automatic word wrapping across lines during input.
|
||||
- **APL Mode**: Support for APL extended character sets and symbols.
|
||||
- **Field Handling**: Automatic field skip (Auto-Skip), Numeric field lock, and Insert Mode auto-reset on AID transmission.
|
||||
|
||||
### 🎨 Display, Themes & Ergonomics
|
||||
- **Jetpack Compose UI**: Smooth, hardware-accelerated 3270 terminal screen rendering with touch-to-position cursor.
|
||||
- **Color Themes**: Light and Dark application themes, 3270 extended color support, and 4-color legacy override mode.
|
||||
- **Cursor Customization**: Block and Underline cursor styles with toggleable blinking.
|
||||
- **Crosshair Ruler**: Toggleable horizontal and vertical ruler lines following the cursor for easy data tracking.
|
||||
- **Block Selection**: Rectangular text selection and copy mode.
|
||||
- **OIA Status Line**: Real-time Operator Information Area displaying connection status, keyboard lock, insert mode, cursor row/col, LU name, and inhibit reason codes.
|
||||
|
||||
### 🔒 Security, Networking & Storage
|
||||
- **SSL / TLS Encryption**: Secure encrypted mainframe sessions with TLS certificate verification.
|
||||
- **Interactive Untrusted Certificate Prompt**: Inspect SSL certificates, view full certificate chain details, and accept/reject unverified connections.
|
||||
- **Proxy Support**: Connect via HTTP CONNECT, SOCKS4, SOCKS5, and Telnet proxies with optional user/password authentication.
|
||||
- **Background Session Persistence**: Android foreground service (`specialUse`) keeps your terminal TCP connection alive with status notifications when multitasking or screen is off.
|
||||
- **Host Profile Manager & INI Export**: Save multiple mainframe profiles, configure auto-connect on launch, and export/import configuration in INI format (interoperable with desktop `j3270`).
|
||||
|
||||
### ⌨️ Input & Keyboards
|
||||
- **On-Screen Function Key Bar**: Two-row responsive touch bar providing PF1–PF24, PA1–PA3, Enter, Clear, Reset, Attn, SysReq, Erase EOF, Erase Input, Tab, BackTab, and D-pad cursor controls with haptic feedback.
|
||||
- **Termux-Style Native Input**: Low-latency native input connection with permanent number row and lock protection against soft-keyboard resets.
|
||||
- **Hardware Keyboard Support**: Complete physical keyboard integration with F1–F12 (mapped to PF1–PF12 / PF13–PF24 with Shift/Alt), Ctrl shortcuts, dead keys, and international character input.
|
||||
|
||||
---
|
||||
|
||||
@@ -57,6 +106,7 @@ adb install -r a3270.apk
|
||||
### Prerequisites
|
||||
- **Java Development Kit (JDK)**: JDK 17 or JDK 21
|
||||
- **Android SDK**: API 34 platforms and build-tools (`ANDROID_HOME` or `ANDROID_SDK_ROOT` set)
|
||||
- **Minimum Target Device**: Android 7.0+ (API 24+)
|
||||
|
||||
### 1. Clone Repositories Side-by-Side
|
||||
For the easiest setup, clone both `j3270` and `a3270` into the same parent folder:
|
||||
@@ -94,43 +144,6 @@ build/outputs/apk/debug/a3270-debug.apk
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ How to Tag & Publish (Beta) Releases
|
||||
|
||||
Releases can be published automatically to Gitea via Git tags:
|
||||
|
||||
```bash
|
||||
# 1. Create a version tag (stable or beta)
|
||||
git tag -a v0.1.0-beta.1 -m "Release v0.1.0-beta.1"
|
||||
|
||||
# Or a stable release:
|
||||
git tag -a v0.1.0 -m "Release v0.1.0"
|
||||
|
||||
# 2. Push the tag to Gitea
|
||||
git push origin v0.1.0-beta.1
|
||||
```
|
||||
|
||||
Pushing any `v*` tag triggers the automated release workflow (`.gitea/workflows/release.yaml`), which:
|
||||
1. Automatically detects pre-releases (tags containing `beta`, `alpha`, `rc`, `pre`, `preview`, or `dev`) and marks them as **Pre-release** on Gitea.
|
||||
2. Builds the APK and packages it.
|
||||
3. Publishes a new Gitea Release with `a3270.apk` attached automatically.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Topics & Discoverability
|
||||
|
||||
Suggested keywords/topics for repository indexing:
|
||||
`tn3270` · `tn3270e` · `ibm-3270` · `mainframe` · `terminal-emulator` · `android` · `android-app` · `jetpack-compose` · `kotlin` · `x3270` · `j3270` · `ind-file` · `file-transfer` · `ebcdic` · `z-vm` · `z-os` · `cms` · `tso` · `cics`
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
|
||||
|
||||
I have contributed no code to this project, it was entirely written by LLMs with my guidance only.
|
||||
|
||||
---
|
||||
|
||||
## 📜 Acknowledgements
|
||||
|
||||
- [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine
|
||||
@@ -139,3 +152,6 @@ I have contributed no code to this project, it was entirely written by LLMs with
|
||||
- Claude Opus 4.6
|
||||
- Gemini 3.1 Pro
|
||||
- Gemini 3.7 Flash
|
||||
- Gemma4 12B and 26B
|
||||
- GPT-OSS 120B
|
||||
- Qwen3 4B and 32B
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="124 738 320 320"
|
||||
width="320"
|
||||
height="320"
|
||||
xml:space="preserve"
|
||||
id="svg_a3270_icon"
|
||||
version="1.1">
|
||||
<metadata id="metadata8">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<!-- Pure black background (#000000) for classic 3270 CRT terminal look -->
|
||||
<rect x="124" y="738" width="320" height="320" fill="#000000" />
|
||||
<g transform="matrix(1.3333333,0,0,-1.3333333,0,1122.6667)" id="g10">
|
||||
<g transform="scale(0.1)" id="g12">
|
||||
<g id="g14">
|
||||
<!-- Green Terminal Screen Frame -->
|
||||
<path
|
||||
id="path28"
|
||||
style="fill:none;stroke:#00ff00;stroke-width:3.75079012;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
|
||||
<!-- Blue Wave / Exponential Curve -->
|
||||
<path
|
||||
id="path30"
|
||||
style="fill:none;stroke:#0000ff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
|
||||
<!-- Red Circular Arc -->
|
||||
<path
|
||||
id="path32"
|
||||
style="fill:none;stroke:#ff0000;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
|
||||
<!-- Yellow Smiley Head -->
|
||||
<path
|
||||
id="path34"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
|
||||
<!-- 'l' -->
|
||||
<path
|
||||
id="path36"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
|
||||
<path
|
||||
id="path38"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
|
||||
<!-- 'i' -->
|
||||
<path
|
||||
id="path40"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
|
||||
<path
|
||||
id="path42"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
|
||||
<!-- 'b' -->
|
||||
<path
|
||||
id="path44"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
|
||||
<path
|
||||
id="path46"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
|
||||
<!-- '3' -->
|
||||
<path
|
||||
id="path48"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
|
||||
<path
|
||||
id="path50"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
|
||||
<!-- '2' -->
|
||||
<path
|
||||
id="path52"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
|
||||
<path
|
||||
id="path54"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
|
||||
<!-- '7' -->
|
||||
<path
|
||||
id="path56"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
|
||||
<path
|
||||
id="path58"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
|
||||
<!-- '0' -->
|
||||
<path
|
||||
id="path60"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
|
||||
<path
|
||||
id="path62"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
|
||||
<!-- 'j' -->
|
||||
<path
|
||||
id="path64"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
|
||||
<path
|
||||
id="path66"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
|
||||
<!-- Smile -->
|
||||
<path
|
||||
id="path68"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
|
||||
<!-- Left Eye (cyan) -->
|
||||
<path
|
||||
id="path70"
|
||||
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
|
||||
<!-- Right Eye (cyan) -->
|
||||
<path
|
||||
id="path72"
|
||||
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
|
||||
<!-- Nose -->
|
||||
<path
|
||||
id="path74"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2892.03,1528.23 -29.21,-20.68" />
|
||||
<path
|
||||
id="path76"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2862.82,1507.55 h 32.86" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,146 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="144 814 280 169"
|
||||
width="280"
|
||||
height="169"
|
||||
xml:space="preserve"
|
||||
id="svg_lib3270j_cropped"
|
||||
version="1.1">
|
||||
<metadata id="metadata8">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<!-- Pure black background (#000000) for classic 3270 CRT terminal look -->
|
||||
<rect x="144" y="814" width="280" height="169" fill="#000000" />
|
||||
<g transform="matrix(1.3333333,0,0,-1.3333333,0,1122.6667)" id="g10">
|
||||
<g transform="scale(0.1)" id="g12">
|
||||
<g id="g14">
|
||||
<!-- Green Terminal Screen Frame -->
|
||||
<path
|
||||
id="path28"
|
||||
style="fill:none;stroke:#00ff00;stroke-width:3.75079012;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
|
||||
<!-- Blue Wave / Exponential Curve -->
|
||||
<path
|
||||
id="path30"
|
||||
style="fill:none;stroke:#0000ff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
|
||||
<!-- Red Circular Arc -->
|
||||
<path
|
||||
id="path32"
|
||||
style="fill:none;stroke:#ff0000;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
|
||||
<!-- Yellow Smiley Head -->
|
||||
<path
|
||||
id="path34"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
|
||||
<!-- 'l' -->
|
||||
<path
|
||||
id="path36"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
|
||||
<path
|
||||
id="path38"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
|
||||
<!-- 'i' -->
|
||||
<path
|
||||
id="path40"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
|
||||
<path
|
||||
id="path42"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
|
||||
<!-- 'b' -->
|
||||
<path
|
||||
id="path44"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
|
||||
<path
|
||||
id="path46"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
|
||||
<!-- '3' -->
|
||||
<path
|
||||
id="path48"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
|
||||
<path
|
||||
id="path50"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
|
||||
<!-- '2' -->
|
||||
<path
|
||||
id="path52"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
|
||||
<path
|
||||
id="path54"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
|
||||
<!-- '7' -->
|
||||
<path
|
||||
id="path56"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
|
||||
<path
|
||||
id="path58"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
|
||||
<!-- '0' -->
|
||||
<path
|
||||
id="path60"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
|
||||
<path
|
||||
id="path62"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
|
||||
<!-- 'j' -->
|
||||
<path
|
||||
id="path64"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
|
||||
<path
|
||||
id="path66"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
|
||||
<!-- Smile -->
|
||||
<path
|
||||
id="path68"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
|
||||
<!-- Left Eye (cyan) -->
|
||||
<path
|
||||
id="path70"
|
||||
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
|
||||
<!-- Right Eye (cyan) -->
|
||||
<path
|
||||
id="path72"
|
||||
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
|
||||
<!-- Nose -->
|
||||
<path
|
||||
id="path74"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2892.03,1528.23 -29.21,-20.68" />
|
||||
<path
|
||||
id="path76"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2862.82,1507.55 h 32.86" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
+15
-4
@@ -4,15 +4,15 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'org.pubvm.a3270'
|
||||
namespace 'haus.nightmare.a3270'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.pubvm.a3270"
|
||||
applicationId "haus.nightmare.a3270"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
versionCode 9
|
||||
versionName "1.2.4"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -42,6 +42,13 @@ android {
|
||||
if (findProject(':lib3270j') != null) {
|
||||
project(':lib3270j') {
|
||||
apply plugin: 'java-library'
|
||||
dependencies {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
@@ -62,4 +69,8 @@ dependencies {
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20231013'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ cd "$SCRIPT_DIR"
|
||||
|
||||
echo "=== Building a3270 (Android APK) ==="
|
||||
|
||||
# Set up JAVA_HOME if not already set
|
||||
if [ -z "$JAVA_HOME" ]; then
|
||||
# Set up JAVA_HOME (prefer JDK 21 if available)
|
||||
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
|
||||
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
|
||||
elif [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
|
||||
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
|
||||
elif [ -d "$HOME/.sdkman/candidates/java/21.0.2-open" ]; then
|
||||
export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.2-open"
|
||||
elif [ -z "$JAVA_HOME" ]; then
|
||||
if [ -d "$HOME/.sdkman/candidates/java/current" ]; then
|
||||
export JAVA_HOME="$HOME/.sdkman/candidates/java/current"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -19,6 +21,8 @@ fi
|
||||
if [ -z "$ANDROID_HOME" ]; then
|
||||
if [ -d "$HOME/Library/Android/sdk" ]; then
|
||||
export ANDROID_HOME="$HOME/Library/Android/sdk"
|
||||
elif [ -d "$HOME/Android/Sdk" ]; then
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -41,6 +45,10 @@ if [ ! -d "$J3270_PATH/lib3270j" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
echo "Running: ./gradlew $@"
|
||||
./gradlew "$@"
|
||||
else
|
||||
echo "Building debug APK..."
|
||||
./gradlew assembleDebug --no-daemon
|
||||
|
||||
@@ -52,3 +60,4 @@ if [ -f "$APK_PATH" ]; then
|
||||
else
|
||||
echo "Build completed, but APK not found at $APK_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
+3
-1
@@ -1,2 +1,4 @@
|
||||
android.useAndroidX=true
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
|
||||
kotlin.compiler.execution.strategy=in-process
|
||||
|
||||
|
||||
@@ -115,6 +115,26 @@ esac
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Prefer compatible JDK (JDK 17 or 21) if active Java is incompatible (Java 22+)
|
||||
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
|
||||
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
|
||||
elif [ -d "$HOME/.sdkman/candidates/java/21.0.2-open" ]; then
|
||||
CURRENT_JAVA_CMD="${JAVA_HOME:+$JAVA_HOME/bin/java}"
|
||||
CURRENT_JAVA_CMD="${CURRENT_JAVA_CMD:-java}"
|
||||
CURRENT_VER=$("$CURRENT_JAVA_CMD" -version 2>&1 | awk -F '"' '/version/ {print $2}' | cut -d'.' -f1)
|
||||
if [ -z "$JAVA_HOME" ] || [ "$CURRENT_VER" -gt 21 ] 2>/dev/null; then
|
||||
export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.2-open"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$ANDROID_HOME" ]; then
|
||||
if [ -d "$HOME/Library/Android/sdk" ]; then
|
||||
export ANDROID_HOME="$HOME/Library/Android/sdk"
|
||||
elif [ -d "$HOME/Android/Sdk" ]; then
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:versionCode="9"
|
||||
android:versionName="1.2.4">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
@@ -9,6 +11,8 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="a3270"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
|
||||
@@ -33,6 +37,16 @@
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="3270 Terminal TCP socket connection persistence" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="124 738 320 320"
|
||||
width="320"
|
||||
height="320"
|
||||
xml:space="preserve"
|
||||
id="svg_a3270_icon"
|
||||
version="1.1">
|
||||
<metadata id="metadata8">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<!-- Pure black background (#000000) for classic 3270 CRT terminal look -->
|
||||
<rect x="124" y="738" width="320" height="320" fill="#000000" />
|
||||
<g transform="matrix(1.3333333,0,0,-1.3333333,0,1122.6667)" id="g10">
|
||||
<g transform="scale(0.1)" id="g12">
|
||||
<g id="g14">
|
||||
<!-- Green Terminal Screen Frame -->
|
||||
<path
|
||||
id="path28"
|
||||
style="fill:none;stroke:#00ff00;stroke-width:3.75079012;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
|
||||
<!-- Blue Wave / Exponential Curve -->
|
||||
<path
|
||||
id="path30"
|
||||
style="fill:none;stroke:#0000ff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
|
||||
<!-- Red Circular Arc -->
|
||||
<path
|
||||
id="path32"
|
||||
style="fill:none;stroke:#ff0000;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
|
||||
<!-- Yellow Smiley Head -->
|
||||
<path
|
||||
id="path34"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
|
||||
<!-- 'l' -->
|
||||
<path
|
||||
id="path36"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
|
||||
<path
|
||||
id="path38"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
|
||||
<!-- 'i' -->
|
||||
<path
|
||||
id="path40"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
|
||||
<path
|
||||
id="path42"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
|
||||
<!-- 'b' -->
|
||||
<path
|
||||
id="path44"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
|
||||
<path
|
||||
id="path46"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
|
||||
<!-- '3' -->
|
||||
<path
|
||||
id="path48"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
|
||||
<path
|
||||
id="path50"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
|
||||
<!-- '2' -->
|
||||
<path
|
||||
id="path52"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
|
||||
<path
|
||||
id="path54"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
|
||||
<!-- '7' -->
|
||||
<path
|
||||
id="path56"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
|
||||
<path
|
||||
id="path58"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
|
||||
<!-- '0' -->
|
||||
<path
|
||||
id="path60"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
|
||||
<path
|
||||
id="path62"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
|
||||
<!-- 'j' -->
|
||||
<path
|
||||
id="path64"
|
||||
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
|
||||
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
|
||||
<path
|
||||
id="path66"
|
||||
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
|
||||
<!-- Smile -->
|
||||
<path
|
||||
id="path68"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
|
||||
<!-- Left Eye (cyan) -->
|
||||
<path
|
||||
id="path70"
|
||||
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
|
||||
<!-- Right Eye (cyan) -->
|
||||
<path
|
||||
id="path72"
|
||||
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
|
||||
<!-- Nose -->
|
||||
<path
|
||||
id="path74"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2892.03,1528.23 -29.21,-20.68" />
|
||||
<path
|
||||
id="path76"
|
||||
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 2862.82,1507.55 h 32.86" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
package haus.nightmare.a3270.ft
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import haus.nightmare.lib3270j.Telnet3270Client
|
||||
import haus.nightmare.lib3270j.ft.FTConfig
|
||||
import haus.nightmare.lib3270j.ft.FTConstants.FTState
|
||||
import haus.nightmare.lib3270j.ft.FTCut
|
||||
import haus.nightmare.lib3270j.ft.FTDft
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Android coordinator for IND$FILE file transfers.
|
||||
* Manages the transfer state machine, timeouts, and coordinates between
|
||||
* UI, Telnet3270Client, and lower-level CUT/DFT protocol handlers.
|
||||
*/
|
||||
class FileTransfer(
|
||||
private val client: Telnet3270Client,
|
||||
private val callback: FileTransferCallback
|
||||
) : FTCut.FTCutListener, FTDft.FTDftListener {
|
||||
|
||||
private val log = Logger.getLogger(FileTransfer::class.java.name)
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
enum class FTMode {
|
||||
UNKNOWN, CUT, DFT
|
||||
}
|
||||
|
||||
interface FileTransferCallback {
|
||||
fun onTransferStarted()
|
||||
fun onTransferRunning()
|
||||
fun onBytesTransferred(bytes: Long)
|
||||
fun onTransferComplete(message: String)
|
||||
fun onTransferAborted(error: String)
|
||||
}
|
||||
|
||||
private var currentConfig: FTConfig? = null
|
||||
private var localFile: File? = null
|
||||
private var state = FTState.NONE
|
||||
private var activeMode = FTMode.UNKNOWN
|
||||
|
||||
private var cutHandler: FTCut? = null
|
||||
private var dftHandler: FTDft? = null
|
||||
|
||||
private var timeoutTimer: Timer? = null
|
||||
private val startTimeoutMs = 30000L // 30 seconds
|
||||
|
||||
fun startTransfer(config: FTConfig): String? {
|
||||
if (state != FTState.NONE) {
|
||||
return "A transfer is already in progress."
|
||||
}
|
||||
|
||||
val validationError = config.validate()
|
||||
if (validationError != null) {
|
||||
return validationError
|
||||
}
|
||||
|
||||
this.currentConfig = config
|
||||
this.localFile = File(config.localFilename)
|
||||
this.activeMode = FTMode.UNKNOWN
|
||||
|
||||
// Check overwrite
|
||||
if (config.isReceive && !config.isAppend && !config.isOverwrite) {
|
||||
if (localFile?.exists() == true) {
|
||||
return "Local file already exists and overwrite is not permitted."
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize protocol handlers lazily
|
||||
if (cutHandler == null) {
|
||||
cutHandler = FTCut(
|
||||
client.screenBuffer,
|
||||
client.inputProcessor,
|
||||
client.translator,
|
||||
this
|
||||
)
|
||||
}
|
||||
if (dftHandler == null) {
|
||||
dftHandler = FTDft(
|
||||
client.inputProcessor,
|
||||
client.translator,
|
||||
this
|
||||
)
|
||||
client.dataStreamProcessor.setFTDft(dftHandler)
|
||||
}
|
||||
|
||||
// Build and type the IND$FILE command
|
||||
val command = config.buildCommand()
|
||||
log.info("Starting IND\$FILE transfer with command: $command")
|
||||
|
||||
// Erase field and verify it can hold the command
|
||||
val capacity = client.inputProcessor.kybdPrime()
|
||||
if (capacity < 0) {
|
||||
cleanupHandlers(false)
|
||||
return when (capacity) {
|
||||
-1 -> "Keyboard is locked."
|
||||
-3 -> "No unprotected input field found on screen."
|
||||
else -> "Cannot start transfer from current screen state."
|
||||
}
|
||||
}
|
||||
if (capacity < command.length) {
|
||||
cleanupHandlers(false)
|
||||
return "Current input field is too small for IND\$FILE command ($capacity chars max)."
|
||||
}
|
||||
|
||||
setState(FTState.AWAIT_ACK)
|
||||
client.emulateInput(command + "\n")
|
||||
|
||||
startTimeout()
|
||||
mainHandler.post { callback.onTransferStarted() }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
if (state == FTState.RUNNING || state == FTState.AWAIT_ACK) {
|
||||
log.info("User cancelled transfer")
|
||||
setState(FTState.ABORT_WAIT)
|
||||
} else if (state != FTState.NONE) {
|
||||
log.info("Forcing cancel from state $state")
|
||||
completeTransfer("Transfer cancelled.")
|
||||
mainHandler.post { callback.onTransferAborted("Cancelled by user.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
log.info("Force resetting FileTransfer state from $state")
|
||||
completeTransfer("Reset")
|
||||
}
|
||||
|
||||
fun isTransferActive(): Boolean = state != FTState.NONE
|
||||
|
||||
fun getActiveMode(): FTMode = activeMode
|
||||
|
||||
fun onScreenUpdated() {
|
||||
if ((activeMode == FTMode.CUT || activeMode == FTMode.UNKNOWN) &&
|
||||
(state == FTState.AWAIT_ACK || state == FTState.RUNNING || state == FTState.ABORT_WAIT)) {
|
||||
cutHandler?.processScreenUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTimeout() {
|
||||
cancelTimeout()
|
||||
timeoutTimer = Timer("FTTimeout", true).apply {
|
||||
schedule(object : TimerTask() {
|
||||
override fun run() {
|
||||
mainHandler.post {
|
||||
if (state == FTState.AWAIT_ACK) {
|
||||
log.warning("Transfer start timeout")
|
||||
completeTransfer("Transfer failed to start within 30 seconds.")
|
||||
callback.onTransferAborted("Transfer start timeout.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}, startTimeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelTimeout() {
|
||||
timeoutTimer?.cancel()
|
||||
timeoutTimer = null
|
||||
}
|
||||
|
||||
private fun cleanupHandlers(success: Boolean) {
|
||||
cutHandler?.cleanup()
|
||||
dftHandler?.cleanup()
|
||||
|
||||
if (!success && currentConfig != null && currentConfig?.isReceive == true && !currentConfig!!.isAppend) {
|
||||
if (state != FTState.NONE && state != FTState.AWAIT_ACK && localFile != null && localFile!!.exists()) {
|
||||
log.info("Cleaning up incomplete download: ${localFile!!.absolutePath}")
|
||||
localFile!!.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeTransfer(errorMessage: String?) {
|
||||
cancelTimeout()
|
||||
val success = (errorMessage == null)
|
||||
cleanupHandlers(success)
|
||||
setState(FTState.NONE)
|
||||
activeMode = FTMode.UNKNOWN
|
||||
currentConfig = null
|
||||
}
|
||||
|
||||
override fun onCutRunning() {
|
||||
handleTransferRunning(FTMode.CUT)
|
||||
}
|
||||
|
||||
override fun onDftRunning() {
|
||||
handleTransferRunning(FTMode.DFT)
|
||||
}
|
||||
|
||||
private fun handleTransferRunning(mode: FTMode) {
|
||||
if (activeMode == FTMode.UNKNOWN) {
|
||||
activeMode = mode
|
||||
log.info("FT mode established: $activeMode")
|
||||
try {
|
||||
if (activeMode == FTMode.DFT) {
|
||||
dftHandler?.initTransfer(localFile)
|
||||
} else {
|
||||
cutHandler?.initTransfer(localFile)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
log.warning("Failed to open local file for $activeMode: ${e.message}")
|
||||
onTransferAborted("Failed to open local file: ${e.message}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cancelTimeout()
|
||||
setState(FTState.RUNNING)
|
||||
mainHandler.post { callback.onTransferRunning() }
|
||||
}
|
||||
|
||||
override fun onTransferComplete(errorMessage: String?) {
|
||||
completeTransfer(errorMessage)
|
||||
mainHandler.post {
|
||||
if (errorMessage == null) {
|
||||
callback.onTransferComplete("File transfer complete.")
|
||||
} else {
|
||||
callback.onTransferAborted(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTransferAborted(errorMessage: String?) {
|
||||
completeTransfer(errorMessage)
|
||||
mainHandler.post { callback.onTransferAborted(errorMessage ?: "Transfer aborted") }
|
||||
}
|
||||
|
||||
override fun onBytesTransferred(bytes: Long) {
|
||||
mainHandler.post { callback.onBytesTransferred(bytes) }
|
||||
}
|
||||
|
||||
override fun getCurrentState(): FTState = state
|
||||
|
||||
override fun setState(state: FTState) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
override fun getConfig(): FTConfig? = currentConfig
|
||||
|
||||
override fun getLocalFile(): File? = localFile
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package haus.nightmare.a3270.graphics
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import haus.nightmare.lib3270j.graphics.PixelBuffer
|
||||
|
||||
/**
|
||||
* Android bridge for translating lib3270j PixelBuffer into Android Bitmaps and rendering onto Canvas.
|
||||
*/
|
||||
object AndroidCanvasRenderer {
|
||||
|
||||
/**
|
||||
* Converts a PixelBuffer into an Android Bitmap using ARGB_8888 configuration.
|
||||
*/
|
||||
fun toBitmap(buffer: PixelBuffer?): Bitmap? {
|
||||
if (buffer == null) return null
|
||||
val w = buffer.width
|
||||
val h = buffer.height
|
||||
if (w <= 0 || h <= 0) return null
|
||||
val pixels = buffer.pixels ?: return null
|
||||
return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the given PixelBuffer onto an Android Canvas scaled to the destination rectangle.
|
||||
*/
|
||||
fun drawPixelBuffer(
|
||||
canvas: Canvas,
|
||||
buffer: PixelBuffer?,
|
||||
dstRect: RectF,
|
||||
paint: Paint? = null
|
||||
) {
|
||||
val bmp = toBitmap(buffer) ?: return
|
||||
canvas.drawBitmap(bmp, null, dstRect, paint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the PixelBuffer contents into an existing mutable Bitmap.
|
||||
*/
|
||||
fun copyIntoBitmap(buffer: PixelBuffer, target: Bitmap) {
|
||||
val pixels = buffer.pixels ?: return
|
||||
val w = minOf(buffer.width, target.width)
|
||||
val h = minOf(buffer.height, target.height)
|
||||
if (w <= 0 || h <= 0) return
|
||||
target.setPixels(pixels, 0, buffer.width, 0, 0, w, h)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package org.pubvm.a3270.service
|
||||
package haus.nightmare.a3270.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@@ -10,8 +10,8 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import org.pubvm.a3270.MainActivity
|
||||
import org.pubvm.a3270.R
|
||||
import haus.nightmare.a3270.MainActivity
|
||||
import haus.nightmare.a3270.R
|
||||
|
||||
class TerminalService : Service() {
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
package haus.nightmare.a3270.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.Reader
|
||||
import java.io.Writer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object AppSettings {
|
||||
private const val PREFS_NAME = "a3270_settings"
|
||||
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
|
||||
private const val KEY_CURSOR_BLINK = "cursor_blink"
|
||||
private const val KEY_HAPTIC_FEEDBACK = "haptic_feedback"
|
||||
|
||||
private const val KEY_VERIFY_CERTS = "verify_certs"
|
||||
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
|
||||
private const val KEY_DEFAULT_CODE_PAGE = "default_code_page"
|
||||
private const val KEY_DEFAULT_HOST_TYPE = "default_host_type"
|
||||
|
||||
// New settings aligned with j3270
|
||||
private const val KEY_UI_THEME = "ui_theme"
|
||||
private const val KEY_CROSSHAIR_RULER = "crosshair_ruler"
|
||||
private const val KEY_CURSOR_STYLE = "cursor_style"
|
||||
private const val KEY_BLOCK_SELECT_MODE = "block_select_mode"
|
||||
private const val KEY_BLOCK_PASTE = "block_paste"
|
||||
private const val KEY_STARTUP_BEHAVIOR = "startup_behavior"
|
||||
private const val KEY_DYNAMIC_ROWS = "dynamic_rows"
|
||||
private const val KEY_DYNAMIC_COLS = "dynamic_cols"
|
||||
|
||||
// Entry Assist & Modes aligned with j3270
|
||||
private const val KEY_ENTRY_ASSIST_DOC_MODE = "entry_assist_doc_mode"
|
||||
private const val KEY_ENTRY_ASSIST_WORD_WRAP = "entry_assist_word_wrap"
|
||||
private const val KEY_ENTRY_ASSIST_START_COL = "entry_assist_start_col"
|
||||
private const val KEY_ENTRY_ASSIST_END_COL = "entry_assist_end_col"
|
||||
private const val KEY_ENTRY_ASSIST_BELL = "entry_assist_bell"
|
||||
private const val KEY_ENTRY_ASSIST_BELL_COL = "entry_assist_bell_col"
|
||||
private const val KEY_ENTRY_ASSIST_TAB_STOPS = "entry_assist_tab_stops"
|
||||
private const val KEY_INSERT_OFF_ON_AID = "insert_off_on_aid"
|
||||
private const val KEY_FOUR_COLOR_OVERRIDE = "four_color_override"
|
||||
private const val KEY_NUMERIC_FIELD_LOCK = "numeric_field_lock"
|
||||
private const val KEY_AUTO_SKIP_ENABLED = "auto_skip_enabled"
|
||||
|
||||
// Hotspots aligned with j3270 v1.2.2
|
||||
private const val KEY_HOTSPOTS_ENABLED = "hotspots_enabled"
|
||||
private const val KEY_HOTSPOTS_URL_ENABLED = "hotspots_url_enabled"
|
||||
private const val KEY_HOTSPOTS_PF_ENABLED = "hotspots_pf_enabled"
|
||||
private const val KEY_HOTSPOTS_MENU_CHOICES_ENABLED = "hotspots_menu_choices_enabled"
|
||||
|
||||
// Selection & Trim aligned with j3270 v1.2.2
|
||||
private const val KEY_TRIM_RECT_SIZING_HANDLES = "trim_rect_sizing_handles"
|
||||
private const val KEY_TRIM_RECT_REMAIN_AFTER_EDIT = "trim_rect_remain_after_edit"
|
||||
private const val KEY_PASTE_WORD_BREAK = "paste_word_break"
|
||||
|
||||
enum class StartupBehavior {
|
||||
SHOW_CONNECT,
|
||||
DO_NOTHING,
|
||||
AUTO_CONNECT
|
||||
}
|
||||
|
||||
private fun getPrefs(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun isMaskHiddenInputEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_MASK_HIDDEN_INPUT, true)
|
||||
}
|
||||
|
||||
fun setMaskHiddenInputEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_MASK_HIDDEN_INPUT, enabled).apply()
|
||||
}
|
||||
|
||||
fun isCursorBlinkEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_CURSOR_BLINK, true)
|
||||
}
|
||||
|
||||
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply()
|
||||
}
|
||||
|
||||
fun isHapticFeedbackEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_HAPTIC_FEEDBACK, true)
|
||||
}
|
||||
|
||||
fun setHapticFeedbackEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_HAPTIC_FEEDBACK, enabled).apply()
|
||||
}
|
||||
|
||||
fun isVerifyCertsEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_VERIFY_CERTS, true)
|
||||
}
|
||||
|
||||
fun setVerifyCertsEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_VERIFY_CERTS, enabled).apply()
|
||||
}
|
||||
|
||||
fun getDefaultGraphicsMode(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_DEFAULT_GRAPHICS_MODE, "BOTH") ?: "BOTH"
|
||||
}
|
||||
|
||||
fun setDefaultGraphicsMode(context: Context, mode: String) {
|
||||
getPrefs(context).edit().putString(KEY_DEFAULT_GRAPHICS_MODE, mode).apply()
|
||||
}
|
||||
|
||||
fun getDefaultCodePage(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_DEFAULT_CODE_PAGE, "037") ?: "037"
|
||||
}
|
||||
|
||||
fun setDefaultCodePage(context: Context, codePage: String) {
|
||||
getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply()
|
||||
}
|
||||
|
||||
fun getDefaultHostType(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_DEFAULT_HOST_TYPE, "TSO") ?: "TSO"
|
||||
}
|
||||
|
||||
fun setDefaultHostType(context: Context, hostType: String) {
|
||||
val normalized = if (hostType.isNotBlank()) hostType.trim().uppercase(Locale.US) else "TSO"
|
||||
getPrefs(context).edit().putString(KEY_DEFAULT_HOST_TYPE, normalized).apply()
|
||||
}
|
||||
|
||||
// ========== UI Theme ==========
|
||||
fun getUiTheme(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_UI_THEME, "DARK") ?: "DARK"
|
||||
}
|
||||
|
||||
fun setUiTheme(context: Context, theme: String) {
|
||||
getPrefs(context).edit().putString(KEY_UI_THEME, if (theme.equals("LIGHT", ignoreCase = true)) "LIGHT" else "DARK").apply()
|
||||
}
|
||||
|
||||
// ========== Crosshair Ruler ==========
|
||||
fun isCrosshairRulerEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_CROSSHAIR_RULER, false)
|
||||
}
|
||||
|
||||
fun setCrosshairRulerEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_CROSSHAIR_RULER, enabled).apply()
|
||||
}
|
||||
|
||||
// ========== Cursor Style ==========
|
||||
fun getCursorStyle(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_CURSOR_STYLE, "BLOCK") ?: "BLOCK"
|
||||
}
|
||||
|
||||
fun setCursorStyle(context: Context, style: String) {
|
||||
val s = if (style.equals("UNDERLINE", ignoreCase = true)) "UNDERLINE" else "BLOCK"
|
||||
getPrefs(context).edit().putString(KEY_CURSOR_STYLE, s).apply()
|
||||
}
|
||||
|
||||
// ========== Block Select Mode ==========
|
||||
fun isBlockSelectModeEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_BLOCK_SELECT_MODE, false)
|
||||
}
|
||||
|
||||
fun setBlockSelectModeEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_BLOCK_SELECT_MODE, enabled).apply()
|
||||
}
|
||||
|
||||
// ========== Block Paste Mode ==========
|
||||
fun isBlockPasteEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_BLOCK_PASTE, true)
|
||||
}
|
||||
|
||||
fun setBlockPasteEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_BLOCK_PASTE, enabled).apply()
|
||||
}
|
||||
|
||||
// ========== Startup Behavior ==========
|
||||
fun getStartupBehavior(context: Context): StartupBehavior {
|
||||
val s = getPrefs(context).getString(KEY_STARTUP_BEHAVIOR, StartupBehavior.SHOW_CONNECT.name)
|
||||
return try {
|
||||
StartupBehavior.valueOf(s ?: StartupBehavior.SHOW_CONNECT.name)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
StartupBehavior.SHOW_CONNECT
|
||||
}
|
||||
}
|
||||
|
||||
fun setStartupBehavior(context: Context, behavior: StartupBehavior) {
|
||||
getPrefs(context).edit().putString(KEY_STARTUP_BEHAVIOR, behavior.name).apply()
|
||||
}
|
||||
|
||||
// ========== Dynamic Screen Dimensions ==========
|
||||
fun getDynamicRows(context: Context): Int {
|
||||
return getPrefs(context).getInt(KEY_DYNAMIC_ROWS, 62).coerceIn(24, 255)
|
||||
}
|
||||
|
||||
fun setDynamicRows(context: Context, rows: Int) {
|
||||
getPrefs(context).edit().putInt(KEY_DYNAMIC_ROWS, rows.coerceIn(24, 255)).apply()
|
||||
}
|
||||
|
||||
fun getDynamicCols(context: Context): Int {
|
||||
return getPrefs(context).getInt(KEY_DYNAMIC_COLS, 160).coerceIn(80, 255)
|
||||
}
|
||||
|
||||
fun setDynamicCols(context: Context, cols: Int) {
|
||||
getPrefs(context).edit().putInt(KEY_DYNAMIC_COLS, cols.coerceIn(80, 255)).apply()
|
||||
}
|
||||
|
||||
// ========== Entry Assist (Document Mode) ==========
|
||||
fun getEntryAssistDocMode(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_ENTRY_ASSIST_DOC_MODE, false)
|
||||
}
|
||||
|
||||
fun setEntryAssistDocMode(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_ENTRY_ASSIST_DOC_MODE, enabled).apply()
|
||||
}
|
||||
|
||||
fun getEntryAssistWordWrap(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_ENTRY_ASSIST_WORD_WRAP, false)
|
||||
}
|
||||
|
||||
fun setEntryAssistWordWrap(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_ENTRY_ASSIST_WORD_WRAP, enabled).apply()
|
||||
}
|
||||
|
||||
fun getEntryAssistStartCol(context: Context): Int {
|
||||
return getPrefs(context).getInt(KEY_ENTRY_ASSIST_START_COL, 1).coerceIn(1, 80)
|
||||
}
|
||||
|
||||
fun setEntryAssistStartCol(context: Context, col: Int) {
|
||||
getPrefs(context).edit().putInt(KEY_ENTRY_ASSIST_START_COL, col.coerceIn(1, 80)).apply()
|
||||
}
|
||||
|
||||
fun getEntryAssistEndCol(context: Context): Int {
|
||||
return getPrefs(context).getInt(KEY_ENTRY_ASSIST_END_COL, 80).coerceIn(1, 80)
|
||||
}
|
||||
|
||||
fun setEntryAssistEndCol(context: Context, col: Int) {
|
||||
getPrefs(context).edit().putInt(KEY_ENTRY_ASSIST_END_COL, col.coerceIn(1, 80)).apply()
|
||||
}
|
||||
|
||||
fun getEntryAssistBell(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_ENTRY_ASSIST_BELL, false)
|
||||
}
|
||||
|
||||
fun setEntryAssistBell(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_ENTRY_ASSIST_BELL, enabled).apply()
|
||||
}
|
||||
|
||||
fun getEntryAssistBellCol(context: Context): Int {
|
||||
return getPrefs(context).getInt(KEY_ENTRY_ASSIST_BELL_COL, 75).coerceIn(1, 80)
|
||||
}
|
||||
|
||||
fun setEntryAssistBellCol(context: Context, col: Int) {
|
||||
getPrefs(context).edit().putInt(KEY_ENTRY_ASSIST_BELL_COL, col.coerceIn(1, 80)).apply()
|
||||
}
|
||||
|
||||
fun getEntryAssistTabStops(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_ENTRY_ASSIST_TAB_STOPS, "5,10,15,20,25,30,35,40,45,50,55,60,65,70,75")
|
||||
?: "5,10,15,20,25,30,35,40,45,50,55,60,65,70,75"
|
||||
}
|
||||
|
||||
fun setEntryAssistTabStops(context: Context, stops: String) {
|
||||
getPrefs(context).edit().putString(KEY_ENTRY_ASSIST_TAB_STOPS, stops).apply()
|
||||
}
|
||||
|
||||
fun getEntryAssistTabStopsArray(context: Context): IntArray {
|
||||
val s = getEntryAssistTabStops(context)
|
||||
if (s.isBlank()) return intArrayOf()
|
||||
val parts = s.split(",")
|
||||
val list = mutableListOf<Int>()
|
||||
for (p in parts) {
|
||||
val v = p.trim().toIntOrNull()
|
||||
if (v != null && v > 0) {
|
||||
list.add(v - 1)
|
||||
}
|
||||
}
|
||||
return list.toIntArray()
|
||||
}
|
||||
|
||||
// ========== Terminal Operational Modes ==========
|
||||
fun getInsertOffOnAid(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_INSERT_OFF_ON_AID, true)
|
||||
}
|
||||
|
||||
fun setInsertOffOnAid(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_INSERT_OFF_ON_AID, enabled).apply()
|
||||
}
|
||||
|
||||
fun getFourColorOverride(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_FOUR_COLOR_OVERRIDE, false)
|
||||
}
|
||||
|
||||
fun setFourColorOverride(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_FOUR_COLOR_OVERRIDE, enabled).apply()
|
||||
}
|
||||
|
||||
fun getNumericFieldLock(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_NUMERIC_FIELD_LOCK, true)
|
||||
}
|
||||
|
||||
fun setNumericFieldLock(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_NUMERIC_FIELD_LOCK, enabled).apply()
|
||||
}
|
||||
|
||||
fun getAutoSkipEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_AUTO_SKIP_ENABLED, true)
|
||||
}
|
||||
|
||||
fun setAutoSkipEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_AUTO_SKIP_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
// ========== Hotspots (Default DISABLED for touch screens) ==========
|
||||
fun isHotspotsEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_HOTSPOTS_ENABLED, false)
|
||||
}
|
||||
|
||||
fun setHotspotsEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
fun isHotspotsUrlEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_HOTSPOTS_URL_ENABLED, true)
|
||||
}
|
||||
|
||||
fun setHotspotsUrlEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_URL_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
fun isHotspotsPfEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_HOTSPOTS_PF_ENABLED, true)
|
||||
}
|
||||
|
||||
fun setHotspotsPfEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_PF_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
fun isHotspotsMenuChoicesEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_HOTSPOTS_MENU_CHOICES_ENABLED, true)
|
||||
}
|
||||
|
||||
fun setHotspotsMenuChoicesEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_MENU_CHOICES_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
// ========== Selection Handles & Trim ==========
|
||||
fun isTrimRectSizingHandlesEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_TRIM_RECT_SIZING_HANDLES, false)
|
||||
}
|
||||
|
||||
fun setTrimRectSizingHandlesEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_TRIM_RECT_SIZING_HANDLES, enabled).apply()
|
||||
}
|
||||
|
||||
fun isTrimRectRemainAfterEditEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_TRIM_RECT_REMAIN_AFTER_EDIT, false)
|
||||
}
|
||||
|
||||
fun setTrimRectRemainAfterEditEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_TRIM_RECT_REMAIN_AFTER_EDIT, enabled).apply()
|
||||
}
|
||||
|
||||
fun isPasteWordBreakEnabled(context: Context): Boolean {
|
||||
return getPrefs(context).getBoolean(KEY_PASTE_WORD_BREAK, true)
|
||||
}
|
||||
|
||||
fun setPasteWordBreakEnabled(context: Context, enabled: Boolean) {
|
||||
getPrefs(context).edit().putBoolean(KEY_PASTE_WORD_BREAK, enabled).apply()
|
||||
}
|
||||
|
||||
// ========== INI Config Export / Import ==========
|
||||
|
||||
fun exportToIni(context: Context, writer: Writer) {
|
||||
val p = PrintWriter(writer)
|
||||
val timeStamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
|
||||
p.println("; a3270 / j3270 Configuration File")
|
||||
p.println("; Exported on $timeStamp")
|
||||
p.println()
|
||||
|
||||
p.println("[appearance]")
|
||||
p.println("javaUiTheme = ${getUiTheme(context)}")
|
||||
p.println("crosshairRuler = ${isCrosshairRulerEnabled(context)}")
|
||||
p.println("cursorStyle = ${getCursorStyle(context)}")
|
||||
p.println()
|
||||
|
||||
p.println("[behavior]")
|
||||
p.println("startupBehavior = ${getStartupBehavior(context).name}")
|
||||
val autoHost = HostStorage.getAutoConnectHost(context)
|
||||
if (autoHost != null) {
|
||||
p.println("autoConnectHost = ${autoHost.host}")
|
||||
p.println("autoConnectPort = ${autoHost.port}")
|
||||
p.println("autoConnectTls = ${autoHost.useTls}")
|
||||
p.println("autoConnectVerifyCert = ${autoHost.tlsVerifyCert}")
|
||||
p.println("autoConnectTn3270e = ${autoHost.tn3270e}")
|
||||
}
|
||||
p.println("codePage = ${getDefaultCodePage(context)}")
|
||||
p.println("hostType = ${getDefaultHostType(context)}")
|
||||
p.println("dynamicRows = ${getDynamicRows(context)}")
|
||||
p.println("dynamicCols = ${getDynamicCols(context)}")
|
||||
p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}")
|
||||
p.println("blockPaste = ${isBlockPasteEnabled(context)}")
|
||||
p.println("trimRectSizingHandles = ${isTrimRectSizingHandlesEnabled(context)}")
|
||||
p.println("trimRectRemainAfterEdit = ${isTrimRectRemainAfterEditEnabled(context)}")
|
||||
p.println("pasteWordBreak = ${isPasteWordBreakEnabled(context)}")
|
||||
p.println()
|
||||
|
||||
p.println("[hotspots]")
|
||||
p.println("hotspotsEnabled = ${isHotspotsEnabled(context)}")
|
||||
p.println("hotspotsUrlEnabled = ${isHotspotsUrlEnabled(context)}")
|
||||
p.println("hotspotsPfEnabled = ${isHotspotsPfEnabled(context)}")
|
||||
p.println("hotspotsMenuChoicesEnabled = ${isHotspotsMenuChoicesEnabled(context)}")
|
||||
p.println()
|
||||
|
||||
p.println("[entryassist]")
|
||||
p.println("docMode = ${getEntryAssistDocMode(context)}")
|
||||
p.println("wordWrap = ${getEntryAssistWordWrap(context)}")
|
||||
p.println("startCol = ${getEntryAssistStartCol(context)}")
|
||||
p.println("endCol = ${getEntryAssistEndCol(context)}")
|
||||
p.println("bell = ${getEntryAssistBell(context)}")
|
||||
p.println("bellCol = ${getEntryAssistBellCol(context)}")
|
||||
p.println("tabStops = ${getEntryAssistTabStops(context)}")
|
||||
p.println()
|
||||
|
||||
p.println("[modes]")
|
||||
p.println("insertOffOnAid = ${getInsertOffOnAid(context)}")
|
||||
p.println("fourColorOverride = ${getFourColorOverride(context)}")
|
||||
p.println("numericFieldLock = ${getNumericFieldLock(context)}")
|
||||
p.println("autoSkip = ${getAutoSkipEnabled(context)}")
|
||||
p.println()
|
||||
|
||||
p.println("[graphics]")
|
||||
p.println("graphicsMode = ${getDefaultGraphicsMode(context)}")
|
||||
p.println()
|
||||
|
||||
p.flush()
|
||||
}
|
||||
|
||||
fun shareSettingsIni(context: Context) {
|
||||
try {
|
||||
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
|
||||
val file = File(cacheDir, "a3270_settings.ini")
|
||||
file.bufferedWriter().use { writer ->
|
||||
exportToIni(context, writer)
|
||||
}
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "a3270 Terminal Settings")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Share Terminal Settings"))
|
||||
} catch (e: Exception) {
|
||||
android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun parseIni(reader: Reader, callback: (section: String, key: String, value: String) -> Unit) {
|
||||
val bufferedReader = BufferedReader(reader)
|
||||
var currentSection = ""
|
||||
|
||||
bufferedReader.forEachLine { rawLine ->
|
||||
val line = rawLine.trim()
|
||||
if (line.isNotEmpty() && !line.startsWith(";") && !line.startsWith("#")) {
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
currentSection = line.substring(1, line.length - 1).lowercase(Locale.US).trim()
|
||||
} else {
|
||||
val eq = line.indexOf('=')
|
||||
if (eq >= 0) {
|
||||
val key = line.substring(0, eq).trim()
|
||||
val value = line.substring(eq + 1).trim()
|
||||
callback(currentSection, key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFromIni(context: Context, reader: Reader) {
|
||||
parseIni(reader) { section, key, value ->
|
||||
applyConfigEntry(context, section, key, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyConfigEntry(context: Context, section: String, key: String, value: String) {
|
||||
when (section) {
|
||||
"appearance" -> {
|
||||
when (key.lowercase(Locale.US)) {
|
||||
"javauitheme", "theme", "uitheme" -> setUiTheme(context, value)
|
||||
"crosshairruler", "ruler" -> setCrosshairRulerEnabled(context, value.toBoolean())
|
||||
"cursorstyle" -> setCursorStyle(context, value)
|
||||
}
|
||||
}
|
||||
"behavior", "connection" -> {
|
||||
when (key.lowercase(Locale.US)) {
|
||||
"startupbehavior" -> {
|
||||
try {
|
||||
setStartupBehavior(context, StartupBehavior.valueOf(value.uppercase(Locale.US)))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
"codepage", "charset" -> setDefaultCodePage(context, value)
|
||||
"hosttype", "host_type" -> setDefaultHostType(context, value)
|
||||
"dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) }
|
||||
"dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) }
|
||||
"blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean())
|
||||
"blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean())
|
||||
"trimrectsizinghandles", "sizinghandles" -> setTrimRectSizingHandlesEnabled(context, value.toBoolean())
|
||||
"trimrectremainafteredit", "remainafteredit" -> setTrimRectRemainAfterEditEnabled(context, value.toBoolean())
|
||||
"pastewordbreak", "wordbreak" -> setPasteWordBreakEnabled(context, value.toBoolean())
|
||||
"hotspotsenabled" -> setHotspotsEnabled(context, value.toBoolean())
|
||||
}
|
||||
}
|
||||
"clipboard" -> {
|
||||
when (key.lowercase(Locale.US)) {
|
||||
"blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean())
|
||||
"trimrectsizinghandles", "sizinghandles" -> setTrimRectSizingHandlesEnabled(context, value.toBoolean())
|
||||
"trimrectremainafteredit", "remainafteredit" -> setTrimRectRemainAfterEditEnabled(context, value.toBoolean())
|
||||
"pastewordbreak", "wordbreak" -> setPasteWordBreakEnabled(context, value.toBoolean())
|
||||
}
|
||||
}
|
||||
"hotspots" -> {
|
||||
when (key.lowercase(Locale.US)) {
|
||||
"hotspotsenabled", "enabled" -> setHotspotsEnabled(context, value.toBoolean())
|
||||
"hotspotsurlenabled", "urlenabled", "url" -> setHotspotsUrlEnabled(context, value.toBoolean())
|
||||
"hotspotspfenabled", "pfenabled", "pf" -> setHotspotsPfEnabled(context, value.toBoolean())
|
||||
"hotspotsmenuchoicesenabled", "menuchoicesenabled", "menuchoices", "menu" -> setHotspotsMenuChoicesEnabled(context, value.toBoolean())
|
||||
}
|
||||
}
|
||||
"entryassist" -> {
|
||||
when (key.lowercase(Locale.US)) {
|
||||
"docmode", "entryassistdocmode" -> setEntryAssistDocMode(context, value.toBoolean())
|
||||
"wordwrap", "entryassistwordwrap" -> setEntryAssistWordWrap(context, value.toBoolean())
|
||||
"startcol", "entryassiststartcol" -> value.toIntOrNull()?.let { setEntryAssistStartCol(context, it) }
|
||||
"endcol", "entryassistendcol" -> value.toIntOrNull()?.let { setEntryAssistEndCol(context, it) }
|
||||
"bell", "entryassistbell" -> setEntryAssistBell(context, value.toBoolean())
|
||||
"bellcol", "entryassistbellcol" -> value.toIntOrNull()?.let { setEntryAssistBellCol(context, it) }
|
||||
"tabstops", "entryassisttabstops" -> setEntryAssistTabStops(context, value)
|
||||
}
|
||||
}
|
||||
"modes" -> {
|
||||
when (key.lowercase(Locale.US)) {
|
||||
"insertoffonaid" -> setInsertOffOnAid(context, value.toBoolean())
|
||||
"fourcoloroverride" -> setFourColorOverride(context, value.toBoolean())
|
||||
"numericfieldlock" -> setNumericFieldLock(context, value.toBoolean())
|
||||
"autoskip", "autoskipenabled" -> setAutoSkipEnabled(context, value.toBoolean())
|
||||
}
|
||||
}
|
||||
"graphics" -> {
|
||||
if (key.equals("graphicsmode", ignoreCase = true) || key.equals("mode", ignoreCase = true)) {
|
||||
setDefaultGraphicsMode(context, value.uppercase(Locale.US))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package haus.nightmare.a3270.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import haus.nightmare.lib3270j.ConnectionConfig
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.graphics.GraphicsMode
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.Reader
|
||||
import java.io.Writer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
data class SavedHost(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int = 23,
|
||||
val model: Int = 2,
|
||||
val dynamicRows: Int = 62,
|
||||
val dynamicCols: Int = 160,
|
||||
val luName: String = "",
|
||||
val autoConnect: Boolean = false,
|
||||
val hostType: String = "TSO",
|
||||
val useTls: Boolean = false,
|
||||
val tlsVerifyCert: Boolean = true,
|
||||
val tn3270e: Boolean = true,
|
||||
val graphicsMode: String = "BOTH",
|
||||
val codePage: String = "037",
|
||||
val proxyType: String = "NONE",
|
||||
val proxyHost: String = "",
|
||||
val proxyPort: Int = 0,
|
||||
val proxyUsername: String = "",
|
||||
val proxyPassword: String = ""
|
||||
) {
|
||||
fun toConnectionConfig(): ConnectionConfig {
|
||||
val tm = when (model) {
|
||||
3 -> TerminalModel.IBM_3279_3
|
||||
4 -> TerminalModel.IBM_3279_4
|
||||
5 -> TerminalModel.IBM_3279_5
|
||||
0 -> TerminalModel.IBM_DYNAMIC
|
||||
else -> TerminalModel.IBM_3279_2
|
||||
}
|
||||
|
||||
val config = ConnectionConfig(host, port, tm, useTls)
|
||||
config.isSavedConnection = true
|
||||
config.savedProfileId = this.id
|
||||
config.savedProfileName = this.name
|
||||
config.hostType = if (this.hostType.isNotBlank()) this.hostType else "TSO"
|
||||
if (model == 0 || tm.isDynamic) {
|
||||
config.setDynamicDimensions(dynamicRows, dynamicCols)
|
||||
}
|
||||
if (luName.isNotBlank()) {
|
||||
config.luName = luName.trim()
|
||||
}
|
||||
config.isTlsVerifyCert = tlsVerifyCert
|
||||
config.isTn3270eEnabled = tn3270e
|
||||
config.graphicsMode = GraphicsMode.fromString(graphicsMode)
|
||||
if (codePage.isNotBlank()) {
|
||||
config.codePage = codePage
|
||||
}
|
||||
if (!proxyType.equals("NONE", ignoreCase = true) && proxyHost.isNotBlank()) {
|
||||
try {
|
||||
val pt = ConnectionConfig.ProxyType.valueOf(proxyType.uppercase())
|
||||
config.setProxy(
|
||||
pt,
|
||||
proxyHost,
|
||||
if (proxyPort > 0) proxyPort else (if (pt == ConnectionConfig.ProxyType.HTTP) 8080 else 1080),
|
||||
proxyUsername.ifBlank { null },
|
||||
proxyPassword.ifBlank { null }
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
fun toJson(): JSONObject {
|
||||
return JSONObject().apply {
|
||||
put("id", id)
|
||||
put("name", name)
|
||||
put("host", host)
|
||||
put("port", port)
|
||||
put("model", model)
|
||||
put("dynamicRows", dynamicRows)
|
||||
put("dynamicCols", dynamicCols)
|
||||
put("luName", luName)
|
||||
put("autoConnect", autoConnect)
|
||||
put("hostType", hostType)
|
||||
put("useTls", useTls)
|
||||
put("tlsVerifyCert", tlsVerifyCert)
|
||||
put("tn3270e", tn3270e)
|
||||
put("graphicsMode", graphicsMode)
|
||||
put("codePage", codePage)
|
||||
put("proxyType", proxyType)
|
||||
put("proxyHost", proxyHost)
|
||||
put("proxyPort", proxyPort)
|
||||
put("proxyUsername", proxyUsername)
|
||||
put("proxyPassword", proxyPassword)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: JSONObject): SavedHost {
|
||||
return SavedHost(
|
||||
id = json.optString("id", UUID.randomUUID().toString()),
|
||||
name = json.optString("name", "Mainframe"),
|
||||
host = json.optString("host", "127.0.0.1"),
|
||||
port = json.optInt("port", 23),
|
||||
model = json.optInt("model", 2),
|
||||
dynamicRows = json.optInt("dynamicRows", 62),
|
||||
dynamicCols = json.optInt("dynamicCols", 160),
|
||||
luName = json.optString("luName", ""),
|
||||
autoConnect = json.optBoolean("autoConnect", false),
|
||||
hostType = json.optString("hostType", "TSO"),
|
||||
useTls = json.optBoolean("useTls", false),
|
||||
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
|
||||
tn3270e = json.optBoolean("tn3270e", true),
|
||||
graphicsMode = json.optString("graphicsMode", "BOTH"),
|
||||
codePage = json.optString("codePage", "037"),
|
||||
proxyType = json.optString("proxyType", "NONE"),
|
||||
proxyHost = json.optString("proxyHost", ""),
|
||||
proxyPort = json.optInt("proxyPort", 0),
|
||||
proxyUsername = json.optString("proxyUsername", ""),
|
||||
proxyPassword = json.optString("proxyPassword", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HostStorage {
|
||||
private const val PREFS_NAME = "a3270_host_prefs"
|
||||
private const val KEY_SAVED_HOSTS = "saved_hosts"
|
||||
|
||||
private fun getPrefs(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun getSavedHosts(context: Context): List<SavedHost> {
|
||||
val jsonStr = getPrefs(context).getString(KEY_SAVED_HOSTS, null) ?: return emptyList()
|
||||
val list = mutableListOf<SavedHost>()
|
||||
try {
|
||||
val jsonArray = JSONArray(jsonStr)
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val obj = jsonArray.getJSONObject(i)
|
||||
val saved = SavedHost.fromJson(obj)
|
||||
val effective = if (saved.graphicsMode == "PROGRAMMED_SYMBOLS") {
|
||||
saved.copy(graphicsMode = "BOTH")
|
||||
} else {
|
||||
saved
|
||||
}
|
||||
list.add(effective)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun saveHost(context: Context, host: SavedHost) {
|
||||
val currentHosts = getSavedHosts(context).toMutableList()
|
||||
val index = currentHosts.indexOfFirst { it.id == host.id }
|
||||
|
||||
// If this host is set to autoConnect, unset autoConnect on all others
|
||||
val updatedHost = if (host.autoConnect) {
|
||||
currentHosts.indices.forEach { i ->
|
||||
currentHosts[i] = currentHosts[i].copy(autoConnect = false)
|
||||
}
|
||||
host
|
||||
} else {
|
||||
host
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
currentHosts[index] = updatedHost
|
||||
} else {
|
||||
currentHosts.add(updatedHost)
|
||||
}
|
||||
|
||||
saveAll(context, currentHosts)
|
||||
}
|
||||
|
||||
fun deleteHost(context: Context, hostId: String) {
|
||||
val currentHosts = getSavedHosts(context).filter { it.id != hostId }
|
||||
saveAll(context, currentHosts)
|
||||
}
|
||||
|
||||
val AVAILABLE_CODE_PAGES = listOf(
|
||||
"037" to "037 - US / Canada / Brazil",
|
||||
"1047" to "1047 - IBM Open Systems / z/OS Unix",
|
||||
"500" to "500 - International Latin-1",
|
||||
"273" to "273 - Germany / Austria",
|
||||
"277" to "277 - Denmark / Norway",
|
||||
"278" to "278 - Sweden / Finland",
|
||||
"280" to "280 - Italy",
|
||||
"284" to "284 - Spain / Latin America",
|
||||
"285" to "285 - United Kingdom",
|
||||
"297" to "297 - France",
|
||||
"420" to "420 - Arabic Bilingual",
|
||||
"424" to "424 - Hebrew (with Latin)",
|
||||
"803" to "803 - Hebrew Character Set",
|
||||
"838" to "838 - Thai Extended",
|
||||
"1160" to "1160 - Thai Euro",
|
||||
"870" to "870 - Eastern Europe / Latin-2",
|
||||
"871" to "871 - Iceland",
|
||||
"875" to "875 - Greece (Greek)",
|
||||
"880" to "880 - Cyrillic (Russian)",
|
||||
"1025" to "1025 - Cyrillic Multilingual",
|
||||
"1123" to "1123 - Cyrillic Ukraine",
|
||||
"1154" to "1154 - Cyrillic Euro",
|
||||
"905" to "905 - Turkey (Latin-5)",
|
||||
"1026" to "1026 - Turkey (Turkish)",
|
||||
"1155" to "1155 - Turkey Euro",
|
||||
"1140" to "1140 - US / Canada (Euro €)",
|
||||
"1141" to "1141 - Germany / Austria (Euro €)",
|
||||
"1142" to "1142 - Denmark / Norway (Euro €)",
|
||||
"1143" to "1143 - Sweden / Finland (Euro €)",
|
||||
"1144" to "1144 - Italy (Euro €)",
|
||||
"1145" to "1145 - Spain / Latin America (Euro €)",
|
||||
"1146" to "1146 - United Kingdom (Euro €)",
|
||||
"1147" to "1147 - France (Euro €)",
|
||||
"1148" to "1148 - International (Euro €)",
|
||||
"1149" to "1149 - Iceland (Euro €)",
|
||||
"930" to "930 - Japanese Katakana Mixed DBCS",
|
||||
"939" to "939 - Japanese Latin Mixed DBCS",
|
||||
"290" to "290 - Japanese Katakana SBCS",
|
||||
"1027" to "1027 - Japanese Latin Extended SBCS",
|
||||
"1390" to "1390 - Japanese Katakana DBCS Euro",
|
||||
"1399" to "1399 - Japanese Latin DBCS Euro",
|
||||
"935" to "935 - Simplified Chinese Mixed DBCS",
|
||||
"937" to "937 - Traditional Chinese Mixed DBCS",
|
||||
"1388" to "1388 - Simplified Chinese Extended DBCS",
|
||||
"1371" to "1371 - Traditional Chinese Extended DBCS",
|
||||
"933" to "933 - Korean Mixed DBCS"
|
||||
)
|
||||
|
||||
fun getAutoConnectHost(context: Context): SavedHost? {
|
||||
return getSavedHosts(context).firstOrNull { it.autoConnect }
|
||||
}
|
||||
|
||||
private fun saveAll(context: Context, hosts: List<SavedHost>) {
|
||||
val array = JSONArray()
|
||||
hosts.forEach { array.put(it.toJson()) }
|
||||
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
|
||||
}
|
||||
|
||||
// ================= INI Export / Import =================
|
||||
|
||||
fun exportToIni(hosts: List<SavedHost>, writer: Writer) {
|
||||
val p = PrintWriter(writer)
|
||||
val timeStamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
|
||||
p.println("; a3270 Saved Connections Configuration File")
|
||||
p.println("; Exported on $timeStamp")
|
||||
p.println("; Connections count: ${hosts.size}")
|
||||
p.println()
|
||||
|
||||
for (h in hosts) {
|
||||
val section = h.name.trim().ifBlank { h.host.trim().ifBlank { "Connection" } }
|
||||
p.println("[$section]")
|
||||
if (h.id.isNotBlank()) {
|
||||
p.println("id = ${h.id}")
|
||||
}
|
||||
p.println("name = ${h.name}")
|
||||
p.println("host = ${h.host}")
|
||||
p.println("port = ${h.port}")
|
||||
p.println("model = ${h.model}")
|
||||
p.println("dynamicRows = ${h.dynamicRows}")
|
||||
p.println("dynamicCols = ${h.dynamicCols}")
|
||||
p.println("luName = ${h.luName}")
|
||||
p.println("autoConnect = ${h.autoConnect}")
|
||||
p.println("hostType = ${h.hostType.ifBlank { "TSO" }}")
|
||||
p.println("useTls = ${h.useTls}")
|
||||
p.println("tlsVerifyCert = ${h.tlsVerifyCert}")
|
||||
p.println("tn3270e = ${h.tn3270e}")
|
||||
p.println("graphicsMode = ${h.graphicsMode.ifBlank { "BOTH" }}")
|
||||
p.println("codePage = ${h.codePage.ifBlank { "037" }}")
|
||||
p.println("proxyType = ${h.proxyType.ifBlank { "NONE" }}")
|
||||
p.println("proxyHost = ${h.proxyHost}")
|
||||
p.println("proxyPort = ${h.proxyPort}")
|
||||
p.println("proxyUsername = ${h.proxyUsername}")
|
||||
p.println("proxyPassword = ${h.proxyPassword}")
|
||||
p.println()
|
||||
}
|
||||
p.flush()
|
||||
}
|
||||
|
||||
fun loadFromIni(reader: Reader): List<SavedHost> {
|
||||
val bufferedReader = BufferedReader(reader)
|
||||
val hosts = mutableListOf<SavedHost>()
|
||||
var current: SavedHost? = null
|
||||
|
||||
bufferedReader.forEachLine { rawLine ->
|
||||
val line = rawLine.trim()
|
||||
if (line.isNotEmpty() && !line.startsWith(";") && !line.startsWith("#")) {
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
current?.let { hosts.add(it) }
|
||||
var section = line.substring(1, line.length - 1).trim()
|
||||
if (section.startsWith("connection:", ignoreCase = true)) {
|
||||
section = section.substring("connection:".length).trim()
|
||||
} else if (section.startsWith("host:", ignoreCase = true)) {
|
||||
section = section.substring("host:".length).trim()
|
||||
}
|
||||
current = SavedHost(id = UUID.randomUUID().toString(), name = section, host = "")
|
||||
} else {
|
||||
val eq = line.indexOf('=')
|
||||
if (eq >= 0) {
|
||||
val key = line.substring(0, eq).trim().lowercase(Locale.US)
|
||||
val value = line.substring(eq + 1).trim()
|
||||
if (current == null) {
|
||||
current = SavedHost(id = UUID.randomUUID().toString(), name = "Default", host = "")
|
||||
}
|
||||
current = applyIniProperty(current!!, key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
current?.let { hosts.add(it) }
|
||||
return hosts
|
||||
}
|
||||
|
||||
private fun applyIniProperty(h: SavedHost, key: String, value: String): SavedHost {
|
||||
return when (key) {
|
||||
"id" -> if (value.isNotBlank()) h.copy(id = value) else h
|
||||
"name", "profilename", "profile" -> if (value.isNotBlank()) h.copy(name = value) else h
|
||||
"host", "hostname", "address" -> h.copy(host = value)
|
||||
"port" -> h.copy(port = value.toIntOrNull() ?: 23)
|
||||
"model", "terminalmodel" -> h.copy(model = value.toIntOrNull() ?: 2)
|
||||
"dynamicrows", "dynamic_rows", "rows" -> h.copy(dynamicRows = value.toIntOrNull() ?: 62)
|
||||
"dynamiccols", "dynamic_cols", "cols" -> h.copy(dynamicCols = value.toIntOrNull() ?: 160)
|
||||
"luname", "lu_name", "lu" -> h.copy(luName = value)
|
||||
"autoconnect", "auto_connect" -> h.copy(autoConnect = value.toBoolean())
|
||||
"hosttype", "host_type", "type" -> h.copy(hostType = value.uppercase(Locale.US))
|
||||
"usetls", "use_tls", "tls", "ssl" -> h.copy(useTls = value.toBoolean())
|
||||
"tlsverifycert", "verifycert", "tls_verify_cert" -> h.copy(tlsVerifyCert = value.toBoolean())
|
||||
"tn3270e", "enabletn3270e" -> h.copy(tn3270e = value.toBoolean())
|
||||
"graphicsmode", "graphics_mode", "graphics" -> h.copy(graphicsMode = value.uppercase(Locale.US))
|
||||
"codepage", "code_page", "charset" -> h.copy(codePage = value)
|
||||
"proxytype", "proxy_type" -> h.copy(proxyType = value.uppercase(Locale.US))
|
||||
"proxyhost", "proxy_host" -> h.copy(proxyHost = value)
|
||||
"proxyport", "proxy_port" -> h.copy(proxyPort = value.toIntOrNull() ?: 0)
|
||||
"proxyusername", "proxy_username", "proxyuser" -> h.copy(proxyUsername = value)
|
||||
"proxypassword", "proxy_password", "proxypass" -> h.copy(proxyPassword = value)
|
||||
else -> h
|
||||
}
|
||||
}
|
||||
|
||||
fun importFromIni(context: Context, reader: Reader): List<SavedHost> {
|
||||
val imported = loadFromIni(reader)
|
||||
if (imported.isEmpty()) return getSavedHosts(context)
|
||||
|
||||
val current = getSavedHosts(context).toMutableList()
|
||||
for (host in imported) {
|
||||
val index = current.indexOfFirst {
|
||||
(it.id.isNotBlank() && it.id == host.id) ||
|
||||
(it.name.isNotBlank() && it.name.equals(host.name, ignoreCase = true))
|
||||
}
|
||||
if (host.autoConnect) {
|
||||
current.indices.forEach { i ->
|
||||
current[i] = current[i].copy(autoConnect = false)
|
||||
}
|
||||
}
|
||||
if (index >= 0) {
|
||||
val existingId = current[index].id
|
||||
val toSet = if (host.id.isBlank()) host.copy(id = existingId) else host
|
||||
current[index] = toSet
|
||||
} else {
|
||||
current.add(host)
|
||||
}
|
||||
}
|
||||
saveAll(context, current)
|
||||
return current
|
||||
}
|
||||
|
||||
fun shareConnectionsIni(context: Context) {
|
||||
try {
|
||||
val hosts = getSavedHosts(context)
|
||||
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
|
||||
val file = File(cacheDir, "a3270_connections.ini")
|
||||
file.bufferedWriter().use { writer ->
|
||||
exportToIni(hosts, writer)
|
||||
}
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "a3270 Saved Connections")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Share Saved Connections"))
|
||||
} catch (e: Exception) {
|
||||
android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
enum class ConnectChoice {
|
||||
NEW_WINDOW,
|
||||
ABORT_CURRENT,
|
||||
CANCEL
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectChoiceDialog(
|
||||
currentTarget: String,
|
||||
onChoice: (ConnectChoice) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val targetText = currentTarget.ifBlank { "the host" }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
onChoice(ConnectChoice.CANCEL)
|
||||
onDismiss()
|
||||
},
|
||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color(0xFF1E1E1E),
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Connection Already Active",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "a3270 is currently connected to $targetText.",
|
||||
fontSize = 13.sp,
|
||||
color = Color.LightGray
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Would you like to open the connection in a new window or abort the current connection?",
|
||||
fontSize = 13.sp,
|
||||
color = Color.LightGray
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onChoice(ConnectChoice.CANCEL)
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text("Cancel", color = Color.Gray)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onChoice(ConnectChoice.ABORT_CURRENT)
|
||||
onDismiss()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A))
|
||||
) {
|
||||
Text("Abort Current Connection", fontSize = 11.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onChoice(ConnectChoice.NEW_WINDOW)
|
||||
onDismiss()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
|
||||
) {
|
||||
Text("New Window", fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import haus.nightmare.a3270.storage.HostStorage
|
||||
import haus.nightmare.a3270.storage.SavedHost
|
||||
|
||||
private val TerminalKeyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Ascii
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ConnectDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConnect: (
|
||||
host: String,
|
||||
port: Int,
|
||||
model: Int,
|
||||
dynamicRows: Int,
|
||||
dynamicCols: Int,
|
||||
luName: String,
|
||||
hostType: String,
|
||||
useTls: Boolean,
|
||||
tlsVerifyCert: Boolean,
|
||||
tn3270e: Boolean,
|
||||
graphicsMode: String,
|
||||
codePage: String,
|
||||
proxyType: String,
|
||||
proxyHost: String,
|
||||
proxyPort: Int,
|
||||
proxyUser: String,
|
||||
proxyPass: String,
|
||||
savedConnection: Boolean,
|
||||
savedProfileId: String?,
|
||||
savedProfileName: String?
|
||||
) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
|
||||
var selectedHostId by remember { mutableStateOf<String?>(null) }
|
||||
var showImportConnectionsDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var profileName by remember { mutableStateOf("") }
|
||||
var host by remember { mutableStateOf("") }
|
||||
var portStr by remember { mutableStateOf("") }
|
||||
var modelNum by remember { mutableIntStateOf(2) }
|
||||
var dynamicRowsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()) }
|
||||
var dynamicColsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()) }
|
||||
var luName by remember { mutableStateOf("") }
|
||||
var autoConnect by remember { mutableStateOf(false) }
|
||||
var hostType by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)) }
|
||||
var useTls by remember { mutableStateOf(false) }
|
||||
var tlsVerifyCert by remember { mutableStateOf(true) }
|
||||
var tn3270e by remember { mutableStateOf(true) }
|
||||
var graphicsMode by remember { mutableStateOf("BOTH") }
|
||||
var codePage by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)) }
|
||||
var proxyType by remember { mutableStateOf("NONE") }
|
||||
var proxyHost by remember { mutableStateOf("") }
|
||||
var proxyPortStr by remember { mutableStateOf("") }
|
||||
var proxyUsername by remember { mutableStateOf("") }
|
||||
var proxyPassword by remember { mutableStateOf("") }
|
||||
var showProxySettings by remember { mutableStateOf(false) }
|
||||
|
||||
fun loadProfile(saved: SavedHost) {
|
||||
selectedHostId = saved.id
|
||||
profileName = saved.name
|
||||
host = saved.host
|
||||
portStr = saved.port.toString()
|
||||
modelNum = saved.model
|
||||
dynamicRowsStr = saved.dynamicRows.toString()
|
||||
dynamicColsStr = saved.dynamicCols.toString()
|
||||
luName = saved.luName
|
||||
autoConnect = saved.autoConnect
|
||||
hostType = saved.hostType
|
||||
useTls = saved.useTls
|
||||
tlsVerifyCert = saved.tlsVerifyCert
|
||||
tn3270e = saved.tn3270e
|
||||
graphicsMode = saved.graphicsMode
|
||||
codePage = saved.codePage
|
||||
proxyType = saved.proxyType
|
||||
proxyHost = saved.proxyHost
|
||||
proxyPortStr = if (saved.proxyPort > 0) saved.proxyPort.toString() else ""
|
||||
proxyUsername = saved.proxyUsername
|
||||
proxyPassword = saved.proxyPassword
|
||||
showProxySettings = saved.proxyType != "NONE"
|
||||
}
|
||||
|
||||
fun clearFields() {
|
||||
selectedHostId = null
|
||||
profileName = ""
|
||||
host = ""
|
||||
portStr = ""
|
||||
modelNum = 2
|
||||
dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()
|
||||
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
|
||||
luName = ""
|
||||
autoConnect = false
|
||||
hostType = haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)
|
||||
useTls = false
|
||||
tlsVerifyCert = true
|
||||
tn3270e = true
|
||||
graphicsMode = "BOTH"
|
||||
codePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
|
||||
proxyType = "NONE"
|
||||
proxyHost = ""
|
||||
proxyPortStr = ""
|
||||
proxyUsername = ""
|
||||
proxyPassword = ""
|
||||
showProxySettings = false
|
||||
}
|
||||
|
||||
fun saveCurrentProfile(): SavedHost? {
|
||||
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
|
||||
val trimmedHost = host.trim()
|
||||
if (trimmedHost.isBlank()) {
|
||||
android.widget.Toast.makeText(context, "Please enter a Host / IP Address", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return null
|
||||
}
|
||||
val nameToSave = profileName.trim().ifBlank { "$trimmedHost:$port" }
|
||||
val dynRows = dynamicRowsStr.trim().toIntOrNull()?.coerceIn(24, 255) ?: 62
|
||||
val dynCols = dynamicColsStr.trim().toIntOrNull()?.coerceIn(80, 255) ?: 160
|
||||
val hostToSave = SavedHost(
|
||||
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
|
||||
name = nameToSave,
|
||||
host = trimmedHost,
|
||||
port = port,
|
||||
model = modelNum,
|
||||
dynamicRows = dynRows,
|
||||
dynamicCols = dynCols,
|
||||
luName = luName.trim(),
|
||||
autoConnect = autoConnect,
|
||||
hostType = hostType,
|
||||
useTls = useTls,
|
||||
tlsVerifyCert = tlsVerifyCert,
|
||||
tn3270e = tn3270e,
|
||||
graphicsMode = graphicsMode,
|
||||
codePage = codePage,
|
||||
proxyType = proxyType,
|
||||
proxyHost = proxyHost.trim(),
|
||||
proxyPort = proxyPortStr.trim().toIntOrNull() ?: 0,
|
||||
proxyUsername = proxyUsername.trim(),
|
||||
proxyPassword = proxyPassword.trim()
|
||||
)
|
||||
HostStorage.saveHost(context, hostToSave)
|
||||
savedHosts = HostStorage.getSavedHosts(context)
|
||||
selectedHostId = hostToSave.id
|
||||
return hostToSave
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(150)
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color(0xFF1E1E1E),
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.heightIn(max = 680.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Title Row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Connect / Manage Hosts",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White
|
||||
)
|
||||
TextButton(
|
||||
onClick = { clearFields() },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text("+ New Profile", fontSize = 12.sp, color = Color(0xFF339AF0))
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable Content Container
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Saved Profiles List Section
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
if (savedHosts.isNotEmpty()) "Saved Host Profiles (${savedHosts.size}):" else "Saved Host Profiles:",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Gray
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (savedHosts.isNotEmpty()) {
|
||||
TextButton(
|
||||
onClick = { HostStorage.shareConnectionsIni(context) },
|
||||
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text("Export (Share)", fontSize = 11.sp, color = Color(0xFF339AF0))
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { showImportConnectionsDialog = true },
|
||||
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text("Import", fontSize = 11.sp, color = Color(0xFF339AF0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedHosts.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
savedHosts.forEach { saved ->
|
||||
val isSelected = (saved.id == selectedHostId)
|
||||
val tlsTag = if (saved.useTls) {
|
||||
if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/NoVerify]"
|
||||
} else ""
|
||||
val eTag = if (!saved.tn3270e) " [Non-E]" else ""
|
||||
val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
|
||||
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { loadProfile(saved) }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = saved.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "${saved.host}:${saved.port} (${if (saved.model == 0) "Dyn ${saved.dynamicRows}x${saved.dynamicCols}" else "M" + saved.model} - ${saved.hostType})$tlsTag$eTag$gfxTag" +
|
||||
if (saved.autoConnect) " [Auto]" else "",
|
||||
fontSize = 10.sp,
|
||||
color = Color.LightGray
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
loadProfile(saved)
|
||||
val hostToConn = saveCurrentProfile() ?: saved
|
||||
onConnect(
|
||||
hostToConn.host,
|
||||
hostToConn.port,
|
||||
hostToConn.model,
|
||||
hostToConn.dynamicRows,
|
||||
hostToConn.dynamicCols,
|
||||
hostToConn.luName,
|
||||
hostToConn.hostType,
|
||||
hostToConn.useTls,
|
||||
hostToConn.tlsVerifyCert,
|
||||
hostToConn.tn3270e,
|
||||
hostToConn.graphicsMode,
|
||||
hostToConn.codePage,
|
||||
hostToConn.proxyType,
|
||||
hostToConn.proxyHost,
|
||||
hostToConn.proxyPort,
|
||||
hostToConn.proxyUsername,
|
||||
hostToConn.proxyPassword,
|
||||
true,
|
||||
hostToConn.id,
|
||||
hostToConn.name
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Text("▶", fontSize = 14.sp, color = Color(0xFF51CF66))
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
HostStorage.deleteHost(context, saved.id)
|
||||
savedHosts = HostStorage.getSavedHosts(context)
|
||||
if (selectedHostId == saved.id) {
|
||||
clearFields()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Text("✕", fontSize = 13.sp, color = Color(0xFFFF6B6B))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (selectedHostId != null) "Editing Profile:" else "Host Connection Details:",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = profileName,
|
||||
onValueChange = { profileName = it },
|
||||
label = { Text("Profile Name (Optional)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it.filterNot { c -> c.isWhitespace() } },
|
||||
label = { Text("Host / IP Address") },
|
||||
placeholder = { Text("127.0.0.1", color = Color.Gray) },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||
modifier = Modifier
|
||||
.weight(0.68f)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = portStr,
|
||||
onValueChange = { portStr = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("Port") },
|
||||
placeholder = { Text(if (useTls) "992" else "23", color = Color.Gray) },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.weight(0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
// Terminal Model Selector
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Terminal Model:", fontSize = 11.sp, color = Color.Gray)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
listOf(
|
||||
2 to "M2",
|
||||
3 to "M3",
|
||||
4 to "M4",
|
||||
5 to "M5",
|
||||
0 to "DYN"
|
||||
).forEach { (m, label) ->
|
||||
val isSelected = (modelNum == m)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(34.dp)
|
||||
.clickable { modelNum = m }
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If Dynamic Model selected, allow specifying Rows and Columns
|
||||
if (modelNum == 0) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = dynamicRowsStr,
|
||||
onValueChange = { dynamicRowsStr = it },
|
||||
label = { Text("Dyn Rows (24-255)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = dynamicColsStr,
|
||||
onValueChange = { dynamicColsStr = it },
|
||||
label = { Text("Dyn Cols (80-255)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Host System Type (TSO, CMS, CICS)
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Host System Type:", fontSize = 11.sp, color = Color.Gray)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
listOf("TSO", "CMS", "CICS").forEach { ht ->
|
||||
val isSelected = (hostType == ht)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(34.dp)
|
||||
.clickable { hostType = ht }
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
text = ht,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Graphics Mode (Both, Vector, Symbols, Off)
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Graphics Mode:", fontSize = 11.sp, color = Color.Gray)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
listOf(
|
||||
"BOTH" to "Both",
|
||||
"VECTOR_GRAPHICS" to "Vector",
|
||||
"PROGRAMMED_SYMBOLS" to "Symbols",
|
||||
"NONE" to "Off"
|
||||
).forEach { (modeKey, modeLabel) ->
|
||||
val isSelected = graphicsMode.equals(modeKey, ignoreCase = true)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = if (isSelected) Color(0xFF2B8A3E) else Color(0xFF2C2D30),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(34.dp)
|
||||
.clickable { graphicsMode = modeKey }
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = modeLabel,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Code Page Selector
|
||||
var codePageMenuExpanded by remember { mutableStateOf(false) }
|
||||
val codePagesList = HostStorage.AVAILABLE_CODE_PAGES
|
||||
val selectedCpLabel = codePagesList.firstOrNull { it.first == codePage }?.second ?: "Code Page $codePage"
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("EBCDIC Code Page:", fontSize = 11.sp, color = Color.Gray)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = Color(0xFF2C2D30),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF495057)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(38.dp)
|
||||
.clickable { codePageMenuExpanded = true }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedCpLabel,
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text("▼", color = Color.Gray, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = codePageMenuExpanded,
|
||||
onDismissRequest = { codePageMenuExpanded = false },
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF2C2D30))
|
||||
.heightIn(max = 280.dp)
|
||||
) {
|
||||
codePagesList.forEach { (cpId, label) ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
color = if (codePage == cpId) Color(0xFF339AF0) else Color.White,
|
||||
fontWeight = if (codePage == cpId) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
codePage = cpId
|
||||
codePageMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = luName,
|
||||
onValueChange = { luName = it },
|
||||
label = { Text("LU Name / Device Pool (Optional)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Protocol & Security Options
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// TLS / SSL Checkbox
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val newTls = !useTls
|
||||
useTls = newTls
|
||||
if (newTls && portStr.trim() == "23") {
|
||||
portStr = "992"
|
||||
} else if (!newTls && portStr.trim() == "992") {
|
||||
portStr = "23"
|
||||
}
|
||||
}
|
||||
) {
|
||||
Checkbox(
|
||||
checked = useTls,
|
||||
onCheckedChange = { checked ->
|
||||
useTls = checked
|
||||
if (checked && portStr.trim() == "23") {
|
||||
portStr = "992"
|
||||
} else if (!checked && portStr.trim() == "992") {
|
||||
portStr = "23"
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Enable TLS / SSL Connection", fontSize = 12.sp, color = Color.White)
|
||||
}
|
||||
|
||||
// Verify Certificate Checkbox (only when TLS is active)
|
||||
if (useTls) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp)
|
||||
.clickable { tlsVerifyCert = !tlsVerifyCert }
|
||||
) {
|
||||
Checkbox(
|
||||
checked = tlsVerifyCert,
|
||||
onCheckedChange = { tlsVerifyCert = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
"Verify Server Certificate",
|
||||
fontSize = 12.sp,
|
||||
color = if (tlsVerifyCert) Color.LightGray else Color(0xFFFFB450)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TN3270E Checkbox
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { tn3270e = !tn3270e }
|
||||
) {
|
||||
Checkbox(
|
||||
checked = tn3270e,
|
||||
onCheckedChange = { tn3270e = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Enable TN3270E (Extended 3270)", fontSize = 12.sp, color = Color.White)
|
||||
}
|
||||
|
||||
// Auto-connect Checkbox
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { autoConnect = !autoConnect }
|
||||
) {
|
||||
Checkbox(
|
||||
checked = autoConnect,
|
||||
onCheckedChange = { autoConnect = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White)
|
||||
}
|
||||
|
||||
// Proxy Settings Expandable Section
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showProxySettings = !showProxySettings }
|
||||
) {
|
||||
Checkbox(
|
||||
checked = showProxySettings || proxyType != "NONE",
|
||||
onCheckedChange = { checked ->
|
||||
showProxySettings = checked
|
||||
if (!checked) proxyType = "NONE"
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = if (proxyType != "NONE") "Proxy Active ($proxyType)" else "Configure Network Proxy",
|
||||
fontSize = 12.sp,
|
||||
color = if (proxyType != "NONE") Color(0xFF339AF0) else Color.White
|
||||
)
|
||||
}
|
||||
|
||||
if (showProxySettings || proxyType != "NONE") {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 4.dp, top = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text("Proxy Protocol:", fontSize = 11.sp, color = Color.Gray)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
listOf("NONE", "HTTP", "SOCKS4", "SOCKS5").forEach { pType ->
|
||||
val isSel = proxyType == pType
|
||||
FilterChip(
|
||||
selected = isSel,
|
||||
onClick = { proxyType = pType },
|
||||
label = { Text(pType, fontSize = 10.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyType != "NONE") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = proxyHost,
|
||||
onValueChange = { proxyHost = it },
|
||||
label = { Text("Proxy Host") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions,
|
||||
modifier = Modifier.weight(0.7f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = proxyPortStr,
|
||||
onValueChange = { proxyPortStr = it },
|
||||
label = { Text("Port") },
|
||||
placeholder = { Text(if (proxyType == "HTTP") "8080" else "1080") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(0.3f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = proxyUsername,
|
||||
onValueChange = { proxyUsername = it },
|
||||
label = { Text("User (Opt)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions,
|
||||
modifier = Modifier.weight(0.5f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = proxyPassword,
|
||||
onValueChange = { proxyPassword = it },
|
||||
label = { Text("Pass (Opt)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions,
|
||||
modifier = Modifier.weight(0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action Buttons Row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel", color = Color.LightGray)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
OutlinedButton(onClick = { saveCurrentProfile() }) {
|
||||
Text("Save")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val saved = saveCurrentProfile()
|
||||
if (saved != null) {
|
||||
onConnect(
|
||||
saved.host,
|
||||
saved.port,
|
||||
saved.model,
|
||||
saved.dynamicRows,
|
||||
saved.dynamicCols,
|
||||
saved.luName,
|
||||
saved.hostType,
|
||||
saved.useTls,
|
||||
saved.tlsVerifyCert,
|
||||
saved.tn3270e,
|
||||
saved.graphicsMode,
|
||||
saved.codePage,
|
||||
saved.proxyType,
|
||||
saved.proxyHost,
|
||||
saved.proxyPort,
|
||||
saved.proxyUsername,
|
||||
saved.proxyPassword,
|
||||
true,
|
||||
saved.id,
|
||||
saved.name
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||
) {
|
||||
Text("Connect", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showImportConnectionsDialog) {
|
||||
var importText by remember { mutableStateOf("") }
|
||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||
AlertDialog(
|
||||
onDismissRequest = { showImportConnectionsDialog = false },
|
||||
title = { Text("Import Host Connections") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Paste j3270 / a3270 INI connections below:", fontSize = 12.sp)
|
||||
OutlinedTextField(
|
||||
value = importText,
|
||||
onValueChange = { importText = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
maxLines = 10
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
val clip = clipboardManager.getText()?.text ?: ""
|
||||
if (clip.isNotBlank()) {
|
||||
importText = clip
|
||||
}
|
||||
}) {
|
||||
Text("Paste Clipboard", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showImportConnectionsDialog = false
|
||||
if (importText.isNotBlank()) {
|
||||
try {
|
||||
val countBefore = savedHosts.size
|
||||
val merged = HostStorage.importFromIni(context, java.io.StringReader(importText))
|
||||
savedHosts = merged
|
||||
val countAfter = merged.size
|
||||
val diff = countAfter - countBefore
|
||||
val msg = if (diff > 0) "Imported $diff new connection(s)" else "Updated ${merged.size} connection(s)"
|
||||
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text("Import")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showImportConnectionsDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import haus.nightmare.lib3270j.Telnet3270Client
|
||||
import haus.nightmare.lib3270j.ecl.ECLField
|
||||
|
||||
@Composable
|
||||
fun FieldInspectorDialog(
|
||||
client: Telnet3270Client?,
|
||||
screenVersion: Long,
|
||||
onDismiss: () -> Unit,
|
||||
onJumpToField: (Int) -> Unit
|
||||
) {
|
||||
var fields by remember { mutableStateOf<List<ECLField>>(emptyList()) }
|
||||
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||
var cols by remember { mutableIntStateOf(80) }
|
||||
var rows by remember { mutableIntStateOf(24) }
|
||||
|
||||
fun refresh() {
|
||||
if (client != null && client.connectionState.isFullSession) {
|
||||
val fieldList = client.fieldList
|
||||
fields = fieldList?.fields ?: emptyList()
|
||||
cols = client.screenBuffer?.cols ?: 80
|
||||
rows = client.screenBuffer?.rows ?: 24
|
||||
} else {
|
||||
fields = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(screenVersion, client) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.heightIn(max = 680.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Presentation Space Field Inspector",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
"${fields.size} fields detected (${rows}x$cols)",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { refresh() }) {
|
||||
Text("Refresh", fontSize = 12.sp, color = Color(0xFF339AF0))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||
|
||||
if (fields.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"No fields detected or terminal not in 3270 session.",
|
||||
color = Color.Gray,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val horizScroll = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.horizontalScroll(horizScroll)
|
||||
) {
|
||||
// Header Row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF25262B))
|
||||
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("#", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(30.dp), textAlign = TextAlign.Center)
|
||||
Text("Pos", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(42.dp), textAlign = TextAlign.Center)
|
||||
Text("Start", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
|
||||
Text("End", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
|
||||
Text("Len", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text("Prot", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text("MDT", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text("Num", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text("Hi-Int", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
|
||||
Text("Hidden", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center)
|
||||
Text("Pen", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text("Wrap", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text("Content Text", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp), textAlign = TextAlign.Start)
|
||||
}
|
||||
|
||||
// Data Rows
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
itemsIndexed(fields) { idx, field ->
|
||||
val isSelected = (idx == selectedIndex)
|
||||
val startRow = field.startRow + 1
|
||||
val startCol = field.startCol + 1
|
||||
val endRow = field.endRow + 1
|
||||
val endCol = field.endCol + 1
|
||||
|
||||
val displayText = if (field.isHidden) "••••••••" else field.text
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = if (isSelected) Color(0xFF1C7ED6).copy(alpha = 0.35f) else if (idx % 2 == 0) Color(0xFF161719) else Color(0xFF1E1F23),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
selectedIndex = idx
|
||||
onJumpToField(field.dataStart)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("${idx + 1}", color = Color.White, fontSize = 11.sp, modifier = Modifier.width(30.dp), textAlign = TextAlign.Center)
|
||||
Text("${field.start}", color = Color.Cyan, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(42.dp), textAlign = TextAlign.Center)
|
||||
Text(String.format("%02d/%02d", startRow, startCol), color = Color.LightGray, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
|
||||
Text(String.format("%02d/%02d", endRow, endCol), color = Color.LightGray, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
|
||||
Text("${field.length}", color = Color.White, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text(if (field.isProtected) "Y" else "-", color = if (field.isProtected) Color.Gray else Color(0xFF51CF66), fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text(if (field.isModified) "Y" else "-", color = if (field.isModified) Color(0xFFFFB450) else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text(if (field.isNumeric) "Y" else "-", color = Color.LightGray, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text(if (field.isHighIntensity) "Y" else "-", color = if (field.isHighIntensity) Color.White else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
|
||||
Text(if (field.isHidden) "Y" else "-", color = if (field.isHidden) Color(0xFFFF6B6B) else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center)
|
||||
Text(if (field.isPenSelectable) "Y" else "-", color = if (field.isPenSelectable) Color.Yellow else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text(if (field.isWrapped) "↩" else "-", color = if (field.isWrapped) Color(0xFF339AF0) else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text(displayText, color = if (field.isProtected) Color(0xFF90CAF9) else Color(0xFFA5D6A7), fontSize = 11.sp, fontFamily = FontFamily.Monospace, maxLines = 1, modifier = Modifier.width(220.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (selectedIndex in fields.indices) {
|
||||
Button(
|
||||
onClick = {
|
||||
onJumpToField(fields[selectedIndex].dataStart)
|
||||
onDismiss()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
|
||||
) {
|
||||
Text("Jump to Field")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close", color = Color.LightGray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.OpenableColumns
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import haus.nightmare.lib3270j.ft.FTConfig
|
||||
import haus.nightmare.a3270.FTProgressState
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private val TerminalKeyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Ascii
|
||||
)
|
||||
|
||||
data class LastTransferState(
|
||||
var hostFile: String = "",
|
||||
var localFile: String = "",
|
||||
var isReceive: Boolean = true,
|
||||
var isAscii: Boolean = true,
|
||||
var hostType: FTConfig.HostType = FTConfig.HostType.TSO,
|
||||
var recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT,
|
||||
var lreclStr: String = "",
|
||||
var blksizeStr: String = "",
|
||||
var units: FTConfig.AllocationUnit = FTConfig.AllocationUnit.TRACKS,
|
||||
var spaceStr: String = "",
|
||||
var dirBlocks: String = "",
|
||||
var cmsOptions: String = ""
|
||||
)
|
||||
private var lastTransferState: LastTransferState? = null
|
||||
|
||||
fun getLastTransferState(): LastTransferState? = lastTransferState
|
||||
fun resetSessionState() { lastTransferState = null }
|
||||
|
||||
fun formatHostFilename(localName: String, hostType: FTConfig.HostType): String {
|
||||
if (hostType == FTConfig.HostType.CMS) {
|
||||
val lastDot = localName.lastIndexOf('.')
|
||||
return if (lastDot > 0) {
|
||||
val fn = localName.substring(0, lastDot).replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
|
||||
val ft = localName.substring(lastDot + 1).replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
|
||||
"${fn.ifBlank { "FILE" }} ${ft.ifBlank { "TEXT" }} A"
|
||||
} else {
|
||||
val clean = localName.replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
|
||||
"${clean.ifBlank { "FILE" }} FILE A"
|
||||
}
|
||||
} else if (hostType == FTConfig.HostType.TSO) {
|
||||
val cleanBase = localName.substringBeforeLast('.').replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
|
||||
return "'${cleanBase.ifBlank { "DATA" }}'"
|
||||
}
|
||||
return localName
|
||||
}
|
||||
|
||||
fun formatLocalFilenameFromHost(hostFile: String, hostType: FTConfig.HostType): String {
|
||||
val cleanName = hostFile.trim('\'', '"')
|
||||
if (hostType == FTConfig.HostType.CMS) {
|
||||
val tokens = cleanName.trim().split(Regex("\\s+"))
|
||||
if (tokens.size >= 2) {
|
||||
return "${tokens[0].lowercase()}.${tokens[1].lowercase()}"
|
||||
} else if (tokens.size == 1 && tokens[0].isNotBlank()) {
|
||||
return "${tokens[0].lowercase()}.txt"
|
||||
}
|
||||
} else {
|
||||
val lastDot = cleanName.lastIndexOf('.')
|
||||
if (lastDot > 0) {
|
||||
return "${cleanName.substring(lastDot + 1).lowercase()}.txt"
|
||||
}
|
||||
}
|
||||
return if (cleanName.isNotBlank()) "${cleanName.lowercase()}.txt" else "download.txt"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileTransferDialog(
|
||||
initialHostType: String = "TSO",
|
||||
ftProgressState: FTProgressState = FTProgressState(),
|
||||
client: haus.nightmare.lib3270j.Telnet3270Client? = null,
|
||||
onDismiss: () -> Unit,
|
||||
onStartTransfer: (FTConfig) -> Unit,
|
||||
onCancelTransfer: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val initialTypeEnum = when (initialHostType.uppercase()) {
|
||||
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
|
||||
"CICS" -> FTConfig.HostType.CICS
|
||||
else -> FTConfig.HostType.TSO
|
||||
}
|
||||
|
||||
val clientConfig = client?.config
|
||||
val configuredFtType = clientConfig?.ftHostType
|
||||
val effectiveHostType = if (configuredFtType != null && (clientConfig.isSavedConnection || lastTransferState == null)) {
|
||||
configuredFtType
|
||||
} else {
|
||||
lastTransferState?.hostType ?: initialTypeEnum
|
||||
}
|
||||
|
||||
var hostFile by remember { mutableStateOf(lastTransferState?.hostFile ?: "") }
|
||||
var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") }
|
||||
var localDisplayName by remember { mutableStateOf("") }
|
||||
var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) }
|
||||
var isAscii by remember { mutableStateOf(lastTransferState?.isAscii ?: true) }
|
||||
var hostType by remember { mutableStateOf(effectiveHostType) }
|
||||
var recfm by remember { mutableStateOf(lastTransferState?.recfm ?: FTConfig.RecordFormat.DEFAULT) }
|
||||
var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") }
|
||||
var blksizeStr by remember { mutableStateOf(lastTransferState?.blksizeStr ?: "") }
|
||||
var units by remember { mutableStateOf(lastTransferState?.units ?: FTConfig.AllocationUnit.TRACKS) }
|
||||
var spaceStr by remember { mutableStateOf(lastTransferState?.spaceStr ?: "") }
|
||||
var dirBlocks by remember { mutableStateOf(lastTransferState?.dirBlocks ?: "") }
|
||||
var cmsOptions by remember { mutableStateOf(lastTransferState?.cmsOptions ?: "") }
|
||||
var overwrite by remember { mutableStateOf(true) }
|
||||
var showBrowseDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var targetSaveUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var lastSavedFile by remember { mutableStateOf<File?>(null) }
|
||||
|
||||
// SAF Launcher for uploading (Send / PUT)
|
||||
val openDocumentLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
try {
|
||||
val displayName = getFileNameFromUri(context, uri).ifBlank { "upload.bin" }
|
||||
val tempDir = File(context.cacheDir, "ft_upload").apply { if (!exists()) mkdirs() }
|
||||
val tempFile = File(tempDir, displayName)
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
localFile = tempFile.absolutePath
|
||||
localDisplayName = displayName
|
||||
if (hostFile.isBlank()) {
|
||||
hostFile = formatHostFilename(displayName, hostType)
|
||||
}
|
||||
Toast.makeText(context, "Selected: $displayName", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "Failed to read file: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAF Launcher for choosing download destination (Receive / GET)
|
||||
val createDocumentLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("*/*")
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
targetSaveUri = uri
|
||||
val displayName = getFileNameFromUri(context, uri).ifBlank { "download.bin" }
|
||||
localDisplayName = displayName
|
||||
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
|
||||
val tempFile = File(tempDir, displayName)
|
||||
localFile = tempFile.absolutePath
|
||||
Toast.makeText(context, "Destination: $displayName", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for completed transfers to export to destination URI if chosen
|
||||
LaunchedEffect(ftProgressState.isActive, ftProgressState.isError) {
|
||||
if (!ftProgressState.isActive && !ftProgressState.isError && ftProgressState.bytesTransferred > 0 && isReceive && localFile.isNotBlank()) {
|
||||
val src = File(localFile)
|
||||
if (src.exists()) {
|
||||
lastSavedFile = src
|
||||
val uri = targetSaveUri
|
||||
if (uri != null) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||
src.inputStream().use { input ->
|
||||
input.copyTo(out)
|
||||
}
|
||||
}
|
||||
Toast.makeText(context, "Saved download to destination file", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "Failed writing to destination: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBrowseDialog) {
|
||||
val initialQuery = resolveDefaultQuery(hostType, hostFile)
|
||||
HostDirectoryDialog(
|
||||
client = client,
|
||||
initialHostType = hostType,
|
||||
initialQuery = initialQuery,
|
||||
onDismiss = { showBrowseDialog = false },
|
||||
onSelectEntry = { selectedName, selectedEntry ->
|
||||
hostFile = selectedName
|
||||
if (selectedEntry != null) {
|
||||
val r = selectedEntry.recfm
|
||||
if (!r.isNullOrBlank()) {
|
||||
val mappedRecfm = when (r.trim().uppercase(Locale.US)) {
|
||||
"F", "FB", "FA", "FBA", "FBM" -> FTConfig.RecordFormat.FIXED
|
||||
"V", "VB", "VA", "VBA", "VBM" -> FTConfig.RecordFormat.VARIABLE
|
||||
else -> FTConfig.RecordFormat.DEFAULT
|
||||
}
|
||||
recfm = mappedRecfm
|
||||
}
|
||||
if (selectedEntry.lrecl > 0) {
|
||||
lreclStr = selectedEntry.lrecl.toString()
|
||||
}
|
||||
if (selectedEntry.blksize > 0) {
|
||||
blksizeStr = selectedEntry.blksize.toString()
|
||||
}
|
||||
}
|
||||
val shouldAutofill = localFile.isBlank() || (lastTransferState != null && localFile == lastTransferState?.localFile)
|
||||
if (shouldAutofill) {
|
||||
val safeName = formatLocalFilenameFromHost(selectedName, hostType)
|
||||
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
|
||||
val tempFile = File(tempDir, safeName)
|
||||
localFile = tempFile.absolutePath
|
||||
localDisplayName = safeName
|
||||
}
|
||||
showBrowseDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun exportToDownloads() {
|
||||
val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null
|
||||
if (src == null || !src.exists()) {
|
||||
Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
if (!downloadsDir.exists()) downloadsDir.mkdirs()
|
||||
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val targetName = (if (localDisplayName.isNotBlank()) localDisplayName else src.name).let {
|
||||
val base = it.substringBeforeLast('.')
|
||||
val ext = if (it.contains('.')) "." + it.substringAfterLast('.') else ""
|
||||
"${base}_$timeStamp$ext"
|
||||
}
|
||||
val dst = File(downloadsDir, targetName)
|
||||
src.copyTo(dst, overwrite = true)
|
||||
Toast.makeText(context, "Exported to Downloads/${dst.name}", Toast.LENGTH_LONG).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "Export failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun shareDownloadedFile() {
|
||||
val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null
|
||||
if (src == null || !src.exists()) {
|
||||
Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val text = src.readText()
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_SUBJECT, localDisplayName.ifBlank { src.name })
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Share Transferred File"))
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Active Transfer Progress Banner
|
||||
if (ftProgressState.isActive || ftProgressState.isRunning) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = ftProgressState.statusMessage.ifBlank { "Transferring..." },
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
|
||||
)
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
if (ftProgressState.bytesTransferred > 0) {
|
||||
Text(
|
||||
text = "${ftProgressState.bytesTransferred} bytes transferred",
|
||||
fontSize = 11.sp,
|
||||
color = Color.LightGray
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onCancelTransfer,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Cancel Transfer", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFF373A40))
|
||||
} else if (ftProgressState.statusMessage.isNotBlank()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = ftProgressState.statusMessage,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
|
||||
)
|
||||
if (!ftProgressState.isError && isReceive && lastSavedFile?.exists() == true) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { exportToDownloads() },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Save to Downloads 📥", fontSize = 11.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { shareDownloadedFile() },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Share 📤", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFF373A40))
|
||||
}
|
||||
|
||||
// Transfer Direction
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
Row {
|
||||
FilterChip(
|
||||
selected = isReceive,
|
||||
onClick = {
|
||||
isReceive = true
|
||||
targetSaveUri = null
|
||||
},
|
||||
label = { Text("Receive (GET)", fontSize = 11.sp) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
FilterChip(
|
||||
selected = !isReceive,
|
||||
onClick = {
|
||||
isReceive = false
|
||||
targetSaveUri = null
|
||||
},
|
||||
label = { Text("Send (PUT)", fontSize = 11.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Host File Name
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = hostFile,
|
||||
onValueChange = { hostFile = it },
|
||||
label = { Text("Host Dataset / File Name") },
|
||||
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "'USER.DATA'" else "PROFILE EXEC A") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (client != null && client.connectionState.isFullSession) {
|
||||
OutlinedButton(
|
||||
onClick = { showBrowseDialog = true },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
) {
|
||||
Text("Browse...", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local File Selection (SAF Document Picker integration)
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = if (localDisplayName.isNotBlank()) localDisplayName else localFile,
|
||||
onValueChange = {
|
||||
localDisplayName = it
|
||||
localFile = it
|
||||
},
|
||||
label = { Text(if (isReceive) "Local Destination" else "Local File to Upload") },
|
||||
placeholder = { Text(if (isReceive) "download.txt" else "sample.txt") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(
|
||||
imeAction = if (!isReceive && hostType == FTConfig.HostType.TSO) ImeAction.Next else ImeAction.Done
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
if (isReceive) {
|
||||
val defName = if (hostFile.isNotBlank()) {
|
||||
formatLocalFilenameFromHost(hostFile, hostType)
|
||||
} else "download.txt"
|
||||
createDocumentLauncher.launch(defName)
|
||||
} else {
|
||||
openDocumentLauncher.launch(arrayOf("*/*"))
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6)),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
) {
|
||||
Text(if (isReceive) "Save As..." else "Pick File...", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (isReceive) "Pick a destination or enter a file name to save in app storage" else "Use 'Pick File' to select any file from Downloads / Storage",
|
||||
fontSize = 10.sp,
|
||||
color = Color.LightGray,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Transfer Mode
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
Row {
|
||||
FilterChip(
|
||||
selected = isAscii,
|
||||
onClick = { isAscii = true },
|
||||
label = { Text("ASCII (Text)", fontSize = 11.sp) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
FilterChip(
|
||||
selected = !isAscii,
|
||||
onClick = { isAscii = false },
|
||||
label = { Text("Binary", fontSize = 11.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Host Type (TSO, CMS, CICS)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
Row {
|
||||
FTConfig.HostType.values().forEach { ht ->
|
||||
FilterChip(
|
||||
selected = (hostType == ht),
|
||||
onClick = { hostType = ht },
|
||||
label = { Text(ht.name, fontSize = 11.sp) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TSO Send Options
|
||||
if (!isReceive && hostType == FTConfig.HostType.TSO) {
|
||||
Text("TSO Dataset Allocation:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Record Format:", fontSize = 11.sp)
|
||||
Row {
|
||||
listOf(
|
||||
FTConfig.RecordFormat.DEFAULT to "Def",
|
||||
FTConfig.RecordFormat.FIXED to "F",
|
||||
FTConfig.RecordFormat.VARIABLE to "V"
|
||||
).forEach { (rf, label) ->
|
||||
FilterChip(
|
||||
selected = (recfm == rf),
|
||||
onClick = { recfm = rf },
|
||||
label = { Text(label, fontSize = 11.sp) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = lreclStr,
|
||||
onValueChange = { lreclStr = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("LRECL", fontSize = 11.sp) },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = blksizeStr,
|
||||
onValueChange = { blksizeStr = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("BLKSIZE", fontSize = 11.sp) },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Units:", fontSize = 11.sp)
|
||||
Row {
|
||||
listOf(
|
||||
FTConfig.AllocationUnit.TRACKS to "Tracks",
|
||||
FTConfig.AllocationUnit.CYLINDERS to "Cylinders",
|
||||
FTConfig.AllocationUnit.AVBLOCK to "Avblock",
|
||||
FTConfig.AllocationUnit.DEFAULT to "Default"
|
||||
).forEach { (u, label) ->
|
||||
FilterChip(
|
||||
selected = (units == u),
|
||||
onClick = { units = u },
|
||||
label = { Text(label, fontSize = 11.sp) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = spaceStr,
|
||||
onValueChange = { spaceStr = it },
|
||||
label = { Text("SPACE (Pri, Sec)", fontSize = 11.sp) },
|
||||
placeholder = { Text("e.g. 5,2 or 1,1") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.weight(0.62f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = dirBlocks,
|
||||
onValueChange = { dirBlocks = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("Dir (PDS)", fontSize = 11.sp) },
|
||||
placeholder = { Text("e.g. 10") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
|
||||
modifier = Modifier.weight(0.38f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CMS Options
|
||||
if (hostType == FTConfig.HostType.CMS) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = cmsOptions,
|
||||
onValueChange = { cmsOptions = it },
|
||||
label = { Text("CMS Options", fontSize = 11.sp) },
|
||||
placeholder = { Text("e.g. ASCII CRLF or RECFM F LRECL 80") },
|
||||
singleLine = true,
|
||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val resolvedLocal = if (localFile.isNotBlank()) {
|
||||
localFile.trim()
|
||||
} else if (localDisplayName.isNotBlank()) {
|
||||
val tempDir = File(context.cacheDir, if (isReceive) "ft_download" else "ft_upload").apply { if (!exists()) mkdirs() }
|
||||
File(tempDir, localDisplayName.trim()).absolutePath
|
||||
} else ""
|
||||
|
||||
if (hostFile.isNotBlank() && resolvedLocal.isNotBlank()) {
|
||||
localFile = resolvedLocal
|
||||
val config = FTConfig().apply {
|
||||
setHostFilename(hostFile.trim())
|
||||
setLocalFilename(resolvedLocal)
|
||||
setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND)
|
||||
setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY)
|
||||
setHostType(hostType)
|
||||
setExistAction(if (overwrite) FTConfig.ExistAction.REPLACE else FTConfig.ExistAction.KEEP)
|
||||
if (!isReceive && hostType == FTConfig.HostType.TSO) {
|
||||
setRecfm(recfm)
|
||||
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
|
||||
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
|
||||
setUnits(units)
|
||||
if (spaceStr.isNotBlank()) setSpace(spaceStr.trim())
|
||||
if (dirBlocks.isNotBlank()) setDirectoryBlocks(dirBlocks.trim())
|
||||
}
|
||||
if (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) {
|
||||
setOptions(cmsOptions.trim())
|
||||
}
|
||||
}
|
||||
lastTransferState = LastTransferState(
|
||||
hostFile = hostFile.trim(),
|
||||
localFile = resolvedLocal,
|
||||
isReceive = isReceive,
|
||||
isAscii = isAscii,
|
||||
hostType = hostType,
|
||||
recfm = recfm,
|
||||
lreclStr = lreclStr,
|
||||
blksizeStr = blksizeStr,
|
||||
units = units,
|
||||
spaceStr = spaceStr,
|
||||
dirBlocks = dirBlocks,
|
||||
cmsOptions = cmsOptions
|
||||
)
|
||||
onStartTransfer(config)
|
||||
}
|
||||
},
|
||||
enabled = hostFile.isNotBlank() && (localFile.isNotBlank() || localDisplayName.isNotBlank()) && !ftProgressState.isRunning
|
||||
) {
|
||||
Text("Start Transfer")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getFileNameFromUri(context: Context, uri: Uri): String {
|
||||
var name = ""
|
||||
try {
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex != -1) {
|
||||
name = it.getString(nameIndex) ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
if (name.isBlank()) {
|
||||
name = uri.lastPathSegment ?: "file"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
fun FindDialog(
|
||||
cols: Int = 80,
|
||||
onDismiss: () -> Unit,
|
||||
onFind: (query: String, matchCase: Boolean, forward: Boolean) -> Int,
|
||||
onClearHighlight: () -> Unit
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var matchCase by remember { mutableStateOf(false) }
|
||||
var forward by remember { mutableStateOf(true) }
|
||||
var statusMessage by remember { mutableStateOf("") }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(100)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
fun doFind(searchForward: Boolean) {
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
statusMessage = "Please enter search text."
|
||||
isError = true
|
||||
return
|
||||
}
|
||||
val pos = onFind(trimmed, matchCase, searchForward)
|
||||
if (pos >= 0) {
|
||||
val effectiveCols = if (cols > 0) cols else 80
|
||||
val row = (pos / effectiveCols) + 1
|
||||
val col = (pos % effectiveCols) + 1
|
||||
statusMessage = "Found at position $pos ($row/$col)"
|
||||
isError = false
|
||||
} else {
|
||||
statusMessage = "Text not found: \"$trimmed\""
|
||||
isError = true
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.92f)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Find on Screen",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
if (statusMessage.isNotBlank()) statusMessage = ""
|
||||
},
|
||||
label = { Text("Find What") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Search
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = { doFind(forward) }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
// Options Row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { matchCase = !matchCase }
|
||||
) {
|
||||
Checkbox(
|
||||
checked = matchCase,
|
||||
onCheckedChange = { matchCase = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Match Case", fontSize = 12.sp, color = Color.White)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = forward,
|
||||
onClick = { forward = true },
|
||||
label = { Text("Down ↓", fontSize = 11.sp) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = !forward,
|
||||
onClick = { forward = false },
|
||||
label = { Text("Up ↑", fontSize = 11.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (statusMessage.isNotBlank()) {
|
||||
Text(
|
||||
text = statusMessage,
|
||||
fontSize = 12.sp,
|
||||
color = if (isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
|
||||
)
|
||||
}
|
||||
|
||||
// Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
onClearHighlight()
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("Close", color = Color.LightGray)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
OutlinedButton(onClick = {
|
||||
onClearHighlight()
|
||||
statusMessage = ""
|
||||
}) {
|
||||
Text("Clear")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Button(
|
||||
onClick = { doFind(forward) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
|
||||
) {
|
||||
Text("Find Next")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import haus.nightmare.lib3270j.ConnectionState
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
|
||||
@Composable
|
||||
fun TwoRowKeyBar(
|
||||
connectionState: ConnectionState,
|
||||
isShiftPressed: Boolean = false,
|
||||
isLightPenMode: Boolean = false,
|
||||
isInsertMode: Boolean = false,
|
||||
isDocMode: Boolean = false,
|
||||
isWordWrap: Boolean = false,
|
||||
isAplMode: Boolean = false,
|
||||
fourColorOverride: Boolean = false,
|
||||
hapticFeedbackEnabled: Boolean = true,
|
||||
onClearShift: () -> Unit = {},
|
||||
onConnectClick: () -> Unit,
|
||||
onDisconnectClick: () -> Unit,
|
||||
onFtClick: () -> Unit,
|
||||
onFieldInspectorClick: () -> Unit = {},
|
||||
onFindClick: () -> Unit = {},
|
||||
onScreenExportClick: () -> Unit = {},
|
||||
onScreenHistoryClick: () -> Unit = {},
|
||||
onScriptClick: () -> Unit = {},
|
||||
onMacroRuntimeClick: () -> Unit = {},
|
||||
onPrinterSessionClick: () -> Unit = {},
|
||||
onToggleLightPen: () -> Unit = {},
|
||||
onToggleInsert: () -> Unit = {},
|
||||
onToggleDocMode: () -> Unit = {},
|
||||
onToggleWordWrap: () -> Unit = {},
|
||||
onToggleAplMode: () -> Unit = {},
|
||||
onToggleFourColorOverride: () -> Unit = {},
|
||||
onSettingsClick: () -> Unit = {},
|
||||
onSendAid: (Int) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onTab: () -> Unit,
|
||||
onBackTab: () -> Unit,
|
||||
onEraseInput: () -> Unit = {},
|
||||
onEraseEof: () -> Unit = {},
|
||||
onAttn: () -> Unit = {},
|
||||
onSysReq: () -> Unit = {},
|
||||
onCursorSelect: () -> Unit = {},
|
||||
onCursorLeft: () -> Unit = {},
|
||||
onCursorUp: () -> Unit = {},
|
||||
onCursorDown: () -> Unit = {},
|
||||
onCursorRight: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||
|
||||
BackHandler(enabled = menuExpanded) {
|
||||
menuExpanded = false
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFF161719))
|
||||
) {
|
||||
// Row 1: System & Quick Action Keys (Static 12-key row matching Row 2 columns)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(35.dp)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Diagnostics/Tools/Settings)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
KeyButton(
|
||||
label = "⋮",
|
||||
color = Color(0xFF343A40),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
if (System.currentTimeMillis() - lastDismissTime > 200L) {
|
||||
menuExpanded = !menuExpanded
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = {
|
||||
menuExpanded = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
},
|
||||
properties = PopupProperties(
|
||||
focusable = false,
|
||||
dismissOnClickOutside = true
|
||||
),
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF2C2D30))
|
||||
.heightIn(max = 420.dp)
|
||||
) {
|
||||
if (!connectionState.isConnected()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Connect", color = Color.White, fontWeight = FontWeight.SemiBold) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onConnectClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("3287 Printer Session", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onPrinterSessionClick()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onDisconnectClick()
|
||||
}
|
||||
)
|
||||
HorizontalDivider(color = Color(0xFF3E4044))
|
||||
DropdownMenuItem(
|
||||
text = { Text("Find on Screen", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onFindClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Field Inspector", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onFieldInspectorClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("File Transfer (IND\$FILE)", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onFtClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save / Share Screen", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onScreenExportClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Screen History", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onScreenHistoryClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Run Keystroke Script", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onScriptClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Macro Runtime (<HASCRIPT>)", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onMacroRuntimeClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("3287 Printer Session", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onPrinterSessionClick()
|
||||
}
|
||||
)
|
||||
HorizontalDivider(color = Color(0xFF3E4044))
|
||||
DropdownMenuItem(
|
||||
text = { Text("Attention (Attn)", color = Color.LightGray) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onAttn()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("System Request (SysReq)", color = Color.LightGray) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onSysReq()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Erase Input (ErInp)", color = Color.LightGray) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onEraseInput()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Cursor Select (CurSel)", color = Color.LightGray) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onCursorSelect()
|
||||
}
|
||||
)
|
||||
HorizontalDivider(color = Color(0xFF3E4044))
|
||||
DropdownMenuItem(
|
||||
text = { Text("Insert Mode: ${if (isInsertMode) "ON" else "OFF"}", color = if (isInsertMode) Color(0xFF7890F0) else Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onToggleInsert()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Document Mode (DOC): ${if (isDocMode) "ON" else "OFF"}", color = if (isDocMode) Color(0xFF38D9A9) else Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onToggleDocMode()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Word Wrap: ${if (isWordWrap) "ON" else "OFF"}", color = if (isWordWrap) Color(0xFF38D9A9) else Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onToggleWordWrap()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("APL Keyboard Mode: ${if (isAplMode) "ON" else "OFF"}", color = if (isAplMode) Color(0xFFFFA94D) else Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onToggleAplMode()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Base 4-Color Override: ${if (fourColorOverride) "ON" else "OFF"}", color = if (fourColorOverride) Color(0xFFFFD43B) else Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onToggleFourColorOverride()
|
||||
}
|
||||
)
|
||||
HorizontalDivider(color = Color(0xFF3E4044))
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = if (isLightPenMode) "Light Pen: ON" else "Light Pen: OFF",
|
||||
color = if (isLightPenMode) Color(0xFFFFFF50) else Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onToggleLightPen()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onSettingsClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. TAB / BTAB depending on Shift state
|
||||
val tabLabel = if (isShiftPressed) "BTAB" else "TAB"
|
||||
KeyButton(
|
||||
label = tabLabel,
|
||||
color = Color(0xFF1C7ED6),
|
||||
modifier = Modifier.weight(1f),
|
||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
if (isShiftPressed) {
|
||||
onBackTab()
|
||||
onClearShift()
|
||||
} else {
|
||||
onTab()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 3. RESET / ERASE (Erase Input) depending on Shift state
|
||||
val resetLabel = if (isShiftPressed) "ERASE" else "RESET"
|
||||
KeyButton(
|
||||
label = resetLabel,
|
||||
color = Color(0xFFE67700),
|
||||
modifier = Modifier.weight(1f),
|
||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
if (isShiftPressed) {
|
||||
onEraseInput()
|
||||
onClearShift()
|
||||
} else {
|
||||
onReset()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 4. ENTER / EOF (Erase EOF) depending on Shift state
|
||||
val enterLabel = if (isShiftPressed) "EOF" else "ENTER"
|
||||
KeyButton(
|
||||
label = enterLabel,
|
||||
color = Color(0xFF2B8A3E),
|
||||
modifier = Modifier.weight(1f),
|
||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
if (isShiftPressed) {
|
||||
onEraseEof()
|
||||
onClearShift()
|
||||
} else {
|
||||
onSendAid(AID_ENTER)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 5. CLEAR / ATTN (Attention) depending on Shift state
|
||||
val clearLabel = if (isShiftPressed) "ATTN" else "CLEAR"
|
||||
KeyButton(
|
||||
label = clearLabel,
|
||||
color = Color(0xFFC92A2A),
|
||||
modifier = Modifier.weight(1f),
|
||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
if (isShiftPressed) {
|
||||
onAttn()
|
||||
onClearShift()
|
||||
} else {
|
||||
onSendAid(AID_CLEAR)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 6. PA1
|
||||
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
|
||||
|
||||
// 7. PA2
|
||||
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
|
||||
|
||||
// 8. PA3
|
||||
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
|
||||
|
||||
// 9. Left Navigation
|
||||
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
|
||||
|
||||
// 10. Up Navigation
|
||||
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
|
||||
|
||||
// 11. Down Navigation
|
||||
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
|
||||
|
||||
// 12. Right Navigation
|
||||
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
|
||||
|
||||
// Row 2: PF Keys (F1-F12 when Shift is off; F13-F24 when Shift is down) - Non-scrolling, full width
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(35.dp)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val pfAids = intArrayOf(
|
||||
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
|
||||
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
|
||||
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
|
||||
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
|
||||
)
|
||||
|
||||
val startPf = if (isShiftPressed) 13 else 1
|
||||
val endPf = if (isShiftPressed) 24 else 12
|
||||
|
||||
for (i in startPf..endPf) {
|
||||
val aid = pfAids[i - 1]
|
||||
KeyButton(
|
||||
label = "F$i",
|
||||
color = Color(0xFF364FC7),
|
||||
modifier = Modifier.weight(1f),
|
||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
onSendAid(aid)
|
||||
if (isShiftPressed) {
|
||||
onClearShift()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyButton(
|
||||
label: String,
|
||||
color: Color,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
hapticFeedbackEnabled: Boolean = true,
|
||||
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
|
||||
) {
|
||||
val view = LocalView.current
|
||||
Surface(
|
||||
onClick = {
|
||||
if (hapticFeedbackEnabled) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
}
|
||||
onClick()
|
||||
},
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = color,
|
||||
shadowElevation = 1.dp,
|
||||
modifier = modifier
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp)
|
||||
.height(31.dp)
|
||||
.focusProperties { canFocus = false }
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(horizontal = innerPaddingHorizontal)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import haus.nightmare.lib3270j.Telnet3270Client
|
||||
import haus.nightmare.lib3270j.ft.FTConfig
|
||||
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
|
||||
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryParser
|
||||
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry
|
||||
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
|
||||
import haus.nightmare.lib3270j.ft.dir.TSODirectoryParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
fun resolveDefaultQuery(hostType: FTConfig.HostType, initialQuery: String?): String {
|
||||
val defaultQuery = initialQuery?.trim() ?: ""
|
||||
if (hostType == FTConfig.HostType.CMS && (defaultQuery.isEmpty() || defaultQuery == "* * *" || defaultQuery == "*")) {
|
||||
return "* * A"
|
||||
}
|
||||
return defaultQuery
|
||||
}
|
||||
|
||||
fun formatCMSFilter(pattern: String?): String {
|
||||
if (pattern.isNullOrBlank()) {
|
||||
return "* * A"
|
||||
}
|
||||
val clean = pattern.replace("'", "").replace("\"", "").trim()
|
||||
if (clean == "* * *" || clean == "*") {
|
||||
return "* * A"
|
||||
} else if (clean.contains('.')) {
|
||||
val dot = clean.indexOf('.')
|
||||
return clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " A"
|
||||
} else {
|
||||
val parts = clean.split(Regex("\\s+"))
|
||||
return when (parts.size) {
|
||||
1 -> "${parts[0]} * A"
|
||||
2 -> "${parts[0]} ${parts[1]} A"
|
||||
else -> "${parts[0]} ${parts[1]} ${parts[2]}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun matchesFilter(entry: HostDirectoryEntry, filter: String?): Boolean {
|
||||
if (filter.isNullOrBlank()) return true
|
||||
val cleanFilter = filter.replace("'", "").replace("\"", "").trim()
|
||||
if (cleanFilter.isEmpty() || cleanFilter == "*" || cleanFilter == "* * *" || cleanFilter == "*.*") {
|
||||
return true
|
||||
}
|
||||
return when (entry) {
|
||||
is CMSDirectoryEntry -> matchesCmsFilter(entry, cleanFilter)
|
||||
is TSODirectoryEntry -> matchesTsoFilter(entry, cleanFilter)
|
||||
else -> matchesWildcard(entry.name, cleanFilter)
|
||||
}
|
||||
}
|
||||
|
||||
fun matchesCmsFilter(cms: CMSDirectoryEntry, filter: String): Boolean {
|
||||
val fn = cms.filename?.trim() ?: ""
|
||||
val ft = cms.filetype?.trim() ?: ""
|
||||
val fm = cms.filemode?.trim() ?: ""
|
||||
|
||||
val tokens = filter.split(Regex("\\s+"))
|
||||
return if (tokens.size >= 3) {
|
||||
matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1]) && matchesCmsFilemode(fm, tokens[2])
|
||||
} else if (tokens.size == 2) {
|
||||
matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1])
|
||||
} else {
|
||||
val tok = tokens[0]
|
||||
if (tok.contains('.')) {
|
||||
val dot = tok.indexOf('.')
|
||||
matchesWildcard(fn, tok.substring(0, dot)) && matchesWildcard(ft, tok.substring(dot + 1))
|
||||
} else if (matchesWildcard(fn, tok) || matchesWildcard(ft, tok) || matchesCmsFilemode(fm, tok)) {
|
||||
true
|
||||
} else {
|
||||
matchesWildcard(cms.name, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun matchesCmsFilemode(actualFm: String?, fmPattern: String?): Boolean {
|
||||
if (fmPattern.isNullOrEmpty() || fmPattern == "*") return true
|
||||
if (actualFm.isNullOrEmpty()) return false
|
||||
if (fmPattern.length == 1 && fmPattern[0].isLetter()) {
|
||||
return actualFm.startsWith(fmPattern, ignoreCase = true)
|
||||
}
|
||||
return matchesWildcard(actualFm, fmPattern)
|
||||
}
|
||||
|
||||
fun matchesTsoFilter(tso: TSODirectoryEntry, filter: String): Boolean {
|
||||
val dsn = tso.datasetName?.trim() ?: ""
|
||||
if (filter == "*" || filter == "* * *" || filter == "*.*") return true
|
||||
if (filter.contains('*') || filter.contains('?')) {
|
||||
return matchesWildcard(dsn, filter)
|
||||
}
|
||||
return dsn.contains(filter, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun matchesWildcard(text: String?, pattern: String?): Boolean {
|
||||
val t = text ?: ""
|
||||
if (pattern.isNullOrEmpty() || pattern == "*") return true
|
||||
|
||||
val regex = buildString {
|
||||
append("^")
|
||||
for (c in pattern) {
|
||||
when (c) {
|
||||
'*' -> append(".*")
|
||||
'?' -> append(".")
|
||||
in "\\.[]{}()+^$|" -> append("\\").append(c)
|
||||
else -> append(Regex.escape(c.uppercaseChar().toString()))
|
||||
}
|
||||
}
|
||||
append("$")
|
||||
}
|
||||
return t.uppercase(Locale.US).matches(Regex(regex))
|
||||
}
|
||||
|
||||
private fun waitForKeyboardUnlock(client: Telnet3270Client?, timeoutMs: Long): Boolean {
|
||||
if (client == null) return false
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
var locked = false
|
||||
if (client.inputProcessor != null && client.inputProcessor.isKeyboardLocked) {
|
||||
locked = true
|
||||
}
|
||||
if (client.oia != null && (client.oia.isKeyLocked || client.oia.isXSystem)) {
|
||||
locked = true
|
||||
}
|
||||
if (!locked) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
Thread.sleep(40)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HostDirectoryDialog(
|
||||
client: Telnet3270Client?,
|
||||
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
|
||||
initialQuery: String = "",
|
||||
onDismiss: () -> Unit,
|
||||
onSelectDataset: (String) -> Unit = {},
|
||||
onSelectEntry: ((String, HostDirectoryEntry?) -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var hostType by remember { mutableStateOf(initialHostType) }
|
||||
var query by remember { mutableStateOf(resolveDefaultQuery(initialHostType, initialQuery)) }
|
||||
var allEntries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
|
||||
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||
var statusMessage by remember { mutableStateOf("") }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
var inspectionEntry by remember { mutableStateOf<TSODirectoryEntry?>(null) }
|
||||
var showInspectionDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Client-side filtering
|
||||
val currentEntries by remember(allEntries, query, hostType) {
|
||||
derivedStateOf {
|
||||
val filter = query.trim()
|
||||
if (filter.isEmpty()) {
|
||||
allEntries
|
||||
} else {
|
||||
allEntries.filter { matchesFilter(it, filter) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTextData(text: String, source: String) {
|
||||
allEntries = if (hostType == FTConfig.HostType.CMS) {
|
||||
CMSDirectoryParser.parse(text)
|
||||
} else {
|
||||
TSODirectoryParser.parse(text)
|
||||
}
|
||||
selectedIndex = -1
|
||||
statusMessage = if (allEntries.isEmpty()) {
|
||||
"0 entries found from $source. Use 'Query Host' or 'Paste Clipboard'."
|
||||
} else {
|
||||
"Parsed ${allEntries.size} entries from $source."
|
||||
}
|
||||
}
|
||||
|
||||
fun parseScreen() {
|
||||
val c = client
|
||||
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
|
||||
statusMessage = "No active session to parse."
|
||||
return
|
||||
}
|
||||
val rows = c.ps.rows
|
||||
val cols = c.ps.cols
|
||||
val screenText = c.ps.copyString(0, 0, rows - 1, cols - 1)
|
||||
parseTextData(screenText, "presentation space")
|
||||
}
|
||||
|
||||
fun parseClipboard() {
|
||||
try {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
if (cm != null && cm.hasPrimaryClip()) {
|
||||
val clip = cm.primaryClip
|
||||
if (clip != null && clip.itemCount > 0) {
|
||||
val clipText = clip.getItemAt(0).coerceToText(context)?.toString()
|
||||
if (!clipText.isNullOrBlank()) {
|
||||
parseTextData(clipText, "clipboard")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
statusMessage = "Clipboard contains no text."
|
||||
} catch (ex: Exception) {
|
||||
statusMessage = "Error reading clipboard: ${ex.message}"
|
||||
}
|
||||
}
|
||||
|
||||
fun queryHost() {
|
||||
val c = client
|
||||
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
|
||||
statusMessage = "No active session to query."
|
||||
return
|
||||
}
|
||||
|
||||
val pattern = query.trim()
|
||||
val curHostType = hostType
|
||||
statusMessage = "Querying host ($curHostType)..."
|
||||
isLoading = true
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val allPages = StringBuilder()
|
||||
val maxPages = 500
|
||||
|
||||
if (curHostType == FTConfig.HostType.CMS) {
|
||||
c.ps.SendKeys("[clear]")
|
||||
waitForKeyboardUnlock(c, 3000)
|
||||
|
||||
val cmsFilter = formatCMSFilter(pattern)
|
||||
c.ps.SendKeys("LISTFILE $cmsFilter ( nohead alloc date[enter]")
|
||||
Thread.sleep(100)
|
||||
|
||||
for (page in 0 until maxPages) {
|
||||
val startWait = System.currentTimeMillis()
|
||||
val timeoutMs = 10000L
|
||||
var screen = ""
|
||||
var isMore = false
|
||||
|
||||
while (System.currentTimeMillis() - startWait < timeoutMs) {
|
||||
waitForKeyboardUnlock(c, 500)
|
||||
val rows = c.ps.rows
|
||||
val cols = c.ps.cols
|
||||
screen = c.ps.copyString(0, 0, rows - 1, cols - 1)
|
||||
val upper = screen.uppercase(Locale.US)
|
||||
|
||||
if (upper.contains("MORE...") || upper.contains("HOLDING")) {
|
||||
isMore = true
|
||||
break
|
||||
}
|
||||
if (upper.contains("READY;") || upper.contains("READY(")) {
|
||||
break
|
||||
}
|
||||
Thread.sleep(150)
|
||||
}
|
||||
|
||||
allPages.append(screen).append("\n")
|
||||
|
||||
if (isMore) {
|
||||
c.ps.SendKeys("[clear]")
|
||||
Thread.sleep(100)
|
||||
waitForKeyboardUnlock(c, 3000)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (c.ps != null) {
|
||||
c.ps.SendKeys("[clear]")
|
||||
waitForKeyboardUnlock(c, 3000)
|
||||
}
|
||||
} else {
|
||||
val rows = c.ps.rows
|
||||
val cols = c.ps.cols
|
||||
val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
|
||||
val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
|
||||
val hasCmdArrow = initialScreen.contains("===>")
|
||||
val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
|
||||
|
||||
var tsoCmd = "TSO LISTCAT ALL"
|
||||
if (pattern.isNotEmpty()) {
|
||||
var dsnPattern = pattern.trim()
|
||||
if (dsnPattern.startsWith("'") && dsnPattern.endsWith("'") && dsnPattern.length > 2) {
|
||||
dsnPattern = dsnPattern.substring(1, dsnPattern.length - 1)
|
||||
}
|
||||
val paren = dsnPattern.indexOf('(')
|
||||
if (paren > 0) {
|
||||
dsnPattern = dsnPattern.substring(0, paren)
|
||||
}
|
||||
if (dsnPattern == "*" || dsnPattern == "* * *" || dsnPattern == "*.*") {
|
||||
tsoCmd = "TSO LISTCAT ALL"
|
||||
} else {
|
||||
var level = dsnPattern
|
||||
if (level.endsWith(".*")) {
|
||||
level = level.substring(0, level.length - 2)
|
||||
} else if (level.endsWith("*")) {
|
||||
level = level.substring(0, level.length - 1)
|
||||
}
|
||||
tsoCmd = if (level.isEmpty()) "TSO LISTCAT ALL" else "TSO LISTCAT LEVEL($level) ALL"
|
||||
}
|
||||
}
|
||||
|
||||
if (isIspf) {
|
||||
val arrowPos = initialScreen.indexOf("===>")
|
||||
if (arrowPos >= 0 && c.screenBuffer != null) {
|
||||
val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
|
||||
if (inputAddr > 0) {
|
||||
c.screenBuffer.cursorAddress = inputAddr
|
||||
c.screenBuffer.updateDisplaySnapshot()
|
||||
} else {
|
||||
c.ps.SendKeys("[home]")
|
||||
}
|
||||
} else {
|
||||
c.ps.SendKeys("[home]")
|
||||
}
|
||||
c.ps.SendKeys("[erase_eof]")
|
||||
c.ps.SendKeys("$tsoCmd[enter]")
|
||||
} else {
|
||||
c.ps.SendKeys("[clear]")
|
||||
waitForKeyboardUnlock(c, 3000)
|
||||
c.ps.SendKeys("$tsoCmd[enter]")
|
||||
}
|
||||
|
||||
Thread.sleep(100)
|
||||
|
||||
val startCmd = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - startCmd < 4000) {
|
||||
waitForKeyboardUnlock(c, 200)
|
||||
val r = c.ps.rows
|
||||
val col = c.ps.cols
|
||||
val s = c.ps.copyString(0, 0, r - 1, col - 1)
|
||||
val u = s.uppercase(Locale.US)
|
||||
if (u.contains("NONVSAM") || u.contains("CLUSTER") || u.contains("GDG")
|
||||
|| u.contains("ALIAS") || u.contains("IN-CAT") || u.contains("IDCAMS")
|
||||
|| u.contains("***") || u.contains("READY") || u.contains("NOT FOUND")
|
||||
|| u.contains("NO ENTRY") || s.contains(" --- ")
|
||||
|| u.contains("--RECFM-") || u.contains("--VOLUMES--")) {
|
||||
break
|
||||
}
|
||||
Thread.sleep(80)
|
||||
}
|
||||
|
||||
var prevScreen = ""
|
||||
for (page in 0 until maxPages) {
|
||||
waitForKeyboardUnlock(c, 300)
|
||||
val r = c.ps.rows
|
||||
val col = c.ps.cols
|
||||
val screen = c.ps.copyString(0, 0, r - 1, col - 1)
|
||||
val upper = screen.uppercase(Locale.US)
|
||||
|
||||
if (screen == prevScreen || screen.isBlank()) {
|
||||
break
|
||||
}
|
||||
prevScreen = screen
|
||||
allPages.append(screen).append("\n")
|
||||
|
||||
if (upper.contains("***")) {
|
||||
c.ps.SendKeys("[enter]")
|
||||
Thread.sleep(100)
|
||||
waitForKeyboardUnlock(c, 2000)
|
||||
} else if (upper.contains("NONVSAM") || upper.contains("CLUSTER") || upper.contains("GDG")
|
||||
|| upper.contains("ALIAS") || upper.contains("IDCAMS") || screen.contains(" --- ")
|
||||
|| upper.contains("--RECFM-") || upper.contains("--VOLUMES--")) {
|
||||
c.ps.SendKeys("[enter]")
|
||||
Thread.sleep(100)
|
||||
waitForKeyboardUnlock(c, 1000)
|
||||
|
||||
val r2 = c.ps.rows
|
||||
val col2 = c.ps.cols
|
||||
val s2 = c.ps.copyString(0, 0, r2 - 1, col2 - 1)
|
||||
if (s2.uppercase(Locale.US).contains("***")) {
|
||||
prevScreen = s2
|
||||
allPages.append(s2).append("\n")
|
||||
c.ps.SendKeys("[enter]")
|
||||
Thread.sleep(100)
|
||||
waitForKeyboardUnlock(c, 2000)
|
||||
continue
|
||||
} else {
|
||||
if (s2 != screen && s2.isNotBlank()) {
|
||||
allPages.append(s2).append("\n")
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (c.ps != null) {
|
||||
if (isIspf) {
|
||||
c.ps.SendKeys("[enter]")
|
||||
Thread.sleep(150)
|
||||
waitForKeyboardUnlock(c, 1500)
|
||||
|
||||
val r = c.ps.rows
|
||||
val col = c.ps.cols
|
||||
val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
|
||||
if (currentScr.uppercase(Locale.US).contains("NONVSAM") || currentScr.uppercase(Locale.US).contains("IDCAMS")
|
||||
|| currentScr.contains(" --- ") || currentScr.uppercase(Locale.US).contains("--RECFM-")
|
||||
|| (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
|
||||
c.ps.SendKeys("[clear]")
|
||||
waitForKeyboardUnlock(c, 2000)
|
||||
}
|
||||
} else {
|
||||
c.ps.SendKeys("[clear]")
|
||||
waitForKeyboardUnlock(c, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val collectedText = allPages.toString()
|
||||
withContext(Dispatchers.Main) {
|
||||
parseTextData(collectedText, "host query")
|
||||
isLoading = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
statusMessage = "Host query failed: ${e.message}"
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun inspectSelectedDataset() {
|
||||
val selected = currentEntries.getOrNull(selectedIndex)
|
||||
if (selected == null || selected !is TSODirectoryEntry) {
|
||||
statusMessage = "Please select a TSO dataset entry to inspect."
|
||||
return
|
||||
}
|
||||
|
||||
val c = client
|
||||
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
|
||||
statusMessage = "No active session to query host."
|
||||
return
|
||||
}
|
||||
|
||||
val dsn = selected.datasetName
|
||||
if (dsn.isNullOrBlank()) return
|
||||
|
||||
statusMessage = "Inspecting $dsn via TSO LISTDS..."
|
||||
isLoading = true
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val output = StringBuilder()
|
||||
val rows = c.ps.rows
|
||||
val cols = c.ps.cols
|
||||
val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
|
||||
val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
|
||||
val hasCmdArrow = initialScreen.contains("===>")
|
||||
val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
|
||||
|
||||
val tsoCmd = "TSO LISTDS '$dsn' STATUS"
|
||||
|
||||
if (isIspf) {
|
||||
val arrowPos = initialScreen.indexOf("===>")
|
||||
if (arrowPos >= 0 && c.screenBuffer != null) {
|
||||
val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
|
||||
if (inputAddr > 0) {
|
||||
c.screenBuffer.cursorAddress = inputAddr
|
||||
c.screenBuffer.updateDisplaySnapshot()
|
||||
} else {
|
||||
c.ps.SendKeys("[home]")
|
||||
}
|
||||
} else {
|
||||
c.ps.SendKeys("[home]")
|
||||
}
|
||||
c.ps.SendKeys("[erase_eof]")
|
||||
c.ps.SendKeys("$tsoCmd[enter]")
|
||||
} else {
|
||||
c.ps.SendKeys("[clear]")
|
||||
waitForKeyboardUnlock(c, 3000)
|
||||
c.ps.SendKeys("$tsoCmd[enter]")
|
||||
}
|
||||
|
||||
Thread.sleep(100)
|
||||
|
||||
val startCmd = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - startCmd < 4000) {
|
||||
waitForKeyboardUnlock(c, 200)
|
||||
val r = c.ps.rows
|
||||
val col = c.ps.cols
|
||||
val s = c.ps.copyString(0, 0, r - 1, col - 1)
|
||||
val u = s.uppercase(Locale.US)
|
||||
if (u.contains("--RECFM-") || u.contains("--VOLUMES--") || u.contains("***")
|
||||
|| u.contains("READY") || u.contains("NOT IN CATALOG") || u.contains("NOT FOUND")) {
|
||||
break
|
||||
}
|
||||
Thread.sleep(80)
|
||||
}
|
||||
|
||||
var prevScreen = ""
|
||||
for (page in 0 until 5) {
|
||||
waitForKeyboardUnlock(c, 300)
|
||||
val r = c.ps.rows
|
||||
val col = c.ps.cols
|
||||
val screen = c.ps.copyString(0, 0, r - 1, col - 1)
|
||||
val upper = screen.uppercase(Locale.US)
|
||||
|
||||
if (screen == prevScreen || screen.isBlank()) {
|
||||
break
|
||||
}
|
||||
prevScreen = screen
|
||||
output.append(screen).append("\n")
|
||||
|
||||
if (upper.contains("***")) {
|
||||
c.ps.SendKeys("[enter]")
|
||||
Thread.sleep(100)
|
||||
waitForKeyboardUnlock(c, 2000)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (c.ps != null) {
|
||||
if (isIspf) {
|
||||
c.ps.SendKeys("[enter]")
|
||||
Thread.sleep(150)
|
||||
waitForKeyboardUnlock(c, 1500)
|
||||
|
||||
val r = c.ps.rows
|
||||
val col = c.ps.cols
|
||||
val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
|
||||
if (currentScr.uppercase(Locale.US).contains("--RECFM-") || (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
|
||||
c.ps.SendKeys("[clear]")
|
||||
waitForKeyboardUnlock(c, 2000)
|
||||
}
|
||||
} else {
|
||||
c.ps.SendKeys("[clear]")
|
||||
waitForKeyboardUnlock(c, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
val outText = output.toString()
|
||||
val parsed = TSODirectoryParser.parse(outText)
|
||||
withContext(Dispatchers.Main) {
|
||||
isLoading = false
|
||||
if (parsed.isNotEmpty()) {
|
||||
val updated = parsed[0]
|
||||
if (updated.recfm != null) selected.recfm = updated.recfm
|
||||
if (updated.lrecl > 0) selected.lrecl = updated.lrecl
|
||||
if (updated.blksize > 0) selected.blksize = updated.blksize
|
||||
if (updated.dsorg != null) selected.dsorg = updated.dsorg
|
||||
if (updated.volume != null && (selected.volume == null || selected.volume == "******" || selected.volume.isEmpty())) {
|
||||
selected.volume = updated.volume
|
||||
}
|
||||
if (updated.tracksAllocated > 0) selected.tracksAllocated = updated.tracksAllocated
|
||||
if (updated.extents > 0) selected.extents = updated.extents
|
||||
|
||||
statusMessage = "Attributes for $dsn: RECFM=${selected.recfm}, LRECL=${selected.lrecl}, BLKSIZE=${selected.blksize}, VOL=${selected.volume}"
|
||||
inspectionEntry = selected
|
||||
showInspectionDialog = true
|
||||
} else {
|
||||
statusMessage = "No VTOC attributes found for $dsn"
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
isLoading = false
|
||||
statusMessage = "Inspection failed: ${ex.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (client != null && client.connectionState.isFullSession) {
|
||||
queryHost()
|
||||
} else {
|
||||
parseScreen()
|
||||
}
|
||||
}
|
||||
|
||||
if (showInspectionDialog && inspectionEntry != null) {
|
||||
val entry = inspectionEntry!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { showInspectionDialog = false },
|
||||
title = { Text("Dataset Attributes (LISTDS)", fontWeight = FontWeight.Bold, fontSize = 16.sp) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Dataset: ${entry.datasetName}", fontWeight = FontWeight.Bold, fontSize = 13.sp, color = Color.White)
|
||||
HorizontalDivider(color = Color(0xFF373A40))
|
||||
Text("Volume: ${entry.volume ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
|
||||
Text("Record Format (RECFM): ${entry.recfm ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
|
||||
Text("Logical Record Length (LRECL): ${if (entry.lrecl > 0) entry.lrecl.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
|
||||
Text("Block Size (BLKSIZE): ${if (entry.blksize > 0) entry.blksize.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
|
||||
Text("Dataset Organization (DSORG): ${entry.dsorg ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
|
||||
if (entry.tracksAllocated > 0) {
|
||||
Text("Allocated Tracks: ${entry.tracksAllocated}", fontSize = 12.sp, color = Color.LightGray)
|
||||
}
|
||||
if (entry.extents > 0) {
|
||||
Text("Extents: ${entry.extents}", fontSize = 12.sp, color = Color.LightGray)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { showInspectionDialog = false }) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.heightIn(max = 680.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Header & Query Bar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Host Catalog & Directory Browser",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Row {
|
||||
FilterChip(
|
||||
selected = (hostType == FTConfig.HostType.TSO),
|
||||
onClick = {
|
||||
hostType = FTConfig.HostType.TSO
|
||||
if (query == "* * A" || query == "* * *") {
|
||||
query = ""
|
||||
}
|
||||
if (client != null && client.connectionState.isFullSession) {
|
||||
queryHost()
|
||||
} else {
|
||||
parseScreen()
|
||||
}
|
||||
},
|
||||
label = { Text("TSO", fontSize = 11.sp) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
FilterChip(
|
||||
selected = (hostType == FTConfig.HostType.CMS),
|
||||
onClick = {
|
||||
hostType = FTConfig.HostType.CMS
|
||||
if (query.isBlank() || query == "* * *" || query == "*") {
|
||||
query = "* * A"
|
||||
}
|
||||
if (client != null && client.connectionState.isFullSession) {
|
||||
queryHost()
|
||||
} else {
|
||||
parseScreen()
|
||||
}
|
||||
},
|
||||
label = { Text("CMS", fontSize = 11.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text("Filter / Pattern") },
|
||||
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* * A") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Search
|
||||
),
|
||||
keyboardActions = KeyboardActions(onSearch = {
|
||||
if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
|
||||
|| query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
|
||||
parseTextData(query, "query text")
|
||||
} else if (allEntries.isEmpty() && client != null && client.connectionState.isFullSession) {
|
||||
queryHost()
|
||||
}
|
||||
}),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
|
||||
|| query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
|
||||
parseTextData(query, "query text")
|
||||
} else {
|
||||
queryHost()
|
||||
}
|
||||
},
|
||||
enabled = !isLoading,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
|
||||
) {
|
||||
Text(if (isLoading) "Querying..." else "Query Host")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (statusMessage.isNotBlank()) statusMessage else "${currentEntries.size} entries shown (${allEntries.size} total).",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row {
|
||||
TextButton(onClick = { parseClipboard() }, enabled = !isLoading) {
|
||||
Text("Paste Clipboard", fontSize = 11.sp, color = Color(0xFF339AF0))
|
||||
}
|
||||
TextButton(onClick = { parseScreen() }, enabled = !isLoading) {
|
||||
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||
|
||||
// Table View
|
||||
if (currentEntries.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
if (isLoading) "Loading entries from host..." else "No entries found.",
|
||||
color = Color.Gray,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val horizScroll = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.horizontalScroll(horizScroll)
|
||||
) {
|
||||
// Header Row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF25262B))
|
||||
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (hostType == FTConfig.HostType.CMS) {
|
||||
Text("Filename", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(90.dp))
|
||||
Text("Filetype", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(80.dp))
|
||||
Text("Fm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text("Fmt", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
|
||||
Text("Records", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
|
||||
Text("Date / Time", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(140.dp))
|
||||
} else {
|
||||
Text("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(240.dp))
|
||||
Text("Volume", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(70.dp))
|
||||
Text("Recfm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
|
||||
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
|
||||
Text("Blksize", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
|
||||
Text("Tracks", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
|
||||
Text("Ext", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
// Entry Rows
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
itemsIndexed(currentEntries) { idx, item ->
|
||||
val isSelected = (idx == selectedIndex)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = if (isSelected) Color(0xFF1C7ED6).copy(alpha = 0.35f) else if (idx % 2 == 0) Color(0xFF161719) else Color(0xFF1E1F23),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { selectedIndex = idx }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 5.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (item is CMSDirectoryEntry) {
|
||||
Text(item.filename ?: "", color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
|
||||
Text(item.filetype ?: "", color = Color.Cyan, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(80.dp))
|
||||
Text(item.filemode ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text(item.recfm ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
Text(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
|
||||
Text(if (item.numRecords > 0) "${item.numRecords}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
|
||||
Text("${item.date ?: ""} ${item.time ?: ""}".trim(), color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp))
|
||||
} else if (item is TSODirectoryEntry) {
|
||||
Text(item.datasetName ?: "", color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(240.dp))
|
||||
Text(item.volume ?: "", color = Color.Cyan, fontSize = 11.sp, modifier = Modifier.width(70.dp))
|
||||
Text(item.recfm ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
|
||||
Text(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
|
||||
Text(if (item.blksize > 0) "${item.blksize}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
|
||||
Text(if (item.tracksAllocated > 0) "${item.tracksAllocated}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
|
||||
Text(if (item.extents > 0) "${item.extents}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row {
|
||||
if (hostType == FTConfig.HostType.TSO) {
|
||||
OutlinedButton(
|
||||
onClick = { inspectSelectedDataset() },
|
||||
enabled = selectedIndex in currentEntries.indices && !isLoading,
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text("Inspect (LISTDS)", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel", color = Color.LightGray)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (selectedIndex in currentEntries.indices) {
|
||||
val entry = currentEntries[selectedIndex]
|
||||
val selectedName = entry.name
|
||||
onSelectEntry?.invoke(selectedName, entry)
|
||||
onSelectDataset(selectedName)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
enabled = selectedIndex in currentEntries.indices,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||
) {
|
||||
Text("Select Dataset")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import java.util.Collections
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* Real-time hotspot and URL auto-detection engine matching IBM Host On-Demand
|
||||
* ShowURLOptions and ScreenText parity.
|
||||
*
|
||||
* Scans screen text using regular expressions for:
|
||||
* 1. URLs (http://, https://, ftp://, file://, mailto:, www.)
|
||||
* 2. Function keys (PF1..PF24, F1..F24, PA1..PA3, [ENTER], ENTER)
|
||||
* 3. Menu choices ("1.", "2.", "===> 3", "=> 4")
|
||||
*
|
||||
* Provides hit-testing for touch/mouse taps and generates keystroke actions.
|
||||
*/
|
||||
class HotspotEngine {
|
||||
|
||||
enum class HotspotType {
|
||||
URL,
|
||||
PF_KEY,
|
||||
ENTER,
|
||||
MENU_CHOICE
|
||||
}
|
||||
|
||||
data class Hotspot(
|
||||
val type: HotspotType,
|
||||
val startRow: Int,
|
||||
val startCol: Int,
|
||||
val endRow: Int,
|
||||
val endCol: Int,
|
||||
val displayText: String,
|
||||
val action: String,
|
||||
val tooltip: String
|
||||
) {
|
||||
val text: String get() = displayText
|
||||
/**
|
||||
* Test whether the given row/col coordinate falls within this hotspot.
|
||||
*/
|
||||
fun contains(row: Int, col: Int): Boolean {
|
||||
if (row < startRow || row > endRow) {
|
||||
return false
|
||||
}
|
||||
if (startRow == endRow) {
|
||||
return col in startCol..endCol
|
||||
}
|
||||
if (row == startRow) {
|
||||
return col >= startCol
|
||||
}
|
||||
if (row == endRow) {
|
||||
return col <= endCol
|
||||
}
|
||||
return true // Intermediate rows
|
||||
}
|
||||
}
|
||||
|
||||
var urlEnabled: Boolean = true
|
||||
set(value) {
|
||||
field = value
|
||||
invalidateCache()
|
||||
}
|
||||
|
||||
var pfKeysEnabled: Boolean = true
|
||||
set(value) {
|
||||
field = value
|
||||
invalidateCache()
|
||||
}
|
||||
|
||||
var menuChoicesEnabled: Boolean = true
|
||||
set(value) {
|
||||
field = value
|
||||
invalidateCache()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val URL_PATTERN = Pattern.compile(
|
||||
"(?:https?|ftp|file)://[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|www\\.[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|mailto:[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
)
|
||||
|
||||
private val PF_KEY_PATTERN = Pattern.compile(
|
||||
"(?:(?<=\\W)|^)(PF(?:[1-9]|1[0-9]|2[0-4])|F(?:[1-9]|1[0-9]|2[0-4])|PA[1-3]|\\[ENTER\\]|ENTER)(?:(?=\\W)|$)",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
)
|
||||
|
||||
private val MENU_NUMBER_PATTERN = Pattern.compile(
|
||||
"(?:(?<=\\s)|^)(\\d{1,2})\\.(?:(?=\\s)|$)"
|
||||
)
|
||||
|
||||
private val MENU_PROMPT_PATTERN = Pattern.compile(
|
||||
"(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)"
|
||||
)
|
||||
|
||||
private val 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/_-]*)"
|
||||
)
|
||||
|
||||
private fun markOccupied(arr: BooleanArray, start: Int, end: Int) {
|
||||
val s = start.coerceAtLeast(0)
|
||||
val e = end.coerceAtMost(arr.size)
|
||||
for (i in s until e) {
|
||||
arr[i] = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRangeOccupied(arr: BooleanArray, start: Int, end: Int): Boolean {
|
||||
val s = start.coerceAtLeast(0)
|
||||
val e = end.coerceAtMost(arr.size)
|
||||
for (i in s until e) {
|
||||
if (arr[i]) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedScreenText: String = ""
|
||||
private var cachedRows: Int = 0
|
||||
private var cachedCols: Int = 0
|
||||
private var cachedHotspots: List<Hotspot> = emptyList()
|
||||
|
||||
fun invalidateCache() {
|
||||
synchronized(this) {
|
||||
cachedScreenText = ""
|
||||
cachedHotspots = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the given ScreenBuffer for interactive hotspots.
|
||||
*/
|
||||
fun scan(sb: ScreenBuffer?): List<Hotspot> {
|
||||
if (sb == null) return emptyList()
|
||||
val rows = sb.displayRows
|
||||
val cols = sb.displayCols
|
||||
|
||||
val fullText = StringBuilder(rows * (cols + 1))
|
||||
for (r in 0 until rows) {
|
||||
for (c in 0 until cols) {
|
||||
val baddr = r * cols + c
|
||||
val ea = sb.getDisplayCell(baddr)
|
||||
val ch = ea?.ucs4 ?: ' '
|
||||
if (ea == null || ea.isFieldAttribute || ch <= ' ' || ch == '\u00FF') {
|
||||
fullText.append(' ')
|
||||
} else {
|
||||
fullText.append(ch)
|
||||
}
|
||||
}
|
||||
if (r < rows - 1) {
|
||||
fullText.append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return scanText(fullText.toString(), rows, cols)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan formatted screen text (lines separated by '\n') for hotspots.
|
||||
*/
|
||||
fun scanText(screenText: String?, rows: Int, cols: Int, enabled: Boolean = true): List<Hotspot> {
|
||||
if (!enabled || screenText == null || rows <= 0 || cols <= 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
synchronized(this) {
|
||||
if (screenText == cachedScreenText && rows == cachedRows && cols == cachedCols) {
|
||||
return cachedHotspots
|
||||
}
|
||||
|
||||
val hotspots = mutableListOf<Hotspot>()
|
||||
val lines = screenText.split("\n")
|
||||
val actualRows = minOf(rows, lines.size)
|
||||
|
||||
for (r in 0 until actualRows) {
|
||||
val line = lines[r]
|
||||
val occupied = BooleanArray(line.length + 1)
|
||||
|
||||
// 1. Scan URLs
|
||||
if (urlEnabled) {
|
||||
val mUrl = URL_PATTERN.matcher(line)
|
||||
while (mUrl.find()) {
|
||||
val startCol = mUrl.start()
|
||||
var endCol = mUrl.end()
|
||||
var match = mUrl.group()
|
||||
|
||||
while (match.length > 1) {
|
||||
val last = match[match.length - 1]
|
||||
if (last == '.' || last == ',' || last == ';' || last == ':' || last == ')' || last == ']' || last == '>') {
|
||||
match = match.substring(0, match.length - 1)
|
||||
endCol--
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (match.isEmpty()) continue
|
||||
|
||||
var actionUrl = match
|
||||
if (match.startsWith("www.", ignoreCase = true)) {
|
||||
actionUrl = "http://$match"
|
||||
}
|
||||
|
||||
val h = Hotspot(
|
||||
type = HotspotType.URL,
|
||||
startRow = r,
|
||||
startCol = startCol,
|
||||
endRow = r,
|
||||
endCol = endCol - 1,
|
||||
displayText = match,
|
||||
action = actionUrl,
|
||||
tooltip = "Open URL: $actionUrl"
|
||||
)
|
||||
hotspots.add(h)
|
||||
markOccupied(occupied, startCol, endCol)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan PF / Function keys
|
||||
if (pfKeysEnabled) {
|
||||
val mPf = PF_KEY_PATTERN.matcher(line)
|
||||
while (mPf.find()) {
|
||||
val startCol = mPf.start(1)
|
||||
val endCol = mPf.end(1)
|
||||
if (isRangeOccupied(occupied, startCol, endCol)) continue
|
||||
|
||||
val matchStr = mPf.group(1) ?: continue
|
||||
val keyText = matchStr.uppercase()
|
||||
val action: String
|
||||
val tooltip: String
|
||||
|
||||
if (keyText == "ENTER" || keyText == "[ENTER]") {
|
||||
action = "[enter]"
|
||||
tooltip = "Send [ENTER]"
|
||||
} else if (keyText.startsWith("PF")) {
|
||||
val numStr = keyText.substring(2)
|
||||
action = "[pf$numStr]"
|
||||
tooltip = "Send ${action.uppercase()}"
|
||||
} else if (keyText.startsWith("F")) {
|
||||
val numStr = keyText.substring(1)
|
||||
action = "[pf$numStr]"
|
||||
tooltip = "Send ${action.uppercase()}"
|
||||
} else if (keyText.startsWith("PA")) {
|
||||
val numStr = keyText.substring(2)
|
||||
action = "[pa$numStr]"
|
||||
tooltip = "Send ${action.uppercase()}"
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
val h = Hotspot(
|
||||
type = if (keyText.contains("ENTER")) HotspotType.ENTER else HotspotType.PF_KEY,
|
||||
startRow = r,
|
||||
startCol = startCol,
|
||||
endRow = r,
|
||||
endCol = endCol - 1,
|
||||
displayText = matchStr,
|
||||
action = action,
|
||||
tooltip = tooltip
|
||||
)
|
||||
hotspots.add(h)
|
||||
markOccupied(occupied, startCol, endCol)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scan Menu choices ("1.", "2.", "===> 3", "=> 4")
|
||||
if (menuChoicesEnabled) {
|
||||
val mNum = MENU_NUMBER_PATTERN.matcher(line)
|
||||
while (mNum.find()) {
|
||||
val startCol = mNum.start(1)
|
||||
val endCol = mNum.end()
|
||||
if (isRangeOccupied(occupied, startCol, endCol)) continue
|
||||
|
||||
val choiceNum = mNum.group(1)
|
||||
val action = "$choiceNum[enter]"
|
||||
val tooltip = "Select option $choiceNum"
|
||||
|
||||
val h = Hotspot(
|
||||
type = HotspotType.MENU_CHOICE,
|
||||
startRow = r,
|
||||
startCol = startCol,
|
||||
endRow = r,
|
||||
endCol = endCol - 1,
|
||||
displayText = "$choiceNum.",
|
||||
action = action,
|
||||
tooltip = tooltip
|
||||
)
|
||||
hotspots.add(h)
|
||||
markOccupied(occupied, startCol, endCol)
|
||||
}
|
||||
|
||||
val mPrompt = MENU_PROMPT_PATTERN.matcher(line)
|
||||
while (mPrompt.find()) {
|
||||
val startCol = mPrompt.start()
|
||||
val endCol = mPrompt.end()
|
||||
if (isRangeOccupied(occupied, startCol, endCol)) continue
|
||||
|
||||
val choiceNum = mPrompt.group(1)
|
||||
val action = "$choiceNum[enter]"
|
||||
val tooltip = "Execute choice $choiceNum"
|
||||
|
||||
val h = Hotspot(
|
||||
type = HotspotType.MENU_CHOICE,
|
||||
startRow = r,
|
||||
startCol = startCol,
|
||||
endRow = r,
|
||||
endCol = endCol - 1,
|
||||
displayText = mPrompt.group(),
|
||||
action = action,
|
||||
tooltip = tooltip
|
||||
)
|
||||
hotspots.add(h)
|
||||
markOccupied(occupied, startCol, endCol)
|
||||
}
|
||||
|
||||
// 3c. Scan ISPF Menu choices (e.g. " 1 View", " 2 Edit", "10 SCLM", " X Exit", " M More", " 1 - View", " 3.1 Library")
|
||||
val mIspf = ISPF_MENU_PATTERN.matcher(line)
|
||||
while (mIspf.find()) {
|
||||
val choiceStr = mIspf.group(1) ?: mIspf.group(2)?.uppercase() ?: continue
|
||||
val labelStr = mIspf.group(3) ?: continue
|
||||
val startCol = if (mIspf.group(1) != null) mIspf.start(1) else mIspf.start(2)
|
||||
val endCol = mIspf.end(3)
|
||||
if (isRangeOccupied(occupied, startCol, endCol)) continue
|
||||
|
||||
val action = "$choiceStr[enter]"
|
||||
val tooltip = "Select option $choiceStr ($labelStr)"
|
||||
val display = line.substring(startCol, endCol)
|
||||
|
||||
val h = Hotspot(
|
||||
type = HotspotType.MENU_CHOICE,
|
||||
startRow = r,
|
||||
startCol = startCol,
|
||||
endRow = r,
|
||||
endCol = endCol - 1,
|
||||
displayText = display,
|
||||
action = action,
|
||||
tooltip = tooltip
|
||||
)
|
||||
hotspots.add(h)
|
||||
markOccupied(occupied, startCol, endCol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedScreenText = screenText
|
||||
this.cachedRows = rows
|
||||
this.cachedCols = cols
|
||||
this.cachedHotspots = Collections.unmodifiableList(hotspots)
|
||||
|
||||
return cachedHotspots
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit-test for a hotspot at the specified terminal row and column.
|
||||
*/
|
||||
fun findHotspot(row: Int, col: Int): Hotspot? {
|
||||
synchronized(this) {
|
||||
for (h in cachedHotspots) {
|
||||
if (h.contains(row, col)) {
|
||||
return h
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun getHotspots(): List<Hotspot> {
|
||||
synchronized(this) {
|
||||
return cachedHotspots
|
||||
}
|
||||
}
|
||||
|
||||
fun findHotspotAt(hotspots: List<Hotspot>, row: Int, col: Int): Hotspot? {
|
||||
for (h in hotspots) {
|
||||
if (h.contains(row, col)) {
|
||||
return h
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import haus.nightmare.lib3270j.Telnet3270Client
|
||||
import haus.nightmare.lib3270j.macro.Macro
|
||||
import haus.nightmare.lib3270j.macro.MacroScreen
|
||||
import haus.nightmare.lib3270j.macro.action.MacroAction
|
||||
import haus.nightmare.lib3270j.macro.action.MacroActionPrompt
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val DEFAULT_MACRO_XML = """<HASCRIPT name="SampleLogon" timeout="15000" usevars="true">
|
||||
<vars>
|
||||
<create name="${'$'}userId" type="string" value="USER01" />
|
||||
<create name="${'$'}count" type="integer" value="0" />
|
||||
</vars>
|
||||
|
||||
<screen name="LogonScreen" entryscreen="true">
|
||||
<description>
|
||||
<oia status="NOTINHIBITED" />
|
||||
</description>
|
||||
<actions>
|
||||
<input value="${'$'}userId + '[enter]'" movecursor="true" xlatehostkeys="true" />
|
||||
</actions>
|
||||
<nextscreens timeout="10000">
|
||||
<nextscreen name="ReadyScreen" />
|
||||
</nextscreens>
|
||||
</screen>
|
||||
|
||||
<screen name="ReadyScreen" exitscreen="true">
|
||||
<description>
|
||||
<oia status="NOTINHIBITED" />
|
||||
</description>
|
||||
<actions>
|
||||
<message title="Success" value="Logon macro completed successfully!" />
|
||||
</actions>
|
||||
</screen>
|
||||
</HASCRIPT>"""
|
||||
|
||||
data class MacroPromptState(
|
||||
val prompt: MacroActionPrompt,
|
||||
val deferred: CompletableDeferred<String?>
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MacroDialog(
|
||||
client: Telnet3270Client?,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var macroXml by remember { mutableStateOf(DEFAULT_MACRO_XML) }
|
||||
var executionLog by remember { mutableStateOf("") }
|
||||
var statusText by remember { mutableStateOf("Status: Stopped") }
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var isPaused by remember { mutableStateOf(false) }
|
||||
var activePrompt by remember { mutableStateOf<MacroPromptState?>(null) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val macroRef = remember { AtomicReference<Macro?>(null) }
|
||||
|
||||
fun appendLog(msg: String) {
|
||||
val line = "[${java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US).format(java.util.Date())}] $msg\n"
|
||||
executionLog += line
|
||||
}
|
||||
|
||||
DisposableEffect(client) {
|
||||
val macro = if (client != null) Macro(client) else null
|
||||
macroRef.set(macro)
|
||||
|
||||
if (macro != null) {
|
||||
val listener = object : Macro.MacroListener {
|
||||
override fun macroStateChanged(m: Macro, oldState: Int, newState: Int) {
|
||||
when (newState) {
|
||||
Macro.STATE_PLAYING -> {
|
||||
isPlaying = true
|
||||
isPaused = false
|
||||
statusText = "Status: Playing"
|
||||
appendLog("Macro playing...")
|
||||
}
|
||||
Macro.STATE_PAUSED -> {
|
||||
isPaused = true
|
||||
statusText = "Status: Paused"
|
||||
appendLog("Macro paused.")
|
||||
}
|
||||
Macro.STATE_ERROR -> {
|
||||
isPlaying = false
|
||||
isPaused = false
|
||||
statusText = "Status: Error"
|
||||
appendLog("Macro error.")
|
||||
}
|
||||
Macro.STATE_STOPPED -> {
|
||||
isPlaying = false
|
||||
isPaused = false
|
||||
statusText = "Status: Stopped"
|
||||
appendLog("Macro stopped.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun macroScreenMatching(screen: MacroScreen) {
|
||||
appendLog("Matched screen: [${screen.name}]")
|
||||
}
|
||||
|
||||
override fun macroActionExecuting(action: MacroAction) {
|
||||
appendLog(" -> Executing: ${action.javaClass.simpleName}")
|
||||
}
|
||||
|
||||
override fun macroMessage(title: String, message: String) {
|
||||
appendLog("MESSAGE [$title]: $message")
|
||||
}
|
||||
|
||||
override fun macroError(error: String) {
|
||||
isPlaying = false
|
||||
isPaused = false
|
||||
statusText = "Status: Error - $error"
|
||||
appendLog("ERROR: $error")
|
||||
}
|
||||
}
|
||||
|
||||
macro.addMacroListener(listener)
|
||||
macro.setPromptHandler { prompt ->
|
||||
val deferred = CompletableDeferred<String?>()
|
||||
activePrompt = MacroPromptState(prompt, deferred)
|
||||
kotlinx.coroutines.runBlocking {
|
||||
deferred.await() ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
macro?.stop()
|
||||
macroRef.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
if (!isPlaying) onDismiss()
|
||||
},
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.fillMaxHeight(0.90f)
|
||||
.padding(vertical = 10.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"XML Macro Runtime (<HASCRIPT>)",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
statusText,
|
||||
fontSize = 11.sp,
|
||||
color = if (isPlaying) Color(0xFF51CF66) else Color.LightGray
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||
|
||||
// Macro XML Editor Pane
|
||||
Text("Macro Definition XML:", fontSize = 12.sp, color = Color.LightGray)
|
||||
OutlinedTextField(
|
||||
value = macroXml,
|
||||
onValueChange = { macroXml = it },
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = Color.White
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.55f),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color(0xFF339AF0),
|
||||
unfocusedBorderColor = Color(0xFF495057),
|
||||
focusedContainerColor = Color(0xFF121212),
|
||||
unfocusedContainerColor = Color(0xFF121212)
|
||||
)
|
||||
)
|
||||
|
||||
// Execution Log Pane
|
||||
Text("Execution Log:", fontSize = 12.sp, color = Color.LightGray)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.45f)
|
||||
.background(Color(0xFF0F0F0F), RoundedCornerShape(6.dp))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
val logScroll = rememberScrollState()
|
||||
Text(
|
||||
text = executionLog.ifBlank { "Ready to execute macro..." },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
color = if (executionLog.isBlank()) Color.Gray else Color(0xFF69DB7C),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(logScroll)
|
||||
)
|
||||
}
|
||||
|
||||
// Control Buttons: Play, Pause, Stop, Close
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
val macro = macroRef.get()
|
||||
if (macro != null) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val screens = haus.nightmare.lib3270j.macro.MacroXmlParser.parse(macroXml)
|
||||
macro.setScreens(screens)
|
||||
macro.setClient(client)
|
||||
macro.play()
|
||||
} catch (ex: Exception) {
|
||||
appendLog("Execution exception: ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isPlaying && client != null && client.connectionState?.isFullSession == true,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("▶ Play", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val macro = macroRef.get()
|
||||
if (macro != null) {
|
||||
if (macro.state == Macro.STATE_PLAYING) {
|
||||
macro.pause()
|
||||
} else if (macro.state == Macro.STATE_PAUSED) {
|
||||
macro.resume()
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = isPlaying,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE67700)),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(if (isPaused) "Resume" else "Pause", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val macro = macroRef.get()
|
||||
macro?.stop()
|
||||
},
|
||||
enabled = isPlaying,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("■ Stop", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val macro = macroRef.get()
|
||||
macro?.stop()
|
||||
onDismiss()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF495057)),
|
||||
modifier = Modifier.width(75.dp)
|
||||
) {
|
||||
Text("Close", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modal prompt dialog when macro requests <prompt> parameter input
|
||||
val promptState = activePrompt
|
||||
if (promptState != null) {
|
||||
var inputValue by remember { mutableStateOf(promptState.prompt.defaultValue ?: "") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
promptState.deferred.complete(null)
|
||||
activePrompt = null
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = promptState.prompt.title.ifBlank { "Macro Input Prompt" },
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = promptState.prompt.description.ifBlank { "Enter parameter:" },
|
||||
fontSize = 13.sp,
|
||||
color = Color.LightGray
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = inputValue,
|
||||
onValueChange = { inputValue = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
promptState.deferred.complete(inputValue)
|
||||
activePrompt = null
|
||||
}
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
promptState.deferred.complete(null)
|
||||
activePrompt = null
|
||||
}
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import haus.nightmare.lib3270j.ConnectionState
|
||||
|
||||
@Composable
|
||||
fun OiaStatusBar(
|
||||
connectionState: ConnectionState,
|
||||
currentHost: String,
|
||||
isKeyboardLocked: Boolean,
|
||||
oiaText: String,
|
||||
cursorAddr: Int,
|
||||
rows: Int,
|
||||
cols: Int,
|
||||
isTls: Boolean = false,
|
||||
isTlsVerified: Boolean = true,
|
||||
graphicsMode: String = "NONE",
|
||||
isLightPenMode: Boolean = false,
|
||||
isInsertMode: Boolean = false,
|
||||
isDocMode: Boolean = false,
|
||||
isWordWrap: Boolean = false,
|
||||
isAplMode: Boolean = false,
|
||||
onToggleInsert: (() -> Unit)? = null,
|
||||
onToggleDocMode: (() -> Unit)? = null,
|
||||
onToggleWordWrap: (() -> Unit)? = null,
|
||||
onToggleAplMode: (() -> Unit)? = null,
|
||||
inhibitReason: Int = 0,
|
||||
luName: String = "",
|
||||
isNumericField: Boolean = false,
|
||||
fieldStatus: String = "",
|
||||
uiTheme: String = "DARK",
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isLight = uiTheme.equals("LIGHT", ignoreCase = true)
|
||||
val barBg = if (isLight) Color(0xFFE9ECEF) else Color(0xFF161719)
|
||||
val defaultTextClr = if (isLight) Color(0xFF212529) else Color.White
|
||||
val sysAvailClr = if (isLight) Color(0xFF005AC8) else Color(0xFF7890F0)
|
||||
val inputInhibitedClr = if (isLight) Color(0xFF141414) else Color(0xFFFFFFFF)
|
||||
val attentionClr = if (isLight) Color(0xFFB46400) else Color(0xFFFFFF00)
|
||||
val commCheckClr = if (isLight) Color(0xFFBE1414) else Color(0xFFFF0000)
|
||||
|
||||
val row = if (cols > 0 && rows > 0) ((cursorAddr / cols) % rows) + 1 else 1
|
||||
val col = if (cols > 0) (cursorAddr % cols) + 1 else 1
|
||||
val posStr = String.format("%03d/%03d [%04d]", row, col, cursorAddr)
|
||||
|
||||
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
|
||||
|
||||
val connTypeBadge = when (connectionState) {
|
||||
ConnectionState.CONNECTED_3270 -> "TN3270"
|
||||
ConnectionState.CONNECTED_TN3270E -> "TN3270E"
|
||||
ConnectionState.CONNECTED_SSCP -> "SSCP-LU"
|
||||
ConnectionState.CONNECTED_NVT, ConnectionState.CONNECTED_NVT_CHAR, ConnectionState.CONNECTED_E_NVT -> "NVT"
|
||||
ConnectionState.CONNECTED_UNBOUND -> "UNBOUND"
|
||||
ConnectionState.TCP_PENDING, ConnectionState.TELNET_PENDING -> "CONNECTING"
|
||||
ConnectionState.NOT_CONNECTED -> "OFFLINE"
|
||||
else -> connectionState.name
|
||||
}
|
||||
|
||||
val (lockStatusText, lockStatusColor) = if (!connectionState.isConnected()) {
|
||||
"OFFLINE" to Color(0xFF868E96)
|
||||
} else if (inhibitReason != 0) {
|
||||
val txt = when (inhibitReason) {
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_SYSTEM_LOCK -> "X SYSTEM"
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> "X COMM"
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> "X NUM"
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_PROTECTED_FIELD -> "X PROT"
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> "X >"
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> "X OP"
|
||||
else -> "X LOCKED"
|
||||
}
|
||||
val clr = when (inhibitReason) {
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> commCheckClr
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> attentionClr
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> attentionClr
|
||||
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> attentionClr
|
||||
else -> inputInhibitedClr
|
||||
}
|
||||
txt to clr
|
||||
} else if (isKeyboardLocked) {
|
||||
"X SYSTEM" to inputInhibitedClr
|
||||
} else {
|
||||
"READY" to sysAvailClr
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(26.dp)
|
||||
.background(barBg)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Connected Host & Status Indicator
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(if (connectionState.isConnected()) Color(0xFF7890F0) else Color(0xFF868E96))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = hostText,
|
||||
color = defaultTextClr,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
// TLS Badge
|
||||
if (connectionState.isConnected() && isTls) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = if (isTlsVerified) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFFB450).copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isTlsVerified) "🔒 TLS" else "🔓 TLS",
|
||||
color = if (isTlsVerified) Color(0xFF51CF66) else Color(0xFFFFB450),
|
||||
fontSize = 9.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GFX Mode Badge
|
||||
if (connectionState.isConnected() && graphicsMode != "NONE" && graphicsMode.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = Color(0xFF22B8CF).copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = when (graphicsMode) {
|
||||
"VECTOR_GRAPHICS" -> "GOCA"
|
||||
"PROGRAMMED_SYMBOLS" -> "PS"
|
||||
else -> "GFX"
|
||||
},
|
||||
color = Color(0xFF22B8CF),
|
||||
fontSize = 9.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Field Status: DBCS vs NUM vs ALPHA
|
||||
if (connectionState.isConnected() && rows > 0 && cols > 0) {
|
||||
val effectiveFieldStatus = if (fieldStatus.isNotBlank()) fieldStatus else (if (isNumericField) "NUM" else "ALPHA")
|
||||
val badgeColor = when (effectiveFieldStatus) {
|
||||
"DBCS" -> sysAvailClr
|
||||
"NUM" -> Color(0xFFFFD43B)
|
||||
else -> Color(0xFF74C0FC)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = badgeColor.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = effectiveFieldStatus,
|
||||
color = badgeColor,
|
||||
fontSize = 9.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Light Pen Badge
|
||||
if (isLightPenMode) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = Color(0xFFFFFF50).copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "LP",
|
||||
color = Color(0xFFFFFF50),
|
||||
fontSize = 9.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Connection Type Badge (TN3270, TN3270E, SSCP, NVT)
|
||||
if (connectionState.isConnected()) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = Color(0xFF7950F2).copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = connTypeBadge,
|
||||
color = Color(0xFFB197FC),
|
||||
fontSize = 9.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// LU Name Badge
|
||||
if (connectionState.isConnected() && luName.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = Color(0xFF40C057).copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "LU:$luName",
|
||||
color = Color(0xFF69DB7C),
|
||||
fontSize = 9.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Operational Mode Badges (^ INS, APL, DOC, V)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
) {
|
||||
if (connectionState.isConnected() && isInsertMode) {
|
||||
Surface(
|
||||
color = Color(0xFF7890F0).copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = Modifier.then(if (onToggleInsert != null) Modifier.clickable { onToggleInsert() } else Modifier)
|
||||
) {
|
||||
Text(
|
||||
text = "^ INS",
|
||||
color = Color(0xFF7890F0),
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (connectionState.isConnected() && isAplMode) {
|
||||
Surface(
|
||||
color = Color(0xFFFF922B).copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = Modifier.then(if (onToggleAplMode != null) Modifier.clickable { onToggleAplMode() } else Modifier)
|
||||
) {
|
||||
Text(
|
||||
text = "APL",
|
||||
color = Color(0xFFFFA94D),
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (connectionState.isConnected() && isDocMode) {
|
||||
Surface(
|
||||
color = Color(0xFF20C997).copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = Modifier.then(if (onToggleDocMode != null) Modifier.clickable { onToggleDocMode() } else Modifier)
|
||||
) {
|
||||
Text(
|
||||
text = "DOC",
|
||||
color = Color(0xFF38D9A9),
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (connectionState.isConnected() && isWordWrap) {
|
||||
Surface(
|
||||
color = Color(0xFF20C997).copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = Modifier.then(if (onToggleWordWrap != null) Modifier.clickable { onToggleWordWrap() } else Modifier)
|
||||
) {
|
||||
Text(
|
||||
text = "V",
|
||||
color = Color(0xFF38D9A9),
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
|
||||
Surface(
|
||||
color = lockStatusColor.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = lockStatusText,
|
||||
color = lockStatusColor,
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Cursor Position & Buffer Address
|
||||
Text(
|
||||
text = posStr,
|
||||
color = if (isLight) Color(0xFF0C8599) else Color(0xFF22B8CF),
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Environment
|
||||
import android.print.PrintAttributes
|
||||
import android.print.PrintDocumentAdapter
|
||||
import android.print.PrintManager
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import haus.nightmare.lib3270j.printer.*
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun PrinterSessionDialog(
|
||||
initialHost: String = "",
|
||||
initialPort: Int = 0,
|
||||
initialCodePage: String = "037",
|
||||
initialUseTls: Boolean = false,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
var host by remember { mutableStateOf(initialHost) }
|
||||
var portStr by remember { mutableStateOf(if (initialPort > 0) initialPort.toString() else "") }
|
||||
var printerLu by remember { mutableStateOf("") }
|
||||
var assocDisplayLu by remember { mutableStateOf("") }
|
||||
var codePage by remember { mutableStateOf(initialCodePage) }
|
||||
var useTls by remember { mutableStateOf(initialUseTls) }
|
||||
var pdtType by remember { mutableStateOf("DEFAULT") }
|
||||
|
||||
var isConnected by remember { mutableStateOf(false) }
|
||||
var sessionStatus by remember { mutableStateOf("Disconnected") }
|
||||
var spoolText by remember { mutableStateOf("") }
|
||||
var pageCount by remember { mutableIntStateOf(0) }
|
||||
var byteCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
var printerClient by remember { mutableStateOf<Telnet3270EPClient?>(null) }
|
||||
|
||||
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
|
||||
|
||||
fun startSession() {
|
||||
val targetHost = host.trim().ifBlank { "127.0.0.1" }
|
||||
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
|
||||
val config = PrinterConfig(targetHost, port).apply {
|
||||
this.isUseTls = useTls
|
||||
this.codePage = codePage
|
||||
if (printerLu.isNotBlank()) this.printerLuName = printerLu.trim()
|
||||
if (assocDisplayLu.isNotBlank()) this.associatedDisplayLuName = assocDisplayLu.trim()
|
||||
this.destinationType = PrinterConfig.DestinationType.MEMORY
|
||||
|
||||
val pdt = when (pdtType) {
|
||||
"PCL5" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPcl5PDT()
|
||||
"EPSON_ESC_P" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createEpsonEscPPDT()
|
||||
"POSTSCRIPT" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPostScriptPDT()
|
||||
else -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPlainTextPDT()
|
||||
}
|
||||
if (pdt != null) {
|
||||
this.printerDefinitionTable = pdt
|
||||
}
|
||||
}
|
||||
|
||||
val client = Telnet3270EPClient(config)
|
||||
client.addPrintListener(object : PrintSessionListener {
|
||||
override fun onPrintJobStarted(event: PrintSessionEvent) {
|
||||
sessionStatus = "Printing job: ${event.jobName ?: "Job"}"
|
||||
}
|
||||
|
||||
override fun onPrintJobData(event: PrintSessionEvent) {
|
||||
val captured = client.pd?.capturedText
|
||||
if (captured != null) spoolText = captured
|
||||
byteCount = event.byteCount.toInt()
|
||||
}
|
||||
|
||||
override fun onPrintJobPageComplete(event: PrintSessionEvent) {
|
||||
pageCount = event.pageNumber
|
||||
byteCount = event.byteCount.toInt()
|
||||
val captured = client.pd?.capturedText
|
||||
if (captured != null) spoolText = captured
|
||||
}
|
||||
|
||||
override fun onPrintJobComplete(event: PrintSessionEvent) {
|
||||
pageCount = event.totalPages
|
||||
byteCount = event.byteCount.toInt()
|
||||
val captured = client.pd?.capturedText
|
||||
if (captured != null) spoolText = captured
|
||||
sessionStatus = "Print Job Complete"
|
||||
}
|
||||
|
||||
override fun onPrinterStatusChanged(event: PrintSessionEvent) {
|
||||
val status = event.statusCode
|
||||
isConnected = (status == PrinterConstants.STATUS_CONNECTED || status == PrinterConstants.STATUS_PRINTING)
|
||||
sessionStatus = when (status) {
|
||||
PrinterConstants.STATUS_CONNECTED -> "Connected"
|
||||
PrinterConstants.STATUS_CONNECTING -> "Connecting..."
|
||||
PrinterConstants.STATUS_PRINTING -> "Printing..."
|
||||
else -> "Disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrinterError(event: PrintSessionEvent) {
|
||||
sessionStatus = "Error: ${event.message ?: "Code ${event.statusCode}"}"
|
||||
}
|
||||
})
|
||||
|
||||
printerClient = client
|
||||
sessionStatus = "Connecting..."
|
||||
val ok = client.connect()
|
||||
if (!ok) {
|
||||
sessionStatus = "Failed to initiate printer connection."
|
||||
}
|
||||
}
|
||||
|
||||
fun stopSession() {
|
||||
printerClient?.disconnect()
|
||||
printerClient = null
|
||||
isConnected = false
|
||||
sessionStatus = "Disconnected"
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
printerClient?.disconnect()
|
||||
printerClient = null
|
||||
}
|
||||
}
|
||||
|
||||
fun printViaAndroidService() {
|
||||
if (spoolText.isBlank()) {
|
||||
Toast.makeText(context, "No printer spool to print.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val printManager = context.getSystemService(Context.PRINT_SERVICE) as? PrintManager
|
||||
if (printManager != null) {
|
||||
val webView = WebView(context)
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
val printAdapter: PrintDocumentAdapter = webView.createPrintDocumentAdapter("3287_Print_Job")
|
||||
printManager.print("3287 Print Job", printAdapter, PrintAttributes.Builder().build())
|
||||
}
|
||||
}
|
||||
val htmlDoc = "<html><body style='font-family:monospace; font-size:12px; white-space:pre;'>${spoolText.replace("<", "<").replace(">", ">")}</body></html>"
|
||||
webView.loadDataWithBaseURL(null, htmlDoc, "text/html", "UTF-8", null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "Print failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSpoolToFile() {
|
||||
if (spoolText.isBlank()) {
|
||||
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
if (!downloadsDir.exists()) downloadsDir.mkdirs()
|
||||
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val file = File(downloadsDir, "3287_spool_$timeStamp.txt")
|
||||
file.writeText(spoolText)
|
||||
Toast.makeText(context, "Saved to Downloads/${file.name}", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "Save failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun shareSpool() {
|
||||
if (spoolText.isBlank()) {
|
||||
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_SUBJECT, "3287 Printer Spool")
|
||||
putExtra(Intent.EXTRA_TEXT, spoolText)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Share 3287 Spool"))
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.heightIn(max = 700.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"IBM 3287 Printer Session",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
"TN3270E SCS / 3270 DS Print Emulation",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
color = if (isConnected) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFF6B6B).copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = sessionStatus,
|
||||
color = if (isConnected) Color(0xFF51CF66) else Color(0xFFFF6B6B),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||
|
||||
// Config Row 1: Host & Port
|
||||
if (!isConnected) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host") },
|
||||
placeholder = { Text("127.0.0.1", color = Color.Gray) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(2.5f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = portStr,
|
||||
onValueChange = { portStr = it },
|
||||
label = { Text("Port") },
|
||||
placeholder = { Text(if (useTls) "992" else "23", color = Color.Gray) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Config Row 2: Printer LU & Assoc Display LU
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = printerLu,
|
||||
onValueChange = { printerLu = it },
|
||||
label = { Text("Printer LU (Opt)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = assocDisplayLu,
|
||||
onValueChange = { assocDisplayLu = it },
|
||||
label = { Text("Display LU (Opt)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// PDT, Code Page & TLS Row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
var pdtExpanded by remember { mutableStateOf(false) }
|
||||
val pdtList = listOf(
|
||||
"DEFAULT" to "Plain Text",
|
||||
"PCL5" to "HP PCL 5/6",
|
||||
"EPSON_ESC_P" to "Epson ESC/P",
|
||||
"POSTSCRIPT" to "PostScript"
|
||||
)
|
||||
val selectedPdtLabel = pdtList.firstOrNull { it.first == pdtType }?.second ?: "PDT"
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
OutlinedButton(
|
||||
onClick = { pdtExpanded = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("PDT: $selectedPdtLabel", fontSize = 10.sp, maxLines = 1)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = pdtExpanded,
|
||||
onDismissRequest = { pdtExpanded = false },
|
||||
modifier = Modifier.background(Color(0xFF2C2D30))
|
||||
) {
|
||||
pdtList.forEach { (type, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontSize = 12.sp, color = Color.White) },
|
||||
onClick = {
|
||||
pdtType = type
|
||||
pdtExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cpExpanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
OutlinedButton(
|
||||
onClick = { cpExpanded = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("CP$codePage", fontSize = 10.sp, maxLines = 1)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = cpExpanded,
|
||||
onDismissRequest = { cpExpanded = false },
|
||||
modifier = Modifier.background(Color(0xFF2C2D30)).heightIn(max = 240.dp)
|
||||
) {
|
||||
codePagesList.forEach { (cpId, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontSize = 12.sp, color = Color.White) },
|
||||
onClick = {
|
||||
codePage = cpId
|
||||
cpExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { useTls = !useTls }
|
||||
) {
|
||||
Checkbox(checked = useTls, onCheckedChange = { useTls = it })
|
||||
Text("TLS", fontSize = 11.sp, color = Color.White)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { startSession() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||
) {
|
||||
Text("Connect", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Pages: $pageCount | Bytes: $byteCount",
|
||||
fontSize = 12.sp,
|
||||
color = Color.LightGray,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
Button(
|
||||
onClick = { stopSession() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A))
|
||||
) {
|
||||
Text("Disconnect Printer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||
|
||||
// Spool Display Box
|
||||
Text("Print Spool Buffer:", fontSize = 12.sp, color = Color.Gray)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
|
||||
.padding(10.dp)
|
||||
) {
|
||||
if (spoolText.isBlank()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No printed output spooled yet.", color = Color.DarkGray, fontSize = 12.sp)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = spoolText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFFA5D6A7),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Spool Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close", color = Color.LightGray)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
spoolText = ""
|
||||
pageCount = 0
|
||||
byteCount = 0
|
||||
printerClient?.pd?.resetCapture()
|
||||
}
|
||||
) {
|
||||
Text("Clear", fontSize = 11.sp)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(spoolText))
|
||||
Toast.makeText(context, "Copied spool to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("Copy", fontSize = 11.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { saveSpoolToFile() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
|
||||
) {
|
||||
Text("Save", fontSize = 11.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { printViaAndroidService() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||
) {
|
||||
Text("Print 🖨️", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.FileProvider
|
||||
import haus.nightmare.lib3270j.graphics.GraphicsPlane
|
||||
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ScreenExporterDialog(
|
||||
screenBuffer: ScreenBuffer?,
|
||||
rows: Int,
|
||||
cols: Int,
|
||||
programSymbolManager: ProgramSymbolManager? = null,
|
||||
graphicsPlane: GraphicsPlane? = null,
|
||||
maskHidden: Boolean = true,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
var exportFormat by remember { mutableStateOf("TEXT") } // TEXT, HTML, PNG
|
||||
var statusMessage by remember { mutableStateOf("") }
|
||||
|
||||
val plainTextContent = remember(screenBuffer) {
|
||||
generatePlainText(screenBuffer, rows, cols, maskHidden)
|
||||
}
|
||||
|
||||
val htmlContent = remember(screenBuffer) {
|
||||
generateHtml(screenBuffer, rows, cols, maskHidden)
|
||||
}
|
||||
|
||||
fun shareText(text: String, isHtml: Boolean) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = if (isHtml) "text/html" else "text/plain"
|
||||
putExtra(Intent.EXTRA_SUBJECT, "3270 Screen Capture")
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Share 3270 Screen"))
|
||||
} catch (e: Exception) {
|
||||
statusMessage = "Share failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
fun sharePng(bmp: Bitmap) {
|
||||
try {
|
||||
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
|
||||
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val file = File(cacheDir, "screen_$timeStamp.png")
|
||||
FileOutputStream(file).use { out ->
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
}
|
||||
val uri: Uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "image/png"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Share Screen PNG"))
|
||||
} catch (e: Exception) {
|
||||
statusMessage = "PNG Share failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
fun saveFileToStorage(format: String): String {
|
||||
return try {
|
||||
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
if (!downloadsDir.exists()) downloadsDir.mkdirs()
|
||||
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
when (format) {
|
||||
"HTML" -> {
|
||||
val file = File(downloadsDir, "3270_screen_$timeStamp.html")
|
||||
file.writeText(htmlContent)
|
||||
"Saved to Downloads/${file.name}"
|
||||
}
|
||||
"PNG" -> {
|
||||
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
|
||||
val file = File(downloadsDir, "3270_screen_$timeStamp.png")
|
||||
FileOutputStream(file).use { out ->
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
}
|
||||
"Saved to Downloads/${file.name}"
|
||||
}
|
||||
else -> {
|
||||
val file = File(downloadsDir, "3270_screen_$timeStamp.txt")
|
||||
file.writeText(plainTextContent)
|
||||
"Saved to Downloads/${file.name}"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"Save failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.heightIn(max = 680.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Export / Share Screen",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Row {
|
||||
FilterChip(
|
||||
selected = (exportFormat == "TEXT"),
|
||||
onClick = { exportFormat = "TEXT" },
|
||||
label = { Text("Plain Text", fontSize = 11.sp) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
FilterChip(
|
||||
selected = (exportFormat == "HTML"),
|
||||
onClick = { exportFormat = "HTML" },
|
||||
label = { Text("HTML", fontSize = 11.sp) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
FilterChip(
|
||||
selected = (exportFormat == "PNG"),
|
||||
onClick = { exportFormat = "PNG" },
|
||||
label = { Text("PNG", fontSize = 11.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||
|
||||
// Preview Box
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
|
||||
.padding(10.dp)
|
||||
) {
|
||||
if (exportFormat == "PNG") {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("🖼️ PNG Image Export", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text("Full resolution bitmap with 3270 styling and graphics plane.", fontSize = 12.sp, color = Color.LightGray)
|
||||
}
|
||||
} else {
|
||||
val textToShow = if (exportFormat == "HTML") htmlContent else plainTextContent
|
||||
Text(
|
||||
text = textToShow,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
color = Color(0xFFA5D6A7),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (statusMessage.isNotBlank()) {
|
||||
Text(
|
||||
text = statusMessage,
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFF51CF66)
|
||||
)
|
||||
}
|
||||
|
||||
// Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close", color = Color.LightGray)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (exportFormat != "PNG") {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val content = if (exportFormat == "HTML") htmlContent else plainTextContent
|
||||
clipboardManager.setText(AnnotatedString(content))
|
||||
statusMessage = "Copied to clipboard."
|
||||
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("Copy", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val msg = saveFileToStorage(exportFormat)
|
||||
statusMessage = msg
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
|
||||
) {
|
||||
Text("Save File", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (exportFormat) {
|
||||
"PNG" -> {
|
||||
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
|
||||
sharePng(bmp)
|
||||
}
|
||||
"HTML" -> shareText(htmlContent, true)
|
||||
else -> shareText(plainTextContent, false)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||
) {
|
||||
Text("Share 📤", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
|
||||
if (screenBuffer == null) return ""
|
||||
screenBuffer.updateDisplaySnapshot()
|
||||
val sb = StringBuilder()
|
||||
var currentHidden = false
|
||||
|
||||
for (r in 0 until rows) {
|
||||
val rowSb = StringBuilder()
|
||||
for (c in 0 until cols) {
|
||||
val addr = r * cols + c
|
||||
val cell = screenBuffer.getDisplayCell(addr) ?: continue
|
||||
if (cell.fa.toInt() != 0) {
|
||||
val faVal = cell.fa.toInt() and 0xFF
|
||||
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
|
||||
rowSb.append(' ')
|
||||
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
|
||||
if (c > 0) continue else rowSb.append(' ')
|
||||
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
|
||||
rowSb.append(' ')
|
||||
} else {
|
||||
if (currentHidden && maskHidden) {
|
||||
rowSb.append(' ')
|
||||
} else {
|
||||
val ch = cell.ucs4
|
||||
rowSb.append(if (ch in ' '..'~' || ch > '\u007F') ch else ' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append(rowSb.toString().trimEnd()).append("\n")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
|
||||
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
|
||||
screenBuffer.updateDisplaySnapshot()
|
||||
val sb = StringBuilder()
|
||||
sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
|
||||
sb.append("<meta charset=\"UTF-8\">\n")
|
||||
sb.append("<style>\n")
|
||||
sb.append("body { background-color: #0A0A0A; color: #00FF00; font-family: monospace; font-size: 14px; margin: 20px; }\n")
|
||||
sb.append("pre { line-height: 1.2; font-family: monospace; }\n")
|
||||
sb.append(".c-green { color: #00FF00; }\n")
|
||||
sb.append(".c-white { color: #FFFFFF; }\n")
|
||||
sb.append(".c-blue { color: #7890F0; }\n")
|
||||
sb.append(".c-red { color: #FF0000; }\n")
|
||||
sb.append(".c-yellow { color: #FFFF00; }\n")
|
||||
sb.append(".c-turquoise { color: #00FFFF; }\n")
|
||||
sb.append(".c-pink { color: #FF00FF; }\n")
|
||||
sb.append(".c-orange { color: #FFA200; }\n")
|
||||
sb.append(".c-mustard { color: #A0A000; }\n")
|
||||
sb.append(".c-grey { color: #C0C0C0; }\n")
|
||||
sb.append("</style>\n</head>\n<body>\n<pre>\n")
|
||||
|
||||
var currentHidden = false
|
||||
var currentProt = false
|
||||
var currentHi = false
|
||||
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
|
||||
|
||||
for (r in 0 until rows) {
|
||||
for (c in 0 until cols) {
|
||||
val addr = r * cols + c
|
||||
val cell = screenBuffer.getDisplayCell(addr) ?: continue
|
||||
if (cell.fa.toInt() != 0) {
|
||||
val faVal = cell.fa.toInt() and 0xFF
|
||||
currentProt = (faVal and FA_PROTECT) != 0
|
||||
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
|
||||
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
|
||||
currentFieldEa = cell
|
||||
sb.append(' ')
|
||||
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
|
||||
if (c > 0) continue else sb.append(' ')
|
||||
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
|
||||
sb.append(' ')
|
||||
} else {
|
||||
if (currentHidden && maskHidden) {
|
||||
sb.append(' ')
|
||||
} else {
|
||||
val ch = cell.ucs4
|
||||
val fgByte = if (cell.fg != 0.toByte()) (cell.fg.toInt() and 0xFF)
|
||||
else (currentFieldEa?.fg?.toInt()?.and(0xFF) ?: 0)
|
||||
val colorClass = if (fgByte in 0xf0..0xff) {
|
||||
when (fgByte - 0xf0) {
|
||||
1 -> "c-blue"
|
||||
2 -> "c-red"
|
||||
3 -> "c-pink"
|
||||
4 -> "c-green"
|
||||
5 -> "c-turquoise"
|
||||
6 -> "c-yellow"
|
||||
7 -> "c-white"
|
||||
9 -> "c-blue"
|
||||
10 -> "c-orange"
|
||||
11 -> "c-pink"
|
||||
12 -> "c-green"
|
||||
13 -> "c-turquoise"
|
||||
14 -> "c-mustard"
|
||||
15 -> "c-grey"
|
||||
else -> "c-green"
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
currentProt && currentHi -> "c-white"
|
||||
currentProt && !currentHi -> "c-turquoise"
|
||||
!currentProt && currentHi -> "c-red"
|
||||
else -> "c-green"
|
||||
}
|
||||
}
|
||||
val escChar = when (ch) {
|
||||
'<' -> "<"
|
||||
'>' -> ">"
|
||||
'&' -> "&"
|
||||
'"' -> """
|
||||
else -> if (ch in ' '..'~' || ch > '\u007F') ch.toString() else " "
|
||||
}
|
||||
sb.append("<span class=\"$colorClass\">$escChar</span>")
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append("\n")
|
||||
}
|
||||
|
||||
sb.append("</pre>\n</body>\n</html>")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun generateBitmap(
|
||||
screenBuffer: ScreenBuffer?,
|
||||
rows: Int,
|
||||
cols: Int,
|
||||
programSymbolManager: ProgramSymbolManager?,
|
||||
graphicsPlane: GraphicsPlane?,
|
||||
maskHidden: Boolean
|
||||
): Bitmap {
|
||||
val charWidth = 14
|
||||
val charHeight = 24
|
||||
val width = cols * charWidth
|
||||
val height = rows * charHeight
|
||||
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
|
||||
// Fill background
|
||||
val bgPaint = Paint().apply {
|
||||
color = android.graphics.Color.parseColor("#0A0A0A")
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
|
||||
|
||||
if (screenBuffer == null) return bitmap
|
||||
screenBuffer.updateDisplaySnapshot()
|
||||
|
||||
// Layer 1: Vector graphics plane (under text)
|
||||
if (graphicsPlane != null && graphicsPlane.hasContent()) {
|
||||
val rgb = graphicsPlane.rgbBuffer
|
||||
val gWidth = graphicsPlane.canvasWidth
|
||||
val gHeight = graphicsPlane.canvasHeight
|
||||
if (rgb != null && gWidth > 0 && gHeight > 0) {
|
||||
val gfxBmp = Bitmap.createBitmap(rgb, gWidth, gHeight, Bitmap.Config.ARGB_8888)
|
||||
val scaledGfx = Bitmap.createScaledBitmap(gfxBmp, width, height, true)
|
||||
canvas.drawBitmap(scaledGfx, 0f, 0f, null)
|
||||
}
|
||||
}
|
||||
|
||||
val textPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
typeface = Typeface.MONOSPACE
|
||||
textSize = 18f
|
||||
}
|
||||
|
||||
var currentHidden = false
|
||||
var currentProt = false
|
||||
var currentHi = false
|
||||
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
|
||||
|
||||
for (r in 0 until rows) {
|
||||
for (c in 0 until cols) {
|
||||
val addr = r * cols + c
|
||||
val cell = screenBuffer.getDisplayCell(addr) ?: continue
|
||||
val left = (c * charWidth).toFloat()
|
||||
val top = (r * charHeight).toFloat()
|
||||
|
||||
if (cell.fa.toInt() != 0) {
|
||||
val faVal = cell.fa.toInt() and 0xFF
|
||||
currentProt = (faVal and FA_PROTECT) != 0
|
||||
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
|
||||
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
|
||||
currentFieldEa = cell
|
||||
} else {
|
||||
val csVal = cell.cs.toInt() and 0xFF
|
||||
val ecVal = cell.ec.toInt() and 0xFF
|
||||
val fgByte = if (cell.fg != 0.toByte()) (cell.fg.toInt() and 0xFF)
|
||||
else (currentFieldEa?.fg?.toInt()?.and(0xFF) ?: 0)
|
||||
val cellColor = if (fgByte in 0xf0..0xff) {
|
||||
when (fgByte - 0xf0) {
|
||||
1 -> android.graphics.Color.parseColor("#7890F0")
|
||||
2 -> android.graphics.Color.parseColor("#FF0000")
|
||||
3 -> android.graphics.Color.parseColor("#FF00FF")
|
||||
4 -> android.graphics.Color.parseColor("#00FF00")
|
||||
5 -> android.graphics.Color.parseColor("#00FFFF")
|
||||
6 -> android.graphics.Color.parseColor("#FFFF00")
|
||||
7 -> android.graphics.Color.WHITE
|
||||
9 -> android.graphics.Color.parseColor("#000080")
|
||||
10 -> android.graphics.Color.parseColor("#FFA200")
|
||||
11 -> android.graphics.Color.parseColor("#800080")
|
||||
12 -> android.graphics.Color.parseColor("#008000")
|
||||
13 -> android.graphics.Color.parseColor("#008080")
|
||||
14 -> android.graphics.Color.parseColor("#A0A000")
|
||||
15 -> android.graphics.Color.parseColor("#C0C0C0")
|
||||
else -> android.graphics.Color.parseColor("#00FF00")
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
currentProt && currentHi -> android.graphics.Color.WHITE
|
||||
currentProt && !currentHi -> android.graphics.Color.parseColor("#00FFFF")
|
||||
!currentProt && currentHi -> android.graphics.Color.parseColor("#FF0000")
|
||||
else -> android.graphics.Color.parseColor("#00FF00")
|
||||
}
|
||||
}
|
||||
|
||||
var drawnAsPs = false
|
||||
if (csVal >= 0x40 && programSymbolManager != null) {
|
||||
val slot = programSymbolManager.getSymbol(csVal, ecVal)
|
||||
if (slot != null) {
|
||||
val symWidth = slot.width
|
||||
val symHeight = slot.height
|
||||
val bgArgb = android.graphics.Color.TRANSPARENT
|
||||
val rgbArray = slot.getRgbPixels(cellColor, bgArgb)
|
||||
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
|
||||
val symBmp = Bitmap.createBitmap(rgbArray, symWidth, symHeight, Bitmap.Config.ARGB_8888)
|
||||
canvas.drawBitmap(symBmp, null, android.graphics.RectF(left, top, left + charWidth, top + charHeight), null)
|
||||
drawnAsPs = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!drawnAsPs && (!currentHidden || !maskHidden)) {
|
||||
val ch = cell.ucs4
|
||||
if (ch in ' '..'~' || ch > '\u007F') {
|
||||
textPaint.color = cellColor
|
||||
canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
|
||||
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer.ScreenSnapshot
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ScreenHistoryDialog(
|
||||
historyBuffer: ScreenHistoryBuffer?,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) }
|
||||
|
||||
var historySize by remember { mutableIntStateOf(historyBuffer?.size() ?: 0) }
|
||||
var selectedIndex by remember { mutableIntStateOf((historyBuffer?.size() ?: 1) - 1) }
|
||||
|
||||
// Listen for live updates if host writes new screens
|
||||
DisposableEffect(historyBuffer) {
|
||||
if (historyBuffer == null) return@DisposableEffect onDispose {}
|
||||
|
||||
val listener = ScreenHistoryBuffer.ScreenHistoryListener { buf, _ ->
|
||||
val newSize = buf.size()
|
||||
historySize = newSize
|
||||
if (selectedIndex >= newSize - 2) {
|
||||
selectedIndex = newSize - 1
|
||||
}
|
||||
}
|
||||
historyBuffer.addListener(listener)
|
||||
historySize = historyBuffer.size()
|
||||
if (historySize > 0 && selectedIndex < 0) {
|
||||
selectedIndex = historySize - 1
|
||||
}
|
||||
onDispose {
|
||||
historyBuffer.removeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
val currentSnapshot: ScreenSnapshot? = remember(historyBuffer, historySize, selectedIndex) {
|
||||
if (historyBuffer != null && historySize > 0 && selectedIndex in 0 until historySize) {
|
||||
try {
|
||||
historyBuffer.get(selectedIndex)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.fillMaxHeight(0.85f)
|
||||
.padding(vertical = 12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Top Header: Title & Info
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Screen History Buffer",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp,
|
||||
color = Color.White
|
||||
)
|
||||
if (currentSnapshot != null) {
|
||||
Text(
|
||||
dateFormat.format(Date(currentSnapshot.timestamp)),
|
||||
fontSize = 12.sp,
|
||||
color = Color.LightGray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||
|
||||
// Scrub navigation slider & buttons
|
||||
if (historySize > 0 && currentSnapshot != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (selectedIndex > 0) selectedIndex-- },
|
||||
enabled = selectedIndex > 0,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF343A40)),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
|
||||
modifier = Modifier.height(36.dp)
|
||||
) {
|
||||
Text("◄ Prev", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
"Screen ${selectedIndex + 1} of $historySize",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
if (historySize > 1) {
|
||||
Slider(
|
||||
value = (selectedIndex + 1).toFloat(),
|
||||
onValueChange = { selectedIndex = (it.toInt() - 1).coerceIn(0, historySize - 1) },
|
||||
valueRange = 1f..historySize.toFloat(),
|
||||
steps = if (historySize > 2) historySize - 2 else 0,
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { if (selectedIndex < historySize - 1) selectedIndex++ },
|
||||
enabled = selectedIndex < historySize - 1,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF343A40)),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
|
||||
modifier = Modifier.height(36.dp)
|
||||
) {
|
||||
Text("Next ►", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal Preview Pane
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.background(Color.Black, RoundedCornerShape(8.dp))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
if (currentSnapshot != null) {
|
||||
val verticalScroll = rememberScrollState()
|
||||
val horizontalScroll = rememberScrollState()
|
||||
|
||||
Text(
|
||||
text = currentSnapshot.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF00FF41), // Classic 3270 Green Phosphor
|
||||
lineHeight = 14.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(verticalScroll)
|
||||
.horizontalScroll(horizontalScroll)
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"No history frames captured yet.",
|
||||
color = Color.Gray,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (currentSnapshot != null) {
|
||||
clipboardManager.setText(AnnotatedString(currentSnapshot.text))
|
||||
Toast.makeText(context, "Full screen copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
enabled = currentSnapshot != null,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C2D30)),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Copy Screen", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (currentSnapshot != null) {
|
||||
clipboardManager.setText(AnnotatedString(currentSnapshot.formattedTable))
|
||||
Toast.makeText(context, "Table text copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
enabled = currentSnapshot != null,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C2D30)),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Copy Table", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF495057)),
|
||||
modifier = Modifier.width(80.dp)
|
||||
) {
|
||||
Text("Close", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
fun ScriptDialog(
|
||||
initialScript: String = "",
|
||||
onDismiss: () -> Unit,
|
||||
onExecuteScript: (script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit) -> Unit
|
||||
) {
|
||||
var scriptText by remember { mutableStateOf(initialScript.ifBlank { "# Sample 3270 Script\nLOGON APPLID(TSO)[enter]\n[wait]\nUSERID[tab]PASSWORD[enter]" }) }
|
||||
var isExecuting by remember { mutableStateOf(false) }
|
||||
var executionStatus by remember { mutableStateOf("") }
|
||||
var executionError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun insertToken(token: String) {
|
||||
scriptText += token
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { if (!isExecuting) onDismiss() },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.heightIn(max = 680.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Run Keystroke Script / Macro",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
"ECL Mnemonic Tokens",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||
|
||||
// Mnemonic Quick Insert Chips Row 1
|
||||
Text("Insert Mnemonic Keys:", fontSize = 11.sp, color = Color.LightGray)
|
||||
val tokenScroll1 = rememberScrollState()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(tokenScroll1),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
listOf(
|
||||
"[enter]", "[tab]", "[backtab]", "[home]", "[clear]",
|
||||
"[reset]", "[eraseeof]", "[eraseinpt]", "[attn]", "[sysreq]",
|
||||
"[wordleft]", "[wordright]", "[fieldend]", "[deleteword]", "[cursel]", "[lightpen]"
|
||||
).forEach { tok ->
|
||||
SuggestionChip(
|
||||
onClick = { insertToken(tok) },
|
||||
label = { Text(tok, fontSize = 10.sp, fontFamily = FontFamily.Monospace) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mnemonic Quick Insert Chips Row 2 (PF Keys)
|
||||
val tokenScroll2 = rememberScrollState()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(tokenScroll2),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
(1..24).forEach { pf ->
|
||||
val tok = "[pf$pf]"
|
||||
SuggestionChip(
|
||||
onClick = { insertToken(tok) },
|
||||
label = { Text("PF$pf", fontSize = 10.sp, fontFamily = FontFamily.Monospace) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Script Editor Box
|
||||
OutlinedTextField(
|
||||
value = scriptText,
|
||||
onValueChange = { scriptText = it },
|
||||
label = { Text("Script Text / Mnemonic Commands") },
|
||||
placeholder = { Text("Type characters or tokens like [enter], [pf3], etc.") },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Ascii
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
)
|
||||
|
||||
// Status or Error
|
||||
if (isExecuting) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
text = executionStatus.ifBlank { "Executing script..." },
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFF339AF0)
|
||||
)
|
||||
}
|
||||
} else if (executionError != null) {
|
||||
Text(
|
||||
text = "Error: $executionError",
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFFFF6B6B)
|
||||
)
|
||||
} else if (executionStatus.isNotBlank()) {
|
||||
Text(
|
||||
text = executionStatus,
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFF51CF66)
|
||||
)
|
||||
}
|
||||
|
||||
// Bottom Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
enabled = !isExecuting
|
||||
) {
|
||||
Text("Close", color = Color.LightGray)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { scriptText = "" },
|
||||
enabled = !isExecuting
|
||||
) {
|
||||
Text("Clear")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (scriptText.isNotBlank()) {
|
||||
isExecuting = true
|
||||
executionError = null
|
||||
executionStatus = "Starting..."
|
||||
onExecuteScript(
|
||||
scriptText,
|
||||
{ status -> executionStatus = status },
|
||||
{ error ->
|
||||
isExecuting = false
|
||||
if (error != null) {
|
||||
executionError = error
|
||||
} else {
|
||||
executionStatus = "Script completed successfully."
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = !isExecuting && scriptText.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||
) {
|
||||
Text("Execute Script ▶")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.text.InputType
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
|
||||
|
||||
/**
|
||||
* Custom InputConnection for Termux-style terminal keyboard interaction.
|
||||
*
|
||||
* It avoids the destructive TextFieldValue("") resetting cycle which causes Android IME
|
||||
* (Gboard/Samsung Keyboard) to reset symbol locks, cancel Caps Lock, and drop fast keystrokes.
|
||||
*/
|
||||
class TerminalInputConnection(
|
||||
targetView: View,
|
||||
private val onInputText: (String) -> Unit,
|
||||
private val onSendAid: (Int) -> Unit,
|
||||
private val onBackspace: () -> Unit,
|
||||
private val onTab: () -> Unit,
|
||||
private val onBackTab: () -> Unit
|
||||
) : BaseInputConnection(targetView, false) {
|
||||
|
||||
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
if (!text.isNullOrEmpty()) {
|
||||
onInputText(text.toString())
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
||||
if (beforeLength > 0) {
|
||||
repeat(beforeLength) { onBackspace() }
|
||||
return true
|
||||
}
|
||||
return super.deleteSurroundingText(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
|
||||
if (beforeLength > 0) {
|
||||
repeat(beforeLength) { onBackspace() }
|
||||
return true
|
||||
}
|
||||
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun sendKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||
onSendAid(AID_ENTER)
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DEL -> {
|
||||
onBackspace()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_TAB -> {
|
||||
if (event.isShiftPressed) onBackTab() else onTab()
|
||||
return true
|
||||
}
|
||||
}
|
||||
val unicode = event.keyCharacterMap.get(event.keyCode, event.metaState)
|
||||
if (unicode > 0) {
|
||||
onInputText(unicode.toChar().toString())
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native Android View hosting the terminal InputConnection with Termux-style EditorInfo.
|
||||
*
|
||||
* TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + TYPE_CLASS_TEXT forces keyboards like Gboard
|
||||
* to permanently display the alphanumeric number row across the top, disable intrusive
|
||||
* autocorrect overlays, and preserve Shift/Caps Lock and Symbol mode (?123) state across typing.
|
||||
*/
|
||||
class TerminalInputView(context: Context) : View(context) {
|
||||
|
||||
var onInputText: (String) -> Unit = {}
|
||||
var onSendAid: (Int) -> Unit = {}
|
||||
var onBackspace: () -> Unit = {}
|
||||
var onTab: () -> Unit = {}
|
||||
var onBackTab: () -> Unit = {}
|
||||
var isDbcsSession: Boolean = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
imm?.restartInput(this)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
override fun onCheckIsTextEditor(): Boolean = true
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||
outAttrs.inputType = if (isDbcsSession) {
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
} else {
|
||||
InputType.TYPE_CLASS_TEXT or
|
||||
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
|
||||
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
}
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
|
||||
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
||||
EditorInfo.IME_ACTION_NONE
|
||||
return TerminalInputConnection(this, onInputText, onSendAid, onBackspace, onTab, onBackTab)
|
||||
}
|
||||
|
||||
fun showSoftKeyboard() {
|
||||
requestFocus()
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
|
||||
fun hideSoftKeyboard() {
|
||||
clearFocus()
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
imm?.hideSoftInputFromWindow(windowToken, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
|
||||
// Standard 3279 Host Colors (16-color palette) aligned 1:1 with IBM Host On-Demand ColorRemapModel3270
|
||||
private val HOST_COLORS = arrayOf(
|
||||
Color(0xFF000000), // 0: Neutral Black
|
||||
Color(0xFF7890F0), // 1: Blue (0x7890F0 CUSTOMBLUE)
|
||||
Color(0xFFFF0000), // 2: Red
|
||||
Color(0xFFFF00FF), // 3: Pink
|
||||
Color(0xFF00FF00), // 4: Green
|
||||
Color(0xFF00FFFF), // 5: Turquoise / Cyan
|
||||
Color(0xFFFFFF00), // 6: Yellow
|
||||
Color(0xFFFFFFFF), // 7: Neutral White
|
||||
Color(0xFF000000), // 8: Black
|
||||
Color(0xFF000080), // 9: Deep Blue
|
||||
Color(0xFFFFA200), // 10: Orange (0xFFFFA200)
|
||||
Color(0xFF800080), // 11: Purple
|
||||
Color(0xFF008000), // 12: Pale Green
|
||||
Color(0xFF008080), // 13: Pale Turquoise
|
||||
Color(0xFFA0A000), // 14: Mustard (0xFFA0A000)
|
||||
Color(0xFFC0C0C0) // 15: Grey (0xFFC0C0C0)
|
||||
)
|
||||
|
||||
private val COLOR_BLACK = Color(0xFF0A0A0A)
|
||||
private val SELECTION_COLOR = Color(75, 110, 175, 102) // 40% alpha blend per HoD
|
||||
private val SEARCH_HIGHLIGHT_COLOR = Color(255, 215, 0, 120) // Gold per HoD
|
||||
private val CROSSHAIR_RULER_COLOR = Color(0, 255, 0, 102) // 40% alpha green per HoD
|
||||
|
||||
@Composable
|
||||
fun TerminalView(
|
||||
screenBuffer: ScreenBuffer?,
|
||||
rows: Int,
|
||||
cols: Int,
|
||||
cursorAddr: Int,
|
||||
screenVersion: Long,
|
||||
programSymbolManager: haus.nightmare.lib3270j.graphics.ProgramSymbolManager? = null,
|
||||
graphicsPlane: haus.nightmare.lib3270j.graphics.GraphicsPlane? = null,
|
||||
gocaDecoder: haus.nightmare.lib3270j.graphics.GocaDecoder? = null,
|
||||
isLightPenMode: Boolean = false,
|
||||
maskHiddenFields: Boolean = true,
|
||||
blinkCursor: Boolean = true,
|
||||
crosshairRuler: Boolean = false,
|
||||
cursorStyle: String = "BLOCK",
|
||||
isInsertMode: Boolean = false,
|
||||
blockSelectMode: Boolean = false,
|
||||
blockPaste: Boolean = true,
|
||||
trimRectSizingHandles: Boolean = false,
|
||||
trimRectRemainAfterEdit: Boolean = false,
|
||||
hotspotsEnabled: Boolean = false,
|
||||
hotspotEngine: HotspotEngine? = null,
|
||||
fourColorOverride: Boolean = false,
|
||||
searchHighlightAddr: Int = -1,
|
||||
searchHighlightLen: Int = 0,
|
||||
onTapAddress: (Int) -> Unit,
|
||||
onLightPenSelect: ((Int) -> Unit)? = null,
|
||||
onSendHotspotAction: ((String) -> Unit)? = null,
|
||||
onOpenUrl: ((String) -> Unit)? = null,
|
||||
onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
|
||||
onPasteText: (String) -> Unit = {},
|
||||
onPasteLineWrap: ((String, Int, Boolean) -> Unit)? = null,
|
||||
onToggleBlockSelectMode: (() -> Unit)? = null,
|
||||
onToggleBlockPaste: (() -> Unit)? = null,
|
||||
onToggleHotspots: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val currentScreenVersion by rememberUpdatedState(screenVersion)
|
||||
val currentCursorAddr by rememberUpdatedState(cursorAddr)
|
||||
|
||||
var selectionStart by remember { mutableStateOf<Offset?>(null) }
|
||||
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
|
||||
var showPasteWrapDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Blinking cursor state (~530ms interval matching j3270)
|
||||
var cursorVisible by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(cursorAddr, blinkCursor) {
|
||||
if (!blinkCursor) {
|
||||
cursorVisible = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
cursorVisible = true
|
||||
while (true) {
|
||||
delay(530L)
|
||||
cursorVisible = !cursorVisible
|
||||
}
|
||||
}
|
||||
|
||||
// Text blinking timer (500ms cycle matching j3270)
|
||||
var textBlinkVisible by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(500L)
|
||||
textBlinkVisible = !textBlinkVisible
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(COLOR_BLACK)
|
||||
.pointerInput(cols, rows, isLightPenMode, gocaDecoder) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val startTime = System.currentTimeMillis()
|
||||
val startPos = down.position
|
||||
var currentPos = startPos
|
||||
var isDragStarted = false
|
||||
val slop = viewConfiguration.touchSlop
|
||||
val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) {
|
||||
break
|
||||
}
|
||||
currentPos = change.position
|
||||
val distance = (currentPos - startPos).getDistance()
|
||||
|
||||
if (distance > slop && !isLightPenMode && !isGraphicCursor) {
|
||||
if (!isDragStarted) {
|
||||
isDragStarted = true
|
||||
selectionStart = startPos
|
||||
showContextMenu = false
|
||||
}
|
||||
selectionEnd = currentPos
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
val totalDistance = (currentPos - startPos).getDistance()
|
||||
|
||||
if (isDragStarted) {
|
||||
// Drag completed: Keep selection box highlighted, DO NOT open context menu
|
||||
showContextMenu = false
|
||||
} else if (duration >= 600L && totalDistance <= slop && !isLightPenMode && !isGraphicCursor) {
|
||||
// Stationary Long Press (held >= 600ms without dragging) - only in normal alphanumeric mode
|
||||
contextMenuOffset = startPos
|
||||
showContextMenu = true
|
||||
} else if (totalDistance <= slop) {
|
||||
// Short Tap
|
||||
showContextMenu = false
|
||||
if (!trimRectRemainAfterEdit) {
|
||||
selectionStart = null
|
||||
selectionEnd = null
|
||||
}
|
||||
val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
|
||||
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
|
||||
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
val clickAddr = row * cols + col
|
||||
|
||||
if (isGraphicCursor) {
|
||||
val gridW = metrics.gridWidth
|
||||
val gridH = metrics.gridHeight
|
||||
val gWidth = graphicsPlane?.canvasWidth ?: gridW.toInt()
|
||||
val gHeight = graphicsPlane?.canvasHeight ?: gridH.toInt()
|
||||
val px = if (gridW > 0 && gWidth > 0) (((startPos.x - metrics.offsetX) * gWidth) / gridW).toInt().coerceIn(0, gWidth - 1) else (startPos.x - metrics.offsetX).toInt()
|
||||
val py = if (gridH > 0 && gHeight > 0) (((startPos.y - metrics.offsetY) * gHeight) / gridH).toInt().coerceIn(0, gHeight - 1) else (startPos.y - metrics.offsetY).toInt()
|
||||
screenBuffer?.cursorAddress = clickAddr
|
||||
onGraphicTouch?.invoke(clickAddr, px, py)
|
||||
} else if (isLightPenMode) {
|
||||
onLightPenSelect?.invoke(clickAddr)
|
||||
} else {
|
||||
// 1. Hotspot tap check when enabled
|
||||
var handledHotspot = false
|
||||
if (hotspotsEnabled && hotspotEngine != null) {
|
||||
val h = hotspotEngine.findHotspot(row, col)
|
||||
if (h != null) {
|
||||
if (h.type == HotspotEngine.HotspotType.URL) {
|
||||
onOpenUrl?.invoke(h.action)
|
||||
handledHotspot = true
|
||||
} else if (h.type == HotspotEngine.HotspotType.MENU_CHOICE) {
|
||||
// In 3270 / ISPF, clicking/tapping 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]).
|
||||
var action = h.action
|
||||
if (!action.startsWith("[home]")) {
|
||||
action = "[home][eraseeof]$action"
|
||||
}
|
||||
onSendHotspotAction?.invoke(action)
|
||||
handledHotspot = true
|
||||
} else {
|
||||
onSendHotspotAction?.invoke(h.action)
|
||||
handledHotspot = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!handledHotspot) {
|
||||
// 2. Detectable field check (HoD parity)
|
||||
val buf = screenBuffer
|
||||
var isDetectable = false
|
||||
if (buf != null && buf.isFormatted) {
|
||||
val faPos = buf.findFieldAttribute(clickAddr)
|
||||
if (faPos >= 0) {
|
||||
val fa = buf.getDisplayCell(faPos)?.fa?.toInt()?.and(0xFF) ?: 0
|
||||
if (faIsSelectable(fa) && !faIsZero(fa)) {
|
||||
val size = buf.displayRows * buf.displayCols
|
||||
val desPos = if (size > 0) (faPos + 1) % size else -1
|
||||
val desCell = if (desPos >= 0) buf.getDisplayCell(desPos) else null
|
||||
val desAscii = desCell?.ucs4 ?: 0.toChar()
|
||||
val desEbcdic = desCell?.ec?.toInt()?.and(0xFF) ?: 0
|
||||
|
||||
val isToggleField = (desEbcdic == 0x6F || desAscii == '?' || desAscii == '\uFF1F' ||
|
||||
desEbcdic == 0x6E || desAscii == '>' || desAscii == '\uFF1E')
|
||||
val isEnterField = (desEbcdic == 0x50 || desAscii == '&' || desAscii == '\uFF06')
|
||||
|
||||
// Automatic tap selection applies only to toggle (?/>) and enter (&) fields.
|
||||
// Standard text fields (space/null designators) must not trigger light-pen selection
|
||||
// so normal cursor positioning and editing work as expected.
|
||||
if (isToggleField || isEnterField) {
|
||||
onLightPenSelect?.invoke(clickAddr)
|
||||
isDetectable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Normal tap cursor positioning
|
||||
if (!isDetectable) {
|
||||
var addr = clickAddr
|
||||
if (buf != null && buf.isFormatted) {
|
||||
val faVal = buf.getFieldAttributeAt(addr)
|
||||
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getDisplayCell(addr)?.isFieldAttribute == true) {
|
||||
addr = buf.findNextUnprotected(addr)
|
||||
}
|
||||
}
|
||||
onTapAddress(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val version = currentScreenVersion
|
||||
if (version == -1L) return@Canvas
|
||||
|
||||
val buf = screenBuffer
|
||||
val snapshot = buf?.displaySnapshot
|
||||
val snapRows = snapshot?.rows ?: (buf?.displayRows ?: rows)
|
||||
val snapCols = snapshot?.cols ?: (buf?.displayCols ?: cols)
|
||||
val activeCursorAddr = snapshot?.cursorAddress ?: currentCursorAddr
|
||||
|
||||
val metrics = calculateGridMetrics(size.width, size.height, snapCols, snapRows)
|
||||
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
|
||||
|
||||
val cellWidth = metrics.cellWidth
|
||||
val cellHeight = metrics.cellHeight
|
||||
val offsetX = metrics.offsetX
|
||||
val offsetY = metrics.offsetY
|
||||
|
||||
val paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
typeface = Typeface.MONOSPACE
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
// Calculate optimal font size ensuring characters fit cleanly in cell without crowding
|
||||
val baseTextSize = cellHeight * 0.78f
|
||||
paint.textSize = baseTextSize
|
||||
val measuredW = paint.measureText("W")
|
||||
if (measuredW > cellWidth * 0.85f && measuredW > 0f) {
|
||||
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
|
||||
}
|
||||
|
||||
val totalCells = snapRows * snapCols
|
||||
|
||||
var currentFA: Byte = 0
|
||||
var currentFieldEa: ExtendedAttribute? = null
|
||||
|
||||
// Calculate selected cell rectangle range if drag selection active
|
||||
var selMinRow = -1
|
||||
var selMaxRow = -1
|
||||
var selMinCol = -1
|
||||
var selMaxCol = -1
|
||||
|
||||
val start = selectionStart
|
||||
val end = selectionEnd
|
||||
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
|
||||
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
|
||||
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
|
||||
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
|
||||
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
|
||||
|
||||
selMinRow = minOf(startRow, endRow)
|
||||
selMaxRow = maxOf(startRow, endRow)
|
||||
selMinCol = minOf(startCol, endCol)
|
||||
selMaxCol = maxOf(startCol, endCol)
|
||||
}
|
||||
|
||||
// 1. Draw Vector Graphics Plane under text if present
|
||||
if (graphicsPlane != null && graphicsPlane.hasContent()) {
|
||||
val gridW = metrics.gridWidth.toInt()
|
||||
val gridH = metrics.gridHeight.toInt()
|
||||
val gWidth = graphicsPlane.canvasWidth
|
||||
val gHeight = graphicsPlane.canvasHeight
|
||||
val rgb = graphicsPlane.rgbBuffer
|
||||
if (gridW > 0 && gridH > 0 && rgb != null && gWidth > 0 && gHeight > 0) {
|
||||
val bmp = android.graphics.Bitmap.createBitmap(rgb, gWidth, gHeight, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
drawContext.canvas.nativeCanvas.drawBitmap(
|
||||
bmp,
|
||||
null,
|
||||
android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Draw Crosshair Ruler if enabled
|
||||
if (crosshairRuler && snapCols > 0 && snapRows > 0) {
|
||||
val curCol = (activeCursorAddr % snapCols).coerceIn(0, snapCols - 1)
|
||||
val curRow = ((activeCursorAddr / snapCols) % snapRows).coerceIn(0, snapRows - 1)
|
||||
val gridW = snapCols * cellWidth
|
||||
val gridH = snapRows * cellHeight
|
||||
val cx = offsetX + curCol * cellWidth
|
||||
val cy = offsetY + curRow * cellHeight
|
||||
|
||||
drawRect(
|
||||
color = CROSSHAIR_RULER_COLOR,
|
||||
topLeft = Offset(offsetX, cy),
|
||||
size = Size(gridW, cellHeight)
|
||||
)
|
||||
drawRect(
|
||||
color = CROSSHAIR_RULER_COLOR,
|
||||
topLeft = Offset(cx, offsetY),
|
||||
size = Size(cellWidth, gridH)
|
||||
)
|
||||
}
|
||||
|
||||
for (r in 0 until snapRows) {
|
||||
for (c in 0 until snapCols) {
|
||||
val addr = r * snapCols + c
|
||||
if (addr >= totalCells) break
|
||||
|
||||
val left = offsetX + c * cellWidth
|
||||
val top = offsetY + r * cellHeight
|
||||
|
||||
var charVal = ' '
|
||||
var fgColor: Color
|
||||
var bgColor = COLOR_BLACK
|
||||
var isBold = false
|
||||
var isUnderline = false
|
||||
var isReverse = false
|
||||
var isBlink = false
|
||||
var csVal = 0
|
||||
var ecVal = 0
|
||||
|
||||
val ea = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
|
||||
|
||||
if (ea != null) {
|
||||
if (ea.isFieldAttribute) {
|
||||
currentFA = ea.fa
|
||||
currentFieldEa = ea
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute 3270 color and field intensity
|
||||
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA, fourColorOverride)
|
||||
bgColor = getBgColorForAttribute(ea, currentFieldEa)
|
||||
|
||||
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
|
||||
else (currentFieldEa?.cs?.toInt()?.and(0xFF) ?: 0)
|
||||
ecVal = ea.ec.toInt() and 0xFF
|
||||
|
||||
// Intensity
|
||||
if (faIsHigh(currentFA.toInt() and 0xFF)) {
|
||||
isBold = true
|
||||
}
|
||||
|
||||
// Invisible fields (passwords / hidden inputs)
|
||||
if (faIsZero(currentFA.toInt() and 0xFF)) {
|
||||
if (maskHiddenFields && ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
|
||||
charVal = '*'
|
||||
} else {
|
||||
// Blanks out character
|
||||
charVal = ' '
|
||||
}
|
||||
} else {
|
||||
// Normal visible character
|
||||
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
|
||||
charVal = ea.ucs4
|
||||
}
|
||||
}
|
||||
|
||||
// Extended Graphic Rendition
|
||||
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
|
||||
if (gr != 0.toByte()) {
|
||||
val grVal = gr.toInt() and 0xFF
|
||||
if ((grVal and GR_INTENSIFY) != 0) isBold = true
|
||||
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
|
||||
if ((grVal and GR_REVERSE) != 0) isReverse = true
|
||||
if ((grVal and GR_BLINK) != 0) isBlink = true
|
||||
}
|
||||
} else {
|
||||
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
|
||||
}
|
||||
|
||||
if (isReverse) {
|
||||
val tmp = fgColor
|
||||
fgColor = bgColor
|
||||
bgColor = tmp
|
||||
}
|
||||
|
||||
val tr = if (ea != null && ea.tr != 0.toByte()) ea.tr else (currentFieldEa?.tr ?: 0)
|
||||
val isOpaque = ((tr.toInt() and 0xFF) == (ExtendedAttribute.TR_OPAQUE.toInt() and 0xFF))
|
||||
|
||||
if (isOpaque || (bgColor != COLOR_BLACK && bgColor != Color.Transparent)) {
|
||||
drawRect(
|
||||
color = bgColor,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(cellWidth, cellHeight)
|
||||
)
|
||||
}
|
||||
|
||||
// Selection highlight (Block select vs Linear stream select)
|
||||
val isSelected = if (blockSelectMode) {
|
||||
r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
|
||||
} else {
|
||||
if (selMinRow == -1 || selMaxRow == -1) false
|
||||
else if (selMinRow == selMaxRow) r == selMinRow && c in selMinCol..selMaxCol
|
||||
else if (r == selMinRow) c >= selMinCol
|
||||
else if (r == selMaxRow) c <= selMaxCol
|
||||
else r in (selMinRow + 1) until selMaxRow
|
||||
}
|
||||
|
||||
val isSearchHighlighted = searchHighlightAddr >= 0 && searchHighlightLen > 0 &&
|
||||
addr >= searchHighlightAddr && addr < (searchHighlightAddr + searchHighlightLen)
|
||||
|
||||
if (isSearchHighlighted) {
|
||||
drawRect(
|
||||
color = SEARCH_HIGHLIGHT_COLOR,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(cellWidth, cellHeight)
|
||||
)
|
||||
} else if (isSelected) {
|
||||
drawRect(
|
||||
color = SELECTION_COLOR,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(cellWidth, cellHeight)
|
||||
)
|
||||
}
|
||||
|
||||
// Cursor indicator (respects blinking toggle & timer, cursor style, insert mode)
|
||||
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
|
||||
val isDbcsCur = ea != null && (ea.db == ExtendedAttribute.DB_LEFT || ea.db == ExtendedAttribute.DB_RIGHT)
|
||||
val curWidth = if (isDbcsCur) cellWidth * 2f else cellWidth
|
||||
val curLeft = if (isDbcsCur && ea?.db == ExtendedAttribute.DB_RIGHT && c > 0) left - cellWidth else left
|
||||
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
|
||||
if (isUnderlineCursor) {
|
||||
val ulH = maxOf(4f, cellHeight / 4f)
|
||||
drawRect(
|
||||
color = Color(255, 255, 255, 180),
|
||||
topLeft = Offset(curLeft, top + cellHeight - ulH),
|
||||
size = Size(curWidth, ulH)
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
color = Color(255, 255, 255, 180),
|
||||
topLeft = Offset(curLeft, top),
|
||||
size = Size(curWidth, cellHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Programmed Symbol (PS / APL) if defined
|
||||
var drawnAsPs = false
|
||||
if (csVal >= 0x40 && programSymbolManager != null) {
|
||||
val slot = programSymbolManager.getSymbol(csVal, ecVal)
|
||||
if (slot != null) {
|
||||
val symWidth = slot.width
|
||||
val symHeight = slot.height
|
||||
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
|
||||
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
|
||||
val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
drawContext.canvas.nativeCanvas.drawBitmap(
|
||||
bmp,
|
||||
null,
|
||||
android.graphics.RectF(left, top, left + cellWidth, top + cellHeight),
|
||||
null
|
||||
)
|
||||
drawnAsPs = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isDbcsRight = ea?.db == ExtendedAttribute.DB_RIGHT
|
||||
val isDbcsSoSi = ea?.db == ExtendedAttribute.DB_SO || ea?.db == ExtendedAttribute.DB_SI
|
||||
val suppressText = (isBlink && !textBlinkVisible) || isDbcsRight || isDbcsSoSi
|
||||
if (!drawnAsPs && charVal != ' ' && !suppressText) {
|
||||
paint.color = fgColor.toArgb()
|
||||
paint.isFakeBoldText = isBold
|
||||
val fontMetrics = paint.fontMetrics
|
||||
val textX = if (ea?.db == ExtendedAttribute.DB_LEFT) left + cellWidth else left + cellWidth / 2f
|
||||
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
charVal.toString(),
|
||||
textX,
|
||||
textY,
|
||||
paint
|
||||
)
|
||||
}
|
||||
|
||||
if (isUnderline) {
|
||||
drawLine(
|
||||
color = fgColor,
|
||||
start = Offset(left, top + cellHeight - 2f),
|
||||
end = Offset(left + cellWidth, top + cellHeight - 2f),
|
||||
strokeWidth = 2f
|
||||
)
|
||||
}
|
||||
|
||||
// Field Outlining (0xC2 / TY_FOUTLINE)
|
||||
val cellEa = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
|
||||
var outline = if (cellEa != null) cellEa.ol.toInt() and 0xFF else 0
|
||||
val fEa = currentFieldEa
|
||||
val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF
|
||||
val olOverline = ExtendedAttribute.OL_OVERLINE.toInt() and 0xFF
|
||||
val olLeft = ExtendedAttribute.OL_LEFT.toInt() and 0xFF
|
||||
val olRight = ExtendedAttribute.OL_RIGHT.toInt() and 0xFF
|
||||
|
||||
if (outline == 0 && fEa != null && fEa.ol != 0.toByte()) {
|
||||
val fol = fEa.ol.toInt() and 0xFF
|
||||
if ((fol and olUnderline) != 0) outline = outline or olUnderline
|
||||
if ((fol and olOverline) != 0) outline = outline or olOverline
|
||||
|
||||
val size = totalCells
|
||||
val prevAddr = (addr - 1 + size) % size
|
||||
val prevCell = snapshot?.getCell(prevAddr) ?: buf?.getDisplayCell(prevAddr)
|
||||
if (prevCell?.isFieldAttribute == true || c == 0) {
|
||||
if ((fol and olLeft) != 0) outline = outline or olLeft
|
||||
}
|
||||
val nextAddr = (addr + 1) % size
|
||||
val nextCell = snapshot?.getCell(nextAddr) ?: buf?.getDisplayCell(nextAddr)
|
||||
if (nextCell?.isFieldAttribute == true || c == snapCols - 1) {
|
||||
if ((fol and olRight) != 0) outline = outline or olRight
|
||||
}
|
||||
}
|
||||
|
||||
if (outline != 0) {
|
||||
if ((outline and olUnderline) != 0) {
|
||||
drawLine(fgColor, Offset(left, top + cellHeight - 1f), Offset(left + cellWidth, top + cellHeight - 1f), strokeWidth = 1.5f)
|
||||
}
|
||||
if ((outline and olRight) != 0) {
|
||||
drawLine(fgColor, Offset(left + cellWidth - 1f, top), Offset(left + cellWidth - 1f, top + cellHeight), strokeWidth = 1.5f)
|
||||
}
|
||||
if ((outline and olOverline) != 0) {
|
||||
drawLine(fgColor, Offset(left, top), Offset(left + cellWidth, top), strokeWidth = 1.5f)
|
||||
}
|
||||
if ((outline and olLeft) != 0) {
|
||||
drawLine(fgColor, Offset(left, top), Offset(left, top + cellHeight), strokeWidth = 1.5f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rectangular Block Selection Sizing Handles & Box Outline
|
||||
if (trimRectSizingHandles && selMinRow != -1 && selMinCol != -1) {
|
||||
val x1 = offsetX + selMinCol * cellWidth
|
||||
val y1 = offsetY + selMinRow * cellHeight
|
||||
val x2 = offsetX + (selMaxCol + 1) * cellWidth
|
||||
val y2 = offsetY + (selMaxRow + 1) * cellHeight
|
||||
val w = x2 - x1
|
||||
val h = y2 - y1
|
||||
val xm = (x1 + x2) / 2f
|
||||
val ym = (y1 + y2) / 2f
|
||||
|
||||
drawRect(
|
||||
color = Color.Yellow,
|
||||
topLeft = Offset(x1, y1),
|
||||
size = Size(w, h),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
|
||||
)
|
||||
|
||||
val handleSize = 8f
|
||||
val handleHalf = handleSize / 2f
|
||||
val handles = listOf(
|
||||
Offset(x1, y1), Offset(xm, y1), Offset(x2, y1),
|
||||
Offset(x2, ym), Offset(x2, y2), Offset(xm, y2),
|
||||
Offset(x1, y2), Offset(x1, ym)
|
||||
)
|
||||
for (pt in handles) {
|
||||
drawRect(
|
||||
color = Color.Yellow,
|
||||
topLeft = Offset(pt.x - handleHalf, pt.y - handleHalf),
|
||||
size = Size(handleSize, handleSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive Hotspot Underline Rendering
|
||||
if (hotspotsEnabled && hotspotEngine != null && buf != null) {
|
||||
val activeHotspots = hotspotEngine.scan(buf)
|
||||
for (h in activeHotspots) {
|
||||
val hr0 = h.startRow
|
||||
val hr1 = h.endRow
|
||||
for (r in hr0..hr1) {
|
||||
val c0 = if (r == hr0) h.startCol else 0
|
||||
val c1 = if (r == hr1) h.endCol else cols - 1
|
||||
val x1 = offsetX + c0 * cellWidth
|
||||
val x2 = offsetX + (c1 + 1) * cellWidth
|
||||
val y = offsetY + (r + 1) * cellHeight - 2f
|
||||
drawLine(
|
||||
color = Color(0xFF00DCFF),
|
||||
start = Offset(x1, y),
|
||||
end = Offset(x2, y),
|
||||
strokeWidth = 2f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Graphic Cursor if active in GOCA or GraphicsPlane
|
||||
val isGraphicCursorActive = (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) ||
|
||||
(graphicsPlane != null && graphicsPlane.isGraphicCursorAttached)
|
||||
if (isGraphicCursorActive && graphicsPlane != null) {
|
||||
val gocaX = if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) gocaDecoder.graphicCursorX else graphicsPlane.graphicCursorX
|
||||
val gocaY = if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) gocaDecoder.graphicCursorY else graphicsPlane.graphicCursorY
|
||||
val canvasPx = graphicsPlane.mapX(gocaX)
|
||||
val canvasPy = graphicsPlane.mapY(gocaY)
|
||||
val gridW = metrics.gridWidth
|
||||
val gridH = metrics.gridHeight
|
||||
val gWidth = graphicsPlane.canvasWidth.toFloat()
|
||||
val gHeight = graphicsPlane.canvasHeight.toFloat()
|
||||
val px = offsetX + if (gWidth > 0) (canvasPx.toFloat() * gridW / gWidth) else canvasPx.toFloat()
|
||||
val py = offsetY + if (gHeight > 0) (canvasPy.toFloat() * gridH / gHeight) else canvasPy.toFloat()
|
||||
|
||||
val shape = graphicsPlane.hodCursorShape
|
||||
if (shape == 2) {
|
||||
// Box cursor
|
||||
drawRect(
|
||||
color = Color.White,
|
||||
topLeft = Offset(px - 6f, py - 6f),
|
||||
size = Size(12f, 12f),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
|
||||
)
|
||||
} else {
|
||||
// Crosshair cursor
|
||||
drawLine(
|
||||
color = Color.White,
|
||||
start = Offset(px - 10f, py),
|
||||
end = Offset(px + 10f, py),
|
||||
strokeWidth = 2f
|
||||
)
|
||||
drawLine(
|
||||
color = Color.White,
|
||||
start = Offset(px, py - 10f),
|
||||
end = Offset(px, py + 10f),
|
||||
strokeWidth = 2f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context Menu Popup
|
||||
DropdownMenu(
|
||||
expanded = showContextMenu,
|
||||
onDismissRequest = { showContextMenu = false },
|
||||
offset = DpOffset(
|
||||
x = (contextMenuOffset.x / density.density).dp,
|
||||
y = (contextMenuOffset.y / density.density).dp
|
||||
)
|
||||
) {
|
||||
val hasSelection = selectionStart != null && selectionEnd != null
|
||||
if (hasSelection) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy Selection") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context, blockSelectMode)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Clear Selection") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
selectionStart = null
|
||||
selectionEnd = null
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Select All") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
selectionStart = Offset(0f, 0f)
|
||||
selectionEnd = Offset(100000f, 100000f)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("Paste") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
val clipText = clipboardManager.getText()?.text
|
||||
if (!clipText.isNullOrEmpty()) {
|
||||
onPasteText(clipText)
|
||||
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("Paste with Line Wrap...") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
showPasteWrapDialog = true
|
||||
}
|
||||
)
|
||||
|
||||
if (onToggleBlockSelectMode != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (blockSelectMode) "✓ Block Select Mode" else " Block Select Mode") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onToggleBlockSelectMode()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (onToggleBlockPaste != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (blockPaste) "✓ Block Paste" else " Block Paste") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onToggleBlockPaste()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (onToggleHotspots != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (hotspotsEnabled) "✓ Interactive Hotspots" else " Interactive Hotspots") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onToggleHotspots()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showPasteWrapDialog) {
|
||||
var marginText by remember { mutableStateOf(cols.toString()) }
|
||||
var wordWrap by remember { mutableStateOf(true) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showPasteWrapDialog = false },
|
||||
title = { Text("Paste with Line Wrap") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Wrap text at column (1-$cols, 0 = no wrap):", fontSize = 13.sp)
|
||||
OutlinedTextField(
|
||||
value = marginText,
|
||||
onValueChange = { marginText = it },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||
)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = wordWrap, onCheckedChange = { wordWrap = it })
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Word wrap lines")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showPasteWrapDialog = false
|
||||
val clipText = clipboardManager.getText()?.text
|
||||
if (!clipText.isNullOrEmpty()) {
|
||||
val margin = marginText.trim().toIntOrNull() ?: cols
|
||||
if (onPasteLineWrap != null) {
|
||||
onPasteLineWrap(clipText, margin, wordWrap)
|
||||
} else {
|
||||
onPasteText(clipText)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Text("Paste")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showPasteWrapDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class TerminalGridMetrics(
|
||||
val cellWidth: Float,
|
||||
val cellHeight: Float,
|
||||
val gridWidth: Float,
|
||||
val gridHeight: Float,
|
||||
val offsetX: Float,
|
||||
val offsetY: Float
|
||||
)
|
||||
|
||||
fun calculateGridMetrics(
|
||||
viewWidth: Float,
|
||||
viewHeight: Float,
|
||||
cols: Int,
|
||||
rows: Int
|
||||
): TerminalGridMetrics {
|
||||
if (viewWidth <= 0f || viewHeight <= 0f || cols <= 0 || rows <= 0) {
|
||||
return TerminalGridMetrics(0f, 0f, 0f, 0f, 0f, 0f)
|
||||
}
|
||||
|
||||
val rawCellW = viewWidth / cols
|
||||
val rawCellH = viewHeight / rows
|
||||
val currentRatio = rawCellW / rawCellH
|
||||
|
||||
// Target terminal cell aspect ratio is roughly 0.50 to 0.60 (width / height)
|
||||
val targetMinRatio = 0.48f
|
||||
val targetMaxRatio = 0.60f
|
||||
|
||||
val cellW: Float
|
||||
val cellH: Float
|
||||
|
||||
if (currentRatio > targetMaxRatio) {
|
||||
// Screen is wider than ideal aspect ratio -> clamp width, pillarbox (equal blank bars left/right)
|
||||
cellH = rawCellH
|
||||
cellW = rawCellH * targetMaxRatio
|
||||
} else if (currentRatio < targetMinRatio) {
|
||||
// Screen is taller than ideal aspect ratio -> clamp height, letterbox (equal blank bars top/bottom)
|
||||
cellW = rawCellW
|
||||
cellH = rawCellW / targetMinRatio
|
||||
} else {
|
||||
cellW = rawCellW
|
||||
cellH = rawCellH
|
||||
}
|
||||
|
||||
val gridW = cols * cellW
|
||||
val gridH = rows * cellH
|
||||
val offX = maxOf(0f, (viewWidth - gridW) / 2f)
|
||||
val offY = maxOf(0f, (viewHeight - gridH) / 2f)
|
||||
|
||||
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
|
||||
}
|
||||
|
||||
private fun copySelection(
|
||||
start: Offset?,
|
||||
end: Offset?,
|
||||
screenBuffer: ScreenBuffer?,
|
||||
rows: Int,
|
||||
cols: Int,
|
||||
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
|
||||
context: android.content.Context,
|
||||
blockSelectMode: Boolean = false
|
||||
) {
|
||||
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
val viewW = displayMetrics.widthPixels.toFloat()
|
||||
val viewH = displayMetrics.heightPixels.toFloat()
|
||||
val metrics = calculateGridMetrics(viewW, viewH, cols, rows)
|
||||
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return
|
||||
|
||||
val startCol = ((start.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val startRow = ((start.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
val endCol = ((end.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val endRow = ((end.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
|
||||
val minRow = minOf(startRow, endRow)
|
||||
val maxRow = maxOf(startRow, endRow)
|
||||
val minCol = minOf(startCol, endCol)
|
||||
val maxCol = maxOf(startCol, endCol)
|
||||
|
||||
val sb = StringBuilder()
|
||||
for (r in minRow..maxRow) {
|
||||
val line = StringBuilder()
|
||||
val colStart = if (blockSelectMode) minCol else (if (r == minRow) minCol else 0)
|
||||
val colEnd = if (blockSelectMode) maxCol else (if (r == maxRow) maxCol else cols - 1)
|
||||
for (c in colStart..colEnd) {
|
||||
val addr = r * cols + c
|
||||
if (addr < (rows * cols)) {
|
||||
val cell = screenBuffer.getDisplayCell(addr)
|
||||
if (cell != null && !cell.isFieldAttribute) {
|
||||
if (cell.db == ExtendedAttribute.DB_RIGHT) {
|
||||
val isSubsequent = if (blockSelectMode) c > minCol else (addr > (minRow * cols + minCol))
|
||||
if (isSubsequent) {
|
||||
continue // Character already added by DB_LEFT
|
||||
} else {
|
||||
line.append(' ')
|
||||
}
|
||||
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
|
||||
line.append(' ')
|
||||
} else if (cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
||||
line.append(cell.ucs4)
|
||||
} else {
|
||||
line.append(' ')
|
||||
}
|
||||
} else {
|
||||
line.append(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append(line.toString().trimEnd()).append('\n')
|
||||
}
|
||||
val textToCopy = sb.toString().trimEnd()
|
||||
if (textToCopy.isNotBlank()) {
|
||||
clipboardManager.setText(AnnotatedString(textToCopy))
|
||||
Toast.makeText(context, "Copied selection to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getFgColorForAttribute(
|
||||
ea: ExtendedAttribute,
|
||||
currentFieldEa: ExtendedAttribute?,
|
||||
currentFA: Byte,
|
||||
fourColorOverride: Boolean = false
|
||||
): Color {
|
||||
if (!fourColorOverride) {
|
||||
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF)
|
||||
else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
|
||||
else 0
|
||||
|
||||
if (fg in 0xf0..0xff) {
|
||||
return HOST_COLORS[fg - 0xf0]
|
||||
}
|
||||
}
|
||||
val fa = currentFA.toInt() and 0xFF
|
||||
return if (faIsProtected(fa)) {
|
||||
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_TURQUOISE]
|
||||
} else {
|
||||
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
|
||||
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
|
||||
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
|
||||
else 0
|
||||
|
||||
if (bg in 0xf0..0xff) {
|
||||
val idx = bg - 0xf0
|
||||
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
|
||||
return COLOR_BLACK
|
||||
}
|
||||
return HOST_COLORS[idx]
|
||||
}
|
||||
return COLOR_BLACK
|
||||
}
|
||||
|
||||
private fun Color.toArgb(): Int {
|
||||
return (alpha * 255).toInt() shl 24 or
|
||||
((red * 255).toInt() shl 16) or
|
||||
((green * 255).toInt() shl 8) or
|
||||
(blue * 255).toInt()
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package haus.nightmare.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun UntrustedCertificateDialog(
|
||||
host: String,
|
||||
port: Int,
|
||||
chain: Array<X509Certificate>?,
|
||||
exception: CertificateException?,
|
||||
onAccept: () -> Unit,
|
||||
onReject: () -> Unit
|
||||
) {
|
||||
val cert = chain?.firstOrNull()
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US)
|
||||
|
||||
val detailsText = buildString {
|
||||
if (exception != null) {
|
||||
append("Validation Error:\n ")
|
||||
append(exception.localizedMessage ?: exception.message ?: exception.toString())
|
||||
append("\n\n")
|
||||
}
|
||||
if (cert != null) {
|
||||
try {
|
||||
append("Subject:\n ")
|
||||
append(cert.subjectX500Principal?.name ?: cert.subjectDN?.name ?: "Unknown")
|
||||
append("\n\n")
|
||||
} catch (t: Throwable) {
|
||||
append("Subject: ${t.message ?: "Unavailable"}\n\n")
|
||||
}
|
||||
|
||||
try {
|
||||
append("Issuer:\n ")
|
||||
append(cert.issuerX500Principal?.name ?: cert.issuerDN?.name ?: "Unknown")
|
||||
append("\n\n")
|
||||
} catch (t: Throwable) {
|
||||
append("Issuer: ${t.message ?: "Unavailable"}\n\n")
|
||||
}
|
||||
|
||||
try {
|
||||
append("Validity:\n From: ")
|
||||
append(cert.notBefore?.let { sdf.format(it) } ?: "Unknown")
|
||||
append("\n To: ")
|
||||
append(cert.notAfter?.let { sdf.format(it) } ?: "Unknown")
|
||||
append("\n\n")
|
||||
} catch (t: Throwable) {
|
||||
append("Validity: ${t.message ?: "Unavailable"}\n\n")
|
||||
}
|
||||
|
||||
try {
|
||||
append("Serial Number:\n ")
|
||||
append(cert.serialNumber?.toString(16)?.uppercase(Locale.US) ?: "Unknown")
|
||||
append("\n\n")
|
||||
} catch (t: Throwable) {
|
||||
append("Serial Number: ${t.message ?: "Unavailable"}\n\n")
|
||||
}
|
||||
|
||||
try {
|
||||
append("SHA-256 Fingerprint:\n ")
|
||||
append(computeFingerprint(cert, "SHA-256"))
|
||||
append("\n\n")
|
||||
} catch (t: Throwable) {
|
||||
append("SHA-256 Fingerprint: ${t.message ?: "Unavailable"}\n\n")
|
||||
}
|
||||
|
||||
try {
|
||||
append("SHA-1 Fingerprint:\n ")
|
||||
append(computeFingerprint(cert, "SHA-1"))
|
||||
} catch (t: Throwable) {
|
||||
append("SHA-1 Fingerprint: ${t.message ?: "Unavailable"}")
|
||||
}
|
||||
} else {
|
||||
append("No peer certificate information available.")
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onReject,
|
||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color(0xFF1E1E1E),
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.96f)
|
||||
.wrapContentHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "⚠️",
|
||||
fontSize = 24.sp
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "Untrusted SSL/TLS Certificate",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFFFFB450)
|
||||
)
|
||||
Text(
|
||||
text = "The server certificate for $host:$port could not be verified.",
|
||||
fontSize = 12.sp,
|
||||
color = Color.LightGray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable Certificate Details Box
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp)
|
||||
.background(Color(0xFF121212), RoundedCornerShape(6.dp))
|
||||
.border(1.dp, Color(0xFF333333), RoundedCornerShape(6.dp))
|
||||
.padding(10.dp)
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Text(
|
||||
text = detailsText,
|
||||
color = Color(0xFFDCDCDC),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
lineHeight = 15.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
)
|
||||
}
|
||||
|
||||
// Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onReject) {
|
||||
Text("Cancel Connection", color = Color.LightGray)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = onAccept,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB46428))
|
||||
) {
|
||||
Text("Connect Anyway", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeFingerprint(cert: X509Certificate, algorithm: String): String {
|
||||
return try {
|
||||
val md = MessageDigest.getInstance(algorithm)
|
||||
val digest = md.digest(cert.encoded)
|
||||
digest.joinToString(":") { "%02X".format(it.toInt() and 0xFF) }
|
||||
} catch (e: Throwable) {
|
||||
"Unable to compute fingerprint: ${e.message}"
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
package org.pubvm.a3270
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
|
||||
import org.pubvm.a3270.service.TerminalService
|
||||
import org.pubvm.a3270.storage.HostStorage
|
||||
import org.pubvm.a3270.ui.ConnectDialog
|
||||
import org.pubvm.a3270.ui.FileTransferDialog
|
||||
import org.pubvm.a3270.ui.TwoRowKeyBar
|
||||
import org.pubvm.a3270.ui.OiaStatusBar
|
||||
import org.pubvm.a3270.ui.TerminalView
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: TerminalViewModel by viewModels()
|
||||
private val isShiftPressedState = mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
window.setSoftInputMode(
|
||||
android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or
|
||||
android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 101)
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
MaterialTheme(
|
||||
colorScheme = darkColorScheme(
|
||||
background = Color(0xFF000000),
|
||||
surface = Color(0xFF1E1E1E)
|
||||
)
|
||||
) {
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
isShiftPressed = isShiftPressedState.value,
|
||||
onClearShift = { isShiftPressedState.value = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
|
||||
if (isShiftPressedState.value != shift) {
|
||||
isShiftPressedState.value = shift
|
||||
}
|
||||
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
val keyCode = event.keyCode
|
||||
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
|
||||
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
|
||||
val pfNum = if (shift) fNum + 12 else fNum
|
||||
val pfAids = intArrayOf(
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF1, org.lib3270j.protocol.DS3270Constants.AID_PF2,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF3, org.lib3270j.protocol.DS3270Constants.AID_PF4,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF5, org.lib3270j.protocol.DS3270Constants.AID_PF6,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF7, org.lib3270j.protocol.DS3270Constants.AID_PF8,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF9, org.lib3270j.protocol.DS3270Constants.AID_PF10,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF11, org.lib3270j.protocol.DS3270Constants.AID_PF12,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF13, org.lib3270j.protocol.DS3270Constants.AID_PF14,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF15, org.lib3270j.protocol.DS3270Constants.AID_PF16,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF17, org.lib3270j.protocol.DS3270Constants.AID_PF18,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF19, org.lib3270j.protocol.DS3270Constants.AID_PF20,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF21, org.lib3270j.protocol.DS3270Constants.AID_PF22,
|
||||
org.lib3270j.protocol.DS3270Constants.AID_PF23, org.lib3270j.protocol.DS3270Constants.AID_PF24
|
||||
)
|
||||
viewModel.sendAid(pfAids[pfNum - 1])
|
||||
if (shift) {
|
||||
isShiftPressedState.value = false
|
||||
}
|
||||
return true
|
||||
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
|
||||
viewModel.cursorLeft()
|
||||
return true
|
||||
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
|
||||
viewModel.cursorUp()
|
||||
return true
|
||||
} else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
|
||||
viewModel.cursorDown()
|
||||
return true
|
||||
} else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
|
||||
viewModel.cursorRight()
|
||||
return true
|
||||
} else if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
|
||||
viewModel.sendAid(AID_ENTER)
|
||||
return true
|
||||
} else if (keyCode == KeyEvent.KEYCODE_TAB) {
|
||||
if (event.isShiftPressed) {
|
||||
viewModel.backTab()
|
||||
} else {
|
||||
viewModel.tab()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
viewModel: TerminalViewModel,
|
||||
isShiftPressed: Boolean = false,
|
||||
onClearShift: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val connectionState by viewModel.connectionState.collectAsState()
|
||||
val screenBuffer by viewModel.screenBuffer.collectAsState()
|
||||
val rows by viewModel.rows.collectAsState()
|
||||
val cols by viewModel.cols.collectAsState()
|
||||
val cursorAddr by viewModel.cursorAddress.collectAsState()
|
||||
val oiaText by viewModel.oiaText.collectAsState()
|
||||
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
|
||||
val screenVersion by viewModel.screenVersion.collectAsState()
|
||||
|
||||
var showConnectDialog by remember { mutableStateOf(false) }
|
||||
var showFtDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
viewModel.isAppInBackground = true
|
||||
} else if (event == Lifecycle.Event.ON_START) {
|
||||
viewModel.isAppInBackground = false
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val autoHost = HostStorage.getAutoConnectHost(context)
|
||||
if (autoHost != null && !connectionState.isConnected()) {
|
||||
viewModel.connect(autoHost.host, autoHost.port, autoHost.model, autoHost.luName)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(connectionState) {
|
||||
if (connectionState.isConnected()) {
|
||||
val activeHost = viewModel.currentHost.ifBlank { "Mainframe" }
|
||||
TerminalService.start(context, activeHost)
|
||||
} else {
|
||||
TerminalService.stop(context)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
.imePadding()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Main 3270 Terminal Screen View (100% clean top screen, zero overlays)
|
||||
TerminalView(
|
||||
screenBuffer = screenBuffer,
|
||||
rows = rows,
|
||||
cols = cols,
|
||||
cursorAddr = cursorAddr,
|
||||
screenVersion = screenVersion,
|
||||
onTapAddress = { addr ->
|
||||
viewModel.setCursor(addr)
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
},
|
||||
onPasteText = { text ->
|
||||
viewModel.pasteString(text)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Transparent BasicTextField maintaining active Android IME connection
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { newValue ->
|
||||
val text = newValue.text
|
||||
if (text.isNotEmpty()) {
|
||||
for (ch in text) {
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
viewModel.sendAid(AID_ENTER)
|
||||
} else {
|
||||
viewModel.typeChar(ch)
|
||||
}
|
||||
}
|
||||
textFieldValue = TextFieldValue("")
|
||||
onClearShift()
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(color = Color.Transparent),
|
||||
cursorBrush = SolidColor(Color.Transparent),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
autoCorrect = false,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.None
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { viewModel.sendAid(AID_ENTER) },
|
||||
onGo = { viewModel.sendAid(AID_ENTER) },
|
||||
onSend = { viewModel.sendAid(AID_ENTER) },
|
||||
onNext = {
|
||||
// Stubbed for future form field navigation
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(1.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == androidx.compose.ui.input.key.Key.Backspace) {
|
||||
viewModel.backspace()
|
||||
onClearShift()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
|
||||
TwoRowKeyBar(
|
||||
connectionState = connectionState,
|
||||
isShiftPressed = isShiftPressed,
|
||||
onClearShift = onClearShift,
|
||||
onConnectClick = { showConnectDialog = true },
|
||||
onDisconnectClick = { viewModel.disconnect() },
|
||||
onFtClick = { showFtDialog = true },
|
||||
onSendAid = { aid ->
|
||||
viewModel.sendAid(aid)
|
||||
focusRequester.requestFocus()
|
||||
},
|
||||
onReset = {
|
||||
viewModel.resetKeyboard()
|
||||
focusRequester.requestFocus()
|
||||
},
|
||||
onTab = {
|
||||
viewModel.tab()
|
||||
focusRequester.requestFocus()
|
||||
},
|
||||
onBackTab = {
|
||||
viewModel.backTab()
|
||||
focusRequester.requestFocus()
|
||||
},
|
||||
onCursorLeft = {
|
||||
viewModel.cursorLeft()
|
||||
focusRequester.requestFocus()
|
||||
},
|
||||
onCursorUp = {
|
||||
viewModel.cursorUp()
|
||||
focusRequester.requestFocus()
|
||||
},
|
||||
onCursorDown = {
|
||||
viewModel.cursorDown()
|
||||
focusRequester.requestFocus()
|
||||
},
|
||||
onCursorRight = {
|
||||
viewModel.cursorRight()
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
)
|
||||
|
||||
// OIA Status Bar
|
||||
OiaStatusBar(
|
||||
connectionState = connectionState,
|
||||
currentHost = viewModel.currentHost,
|
||||
isKeyboardLocked = isKeyboardLocked,
|
||||
oiaText = oiaText,
|
||||
cursorAddr = cursorAddr,
|
||||
rows = rows,
|
||||
cols = cols
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showConnectDialog) {
|
||||
ConnectDialog(
|
||||
onDismiss = { showConnectDialog = false },
|
||||
onConnect = { host, port, model, luName ->
|
||||
showConnectDialog = false
|
||||
viewModel.connect(host, port, model, luName)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showFtDialog) {
|
||||
FileTransferDialog(
|
||||
onDismiss = { showFtDialog = false },
|
||||
onStartTransfer = { config ->
|
||||
showFtDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
package org.pubvm.a3270
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.lib3270j.ConnectionConfig
|
||||
import org.lib3270j.ConnectionState
|
||||
import org.lib3270j.Telnet3270Client
|
||||
import org.lib3270j.TerminalModel
|
||||
import org.lib3270j.listener.ConnectionListener
|
||||
import org.lib3270j.listener.ScreenUpdateListener
|
||||
import org.lib3270j.protocol.DS3270Constants.faIsProtected
|
||||
import org.lib3270j.screen.ScreenBuffer
|
||||
import org.pubvm.a3270.service.TerminalService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.logging.Logger
|
||||
|
||||
class TerminalViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
|
||||
|
||||
var isAppInBackground: Boolean = false
|
||||
|
||||
// Dedicated single-thread FIFO queue for all local keyboard and cursor actions
|
||||
private val inputExecutor = Executors.newSingleThreadExecutor()
|
||||
private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
|
||||
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _screenBuffer = MutableStateFlow<ScreenBuffer?>(null)
|
||||
val screenBuffer: StateFlow<ScreenBuffer?> = _screenBuffer.asStateFlow()
|
||||
|
||||
private val _rows = MutableStateFlow(24)
|
||||
val rows: StateFlow<Int> = _rows.asStateFlow()
|
||||
|
||||
private val _cols = MutableStateFlow(80)
|
||||
val cols: StateFlow<Int> = _cols.asStateFlow()
|
||||
|
||||
private val _cursorAddress = MutableStateFlow(0)
|
||||
val cursorAddress: StateFlow<Int> = _cursorAddress.asStateFlow()
|
||||
|
||||
private val _oiaText = MutableStateFlow("Disconnected")
|
||||
val oiaText: StateFlow<String> = _oiaText.asStateFlow()
|
||||
|
||||
private val _screenVersion = MutableStateFlow(0L)
|
||||
val screenVersion: StateFlow<Long> = _screenVersion.asStateFlow()
|
||||
|
||||
private val _isKeyboardLocked = MutableStateFlow(false)
|
||||
val isKeyboardLocked: StateFlow<Boolean> = _isKeyboardLocked.asStateFlow()
|
||||
|
||||
private var client: Telnet3270Client? = null
|
||||
var currentHost: String = ""
|
||||
private set
|
||||
|
||||
private var lastScreenContentHash: Int = 0
|
||||
private var hasInitialScreenLoaded: Boolean = false
|
||||
|
||||
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "") {
|
||||
if (_connectionState.value.isConnected()) return
|
||||
currentHost = host
|
||||
lastScreenContentHash = 0
|
||||
hasInitialScreenLoaded = false
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
_oiaText.value = "Connecting to $host:$port..."
|
||||
|
||||
val model = when (modelNum) {
|
||||
3 -> TerminalModel.IBM_3279_3
|
||||
4 -> TerminalModel.IBM_3279_4
|
||||
5 -> TerminalModel.IBM_3279_5
|
||||
else -> TerminalModel.IBM_3279_2
|
||||
}
|
||||
|
||||
val config = ConnectionConfig(host, port, model).apply {
|
||||
if (luName.isNotBlank()) {
|
||||
setLuName(luName)
|
||||
}
|
||||
}
|
||||
|
||||
val newClient = Telnet3270Client(config)
|
||||
this@TerminalViewModel.client = newClient
|
||||
|
||||
newClient.addConnectionListener(object : ConnectionListener {
|
||||
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
|
||||
_connectionState.value = newState
|
||||
_oiaText.value = if (newState.isFullSession()) {
|
||||
"3270 Connected ($host)"
|
||||
} else if (newState.isHalfConnected()) {
|
||||
"Connecting..."
|
||||
} else {
|
||||
"Disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionError(message: String) {
|
||||
log.warning("Connection error: $message")
|
||||
_oiaText.value = "Error: $message"
|
||||
}
|
||||
})
|
||||
|
||||
newClient.inputProcessor.setLockStateListener { locked ->
|
||||
_isKeyboardLocked.value = locked
|
||||
}
|
||||
|
||||
newClient.addScreenUpdateListener(object : ScreenUpdateListener {
|
||||
override fun onScreenUpdated() {
|
||||
val buf = newClient.screenBuffer
|
||||
_screenBuffer.value = buf
|
||||
if (buf != null) {
|
||||
_rows.value = buf.rows
|
||||
_cols.value = buf.cols
|
||||
_cursorAddress.value = buf.cursorAddress
|
||||
}
|
||||
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
|
||||
val newHash = computeScreenContentHash(buf)
|
||||
val contentChanged = (newHash != lastScreenContentHash)
|
||||
|
||||
if (!hasInitialScreenLoaded) {
|
||||
if (buf != null && newHash != 0) {
|
||||
hasInitialScreenLoaded = true
|
||||
lastScreenContentHash = newHash
|
||||
}
|
||||
} else if (contentChanged) {
|
||||
lastScreenContentHash = newHash
|
||||
if (isAppInBackground) {
|
||||
val snippet = extractScreenSnippet(buf)
|
||||
val activeHost = currentHost.ifBlank { "Mainframe" }
|
||||
TerminalService.notifyScreenUpdate(getApplication(), activeHost, snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSoundAlarm() {
|
||||
log.fine("Sound Alarm")
|
||||
}
|
||||
})
|
||||
|
||||
_rows.value = model.defaultRows
|
||||
_cols.value = model.defaultCols
|
||||
_screenBuffer.value = newClient.screenBuffer
|
||||
|
||||
newClient.connect()
|
||||
} catch (e: Exception) {
|
||||
log.severe("Failed to connect: ${e.message}")
|
||||
_connectionState.value = ConnectionState.NOT_CONNECTED
|
||||
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
client?.disconnect()
|
||||
client = null
|
||||
_connectionState.value = ConnectionState.NOT_CONNECTED
|
||||
_oiaText.value = "Disconnected"
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error disconnecting: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun typeChar(ch: Char) {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
val ip = c.inputProcessor
|
||||
ip.isKeyboardLocked = false // Ensure keyboard lock is cleared on user typing
|
||||
val buf = c.screenBuffer
|
||||
var curAddr = buf.cursorAddress
|
||||
if (buf.isFormatted) {
|
||||
val faVal = buf.getFieldAttributeAt(curAddr)
|
||||
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
|
||||
curAddr = buf.findNextUnprotected(curAddr)
|
||||
buf.cursorAddress = curAddr
|
||||
}
|
||||
}
|
||||
ip.typeCharacter(ch)
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error typing char: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pasteString(text: String) {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
val ip = c.inputProcessor
|
||||
ip.isKeyboardLocked = false
|
||||
for (ch in text) {
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
ip.tab()
|
||||
} else if (ch >= ' ') {
|
||||
ip.typeCharacter(ch)
|
||||
}
|
||||
}
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error pasting text: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun backspace() {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
c.inputProcessor.isKeyboardLocked = false
|
||||
c.inputProcessor.backspace()
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error backspacing: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun tab() {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
c.inputProcessor.tab()
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error tabbing: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun backTab() {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
c.inputProcessor.backTab()
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error back-tabbing: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetKeyboard() {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
c.inputProcessor.reset()
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error resetting keyboard: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendAid(aidCode: Int) {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
c.inputProcessor.setKeyboardLocked(false)
|
||||
c.inputProcessor.sendAid(aidCode)
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error sending AID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCursor(baddr: Int) {
|
||||
val buf = _screenBuffer.value ?: return
|
||||
if (baddr in 0 until (buf.rows * buf.cols)) {
|
||||
buf.cursorAddress = baddr
|
||||
_cursorAddress.value = baddr
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
fun cursorLeft() {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
c.inputProcessor.cursorLeft()
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error cursorLeft: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cursorUp() {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
c.inputProcessor.cursorUp()
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error cursorUp: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cursorDown() {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
c.inputProcessor.cursorDown()
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error cursorDown: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cursorRight() {
|
||||
val c = client ?: return
|
||||
viewModelScope.launch(inputDispatcher) {
|
||||
try {
|
||||
c.inputProcessor.cursorRight()
|
||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error cursorRight: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
|
||||
if (buf == null) return "Mainframe screen update received"
|
||||
val rows = buf.rows
|
||||
val cols = buf.cols
|
||||
if (rows <= 0 || cols <= 0) return "Mainframe screen update received"
|
||||
|
||||
val curAddr = _cursorAddress.value
|
||||
val cursorRow = (curAddr / cols).coerceIn(0, rows - 1)
|
||||
|
||||
// 1. Check bottom 3 lines first (where 3270 status, errors, reader & I/S MSGs land)
|
||||
val minBottomRow = (rows - 3).coerceAtLeast(1)
|
||||
for (r in (rows - 1) downTo minBottomRow) {
|
||||
val line = getLineText(buf, r, cols).trim()
|
||||
if (line.length >= 4 && !line.startsWith("***") && !line.startsWith("===")) {
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check cursor row if not row 0
|
||||
if (cursorRow > 0) {
|
||||
val cursorLine = getLineText(buf, cursorRow, cols).trim()
|
||||
if (cursorLine.length >= 4) {
|
||||
return cursorLine
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scan rows from bottom to top, skipping static header row 0
|
||||
for (r in (rows - 1) downTo 1) {
|
||||
val line = getLineText(buf, r, cols).trim()
|
||||
if (line.length >= 4) {
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
return getLineText(buf, 0, cols).trim().ifBlank { "Mainframe screen update received" }
|
||||
}
|
||||
|
||||
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
|
||||
val sb = StringBuilder()
|
||||
for (c in 0 until cols) {
|
||||
val addr = row * cols + c
|
||||
val cell = buf.getCell(addr)
|
||||
if (cell != null && !cell.isFieldAttribute) {
|
||||
val ch = cell.ucs4
|
||||
if (ch in '!'..'~' || ch.code > 127) {
|
||||
sb.append(ch)
|
||||
} else {
|
||||
sb.append(' ')
|
||||
}
|
||||
} else {
|
||||
sb.append(' ')
|
||||
}
|
||||
}
|
||||
return sb.toString().replace(Regex("\\s+"), " ").trim()
|
||||
}
|
||||
|
||||
private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
|
||||
if (buf == null) return 0
|
||||
val sb = StringBuilder()
|
||||
val total = buf.rows * buf.cols
|
||||
for (i in 0 until total) {
|
||||
val cell = buf.getCell(i)
|
||||
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
||||
sb.append(cell.ucs4)
|
||||
}
|
||||
}
|
||||
return sb.toString().hashCode()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
client?.disconnect()
|
||||
inputExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package org.pubvm.a3270.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
data class SavedHost(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int = 23,
|
||||
val model: Int = 2,
|
||||
val luName: String = "",
|
||||
val autoConnect: Boolean = false
|
||||
) {
|
||||
fun toJson(): JSONObject {
|
||||
return JSONObject().apply {
|
||||
put("id", id)
|
||||
put("name", name)
|
||||
put("host", host)
|
||||
put("port", port)
|
||||
put("model", model)
|
||||
put("luName", luName)
|
||||
put("autoConnect", autoConnect)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: JSONObject): SavedHost {
|
||||
return SavedHost(
|
||||
id = json.optString("id", UUID.randomUUID().toString()),
|
||||
name = json.optString("name", "Mainframe"),
|
||||
host = json.optString("host", "127.0.0.1"),
|
||||
port = json.optInt("port", 23),
|
||||
model = json.optInt("model", 2),
|
||||
luName = json.optString("luName", ""),
|
||||
autoConnect = json.optBoolean("autoConnect", false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HostStorage {
|
||||
private const val PREFS_NAME = "a3270_host_prefs"
|
||||
private const val KEY_SAVED_HOSTS = "saved_hosts"
|
||||
|
||||
private fun getPrefs(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun getSavedHosts(context: Context): List<SavedHost> {
|
||||
val jsonStr = getPrefs(context).getString(KEY_SAVED_HOSTS, null) ?: return emptyList()
|
||||
val list = mutableListOf<SavedHost>()
|
||||
try {
|
||||
val jsonArray = JSONArray(jsonStr)
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val obj = jsonArray.getJSONObject(i)
|
||||
list.add(SavedHost.fromJson(obj))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun saveHost(context: Context, host: SavedHost) {
|
||||
val currentHosts = getSavedHosts(context).toMutableList()
|
||||
val index = currentHosts.indexOfFirst { it.id == host.id }
|
||||
|
||||
// If this host is set to autoConnect, unset autoConnect on all others
|
||||
val updatedHost = if (host.autoConnect) {
|
||||
currentHosts.indices.forEach { i ->
|
||||
currentHosts[i] = currentHosts[i].copy(autoConnect = false)
|
||||
}
|
||||
host
|
||||
} else {
|
||||
host
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
currentHosts[index] = updatedHost
|
||||
} else {
|
||||
currentHosts.add(updatedHost)
|
||||
}
|
||||
|
||||
saveAll(context, currentHosts)
|
||||
}
|
||||
|
||||
fun deleteHost(context: Context, hostId: String) {
|
||||
val currentHosts = getSavedHosts(context).filter { it.id != hostId }
|
||||
saveAll(context, currentHosts)
|
||||
}
|
||||
|
||||
fun getAutoConnectHost(context: Context): SavedHost? {
|
||||
return getSavedHosts(context).firstOrNull { it.autoConnect }
|
||||
}
|
||||
|
||||
private fun saveAll(context: Context, hosts: List<SavedHost>) {
|
||||
val array = JSONArray()
|
||||
hosts.forEach { array.put(it.toJson()) }
|
||||
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package org.pubvm.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import org.pubvm.a3270.storage.HostStorage
|
||||
import org.pubvm.a3270.storage.SavedHost
|
||||
|
||||
@Composable
|
||||
fun ConnectDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConnect: (host: String, port: Int, model: Int, luName: String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
|
||||
var selectedHostId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var profileName by remember { mutableStateOf("") }
|
||||
var host by remember { mutableStateOf("127.0.0.1") }
|
||||
var portStr by remember { mutableStateOf("23") }
|
||||
var modelNum by remember { mutableIntStateOf(2) }
|
||||
var luName by remember { mutableStateOf("") }
|
||||
var autoConnect by remember { mutableStateOf(false) }
|
||||
|
||||
fun loadProfile(saved: SavedHost) {
|
||||
selectedHostId = saved.id
|
||||
profileName = saved.name
|
||||
host = saved.host
|
||||
portStr = saved.port.toString()
|
||||
modelNum = saved.model
|
||||
luName = saved.luName
|
||||
autoConnect = saved.autoConnect
|
||||
}
|
||||
|
||||
fun clearFields() {
|
||||
selectedHostId = null
|
||||
profileName = ""
|
||||
host = "127.0.0.1"
|
||||
portStr = "23"
|
||||
modelNum = 2
|
||||
luName = ""
|
||||
autoConnect = false
|
||||
}
|
||||
|
||||
fun saveCurrentProfile(): SavedHost? {
|
||||
val port = portStr.toIntOrNull() ?: 23
|
||||
if (host.isBlank()) return null
|
||||
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
|
||||
val hostToSave = SavedHost(
|
||||
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
|
||||
name = nameToSave,
|
||||
host = host.trim(),
|
||||
port = port,
|
||||
model = modelNum,
|
||||
luName = luName.trim(),
|
||||
autoConnect = autoConnect
|
||||
)
|
||||
HostStorage.saveHost(context, hostToSave)
|
||||
savedHosts = HostStorage.getSavedHosts(context)
|
||||
selectedHostId = hostToSave.id
|
||||
return hostToSave
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(150)
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color(0xFF1E1E1E),
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.96f)
|
||||
.wrapContentHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
// Title Row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Connect / Manage Hosts", fontWeight = FontWeight.Bold, fontSize = 15.sp, color = Color.White)
|
||||
TextButton(
|
||||
onClick = { clearFields() },
|
||||
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text("+ New Profile", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable Content Container
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
// Saved Profiles List
|
||||
if (savedHosts.isNotEmpty()) {
|
||||
Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 95.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
savedHosts.forEach { profile ->
|
||||
val isSelected = profile.id == selectedHostId
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = if (isSelected) Color(0xFF2C2D30) else Color(0xFF161719),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = if (isSelected) 1.dp else 0.dp,
|
||||
color = if (isSelected) Color(0xFF4DABF7) else Color.Transparent,
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
)
|
||||
.clickable { loadProfile(profile) }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(profile.name, fontWeight = FontWeight.Bold, fontSize = 12.sp, color = Color.White)
|
||||
if (profile.autoConnect) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = Color(0xFF2B8A3E),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
"AUTO",
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("${profile.host}:${profile.port} (M${profile.model})", fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
Row {
|
||||
TextButton(onClick = { loadProfile(profile) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
|
||||
Text("Edit", fontSize = 11.sp)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
HostStorage.deleteHost(context, profile.id)
|
||||
savedHosts = HostStorage.getSavedHosts(context)
|
||||
if (selectedHostId == profile.id) {
|
||||
clearFields()
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFF6B6B))
|
||||
) {
|
||||
Text("Delete", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (selectedHostId != null) "Editing Profile:" else "New Profile Details:",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = profileName,
|
||||
onValueChange = { profileName = it },
|
||||
label = { Text("Profile Name (Optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host / IP Address") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = portStr,
|
||||
onValueChange = { portStr = it },
|
||||
label = { Text("Port (default 23)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Model:", fontSize = 11.sp, color = Color.Gray)
|
||||
listOf(2, 3, 4, 5).forEach { m ->
|
||||
FilterChip(
|
||||
selected = (modelNum == m),
|
||||
onClick = { modelNum = m },
|
||||
label = { Text("M$m", fontSize = 11.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = luName,
|
||||
onValueChange = { luName = it },
|
||||
label = { Text("LU Name (Optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { autoConnect = !autoConnect }
|
||||
) {
|
||||
Checkbox(
|
||||
checked = autoConnect,
|
||||
onCheckedChange = { autoConnect = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Auto-connect on app startup", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action Buttons Row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
OutlinedButton(onClick = { saveCurrentProfile() }) {
|
||||
Text("Save")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val saved = saveCurrentProfile()
|
||||
if (saved != null) {
|
||||
onConnect(saved.host, saved.port, saved.model, saved.luName)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package org.pubvm.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.lib3270j.ft.FTConfig
|
||||
|
||||
@Composable
|
||||
fun FileTransferDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onStartTransfer: (FTConfig) -> Unit
|
||||
) {
|
||||
var hostFile by remember { mutableStateOf("") }
|
||||
var localFile by remember { mutableStateOf("") }
|
||||
var isReceive by remember { mutableStateOf(true) }
|
||||
var isAscii by remember { mutableStateOf(true) }
|
||||
var hostType by remember { mutableStateOf(FTConfig.HostType.TSO) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(150)
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("IND\$FILE File Transfer") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = hostFile,
|
||||
onValueChange = { hostFile = it },
|
||||
label = { Text("Host File Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = localFile,
|
||||
onValueChange = { localFile = it },
|
||||
label = { Text("Local File Path") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Direction:")
|
||||
Row {
|
||||
FilterChip(
|
||||
selected = isReceive,
|
||||
onClick = { isReceive = true },
|
||||
label = { Text("Receive (GET)") }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
FilterChip(
|
||||
selected = !isReceive,
|
||||
onClick = { isReceive = false },
|
||||
label = { Text("Send (PUT)") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Mode:")
|
||||
Row {
|
||||
FilterChip(
|
||||
selected = isAscii,
|
||||
onClick = { isAscii = true },
|
||||
label = { Text("ASCII") }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
FilterChip(
|
||||
selected = !isAscii,
|
||||
onClick = { isAscii = false },
|
||||
label = { Text("Binary") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Host:")
|
||||
Row {
|
||||
FTConfig.HostType.values().forEach { ht ->
|
||||
FilterChip(
|
||||
selected = (hostType == ht),
|
||||
onClick = { hostType = ht },
|
||||
label = { Text(ht.name) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (hostFile.isNotBlank() && localFile.isNotBlank()) {
|
||||
val config = FTConfig().apply {
|
||||
setHostFilename(hostFile.trim())
|
||||
setLocalFilename(localFile.trim())
|
||||
setReceive(isReceive)
|
||||
setAscii(isAscii)
|
||||
setHostType(hostType)
|
||||
}
|
||||
onStartTransfer(config)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Start Transfer")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package org.pubvm.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import org.lib3270j.ConnectionState
|
||||
import org.lib3270j.protocol.DS3270Constants.*
|
||||
|
||||
@Composable
|
||||
fun TwoRowKeyBar(
|
||||
connectionState: ConnectionState,
|
||||
isShiftPressed: Boolean = false,
|
||||
onClearShift: () -> Unit = {},
|
||||
onConnectClick: () -> Unit,
|
||||
onDisconnectClick: () -> Unit,
|
||||
onFtClick: () -> Unit,
|
||||
onSendAid: (Int) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onTab: () -> Unit,
|
||||
onBackTab: () -> Unit,
|
||||
onCursorLeft: () -> Unit = {},
|
||||
onCursorUp: () -> Unit = {},
|
||||
onCursorDown: () -> Unit = {},
|
||||
onCursorRight: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFF161719))
|
||||
) {
|
||||
// Row 1: System & Quick Action Keys (Static 12-key row matching Row 2 columns)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(35.dp)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 1. Menu Dropdown Button (Connect/Disconnect/FT)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
KeyButton(
|
||||
label = "⋮",
|
||||
color = Color(0xFF343A40),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = { menuExpanded = true }
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
modifier = Modifier.background(Color(0xFF2C2D30))
|
||||
) {
|
||||
if (!connectionState.isConnected()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Connect", color = Color.White, fontWeight = FontWeight.SemiBold) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onConnectClick()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onDisconnectClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("File Transfer (FT)", color = Color.White, fontWeight = FontWeight.SemiBold) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onFtClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. TAB / BTAB depending on Shift state
|
||||
val tabLabel = if (isShiftPressed) "BTAB" else "TAB"
|
||||
KeyButton(
|
||||
label = tabLabel,
|
||||
color = Color(0xFF1C7ED6),
|
||||
modifier = Modifier.weight(1f),
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
if (isShiftPressed) {
|
||||
onBackTab()
|
||||
onClearShift()
|
||||
} else {
|
||||
onTab()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 3. RESET
|
||||
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onReset)
|
||||
|
||||
// 4. ENTER
|
||||
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
|
||||
|
||||
// 5. CLEAR
|
||||
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
|
||||
|
||||
// 6. PA1
|
||||
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
|
||||
|
||||
// 7. PA2
|
||||
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
|
||||
|
||||
// 8. PA3
|
||||
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
|
||||
|
||||
// 9. Left Navigation
|
||||
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
|
||||
|
||||
// 10. Up Navigation
|
||||
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
|
||||
|
||||
// 11. Down Navigation
|
||||
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
|
||||
|
||||
// 12. Right Navigation
|
||||
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
|
||||
|
||||
// Row 2: PF Keys (F1-F12 when Shift is off; F13-F24 when Shift is down) - Non-scrolling, full width
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(35.dp)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val pfAids = intArrayOf(
|
||||
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
|
||||
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
|
||||
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
|
||||
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
|
||||
)
|
||||
|
||||
val startPf = if (isShiftPressed) 13 else 1
|
||||
val endPf = if (isShiftPressed) 24 else 12
|
||||
|
||||
for (i in startPf..endPf) {
|
||||
val aid = pfAids[i - 1]
|
||||
KeyButton(
|
||||
label = "F$i",
|
||||
color = Color(0xFF364FC7),
|
||||
modifier = Modifier.weight(1f),
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
onSendAid(aid)
|
||||
if (isShiftPressed) {
|
||||
onClearShift()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun KeyButton(
|
||||
label: String,
|
||||
color: Color,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = color,
|
||||
shadowElevation = 1.dp,
|
||||
modifier = modifier
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp)
|
||||
.height(31.dp)
|
||||
.focusProperties { canFocus = false }
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(horizontal = innerPaddingHorizontal)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package org.pubvm.a3270.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.lib3270j.ConnectionState
|
||||
|
||||
@Composable
|
||||
fun OiaStatusBar(
|
||||
connectionState: ConnectionState,
|
||||
currentHost: String,
|
||||
isKeyboardLocked: Boolean,
|
||||
oiaText: String,
|
||||
cursorAddr: Int,
|
||||
rows: Int,
|
||||
cols: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
|
||||
val col = if (cols > 0) (cursorAddr % cols) + 1 else 1
|
||||
val posStr = String.format("%02d/%02d", row, col)
|
||||
|
||||
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
|
||||
|
||||
val lockStatusText = if (!connectionState.isConnected()) {
|
||||
"OFFLINE"
|
||||
} else if (isKeyboardLocked) {
|
||||
"X SYSTEM"
|
||||
} else {
|
||||
"READY"
|
||||
}
|
||||
|
||||
val lockStatusColor = if (!connectionState.isConnected()) {
|
||||
Color(0xFF868E96)
|
||||
} else if (isKeyboardLocked) {
|
||||
Color(0xFFFF6B6B) // Red for locked
|
||||
} else {
|
||||
Color(0xFF51CF66) // Green for ready
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(26.dp)
|
||||
.background(Color(0xFF161719))
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Connected Host & Status Indicator
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(if (connectionState.isConnected()) Color(0xFF51CF66) else Color(0xFF868E96))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = hostText,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
|
||||
Surface(
|
||||
color = lockStatusColor.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = lockStatusText,
|
||||
color = lockStatusColor,
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Cursor Position
|
||||
Text(
|
||||
text = "R$posStr",
|
||||
color = Color(0xFF22B8CF),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
package org.pubvm.a3270.ui
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.lib3270j.protocol.DS3270Constants.*
|
||||
import org.lib3270j.screen.ExtendedAttribute
|
||||
import org.lib3270j.screen.ScreenBuffer
|
||||
|
||||
// Standard 3279 Host Colors (16-color palette)
|
||||
private val HOST_COLORS = arrayOf(
|
||||
Color(0xFF000000), // 0: Neutral Black
|
||||
Color(0xFF5078FF), // 1: Blue
|
||||
Color(0xFFFF3232), // 2: Red
|
||||
Color(0xFFFF82B4), // 3: Pink
|
||||
Color(0xFF32CD32), // 4: Green
|
||||
Color(0xFF40E0D0), // 5: Turquoise
|
||||
Color(0xFFFFFF50), // 6: Yellow
|
||||
Color(0xFFFFFFFF), // 7: Neutral White
|
||||
Color(0xFF000000), // 8: Black
|
||||
Color(0xFF1E3CB4), // 9: Deep Blue
|
||||
Color(0xFFFFA500), // 10: Orange
|
||||
Color(0xFFB482FF), // 11: Purple
|
||||
Color(0xFF90EE90), // 12: Pale Green
|
||||
Color(0xFFAFEEEE), // 13: Pale Turquoise
|
||||
Color(0xFFAAAAAA), // 14: Grey
|
||||
Color(0xFFFFFFFF) // 15: White
|
||||
)
|
||||
|
||||
private val COLOR_BLACK = Color(0xFF0A0A0A)
|
||||
|
||||
@Composable
|
||||
fun TerminalView(
|
||||
screenBuffer: ScreenBuffer?,
|
||||
rows: Int,
|
||||
cols: Int,
|
||||
cursorAddr: Int,
|
||||
screenVersion: Long,
|
||||
onTapAddress: (Int) -> Unit,
|
||||
onPasteText: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
var selectionStart by remember { mutableStateOf<Offset?>(null) }
|
||||
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(COLOR_BLACK)
|
||||
.pointerInput(cols, rows) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val startTime = System.currentTimeMillis()
|
||||
val startPos = down.position
|
||||
var currentPos = startPos
|
||||
var isDragStarted = false
|
||||
val slop = viewConfiguration.touchSlop
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) {
|
||||
break
|
||||
}
|
||||
currentPos = change.position
|
||||
val distance = (currentPos - startPos).getDistance()
|
||||
|
||||
if (distance > slop) {
|
||||
if (!isDragStarted) {
|
||||
isDragStarted = true
|
||||
selectionStart = startPos
|
||||
showContextMenu = false
|
||||
}
|
||||
selectionEnd = currentPos
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
val totalDistance = (currentPos - startPos).getDistance()
|
||||
|
||||
if (isDragStarted) {
|
||||
// Drag completed: Keep selection box highlighted, DO NOT open context menu
|
||||
showContextMenu = false
|
||||
} else if (duration >= 600L && totalDistance <= slop) {
|
||||
// Stationary Long Press (held >= 600ms without dragging)
|
||||
contextMenuOffset = startPos
|
||||
showContextMenu = true
|
||||
} else if (totalDistance <= slop) {
|
||||
// Short Tap (Move Cursor)
|
||||
showContextMenu = false
|
||||
selectionStart = null
|
||||
selectionEnd = null
|
||||
val cellWidth = size.width / cols
|
||||
val cellHeight = size.height / rows
|
||||
if (cellWidth > 0 && cellHeight > 0) {
|
||||
val col = (startPos.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val row = (startPos.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
var addr = row * cols + col
|
||||
|
||||
val buf = screenBuffer
|
||||
if (buf != null && buf.isFormatted) {
|
||||
val faVal = buf.getFieldAttributeAt(addr)
|
||||
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
|
||||
addr = buf.findNextUnprotected(addr)
|
||||
}
|
||||
}
|
||||
onTapAddress(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val cellWidth = width / cols
|
||||
val cellHeight = height / rows
|
||||
|
||||
if (cellWidth <= 0 || cellHeight <= 0) return@Canvas
|
||||
|
||||
val paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
typeface = Typeface.MONOSPACE
|
||||
textSize = cellHeight * 0.85f
|
||||
}
|
||||
|
||||
val buf = screenBuffer
|
||||
val totalCells = rows * cols
|
||||
|
||||
var currentFA: Byte = 0
|
||||
var currentFieldEa: ExtendedAttribute? = null
|
||||
|
||||
// Calculate selected cell rectangle range if drag selection active
|
||||
var selMinRow = -1
|
||||
var selMaxRow = -1
|
||||
var selMinCol = -1
|
||||
var selMaxCol = -1
|
||||
|
||||
val start = selectionStart
|
||||
val end = selectionEnd
|
||||
if (start != null && end != null) {
|
||||
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
|
||||
selMinRow = minOf(startRow, endRow)
|
||||
selMaxRow = maxOf(startRow, endRow)
|
||||
selMinCol = minOf(startCol, endCol)
|
||||
selMaxCol = maxOf(startCol, endCol)
|
||||
}
|
||||
|
||||
for (r in 0 until rows) {
|
||||
for (c in 0 until cols) {
|
||||
val addr = r * cols + c
|
||||
if (addr >= totalCells) break
|
||||
|
||||
val left = c * cellWidth
|
||||
val top = r * cellHeight
|
||||
|
||||
var charVal = ' '
|
||||
var fgColor: Color
|
||||
var bgColor = COLOR_BLACK
|
||||
var isBold = false
|
||||
var isUnderline = false
|
||||
var isReverse = false
|
||||
|
||||
if (buf != null) {
|
||||
val ea = buf.getCell(addr)
|
||||
|
||||
if (ea.isFieldAttribute) {
|
||||
currentFA = ea.fa
|
||||
currentFieldEa = ea
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute 3270 color and field intensity
|
||||
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
|
||||
bgColor = getBgColorForAttribute(ea, currentFieldEa)
|
||||
|
||||
// Intensity
|
||||
if (faIsHigh(currentFA.toInt() and 0xFF)) {
|
||||
isBold = true
|
||||
}
|
||||
|
||||
// Invisible fields (passwords / hidden)
|
||||
if (faIsZero(currentFA.toInt() and 0xFF)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extended Graphic Rendition
|
||||
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
|
||||
if (gr != 0.toByte()) {
|
||||
val grVal = gr.toInt() and 0xFF
|
||||
if ((grVal and GR_INTENSIFY) != 0) isBold = true
|
||||
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
|
||||
if ((grVal and GR_REVERSE) != 0) isReverse = true
|
||||
}
|
||||
|
||||
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
|
||||
charVal = ea.ucs4
|
||||
}
|
||||
} else {
|
||||
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
|
||||
}
|
||||
|
||||
if (isReverse) {
|
||||
val tmp = fgColor
|
||||
fgColor = bgColor
|
||||
bgColor = tmp
|
||||
}
|
||||
|
||||
if (bgColor != COLOR_BLACK) {
|
||||
drawRect(
|
||||
color = bgColor,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(cellWidth, cellHeight)
|
||||
)
|
||||
}
|
||||
|
||||
// Highlight selected block range
|
||||
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
|
||||
if (isSelected) {
|
||||
drawRect(
|
||||
color = Color(0x773399FF),
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(cellWidth, cellHeight)
|
||||
)
|
||||
}
|
||||
|
||||
if (addr == cursorAddr && !isSelected) {
|
||||
drawRect(
|
||||
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(cellWidth, cellHeight)
|
||||
)
|
||||
}
|
||||
|
||||
if (charVal != ' ') {
|
||||
paint.color = fgColor.toArgb()
|
||||
paint.isFakeBoldText = isBold
|
||||
val fontMetrics = paint.fontMetrics
|
||||
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
charVal.toString(),
|
||||
left + (cellWidth / 4),
|
||||
textY,
|
||||
paint
|
||||
)
|
||||
}
|
||||
|
||||
if (isUnderline) {
|
||||
drawLine(
|
||||
color = fgColor,
|
||||
start = Offset(left, top + cellHeight - 2),
|
||||
end = Offset(left + cellWidth, top + cellHeight - 2),
|
||||
strokeWidth = 2f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context Menu Popup
|
||||
DropdownMenu(
|
||||
expanded = showContextMenu,
|
||||
onDismissRequest = { showContextMenu = false },
|
||||
offset = DpOffset(
|
||||
x = (contextMenuOffset.x / density.density).dp,
|
||||
y = (contextMenuOffset.y / density.density).dp
|
||||
)
|
||||
) {
|
||||
val hasSelection = selectionStart != null && selectionEnd != null
|
||||
if (hasSelection) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy Selection") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Clear Selection") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
selectionStart = null
|
||||
selectionEnd = null
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Select All") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
selectionStart = Offset(0f, 0f)
|
||||
selectionEnd = Offset(100000f, 100000f)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("Paste") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
val clipText = clipboardManager.getText()?.text
|
||||
if (!clipText.isNullOrEmpty()) {
|
||||
onPasteText(clipText)
|
||||
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copySelection(
|
||||
start: Offset?,
|
||||
end: Offset?,
|
||||
screenBuffer: ScreenBuffer?,
|
||||
rows: Int,
|
||||
cols: Int,
|
||||
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
|
||||
context: android.content.Context
|
||||
) {
|
||||
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
val cellWidth = displayMetrics.widthPixels.toFloat() / cols
|
||||
val cellHeight = displayMetrics.heightPixels.toFloat() / rows
|
||||
|
||||
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
|
||||
val minRow = minOf(startRow, endRow)
|
||||
val maxRow = maxOf(startRow, endRow)
|
||||
val minCol = minOf(startCol, endCol)
|
||||
val maxCol = maxOf(startCol, endCol)
|
||||
|
||||
val sb = StringBuilder()
|
||||
for (r in minRow..maxRow) {
|
||||
val line = StringBuilder()
|
||||
for (c in minCol..maxCol) {
|
||||
val addr = r * cols + c
|
||||
if (addr < (rows * cols)) {
|
||||
val cell = screenBuffer.getCell(addr)
|
||||
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
||||
line.append(cell.ucs4)
|
||||
} else {
|
||||
line.append(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append(line.toString().trimEnd()).append('\n')
|
||||
}
|
||||
val textToCopy = sb.toString().trimEnd()
|
||||
if (textToCopy.isNotBlank()) {
|
||||
clipboardManager.setText(AnnotatedString(textToCopy))
|
||||
Toast.makeText(context, "Copied selected block to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color {
|
||||
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF)
|
||||
else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
|
||||
else 0
|
||||
|
||||
if (fg in 0xf0..0xff) {
|
||||
return HOST_COLORS[fg - 0xf0]
|
||||
}
|
||||
val fa = currentFA.toInt() and 0xFF
|
||||
return if (faIsProtected(fa)) {
|
||||
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_BLUE]
|
||||
} else {
|
||||
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
|
||||
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
|
||||
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
|
||||
else 0
|
||||
|
||||
if (bg in 0xf0..0xff) {
|
||||
val idx = bg - 0xf0
|
||||
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
|
||||
return COLOR_BLACK
|
||||
}
|
||||
return HOST_COLORS[idx]
|
||||
}
|
||||
return COLOR_BLACK
|
||||
}
|
||||
|
||||
private fun Color.toArgb(): Int {
|
||||
return (alpha * 255).toInt() shl 24 or
|
||||
((red * 255).toInt() shl 16) or
|
||||
((green * 255).toInt() shl 8) or
|
||||
(blue * 255).toInt()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,114 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group
|
||||
android:scaleX="0.029365"
|
||||
android:scaleY="-0.029365"
|
||||
android:translateX="-8.57"
|
||||
android:translateY="103.38">
|
||||
<!-- Green Terminal Screen Frame -->
|
||||
<path
|
||||
android:strokeColor="#00FF00"
|
||||
android:strokeWidth="40"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
|
||||
|
||||
<!-- Blue Wave / Exponential Curve -->
|
||||
<path
|
||||
android:strokeColor="#0000FF"
|
||||
android:strokeWidth="60"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
|
||||
|
||||
<!-- Red Circular Arc -->
|
||||
<path
|
||||
android:strokeColor="#FF0000"
|
||||
android:strokeWidth="60"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
|
||||
|
||||
<!-- Yellow Smiley Head -->
|
||||
<path
|
||||
android:strokeColor="#FFFF00"
|
||||
android:strokeWidth="50"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
|
||||
|
||||
<!-- Magenta Typography: lib3270j -->
|
||||
<!-- 'l' -->
|
||||
<path
|
||||
android:fillColor="#FF00FF"
|
||||
android:pathData="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
|
||||
<!-- 'i' -->
|
||||
<path
|
||||
android:fillColor="#FF00FF"
|
||||
android:pathData="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
|
||||
<!-- 'b' -->
|
||||
<path
|
||||
android:fillColor="#FF00FF"
|
||||
android:pathData="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
|
||||
<!-- '3' -->
|
||||
<path
|
||||
android:fillColor="#FF00FF"
|
||||
android:pathData="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
|
||||
<!-- '2' -->
|
||||
<path
|
||||
android:fillColor="#FF00FF"
|
||||
android:pathData="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
|
||||
<!-- '7' -->
|
||||
<path
|
||||
android:fillColor="#FF00FF"
|
||||
android:pathData="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
|
||||
<!-- '0' -->
|
||||
<path
|
||||
android:fillColor="#FF00FF"
|
||||
android:pathData="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
|
||||
<!-- 'j' -->
|
||||
<path
|
||||
android:fillColor="#FF00FF"
|
||||
android:pathData="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
|
||||
|
||||
<!-- Smiley Details -->
|
||||
<!-- Mouth -->
|
||||
<path
|
||||
android:strokeColor="#FFFF00"
|
||||
android:strokeWidth="50"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
|
||||
<!-- Left Eye -->
|
||||
<path
|
||||
android:strokeColor="#00FFFF"
|
||||
android:strokeWidth="50"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
|
||||
<!-- Right Eye -->
|
||||
<path
|
||||
android:strokeColor="#00FFFF"
|
||||
android:strokeWidth="50"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
|
||||
<!-- Nose -->
|
||||
<path
|
||||
android:strokeColor="#FFFF00"
|
||||
android:strokeWidth="50"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="m 2892.03,1528.23 -29.21,-20.68" />
|
||||
<path
|
||||
android:strokeColor="#FFFF00"
|
||||
android:strokeWidth="50"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="m 2862.82,1507.55 h 32.86" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/ic_launcher_background" />
|
||||
<item android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/ic_launcher_background" />
|
||||
<item android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="exports" path="exports/" />
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,109 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.a3270.storage.SavedHost
|
||||
import haus.nightmare.lib3270j.charset.CodePageRegistry
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class CodePageAndStorageTest {
|
||||
|
||||
@Test
|
||||
fun testSavedHostCodePageSerialization() {
|
||||
val host = SavedHost(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = "Test Mainframe",
|
||||
host = "192.168.1.100",
|
||||
port = 23,
|
||||
model = 4,
|
||||
luName = "LU3270",
|
||||
autoConnect = true,
|
||||
hostType = "TSO",
|
||||
useTls = true,
|
||||
tlsVerifyCert = false,
|
||||
tn3270e = true,
|
||||
graphicsMode = "BOTH",
|
||||
codePage = "1047",
|
||||
proxyType = "SOCKS5",
|
||||
proxyHost = "proxy.example.com",
|
||||
proxyPort = 1080,
|
||||
proxyUsername = "testuser",
|
||||
proxyPassword = "secretpassword"
|
||||
)
|
||||
|
||||
val json = host.toJson()
|
||||
assertEquals("1047", json.getString("codePage"))
|
||||
assertEquals("Test Mainframe", json.getString("name"))
|
||||
assertTrue(json.getBoolean("autoConnect"))
|
||||
assertEquals("SOCKS5", json.getString("proxyType"))
|
||||
assertEquals("proxy.example.com", json.getString("proxyHost"))
|
||||
assertEquals(1080, json.getInt("proxyPort"))
|
||||
assertEquals("testuser", json.getString("proxyUsername"))
|
||||
assertEquals("secretpassword", json.getString("proxyPassword"))
|
||||
|
||||
val deserialized = SavedHost.fromJson(json)
|
||||
assertEquals(host.id, deserialized.id)
|
||||
assertEquals(host.name, deserialized.name)
|
||||
assertEquals(host.host, deserialized.host)
|
||||
assertEquals(host.port, deserialized.port)
|
||||
assertEquals(host.model, deserialized.model)
|
||||
assertEquals(host.codePage, deserialized.codePage)
|
||||
assertEquals(host.graphicsMode, deserialized.graphicsMode)
|
||||
assertEquals(host.proxyType, deserialized.proxyType)
|
||||
assertEquals(host.proxyHost, deserialized.proxyHost)
|
||||
assertEquals(host.proxyPort, deserialized.proxyPort)
|
||||
assertEquals(host.proxyUsername, deserialized.proxyUsername)
|
||||
assertEquals(host.proxyPassword, deserialized.proxyPassword)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSavedHostDefaultCodePageFallback() {
|
||||
// Old JSON without codePage or proxy fields
|
||||
val json = org.json.JSONObject().apply {
|
||||
put("name", "Legacy")
|
||||
put("host", "mvs.local")
|
||||
}
|
||||
val deserialized = SavedHost.fromJson(json)
|
||||
assertEquals("037", deserialized.codePage)
|
||||
assertEquals("NONE", deserialized.proxyType)
|
||||
assertEquals("", deserialized.proxyHost)
|
||||
assertEquals(0, deserialized.proxyPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCodePageRegistryResolution() {
|
||||
val cp037 = CodePageRegistry.getCodePage("037")
|
||||
assertNotNull(cp037)
|
||||
assertEquals("037", cp037.codePageId)
|
||||
|
||||
val cp1047 = CodePageRegistry.getCodePage("1047")
|
||||
assertNotNull(cp1047)
|
||||
assertEquals("1047", cp1047.codePageId)
|
||||
|
||||
val cp930 = CodePageRegistry.getCodePage("930")
|
||||
assertNotNull(cp930)
|
||||
assertTrue(cp930.isDBCS)
|
||||
|
||||
val cp1140 = CodePageRegistry.getCodePage("1140")
|
||||
assertNotNull(cp1140)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEbcdicTranslatorCodePageSwitching() {
|
||||
val translator = EbcdicTranslator("037")
|
||||
assertEquals("037", translator.codePageId)
|
||||
|
||||
val translator1047 = EbcdicTranslator("1047")
|
||||
assertEquals("1047", translator1047.codePageId)
|
||||
|
||||
val model = TerminalModel.IBM_3279_2
|
||||
val sb = ScreenBuffer(model, translator)
|
||||
assertEquals('A', translator.ebcdicToUnicode(0xC1))
|
||||
sb.getCell(0).ec = 0xC1.toByte()
|
||||
sb.translateToUnicode()
|
||||
assertEquals('A', sb.getCell(0).ucs4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.a3270.storage.AppSettings
|
||||
import haus.nightmare.a3270.storage.HostStorage
|
||||
import haus.nightmare.a3270.storage.SavedHost
|
||||
import haus.nightmare.a3270.ui.ConnectChoice
|
||||
import haus.nightmare.a3270.ui.LastTransferState
|
||||
import haus.nightmare.a3270.ui.formatCMSFilter
|
||||
import haus.nightmare.a3270.ui.matchesCmsFilemode
|
||||
import haus.nightmare.a3270.ui.matchesCmsFilter
|
||||
import haus.nightmare.a3270.ui.matchesFilter
|
||||
import haus.nightmare.a3270.ui.matchesTsoFilter
|
||||
import haus.nightmare.a3270.ui.matchesWildcard
|
||||
import haus.nightmare.a3270.ui.resolveDefaultQuery
|
||||
import haus.nightmare.lib3270j.ConnectionConfig
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.ft.FTConfig
|
||||
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
|
||||
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
|
||||
import haus.nightmare.lib3270j.graphics.GraphicsMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.StringReader
|
||||
import java.util.UUID
|
||||
|
||||
class ConnectionAndHostDirectoryTest {
|
||||
|
||||
@Test
|
||||
fun testSavedHostToConnectionConfigPreservesMetadata() {
|
||||
val hostId = UUID.randomUUID().toString()
|
||||
val host = SavedHost(
|
||||
id = hostId,
|
||||
name = "MVS Production LPAR",
|
||||
host = "mvs.prod.corp",
|
||||
port = 992,
|
||||
model = 4,
|
||||
dynamicRows = 62,
|
||||
dynamicCols = 160,
|
||||
luName = "LU3270A",
|
||||
autoConnect = false,
|
||||
hostType = "TSO",
|
||||
useTls = true,
|
||||
tlsVerifyCert = true,
|
||||
tn3270e = true,
|
||||
graphicsMode = "VECTOR_GRAPHICS",
|
||||
codePage = "1047",
|
||||
proxyType = "SOCKS5",
|
||||
proxyHost = "proxy.corp",
|
||||
proxyPort = 1080,
|
||||
proxyUsername = "mvsadmin",
|
||||
proxyPassword = "mypassword"
|
||||
)
|
||||
|
||||
val config = host.toConnectionConfig()
|
||||
|
||||
assertEquals("mvs.prod.corp", config.host)
|
||||
assertEquals(992, config.port)
|
||||
assertEquals(TerminalModel.IBM_3279_4, config.model)
|
||||
assertEquals("LU3270A", config.luName)
|
||||
assertTrue(config.isUseTls)
|
||||
assertTrue(config.isTlsVerifyCert)
|
||||
assertTrue(config.isTn3270eEnabled)
|
||||
assertEquals(GraphicsMode.VECTOR_GRAPHICS, config.graphicsMode)
|
||||
assertEquals("1047", config.codePage)
|
||||
assertEquals(ConnectionConfig.ProxyType.SOCKS5, config.proxyType)
|
||||
assertEquals("proxy.corp", config.proxyHost)
|
||||
assertEquals(1080, config.proxyPort)
|
||||
assertEquals("mvsadmin", config.proxyUsername)
|
||||
assertEquals("mypassword", config.proxyPassword)
|
||||
|
||||
// Metadata preservation added in connection hardening
|
||||
assertTrue("savedConnection flag must be true", config.isSavedConnection)
|
||||
assertEquals(hostId, config.savedProfileId)
|
||||
assertEquals("MVS Production LPAR", config.savedProfileName)
|
||||
assertEquals("TSO", config.hostType)
|
||||
assertEquals(FTConfig.HostType.TSO, config.ftHostType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSavedHostToConnectionConfigCmsHostType() {
|
||||
val host = SavedHost(
|
||||
id = "cms-1",
|
||||
name = "VM/CMS Guest",
|
||||
host = "vm.local",
|
||||
port = 23,
|
||||
hostType = "CMS"
|
||||
)
|
||||
val config = host.toConnectionConfig()
|
||||
assertEquals("CMS", config.hostType)
|
||||
assertEquals(FTConfig.HostType.CMS, config.ftHostType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testResolveDefaultQuery() {
|
||||
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, null))
|
||||
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, ""))
|
||||
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, " "))
|
||||
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "*"))
|
||||
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "* * *"))
|
||||
assertEquals("PROFILE EXEC A", resolveDefaultQuery(FTConfig.HostType.CMS, "PROFILE EXEC A"))
|
||||
|
||||
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, null))
|
||||
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, ""))
|
||||
assertEquals("SYS1.*", resolveDefaultQuery(FTConfig.HostType.TSO, "SYS1.*"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFormatCMSFilter() {
|
||||
assertEquals("* * A", formatCMSFilter(null))
|
||||
assertEquals("* * A", formatCMSFilter(""))
|
||||
assertEquals("* * A", formatCMSFilter(" "))
|
||||
assertEquals("* * A", formatCMSFilter("*"))
|
||||
assertEquals("* * A", formatCMSFilter("* * *"))
|
||||
assertEquals("MYFILE * A", formatCMSFilter("MYFILE"))
|
||||
assertEquals("MYFILE EXEC A", formatCMSFilter("MYFILE EXEC"))
|
||||
assertEquals("MYFILE EXEC B", formatCMSFilter("MYFILE EXEC B"))
|
||||
assertEquals("PROFILE EXEC A", formatCMSFilter("PROFILE.EXEC"))
|
||||
assertEquals("PROFILE EXEC A", formatCMSFilter("'PROFILE.EXEC'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMatchesWildcard() {
|
||||
assertTrue(matchesWildcard("HELLO", "*"))
|
||||
assertTrue(matchesWildcard("HELLO", "H*"))
|
||||
assertTrue(matchesWildcard("HELLO", "H*O"))
|
||||
assertTrue(matchesWildcard("HELLO", "H?LLO"))
|
||||
assertTrue(matchesWildcard("HELLO", "?????"))
|
||||
assertFalse(matchesWildcard("HELLO", "H?L"))
|
||||
assertFalse(matchesWildcard("HELLO", "WORLD"))
|
||||
|
||||
assertTrue(matchesWildcard("DATA.SET.NAME", "*.NAME"))
|
||||
assertTrue(matchesWildcard("DATA.SET.NAME", "DATA.*"))
|
||||
assertFalse(matchesWildcard("DATA.SET.NAME", "SYS1.*"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMatchesCmsFilterAndFilemode() {
|
||||
val entry = CMSDirectoryEntry("PROFILE", "EXEC", "A1").apply {
|
||||
recfm = "F"
|
||||
lrecl = 80
|
||||
blksize = 80
|
||||
}
|
||||
|
||||
assertTrue(matchesFilter(entry, null))
|
||||
assertTrue(matchesFilter(entry, ""))
|
||||
assertTrue(matchesFilter(entry, "* * *"))
|
||||
assertTrue(matchesFilter(entry, "* * A"))
|
||||
assertTrue(matchesFilter(entry, "PROFILE * A"))
|
||||
assertTrue(matchesFilter(entry, "PROFILE EXEC *"))
|
||||
assertTrue(matchesFilter(entry, "PROFILE EXEC A1"))
|
||||
assertTrue(matchesFilter(entry, "PROFILE.EXEC"))
|
||||
assertTrue(matchesFilter(entry, "PROFILE"))
|
||||
assertTrue(matchesFilter(entry, "EXEC"))
|
||||
|
||||
assertFalse(matchesFilter(entry, "* * B"))
|
||||
assertFalse(matchesFilter(entry, "TEST * *"))
|
||||
assertFalse(matchesFilter(entry, "*.COBOL"))
|
||||
assertFalse(matchesFilter(entry, "PROFILE EXEC A2"))
|
||||
|
||||
assertTrue(matchesCmsFilemode("A1", "A"))
|
||||
assertTrue(matchesCmsFilemode("A1", "A1"))
|
||||
assertTrue(matchesCmsFilemode("A1", "*"))
|
||||
assertFalse(matchesCmsFilemode("A1", "B"))
|
||||
assertFalse(matchesCmsFilemode("A1", "A2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMatchesTsoFilter() {
|
||||
val entry = TSODirectoryEntry("USER.SOURCE.COBOL").apply {
|
||||
volume = "VOL001"
|
||||
dsorg = "PO"
|
||||
}
|
||||
|
||||
assertTrue(matchesFilter(entry, null))
|
||||
assertTrue(matchesFilter(entry, ""))
|
||||
assertTrue(matchesFilter(entry, "*"))
|
||||
assertTrue(matchesFilter(entry, "*SOURCE*"))
|
||||
assertTrue(matchesFilter(entry, "USER.*.COBOL"))
|
||||
assertTrue(matchesFilter(entry, "COBOL")) // substring search
|
||||
assertTrue(matchesFilter(entry, "USER.SOURCE"))
|
||||
|
||||
assertFalse(matchesFilter(entry, "SYS1.*"))
|
||||
assertFalse(matchesFilter(entry, "*.PLI"))
|
||||
assertFalse(matchesFilter(entry, "FORTRAN"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppSettingsIniHostTypeParsing() {
|
||||
val testIni = """
|
||||
[Connection]
|
||||
hostType = CMS
|
||||
|
||||
[General]
|
||||
defaultHostType = CICS
|
||||
""".trimIndent()
|
||||
|
||||
var parsedHostType: String? = null
|
||||
AppSettings.parseIni(StringReader(testIni)) { _, key, value ->
|
||||
if (key.equals("hostType", ignoreCase = true) ||
|
||||
key.equals("hosttype", ignoreCase = true) ||
|
||||
key.equals("host_type", ignoreCase = true) ||
|
||||
key.equals("defaulthosttype", ignoreCase = true)
|
||||
) {
|
||||
parsedHostType = value
|
||||
}
|
||||
}
|
||||
|
||||
assertNotNull(parsedHostType)
|
||||
// First or second value parsed
|
||||
assertTrue(parsedHostType.equals("CMS", ignoreCase = true) || parsedHostType.equals("CICS", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLastTransferStateUnitsAndDirBlocks() {
|
||||
val state = LastTransferState()
|
||||
assertEquals(FTConfig.HostType.TSO, state.hostType)
|
||||
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
|
||||
assertEquals("", state.dirBlocks)
|
||||
|
||||
state.units = FTConfig.AllocationUnit.TRACKS
|
||||
state.dirBlocks = "20"
|
||||
state.hostType = FTConfig.HostType.CMS
|
||||
|
||||
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
|
||||
assertEquals("20", state.dirBlocks)
|
||||
assertEquals(FTConfig.HostType.CMS, state.hostType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConnectChoiceEnum() {
|
||||
val choices = ConnectChoice.values()
|
||||
assertEquals(3, choices.size)
|
||||
assertTrue(choices.contains(ConnectChoice.NEW_WINDOW))
|
||||
assertTrue(choices.contains(ConnectChoice.ABORT_CURRENT))
|
||||
assertTrue(choices.contains(ConnectChoice.CANCEL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHostStorageExportAndLoadIniRoundtrip() {
|
||||
val host1 = SavedHost(
|
||||
id = "test-h1",
|
||||
name = "Test Mainframe MVS",
|
||||
host = "mvs.corp.local",
|
||||
port = 992,
|
||||
model = 4,
|
||||
dynamicRows = 62,
|
||||
dynamicCols = 160,
|
||||
luName = "LU01",
|
||||
autoConnect = true,
|
||||
hostType = "TSO",
|
||||
useTls = true,
|
||||
tlsVerifyCert = false,
|
||||
tn3270e = true,
|
||||
graphicsMode = "VECTOR_GRAPHICS",
|
||||
codePage = "1047",
|
||||
proxyType = "SOCKS5",
|
||||
proxyHost = "proxy.corp",
|
||||
proxyPort = 1080,
|
||||
proxyUsername = "admin",
|
||||
proxyPassword = "secret"
|
||||
)
|
||||
val host2 = SavedHost(
|
||||
id = "test-h2",
|
||||
name = "VM CMS Guest",
|
||||
host = "vm.corp.local",
|
||||
port = 23,
|
||||
hostType = "CMS"
|
||||
)
|
||||
|
||||
val sw = java.io.StringWriter()
|
||||
HostStorage.exportToIni(listOf(host1, host2), sw)
|
||||
val exportedIni = sw.toString()
|
||||
|
||||
assertTrue(exportedIni.contains("[Test Mainframe MVS]"))
|
||||
assertTrue(exportedIni.contains("host = mvs.corp.local"))
|
||||
assertTrue(exportedIni.contains("port = 992"))
|
||||
assertTrue(exportedIni.contains("autoConnect = true"))
|
||||
assertTrue(exportedIni.contains("hostType = TSO"))
|
||||
assertTrue(exportedIni.contains("useTls = true"))
|
||||
assertTrue(exportedIni.contains("[VM CMS Guest]"))
|
||||
assertTrue(exportedIni.contains("hostType = CMS"))
|
||||
|
||||
val loaded = HostStorage.loadFromIni(StringReader(exportedIni))
|
||||
assertEquals(2, loaded.size)
|
||||
|
||||
val loadedH1 = loaded.find { it.name == "Test Mainframe MVS" }
|
||||
assertNotNull(loadedH1)
|
||||
assertEquals("test-h1", loadedH1!!.id)
|
||||
assertEquals("mvs.corp.local", loadedH1.host)
|
||||
assertEquals(992, loadedH1.port)
|
||||
assertEquals(4, loadedH1.model)
|
||||
assertEquals(62, loadedH1.dynamicRows)
|
||||
assertEquals(160, loadedH1.dynamicCols)
|
||||
assertEquals("LU01", loadedH1.luName)
|
||||
assertTrue(loadedH1.autoConnect)
|
||||
assertEquals("TSO", loadedH1.hostType)
|
||||
assertTrue(loadedH1.useTls)
|
||||
assertFalse(loadedH1.tlsVerifyCert)
|
||||
assertTrue(loadedH1.tn3270e)
|
||||
assertEquals("VECTOR_GRAPHICS", loadedH1.graphicsMode)
|
||||
assertEquals("1047", loadedH1.codePage)
|
||||
assertEquals("SOCKS5", loadedH1.proxyType)
|
||||
assertEquals("proxy.corp", loadedH1.proxyHost)
|
||||
assertEquals(1080, loadedH1.proxyPort)
|
||||
assertEquals("admin", loadedH1.proxyUsername)
|
||||
assertEquals("secret", loadedH1.proxyPassword)
|
||||
|
||||
val loadedH2 = loaded.find { it.name == "VM CMS Guest" }
|
||||
assertNotNull(loadedH2)
|
||||
assertEquals("test-h2", loadedH2!!.id)
|
||||
assertEquals("vm.corp.local", loadedH2.host)
|
||||
assertEquals(23, loadedH2.port)
|
||||
assertEquals("CMS", loadedH2.hostType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.a3270.storage.HostStorage
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.CodePageRegistry
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.lang.reflect.Method
|
||||
|
||||
class DBCSAndParityCompatibilityTest {
|
||||
|
||||
@Test
|
||||
fun testNewJapaneseCodePagesAvailableAndResolvable() {
|
||||
val availableKeys = HostStorage.AVAILABLE_CODE_PAGES.map { it.first }
|
||||
assertTrue("290 should be available in HostStorage", availableKeys.contains("290"))
|
||||
assertTrue("1027 should be available in HostStorage", availableKeys.contains("1027"))
|
||||
assertTrue("1390 should be available in HostStorage", availableKeys.contains("1390"))
|
||||
assertTrue("1399 should be available in HostStorage", availableKeys.contains("1399"))
|
||||
|
||||
val cp290 = CodePageRegistry.getCodePage("290")
|
||||
assertNotNull("Cp290 must resolve", cp290)
|
||||
assertFalse("Cp290 is SBCS", cp290.isDBCS)
|
||||
|
||||
val cp1027 = CodePageRegistry.getCodePage("1027")
|
||||
assertNotNull("Cp1027 must resolve", cp1027)
|
||||
assertFalse("Cp1027 is SBCS", cp1027.isDBCS)
|
||||
|
||||
val cp1390 = CodePageRegistry.getCodePage("1390")
|
||||
assertNotNull("Cp1390 must resolve", cp1390)
|
||||
assertTrue("Cp1390 is DBCS", cp1390.isDBCS)
|
||||
|
||||
val cp1399 = CodePageRegistry.getCodePage("1399")
|
||||
assertNotNull("Cp1399 must resolve", cp1399)
|
||||
assertTrue("Cp1399 is DBCS", cp1399.isDBCS)
|
||||
|
||||
val trans1390 = EbcdicTranslator("1390")
|
||||
assertTrue("Translator for 1390 must report isDBCS", trans1390.isDBCS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScreenExporterDBCSNoDuplication() {
|
||||
val trans = EbcdicTranslator("930")
|
||||
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, trans)
|
||||
screen.erase(false)
|
||||
|
||||
// Put a 2-cell DBCS character at cells 0 and 1: '東' (\u6771)
|
||||
val cell0 = screen.getCell(0)
|
||||
cell0.db = ExtendedAttribute.DB_LEFT
|
||||
cell0.ucs4 = '\u6771'
|
||||
|
||||
val cell1 = screen.getCell(1)
|
||||
cell1.db = ExtendedAttribute.DB_RIGHT
|
||||
cell1.ucs4 = '\u6771'
|
||||
|
||||
// Put SBCS 'X' at cell 2
|
||||
val cell2 = screen.getCell(2)
|
||||
cell2.db = ExtendedAttribute.DB_NONE
|
||||
cell2.ucs4 = 'X'
|
||||
|
||||
// Invoke private generatePlainText via reflection
|
||||
val exporterClass = Class.forName("haus.nightmare.a3270.ui.ScreenExporterDialogKt")
|
||||
val method: Method = exporterClass.getDeclaredMethod(
|
||||
"generatePlainText",
|
||||
ScreenBuffer::class.java,
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
Boolean::class.javaPrimitiveType
|
||||
)
|
||||
method.isAccessible = true
|
||||
|
||||
val plainText = method.invoke(null, screen, 1, 80, true) as String
|
||||
assertTrue("Output should start with single DBCS char + X: expected '東X', got: '$plainText'", plainText.startsWith("東X"))
|
||||
assertFalse("DBCS char must not be doubled into '東東X'", plainText.startsWith("東東"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTerminalGridMetricsCenteringClampedAtZero() {
|
||||
val terminalViewClass = Class.forName("haus.nightmare.a3270.ui.TerminalViewKt")
|
||||
val method = terminalViewClass.getDeclaredMethod(
|
||||
"calculateGridMetrics",
|
||||
Float::class.javaPrimitiveType,
|
||||
Float::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType
|
||||
)
|
||||
method.isAccessible = true
|
||||
|
||||
// View is constrained / non-negative offsets
|
||||
val constrainedMetrics = method.invoke(null, 400f, 300f, 80, 24)
|
||||
val offXField = constrainedMetrics.javaClass.getDeclaredField("offsetX")
|
||||
offXField.isAccessible = true
|
||||
val offX = offXField.getFloat(constrainedMetrics)
|
||||
|
||||
val offYField = constrainedMetrics.javaClass.getDeclaredField("offsetY")
|
||||
offYField.isAccessible = true
|
||||
val offY = offYField.getFloat(constrainedMetrics)
|
||||
|
||||
assertTrue("Constrained offX must be non-negative", offX >= 0f)
|
||||
assertTrue("Constrained offY must be non-negative", offY >= 0f)
|
||||
|
||||
// Wide view (pillarbox with positive offX)
|
||||
val wideMetrics = method.invoke(null, 3000f, 1000f, 80, 24)
|
||||
val wideOffX = offXField.getFloat(wideMetrics)
|
||||
val wideOffY = offYField.getFloat(wideMetrics)
|
||||
assertTrue("Wide view offX should be positive", wideOffX > 0f)
|
||||
assertEquals(0f, wideOffY, 0.001f)
|
||||
|
||||
// Tall view (letterbox with positive offY)
|
||||
val tallMetrics = method.invoke(null, 1000f, 3000f, 80, 24)
|
||||
val tallOffX = offXField.getFloat(tallMetrics)
|
||||
val tallOffY = offYField.getFloat(tallMetrics)
|
||||
assertEquals(0f, tallOffX, 0.001f)
|
||||
assertTrue("Tall view offY should be positive", tallOffY > 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExtendedAttributeTransparencyConstants() {
|
||||
assertEquals(0x00.toByte(), ExtendedAttribute.TR_DEFAULT)
|
||||
assertEquals(0xF0.toByte(), ExtendedAttribute.TR_OR)
|
||||
assertEquals(0xFF.toByte(), ExtendedAttribute.TR_OPAQUE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class DisplaySnapshotIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun testSnapshotCaptureAndLockFreeReads() {
|
||||
val translator = EbcdicTranslator()
|
||||
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||
|
||||
sb.erase(false)
|
||||
val initialVersion = sb.snapshotVersion
|
||||
|
||||
sb.getCell(0).ucs4 = 'H'
|
||||
sb.getCell(1).ucs4 = 'E'
|
||||
sb.getCell(2).ucs4 = 'L'
|
||||
sb.getCell(3).ucs4 = 'L'
|
||||
sb.getCell(4).ucs4 = 'O'
|
||||
sb.setCursorAddress(42)
|
||||
sb.updateDisplaySnapshot()
|
||||
|
||||
val snap = sb.displaySnapshot
|
||||
assertNotNull("DisplaySnapshot must not be null after updateDisplaySnapshot()", snap)
|
||||
assertTrue("Snapshot version must increase", sb.snapshotVersion > initialVersion)
|
||||
assertEquals(24, snap.rows)
|
||||
assertEquals(80, snap.cols)
|
||||
assertEquals(42, snap.cursorAddress)
|
||||
assertEquals('H', snap.getCell(0)?.ucs4)
|
||||
assertEquals('E', snap.getCell(1)?.ucs4)
|
||||
assertEquals('L', snap.getCell(2)?.ucs4)
|
||||
assertEquals('L', snap.getCell(3)?.ucs4)
|
||||
assertEquals('O', snap.getCell(4)?.ucs4)
|
||||
|
||||
// getDisplayCell fallback
|
||||
assertEquals('H', sb.getDisplayCell(0)?.ucs4)
|
||||
assertEquals(42, sb.displayCursorAddress)
|
||||
assertEquals(24, sb.displayRows)
|
||||
assertEquals(80, sb.displayCols)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTearFreeRenderingUnderConcurrentBufferMutation() {
|
||||
val translator = EbcdicTranslator()
|
||||
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||
sb.erase(false)
|
||||
|
||||
val size = 24 * 80
|
||||
val totalUpdates = 2000
|
||||
val workerCount = 4
|
||||
val executor = Executors.newFixedThreadPool(workerCount + 1)
|
||||
val running = AtomicBoolean(true)
|
||||
val tornCellCount = AtomicInteger(0)
|
||||
val exceptionCount = AtomicInteger(0)
|
||||
val totalReads = AtomicLong(0)
|
||||
|
||||
val startLatch = CountDownLatch(1)
|
||||
val writersDoneLatch = CountDownLatch(1)
|
||||
|
||||
// Reader workers simulating Compose Canvas draw passes
|
||||
for (w in 0 until workerCount) {
|
||||
executor.submit {
|
||||
try {
|
||||
startLatch.await()
|
||||
while (running.get()) {
|
||||
val snap = sb.displaySnapshot ?: continue
|
||||
val snapSize = snap.rows * snap.cols
|
||||
|
||||
for (addr in 0 until snapSize) {
|
||||
val cell = snap.getCell(addr) ?: continue
|
||||
totalReads.incrementAndGet()
|
||||
|
||||
// Patterned validation:
|
||||
// ucs4 = 'A' + mod, ec = 0x10 + mod, fg = 0x30 + mod, bg = 0x50 + mod
|
||||
if (cell.ucs4 in 'A'..'Z') {
|
||||
val ucsOffset = cell.ucs4 - 'A'
|
||||
val ecOffset = (cell.ec.toInt() and 0xFF) - 0x10
|
||||
val fgOffset = (cell.fg.toInt() and 0xFF) - 0x30
|
||||
val bgOffset = (cell.bg.toInt() and 0xFF) - 0x50
|
||||
|
||||
if (ucsOffset != ecOffset || ucsOffset != fgOffset || ucsOffset != bgOffset) {
|
||||
tornCellCount.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
exceptionCount.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Writer task simulating background Telnet network receiver thread
|
||||
executor.submit {
|
||||
try {
|
||||
startLatch.await()
|
||||
for (iter in 0 until totalUpdates) {
|
||||
val mod = iter % 26
|
||||
val ch = ('A' + mod)
|
||||
val ec = (0x10 + mod).toByte()
|
||||
val fg = (0x30 + mod).toByte()
|
||||
val bg = (0x50 + mod).toByte()
|
||||
|
||||
val rawBuf = sb.buffer
|
||||
for (i in 0 until size) {
|
||||
val cell = rawBuf[i]
|
||||
cell.ucs4 = ch
|
||||
cell.ec = ec
|
||||
cell.fg = fg
|
||||
cell.bg = bg
|
||||
}
|
||||
sb.setCursorAddress(iter % size)
|
||||
sb.updateDisplaySnapshot()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
exceptionCount.incrementAndGet()
|
||||
} finally {
|
||||
writersDoneLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
startLatch.countDown()
|
||||
val finished = writersDoneLatch.await(10, TimeUnit.SECONDS)
|
||||
assertTrue("Writer must complete updates without deadlock", finished)
|
||||
|
||||
running.set(false)
|
||||
executor.shutdown()
|
||||
assertTrue("Executor must terminate cleanly", executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
|
||||
assertEquals("Zero exceptions must occur during concurrent execution", 0, exceptionCount.get())
|
||||
assertEquals("Zero torn cell states must occur during DisplaySnapshot access", 0, tornCellCount.get())
|
||||
assertTrue("Total reads must be significant", totalReads.get() > 50_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConcurrentReadsDuringScreenResizing() {
|
||||
val translator = EbcdicTranslator()
|
||||
val sb = ScreenBuffer(24, 80, 27, 132, translator)
|
||||
val workerCount = 4
|
||||
val executor = Executors.newFixedThreadPool(workerCount + 1)
|
||||
val running = AtomicBoolean(true)
|
||||
val exceptionCount = AtomicInteger(0)
|
||||
val successfulReads = AtomicLong(0)
|
||||
|
||||
val startLatch = CountDownLatch(1)
|
||||
val writerLatch = CountDownLatch(1)
|
||||
|
||||
for (w in 0 until workerCount) {
|
||||
executor.submit {
|
||||
try {
|
||||
startLatch.await()
|
||||
while (running.get()) {
|
||||
val snap = sb.displaySnapshot ?: continue
|
||||
val total = snap.rows * snap.cols
|
||||
|
||||
for (i in 0 until total) {
|
||||
val cell = snap.getCell(i)
|
||||
if (cell != null) {
|
||||
successfulReads.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
exceptionCount.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Writer alternates dimensions between 24x80 and 27x132
|
||||
executor.submit {
|
||||
try {
|
||||
startLatch.await()
|
||||
for (iter in 0 until 500) {
|
||||
sb.erase((iter % 2) != 0)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
exceptionCount.incrementAndGet()
|
||||
} finally {
|
||||
writerLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
startLatch.countDown()
|
||||
assertTrue("Resize writer must complete without deadlock", writerLatch.await(10, TimeUnit.SECONDS))
|
||||
|
||||
running.set(false)
|
||||
executor.shutdown()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
|
||||
assertEquals("Zero exceptions must occur during dynamic dimension resizing", 0, exceptionCount.get())
|
||||
assertTrue("Successful reads across dimension resizing", successfulReads.get() > 20_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.a3270.storage.SavedHost
|
||||
import haus.nightmare.a3270.ui.getFgColorForAttribute
|
||||
import haus.nightmare.lib3270j.ConnectionConfig
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.datastream.DataStreamProcessor
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.UUID
|
||||
|
||||
class DynamicAndParityFeaturesTest {
|
||||
|
||||
@Test
|
||||
fun testSavedHostDynamicModelSerialization() {
|
||||
val host = SavedHost(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = "Dynamic Mainframe Session",
|
||||
host = "mainframe.example.org",
|
||||
port = 23,
|
||||
model = 0,
|
||||
dynamicRows = 62,
|
||||
dynamicCols = 160,
|
||||
luName = "DYNLU01",
|
||||
autoConnect = false,
|
||||
hostType = "TSO",
|
||||
useTls = true,
|
||||
tlsVerifyCert = true,
|
||||
tn3270e = true,
|
||||
graphicsMode = "BOTH",
|
||||
codePage = "037"
|
||||
)
|
||||
|
||||
val json = host.toJson()
|
||||
assertEquals(0, json.getInt("model"))
|
||||
assertEquals(62, json.getInt("dynamicRows"))
|
||||
assertEquals(160, json.getInt("dynamicCols"))
|
||||
|
||||
val deserialized = SavedHost.fromJson(json)
|
||||
assertEquals(0, deserialized.model)
|
||||
assertEquals(62, deserialized.dynamicRows)
|
||||
assertEquals(160, deserialized.dynamicCols)
|
||||
assertEquals("Dynamic Mainframe Session", deserialized.name)
|
||||
assertEquals("mainframe.example.org", deserialized.host)
|
||||
assertEquals(23, deserialized.port)
|
||||
assertTrue(deserialized.useTls)
|
||||
assertEquals("BOTH", deserialized.graphicsMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSavedHostBackwardCompatibilityDefaults() {
|
||||
// Legacy JSON without dynamicRows and dynamicCols
|
||||
val json = JSONObject().apply {
|
||||
put("id", "legacy-id")
|
||||
put("name", "Legacy Host")
|
||||
put("host", "legacy.host")
|
||||
put("port", 23)
|
||||
put("model", 2)
|
||||
}
|
||||
|
||||
val deserialized = SavedHost.fromJson(json)
|
||||
assertEquals(62, deserialized.dynamicRows)
|
||||
assertEquals(160, deserialized.dynamicCols)
|
||||
assertEquals(2, deserialized.model)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConnectionConfigDynamicModel() {
|
||||
val config = ConnectionConfig("10.0.0.1", 23)
|
||||
config.setDynamicDimensions(72, 140)
|
||||
|
||||
assertEquals(TerminalModel.IBM_DYNAMIC, config.model)
|
||||
assertTrue(config.isDynamicModel)
|
||||
assertEquals(72, config.dynamicRows)
|
||||
assertEquals(140, config.dynamicCols)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHodColorRemappingConstants() {
|
||||
// Base 4-Color Model parity verification:
|
||||
// Normal Protected must be Turquoise/Cyan (0x00FFFF), NOT Blue
|
||||
// Intensified Unprotected must be Red (0xFF0000)
|
||||
// Normal Unprotected is Green (0x00FF00)
|
||||
// Intensified Protected is White (0xFFFFFF)
|
||||
val turquoiseRgb = 0x00FFFF
|
||||
val redRgb = 0xFF0000
|
||||
val greenRgb = 0x00FF00
|
||||
val whiteRgb = 0xFFFFFF
|
||||
|
||||
assertEquals(0x00FFFF, turquoiseRgb)
|
||||
assertEquals(0xFF0000, redRgb)
|
||||
assertEquals(0x00FF00, greenRgb)
|
||||
assertEquals(0xFFFFFF, whiteRgb)
|
||||
|
||||
// Validate bit patterns match IBM 3270 Field Attributes
|
||||
val prot = FA_PROTECT
|
||||
val num = FA_NUMERIC
|
||||
val hi = FA_INT_HIGH_SEL
|
||||
|
||||
assertTrue((prot and FA_PROTECT) != 0)
|
||||
assertTrue((num and FA_NUMERIC) != 0)
|
||||
assertTrue((hi and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCharacterAttributesPersistAcrossFields() {
|
||||
val model = TerminalModel.IBM_3279_2
|
||||
val translator = EbcdicTranslator()
|
||||
val screen = ScreenBuffer(model, translator)
|
||||
val processor = DataStreamProcessor(screen, translator)
|
||||
|
||||
val stream = ByteArrayOutputStream()
|
||||
stream.write(CMD_EW)
|
||||
stream.write(0xC3) // WCC
|
||||
|
||||
// SF(prot, skip)
|
||||
stream.write(ORDER_SF)
|
||||
stream.write(FA_PRINTABLE or FA_PROTECT or FA_NUMERIC)
|
||||
|
||||
// SA(yellow): XA_FOREGROUND, COLOR_YELLOW (0xF6)
|
||||
stream.write(ORDER_SA)
|
||||
stream.write(XA_FOREGROUND)
|
||||
stream.write(0xF6)
|
||||
|
||||
// Data: " 4 "
|
||||
stream.write(translator.stringToEbcdic(" 4 "))
|
||||
|
||||
// Second SF(prot, skip)
|
||||
stream.write(ORDER_SF)
|
||||
stream.write(FA_PRINTABLE or FA_PROTECT or FA_NUMERIC)
|
||||
|
||||
// Data: "DISPLAY"
|
||||
stream.write(translator.stringToEbcdic("DISPLAY"))
|
||||
|
||||
val record = stream.toByteArray()
|
||||
processor.processRecord(record, 0, record.size, true)
|
||||
|
||||
// " 4 " cells (indices 1..5) should have yellow foreground (0xF6)
|
||||
for (i in 1..5) {
|
||||
assertEquals(0xF6.toByte(), screen.getCell(i).fg)
|
||||
}
|
||||
|
||||
// Across second SF, "DISPLAY" cells (indices 7..13) must retain yellow foreground (0xF6) (commit c28c097e)
|
||||
for (i in 7..13) {
|
||||
assertEquals(0xF6.toByte(), screen.getCell(i).fg)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTerminalViewColorResolutionForProtectedField() {
|
||||
val ea = ExtendedAttribute()
|
||||
// Normal protected field attribute -> Turquoise / Cyan (0x00FFFF)
|
||||
val protNormalColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
assertEquals(androidx.compose.ui.graphics.Color(0xFF00FFFF), protNormalColor)
|
||||
|
||||
// Intensified protected field attribute -> White (0xFFC0C0C0 per HOST_COLOR_WHITE / HoD palette)
|
||||
val protHighColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_PROTECT or FA_INT_HIGH_SEL).toByte())
|
||||
assertEquals(androidx.compose.ui.graphics.Color(0xFFC0C0C0), protHighColor)
|
||||
|
||||
// Normal unprotected -> Green (0x00FF00)
|
||||
val unprotNormalColor = getFgColorForAttribute(ea, null, FA_PRINTABLE.toByte())
|
||||
assertEquals(androidx.compose.ui.graphics.Color(0xFF00FF00), unprotNormalColor)
|
||||
|
||||
// Intensified unprotected -> Red (0xFF0000)
|
||||
val unprotHighColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte())
|
||||
assertEquals(androidx.compose.ui.graphics.Color(0xFFFF0000), unprotHighColor)
|
||||
|
||||
// Character mode override (SA yellow = 0xF6)
|
||||
ea.fg = 0xF6.toByte()
|
||||
val yellowColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
assertEquals(androidx.compose.ui.graphics.Color(0xFFFFFF00), yellowColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.ecl.ECLConstants
|
||||
import haus.nightmare.lib3270j.ecl.ECLFieldList
|
||||
import haus.nightmare.lib3270j.ecl.ECLPS
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class EclAndSearchTest {
|
||||
|
||||
private lateinit var screenBuffer: ScreenBuffer
|
||||
private lateinit var translator: EbcdicTranslator
|
||||
private lateinit var inputProcessor: haus.nightmare.lib3270j.input.InputProcessor
|
||||
private lateinit var ps: ECLPS
|
||||
private lateinit var fieldList: ECLFieldList
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val model = TerminalModel.IBM_3279_2 // 24x80
|
||||
translator = EbcdicTranslator()
|
||||
screenBuffer = ScreenBuffer(model, translator)
|
||||
inputProcessor = haus.nightmare.lib3270j.input.InputProcessor(screenBuffer, translator, null)
|
||||
ps = ECLPS(screenBuffer, inputProcessor, translator)
|
||||
fieldList = ps.fieldList
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPresentationSpaceSearchForwardAndBackward() {
|
||||
val text = "USERID ===> IBMUSER"
|
||||
for (i in text.indices) {
|
||||
screenBuffer.getCell(100 + i).ucs4 = text[i]
|
||||
screenBuffer.getCell(100 + i).ec = translator.unicodeToEbcdic(text[i]).toByte()
|
||||
}
|
||||
|
||||
// Search Forward for "IBMUSER" from row 0 col 0
|
||||
val foundPos = ps.searchString("IBMUSER", 0, 0, ECLConstants.SEARCH_FORWARD, true)
|
||||
assertEquals(100 + text.indexOf("IBMUSER"), foundPos)
|
||||
|
||||
// Case-sensitive search failure
|
||||
val notFound = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, false)
|
||||
assertEquals(-1, notFound)
|
||||
|
||||
// Case-insensitive search match
|
||||
val foundCaseInsensitive = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, true)
|
||||
assertEquals(100 + text.indexOf("IBMUSER"), foundCaseInsensitive)
|
||||
|
||||
// Search Backward from bottom of screen
|
||||
val foundBackward = ps.searchString("USERID", 23, 79, ECLConstants.SEARCH_BACKWARD, true)
|
||||
assertEquals(100, foundBackward)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFieldListExtractionAndFlags() {
|
||||
// Position 0: Protected label
|
||||
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
val label = "COMMAND ===>"
|
||||
for (i in label.indices) {
|
||||
screenBuffer.getCell(1 + i).ucs4 = label[i]
|
||||
screenBuffer.getCell(1 + i).ec = translator.unicodeToEbcdic(label[i]).toByte()
|
||||
}
|
||||
|
||||
// Position 20: Unprotected input field with MDT
|
||||
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
|
||||
val cmd = "TSO TEST"
|
||||
for (i in cmd.indices) {
|
||||
screenBuffer.getCell(21 + i).ucs4 = cmd[i]
|
||||
screenBuffer.getCell(21 + i).ec = translator.unicodeToEbcdic(cmd[i]).toByte()
|
||||
}
|
||||
|
||||
// Position 40: Hidden field (zero intensity)
|
||||
screenBuffer.setCellFA(40, (FA_PRINTABLE or FA_PROTECT or FA_INT_ZERO_NSEL).toByte())
|
||||
|
||||
fieldList.refresh()
|
||||
val fields = fieldList.fields
|
||||
assertEquals(3, fields.size)
|
||||
|
||||
// Field 0: Label
|
||||
val f0 = fields[0]
|
||||
assertTrue(f0.isProtected)
|
||||
assertFalse(f0.isModified)
|
||||
assertEquals(0, f0.start)
|
||||
assertEquals(1, f0.dataStart)
|
||||
assertTrue(f0.text.startsWith("COMMAND ===>"))
|
||||
|
||||
// Field 1: Input command
|
||||
val f1 = fields[1]
|
||||
assertFalse(f1.isProtected)
|
||||
assertTrue(f1.isModified)
|
||||
assertEquals(20, f1.start)
|
||||
assertEquals(21, f1.dataStart)
|
||||
assertTrue(f1.text.startsWith("TSO TEST"))
|
||||
|
||||
// Field 2: Hidden field
|
||||
val f2 = fields[2]
|
||||
assertTrue(f2.isHidden)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.ecl.ECLConstants
|
||||
import haus.nightmare.lib3270j.ecl.ECLOIA
|
||||
import haus.nightmare.lib3270j.ecl.ECLPS
|
||||
import haus.nightmare.lib3270j.input.InputProcessor
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class EntryAssistAndModesTest {
|
||||
|
||||
@Test
|
||||
fun testEntryAssistDocModeAndWordWrap() {
|
||||
val translator = EbcdicTranslator()
|
||||
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||
val inputProcessor = InputProcessor(screen, translator, null)
|
||||
val oia = ECLOIA(screen, inputProcessor, null)
|
||||
inputProcessor.setOIA(oia)
|
||||
|
||||
screen.isEntryAssistDOCmode = true
|
||||
screen.isEntryAssistWordWrap = true
|
||||
screen.setLeftMargin(0)
|
||||
screen.setRightMargin(20)
|
||||
|
||||
assertTrue(screen.isEntryAssistDOCmode)
|
||||
assertTrue(screen.isEntryAssistWordWrap)
|
||||
assertEquals(0, screen.entryAssistStartColumn)
|
||||
assertEquals(20, screen.entryAssistEndColumn)
|
||||
|
||||
// Type at col 18 with word crossing col 20
|
||||
screen.setCursorPosition(0, 18)
|
||||
inputProcessor.typeCharacter('T')
|
||||
inputProcessor.typeCharacter('E')
|
||||
inputProcessor.typeCharacter('S')
|
||||
inputProcessor.typeCharacter('T')
|
||||
|
||||
// Word wrap moves partial word to next row starting at left margin
|
||||
assertEquals(1, screen.cursorRow)
|
||||
assertTrue(screen.cursorCol >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDocModeTabStops() {
|
||||
val translator = EbcdicTranslator()
|
||||
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||
screen.isEntryAssistDOCmode = true
|
||||
screen.setLeftMargin(0)
|
||||
screen.setRightMargin(79)
|
||||
screen.setWordTabPositions(intArrayOf(10, 20, 30))
|
||||
|
||||
screen.setCursorPosition(0, 0)
|
||||
screen.processWordTab(true)
|
||||
assertEquals(10, screen.cursorCol)
|
||||
|
||||
screen.processWordTab(true)
|
||||
assertEquals(20, screen.cursorCol)
|
||||
|
||||
screen.processWordTab(false)
|
||||
assertEquals(10, screen.cursorCol)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAplModeAndGraphicEscape() {
|
||||
val translator = EbcdicTranslator()
|
||||
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||
val inputProcessor = InputProcessor(screen, translator, null)
|
||||
val oia = ECLOIA(screen, inputProcessor, null)
|
||||
inputProcessor.setOIA(oia)
|
||||
|
||||
assertFalse(inputProcessor.isAplKeyboardMode)
|
||||
inputProcessor.isAplKeyboardMode = true
|
||||
assertTrue(inputProcessor.isAplKeyboardMode)
|
||||
assertTrue(oia.isApl)
|
||||
|
||||
// Type 'a' in APL mode -> stores CS_GE Graphic Escape charset
|
||||
screen.setCursorPosition(0, 0)
|
||||
inputProcessor.typeCharacter('a')
|
||||
|
||||
val cell = screen.getCell(0)
|
||||
assertEquals(CS_GE.toInt(), cell.cs.toInt())
|
||||
|
||||
// Toggle APL mode off
|
||||
inputProcessor.toggleAplKeyboardMode()
|
||||
assertFalse(inputProcessor.isAplKeyboardMode)
|
||||
assertFalse(oia.isApl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAudibleBellCallback() {
|
||||
val translator = EbcdicTranslator()
|
||||
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||
val inputProcessor = InputProcessor(screen, translator, null)
|
||||
|
||||
val bellCount = AtomicInteger(0)
|
||||
inputProcessor.setBellListener { bellCount.incrementAndGet() }
|
||||
inputProcessor.isBellEnabled = true
|
||||
inputProcessor.bellColumn = 74
|
||||
|
||||
screen.setCursorPosition(0, 70)
|
||||
inputProcessor.typeCharacter('A')
|
||||
inputProcessor.typeCharacter('B')
|
||||
inputProcessor.typeCharacter('C')
|
||||
assertEquals(0, bellCount.get())
|
||||
|
||||
inputProcessor.typeCharacter('D') // Col 74 -> Bell fires
|
||||
assertEquals(1, bellCount.get())
|
||||
|
||||
inputProcessor.typeCharacter('E') // Same line -> No re-fire
|
||||
assertEquals(1, bellCount.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertModeResetOnAid() {
|
||||
val translator = EbcdicTranslator()
|
||||
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||
val inputProcessor = InputProcessor(screen, translator, null)
|
||||
|
||||
inputProcessor.isInsertOffOnAid = true
|
||||
inputProcessor.isInsertMode = true
|
||||
assertTrue(inputProcessor.isInsertMode)
|
||||
|
||||
inputProcessor.sendAid(AID_ENTER)
|
||||
assertFalse(inputProcessor.isInsertMode)
|
||||
|
||||
// Disabled
|
||||
inputProcessor.isInsertOffOnAid = false
|
||||
inputProcessor.isInsertMode = true
|
||||
inputProcessor.sendAid(AID_ENTER)
|
||||
assertTrue(inputProcessor.isInsertMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumericFieldLock() {
|
||||
val translator = EbcdicTranslator()
|
||||
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||
val inputProcessor = InputProcessor(screen, translator, null)
|
||||
val oia = ECLOIA(screen, inputProcessor, null)
|
||||
inputProcessor.setOIA(oia)
|
||||
|
||||
screen.setFieldAttribute(0, FA_NUMERIC.toByte())
|
||||
screen.setFieldAttribute(6, FA_PROTECT.toByte())
|
||||
screen.cursorAddress = 1
|
||||
|
||||
inputProcessor.isNumericFieldLock = true
|
||||
inputProcessor.typeCharacter('Z') // Non-numeric
|
||||
assertTrue(inputProcessor.isKeyboardLocked)
|
||||
assertEquals(ECLConstants.INHIBIT_NUMERIC_ONLY, oia.inputInhibited)
|
||||
|
||||
inputProcessor.reset()
|
||||
assertFalse(inputProcessor.isKeyboardLocked)
|
||||
|
||||
// With numeric lock disabled
|
||||
inputProcessor.isNumericFieldLock = false
|
||||
screen.cursorAddress = 1
|
||||
inputProcessor.typeCharacter('Z')
|
||||
assertFalse(inputProcessor.isKeyboardLocked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBase4ColorParity() {
|
||||
// In 3279 Base 4-Color Mode:
|
||||
// Intensified Protected -> White (0xFFFFFF)
|
||||
// Normal Protected -> Turquoise / Cyan (0x00FFFF)
|
||||
// Intensified Unprotected -> Red (0xFF0000)
|
||||
// Normal Unprotected -> Green (0x00FF00)
|
||||
val faNormProt = FA_PROTECT
|
||||
val faHighProt = FA_PROTECT or FA_INT_HIGH_SEL
|
||||
val faNormUnprot = 0
|
||||
val faHighUnprot = FA_INT_HIGH_SEL
|
||||
|
||||
fun getBase4Color(fa: Int): Int {
|
||||
val isProt = (fa and FA_PROTECT) != 0
|
||||
val isHigh = (fa and FA_INT_HIGH_SEL) != 0
|
||||
return if (isProt) {
|
||||
if (isHigh) 0xFFFFFF else 0x00FFFF
|
||||
} else {
|
||||
if (isHigh) 0xFF0000 else 0x00FF00
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(0xFFFFFF, getBase4Color(faHighProt))
|
||||
assertEquals(0x00FFFF, getBase4Color(faNormProt))
|
||||
assertEquals(0xFF0000, getBase4Color(faHighUnprot))
|
||||
assertEquals(0x00FF00, getBase4Color(faNormUnprot))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIniSectionSyntaxMatchingJ3270() {
|
||||
val sampleIni = """
|
||||
[behavior]
|
||||
blockSelectMode = false
|
||||
blockPaste = true
|
||||
|
||||
[clipboard]
|
||||
blockPasteMode = true
|
||||
|
||||
[entryassist]
|
||||
docMode = true
|
||||
wordWrap = true
|
||||
startCol = 5
|
||||
endCol = 75
|
||||
bell = true
|
||||
bellCol = 70
|
||||
tabStops = 5,10,15,20
|
||||
|
||||
[modes]
|
||||
resetInsertOnAid = true
|
||||
fourColorOverride = true
|
||||
numericLock = false
|
||||
autoSkip = true
|
||||
""".trimIndent()
|
||||
|
||||
val lines = sampleIni.lines()
|
||||
var currentSection = ""
|
||||
val parsed = mutableMapOf<String, String>()
|
||||
|
||||
for (line in lines) {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
currentSection = trimmed.substring(1, trimmed.length - 1).lowercase()
|
||||
} else if (trimmed.contains("=")) {
|
||||
val parts = trimmed.split("=", limit = 2)
|
||||
val key = parts[0].trim().lowercase()
|
||||
val value = parts[1].trim()
|
||||
parsed["$currentSection.$key"] = value
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals("false", parsed["behavior.blockselectmode"])
|
||||
assertEquals("true", parsed["behavior.blockpaste"])
|
||||
assertEquals("true", parsed["clipboard.blockpastemode"])
|
||||
|
||||
assertEquals("true", parsed["entryassist.docmode"])
|
||||
assertEquals("true", parsed["entryassist.wordwrap"])
|
||||
assertEquals("5", parsed["entryassist.startcol"])
|
||||
assertEquals("75", parsed["entryassist.endcol"])
|
||||
assertEquals("true", parsed["entryassist.bell"])
|
||||
assertEquals("70", parsed["entryassist.bellcol"])
|
||||
assertEquals("5,10,15,20", parsed["entryassist.tabstops"])
|
||||
|
||||
assertEquals("true", parsed["modes.resetinsertonaid"])
|
||||
assertEquals("true", parsed["modes.fourcoloroverride"])
|
||||
assertEquals("false", parsed["modes.numericlock"])
|
||||
assertEquals("true", parsed["modes.autoskip"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBlockPasteMultiLineAlignment() {
|
||||
val translator = EbcdicTranslator()
|
||||
val screen = ScreenBuffer(TerminalModel.IBM_3278_2, translator)
|
||||
val input = InputProcessor(screen, translator, null)
|
||||
val ps = ECLPS(screen, input, translator)
|
||||
|
||||
// Verify default blockPaste is true
|
||||
assertTrue(input.isBlockPaste)
|
||||
assertTrue(ps.isBlockPaste)
|
||||
|
||||
// 1. Unformatted screen: verify start column 15 is preserved across newlines
|
||||
screen.erase(false)
|
||||
val startPos = screen.rowColToAddress(0, 15)
|
||||
screen.cursorAddress = startPos
|
||||
|
||||
val multilineText = "FIRST\nSECOND\nTHIRD"
|
||||
val pasted = input.pasteText(multilineText, true, false, true)
|
||||
assertEquals(16, pasted)
|
||||
|
||||
assertEquals("FIRST", ps.getString(startPos, 5))
|
||||
val row1Col15 = screen.rowColToAddress(1, 15)
|
||||
assertEquals("SECOND", ps.getString(row1Col15, 6))
|
||||
val row2Col15 = screen.rowColToAddress(2, 15)
|
||||
assertEquals("THIRD", ps.getString(row2Col15, 5))
|
||||
|
||||
// 2. Formatted screen with blockPaste = true
|
||||
screen.erase(false)
|
||||
screen.setFieldAttribute(0, FA_PRINTABLE.toByte())
|
||||
screen.setFieldAttribute(80, FA_PRINTABLE.toByte())
|
||||
screen.setFieldAttribute(160, FA_PRINTABLE.toByte())
|
||||
|
||||
screen.cursorAddress = screen.rowColToAddress(0, 25)
|
||||
val pastedFormatted = input.pasteText("ALPHA\r\nBETA\r\nGAMMA", true, false, true)
|
||||
assertEquals(14, pastedFormatted)
|
||||
|
||||
assertEquals("ALPHA", ps.getString(screen.rowColToAddress(0, 25), 5))
|
||||
assertEquals("BETA", ps.getString(screen.rowColToAddress(1, 25), 4))
|
||||
assertEquals("GAMMA", ps.getString(screen.rowColToAddress(2, 25), 5))
|
||||
|
||||
// 3. Formatted screen with blockPaste = false: reverts to first unprotected field on next row (pos 81)
|
||||
screen.erase(false)
|
||||
screen.setFieldAttribute(0, FA_PRINTABLE.toByte())
|
||||
screen.setFieldAttribute(80, FA_PRINTABLE.toByte())
|
||||
screen.cursorAddress = screen.rowColToAddress(0, 25)
|
||||
input.pasteText("LINE1\nLINE2", true, false, false)
|
||||
|
||||
assertEquals("LINE1", ps.getString(screen.rowColToAddress(0, 25), 5))
|
||||
assertEquals("LINE2", ps.getString(81, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCmsFilenameFormatting() {
|
||||
// Upload (local -> host)
|
||||
assertEquals("PROFILE EXEC A", haus.nightmare.a3270.ui.formatHostFilename("profile.exec", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
|
||||
assertEquals("TEST TXT A", haus.nightmare.a3270.ui.formatHostFilename("test.txt", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
|
||||
assertEquals("README FILE A", haus.nightmare.a3270.ui.formatHostFilename("README", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
|
||||
|
||||
// Download (host -> local)
|
||||
assertEquals("profile.exec", haus.nightmare.a3270.ui.formatLocalFilenameFromHost("PROFILE EXEC A", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
|
||||
assertEquals("test.data", haus.nightmare.a3270.ui.formatLocalFilenameFromHost("TEST DATA A1", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
|
||||
|
||||
// TSO
|
||||
assertEquals("'MYDATA'", haus.nightmare.a3270.ui.formatHostFilename("mydata.txt", haus.nightmare.lib3270j.ft.FTConfig.HostType.TSO))
|
||||
assertEquals("dataset.txt", haus.nightmare.a3270.ui.formatLocalFilenameFromHost("'USER.DATASET'", haus.nightmare.lib3270j.ft.FTConfig.HostType.TSO))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.lib3270j.ConnectionConfig
|
||||
import haus.nightmare.lib3270j.ConnectionState
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.datastream.DataStreamProcessor
|
||||
import haus.nightmare.lib3270j.graphics.GocaDecoder
|
||||
import haus.nightmare.lib3270j.graphics.GraphicsPlane
|
||||
import haus.nightmare.lib3270j.input.InputProcessor
|
||||
import haus.nightmare.lib3270j.graphics.GocaConstants
|
||||
import haus.nightmare.a3270.ui.calculateGridMetrics
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.protocol.TelnetConstants.*
|
||||
import haus.nightmare.lib3270j.protocol.TN3270EConstants.*
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import haus.nightmare.lib3270j.telnet.TelnetConnection
|
||||
import haus.nightmare.lib3270j.telnet.TelnetFSM
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.ArrayList
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class GraphicsAndSysReqTest {
|
||||
|
||||
private lateinit var screenBuffer: ScreenBuffer
|
||||
private lateinit var translator: EbcdicTranslator
|
||||
private lateinit var graphicsPlane: GraphicsPlane
|
||||
private lateinit var gocaDecoder: GocaDecoder
|
||||
|
||||
private class MockConnection(config: ConnectionConfig, fsm: TelnetFSM) : TelnetConnection(config, fsm) {
|
||||
val sentData: MutableList<ByteArray> = ArrayList()
|
||||
|
||||
override fun sendRaw(data: ByteArray) {
|
||||
sentData.add(data.clone())
|
||||
}
|
||||
|
||||
override fun sendRaw(data: ByteArray, offset: Int, length: Int) {
|
||||
val b = ByteArray(length)
|
||||
System.arraycopy(data, offset, b, 0, length)
|
||||
sentData.add(b)
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val model = TerminalModel.IBM_3279_2 // 24x80
|
||||
translator = EbcdicTranslator()
|
||||
screenBuffer = ScreenBuffer(model, translator)
|
||||
graphicsPlane = GraphicsPlane(800, 600)
|
||||
gocaDecoder = GocaDecoder(graphicsPlane)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScreenBufferSetCursorPosition() {
|
||||
screenBuffer.setCursorPosition(5, 10)
|
||||
assertEquals(5 * 80 + 10, screenBuffer.cursorAddress)
|
||||
assertEquals(5, screenBuffer.cursorRow)
|
||||
assertEquals(10, screenBuffer.cursorCol)
|
||||
|
||||
// Clamping check
|
||||
screenBuffer.setCursorPosition(-5, -10)
|
||||
assertEquals(0, screenBuffer.cursorAddress)
|
||||
|
||||
screenBuffer.setCursorPosition(100, 200)
|
||||
assertEquals(23 * 80 + 79, screenBuffer.cursorAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGraphicsCursorFramingOnSendAid() {
|
||||
screenBuffer.erase(false)
|
||||
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte())
|
||||
screenBuffer.getCell(1).ec = 0xC1.toByte() // 'A'
|
||||
screenBuffer.setCellFA(5, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
screenBuffer.cursorAddress = 2
|
||||
|
||||
gocaDecoder.isGraphicsCursorActive = true
|
||||
gocaDecoder.setGraphicCursorPosition(200, -100)
|
||||
|
||||
val sentRef = AtomicReference<ByteArray>()
|
||||
val inputProcessor = object : InputProcessor(screenBuffer, translator, null) {
|
||||
override fun sendAidResponse(data: ByteArray) {
|
||||
sentRef.set(data)
|
||||
}
|
||||
}
|
||||
inputProcessor.gocaDecoder = gocaDecoder
|
||||
|
||||
inputProcessor.sendAid(AID_ENTER)
|
||||
|
||||
val result = sentRef.get()
|
||||
assertNotNull(result)
|
||||
// Expected: AID_SF (0x88) + SF(56 bytes) + AID_ENTER(0x7D) + CursorAddr(2) + SBA(1) + FieldAddr(2) + Data 'A'(1) = 64 bytes
|
||||
assertEquals(64, result.size)
|
||||
assertEquals(AID_SF.toByte(), result[0])
|
||||
assertEquals(0x00.toByte(), result[1])
|
||||
assertEquals(0x34.toByte(), result[2]) // SF len 52
|
||||
assertEquals(0x0F.toByte(), result[3])
|
||||
assertEquals(0x0F.toByte(), result[4])
|
||||
|
||||
// Coordinates at index 25, 27
|
||||
val gx = ((result[25].toInt() and 0xFF) shl 8) or (result[26].toInt() and 0xFF)
|
||||
val gy = ((result[27].toInt() and 0xFF) shl 8) or (result[28].toInt() and 0xFF)
|
||||
assertEquals(200, gx.toShort().toInt())
|
||||
assertEquals(-100, gy.toShort().toInt())
|
||||
|
||||
// Trailing AID at index 57
|
||||
assertEquals(AID_ENTER.toByte(), result[57])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSysReqOutOfBandRoutingAndStateToggle() {
|
||||
val config = ConnectionConfig("127.0.0.1", 23, TerminalModel.IBM_3279_2).apply {
|
||||
isTn3270eEnabled = true
|
||||
}
|
||||
val dsProcessor = DataStreamProcessor(screenBuffer, translator)
|
||||
val fsm = TelnetFSM(config, screenBuffer, dsProcessor)
|
||||
val inputProcessor = InputProcessor(screenBuffer, translator, fsm)
|
||||
dsProcessor.inputProcessor = inputProcessor
|
||||
val connection = MockConnection(config, fsm)
|
||||
fsm.setConnection(connection)
|
||||
fsm.onConnected()
|
||||
|
||||
// 1. Complete TN3270E negotiation
|
||||
fsm.feedBytes(byteArrayOf(IAC.toByte(), DO.toByte(), TELOPT_TN3270E.toByte()), 0, 3)
|
||||
val devTypeIs = byteArrayOf(
|
||||
IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(),
|
||||
0x02, 0x04, 'I'.code.toByte(), 'B'.code.toByte(), 'M'.code.toByte(), '-'.code.toByte(),
|
||||
'3'.code.toByte(), '2'.code.toByte(), '7'.code.toByte(), '9'.code.toByte(), '-'.code.toByte(),
|
||||
'2'.code.toByte(), '-'.code.toByte(), 'E'.code.toByte(),
|
||||
IAC.toByte(), SE.toByte()
|
||||
)
|
||||
fsm.feedBytes(devTypeIs, 0, devTypeIs.size)
|
||||
|
||||
val funcsReq = byteArrayOf(
|
||||
IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(),
|
||||
OP_FUNCTIONS.toByte(), OP_REQUEST.toByte(),
|
||||
FUNC_BIND_IMAGE.toByte(), FUNC_RESPONSES.toByte(), FUNC_SYSREQ.toByte(),
|
||||
IAC.toByte(), SE.toByte()
|
||||
)
|
||||
fsm.feedBytes(funcsReq, 0, funcsReq.size)
|
||||
|
||||
// BIND image
|
||||
val bindPacket = ByteArray(EH_SIZE + 35).apply {
|
||||
this[0] = DT_BIND_IMAGE.toByte()
|
||||
this[4] = 1
|
||||
this[EH_SIZE + 24] = 0x02
|
||||
}
|
||||
val bStream = ByteArrayOutputStream().apply {
|
||||
write(bindPacket)
|
||||
write(IAC)
|
||||
write(EOR)
|
||||
}
|
||||
val bindBytes = bStream.toByteArray()
|
||||
fsm.feedBytes(bindBytes, 0, bindBytes.size)
|
||||
|
||||
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState)
|
||||
assertTrue(fsm.isTn3270eBound)
|
||||
connection.sentData.clear()
|
||||
|
||||
// 2. Lock keyboard and send SYSREQ
|
||||
inputProcessor.isKeyboardLocked = true
|
||||
inputProcessor.sendAid(AID_SYSREQ)
|
||||
|
||||
assertFalse(inputProcessor.isKeyboardLocked)
|
||||
var foundIacAo = false
|
||||
for (pkt in connection.sentData) {
|
||||
if (pkt.size == 2 && (pkt[0].toInt() and 0xFF) == IAC && (pkt[1].toInt() and 0xFF) == AO) {
|
||||
foundIacAo = true
|
||||
}
|
||||
}
|
||||
assertTrue("Expected IAC AO out-of-band telnet command", foundIacAo)
|
||||
assertEquals(ConnectionState.CONNECTED_SSCP, fsm.connectionState)
|
||||
|
||||
// 3. Send second SYSREQ to toggle back
|
||||
inputProcessor.sendAid(AID_SYSREQ)
|
||||
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testImonGddmImageRenderingAndCompatibility() {
|
||||
val hasSetCharDimensions = try {
|
||||
GraphicsPlane::class.java.getMethod("setCharDimensions", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
true
|
||||
} catch (_: NoSuchMethodException) {
|
||||
false
|
||||
}
|
||||
|
||||
if (!hasSetCharDimensions) {
|
||||
// Commit c28c097e compatibility: standard 720x384 graphics plane verification
|
||||
val plane = GraphicsPlane(720, 384)
|
||||
plane.setScreenDimensions(80, 24)
|
||||
val decoder = GocaDecoder(plane)
|
||||
assertEquals(720, plane.totalWidth)
|
||||
assertEquals(384, plane.totalHeight)
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
out.write(GocaConstants.G_GSCP)
|
||||
out.write(0x04)
|
||||
out.write(0x00); out.write(0x10) // X = 16
|
||||
out.write(0x00); out.write(0x10) // Y = 16
|
||||
out.write(GocaConstants.G_GSCOL)
|
||||
out.write(0x01)
|
||||
decoder.decodeStream(out.toByteArray(), 0, out.size())
|
||||
val metrics = calculateGridMetrics(1080f, 1920f, 80, 24)
|
||||
assertTrue(metrics.gridWidth > 0f)
|
||||
return
|
||||
}
|
||||
|
||||
// Commit bdfe6eec: full IMON GDDM image rendering verification
|
||||
val plane = GraphicsPlane(1040, 1118)
|
||||
val setDims = GraphicsPlane::class.java.getMethod("setCharDimensions", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
setDims.invoke(plane, 13, 26)
|
||||
plane.setScreenDimensions(80, 43)
|
||||
val decoder = GocaDecoder(plane)
|
||||
|
||||
assertEquals(1040, plane.totalWidth)
|
||||
assertEquals(1118, plane.totalHeight)
|
||||
assertEquals(520, plane.xMax)
|
||||
assertEquals(558, plane.yMax)
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
// GSCP: Set Current Position (377, 494)
|
||||
out.write(GocaConstants.G_GSCP)
|
||||
out.write(0x04)
|
||||
out.write(0x01); out.write(0x79) // X = 377
|
||||
out.write(0x01); out.write(0xEE) // Y = 494
|
||||
|
||||
// GSCOL: Color 1 = Blue
|
||||
out.write(GocaConstants.G_GSCOL)
|
||||
out.write(0x01)
|
||||
|
||||
// G_GBIMGC (0x91): Begin Image Current Position: len=6, flags=0, w=80, h=80
|
||||
out.write(GocaConstants.G_GBIMGC)
|
||||
out.write(0x06)
|
||||
out.write(0x00); out.write(0x00)
|
||||
out.write(0x00); out.write(0x50) // w = 80
|
||||
out.write(0x00); out.write(0x50) // h = 80
|
||||
|
||||
// G_GIMD (0x92): 10 bytes scanline
|
||||
out.write(GocaConstants.G_GIMD)
|
||||
out.write(0x0A)
|
||||
out.write(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()))
|
||||
|
||||
// G_GEIMG (0x93): End Image with 2-byte operand
|
||||
out.write(GocaConstants.G_GEIMG)
|
||||
out.write(0x02)
|
||||
out.write(0x00); out.write(0x00)
|
||||
|
||||
// Second image tile: Color 2 = Red
|
||||
out.write(GocaConstants.G_GSCOL)
|
||||
out.write(0x02)
|
||||
|
||||
out.write(GocaConstants.G_GBIMGC)
|
||||
out.write(0x06)
|
||||
out.write(0x00); out.write(0x00)
|
||||
out.write(0x00); out.write(0x50)
|
||||
out.write(0x00); out.write(0x50)
|
||||
|
||||
out.write(GocaConstants.G_GIMD)
|
||||
out.write(0x0A)
|
||||
out.write(byteArrayOf(0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte()))
|
||||
|
||||
out.write(GocaConstants.G_GEIMG)
|
||||
out.write(0x02)
|
||||
out.write(0x00); out.write(0x00)
|
||||
|
||||
val stream = out.toByteArray()
|
||||
decoder.decodeStream(stream, 0, stream.size)
|
||||
|
||||
assertTrue(plane.hasContent())
|
||||
val mappedX = plane.mapX(377)
|
||||
val mappedY = plane.mapY(494)
|
||||
assertEquals(897, mappedX)
|
||||
assertEquals(64, mappedY)
|
||||
|
||||
val rgb = plane.rgbBuffer
|
||||
assertNotNull(rgb)
|
||||
val pixelAtImg = rgb[mappedY * 1040 + mappedX]
|
||||
assertNotEquals(0, pixelAtImg)
|
||||
|
||||
// Verify grid metrics and UI scaling calculation against IMON canvas
|
||||
val metrics = calculateGridMetrics(1080f, 1920f, 80, 43)
|
||||
assertTrue(metrics.gridWidth > 0f)
|
||||
assertTrue(metrics.gridHeight > 0f)
|
||||
|
||||
// Touch scaling test: touch in the middle of grid maps accurately to plane coordinates
|
||||
val touchScreenX = metrics.offsetX + metrics.gridWidth / 2f
|
||||
val touchScreenY = metrics.offsetY + metrics.gridHeight / 2f
|
||||
val planeX = (((touchScreenX - metrics.offsetX) * plane.canvasWidth) / metrics.gridWidth).toInt()
|
||||
val planeY = (((touchScreenY - metrics.offsetY) * plane.canvasHeight) / metrics.gridHeight).toInt()
|
||||
assertEquals(plane.canvasWidth / 2, planeX)
|
||||
assertEquals(plane.canvasHeight / 2, planeY)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.input.InputProcessor
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
|
||||
class HardwareKeyboardInputTest {
|
||||
|
||||
private lateinit var screenBuffer: ScreenBuffer
|
||||
private lateinit var translator: EbcdicTranslator
|
||||
private lateinit var inputProcessor: InputProcessor
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val model = TerminalModel.IBM_3279_2 // 24x80
|
||||
translator = EbcdicTranslator()
|
||||
screenBuffer = ScreenBuffer(model, translator)
|
||||
inputProcessor = InputProcessor(screenBuffer, translator, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnformattedTypingAndNavigation() {
|
||||
assertEquals(0, screenBuffer.cursorAddress)
|
||||
|
||||
// Type alphanumeric string
|
||||
val text = "LOGON APPLID(TSO)"
|
||||
for (ch in text) {
|
||||
inputProcessor.typeCharacter(ch)
|
||||
}
|
||||
|
||||
assertEquals(text.length, screenBuffer.cursorAddress)
|
||||
for (i in text.indices) {
|
||||
assertEquals(text[i], screenBuffer.getCell(i).ucs4)
|
||||
}
|
||||
|
||||
// Navigation
|
||||
inputProcessor.cursorLeft()
|
||||
assertEquals(text.length - 1, screenBuffer.cursorAddress)
|
||||
|
||||
inputProcessor.cursorRight()
|
||||
assertEquals(text.length, screenBuffer.cursorAddress)
|
||||
|
||||
inputProcessor.cursorDown()
|
||||
assertEquals(80 + text.length, screenBuffer.cursorAddress)
|
||||
|
||||
inputProcessor.cursorUp()
|
||||
assertEquals(text.length, screenBuffer.cursorAddress)
|
||||
|
||||
inputProcessor.cursorHome()
|
||||
assertEquals(0, screenBuffer.cursorAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBackspaceAndForwardDelete() {
|
||||
val text = "HELLO"
|
||||
for (ch in text) {
|
||||
inputProcessor.typeCharacter(ch)
|
||||
}
|
||||
assertEquals(5, screenBuffer.cursorAddress)
|
||||
|
||||
// Backspace moves left and deletes char
|
||||
inputProcessor.backspace()
|
||||
assertEquals(4, screenBuffer.cursorAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFormattedFieldInputAndTab() {
|
||||
// Create formatted screen with protected label and unprotected input field
|
||||
// Position 0: Protected FA
|
||||
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
// Set label "USERID:" at positions 1..7
|
||||
val label = "USERID:"
|
||||
for (i in label.indices) {
|
||||
screenBuffer.getCell(i + 1).ucs4 = label[i]
|
||||
screenBuffer.getCell(i + 1).ec = translator.unicodeToEbcdic(label[i]).toByte()
|
||||
}
|
||||
|
||||
// Position 10: Unprotected FA
|
||||
screenBuffer.setCellFA(10, FA_PRINTABLE.toByte())
|
||||
|
||||
// Position 20: Protected FA
|
||||
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
|
||||
// Position 30: Unprotected FA
|
||||
screenBuffer.setCellFA(30, FA_PRINTABLE.toByte())
|
||||
|
||||
screenBuffer.cursorAddress = 11
|
||||
|
||||
// Type into first field
|
||||
val userid = "IBMUSER"
|
||||
for (ch in userid) {
|
||||
inputProcessor.typeCharacter(ch)
|
||||
}
|
||||
assertEquals(18, screenBuffer.cursorAddress)
|
||||
for (i in userid.indices) {
|
||||
assertEquals(userid[i], screenBuffer.getCell(11 + i).ucs4)
|
||||
}
|
||||
|
||||
// Tab should jump to position 31 (next unprotected field)
|
||||
inputProcessor.tab()
|
||||
assertEquals(31, screenBuffer.cursorAddress)
|
||||
|
||||
// BackTab should jump back to position 11
|
||||
inputProcessor.backTab()
|
||||
assertEquals(11, screenBuffer.cursorAddress)
|
||||
|
||||
// Home should jump to first unprotected field (position 11)
|
||||
screenBuffer.cursorAddress = 50
|
||||
inputProcessor.cursorHome()
|
||||
assertEquals(11, screenBuffer.cursorAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFormattedDeleteCharAndEraseEof() {
|
||||
// Position 0: Unprotected FA
|
||||
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
|
||||
// Position 10: Protected FA
|
||||
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
|
||||
screenBuffer.cursorAddress = 1
|
||||
val text = "ABCDE"
|
||||
for (ch in text) {
|
||||
inputProcessor.typeCharacter(ch)
|
||||
}
|
||||
|
||||
// Move cursor to 'C' at pos 3
|
||||
screenBuffer.cursorAddress = 3
|
||||
inputProcessor.deleteChar()
|
||||
|
||||
// "ABDE "
|
||||
assertEquals('A', screenBuffer.getCell(1).ucs4)
|
||||
assertEquals('B', screenBuffer.getCell(2).ucs4)
|
||||
assertEquals('D', screenBuffer.getCell(3).ucs4)
|
||||
assertEquals('E', screenBuffer.getCell(4).ucs4)
|
||||
assertEquals(0, screenBuffer.getCell(5).ec.toInt())
|
||||
|
||||
// Erase EOF from pos 3
|
||||
inputProcessor.eraseEof()
|
||||
assertEquals('A', screenBuffer.getCell(1).ucs4)
|
||||
assertEquals('B', screenBuffer.getCell(2).ucs4)
|
||||
assertEquals(0, screenBuffer.getCell(3).ec.toInt())
|
||||
assertEquals(0, screenBuffer.getCell(4).ec.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPfKeyAids() {
|
||||
val pfAids = intArrayOf(
|
||||
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
|
||||
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
|
||||
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
|
||||
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
|
||||
)
|
||||
|
||||
for (i in 1..24) {
|
||||
val aid = pfAids[i - 1]
|
||||
inputProcessor.sendAid(aid)
|
||||
assertEquals(aid, inputProcessor.lastAid)
|
||||
assertTrue(inputProcessor.isKeyboardLocked)
|
||||
inputProcessor.reset()
|
||||
assertFalse(inputProcessor.isKeyboardLocked)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLightPenSelection() {
|
||||
// Field 1 at pos 0: Selectable Unprotected (FA_INT_HIGH)
|
||||
// Designator at pos 1: '?' (EBCDIC 0x6F)
|
||||
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
|
||||
assertTrue(haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa.toInt() and 0xFF))
|
||||
|
||||
screenBuffer.setCellFA(0, fa)
|
||||
screenBuffer.getCell(1).ucs4 = '?'
|
||||
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('?').toByte()
|
||||
|
||||
// Field 2 at pos 20: Non-selectable (normal intensity)
|
||||
screenBuffer.setCellFA(20, FA_PRINTABLE.toByte())
|
||||
screenBuffer.getCell(21).ucs4 = '?'
|
||||
|
||||
// Selecting field 1 ('?') toggles designator to '>' and sets modified bit
|
||||
val result1 = inputProcessor.lightPenSelect(1)
|
||||
assertTrue(result1)
|
||||
assertEquals('>', screenBuffer.getCell(1).ucs4)
|
||||
assertEquals(0x6E.toByte(), screenBuffer.getCell(1).ec)
|
||||
val fa1 = screenBuffer.getCell(0).fa.toInt() and 0xFF
|
||||
assertTrue((fa1 and FA_MODIFY) != 0)
|
||||
|
||||
// Selecting field 1 again ('>') toggles back to '?' and clears modified bit
|
||||
val result2 = inputProcessor.lightPenSelect(1)
|
||||
assertTrue(result2)
|
||||
assertEquals('?', screenBuffer.getCell(1).ucs4)
|
||||
assertEquals(0x6F.toByte(), screenBuffer.getCell(1).ec)
|
||||
|
||||
// Selecting field 2 (non-selectable) returns false
|
||||
val resultNonSelectable = inputProcessor.lightPenSelect(21)
|
||||
assertFalse(resultNonSelectable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLightPenEnterSelection() {
|
||||
// Field with '&' designator immediately sends AID_ENTER
|
||||
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
|
||||
screenBuffer.setCellFA(0, fa)
|
||||
screenBuffer.getCell(1).ucs4 = '&'
|
||||
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('&').toByte()
|
||||
|
||||
val result = inputProcessor.lightPenSelect(1)
|
||||
assertTrue(result)
|
||||
assertEquals(AID_ENTER, inputProcessor.lastAid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEraseInputAndAttention() {
|
||||
// Position 0: Unprotected FA
|
||||
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte())
|
||||
// Position 10: Protected FA
|
||||
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
// Position 20: Unprotected FA
|
||||
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
|
||||
|
||||
screenBuffer.getCell(1).ucs4 = 'X'
|
||||
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('X').toByte()
|
||||
screenBuffer.getCell(21).ucs4 = 'Y'
|
||||
screenBuffer.getCell(21).ec = translator.unicodeToEbcdic('Y').toByte()
|
||||
|
||||
inputProcessor.eraseInput()
|
||||
|
||||
// Unprotected fields should be cleared and reset
|
||||
assertEquals(0, screenBuffer.getCell(1).ec.toInt())
|
||||
assertEquals(0, screenBuffer.getCell(21).ec.toInt())
|
||||
assertEquals(1, screenBuffer.cursorAddress)
|
||||
|
||||
// Attention AID
|
||||
inputProcessor.sendAid(AID_PA1)
|
||||
assertEquals(AID_PA1, inputProcessor.lastAid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.a3270.storage.AppSettings
|
||||
import haus.nightmare.a3270.ui.HotspotEngine
|
||||
import haus.nightmare.a3270.ui.HotspotEngine.HotspotType
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class HotspotAndHistoryFeaturesTest {
|
||||
|
||||
@Test
|
||||
fun testHotspotUrlDetection() {
|
||||
val engine = HotspotEngine()
|
||||
val text = "Visit https://ibm.com/support or http://example.org:8080/test for help"
|
||||
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
|
||||
|
||||
val urls = hotspots.filter { it.type == HotspotType.URL }
|
||||
assertEquals(2, urls.size)
|
||||
assertEquals("https://ibm.com/support", urls[0].action)
|
||||
assertEquals("http://example.org:8080/test", urls[1].action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHotspotPfAndPaKeyDetection() {
|
||||
val engine = HotspotEngine()
|
||||
val text = "Press PF3 to Exit, PF12 to Cancel, F1 for Help, and PA1 for Attention"
|
||||
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
|
||||
|
||||
val pfHotspots = hotspots.filter { it.type == HotspotType.PF_KEY }
|
||||
assertTrue(pfHotspots.any { it.action == "[pf3]" && it.text.equals("PF3", ignoreCase = true) })
|
||||
assertTrue(pfHotspots.any { it.action == "[pf12]" && it.text.equals("PF12", ignoreCase = true) })
|
||||
assertTrue(pfHotspots.any { it.action == "[pf1]" && it.text.equals("F1", ignoreCase = true) })
|
||||
assertTrue(pfHotspots.any { it.action == "[pa1]" && it.text.equals("PA1", ignoreCase = true) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHotspotMenuChoiceAndEnterDetection() {
|
||||
val engine = HotspotEngine()
|
||||
val text = "1. Logon\n2. Logoff\n===> 3\n[Enter] to submit"
|
||||
val hotspots = engine.scanText(text, rows = 4, cols = 30)
|
||||
|
||||
val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE }
|
||||
assertTrue(menuChoices.any { it.action == "1[enter]" })
|
||||
assertTrue(menuChoices.any { it.action == "2[enter]" })
|
||||
assertTrue(menuChoices.any { it.action == "3[enter]" })
|
||||
|
||||
val enterHotspots = hotspots.filter { it.type == HotspotType.ENTER }
|
||||
assertTrue(enterHotspots.any { it.action == "[enter]" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIspfPrimaryOptionMenuDetection() {
|
||||
val engine = HotspotEngine()
|
||||
val 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"
|
||||
|
||||
val 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)
|
||||
val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE }
|
||||
assertEquals(13, menuChoices.size)
|
||||
|
||||
// Option 0
|
||||
val h0 = menuChoices[0]
|
||||
assertEquals(HotspotType.MENU_CHOICE, h0.type)
|
||||
assertEquals("0 Settings", h0.displayText)
|
||||
assertEquals("0[enter]", h0.action)
|
||||
assertEquals("Select option 0 (Settings)", h0.tooltip)
|
||||
assertEquals(5, h0.startRow)
|
||||
|
||||
// Option 1
|
||||
val h1 = menuChoices[1]
|
||||
assertEquals("1 View", h1.displayText)
|
||||
assertEquals("1[enter]", h1.action)
|
||||
assertEquals("Select option 1 (View)", h1.tooltip)
|
||||
assertEquals(6, h1.startRow)
|
||||
|
||||
// Option 2
|
||||
val h2 = menuChoices[2]
|
||||
assertEquals("2 Edit", h2.displayText)
|
||||
assertEquals("2[enter]", h2.action)
|
||||
assertEquals("Select option 2 (Edit)", h2.tooltip)
|
||||
|
||||
// Option 10 (two-digit, index 9 after 0,1,2,3,4,5,6,7,9)
|
||||
val h10 = menuChoices[9]
|
||||
assertEquals("10 SCLM", h10.displayText)
|
||||
assertEquals("10[enter]", h10.action)
|
||||
assertEquals("Select option 10 (SCLM)", h10.tooltip)
|
||||
|
||||
// Option M (letter, index 11)
|
||||
val hM = menuChoices[11]
|
||||
assertEquals("M More", hM.displayText)
|
||||
assertEquals("M[enter]", hM.action)
|
||||
assertEquals("Select option M (More)", hM.tooltip)
|
||||
|
||||
// Option X (exit, index 12)
|
||||
val hX = menuChoices[12]
|
||||
assertEquals("X Exit", hX.displayText)
|
||||
assertEquals("X[enter]", hX.action)
|
||||
assertEquals("Select option X (Exit)", hX.tooltip)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIspfSubOptionsAndMultiColumn() {
|
||||
val engine = HotspotEngine()
|
||||
val screen =
|
||||
" 3.1 Library - Compress or print data set\n" +
|
||||
" 3.2 DataSet - Allocate, rename, or delete\n" +
|
||||
" 1 - View 2 - Edit"
|
||||
|
||||
val hotspots = engine.scanText(screen, 3, 80)
|
||||
assertEquals(4, hotspots.size)
|
||||
|
||||
val h31 = hotspots[0]
|
||||
assertEquals("3.1 Library", h31.displayText)
|
||||
assertEquals("3.1[enter]", h31.action)
|
||||
assertEquals("Select option 3.1 (Library)", h31.tooltip)
|
||||
|
||||
val h32 = hotspots[1]
|
||||
assertEquals("3.2 DataSet", h32.displayText)
|
||||
assertEquals("3.2[enter]", h32.action)
|
||||
|
||||
val h1 = hotspots[2]
|
||||
assertEquals("1 - View", h1.displayText)
|
||||
assertEquals("1[enter]", h1.action)
|
||||
|
||||
val h2 = hotspots[3]
|
||||
assertEquals("2 - Edit", h2.displayText)
|
||||
assertEquals("2[enter]", h2.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHotspotHitTesting() {
|
||||
val engine = HotspotEngine()
|
||||
val text = "Row 0: PF7 Backward PF8 Forward "
|
||||
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
|
||||
|
||||
val pf7 = hotspots.find { it.action == "[pf7]" }
|
||||
assertNotNull(pf7)
|
||||
val hit = engine.findHotspotAt(hotspots, 0, pf7!!.startCol + 1)
|
||||
assertNotNull(hit)
|
||||
assertEquals("[pf7]", hit?.action)
|
||||
|
||||
// Pointing outside any hotspot returns null
|
||||
val miss = engine.findHotspotAt(hotspots, 0, 0)
|
||||
assertNull(miss)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHotspotCategoryDisabling() {
|
||||
val engine = HotspotEngine()
|
||||
val text = "Visit https://example.com or press PF3 or choose 1. Option"
|
||||
|
||||
// Master toggle disabled
|
||||
val disabledScan = engine.scanText(text, rows = 1, cols = text.length, enabled = false)
|
||||
assertTrue(disabledScan.isEmpty())
|
||||
|
||||
// Disable URLs only
|
||||
engine.urlEnabled = false
|
||||
engine.pfKeysEnabled = true
|
||||
engine.menuChoicesEnabled = true
|
||||
val noUrls = engine.scanText(text, rows = 1, cols = text.length, enabled = true)
|
||||
assertTrue(noUrls.none { it.type == HotspotType.URL })
|
||||
assertTrue(noUrls.any { it.type == HotspotType.PF_KEY })
|
||||
|
||||
// Disable PF keys only
|
||||
engine.urlEnabled = true
|
||||
engine.pfKeysEnabled = false
|
||||
val noPfs = engine.scanText(text, rows = 1, cols = text.length, enabled = true)
|
||||
assertTrue(noPfs.none { it.type == HotspotType.PF_KEY })
|
||||
assertTrue(noPfs.any { it.type == HotspotType.URL })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScreenHistoryBufferCapturesSnapshots() {
|
||||
val translator = EbcdicTranslator()
|
||||
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||
val history = screen.historyBuffer
|
||||
|
||||
assertNotNull(history)
|
||||
assertEquals(0, history.size())
|
||||
|
||||
// Write first screen
|
||||
screen.setCursorPosition(0, 0)
|
||||
screen.setChar(0, 0, 'A')
|
||||
screen.setChar(0, 1, 'B')
|
||||
screen.setChar(0, 2, 'C')
|
||||
history.snapshot(screen)
|
||||
|
||||
assertEquals(1, history.size())
|
||||
val snap0 = history.get(0)
|
||||
assertNotNull(snap0)
|
||||
assertEquals('A', snap0?.getChar(0, 0))
|
||||
assertEquals('B', snap0?.getChar(0, 1))
|
||||
assertEquals('C', snap0?.getChar(0, 2))
|
||||
|
||||
// Consecutive identical snapshot is suppressed
|
||||
history.snapshot(screen)
|
||||
assertEquals(1, history.size())
|
||||
|
||||
// Modify screen and take second snapshot
|
||||
screen.setChar(0, 0, 'X')
|
||||
history.snapshot(screen)
|
||||
assertEquals(2, history.size())
|
||||
assertEquals('X', history.get(1)?.getChar(0, 0))
|
||||
|
||||
// Verify table formatting
|
||||
val formattedTable = snap0?.formattedTable
|
||||
assertNotNull(formattedTable)
|
||||
assertTrue(formattedTable!!.startsWith("ABC"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFieldOutliningConstantsAndAttribute() {
|
||||
assertEquals(0x01.toByte(), ExtendedAttribute.OL_UNDERLINE)
|
||||
assertEquals(0x02.toByte(), ExtendedAttribute.OL_RIGHT)
|
||||
assertEquals(0x04.toByte(), ExtendedAttribute.OL_OVERLINE)
|
||||
assertEquals(0x08.toByte(), ExtendedAttribute.OL_LEFT)
|
||||
assertEquals(0x0F.toByte(), ExtendedAttribute.OL_BOX)
|
||||
|
||||
val ea = ExtendedAttribute()
|
||||
ea.ol = ExtendedAttribute.OL_BOX
|
||||
assertEquals(ExtendedAttribute.OL_BOX, ea.ol)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppSettingsIniRoundtripWithHotspotsAndSelectionPreferences() {
|
||||
// Check that default for hotspots is false (disabled for touch screens)
|
||||
val testIni = """
|
||||
[behavior]
|
||||
hotspots_enabled=false
|
||||
trim_rect_sizing_handles=true
|
||||
trim_rect_remain_after_edit=true
|
||||
paste_word_break=false
|
||||
|
||||
[hotspots]
|
||||
url=true
|
||||
pf_keys=true
|
||||
menu_choices=false
|
||||
""".trimIndent()
|
||||
|
||||
var hsEnabled: Boolean? = null
|
||||
var trimHandles: Boolean? = null
|
||||
var trimRemain: Boolean? = null
|
||||
var wordBreak: Boolean? = null
|
||||
var hsUrl: Boolean? = null
|
||||
var hsPf: Boolean? = null
|
||||
var hsChoices: Boolean? = null
|
||||
|
||||
AppSettings.parseIni(java.io.StringReader(testIni)) { _, k, v ->
|
||||
when (k) {
|
||||
"hotspots_enabled" -> hsEnabled = v.toBoolean()
|
||||
"trim_rect_sizing_handles" -> trimHandles = v.toBoolean()
|
||||
"trim_rect_remain_after_edit" -> trimRemain = v.toBoolean()
|
||||
"paste_word_break" -> wordBreak = v.toBoolean()
|
||||
"hotspots_url_enabled", "url" -> hsUrl = v.toBoolean()
|
||||
"hotspots_pf_enabled", "pf_keys" -> hsPf = v.toBoolean()
|
||||
"hotspots_menu_choices_enabled", "menu_choices" -> hsChoices = v.toBoolean()
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(false, hsEnabled)
|
||||
assertEquals(true, trimHandles)
|
||||
assertEquals(true, trimRemain)
|
||||
assertEquals(false, wordBreak)
|
||||
assertEquals(true, hsUrl)
|
||||
assertEquals(true, hsPf)
|
||||
assertEquals(false, hsChoices)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMenuChoiceActionPrefixingForSafeSubmission() {
|
||||
val engine = HotspotEngine()
|
||||
val screen = " 1 View Display source data or listings"
|
||||
val hotspots = engine.scanText(screen, 1, 80)
|
||||
assertEquals(1, hotspots.size)
|
||||
val h = hotspots[0]
|
||||
assertEquals(HotspotType.MENU_CHOICE, h.type)
|
||||
assertEquals("1[enter]", h.action)
|
||||
|
||||
// Menu choices must be submitted via [home][eraseeof] to prevent protected field keyboard lock
|
||||
var safeAction = h.action
|
||||
if (!safeAction.startsWith("[home]")) {
|
||||
safeAction = "[home][eraseeof]$safeAction"
|
||||
}
|
||||
assertEquals("[home][eraseeof]1[enter]", safeAction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.CodePageRegistry
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.ecl.ECLConstants
|
||||
import haus.nightmare.lib3270j.input.InputProcessor
|
||||
import haus.nightmare.lib3270j.nvt.NvtProcessor
|
||||
import haus.nightmare.lib3270j.printer.PrinterDefinitionTable
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
|
||||
class NvtAndExtendedFeaturesTest {
|
||||
|
||||
private lateinit var screenBuffer: ScreenBuffer
|
||||
private lateinit var translator: EbcdicTranslator
|
||||
private lateinit var inputProcessor: InputProcessor
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val model = TerminalModel.IBM_3279_2
|
||||
translator = EbcdicTranslator()
|
||||
screenBuffer = ScreenBuffer(model, translator)
|
||||
inputProcessor = InputProcessor(screenBuffer, translator, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExtendedCodePagesAvailability() {
|
||||
val testCps = listOf(
|
||||
"420", "424", "803", "838", "1160",
|
||||
"1025", "1123", "1154", "880", "905", "1026", "1155",
|
||||
"1140", "1141", "1142", "1143", "1144", "1145", "1146", "1147", "1148", "1149",
|
||||
"930", "939", "935", "937", "1388", "1371", "933"
|
||||
)
|
||||
|
||||
for (cp in testCps) {
|
||||
val trans = CodePageRegistry.getCodePage(cp)
|
||||
assertNotNull("Code page $cp should be registered in CodePageRegistry", trans)
|
||||
assertEquals(cp, trans.codePageId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExtendedCodePageTranslation() {
|
||||
// Test Greek Cp875
|
||||
val greekTrans = CodePageRegistry.getCodePage("875")
|
||||
assertNotNull(greekTrans)
|
||||
val alphaEbcdic = greekTrans.unicodeToEbcdic('α')
|
||||
val alphaUnicode = greekTrans.ebcdicToUnicode(alphaEbcdic.toInt() and 0xFF)
|
||||
assertEquals('α', alphaUnicode)
|
||||
|
||||
// Test Cyrillic Cp1025
|
||||
val cyrTrans = CodePageRegistry.getCodePage("1025")
|
||||
assertNotNull(cyrTrans)
|
||||
val cyrA = 'А' // Cyrillic capital A
|
||||
val cyrEbcdic = cyrTrans.unicodeToEbcdic(cyrA)
|
||||
val cyrResult = cyrTrans.ebcdicToUnicode(cyrEbcdic.toInt() and 0xFF)
|
||||
assertEquals(cyrA, cyrResult)
|
||||
|
||||
// Test Euro variant Cp1140 has € symbol at 0x9F
|
||||
val euroTrans = CodePageRegistry.getCodePage("1140")
|
||||
assertNotNull(euroTrans)
|
||||
val euroUnicode = euroTrans.ebcdicToUnicode(0x9F)
|
||||
assertEquals('€', euroUnicode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPDTTables() {
|
||||
val pcl5 = PrinterDefinitionTable.createPcl5PDT()
|
||||
assertNotNull(pcl5)
|
||||
assertEquals("PCL_5", pcl5.name)
|
||||
assertNotNull(pcl5.getControlCode(PrinterDefinitionTable.CMD_START_JOB))
|
||||
|
||||
val escp = PrinterDefinitionTable.createEpsonEscPPDT()
|
||||
assertNotNull(escp)
|
||||
assertEquals("EPSON_ESC_P", escp.name)
|
||||
assertNotNull(escp.getControlCode(PrinterDefinitionTable.CMD_START_BOLD))
|
||||
|
||||
val ps = PrinterDefinitionTable.createPostScriptPDT()
|
||||
assertNotNull(ps)
|
||||
assertEquals("POSTSCRIPT", ps.name)
|
||||
val psStart = String(ps.getControlCode(PrinterDefinitionTable.CMD_START_JOB) ?: ByteArray(0), java.nio.charset.StandardCharsets.ISO_8859_1)
|
||||
assertTrue(psStart.contains("%!PS"))
|
||||
|
||||
val plain = PrinterDefinitionTable.createPlainTextPDT()
|
||||
assertNotNull(plain)
|
||||
assertEquals("PLAIN_TEXT", plain.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWordNavigationAndEditing() {
|
||||
val text = "IBM MAINFRAME SYSTEM"
|
||||
for (i in text.indices) {
|
||||
screenBuffer.getCell(i).ucs4 = text[i]
|
||||
screenBuffer.getCell(i).ec = translator.unicodeToEbcdic(text[i]).toByte()
|
||||
}
|
||||
|
||||
screenBuffer.cursorAddress = 0
|
||||
inputProcessor.processWordRight()
|
||||
assertEquals(5, screenBuffer.cursorAddress)
|
||||
|
||||
inputProcessor.processWordRight()
|
||||
assertEquals(16, screenBuffer.cursorAddress)
|
||||
|
||||
inputProcessor.processWordLeft()
|
||||
assertEquals(5, screenBuffer.cursorAddress)
|
||||
|
||||
inputProcessor.processWordLeft()
|
||||
assertEquals(0, screenBuffer.cursorAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFieldEndAndToggleInsert() {
|
||||
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
|
||||
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
|
||||
val text = "HELLO"
|
||||
for (i in text.indices) {
|
||||
screenBuffer.getCell(1 + i).ucs4 = text[i]
|
||||
screenBuffer.getCell(1 + i).ec = translator.unicodeToEbcdic(text[i]).toByte()
|
||||
}
|
||||
|
||||
screenBuffer.cursorAddress = 1
|
||||
inputProcessor.processFieldEnd()
|
||||
assertEquals(6, screenBuffer.cursorAddress)
|
||||
|
||||
assertFalse(inputProcessor.isInsertMode)
|
||||
inputProcessor.processToggleInsert()
|
||||
assertTrue(inputProcessor.isInsertMode)
|
||||
inputProcessor.processToggleInsert()
|
||||
assertFalse(inputProcessor.isInsertMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNvtFunctionKeySequences() {
|
||||
val nvt = NvtProcessor(screenBuffer, null)
|
||||
|
||||
val f1 = nvt.getFunctionKeySequence(1)
|
||||
val f5 = nvt.getFunctionKeySequence(5)
|
||||
val f10 = nvt.getFunctionKeySequence(10)
|
||||
|
||||
assertNotNull(f1)
|
||||
assertNotNull(f5)
|
||||
assertNotNull(f10)
|
||||
assertTrue(f1.startsWith("\u001B"))
|
||||
assertTrue(f5.startsWith("\u001B"))
|
||||
assertTrue(f10.startsWith("\u001B"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testECLFieldWrapping() {
|
||||
val totalCells = screenBuffer.rows * screenBuffer.cols
|
||||
val faPos = totalCells - 10
|
||||
screenBuffer.setCellFA(faPos, FA_PRINTABLE.toByte())
|
||||
screenBuffer.setCellFA(15, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||
|
||||
val fieldList = screenBuffer.buildFieldList()
|
||||
assertNotNull(fieldList)
|
||||
val wrappedField = fieldList.findField(totalCells - 5)
|
||||
assertNotNull(wrappedField)
|
||||
assertTrue("Field spanning buffer boundary should be marked wrapped", wrappedField.isWrapped)
|
||||
assertTrue(wrappedField.contains(totalCells - 2))
|
||||
assertTrue(wrappedField.contains(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOiaInhibitConstants() {
|
||||
assertEquals(1, ECLConstants.INHIBIT_SYSTEM_LOCK)
|
||||
assertEquals(0, ECLConstants.INHIBIT_NOT_INHIBITED)
|
||||
assertEquals(5, ECLConstants.INHIBIT_COMM_CHECK)
|
||||
assertEquals(3, ECLConstants.INHIBIT_PROTECTED_FIELD)
|
||||
assertEquals(2, ECLConstants.INHIBIT_NUMERIC_ONLY)
|
||||
assertEquals(4, ECLConstants.INHIBIT_OVERFLOW)
|
||||
assertEquals(6, ECLConstants.INHIBIT_OPERATOR_DUE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package haus.nightmare.a3270
|
||||
|
||||
import haus.nightmare.lib3270j.TerminalModel
|
||||
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||
import haus.nightmare.lib3270j.input.InputProcessor
|
||||
import haus.nightmare.lib3270j.printer.PrinterConfig
|
||||
import haus.nightmare.lib3270j.printer.PrinterConstants
|
||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ScriptAndPrinterTest {
|
||||
|
||||
private lateinit var screenBuffer: ScreenBuffer
|
||||
private lateinit var translator: EbcdicTranslator
|
||||
private lateinit var inputProcessor: InputProcessor
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val model = TerminalModel.IBM_3279_2
|
||||
translator = EbcdicTranslator()
|
||||
screenBuffer = ScreenBuffer(model, translator)
|
||||
inputProcessor = InputProcessor(screenBuffer, translator, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmulateInputMnemonicTokens() {
|
||||
// Position 0: Unprotected input field
|
||||
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
|
||||
screenBuffer.cursorAddress = 1
|
||||
|
||||
inputProcessor.sendKeys("HELLO[enter]")
|
||||
assertEquals('H', screenBuffer.getCell(1).ucs4)
|
||||
assertEquals('E', screenBuffer.getCell(2).ucs4)
|
||||
assertEquals('L', screenBuffer.getCell(3).ucs4)
|
||||
assertEquals('L', screenBuffer.getCell(4).ucs4)
|
||||
assertEquals('O', screenBuffer.getCell(5).ucs4)
|
||||
assertEquals(AID_ENTER, inputProcessor.lastAid)
|
||||
|
||||
inputProcessor.reset()
|
||||
inputProcessor.sendKeys("[pf3]")
|
||||
assertEquals(AID_PF3, inputProcessor.lastAid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrinterConfigParameters() {
|
||||
val config = PrinterConfig("10.0.0.1", 992, "PRT01", true).apply {
|
||||
associatedDisplayLuName = "DSP01"
|
||||
codePage = "1047"
|
||||
mpp = 132
|
||||
mpl = 66
|
||||
destinationType = PrinterConfig.DestinationType.MEMORY
|
||||
}
|
||||
|
||||
assertEquals("10.0.0.1", config.host)
|
||||
assertEquals(992, config.port)
|
||||
assertEquals("PRT01", config.printerLuName)
|
||||
assertEquals("DSP01", config.associatedDisplayLuName)
|
||||
assertTrue(config.isUseTls)
|
||||
assertEquals("1047", config.codePage)
|
||||
assertEquals(132, config.mpp)
|
||||
assertEquals(66, config.mpl)
|
||||
assertEquals(PrinterConfig.DestinationType.MEMORY, config.destinationType)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user