Compare commits
15 Commits
v0.2.2-beta
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
88e3ffcbe9
|
|||
|
f389e626ea
|
|||
|
0eaab32e2f
|
|||
|
da289730db
|
|||
|
f77f51474c
|
|||
|
e82d01003b
|
|||
|
31b85f491c
|
|||
|
9f96555c9d
|
|||
|
a7cc4b9d50
|
|||
|
600b626e94
|
|||
|
18c4203c0d
|
|||
|
f6d69bf0ff
|
|||
|
aa008664bc
|
|||
|
8344b9f4f7
|
|||
|
052a6619ae
|
@@ -41,6 +41,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
chmod +x ./gradlew ./build.sh
|
chmod +x ./gradlew ./build.sh
|
||||||
|
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: |
|
||||||
|
./gradlew testDebugUnitTest --no-daemon
|
||||||
|
env:
|
||||||
|
J3270_DIR: ${{ github.workspace }}/j3270
|
||||||
|
|
||||||
- name: Build Android APK
|
- name: Build Android APK
|
||||||
run: |
|
run: |
|
||||||
./gradlew assembleDebug --no-daemon
|
./gradlew assembleDebug --no-daemon
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ captures/
|
|||||||
.cxx
|
.cxx
|
||||||
*.jar
|
*.jar
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
*.txt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[](https://git.hugfreevikings.wtf/rudi/a3270/actions)
|
[](https://git.hugfreevikings.wtf/rudi/a3270/actions)
|
||||||
[](https://developer.android.com)
|
[](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.
|
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.
|
||||||
|
|
||||||
@@ -34,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
|
## ✨ Features
|
||||||
|
|
||||||
- **Modern Jetpack Compose UI**: Responsive, touch-optimized 3270 terminal screen rendering.
|
### 🖥️ Terminal Emulation & Protocol
|
||||||
- **TN3270 & TN3270E Protocol Support**: RFC 2355 compliant state machine, negotiation, Device-Type query, and SSL/TLS encryption via `lib3270j`.
|
- **RFC 2355 TN3270 & TN3270E Engine**: Robust state machine, Option Negotiation, Device-Type Query, and LU name specification powered by `lib3270j`.
|
||||||
- **On-Screen Function Key Bar**: Quick-access touch bar for PF1–PF24, PA1–PA3, Enter, Clear, Reset, and Attn keys.
|
- **Standard & Dynamic Terminal Models**:
|
||||||
- **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.
|
- Model 2 (24 × 80)
|
||||||
- **OIA Status Line**: Real-time Operator Information Area displaying connection state, keyboard lock, insert mode, cursor row/col, and LU name.
|
- Model 3 (32 × 80)
|
||||||
- **Host Profile Storage**: Save and manage multiple mainframe connection profiles with custom ports, models, and SSL/TLS options.
|
- Model 4 (43 × 80)
|
||||||
- **Background Session Persistence**: Android foreground service keeps your terminal session alive when multitasking.
|
- Model 5 (27 × 132)
|
||||||
- **Terminal Models**:
|
- **Dynamic Screen Dimensions**: Configurable custom geometry (rows & columns up to 255 × 255).
|
||||||
- Model 2 (24x80)
|
- **EBCDIC Code Pages**: Full character translation across international code pages (037, 1047, 500, etc.).
|
||||||
- Model 3 (32x80)
|
|
||||||
- Model 4 (43x80)
|
### 📊 Graphics & Peripherals
|
||||||
- Model 5 (27x132)
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -56,6 +106,7 @@ adb install -r a3270.apk
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
- **Java Development Kit (JDK)**: JDK 17 or JDK 21
|
- **Java Development Kit (JDK)**: JDK 17 or JDK 21
|
||||||
- **Android SDK**: API 34 platforms and build-tools (`ANDROID_HOME` or `ANDROID_SDK_ROOT` set)
|
- **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
|
### 1. Clone Repositories Side-by-Side
|
||||||
For the easiest setup, clone both `j3270` and `a3270` into the same parent folder:
|
For the easiest setup, clone both `j3270` and `a3270` into the same parent folder:
|
||||||
@@ -93,14 +144,6 @@ build/outputs/apk/debug/a3270-debug.apk
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🤝 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
|
## 📜 Acknowledgements
|
||||||
|
|
||||||
- [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine
|
- [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine
|
||||||
|
|||||||
+5
-4
@@ -4,15 +4,15 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'org.pubvm.a3270'
|
namespace 'haus.nightmare.a3270'
|
||||||
compileSdk 34
|
compileSdk 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "org.pubvm.a3270"
|
applicationId "haus.nightmare.a3270"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 4
|
versionCode 6
|
||||||
versionName "0.1.3"
|
versionName "1.0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -71,5 +71,6 @@ dependencies {
|
|||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
testImplementation 'org.json:json:20231013'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ echo "=== Building a3270 (Android APK) ==="
|
|||||||
# Set up JAVA_HOME (prefer JDK 21 if available)
|
# Set up JAVA_HOME (prefer JDK 21 if available)
|
||||||
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
|
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
|
||||||
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
|
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
|
||||||
|
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
|
elif [ -z "$JAVA_HOME" ]; then
|
||||||
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
|
if [ -d "$HOME/.sdkman/candidates/java/current" ]; then
|
||||||
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
|
export JAVA_HOME="$HOME/.sdkman/candidates/java/current"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ fi
|
|||||||
if [ -z "$ANDROID_HOME" ]; then
|
if [ -z "$ANDROID_HOME" ]; then
|
||||||
if [ -d "$HOME/Library/Android/sdk" ]; then
|
if [ -d "$HOME/Library/Android/sdk" ]; then
|
||||||
export ANDROID_HOME="$HOME/Library/Android/sdk"
|
export ANDROID_HOME="$HOME/Library/Android/sdk"
|
||||||
|
elif [ -d "$HOME/Android/Sdk" ]; then
|
||||||
|
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -42,7 +46,7 @@ if [ ! -d "$J3270_PATH/lib3270j" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building debug APK..."
|
echo "Building debug APK..."
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug --no-daemon
|
||||||
|
|
||||||
APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk"
|
APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk"
|
||||||
if [ -f "$APK_PATH" ]; then
|
if [ -f "$APK_PATH" ]; then
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
|
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
|
||||||
|
kotlin.compiler.execution.strategy=in-process
|
||||||
|
|
||||||
|
|||||||
+458
-57
@@ -1,4 +1,4 @@
|
|||||||
package org.pubvm.a3270
|
package haus.nightmare.a3270
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -23,18 +23,24 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.KeyCharacterMap
|
import android.view.KeyCharacterMap
|
||||||
import org.lib3270j.protocol.DS3270Constants.*
|
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||||
import org.pubvm.a3270.service.TerminalService
|
import haus.nightmare.a3270.service.TerminalService
|
||||||
import org.pubvm.a3270.storage.HostStorage
|
import haus.nightmare.a3270.storage.AppSettings
|
||||||
import org.pubvm.a3270.ui.ConnectDialog
|
import haus.nightmare.a3270.storage.HostStorage
|
||||||
import org.pubvm.a3270.ui.FileTransferDialog
|
import haus.nightmare.a3270.ui.ConnectDialog
|
||||||
import org.pubvm.a3270.ui.OiaStatusBar
|
import haus.nightmare.a3270.ui.FileTransferDialog
|
||||||
import org.pubvm.a3270.ui.SettingsDialog
|
import haus.nightmare.a3270.ui.OiaStatusBar
|
||||||
import org.pubvm.a3270.ui.TerminalInputView
|
import haus.nightmare.a3270.ui.SettingsDialog
|
||||||
import org.pubvm.a3270.ui.TerminalView
|
import haus.nightmare.a3270.ui.TerminalInputView
|
||||||
import org.pubvm.a3270.ui.TwoRowKeyBar
|
import haus.nightmare.a3270.ui.TerminalView
|
||||||
|
import haus.nightmare.a3270.ui.TwoRowKeyBar
|
||||||
|
import haus.nightmare.a3270.ui.FieldInspectorDialog
|
||||||
|
import haus.nightmare.a3270.ui.FindDialog
|
||||||
|
import haus.nightmare.a3270.ui.ScreenExporterDialog
|
||||||
|
import haus.nightmare.a3270.ui.ScriptDialog
|
||||||
|
import haus.nightmare.a3270.ui.PrinterSessionDialog
|
||||||
|
|
||||||
import org.pubvm.a3270.ui.UntrustedCertificateDialog
|
import haus.nightmare.a3270.ui.UntrustedCertificateDialog
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -46,19 +52,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
rootLogger.removeHandler(h)
|
rootLogger.removeHandler(h)
|
||||||
}
|
}
|
||||||
rootLogger.addHandler(object : java.util.logging.Handler() {
|
rootLogger.addHandler(object : java.util.logging.Handler() {
|
||||||
override fun publish(record: java.util.logging.LogRecord?) {
|
override fun publish(record: java.util.logging.LogRecord) {
|
||||||
if (record == null) return
|
val tag = "lib3270j-" + (record.loggerName?.substringAfterLast('.') ?: "core")
|
||||||
val msg = record.message ?: ""
|
val msg = record.message ?: ""
|
||||||
val tag = "a3270-" + (record.loggerName?.substringAfterLast('.') ?: "lib3270j")
|
when (record.level.intValue()) {
|
||||||
when {
|
java.util.logging.Level.SEVERE.intValue() -> android.util.Log.e(tag, msg, record.thrown)
|
||||||
record.level.intValue() >= java.util.logging.Level.SEVERE.intValue() ->
|
java.util.logging.Level.WARNING.intValue() -> android.util.Log.w(tag, msg, record.thrown)
|
||||||
android.util.Log.e(tag, msg, record.thrown)
|
java.util.logging.Level.INFO.intValue() -> android.util.Log.i(tag, msg, record.thrown)
|
||||||
record.level.intValue() >= java.util.logging.Level.WARNING.intValue() ->
|
java.util.logging.Level.CONFIG.intValue() -> android.util.Log.d(tag, msg, record.thrown)
|
||||||
android.util.Log.w(tag, msg, record.thrown)
|
else -> android.util.Log.v(tag, msg, record.thrown)
|
||||||
record.level.intValue() >= java.util.logging.Level.INFO.intValue() ->
|
|
||||||
android.util.Log.i(tag, msg, record.thrown)
|
|
||||||
else ->
|
|
||||||
android.util.Log.d(tag, msg, record.thrown)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun flush() {}
|
override fun flush() {}
|
||||||
@@ -69,6 +71,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val viewModel: TerminalViewModel by viewModels()
|
private val viewModel: TerminalViewModel by viewModels()
|
||||||
private val isShiftPressedState = mutableStateOf(false)
|
private val isShiftPressedState = mutableStateOf(false)
|
||||||
|
private val isFindDialogOpenState = mutableStateOf(false)
|
||||||
|
private val isFtDialogOpenState = mutableStateOf(false)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -83,17 +87,29 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(
|
val uiTheme by viewModel.uiTheme.collectAsState()
|
||||||
colorScheme = darkColorScheme(
|
val colorScheme = if (uiTheme.equals("LIGHT", ignoreCase = true)) {
|
||||||
|
lightColorScheme(
|
||||||
|
background = Color(0xFFF2F2F4),
|
||||||
|
surface = Color.White,
|
||||||
|
primary = Color(0xFF1971C2)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
darkColorScheme(
|
||||||
background = Color.Black,
|
background = Color.Black,
|
||||||
surface = Color(0xFF1E1E1E),
|
surface = Color(0xFF1E1E1E),
|
||||||
primary = Color(0xFF339AF0)
|
primary = Color(0xFF339AF0)
|
||||||
)
|
)
|
||||||
) {
|
}
|
||||||
|
MaterialTheme(colorScheme = colorScheme) {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
isShiftPressed = isShiftPressedState.value,
|
isShiftPressed = isShiftPressedState.value,
|
||||||
onClearShift = { isShiftPressedState.value = false }
|
onClearShift = { isShiftPressedState.value = false },
|
||||||
|
isFindDialogOpen = isFindDialogOpenState.value,
|
||||||
|
onSetFindDialogOpen = { isFindDialogOpenState.value = it },
|
||||||
|
isFtDialogOpen = isFtDialogOpenState.value,
|
||||||
|
onSetFtDialogOpen = { isFtDialogOpenState.value = it }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +160,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
|
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
|
||||||
if (isShiftPressedState.value != shift) {
|
if (isShiftPressedState.value != shift) {
|
||||||
@@ -174,6 +191,29 @@ class MainActivity : ComponentActivity() {
|
|||||||
val alt = event.isAltPressed || (event.metaState and KeyEvent.META_ALT_ON != 0)
|
val alt = event.isAltPressed || (event.metaState and KeyEvent.META_ALT_ON != 0)
|
||||||
|
|
||||||
// 1. Function Keys (F1..F12 -> PF1..PF12 or PF13..PF24 with Shift or Alt)
|
// 1. Function Keys (F1..F12 -> PF1..PF12 or PF13..PF24 with Shift or Alt)
|
||||||
|
// Mode toggles matching j3270:
|
||||||
|
// Alt+F1: DOC mode (Entry Assist)
|
||||||
|
// Alt+F2: Word Wrap
|
||||||
|
// Alt+F3: APL Mode
|
||||||
|
if (alt && keyCode == KeyEvent.KEYCODE_F1) {
|
||||||
|
viewModel.toggleDocMode()
|
||||||
|
val isDoc = viewModel.isDocMode.value
|
||||||
|
Toast.makeText(this, "DOC Mode: " + (if (isDoc) "ON" else "OFF"), Toast.LENGTH_SHORT).show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (alt && keyCode == KeyEvent.KEYCODE_F2) {
|
||||||
|
viewModel.toggleWordWrap()
|
||||||
|
val isWrap = viewModel.isWordWrap.value
|
||||||
|
Toast.makeText(this, "Word Wrap: " + (if (isWrap) "ON" else "OFF"), Toast.LENGTH_SHORT).show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (alt && keyCode == KeyEvent.KEYCODE_F3) {
|
||||||
|
viewModel.toggleAplMode()
|
||||||
|
val isApl = viewModel.isAplMode.value
|
||||||
|
Toast.makeText(this, "APL Mode: " + (if (isApl) "ON" else "OFF"), Toast.LENGTH_SHORT).show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
|
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
|
||||||
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
|
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
|
||||||
val pfNum = if (shift || alt) fNum + 12 else fNum
|
val pfNum = if (shift || alt) fNum + 12 else fNum
|
||||||
@@ -193,6 +233,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
// 2. Ctrl Shortcuts
|
// 2. Ctrl Shortcuts
|
||||||
if (ctrl) {
|
if (ctrl) {
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_F -> {
|
||||||
|
isFindDialogOpenState.value = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_D -> {
|
||||||
|
viewModel.disconnect()
|
||||||
|
return true
|
||||||
|
}
|
||||||
KeyEvent.KEYCODE_V -> {
|
KeyEvent.KEYCODE_V -> {
|
||||||
pasteFromClipboard()
|
pasteFromClipboard()
|
||||||
return true
|
return true
|
||||||
@@ -209,6 +257,26 @@ class MainActivity : ComponentActivity() {
|
|||||||
viewModel.sendAid(AID_PA1)
|
viewModel.sendAid(AID_PA1)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
KeyEvent.KEYCODE_A -> {
|
||||||
|
viewModel.attn()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_S -> {
|
||||||
|
viewModel.sysReq()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||||
|
viewModel.wordLeft()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||||
|
viewModel.wordRight()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_FORWARD_DEL, KeyEvent.KEYCODE_DEL -> {
|
||||||
|
viewModel.deleteWord()
|
||||||
|
return true
|
||||||
|
}
|
||||||
KeyEvent.KEYCODE_HOME -> {
|
KeyEvent.KEYCODE_HOME -> {
|
||||||
viewModel.cursorHome()
|
viewModel.cursorHome()
|
||||||
return true
|
return true
|
||||||
@@ -220,9 +288,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Alt Shortcuts (PA keys, Clear, Reset)
|
// 3. Alt Shortcuts (PA keys, Clear, Reset, Enter, Erase, Attn, SysReq, CursorSelect, Ruler, FT)
|
||||||
if (alt) {
|
if (alt) {
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||||
|
viewModel.sendAid(AID_ENTER)
|
||||||
|
return true
|
||||||
|
}
|
||||||
KeyEvent.KEYCODE_1 -> {
|
KeyEvent.KEYCODE_1 -> {
|
||||||
viewModel.sendAid(AID_PA1)
|
viewModel.sendAid(AID_PA1)
|
||||||
return true
|
return true
|
||||||
@@ -235,7 +307,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
viewModel.sendAid(AID_PA3)
|
viewModel.sendAid(AID_PA3)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_C -> {
|
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_K -> {
|
||||||
viewModel.sendAid(AID_CLEAR)
|
viewModel.sendAid(AID_CLEAR)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -243,6 +315,42 @@ class MainActivity : ComponentActivity() {
|
|||||||
viewModel.resetKeyboard()
|
viewModel.resetKeyboard()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
KeyEvent.KEYCODE_E -> {
|
||||||
|
viewModel.eraseInput()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_A -> {
|
||||||
|
viewModel.attn()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_S -> {
|
||||||
|
viewModel.sysReq()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_Q -> {
|
||||||
|
viewModel.cursorSelect()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_X -> {
|
||||||
|
viewModel.toggleCrosshairRuler()
|
||||||
|
val isRuler = viewModel.isCrosshairRuler.value
|
||||||
|
Toast.makeText(this, "Crosshair Ruler: " + (if (isRuler) "ON" else "OFF"), Toast.LENGTH_SHORT).show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_T -> {
|
||||||
|
isFtDialogOpenState.value = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_D -> {
|
||||||
|
viewModel.deleteWord()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_L -> {
|
||||||
|
viewModel.toggleLightPen()
|
||||||
|
val isLp = viewModel.isLightPenMode.value
|
||||||
|
Toast.makeText(this, "Light Pen: " + (if (isLp) "ON" else "OFF"), Toast.LENGTH_SHORT).show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +362,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
// 5. Standard Navigation & Special Keys
|
// 5. Standard Navigation & Special Keys
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_INSERT -> {
|
||||||
|
viewModel.toggleInsert()
|
||||||
|
return true
|
||||||
|
}
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||||
viewModel.cursorLeft()
|
viewModel.cursorLeft()
|
||||||
return true
|
return true
|
||||||
@@ -287,7 +399,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||||
viewModel.sendAid(AID_ENTER)
|
if (shift) {
|
||||||
|
viewModel.eraseEof()
|
||||||
|
isShiftPressedState.value = false
|
||||||
|
} else {
|
||||||
|
viewModel.sendAid(AID_ENTER)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_TAB -> {
|
KeyEvent.KEYCODE_TAB -> {
|
||||||
@@ -309,7 +426,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_ESCAPE -> {
|
KeyEvent.KEYCODE_ESCAPE -> {
|
||||||
if (shift) {
|
if (shift) {
|
||||||
viewModel.sendAid(AID_CLEAR)
|
viewModel.eraseInput()
|
||||||
isShiftPressedState.value = false
|
isShiftPressedState.value = false
|
||||||
} else {
|
} else {
|
||||||
viewModel.resetKeyboard()
|
viewModel.resetKeyboard()
|
||||||
@@ -379,7 +496,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
viewModel: TerminalViewModel,
|
viewModel: TerminalViewModel,
|
||||||
isShiftPressed: Boolean = false,
|
isShiftPressed: Boolean = false,
|
||||||
onClearShift: () -> Unit = {}
|
onClearShift: () -> Unit = {},
|
||||||
|
isFindDialogOpen: Boolean = false,
|
||||||
|
onSetFindDialogOpen: (Boolean) -> Unit = {},
|
||||||
|
isFtDialogOpen: Boolean = false,
|
||||||
|
onSetFtDialogOpen: (Boolean) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val connectionState by viewModel.connectionState.collectAsState()
|
val connectionState by viewModel.connectionState.collectAsState()
|
||||||
@@ -395,17 +516,59 @@ fun MainScreen(
|
|||||||
val hapticFeedback by viewModel.hapticFeedback.collectAsState()
|
val hapticFeedback by viewModel.hapticFeedback.collectAsState()
|
||||||
val verifyCerts by viewModel.verifyCerts.collectAsState()
|
val verifyCerts by viewModel.verifyCerts.collectAsState()
|
||||||
val defaultGraphicsMode by viewModel.defaultGraphicsMode.collectAsState()
|
val defaultGraphicsMode by viewModel.defaultGraphicsMode.collectAsState()
|
||||||
|
val codePage by viewModel.codePage.collectAsState()
|
||||||
|
val searchHighlightAddr by viewModel.searchHighlightAddr.collectAsState()
|
||||||
|
val searchHighlightLen by viewModel.searchHighlightLen.collectAsState()
|
||||||
val isTlsActive by viewModel.isTlsActive.collectAsState()
|
val isTlsActive by viewModel.isTlsActive.collectAsState()
|
||||||
val isTlsVerified by viewModel.isTlsVerified.collectAsState()
|
val isTlsVerified by viewModel.isTlsVerified.collectAsState()
|
||||||
val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState()
|
val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState()
|
||||||
val ftState by viewModel.ftState.collectAsState()
|
val ftState by viewModel.ftState.collectAsState()
|
||||||
|
val isLightPenMode by viewModel.isLightPenMode.collectAsState()
|
||||||
|
|
||||||
|
val isCrosshairRuler by viewModel.isCrosshairRuler.collectAsState()
|
||||||
|
val cursorStyle by viewModel.cursorStyle.collectAsState()
|
||||||
|
val blockSelectMode by viewModel.blockSelectMode.collectAsState()
|
||||||
|
val uiTheme by viewModel.uiTheme.collectAsState()
|
||||||
|
val isInsertMode by viewModel.isInsertMode.collectAsState()
|
||||||
|
val isDocMode by viewModel.isDocMode.collectAsState()
|
||||||
|
val isWordWrap by viewModel.isWordWrap.collectAsState()
|
||||||
|
val isAplMode by viewModel.isAplMode.collectAsState()
|
||||||
|
val fourColorOverride by viewModel.fourColorOverride.collectAsState()
|
||||||
|
|
||||||
|
val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) {
|
||||||
|
if (screenBuffer != null && screenBuffer?.isFormatted == true) {
|
||||||
|
val fa = screenBuffer?.getFieldAttributeAt(cursorAddr) ?: 0.toByte()
|
||||||
|
(fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
|
||||||
var showConnectDialog by remember { mutableStateOf(false) }
|
var showConnectDialog by remember { mutableStateOf(false) }
|
||||||
var showFtDialog by remember { mutableStateOf(false) }
|
var showFtDialog by remember { mutableStateOf(false) }
|
||||||
var showSettingsDialog by remember { mutableStateOf(false) }
|
var showSettingsDialog by remember { mutableStateOf(false) }
|
||||||
|
var showFieldInspectorDialog by remember { mutableStateOf(false) }
|
||||||
|
var showFindDialog by remember { mutableStateOf(false) }
|
||||||
|
var showScreenExportDialog by remember { mutableStateOf(false) }
|
||||||
|
var showScriptDialog by remember { mutableStateOf(false) }
|
||||||
|
var showPrinterSessionDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
|
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(isFindDialogOpen) {
|
||||||
|
if (isFindDialogOpen) {
|
||||||
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showFindDialog = true
|
||||||
|
onSetFindDialogOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isFtDialogOpen) {
|
||||||
|
if (isFtDialogOpen) {
|
||||||
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showFtDialog = true
|
||||||
|
onSetFtDialogOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
@@ -422,19 +585,40 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val autoHost = HostStorage.getAutoConnectHost(context)
|
val startupBehavior = AppSettings.getStartupBehavior(context)
|
||||||
if (autoHost != null && !connectionState.isConnected()) {
|
when (startupBehavior) {
|
||||||
viewModel.connect(
|
AppSettings.StartupBehavior.AUTO_CONNECT -> {
|
||||||
autoHost.host,
|
val autoHost = HostStorage.getAutoConnectHost(context)
|
||||||
autoHost.port,
|
if (autoHost != null && !connectionState.isConnected()) {
|
||||||
autoHost.model,
|
viewModel.connect(
|
||||||
autoHost.luName,
|
autoHost.host,
|
||||||
autoHost.hostType,
|
autoHost.port,
|
||||||
autoHost.useTls,
|
autoHost.model,
|
||||||
autoHost.tlsVerifyCert,
|
autoHost.dynamicRows,
|
||||||
autoHost.tn3270e,
|
autoHost.dynamicCols,
|
||||||
autoHost.graphicsMode
|
autoHost.luName,
|
||||||
)
|
autoHost.hostType,
|
||||||
|
autoHost.useTls,
|
||||||
|
autoHost.tlsVerifyCert,
|
||||||
|
autoHost.tn3270e,
|
||||||
|
autoHost.graphicsMode,
|
||||||
|
autoHost.codePage,
|
||||||
|
autoHost.proxyType,
|
||||||
|
autoHost.proxyHost,
|
||||||
|
autoHost.proxyPort,
|
||||||
|
autoHost.proxyUsername,
|
||||||
|
autoHost.proxyPassword
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppSettings.StartupBehavior.SHOW_CONNECT -> {
|
||||||
|
if (!connectionState.isConnected()) {
|
||||||
|
showConnectDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppSettings.StartupBehavior.DO_NOTHING -> {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,12 +650,31 @@ fun MainScreen(
|
|||||||
screenVersion = screenVersion,
|
screenVersion = screenVersion,
|
||||||
programSymbolManager = viewModel.getClient()?.programSymbolManager,
|
programSymbolManager = viewModel.getClient()?.programSymbolManager,
|
||||||
graphicsPlane = viewModel.getClient()?.graphicsPlane,
|
graphicsPlane = viewModel.getClient()?.graphicsPlane,
|
||||||
|
gocaDecoder = viewModel.getGocaDecoder(),
|
||||||
|
isLightPenMode = isLightPenMode,
|
||||||
maskHiddenFields = maskHiddenInput,
|
maskHiddenFields = maskHiddenInput,
|
||||||
blinkCursor = cursorBlink,
|
blinkCursor = cursorBlink,
|
||||||
|
searchHighlightAddr = searchHighlightAddr,
|
||||||
|
searchHighlightLen = searchHighlightLen,
|
||||||
|
crosshairRuler = isCrosshairRuler,
|
||||||
|
cursorStyle = cursorStyle,
|
||||||
|
isInsertMode = isInsertMode,
|
||||||
|
blockSelectMode = blockSelectMode,
|
||||||
|
fourColorOverride = fourColorOverride,
|
||||||
|
onPasteLineWrap = { text, wrapCol, wordWrap ->
|
||||||
|
viewModel.pasteLineWrap(text, wrapCol, wordWrap)
|
||||||
|
},
|
||||||
onTapAddress = { addr ->
|
onTapAddress = { addr ->
|
||||||
viewModel.setCursor(addr)
|
viewModel.setCursor(addr)
|
||||||
terminalInputViewRef?.showSoftKeyboard()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
},
|
},
|
||||||
|
onLightPenSelect = { addr ->
|
||||||
|
viewModel.selectLightPen(addr)
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onGraphicTouch = { clickAddr, px, py ->
|
||||||
|
viewModel.handleGraphicTouch(clickAddr, px, py)
|
||||||
|
},
|
||||||
onPasteText = { text ->
|
onPasteText = { text ->
|
||||||
viewModel.pasteString(text)
|
viewModel.pasteString(text)
|
||||||
},
|
},
|
||||||
@@ -512,12 +715,57 @@ fun MainScreen(
|
|||||||
TwoRowKeyBar(
|
TwoRowKeyBar(
|
||||||
connectionState = connectionState,
|
connectionState = connectionState,
|
||||||
isShiftPressed = isShiftPressed,
|
isShiftPressed = isShiftPressed,
|
||||||
|
isLightPenMode = isLightPenMode,
|
||||||
|
isDocMode = isDocMode,
|
||||||
|
isWordWrap = isWordWrap,
|
||||||
|
isAplMode = isAplMode,
|
||||||
|
isInsertMode = isInsertMode,
|
||||||
|
fourColorOverride = fourColorOverride,
|
||||||
hapticFeedbackEnabled = hapticFeedback,
|
hapticFeedbackEnabled = hapticFeedback,
|
||||||
onClearShift = onClearShift,
|
onClearShift = onClearShift,
|
||||||
onConnectClick = { showConnectDialog = true },
|
onConnectClick = {
|
||||||
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showConnectDialog = true
|
||||||
|
},
|
||||||
onDisconnectClick = { viewModel.disconnect() },
|
onDisconnectClick = { viewModel.disconnect() },
|
||||||
onFtClick = { showFtDialog = true },
|
onFtClick = {
|
||||||
onSettingsClick = { showSettingsDialog = true },
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showFtDialog = true
|
||||||
|
},
|
||||||
|
onFieldInspectorClick = {
|
||||||
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showFieldInspectorDialog = true
|
||||||
|
},
|
||||||
|
onFindClick = {
|
||||||
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showFindDialog = true
|
||||||
|
},
|
||||||
|
onScreenExportClick = {
|
||||||
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showScreenExportDialog = true
|
||||||
|
},
|
||||||
|
onScriptClick = {
|
||||||
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showScriptDialog = true
|
||||||
|
},
|
||||||
|
onPrinterSessionClick = {
|
||||||
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showPrinterSessionDialog = true
|
||||||
|
},
|
||||||
|
onToggleLightPen = { viewModel.toggleLightPen() },
|
||||||
|
onToggleDocMode = { viewModel.toggleDocMode() },
|
||||||
|
onToggleWordWrap = { viewModel.toggleWordWrap() },
|
||||||
|
onToggleAplMode = { viewModel.toggleAplMode() },
|
||||||
|
onToggleInsert = { viewModel.toggleInsert() },
|
||||||
|
onToggleFourColorOverride = {
|
||||||
|
val next = !fourColorOverride
|
||||||
|
viewModel.setFourColorOverride(next)
|
||||||
|
AppSettings.setFourColorOverride(context, next)
|
||||||
|
},
|
||||||
|
onSettingsClick = {
|
||||||
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
|
showSettingsDialog = true
|
||||||
|
},
|
||||||
onSendAid = { aid ->
|
onSendAid = { aid ->
|
||||||
viewModel.sendAid(aid)
|
viewModel.sendAid(aid)
|
||||||
terminalInputViewRef?.showSoftKeyboard()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
@@ -534,6 +782,26 @@ fun MainScreen(
|
|||||||
viewModel.backTab()
|
viewModel.backTab()
|
||||||
terminalInputViewRef?.showSoftKeyboard()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
},
|
},
|
||||||
|
onEraseInput = {
|
||||||
|
viewModel.eraseInput()
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onEraseEof = {
|
||||||
|
viewModel.eraseEof()
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onAttn = {
|
||||||
|
viewModel.attn()
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onSysReq = {
|
||||||
|
viewModel.sysReq()
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onCursorSelect = {
|
||||||
|
viewModel.cursorSelect()
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
onCursorLeft = {
|
onCursorLeft = {
|
||||||
viewModel.cursorLeft()
|
viewModel.cursorLeft()
|
||||||
terminalInputViewRef?.showSoftKeyboard()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
@@ -563,17 +831,33 @@ fun MainScreen(
|
|||||||
cols = cols,
|
cols = cols,
|
||||||
isTls = isTlsActive,
|
isTls = isTlsActive,
|
||||||
isTlsVerified = isTlsVerified,
|
isTlsVerified = isTlsVerified,
|
||||||
graphicsMode = defaultGraphicsMode
|
graphicsMode = defaultGraphicsMode,
|
||||||
|
isLightPenMode = isLightPenMode,
|
||||||
|
isInsertMode = isInsertMode,
|
||||||
|
isDocMode = isDocMode,
|
||||||
|
isWordWrap = isWordWrap,
|
||||||
|
isAplMode = isAplMode,
|
||||||
|
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
|
||||||
|
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "",
|
||||||
|
isNumericField = isNumericField,
|
||||||
|
uiTheme = uiTheme,
|
||||||
|
onToggleDocMode = { viewModel.toggleDocMode() },
|
||||||
|
onToggleWordWrap = { viewModel.toggleWordWrap() },
|
||||||
|
onToggleAplMode = { viewModel.toggleAplMode() },
|
||||||
|
onToggleInsert = { viewModel.toggleInsert() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showConnectDialog) {
|
if (showConnectDialog) {
|
||||||
ConnectDialog(
|
ConnectDialog(
|
||||||
onDismiss = { showConnectDialog = false },
|
onDismiss = {
|
||||||
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode ->
|
|
||||||
showConnectDialog = false
|
showConnectDialog = false
|
||||||
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode)
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass ->
|
||||||
|
showConnectDialog = false
|
||||||
|
viewModel.connect(host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass)
|
||||||
terminalInputViewRef?.showSoftKeyboard()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -583,7 +867,11 @@ fun MainScreen(
|
|||||||
FileTransferDialog(
|
FileTransferDialog(
|
||||||
initialHostType = viewModel.activeHostType,
|
initialHostType = viewModel.activeHostType,
|
||||||
ftProgressState = ftState,
|
ftProgressState = ftState,
|
||||||
onDismiss = { showFtDialog = false },
|
client = viewModel.getClient(),
|
||||||
|
onDismiss = {
|
||||||
|
showFtDialog = false
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
onStartTransfer = { config ->
|
onStartTransfer = { config ->
|
||||||
viewModel.startFileTransfer(config) { error ->
|
viewModel.startFileTransfer(config) { error ->
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
@@ -604,10 +892,123 @@ fun MainScreen(
|
|||||||
initialHapticFeedback = hapticFeedback,
|
initialHapticFeedback = hapticFeedback,
|
||||||
initialVerifyCerts = verifyCerts,
|
initialVerifyCerts = verifyCerts,
|
||||||
initialDefaultGraphicsMode = defaultGraphicsMode,
|
initialDefaultGraphicsMode = defaultGraphicsMode,
|
||||||
onDismiss = { showSettingsDialog = false },
|
initialDefaultCodePage = codePage,
|
||||||
onSave = { mask, blink, haptic, verify, gfx ->
|
initialUiTheme = uiTheme,
|
||||||
|
initialCrosshairRuler = isCrosshairRuler,
|
||||||
|
initialCursorStyle = cursorStyle,
|
||||||
|
initialBlockSelectMode = blockSelectMode,
|
||||||
|
initialStartupBehavior = AppSettings.getStartupBehavior(context).name,
|
||||||
|
initialDynamicRows = AppSettings.getDynamicRows(context),
|
||||||
|
initialDynamicCols = AppSettings.getDynamicCols(context),
|
||||||
|
initialEntryAssistDocMode = isDocMode,
|
||||||
|
initialEntryAssistWordWrap = isWordWrap,
|
||||||
|
initialEntryAssistStartCol = AppSettings.getEntryAssistStartCol(context),
|
||||||
|
initialEntryAssistEndCol = AppSettings.getEntryAssistEndCol(context),
|
||||||
|
initialEntryAssistBell = AppSettings.getEntryAssistBell(context),
|
||||||
|
initialEntryAssistBellCol = AppSettings.getEntryAssistBellCol(context),
|
||||||
|
initialEntryAssistTabStops = AppSettings.getEntryAssistTabStops(context),
|
||||||
|
initialInsertOffOnAid = AppSettings.getInsertOffOnAid(context),
|
||||||
|
initialFourColorOverride = fourColorOverride,
|
||||||
|
initialNumericFieldLock = AppSettings.getNumericFieldLock(context),
|
||||||
|
initialAutoSkipEnabled = AppSettings.getAutoSkipEnabled(context),
|
||||||
|
onDismiss = {
|
||||||
showSettingsDialog = false
|
showSettingsDialog = false
|
||||||
viewModel.updateSettings(mask, blink, haptic, verify, gfx)
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, startup, dynRows, dynCols,
|
||||||
|
doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk ->
|
||||||
|
showSettingsDialog = false
|
||||||
|
viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp)
|
||||||
|
viewModel.setUiTheme(theme)
|
||||||
|
viewModel.setCrosshairRuler(crosshair)
|
||||||
|
viewModel.setCursorStyle(style)
|
||||||
|
viewModel.setBlockSelectMode(blockSel)
|
||||||
|
AppSettings.setStartupBehavior(context, AppSettings.StartupBehavior.valueOf(startup))
|
||||||
|
AppSettings.setDynamicRows(context, dynRows)
|
||||||
|
AppSettings.setDynamicCols(context, dynCols)
|
||||||
|
AppSettings.setEntryAssistDocMode(context, doc)
|
||||||
|
AppSettings.setEntryAssistWordWrap(context, wrap)
|
||||||
|
AppSettings.setEntryAssistStartCol(context, startCol)
|
||||||
|
AppSettings.setEntryAssistEndCol(context, endCol)
|
||||||
|
AppSettings.setEntryAssistBell(context, bell)
|
||||||
|
AppSettings.setEntryAssistBellCol(context, bellCol)
|
||||||
|
AppSettings.setEntryAssistTabStops(context, tabs)
|
||||||
|
AppSettings.setInsertOffOnAid(context, resetIns)
|
||||||
|
AppSettings.setFourColorOverride(context, fourCol)
|
||||||
|
AppSettings.setNumericFieldLock(context, numLk)
|
||||||
|
AppSettings.setAutoSkipEnabled(context, autoSk)
|
||||||
|
viewModel.reloadSettings()
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFieldInspectorDialog) {
|
||||||
|
FieldInspectorDialog(
|
||||||
|
client = viewModel.getClient(),
|
||||||
|
screenVersion = screenVersion,
|
||||||
|
onDismiss = {
|
||||||
|
showFieldInspectorDialog = false
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onJumpToField = { addr ->
|
||||||
|
viewModel.setCursor(addr)
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFindDialog) {
|
||||||
|
FindDialog(
|
||||||
|
cols = cols,
|
||||||
|
onDismiss = {
|
||||||
|
showFindDialog = false
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onFind = { query: String, matchCase: Boolean, forward: Boolean ->
|
||||||
|
viewModel.find(query, matchCase, forward)
|
||||||
|
},
|
||||||
|
onClearHighlight = {
|
||||||
|
viewModel.clearSearchHighlight()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showScreenExportDialog) {
|
||||||
|
ScreenExporterDialog(
|
||||||
|
screenBuffer = screenBuffer,
|
||||||
|
rows = rows,
|
||||||
|
cols = cols,
|
||||||
|
programSymbolManager = viewModel.getClient()?.programSymbolManager,
|
||||||
|
graphicsPlane = viewModel.getClient()?.graphicsPlane,
|
||||||
|
maskHidden = maskHiddenInput,
|
||||||
|
onDismiss = {
|
||||||
|
showScreenExportDialog = false
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showScriptDialog) {
|
||||||
|
ScriptDialog(
|
||||||
|
onDismiss = {
|
||||||
|
showScriptDialog = false
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
},
|
||||||
|
onExecuteScript = { script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit ->
|
||||||
|
viewModel.executeScript(script, onStatus, onComplete)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPrinterSessionDialog) {
|
||||||
|
val activeHost = viewModel.currentHost.ifBlank { "127.0.0.1" }
|
||||||
|
PrinterSessionDialog(
|
||||||
|
initialHost = activeHost,
|
||||||
|
initialCodePage = codePage,
|
||||||
|
initialUseTls = isTlsActive,
|
||||||
|
onDismiss = {
|
||||||
|
showPrinterSessionDialog = false
|
||||||
terminalInputViewRef?.showSoftKeyboard()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
package org.pubvm.a3270.ft
|
package haus.nightmare.a3270.ft
|
||||||
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import org.lib3270j.Telnet3270Client
|
import haus.nightmare.lib3270j.Telnet3270Client
|
||||||
import org.lib3270j.ft.FTConfig
|
import haus.nightmare.lib3270j.ft.FTConfig
|
||||||
import org.lib3270j.ft.FTConstants.FTState
|
import haus.nightmare.lib3270j.ft.FTConstants.FTState
|
||||||
import org.lib3270j.ft.FTCut
|
import haus.nightmare.lib3270j.ft.FTCut
|
||||||
import org.lib3270j.ft.FTDft
|
import haus.nightmare.lib3270j.ft.FTDft
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.Timer
|
import java.util.Timer
|
||||||
@@ -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.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -10,8 +10,8 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import org.pubvm.a3270.MainActivity
|
import haus.nightmare.a3270.MainActivity
|
||||||
import org.pubvm.a3270.R
|
import haus.nightmare.a3270.R
|
||||||
|
|
||||||
class TerminalService : Service() {
|
class TerminalService : Service() {
|
||||||
|
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package haus.nightmare.a3270.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import java.io.BufferedReader
|
||||||
|
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"
|
||||||
|
|
||||||
|
// 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_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"
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 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("dynamicRows = ${getDynamicRows(context)}")
|
||||||
|
p.println("dynamicCols = ${getDynamicCols(context)}")
|
||||||
|
p.println("blockSelectMode = ${isBlockSelectModeEnabled(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 loadFromIni(context: Context, reader: Reader) {
|
||||||
|
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()
|
||||||
|
applyConfigEntry(context, currentSection, 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)
|
||||||
|
"dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) }
|
||||||
|
"dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) }
|
||||||
|
"blockselectmode" -> setBlockSelectModeEnabled(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-3
@@ -1,4 +1,4 @@
|
|||||||
package org.pubvm.a3270.storage
|
package haus.nightmare.a3270.storage
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
@@ -12,13 +12,21 @@ data class SavedHost(
|
|||||||
val host: String,
|
val host: String,
|
||||||
val port: Int = 23,
|
val port: Int = 23,
|
||||||
val model: Int = 2,
|
val model: Int = 2,
|
||||||
|
val dynamicRows: Int = 62,
|
||||||
|
val dynamicCols: Int = 160,
|
||||||
val luName: String = "",
|
val luName: String = "",
|
||||||
val autoConnect: Boolean = false,
|
val autoConnect: Boolean = false,
|
||||||
val hostType: String = "TSO",
|
val hostType: String = "TSO",
|
||||||
val useTls: Boolean = false,
|
val useTls: Boolean = false,
|
||||||
val tlsVerifyCert: Boolean = true,
|
val tlsVerifyCert: Boolean = true,
|
||||||
val tn3270e: Boolean = true,
|
val tn3270e: Boolean = true,
|
||||||
val graphicsMode: String = "BOTH"
|
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 toJson(): JSONObject {
|
fun toJson(): JSONObject {
|
||||||
return JSONObject().apply {
|
return JSONObject().apply {
|
||||||
@@ -27,6 +35,8 @@ data class SavedHost(
|
|||||||
put("host", host)
|
put("host", host)
|
||||||
put("port", port)
|
put("port", port)
|
||||||
put("model", model)
|
put("model", model)
|
||||||
|
put("dynamicRows", dynamicRows)
|
||||||
|
put("dynamicCols", dynamicCols)
|
||||||
put("luName", luName)
|
put("luName", luName)
|
||||||
put("autoConnect", autoConnect)
|
put("autoConnect", autoConnect)
|
||||||
put("hostType", hostType)
|
put("hostType", hostType)
|
||||||
@@ -34,6 +44,12 @@ data class SavedHost(
|
|||||||
put("tlsVerifyCert", tlsVerifyCert)
|
put("tlsVerifyCert", tlsVerifyCert)
|
||||||
put("tn3270e", tn3270e)
|
put("tn3270e", tn3270e)
|
||||||
put("graphicsMode", graphicsMode)
|
put("graphicsMode", graphicsMode)
|
||||||
|
put("codePage", codePage)
|
||||||
|
put("proxyType", proxyType)
|
||||||
|
put("proxyHost", proxyHost)
|
||||||
|
put("proxyPort", proxyPort)
|
||||||
|
put("proxyUsername", proxyUsername)
|
||||||
|
put("proxyPassword", proxyPassword)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,13 +61,21 @@ data class SavedHost(
|
|||||||
host = json.optString("host", "127.0.0.1"),
|
host = json.optString("host", "127.0.0.1"),
|
||||||
port = json.optInt("port", 23),
|
port = json.optInt("port", 23),
|
||||||
model = json.optInt("model", 2),
|
model = json.optInt("model", 2),
|
||||||
|
dynamicRows = json.optInt("dynamicRows", 62),
|
||||||
|
dynamicCols = json.optInt("dynamicCols", 160),
|
||||||
luName = json.optString("luName", ""),
|
luName = json.optString("luName", ""),
|
||||||
autoConnect = json.optBoolean("autoConnect", false),
|
autoConnect = json.optBoolean("autoConnect", false),
|
||||||
hostType = json.optString("hostType", "TSO"),
|
hostType = json.optString("hostType", "TSO"),
|
||||||
useTls = json.optBoolean("useTls", false),
|
useTls = json.optBoolean("useTls", false),
|
||||||
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
|
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
|
||||||
tn3270e = json.optBoolean("tn3270e", true),
|
tn3270e = json.optBoolean("tn3270e", true),
|
||||||
graphicsMode = json.optString("graphicsMode", "BOTH")
|
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", "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +138,51 @@ object HostStorage {
|
|||||||
saveAll(context, currentHosts)
|
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",
|
||||||
|
"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? {
|
fun getAutoConnectHost(context: Context): SavedHost? {
|
||||||
return getSavedHosts(context).firstOrNull { it.autoConnect }
|
return getSavedHosts(context).firstOrNull { it.autoConnect }
|
||||||
}
|
}
|
||||||
+284
-19
@@ -1,4 +1,4 @@
|
|||||||
package org.pubvm.a3270.ui
|
package haus.nightmare.a3270.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -17,13 +18,22 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import org.pubvm.a3270.storage.HostStorage
|
import haus.nightmare.a3270.storage.HostStorage
|
||||||
import org.pubvm.a3270.storage.SavedHost
|
import haus.nightmare.a3270.storage.SavedHost
|
||||||
|
|
||||||
|
private val TerminalKeyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrect = false,
|
||||||
|
keyboardType = KeyboardType.Ascii
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectDialog(
|
fun ConnectDialog(
|
||||||
@@ -32,12 +42,20 @@ fun ConnectDialog(
|
|||||||
host: String,
|
host: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
model: Int,
|
model: Int,
|
||||||
|
dynamicRows: Int,
|
||||||
|
dynamicCols: Int,
|
||||||
luName: String,
|
luName: String,
|
||||||
hostType: String,
|
hostType: String,
|
||||||
useTls: Boolean,
|
useTls: Boolean,
|
||||||
tlsVerifyCert: Boolean,
|
tlsVerifyCert: Boolean,
|
||||||
tn3270e: Boolean,
|
tn3270e: Boolean,
|
||||||
graphicsMode: String
|
graphicsMode: String,
|
||||||
|
codePage: String,
|
||||||
|
proxyType: String,
|
||||||
|
proxyHost: String,
|
||||||
|
proxyPort: Int,
|
||||||
|
proxyUser: String,
|
||||||
|
proxyPass: String
|
||||||
) -> Unit
|
) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -45,9 +63,11 @@ fun ConnectDialog(
|
|||||||
var selectedHostId by remember { mutableStateOf<String?>(null) }
|
var selectedHostId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
var profileName by remember { mutableStateOf("") }
|
var profileName by remember { mutableStateOf("") }
|
||||||
var host by remember { mutableStateOf("127.0.0.1") }
|
var host by remember { mutableStateOf("") }
|
||||||
var portStr by remember { mutableStateOf("23") }
|
var portStr by remember { mutableStateOf("") }
|
||||||
var modelNum by remember { mutableIntStateOf(2) }
|
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 luName by remember { mutableStateOf("") }
|
||||||
var autoConnect by remember { mutableStateOf(false) }
|
var autoConnect by remember { mutableStateOf(false) }
|
||||||
var hostType by remember { mutableStateOf("TSO") }
|
var hostType by remember { mutableStateOf("TSO") }
|
||||||
@@ -55,6 +75,13 @@ fun ConnectDialog(
|
|||||||
var tlsVerifyCert by remember { mutableStateOf(true) }
|
var tlsVerifyCert by remember { mutableStateOf(true) }
|
||||||
var tn3270e by remember { mutableStateOf(true) }
|
var tn3270e by remember { mutableStateOf(true) }
|
||||||
var graphicsMode by remember { mutableStateOf("BOTH") }
|
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) {
|
fun loadProfile(saved: SavedHost) {
|
||||||
selectedHostId = saved.id
|
selectedHostId = saved.id
|
||||||
@@ -62,6 +89,8 @@ fun ConnectDialog(
|
|||||||
host = saved.host
|
host = saved.host
|
||||||
portStr = saved.port.toString()
|
portStr = saved.port.toString()
|
||||||
modelNum = saved.model
|
modelNum = saved.model
|
||||||
|
dynamicRowsStr = saved.dynamicRows.toString()
|
||||||
|
dynamicColsStr = saved.dynamicCols.toString()
|
||||||
luName = saved.luName
|
luName = saved.luName
|
||||||
autoConnect = saved.autoConnect
|
autoConnect = saved.autoConnect
|
||||||
hostType = saved.hostType
|
hostType = saved.hostType
|
||||||
@@ -69,14 +98,23 @@ fun ConnectDialog(
|
|||||||
tlsVerifyCert = saved.tlsVerifyCert
|
tlsVerifyCert = saved.tlsVerifyCert
|
||||||
tn3270e = saved.tn3270e
|
tn3270e = saved.tn3270e
|
||||||
graphicsMode = saved.graphicsMode
|
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() {
|
fun clearFields() {
|
||||||
selectedHostId = null
|
selectedHostId = null
|
||||||
profileName = ""
|
profileName = ""
|
||||||
host = "127.0.0.1"
|
host = ""
|
||||||
portStr = "23"
|
portStr = ""
|
||||||
modelNum = 2
|
modelNum = 2
|
||||||
|
dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()
|
||||||
|
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
|
||||||
luName = ""
|
luName = ""
|
||||||
autoConnect = false
|
autoConnect = false
|
||||||
hostType = "TSO"
|
hostType = "TSO"
|
||||||
@@ -84,25 +122,46 @@ fun ConnectDialog(
|
|||||||
tlsVerifyCert = true
|
tlsVerifyCert = true
|
||||||
tn3270e = true
|
tn3270e = true
|
||||||
graphicsMode = "BOTH"
|
graphicsMode = "BOTH"
|
||||||
|
codePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
|
||||||
|
proxyType = "NONE"
|
||||||
|
proxyHost = ""
|
||||||
|
proxyPortStr = ""
|
||||||
|
proxyUsername = ""
|
||||||
|
proxyPassword = ""
|
||||||
|
showProxySettings = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveCurrentProfile(): SavedHost? {
|
fun saveCurrentProfile(): SavedHost? {
|
||||||
val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23
|
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
|
||||||
if (host.isBlank()) return null
|
val trimmedHost = host.trim()
|
||||||
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
|
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(
|
val hostToSave = SavedHost(
|
||||||
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
|
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
|
||||||
name = nameToSave,
|
name = nameToSave,
|
||||||
host = host.trim(),
|
host = trimmedHost,
|
||||||
port = port,
|
port = port,
|
||||||
model = modelNum,
|
model = modelNum,
|
||||||
|
dynamicRows = dynRows,
|
||||||
|
dynamicCols = dynCols,
|
||||||
luName = luName.trim(),
|
luName = luName.trim(),
|
||||||
autoConnect = autoConnect,
|
autoConnect = autoConnect,
|
||||||
hostType = hostType,
|
hostType = hostType,
|
||||||
useTls = useTls,
|
useTls = useTls,
|
||||||
tlsVerifyCert = tlsVerifyCert,
|
tlsVerifyCert = tlsVerifyCert,
|
||||||
tn3270e = tn3270e,
|
tn3270e = tn3270e,
|
||||||
graphicsMode = graphicsMode
|
graphicsMode = graphicsMode,
|
||||||
|
codePage = codePage,
|
||||||
|
proxyType = proxyType,
|
||||||
|
proxyHost = proxyHost.trim(),
|
||||||
|
proxyPort = proxyPortStr.trim().toIntOrNull() ?: 0,
|
||||||
|
proxyUsername = proxyUsername.trim(),
|
||||||
|
proxyPassword = proxyPassword.trim()
|
||||||
)
|
)
|
||||||
HostStorage.saveHost(context, hostToSave)
|
HostStorage.saveHost(context, hostToSave)
|
||||||
savedHosts = HostStorage.getSavedHosts(context)
|
savedHosts = HostStorage.getSavedHosts(context)
|
||||||
@@ -214,7 +273,7 @@ fun ConnectDialog(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$eTag$gfxTag" +
|
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 "",
|
if (saved.autoConnect) " [Auto]" else "",
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
color = Color.LightGray
|
color = Color.LightGray
|
||||||
@@ -229,12 +288,20 @@ fun ConnectDialog(
|
|||||||
hostToConn.host,
|
hostToConn.host,
|
||||||
hostToConn.port,
|
hostToConn.port,
|
||||||
hostToConn.model,
|
hostToConn.model,
|
||||||
|
hostToConn.dynamicRows,
|
||||||
|
hostToConn.dynamicCols,
|
||||||
hostToConn.luName,
|
hostToConn.luName,
|
||||||
hostToConn.hostType,
|
hostToConn.hostType,
|
||||||
hostToConn.useTls,
|
hostToConn.useTls,
|
||||||
hostToConn.tlsVerifyCert,
|
hostToConn.tlsVerifyCert,
|
||||||
hostToConn.tn3270e,
|
hostToConn.tn3270e,
|
||||||
hostToConn.graphicsMode
|
hostToConn.graphicsMode,
|
||||||
|
hostToConn.codePage,
|
||||||
|
hostToConn.proxyType,
|
||||||
|
hostToConn.proxyHost,
|
||||||
|
hostToConn.proxyPort,
|
||||||
|
hostToConn.proxyUsername,
|
||||||
|
hostToConn.proxyPassword
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
@@ -273,6 +340,7 @@ fun ConnectDialog(
|
|||||||
onValueChange = { profileName = it },
|
onValueChange = { profileName = it },
|
||||||
label = { Text("Profile Name (Optional)") },
|
label = { Text("Profile Name (Optional)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -282,18 +350,22 @@ fun ConnectDialog(
|
|||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = host,
|
value = host,
|
||||||
onValueChange = { host = it },
|
onValueChange = { host = it.filterNot { c -> c.isWhitespace() } },
|
||||||
label = { Text("Host / IP Address") },
|
label = { Text("Host / IP Address") },
|
||||||
|
placeholder = { Text("127.0.0.1", color = Color.Gray) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(0.68f)
|
.weight(0.68f)
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = portStr,
|
value = portStr,
|
||||||
onValueChange = { portStr = it },
|
onValueChange = { portStr = it.filter { c -> c.isDigit() } },
|
||||||
label = { Text("Port") },
|
label = { Text("Port") },
|
||||||
|
placeholder = { Text(if (useTls) "992" else "23", color = Color.Gray) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
|
||||||
modifier = Modifier.weight(0.32f)
|
modifier = Modifier.weight(0.32f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -310,7 +382,8 @@ fun ConnectDialog(
|
|||||||
2 to "M2",
|
2 to "M2",
|
||||||
3 to "M3",
|
3 to "M3",
|
||||||
4 to "M4",
|
4 to "M4",
|
||||||
5 to "M5"
|
5 to "M5",
|
||||||
|
0 to "DYN"
|
||||||
).forEach { (m, label) ->
|
).forEach { (m, label) ->
|
||||||
val isSelected = (modelNum == m)
|
val isSelected = (modelNum == m)
|
||||||
Surface(
|
Surface(
|
||||||
@@ -336,6 +409,32 @@ fun ConnectDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// Host System Type (TSO, CMS, CICS)
|
||||||
@@ -412,11 +511,73 @@ fun ConnectDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
OutlinedTextField(
|
||||||
value = luName,
|
value = luName,
|
||||||
onValueChange = { luName = it },
|
onValueChange = { luName = it },
|
||||||
label = { Text("LU Name / Device Pool (Optional)") },
|
label = { Text("LU Name / Device Pool (Optional)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -506,6 +667,102 @@ fun ConnectDialog(
|
|||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White)
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,12 +791,20 @@ fun ConnectDialog(
|
|||||||
saved.host,
|
saved.host,
|
||||||
saved.port,
|
saved.port,
|
||||||
saved.model,
|
saved.model,
|
||||||
|
saved.dynamicRows,
|
||||||
|
saved.dynamicCols,
|
||||||
saved.luName,
|
saved.luName,
|
||||||
saved.hostType,
|
saved.hostType,
|
||||||
saved.useTls,
|
saved.useTls,
|
||||||
saved.tlsVerifyCert,
|
saved.tlsVerifyCert,
|
||||||
saved.tn3270e,
|
saved.tn3270e,
|
||||||
saved.graphicsMode
|
saved.graphicsMode,
|
||||||
|
saved.codePage,
|
||||||
|
saved.proxyType,
|
||||||
|
saved.proxyHost,
|
||||||
|
saved.proxyPort,
|
||||||
|
saved.proxyUsername,
|
||||||
|
saved.proxyPassword
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -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,625 @@
|
|||||||
|
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(
|
||||||
|
val hostFile: String = "",
|
||||||
|
val localFile: String = "",
|
||||||
|
val isReceive: Boolean = true,
|
||||||
|
val isAscii: Boolean = true,
|
||||||
|
val hostType: FTConfig.HostType = FTConfig.HostType.TSO,
|
||||||
|
val recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT,
|
||||||
|
val lreclStr: String = "",
|
||||||
|
val blksizeStr: String = "",
|
||||||
|
val spaceStr: String = "",
|
||||||
|
val cmsOptions: String = ""
|
||||||
|
)
|
||||||
|
private var lastTransferState: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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(lastTransferState?.hostType ?: initialTypeEnum) }
|
||||||
|
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 spaceStr by remember { mutableStateOf(lastTransferState?.spaceStr ?: "") }
|
||||||
|
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) {
|
||||||
|
HostDirectoryDialog(
|
||||||
|
client = client,
|
||||||
|
initialHostType = hostType,
|
||||||
|
initialQuery = hostFile,
|
||||||
|
onDismiss = { showBrowseDialog = false },
|
||||||
|
onSelectDataset = { selected ->
|
||||||
|
hostFile = selected
|
||||||
|
if (localFile.isBlank()) {
|
||||||
|
val safeName = formatLocalFilenameFromHost(selected, 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = spaceStr,
|
||||||
|
onValueChange = { spaceStr = it },
|
||||||
|
label = { Text("SPACE (Allocation)", fontSize = 11.sp) },
|
||||||
|
placeholder = { Text("e.g. (1,1) cyl or 5,2") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) }
|
||||||
|
if (spaceStr.isNotBlank()) setSpace(spaceStr.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,
|
||||||
|
spaceStr = spaceStr,
|
||||||
|
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,442 @@
|
|||||||
|
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 = {},
|
||||||
|
onScriptClick: () -> 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("Run Keystroke Script", color = Color.White) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onScriptClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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,310 @@
|
|||||||
|
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.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.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.HostDirectoryEntry
|
||||||
|
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HostDirectoryDialog(
|
||||||
|
client: Telnet3270Client?,
|
||||||
|
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
|
||||||
|
initialQuery: String = "",
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelectDataset: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var hostType by remember { mutableStateOf(initialHostType) }
|
||||||
|
var query by remember { mutableStateOf(initialQuery) }
|
||||||
|
var entries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
|
||||||
|
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||||
|
var statusMessage by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
fun runQuery() {
|
||||||
|
val c = client
|
||||||
|
if (c == null || !c.connectionState.isFullSession) {
|
||||||
|
statusMessage = "Terminal not connected."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val xfer = c.xfer
|
||||||
|
if (xfer == null) {
|
||||||
|
statusMessage = "ECL transfer interface not available."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val q = query.trim()
|
||||||
|
if (hostType == FTConfig.HostType.CMS) {
|
||||||
|
val list = xfer.getCmsDirectory(q)
|
||||||
|
entries = list ?: emptyList()
|
||||||
|
} else {
|
||||||
|
val list = xfer.getTsoDirectory(q)
|
||||||
|
entries = list ?: emptyList()
|
||||||
|
}
|
||||||
|
selectedIndex = -1
|
||||||
|
statusMessage = "${entries.size} entries found."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseScreen() {
|
||||||
|
val c = client
|
||||||
|
if (c == null || !c.connectionState.isFullSession) {
|
||||||
|
statusMessage = "No active screen to parse."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val xfer = c.xfer
|
||||||
|
if (xfer == null) {
|
||||||
|
statusMessage = "ECL transfer interface not available."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val ps = c.ps
|
||||||
|
val screenText = ps.getString(0, ps.size)
|
||||||
|
if (hostType == FTConfig.HostType.CMS) {
|
||||||
|
val list = xfer.getCmsDirectory(screenText)
|
||||||
|
entries = list ?: emptyList()
|
||||||
|
} else {
|
||||||
|
val list = xfer.getTsoDirectory(screenText)
|
||||||
|
entries = list ?: emptyList()
|
||||||
|
}
|
||||||
|
selectedIndex = -1
|
||||||
|
statusMessage = "Parsed ${entries.size} entries from screen."
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (initialQuery.isNotBlank()) {
|
||||||
|
runQuery()
|
||||||
|
} else {
|
||||||
|
parseScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
label = { Text("TSO", fontSize = 11.sp) }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
FilterChip(
|
||||||
|
selected = (hostType == FTConfig.HostType.CMS),
|
||||||
|
onClick = { hostType = FTConfig.HostType.CMS },
|
||||||
|
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("Query Pattern") },
|
||||||
|
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* EXEC *") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrect = false,
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
imeAction = ImeAction.Search
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { runQuery() }),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { runQuery() },
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
|
||||||
|
) {
|
||||||
|
Text("Query")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = statusMessage.ifBlank { "Enter query or parse screen." },
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
TextButton(onClick = { parseScreen() }) {
|
||||||
|
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||||
|
|
||||||
|
// Table View
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("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(220.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(entries) { 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("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
|
||||||
|
Text("${item.numRecords}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
|
||||||
|
Text("${item.date} ${item.time}", 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(220.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("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
|
||||||
|
Text("${item.blksize}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
|
||||||
|
Text("${item.tracksAllocated}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
|
||||||
|
Text("${item.extents}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom Action Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel", color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (selectedIndex in entries.indices) {
|
||||||
|
val selectedName = entries[selectedIndex].name
|
||||||
|
onSelectDataset(selectedName)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = selectedIndex in entries.indices,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||||
|
) {
|
||||||
|
Text("Select Dataset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
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,
|
||||||
|
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: NUM vs ALPHA
|
||||||
|
if (connectionState.isConnected() && rows > 0 && cols > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Surface(
|
||||||
|
color = Color(0xFF339AF0).copy(alpha = 0.2f),
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isNumericField) "NUM" else "ALPHA",
|
||||||
|
color = if (isNumericField) Color(0xFFFFD43B) else Color(0xFF74C0FC),
|
||||||
|
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,521 @@
|
|||||||
|
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.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 ""
|
||||||
|
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.getCell(addr)
|
||||||
|
if (cell.fa.toInt() != 0) {
|
||||||
|
val faVal = cell.fa.toInt() and 0xFF
|
||||||
|
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
|
||||||
|
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>"
|
||||||
|
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.getCell(addr)
|
||||||
|
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 (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
|
||||||
|
|
||||||
|
// 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.getCell(addr)
|
||||||
|
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,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 ▶")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,795 @@
|
|||||||
|
package haus.nightmare.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsDialog(
|
||||||
|
initialMaskHiddenInput: Boolean,
|
||||||
|
initialCursorBlink: Boolean,
|
||||||
|
initialHapticFeedback: Boolean = true,
|
||||||
|
initialVerifyCerts: Boolean = true,
|
||||||
|
initialDefaultGraphicsMode: String = "BOTH",
|
||||||
|
initialDefaultCodePage: String = "037",
|
||||||
|
initialUiTheme: String = "DARK",
|
||||||
|
initialCrosshairRuler: Boolean = false,
|
||||||
|
initialCursorStyle: String = "BLOCK",
|
||||||
|
initialBlockSelectMode: Boolean = false,
|
||||||
|
initialStartupBehavior: String = "SHOW_CONNECT",
|
||||||
|
initialDynamicRows: Int = 62,
|
||||||
|
initialDynamicCols: Int = 160,
|
||||||
|
initialEntryAssistDocMode: Boolean = false,
|
||||||
|
initialEntryAssistWordWrap: Boolean = false,
|
||||||
|
initialEntryAssistStartCol: Int = 1,
|
||||||
|
initialEntryAssistEndCol: Int = 80,
|
||||||
|
initialEntryAssistBell: Boolean = false,
|
||||||
|
initialEntryAssistBellCol: Int = 75,
|
||||||
|
initialEntryAssistTabStops: String = "5,10,15,20,25,30,35,40,45,50,55,60,65,70,75",
|
||||||
|
initialInsertOffOnAid: Boolean = true,
|
||||||
|
initialFourColorOverride: Boolean = false,
|
||||||
|
initialNumericFieldLock: Boolean = true,
|
||||||
|
initialAutoSkipEnabled: Boolean = true,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (
|
||||||
|
maskHiddenInput: Boolean,
|
||||||
|
cursorBlink: Boolean,
|
||||||
|
hapticFeedback: Boolean,
|
||||||
|
verifyCerts: Boolean,
|
||||||
|
defaultGraphicsMode: String,
|
||||||
|
defaultCodePage: String,
|
||||||
|
uiTheme: String,
|
||||||
|
crosshairRuler: Boolean,
|
||||||
|
cursorStyle: String,
|
||||||
|
blockSelectMode: Boolean,
|
||||||
|
startupBehavior: String,
|
||||||
|
dynamicRows: Int,
|
||||||
|
dynamicCols: Int,
|
||||||
|
entryAssistDocMode: Boolean,
|
||||||
|
entryAssistWordWrap: Boolean,
|
||||||
|
entryAssistStartCol: Int,
|
||||||
|
entryAssistEndCol: Int,
|
||||||
|
entryAssistBell: Boolean,
|
||||||
|
entryAssistBellCol: Int,
|
||||||
|
entryAssistTabStops: String,
|
||||||
|
insertOffOnAid: Boolean,
|
||||||
|
fourColorOverride: Boolean,
|
||||||
|
numericFieldLock: Boolean,
|
||||||
|
autoSkipEnabled: Boolean
|
||||||
|
) -> Unit
|
||||||
|
) {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||||
|
|
||||||
|
var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
|
||||||
|
var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
|
||||||
|
var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) }
|
||||||
|
var verifyCerts by remember { mutableStateOf(initialVerifyCerts) }
|
||||||
|
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
|
||||||
|
var defaultCodePage by remember { mutableStateOf(initialDefaultCodePage) }
|
||||||
|
|
||||||
|
var uiTheme by remember { mutableStateOf(initialUiTheme) }
|
||||||
|
var crosshairRuler by remember { mutableStateOf(initialCrosshairRuler) }
|
||||||
|
var cursorStyle by remember { mutableStateOf(initialCursorStyle) }
|
||||||
|
var blockSelectMode by remember { mutableStateOf(initialBlockSelectMode) }
|
||||||
|
var startupBehavior by remember { mutableStateOf(initialStartupBehavior) }
|
||||||
|
var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) }
|
||||||
|
var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) }
|
||||||
|
|
||||||
|
var entryAssistDocMode by remember { mutableStateOf(initialEntryAssistDocMode) }
|
||||||
|
var entryAssistWordWrap by remember { mutableStateOf(initialEntryAssistWordWrap) }
|
||||||
|
var entryAssistStartColStr by remember { mutableStateOf(initialEntryAssistStartCol.toString()) }
|
||||||
|
var entryAssistEndColStr by remember { mutableStateOf(initialEntryAssistEndCol.toString()) }
|
||||||
|
var entryAssistBell by remember { mutableStateOf(initialEntryAssistBell) }
|
||||||
|
var entryAssistBellColStr by remember { mutableStateOf(initialEntryAssistBellCol.toString()) }
|
||||||
|
var entryAssistTabStops by remember { mutableStateOf(initialEntryAssistTabStops) }
|
||||||
|
var insertOffOnAid by remember { mutableStateOf(initialInsertOffOnAid) }
|
||||||
|
var fourColorOverride by remember { mutableStateOf(initialFourColorOverride) }
|
||||||
|
var numericFieldLock by remember { mutableStateOf(initialNumericFieldLock) }
|
||||||
|
var autoSkipEnabled by remember { mutableStateOf(initialAutoSkipEnabled) }
|
||||||
|
|
||||||
|
var showImportDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
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
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Terminal Settings",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// SECTION 1: APPEARANCE
|
||||||
|
Text("Appearance", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// UI Theme
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text("UI Theme", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
listOf("DARK" to "Dark", "LIGHT" to "Light").forEach { (themeKey, themeLabel) ->
|
||||||
|
val isSelected = uiTheme.equals(themeKey, ignoreCase = true)
|
||||||
|
FilterChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { uiTheme = themeKey },
|
||||||
|
label = { Text(themeLabel, fontSize = 11.sp) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Crosshair Ruler
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Crosshair Ruler", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Display intersecting row/column ruler tracking cursor position.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = crosshairRuler,
|
||||||
|
onCheckedChange = { crosshairRuler = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Cursor Style
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Cursor Style", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Shape of 3270 screen cursor.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
listOf("BLOCK" to "Block", "UNDERLINE" to "Underline").forEach { (styleKey, styleLabel) ->
|
||||||
|
val isSelected = cursorStyle.equals(styleKey, ignoreCase = true)
|
||||||
|
FilterChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { cursorStyle = styleKey },
|
||||||
|
label = { Text(styleLabel, fontSize = 11.sp) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Blinking Cursor
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Blinking Cursor", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Blink the terminal cursor indicator.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = cursorBlink,
|
||||||
|
onCheckedChange = { cursorBlink = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// SECTION 2: INTERACTION & BEHAVIOR
|
||||||
|
Text("Interaction & Behavior", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Mask Hidden Inputs
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Show '*' for Hidden Fields", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Display asterisks for typed characters in password fields.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = maskHiddenInput,
|
||||||
|
onCheckedChange = { maskHiddenInput = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Block Selection Mode
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Block Selection Mode", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Select rectangular blocks instead of linear character stream.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = blockSelectMode,
|
||||||
|
onCheckedChange = { blockSelectMode = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Haptic Feedback
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Button Haptic Feedback", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Vibrate softly when tapping keybar buttons.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = hapticFeedback,
|
||||||
|
onCheckedChange = { hapticFeedback = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Startup Behavior
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Startup Behavior", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Action to take when launching the application.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
"SHOW_CONNECT" to "Show Connect",
|
||||||
|
"DO_NOTHING" to "Do Nothing",
|
||||||
|
"AUTO_CONNECT" to "Auto-Connect"
|
||||||
|
).forEach { (sbKey, sbLabel) ->
|
||||||
|
val isSelected = startupBehavior.equals(sbKey, ignoreCase = true)
|
||||||
|
FilterChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { startupBehavior = sbKey },
|
||||||
|
label = { Text(sbLabel, fontSize = 10.sp) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// SECTION 3: PROTOCOL & DYNAMIC DEFAULTS
|
||||||
|
Text("Protocol & Defaults", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Default Dynamic Dimensions
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Default Dynamic Screen Dimensions", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Rows (24-255) and columns (80-255) for IBM-DYNAMIC sessions.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = dynamicRowsStr,
|
||||||
|
onValueChange = { dynamicRowsStr = it },
|
||||||
|
label = { Text("Rows (24-255)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = dynamicColsStr,
|
||||||
|
onValueChange = { dynamicColsStr = it },
|
||||||
|
label = { Text("Cols (80-255)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Graphics Mode
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Default Graphics Mode", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Spacer(modifier = Modifier.height(6.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 = defaultGraphicsMode.equals(modeKey, ignoreCase = true)
|
||||||
|
FilterChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { defaultGraphicsMode = modeKey },
|
||||||
|
label = { Text(modeLabel, fontSize = 10.sp) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Default CodePage
|
||||||
|
var codePageMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
|
||||||
|
val selectedCpLabel = codePagesList.firstOrNull { it.first == defaultCodePage }?.second ?: "Code Page $defaultCodePage"
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Default EBCDIC Code Page", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Spacer(modifier = Modifier.height(6.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 (defaultCodePage == cpId) Color(0xFF339AF0) else Color.White,
|
||||||
|
fontWeight = if (defaultCodePage == cpId) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
defaultCodePage = cpId
|
||||||
|
codePageMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Verify TLS Certs
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Verify TLS Certificates", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Validate TLS certificates against system trust store.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = verifyCerts,
|
||||||
|
onCheckedChange = { verifyCerts = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// SECTION 4: ENTRY ASSIST & MODES
|
||||||
|
Text("Entry Assist & Modes", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
|
||||||
|
Text("Document Mode word wrap, margins, audible bell, and operational locks matching j3270.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Document Mode
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Document Mode (DOC)", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Enable word wrapping and margin tracking during text entry.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = entryAssistDocMode,
|
||||||
|
onCheckedChange = { entryAssistDocMode = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Word Wrap
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Word Wrap (V)", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Automatically wrap partial words at the right margin.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = entryAssistWordWrap,
|
||||||
|
onCheckedChange = { entryAssistWordWrap = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Margins (Left / Right)
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Entry Assist Margins", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Left margin (1-80) and Right margin (1-80).", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = entryAssistStartColStr,
|
||||||
|
onValueChange = { entryAssistStartColStr = it },
|
||||||
|
label = { Text("Left Col (1-80)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = entryAssistEndColStr,
|
||||||
|
onValueChange = { entryAssistEndColStr = it },
|
||||||
|
label = { Text("Right Col (1-80)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Audible EOL Bell
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Audible EOL Bell Warning", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Beep warning signal when typing reaches the bell column.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = entryAssistBell,
|
||||||
|
onCheckedChange = { entryAssistBell = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = entryAssistBellColStr,
|
||||||
|
onValueChange = { entryAssistBellColStr = it },
|
||||||
|
label = { Text("Bell Column (1-80)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Tab Stops
|
||||||
|
OutlinedTextField(
|
||||||
|
value = entryAssistTabStops,
|
||||||
|
onValueChange = { entryAssistTabStops = it },
|
||||||
|
label = { Text("Word Tab Stops") },
|
||||||
|
placeholder = { Text("5,10,15,20,25,30,35,40,45,50,55,60,65,70,75") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Operational Mode Toggles
|
||||||
|
// 1. Reset Insert on AID
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Reset Insert Mode on AID Key", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Automatically cancel insert mode upon Enter, PF, PA, or Clear.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = insertOffOnAid,
|
||||||
|
onCheckedChange = { insertOffOnAid = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// 2. Base 4-Color Override
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Base 4-Color Override Mode", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Force 3279 4-color mapping (green/white/red/turquoise), ignoring host extended colors.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = fourColorOverride,
|
||||||
|
onCheckedChange = { fourColorOverride = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// 3. Numeric Field Lock
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Numeric Field Lock (-NUMERIC)", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Inhibit keyboard if a non-numeric character is typed in a numeric-only field.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = numericFieldLock,
|
||||||
|
onCheckedChange = { numericFieldLock = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// 4. Auto-Skip
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text("Auto-Skip Across Fields", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
Text("Automatically advance cursor to next unprotected field when field is filled.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = autoSkipEnabled,
|
||||||
|
onCheckedChange = { autoSkipEnabled = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// SECTION 5: INI CONFIGURATION BACKUP
|
||||||
|
Text("Configuration File (INI)", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
|
||||||
|
Text("Compatible with j3270 desktop INI configuration files.", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val sw = java.io.StringWriter()
|
||||||
|
haus.nightmare.a3270.storage.AppSettings.exportToIni(context, sw)
|
||||||
|
val iniText = sw.toString()
|
||||||
|
clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(iniText))
|
||||||
|
android.widget.Toast.makeText(context, "Exported INI copied to clipboard", android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Export to INI", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showImportDialog = true },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Import from INI", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel", color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val dRows = dynamicRowsStr.trim().toIntOrNull()?.coerceIn(24, 255) ?: 62
|
||||||
|
val dCols = dynamicColsStr.trim().toIntOrNull()?.coerceIn(80, 255) ?: 160
|
||||||
|
val eaStartCol = entryAssistStartColStr.trim().toIntOrNull()?.coerceIn(1, 80) ?: 1
|
||||||
|
val eaEndCol = entryAssistEndColStr.trim().toIntOrNull()?.coerceIn(1, 80) ?: 80
|
||||||
|
val eaBellCol = entryAssistBellColStr.trim().toIntOrNull()?.coerceIn(1, 80) ?: 75
|
||||||
|
onSave(
|
||||||
|
maskHiddenInput,
|
||||||
|
cursorBlink,
|
||||||
|
hapticFeedback,
|
||||||
|
verifyCerts,
|
||||||
|
defaultGraphicsMode,
|
||||||
|
defaultCodePage,
|
||||||
|
uiTheme,
|
||||||
|
crosshairRuler,
|
||||||
|
cursorStyle,
|
||||||
|
blockSelectMode,
|
||||||
|
startupBehavior,
|
||||||
|
dRows,
|
||||||
|
dCols,
|
||||||
|
entryAssistDocMode,
|
||||||
|
entryAssistWordWrap,
|
||||||
|
eaStartCol,
|
||||||
|
eaEndCol,
|
||||||
|
entryAssistBell,
|
||||||
|
eaBellCol,
|
||||||
|
entryAssistTabStops,
|
||||||
|
insertOffOnAid,
|
||||||
|
fourColorOverride,
|
||||||
|
numericFieldLock,
|
||||||
|
autoSkipEnabled
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||||
|
) {
|
||||||
|
Text("Save", color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showImportDialog) {
|
||||||
|
var importText by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showImportDialog = false },
|
||||||
|
title = { Text("Import INI Configuration") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Paste INI configuration content below:", fontSize = 12.sp)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = importText,
|
||||||
|
onValueChange = { importText = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp),
|
||||||
|
maxLines = 10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
showImportDialog = false
|
||||||
|
if (importText.isNotBlank()) {
|
||||||
|
try {
|
||||||
|
haus.nightmare.a3270.storage.AppSettings.loadFromIni(context, java.io.StringReader(importText))
|
||||||
|
uiTheme = haus.nightmare.a3270.storage.AppSettings.getUiTheme(context)
|
||||||
|
crosshairRuler = haus.nightmare.a3270.storage.AppSettings.isCrosshairRulerEnabled(context)
|
||||||
|
cursorStyle = haus.nightmare.a3270.storage.AppSettings.getCursorStyle(context)
|
||||||
|
blockSelectMode = haus.nightmare.a3270.storage.AppSettings.isBlockSelectModeEnabled(context)
|
||||||
|
startupBehavior = haus.nightmare.a3270.storage.AppSettings.getStartupBehavior(context).name
|
||||||
|
dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()
|
||||||
|
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
|
||||||
|
defaultCodePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
|
||||||
|
defaultGraphicsMode = haus.nightmare.a3270.storage.AppSettings.getDefaultGraphicsMode(context)
|
||||||
|
entryAssistDocMode = haus.nightmare.a3270.storage.AppSettings.getEntryAssistDocMode(context)
|
||||||
|
entryAssistWordWrap = haus.nightmare.a3270.storage.AppSettings.getEntryAssistWordWrap(context)
|
||||||
|
entryAssistStartColStr = haus.nightmare.a3270.storage.AppSettings.getEntryAssistStartCol(context).toString()
|
||||||
|
entryAssistEndColStr = haus.nightmare.a3270.storage.AppSettings.getEntryAssistEndCol(context).toString()
|
||||||
|
entryAssistBell = haus.nightmare.a3270.storage.AppSettings.getEntryAssistBell(context)
|
||||||
|
entryAssistBellColStr = haus.nightmare.a3270.storage.AppSettings.getEntryAssistBellCol(context).toString()
|
||||||
|
entryAssistTabStops = haus.nightmare.a3270.storage.AppSettings.getEntryAssistTabStops(context)
|
||||||
|
insertOffOnAid = haus.nightmare.a3270.storage.AppSettings.getInsertOffOnAid(context)
|
||||||
|
fourColorOverride = haus.nightmare.a3270.storage.AppSettings.getFourColorOverride(context)
|
||||||
|
numericFieldLock = haus.nightmare.a3270.storage.AppSettings.getNumericFieldLock(context)
|
||||||
|
autoSkipEnabled = haus.nightmare.a3270.storage.AppSettings.getAutoSkipEnabled(context)
|
||||||
|
android.widget.Toast.makeText(context, "Settings imported from INI", 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 = { showImportDialog = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-2
@@ -1,4 +1,4 @@
|
|||||||
package org.pubvm.a3270.ui
|
package haus.nightmare.a3270.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
@@ -8,7 +8,7 @@ import android.view.inputmethod.BaseInputConnection
|
|||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.inputmethod.InputConnection
|
import android.view.inputmethod.InputConnection
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
|
import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom InputConnection for Termux-style terminal keyboard interaction.
|
* Custom InputConnection for Termux-style terminal keyboard interaction.
|
||||||
@@ -111,4 +111,10 @@ class TerminalInputView(context: Context) : View(context) {
|
|||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||||
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hideSoftKeyboard() {
|
||||||
|
clearFocus()
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||||
|
imm?.hideSoftInputFromWindow(windowToken, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+278
-78
@@ -1,4 +1,4 @@
|
|||||||
package org.pubvm.a3270.ui
|
package haus.nightmare.a3270.ui
|
||||||
|
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
@@ -6,12 +6,10 @@ import android.widget.Toast
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
@@ -24,32 +22,36 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.lib3270j.protocol.DS3270Constants.*
|
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||||
import org.lib3270j.screen.ExtendedAttribute
|
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||||
import org.lib3270j.screen.ScreenBuffer
|
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||||
|
|
||||||
// Standard 3279 Host Colors (16-color palette)
|
// Standard 3279 Host Colors (16-color palette) aligned 1:1 with IBM Host On-Demand ColorRemapModel3270
|
||||||
private val HOST_COLORS = arrayOf(
|
private val HOST_COLORS = arrayOf(
|
||||||
Color(0xFF000000), // 0: Neutral Black
|
Color(0xFF000000), // 0: Neutral Black
|
||||||
Color(0xFF5078FF), // 1: Blue
|
Color(0xFF7890F0), // 1: Blue (0x7890F0 CUSTOMBLUE)
|
||||||
Color(0xFFFF3232), // 2: Red
|
Color(0xFFFF0000), // 2: Red
|
||||||
Color(0xFFFF82B4), // 3: Pink
|
Color(0xFFFF00FF), // 3: Pink
|
||||||
Color(0xFF32CD32), // 4: Green
|
Color(0xFF00FF00), // 4: Green
|
||||||
Color(0xFF40E0D0), // 5: Turquoise
|
Color(0xFF00FFFF), // 5: Turquoise / Cyan
|
||||||
Color(0xFFFFFF50), // 6: Yellow
|
Color(0xFFFFFF00), // 6: Yellow
|
||||||
Color(0xFFFFFFFF), // 7: Neutral White
|
Color(0xFFFFFFFF), // 7: Neutral White
|
||||||
Color(0xFF000000), // 8: Black
|
Color(0xFF000000), // 8: Black
|
||||||
Color(0xFF1E3CB4), // 9: Deep Blue
|
Color(0xFF000080), // 9: Deep Blue
|
||||||
Color(0xFFFFA500), // 10: Orange
|
Color(0xFFFFA200), // 10: Orange (0xFFFFA200)
|
||||||
Color(0xFFB482FF), // 11: Purple
|
Color(0xFF800080), // 11: Purple
|
||||||
Color(0xFF90EE90), // 12: Pale Green
|
Color(0xFF008000), // 12: Pale Green
|
||||||
Color(0xFFAFEEEE), // 13: Pale Turquoise
|
Color(0xFF008080), // 13: Pale Turquoise
|
||||||
Color(0xFFAAAAAA), // 14: Grey
|
Color(0xFFA0A000), // 14: Mustard (0xFFA0A000)
|
||||||
Color(0xFFFFFFFF) // 15: White
|
Color(0xFFC0C0C0) // 15: Grey (0xFFC0C0C0)
|
||||||
)
|
)
|
||||||
|
|
||||||
private val COLOR_BLACK = Color(0xFF0A0A0A)
|
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
|
@Composable
|
||||||
fun TerminalView(
|
fun TerminalView(
|
||||||
@@ -58,12 +60,24 @@ fun TerminalView(
|
|||||||
cols: Int,
|
cols: Int,
|
||||||
cursorAddr: Int,
|
cursorAddr: Int,
|
||||||
screenVersion: Long,
|
screenVersion: Long,
|
||||||
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
|
programSymbolManager: haus.nightmare.lib3270j.graphics.ProgramSymbolManager? = null,
|
||||||
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
|
graphicsPlane: haus.nightmare.lib3270j.graphics.GraphicsPlane? = null,
|
||||||
|
gocaDecoder: haus.nightmare.lib3270j.graphics.GocaDecoder? = null,
|
||||||
|
isLightPenMode: Boolean = false,
|
||||||
maskHiddenFields: Boolean = true,
|
maskHiddenFields: Boolean = true,
|
||||||
blinkCursor: Boolean = true,
|
blinkCursor: Boolean = true,
|
||||||
|
crosshairRuler: Boolean = false,
|
||||||
|
cursorStyle: String = "BLOCK",
|
||||||
|
isInsertMode: Boolean = false,
|
||||||
|
blockSelectMode: Boolean = false,
|
||||||
|
fourColorOverride: Boolean = false,
|
||||||
|
searchHighlightAddr: Int = -1,
|
||||||
|
searchHighlightLen: Int = 0,
|
||||||
onTapAddress: (Int) -> Unit,
|
onTapAddress: (Int) -> Unit,
|
||||||
|
onLightPenSelect: ((Int) -> Unit)? = null,
|
||||||
|
onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
|
||||||
onPasteText: (String) -> Unit = {},
|
onPasteText: (String) -> Unit = {},
|
||||||
|
onPasteLineWrap: ((String, Int, Boolean) -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -77,6 +91,7 @@ fun TerminalView(
|
|||||||
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
|
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
|
||||||
var showContextMenu by remember { mutableStateOf(false) }
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
|
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
var showPasteWrapDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Blinking cursor state (~530ms interval matching j3270)
|
// Blinking cursor state (~530ms interval matching j3270)
|
||||||
var cursorVisible by remember { mutableStateOf(true) }
|
var cursorVisible by remember { mutableStateOf(true) }
|
||||||
@@ -92,12 +107,21 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(COLOR_BLACK)
|
.background(COLOR_BLACK)
|
||||||
.pointerInput(cols, rows) {
|
.pointerInput(cols, rows, isLightPenMode, gocaDecoder) {
|
||||||
awaitPointerEventScope {
|
awaitPointerEventScope {
|
||||||
while (true) {
|
while (true) {
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
@@ -106,6 +130,7 @@ fun TerminalView(
|
|||||||
var currentPos = startPos
|
var currentPos = startPos
|
||||||
var isDragStarted = false
|
var isDragStarted = false
|
||||||
val slop = viewConfiguration.touchSlop
|
val slop = viewConfiguration.touchSlop
|
||||||
|
val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
@@ -116,7 +141,7 @@ fun TerminalView(
|
|||||||
currentPos = change.position
|
currentPos = change.position
|
||||||
val distance = (currentPos - startPos).getDistance()
|
val distance = (currentPos - startPos).getDistance()
|
||||||
|
|
||||||
if (distance > slop) {
|
if (distance > slop && !isLightPenMode && !isGraphicCursor) {
|
||||||
if (!isDragStarted) {
|
if (!isDragStarted) {
|
||||||
isDragStarted = true
|
isDragStarted = true
|
||||||
selectionStart = startPos
|
selectionStart = startPos
|
||||||
@@ -133,12 +158,12 @@ fun TerminalView(
|
|||||||
if (isDragStarted) {
|
if (isDragStarted) {
|
||||||
// Drag completed: Keep selection box highlighted, DO NOT open context menu
|
// Drag completed: Keep selection box highlighted, DO NOT open context menu
|
||||||
showContextMenu = false
|
showContextMenu = false
|
||||||
} else if (duration >= 600L && totalDistance <= slop) {
|
} else if (duration >= 600L && totalDistance <= slop && !isLightPenMode && !isGraphicCursor) {
|
||||||
// Stationary Long Press (held >= 600ms without dragging)
|
// Stationary Long Press (held >= 600ms without dragging) - only in normal alphanumeric mode
|
||||||
contextMenuOffset = startPos
|
contextMenuOffset = startPos
|
||||||
showContextMenu = true
|
showContextMenu = true
|
||||||
} else if (totalDistance <= slop) {
|
} else if (totalDistance <= slop) {
|
||||||
// Short Tap (Move Cursor)
|
// Short Tap
|
||||||
showContextMenu = false
|
showContextMenu = false
|
||||||
selectionStart = null
|
selectionStart = null
|
||||||
selectionEnd = null
|
selectionEnd = null
|
||||||
@@ -146,16 +171,30 @@ fun TerminalView(
|
|||||||
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
|
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
|
||||||
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
|
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 row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
var addr = row * cols + col
|
val clickAddr = row * cols + col
|
||||||
|
|
||||||
val buf = screenBuffer
|
if (isGraphicCursor) {
|
||||||
if (buf != null && buf.isFormatted) {
|
val gridW = metrics.gridWidth
|
||||||
val faVal = buf.getFieldAttributeAt(addr)
|
val gridH = metrics.gridHeight
|
||||||
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
|
val gWidth = graphicsPlane?.canvasWidth ?: gridW.toInt()
|
||||||
addr = buf.findNextUnprotected(addr)
|
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 {
|
||||||
|
var addr = clickAddr
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
onTapAddress(addr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +253,45 @@ fun TerminalView(
|
|||||||
selMaxCol = maxOf(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 && cols > 0 && rows > 0) {
|
||||||
|
val curCol = (activeCursorAddr % cols).coerceIn(0, cols - 1)
|
||||||
|
val curRow = ((activeCursorAddr / cols) % rows).coerceIn(0, rows - 1)
|
||||||
|
val gridW = cols * cellWidth
|
||||||
|
val gridH = rows * 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 rows) {
|
for (r in 0 until rows) {
|
||||||
for (c in 0 until cols) {
|
for (c in 0 until cols) {
|
||||||
val addr = r * cols + c
|
val addr = r * cols + c
|
||||||
@@ -228,6 +306,7 @@ fun TerminalView(
|
|||||||
var isBold = false
|
var isBold = false
|
||||||
var isUnderline = false
|
var isUnderline = false
|
||||||
var isReverse = false
|
var isReverse = false
|
||||||
|
var isBlink = false
|
||||||
var csVal = 0
|
var csVal = 0
|
||||||
var ecVal = 0
|
var ecVal = 0
|
||||||
|
|
||||||
@@ -241,7 +320,7 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compute 3270 color and field intensity
|
// Compute 3270 color and field intensity
|
||||||
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
|
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA, fourColorOverride)
|
||||||
bgColor = getBgColorForAttribute(ea, currentFieldEa)
|
bgColor = getBgColorForAttribute(ea, currentFieldEa)
|
||||||
|
|
||||||
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
|
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
|
||||||
@@ -275,6 +354,7 @@ fun TerminalView(
|
|||||||
if ((grVal and GR_INTENSIFY) != 0) isBold = true
|
if ((grVal and GR_INTENSIFY) != 0) isBold = true
|
||||||
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
|
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
|
||||||
if ((grVal and GR_REVERSE) != 0) isReverse = true
|
if ((grVal and GR_REVERSE) != 0) isReverse = true
|
||||||
|
if ((grVal and GR_BLINK) != 0) isBlink = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
|
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
|
||||||
@@ -286,7 +366,7 @@ fun TerminalView(
|
|||||||
bgColor = tmp
|
bgColor = tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bgColor != COLOR_BLACK) {
|
if (bgColor != COLOR_BLACK && bgColor != Color.Transparent) {
|
||||||
drawRect(
|
drawRect(
|
||||||
color = bgColor,
|
color = bgColor,
|
||||||
topLeft = Offset(left, top),
|
topLeft = Offset(left, top),
|
||||||
@@ -294,23 +374,51 @@ fun TerminalView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw selection box highlight if selected
|
// Selection highlight (Block select vs Linear stream select)
|
||||||
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
|
val isSelected = if (blockSelectMode) {
|
||||||
if (isSelected) {
|
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(
|
drawRect(
|
||||||
color = Color(0x773399FF),
|
color = SEARCH_HIGHLIGHT_COLOR,
|
||||||
|
topLeft = Offset(left, top),
|
||||||
|
size = Size(cellWidth, cellHeight)
|
||||||
|
)
|
||||||
|
} else if (isSelected) {
|
||||||
|
drawRect(
|
||||||
|
color = SELECTION_COLOR,
|
||||||
topLeft = Offset(left, top),
|
topLeft = Offset(left, top),
|
||||||
size = Size(cellWidth, cellHeight)
|
size = Size(cellWidth, cellHeight)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor indicator (respects blinking toggle & timer)
|
// Cursor indicator (respects blinking toggle & timer, cursor style, insert mode)
|
||||||
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
|
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
|
||||||
drawRect(
|
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
|
||||||
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
|
if (isUnderlineCursor) {
|
||||||
topLeft = Offset(left, top),
|
val ulH = maxOf(4f, cellHeight / 4f)
|
||||||
size = Size(cellWidth, cellHeight)
|
drawRect(
|
||||||
)
|
color = Color(255, 255, 255, 180),
|
||||||
|
topLeft = Offset(left, top + cellHeight - ulH),
|
||||||
|
size = Size(cellWidth, ulH)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
drawRect(
|
||||||
|
color = Color(255, 255, 255, 180),
|
||||||
|
topLeft = Offset(left, top),
|
||||||
|
size = Size(cellWidth, cellHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw Programmed Symbol (PS / APL) if defined
|
// Draw Programmed Symbol (PS / APL) if defined
|
||||||
@@ -322,7 +430,7 @@ fun TerminalView(
|
|||||||
val symHeight = slot.height
|
val symHeight = slot.height
|
||||||
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
|
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
|
||||||
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
|
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
|
||||||
val bmp = android.graphics.Bitmap.createBitmap(rgbArray as IntArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
|
val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
|
||||||
drawContext.canvas.nativeCanvas.drawBitmap(
|
drawContext.canvas.nativeCanvas.drawBitmap(
|
||||||
bmp,
|
bmp,
|
||||||
null,
|
null,
|
||||||
@@ -334,7 +442,8 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!drawnAsPs && charVal != ' ') {
|
val suppressText = isBlink && !textBlinkVisible
|
||||||
|
if (!drawnAsPs && charVal != ' ' && !suppressText) {
|
||||||
paint.color = fgColor.toArgb()
|
paint.color = fgColor.toArgb()
|
||||||
paint.isFakeBoldText = isBold
|
paint.isFakeBoldText = isBold
|
||||||
val fontMetrics = paint.fontMetrics
|
val fontMetrics = paint.fontMetrics
|
||||||
@@ -360,22 +469,44 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw Vector Graphics Plane overlay if present
|
// Draw Graphic Cursor if active in GOCA or GraphicsPlane
|
||||||
if (graphicsPlane != null && graphicsPlane.hasContent()) {
|
val isGraphicCursorActive = (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) ||
|
||||||
val gridW = metrics.gridWidth.toInt()
|
(graphicsPlane != null && graphicsPlane.isGraphicCursorAttached)
|
||||||
val gridH = metrics.gridHeight.toInt()
|
if (isGraphicCursorActive && graphicsPlane != null) {
|
||||||
if (gridW > 0 && gridH > 0) {
|
val gocaX = if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) gocaDecoder.graphicCursorX else graphicsPlane.graphicCursorX
|
||||||
graphicsPlane.resize(gridW, gridH)
|
val gocaY = if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) gocaDecoder.graphicCursorY else graphicsPlane.graphicCursorY
|
||||||
val rgb = graphicsPlane.rgbBuffer
|
val canvasPx = graphicsPlane.mapX(gocaX)
|
||||||
if (rgb != null) {
|
val canvasPy = graphicsPlane.mapY(gocaY)
|
||||||
val bmp = android.graphics.Bitmap.createBitmap(rgb, gridW, gridH, android.graphics.Bitmap.Config.ARGB_8888)
|
val gridW = metrics.gridWidth
|
||||||
drawContext.canvas.nativeCanvas.drawBitmap(
|
val gridH = metrics.gridHeight
|
||||||
bmp,
|
val gWidth = graphicsPlane.canvasWidth.toFloat()
|
||||||
null,
|
val gHeight = graphicsPlane.canvasHeight.toFloat()
|
||||||
android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()),
|
val px = offsetX + if (gWidth > 0) (canvasPx.toFloat() * gridW / gWidth) else canvasPx.toFloat()
|
||||||
null
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,7 +526,7 @@ fun TerminalView(
|
|||||||
text = { Text("Copy Selection") },
|
text = { Text("Copy Selection") },
|
||||||
onClick = {
|
onClick = {
|
||||||
showContextMenu = false
|
showContextMenu = false
|
||||||
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context)
|
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context, blockSelectMode)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
@@ -430,6 +561,65 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Paste with Line Wrap...") },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
showPasteWrapDialog = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,7 +682,8 @@ private fun copySelection(
|
|||||||
rows: Int,
|
rows: Int,
|
||||||
cols: Int,
|
cols: Int,
|
||||||
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
|
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
|
||||||
context: android.content.Context
|
context: android.content.Context,
|
||||||
|
blockSelectMode: Boolean = false
|
||||||
) {
|
) {
|
||||||
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
|
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
|
||||||
val displayMetrics = context.resources.displayMetrics
|
val displayMetrics = context.resources.displayMetrics
|
||||||
@@ -514,7 +705,9 @@ private fun copySelection(
|
|||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
for (r in minRow..maxRow) {
|
for (r in minRow..maxRow) {
|
||||||
val line = StringBuilder()
|
val line = StringBuilder()
|
||||||
for (c in minCol..maxCol) {
|
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
|
val addr = r * cols + c
|
||||||
if (addr < (rows * cols)) {
|
if (addr < (rows * cols)) {
|
||||||
val cell = screenBuffer.getCell(addr)
|
val cell = screenBuffer.getCell(addr)
|
||||||
@@ -530,22 +723,29 @@ private fun copySelection(
|
|||||||
val textToCopy = sb.toString().trimEnd()
|
val textToCopy = sb.toString().trimEnd()
|
||||||
if (textToCopy.isNotBlank()) {
|
if (textToCopy.isNotBlank()) {
|
||||||
clipboardManager.setText(AnnotatedString(textToCopy))
|
clipboardManager.setText(AnnotatedString(textToCopy))
|
||||||
Toast.makeText(context, "Copied selected block to clipboard", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Copied selection to clipboard", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color {
|
private fun getFgColorForAttribute(
|
||||||
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF)
|
ea: ExtendedAttribute,
|
||||||
else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
|
currentFieldEa: ExtendedAttribute?,
|
||||||
else 0
|
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) {
|
if (fg in 0xf0..0xff) {
|
||||||
return HOST_COLORS[fg - 0xf0]
|
return HOST_COLORS[fg - 0xf0]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val fa = currentFA.toInt() and 0xFF
|
val fa = currentFA.toInt() and 0xFF
|
||||||
return if (faIsProtected(fa)) {
|
return if (faIsProtected(fa)) {
|
||||||
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_BLUE]
|
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_TURQUOISE]
|
||||||
} else {
|
} else {
|
||||||
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
|
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package org.pubvm.a3270.ui
|
package haus.nightmare.a3270.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -1,707 +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.channels.Channel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.lib3270j.ConnectionConfig
|
|
||||||
import org.lib3270j.ConnectionState
|
|
||||||
import org.lib3270j.Telnet3270Client
|
|
||||||
import org.lib3270j.TerminalModel
|
|
||||||
import org.lib3270j.ft.FTConfig
|
|
||||||
import org.lib3270j.listener.ConnectionListener
|
|
||||||
import org.lib3270j.listener.ScreenUpdateListener
|
|
||||||
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
|
|
||||||
import org.lib3270j.protocol.DS3270Constants.faIsProtected
|
|
||||||
import org.lib3270j.screen.ScreenBuffer
|
|
||||||
import org.pubvm.a3270.ft.FileTransfer
|
|
||||||
import org.pubvm.a3270.service.TerminalService
|
|
||||||
import org.pubvm.a3270.storage.AppSettings
|
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import java.util.logging.Logger
|
|
||||||
|
|
||||||
sealed interface TerminalInputAction {
|
|
||||||
data class TypeText(val text: String) : TerminalInputAction
|
|
||||||
data class SendAid(val aidCode: Int) : TerminalInputAction
|
|
||||||
data object Backspace : TerminalInputAction
|
|
||||||
data object DeleteChar : TerminalInputAction
|
|
||||||
data object Tab : TerminalInputAction
|
|
||||||
data object BackTab : TerminalInputAction
|
|
||||||
data object CursorLeft : TerminalInputAction
|
|
||||||
data object CursorRight : TerminalInputAction
|
|
||||||
data object CursorUp : TerminalInputAction
|
|
||||||
data object CursorDown : TerminalInputAction
|
|
||||||
data object CursorHome : TerminalInputAction
|
|
||||||
data object EraseEof : TerminalInputAction
|
|
||||||
data object EraseInput : TerminalInputAction
|
|
||||||
data object Newline : TerminalInputAction
|
|
||||||
data object Reset : TerminalInputAction
|
|
||||||
data class SetCursor(val baddr: Int) : TerminalInputAction
|
|
||||||
}
|
|
||||||
|
|
||||||
data class UntrustedCertPromptState(
|
|
||||||
val host: String,
|
|
||||||
val port: Int,
|
|
||||||
val chain: Array<java.security.cert.X509Certificate>?,
|
|
||||||
val exception: java.security.cert.CertificateException?,
|
|
||||||
val deferred: kotlinx.coroutines.CompletableDeferred<Boolean>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class FTProgressState(
|
|
||||||
val isActive: Boolean = false,
|
|
||||||
val isRunning: Boolean = false,
|
|
||||||
val bytesTransferred: Long = 0L,
|
|
||||||
val statusMessage: String = "",
|
|
||||||
val isError: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
// Non-blocking FIFO input channel buffer
|
|
||||||
private val inputChannel = Channel<TerminalInputAction>(Channel.UNLIMITED)
|
|
||||||
|
|
||||||
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 val _maskHiddenInput = MutableStateFlow(AppSettings.isMaskHiddenInputEnabled(application))
|
|
||||||
val maskHiddenInput: StateFlow<Boolean> = _maskHiddenInput.asStateFlow()
|
|
||||||
|
|
||||||
private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application))
|
|
||||||
val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow()
|
|
||||||
|
|
||||||
private val _hapticFeedback = MutableStateFlow(AppSettings.isHapticFeedbackEnabled(application))
|
|
||||||
val hapticFeedback: StateFlow<Boolean> = _hapticFeedback.asStateFlow()
|
|
||||||
|
|
||||||
private val _verifyCerts = MutableStateFlow(AppSettings.isVerifyCertsEnabled(application))
|
|
||||||
val verifyCerts: StateFlow<Boolean> = _verifyCerts.asStateFlow()
|
|
||||||
|
|
||||||
private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application))
|
|
||||||
val defaultGraphicsMode: StateFlow<String> = _defaultGraphicsMode.asStateFlow()
|
|
||||||
|
|
||||||
private val _isTlsActive = MutableStateFlow(false)
|
|
||||||
val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow()
|
|
||||||
|
|
||||||
private val _isTlsVerified = MutableStateFlow(true)
|
|
||||||
val isTlsVerified: StateFlow<Boolean> = _isTlsVerified.asStateFlow()
|
|
||||||
|
|
||||||
private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null)
|
|
||||||
val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow()
|
|
||||||
|
|
||||||
// File Transfer State
|
|
||||||
private var fileTransferCoordinator: FileTransfer? = null
|
|
||||||
private val _ftState = MutableStateFlow(FTProgressState())
|
|
||||||
val ftState: StateFlow<FTProgressState> = _ftState.asStateFlow()
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var client: Telnet3270Client? = null
|
|
||||||
var currentHost: String = ""
|
|
||||||
private set
|
|
||||||
var activeHostType: String = "TSO"
|
|
||||||
private set
|
|
||||||
|
|
||||||
fun getClient(): Telnet3270Client? = client
|
|
||||||
|
|
||||||
private var lastScreenContentHash: Int = 0
|
|
||||||
private var hasInitialScreenLoaded: Boolean = false
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Start continuous background input buffer consumer
|
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
for (action in inputChannel) {
|
|
||||||
processInputAction(action)
|
|
||||||
// Drain any additional pending actions in the buffer before updating the UI
|
|
||||||
while (true) {
|
|
||||||
val next = inputChannel.tryReceive().getOrNull() ?: break
|
|
||||||
processInputAction(next)
|
|
||||||
}
|
|
||||||
val c = client
|
|
||||||
if (c != null) {
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
}
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processInputAction(action: TerminalInputAction) {
|
|
||||||
val c = client ?: return
|
|
||||||
val ip = c.inputProcessor
|
|
||||||
val buf = c.screenBuffer
|
|
||||||
|
|
||||||
try {
|
|
||||||
when (action) {
|
|
||||||
is TerminalInputAction.TypeText -> {
|
|
||||||
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}")
|
|
||||||
ip.isKeyboardLocked = false
|
|
||||||
for (ch in action.text) {
|
|
||||||
if (ch == '\n' || ch == '\r') {
|
|
||||||
ip.setKeyboardLocked(false)
|
|
||||||
ip.sendAid(AID_ENTER)
|
|
||||||
} else if (ch >= ' ') {
|
|
||||||
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)
|
|
||||||
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(curAddr).ucs4}'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is TerminalInputAction.SendAid -> {
|
|
||||||
ip.setKeyboardLocked(false)
|
|
||||||
ip.sendAid(action.aidCode)
|
|
||||||
}
|
|
||||||
is TerminalInputAction.Backspace -> {
|
|
||||||
ip.isKeyboardLocked = false
|
|
||||||
ip.backspace()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.DeleteChar -> {
|
|
||||||
ip.isKeyboardLocked = false
|
|
||||||
ip.deleteChar()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.Tab -> {
|
|
||||||
ip.tab()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.BackTab -> {
|
|
||||||
ip.backTab()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.CursorLeft -> {
|
|
||||||
ip.cursorLeft()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.CursorRight -> {
|
|
||||||
ip.cursorRight()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.CursorUp -> {
|
|
||||||
ip.cursorUp()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.CursorDown -> {
|
|
||||||
ip.cursorDown()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.CursorHome -> {
|
|
||||||
ip.cursorHome()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.EraseEof -> {
|
|
||||||
ip.isKeyboardLocked = false
|
|
||||||
ip.eraseEof()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.EraseInput -> {
|
|
||||||
ip.isKeyboardLocked = false
|
|
||||||
ip.eraseInput()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.Newline -> {
|
|
||||||
ip.newline()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.Reset -> {
|
|
||||||
ip.reset()
|
|
||||||
}
|
|
||||||
is TerminalInputAction.SetCursor -> {
|
|
||||||
if (action.baddr in 0 until (buf.rows * buf.cols)) {
|
|
||||||
buf.cursorAddress = action.baddr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error processing input action: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resolveUntrustedCert(accept: Boolean) {
|
|
||||||
try {
|
|
||||||
val prompt = _untrustedCertPrompt.value
|
|
||||||
if (prompt != null) {
|
|
||||||
prompt.deferred.complete(accept)
|
|
||||||
_untrustedCertPrompt.value = null
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
log.warning("Error resolving untrusted cert: ${t.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateSettings(maskHidden: Boolean, blink: Boolean, haptic: Boolean, verifyCertsVal: Boolean = true, defaultGraphicsVal: String = "BOTH") {
|
|
||||||
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
|
|
||||||
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
|
|
||||||
AppSettings.setHapticFeedbackEnabled(getApplication(), haptic)
|
|
||||||
AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal)
|
|
||||||
AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal)
|
|
||||||
_maskHiddenInput.value = maskHidden
|
|
||||||
_cursorBlink.value = blink
|
|
||||||
_hapticFeedback.value = haptic
|
|
||||||
_verifyCerts.value = verifyCertsVal
|
|
||||||
_defaultGraphicsMode.value = defaultGraphicsVal
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connect(
|
|
||||||
host: String,
|
|
||||||
port: Int = 23,
|
|
||||||
modelNum: Int = 2,
|
|
||||||
luName: String = "",
|
|
||||||
hostType: String = "TSO",
|
|
||||||
useTls: Boolean = false,
|
|
||||||
tlsVerifyCert: Boolean = true,
|
|
||||||
tn3270e: Boolean = true,
|
|
||||||
graphicsModeStr: String = "BOTH"
|
|
||||||
) {
|
|
||||||
currentHost = host
|
|
||||||
activeHostType = hostType
|
|
||||||
lastScreenContentHash = 0
|
|
||||||
hasInitialScreenLoaded = false
|
|
||||||
fileTransferCoordinator = null
|
|
||||||
_ftState.value = FTProgressState()
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
// Always disconnect any existing client before establishing a new connection
|
|
||||||
if (client != null || _connectionState.value.isConnected()) {
|
|
||||||
try {
|
|
||||||
client?.disconnect()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
client = null
|
|
||||||
_connectionState.value = ConnectionState.NOT_CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
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 parsedConfig = ConnectionConfig.parseHostString(host, port, model)
|
|
||||||
val effectiveHost = parsedConfig.host
|
|
||||||
val effectivePort = parsedConfig.port
|
|
||||||
val effectiveTls = useTls || parsedConfig.isUseTls
|
|
||||||
val effectiveTn3270e = tn3270e && parsedConfig.isTn3270eEnabled
|
|
||||||
val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication())
|
|
||||||
val effectiveVerify = tlsVerifyCert && globalVerify
|
|
||||||
val gMode = org.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
|
|
||||||
|
|
||||||
_isTlsActive.value = effectiveTls
|
|
||||||
_isTlsVerified.value = effectiveVerify
|
|
||||||
_oiaText.value = "Connecting to $effectiveHost:$effectivePort" + (if (effectiveTls) " [TLS]" else "") + "..."
|
|
||||||
|
|
||||||
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply {
|
|
||||||
if (luName.isNotBlank()) {
|
|
||||||
setLuName(luName)
|
|
||||||
}
|
|
||||||
isUseTls = effectiveTls
|
|
||||||
isTlsVerifyCert = effectiveVerify
|
|
||||||
isTn3270eEnabled = effectiveTn3270e
|
|
||||||
graphicsMode = gMode
|
|
||||||
}
|
|
||||||
|
|
||||||
config.certificateVerifier = org.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
|
|
||||||
if (!AppSettings.isVerifyCertsEnabled(getApplication())) {
|
|
||||||
log.info("Certificate verification bypassed via global AppSettings")
|
|
||||||
return@TlsCertificateVerifier true
|
|
||||||
}
|
|
||||||
log.info("Prompting user for untrusted certificate: $effectiveHost:$effectivePort")
|
|
||||||
val deferred = kotlinx.coroutines.CompletableDeferred<Boolean>()
|
|
||||||
_untrustedCertPrompt.value = UntrustedCertPromptState(
|
|
||||||
host = effectiveHost,
|
|
||||||
port = effectivePort,
|
|
||||||
chain = chain,
|
|
||||||
exception = exception,
|
|
||||||
deferred = deferred
|
|
||||||
)
|
|
||||||
val accepted = try {
|
|
||||||
kotlinx.coroutines.runBlocking { deferred.await() }
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
log.warning("Certificate verifier prompt interrupted or failed: ${t.message}")
|
|
||||||
false
|
|
||||||
} finally {
|
|
||||||
_untrustedCertPrompt.value = null
|
|
||||||
}
|
|
||||||
accepted
|
|
||||||
}
|
|
||||||
|
|
||||||
val newClient = Telnet3270Client(config)
|
|
||||||
this@TerminalViewModel.client = newClient
|
|
||||||
|
|
||||||
newClient.addConnectionListener(object : ConnectionListener {
|
|
||||||
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
|
|
||||||
_connectionState.value = newState
|
|
||||||
val tlsSuffix = if (effectiveTls) {
|
|
||||||
if (effectiveVerify) " [🔒 TLS]" else " [🔓 TLS/Unverified]"
|
|
||||||
} else ""
|
|
||||||
_oiaText.value = if (newState.isFullSession()) {
|
|
||||||
"3270 Connected ($effectiveHost - $activeHostType)$tlsSuffix"
|
|
||||||
} 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()
|
|
||||||
|
|
||||||
// Drive CUT mode file transfers if active
|
|
||||||
fileTransferCoordinator?.onScreenUpdated()
|
|
||||||
|
|
||||||
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: Throwable) {
|
|
||||||
log.severe("Failed to connect: ${e.message}")
|
|
||||||
_connectionState.value = ConnectionState.NOT_CONNECTED
|
|
||||||
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message ?: "Connection error"}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
fileTransferCoordinator?.reset()
|
|
||||||
fileTransferCoordinator = null
|
|
||||||
client?.disconnect()
|
|
||||||
client = null
|
|
||||||
_connectionState.value = ConnectionState.NOT_CONNECTED
|
|
||||||
_oiaText.value = "Disconnected"
|
|
||||||
_ftState.value = FTProgressState()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error disconnecting: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startFileTransfer(config: FTConfig, onResult: (String?) -> Unit = {}) {
|
|
||||||
val c = client
|
|
||||||
if (c == null || !_connectionState.value.isConnected()) {
|
|
||||||
val err = "Terminal is not connected."
|
|
||||||
_ftState.value = FTProgressState(isActive = false, statusMessage = err, isError = true)
|
|
||||||
onResult(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
// Resolve local path if relative (default to app files or download directory)
|
|
||||||
val rawPath = config.localFilename
|
|
||||||
if (!rawPath.startsWith("/")) {
|
|
||||||
val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
|
|
||||||
val resolvedFile = File(appFilesDir, rawPath)
|
|
||||||
config.localFilename = resolvedFile.absolutePath
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileTransferCoordinator == null) {
|
|
||||||
fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback {
|
|
||||||
override fun onTransferStarted() {
|
|
||||||
_ftState.value = FTProgressState(
|
|
||||||
isActive = true,
|
|
||||||
isRunning = false,
|
|
||||||
bytesTransferred = 0L,
|
|
||||||
statusMessage = "Starting IND\$FILE transfer..."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTransferRunning() {
|
|
||||||
_ftState.value = _ftState.value.copy(
|
|
||||||
isRunning = true,
|
|
||||||
statusMessage = "Transfer in progress..."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBytesTransferred(bytes: Long) {
|
|
||||||
_ftState.value = _ftState.value.copy(
|
|
||||||
bytesTransferred = bytes,
|
|
||||||
statusMessage = "Transferring: $bytes bytes"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTransferComplete(message: String) {
|
|
||||||
_ftState.value = FTProgressState(
|
|
||||||
isActive = false,
|
|
||||||
isRunning = false,
|
|
||||||
bytesTransferred = _ftState.value.bytesTransferred,
|
|
||||||
statusMessage = message,
|
|
||||||
isError = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTransferAborted(error: String) {
|
|
||||||
_ftState.value = FTProgressState(
|
|
||||||
isActive = false,
|
|
||||||
isRunning = false,
|
|
||||||
bytesTransferred = _ftState.value.bytesTransferred,
|
|
||||||
statusMessage = "Transfer failed: $error",
|
|
||||||
isError = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
val err = fileTransferCoordinator?.startTransfer(config)
|
|
||||||
if (err != null) {
|
|
||||||
_ftState.value = FTProgressState(
|
|
||||||
isActive = false,
|
|
||||||
statusMessage = "Error: $err",
|
|
||||||
isError = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
onResult(err)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error in startFileTransfer: ${e.message}")
|
|
||||||
_ftState.value = FTProgressState(
|
|
||||||
isActive = false,
|
|
||||||
statusMessage = "Transfer error: ${e.message}",
|
|
||||||
isError = true
|
|
||||||
)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
onResult(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelFileTransfer() {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
fileTransferCoordinator?.cancel()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error cancelling transfer: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun typeChar(ch: Char) {
|
|
||||||
inputChannel.trySend(TerminalInputAction.TypeText(ch.toString()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun typeString(text: String) {
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
inputChannel.trySend(TerminalInputAction.TypeText(text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pasteString(text: String) {
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
inputChannel.trySend(TerminalInputAction.TypeText(text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun backspace() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.Backspace)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteChar() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.DeleteChar)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun tab() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.Tab)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun backTab() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.BackTab)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetKeyboard() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.Reset)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun eraseEof() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.EraseEof)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun eraseInput() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.EraseInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun newline() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.Newline)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendAid(aidCode: Int) {
|
|
||||||
inputChannel.trySend(TerminalInputAction.SendAid(aidCode))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCursor(baddr: Int) {
|
|
||||||
inputChannel.trySend(TerminalInputAction.SetCursor(baddr))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cursorLeft() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.CursorLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cursorUp() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.CursorUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cursorDown() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.CursorDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cursorRight() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.CursorRight)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cursorHome() {
|
|
||||||
inputChannel.trySend(TerminalInputAction.CursorHome)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
fileTransferCoordinator?.reset()
|
|
||||||
fileTransferCoordinator = null
|
|
||||||
client?.disconnect()
|
|
||||||
inputChannel.close()
|
|
||||||
inputExecutor.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package org.pubvm.a3270.storage
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
|
|
||||||
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 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
package org.pubvm.a3270.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
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.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import org.lib3270j.ft.FTConfig
|
|
||||||
import org.pubvm.a3270.FTProgressState
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FileTransferDialog(
|
|
||||||
initialHostType: String = "TSO",
|
|
||||||
ftProgressState: FTProgressState = FTProgressState(),
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onStartTransfer: (FTConfig) -> Unit,
|
|
||||||
onCancelTransfer: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
val initialTypeEnum = when (initialHostType.uppercase()) {
|
|
||||||
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
|
|
||||||
"CICS" -> FTConfig.HostType.CICS
|
|
||||||
else -> FTConfig.HostType.TSO
|
|
||||||
}
|
|
||||||
|
|
||||||
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(initialTypeEnum) }
|
|
||||||
var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) }
|
|
||||||
var lreclStr by remember { mutableStateOf("") }
|
|
||||||
var blksizeStr by remember { mutableStateOf("") }
|
|
||||||
var overwrite by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
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()) {
|
|
||||||
Text(
|
|
||||||
text = ftProgressState.statusMessage,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = localFile,
|
|
||||||
onValueChange = { localFile = it },
|
|
||||||
label = { Text("Local File Name / Path") },
|
|
||||||
placeholder = { Text("sample.txt") },
|
|
||||||
supportingText = { Text("Relative names will be stored in app Downloads/Files storage", fontSize = 10.sp) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 },
|
|
||||||
label = { Text("Receive (GET)", fontSize = 11.sp) }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
FilterChip(
|
|
||||||
selected = !isReceive,
|
|
||||||
onClick = { isReceive = false },
|
|
||||||
label = { Text("Send (PUT)", fontSize = 11.sp) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 },
|
|
||||||
label = { Text("LRECL", fontSize = 11.sp) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = blksizeStr,
|
|
||||||
onValueChange = { blksizeStr = it },
|
|
||||||
label = { Text("BLKSIZE", fontSize = 11.sp) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (hostFile.isNotBlank() && localFile.isNotBlank()) {
|
|
||||||
val config = FTConfig().apply {
|
|
||||||
setHostFilename(hostFile.trim())
|
|
||||||
setLocalFilename(localFile.trim())
|
|
||||||
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.toIntOrNull()?.let { setLrecl(it) }
|
|
||||||
blksizeStr.toIntOrNull()?.let { setBlksize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onStartTransfer(config)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning
|
|
||||||
) {
|
|
||||||
Text("Start Transfer")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
package org.pubvm.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 org.lib3270j.ConnectionState
|
|
||||||
import org.lib3270j.protocol.DS3270Constants.*
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TwoRowKeyBar(
|
|
||||||
connectionState: ConnectionState,
|
|
||||||
isShiftPressed: Boolean = false,
|
|
||||||
hapticFeedbackEnabled: Boolean = true,
|
|
||||||
onClearShift: () -> Unit = {},
|
|
||||||
onConnectClick: () -> Unit,
|
|
||||||
onDisconnectClick: () -> Unit,
|
|
||||||
onFtClick: () -> Unit,
|
|
||||||
onSettingsClick: () -> 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/Settings)
|
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
|
||||||
KeyButton(
|
|
||||||
label = "⋮",
|
|
||||||
color = Color(0xFF343A40),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onReset)
|
|
||||||
|
|
||||||
// 4. ENTER
|
|
||||||
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
|
|
||||||
|
|
||||||
// 5. CLEAR
|
|
||||||
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +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,
|
|
||||||
isTls: Boolean = false,
|
|
||||||
isTlsVerified: Boolean = true,
|
|
||||||
graphicsMode: String = "NONE",
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,268 +0,0 @@
|
|||||||
package org.pubvm.a3270.ui
|
|
||||||
|
|
||||||
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.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
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsDialog(
|
|
||||||
initialMaskHiddenInput: Boolean,
|
|
||||||
initialCursorBlink: Boolean,
|
|
||||||
initialHapticFeedback: Boolean = true,
|
|
||||||
initialVerifyCerts: Boolean = true,
|
|
||||||
initialDefaultGraphicsMode: String = "BOTH",
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String) -> Unit
|
|
||||||
) {
|
|
||||||
var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
|
|
||||||
var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
|
|
||||||
var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) }
|
|
||||||
var verifyCerts by remember { mutableStateOf(initialVerifyCerts) }
|
|
||||||
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
|
|
||||||
|
|
||||||
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
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(20.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Terminal Settings",
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Setting 1: Mask Protected/Hidden Input with '*'
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Show '*' for Hidden Fields",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Display asterisks for typed characters in non-display / password fields rather than blank spaces.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = Color.LightGray
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = maskHiddenInput,
|
|
||||||
onCheckedChange = { maskHiddenInput = it },
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = Color.White,
|
|
||||||
checkedTrackColor = Color(0xFF2B8A3E)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Setting 2: Blinking Cursor
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Blinking Cursor",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Blink the terminal cursor to clearly indicate current screen position.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = Color.LightGray
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = cursorBlink,
|
|
||||||
onCheckedChange = { cursorBlink = it },
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = Color.White,
|
|
||||||
checkedTrackColor = Color(0xFF2B8A3E)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Setting 3: Button Haptic Feedback
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Button Haptic Feedback",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Vibrate softly when tapping function and navigation buttons to match keyboard tactile feedback.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = Color.LightGray
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = hapticFeedback,
|
|
||||||
onCheckedChange = { hapticFeedback = it },
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = Color.White,
|
|
||||||
checkedTrackColor = Color(0xFF2B8A3E)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Setting 4: Overall TLS Certificate Verification
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Verify TLS Certificates",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Enforce SSL/TLS certificate validation. Turn off to allow self-signed or unverified certificates without security prompts.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = Color.LightGray
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = verifyCerts,
|
|
||||||
onCheckedChange = { verifyCerts = it },
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = Color.White,
|
|
||||||
checkedTrackColor = Color(0xFF2B8A3E)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
HorizontalDivider(color = Color(0xFF2C2D30))
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Setting 5: Default Graphics Support Mode
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = "Default Graphics Mode",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Configure default vector graphics (GOCA) and Programmed Symbols (APL) mode.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = Color.LightGray
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.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 = defaultGraphicsMode.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 { defaultGraphicsMode = 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 = androidx.compose.ui.text.style.TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
// Action Buttons
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End
|
|
||||||
) {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Cancel", color = Color.LightGray)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode)
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
|
||||||
) {
|
|
||||||
Text("Save", color = Color.White)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,104 @@
|
|||||||
|
package haus.nightmare.a3270
|
||||||
|
|
||||||
|
import haus.nightmare.a3270.storage.SavedHost
|
||||||
|
import haus.nightmare.lib3270j.ConnectionConfig
|
||||||
|
import haus.nightmare.lib3270j.TerminalModel
|
||||||
|
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,255 @@
|
|||||||
|
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.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 = """
|
||||||
|
[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("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 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,178 @@
|
|||||||
|
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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-6
@@ -1,13 +1,13 @@
|
|||||||
package org.pubvm.a3270
|
package haus.nightmare.a3270
|
||||||
|
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.lib3270j.TerminalModel
|
import haus.nightmare.lib3270j.TerminalModel
|
||||||
import org.lib3270j.charset.EbcdicTranslator
|
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||||
import org.lib3270j.input.InputProcessor
|
import haus.nightmare.lib3270j.input.InputProcessor
|
||||||
import org.lib3270j.protocol.DS3270Constants.*
|
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||||
import org.lib3270j.screen.ScreenBuffer
|
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||||
|
|
||||||
class HardwareKeyboardInputTest {
|
class HardwareKeyboardInputTest {
|
||||||
|
|
||||||
@@ -165,4 +165,77 @@ class HardwareKeyboardInputTest {
|
|||||||
assertFalse(inputProcessor.isKeyboardLocked)
|
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,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