8 Commits

Author SHA1 Message Date
rudi 351379b5b3 Merge branch 'main' of git.hugfreevikings.wtf:rudi/a3270
Build and Test a3270 / Build Android APK (push) Successful in 4m20s
# Conflicts:
#	build.sh
#	src/main/java/haus/nightmare/a3270/ui/TerminalView.kt
2026-09-12 15:54:59 +00:00
rudi eba1406cbb updtaes from j3270 2026-09-12 15:50:42 +00:00
rudi 88e3ffcbe9 Fix 3dot menu bug
Build and Test a3270 / Build Android APK (push) Successful in 6m3s
Release a3270 / Build & Publish Release (push) Successful in 3m56s
2026-09-08 11:40:39 -04:00
rudi f389e626ea Adjust graphics from j3270 changes
Build and Test a3270 / Build Android APK (push) Successful in 6m18s
2026-09-07 20:17:11 -04:00
rudi 0eaab32e2f Update Status/FileTransfer
Build and Test a3270 / Build Android APK (push) Successful in 3m39s
2026-09-06 09:30:26 -04:00
rudi da289730db Update README
Build and Test a3270 / Build Android APK (push) Successful in 2m54s
2026-09-05 14:24:32 -04:00
rudi f77f51474c Catch up to j3270
Build and Test a3270 / Build Android APK (push) Successful in 7m33s
2026-09-04 20:58:34 -04:00
rudi e82d01003b IBM-DYNAMIC
Build and Test a3270 / Build Android APK (push) Successful in 7m24s
Release a3270 / Build & Publish Release (push) Successful in 7m53s
2026-09-03 16:49:49 -04:00
23 changed files with 3017 additions and 379 deletions
+6
View File
@@ -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
+1
View File
@@ -9,3 +9,4 @@ captures/
.cxx .cxx
*.jar *.jar
!gradle/wrapper/gradle-wrapper.jar !gradle/wrapper/gradle-wrapper.jar
*.txt
+63 -12
View File
@@ -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 PF1PF24, PA1PA3, 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 PF1PF24, PA1PA3, 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 F1F12 (mapped to PF1PF12 / PF13PF24 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:
+6 -1
View File
@@ -45,8 +45,12 @@ if [ ! -d "$J3270_PATH/lib3270j" ]; then
exit 1 exit 1
fi fi
if [ $# -gt 0 ]; then
echo "Running: ./gradlew $@"
./gradlew "$@"
else
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
@@ -56,3 +60,4 @@ if [ -f "$APK_PATH" ]; then
else else
echo "Build completed, but APK not found at $APK_PATH" echo "Build completed, but APK not found at $APK_PATH"
fi fi
fi
Vendored
+20
View File
@@ -115,6 +115,26 @@ esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Prefer compatible JDK (JDK 17 or 21) if active Java is incompatible (Java 22+)
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
elif [ -d "$HOME/.sdkman/candidates/java/21.0.2-open" ]; then
CURRENT_JAVA_CMD="${JAVA_HOME:+$JAVA_HOME/bin/java}"
CURRENT_JAVA_CMD="${CURRENT_JAVA_CMD:-java}"
CURRENT_VER=$("$CURRENT_JAVA_CMD" -version 2>&1 | awk -F '"' '/version/ {print $2}' | cut -d'.' -f1)
if [ -z "$JAVA_HOME" ] || [ "$CURRENT_VER" -gt 21 ] 2>/dev/null; then
export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.2-open"
fi
fi
if [ -z "$ANDROID_HOME" ]; then
if [ -d "$HOME/Library/Android/sdk" ]; then
export ANDROID_HOME="$HOME/Library/Android/sdk"
elif [ -d "$HOME/Android/Sdk" ]; then
export ANDROID_HOME="$HOME/Android/Sdk"
fi
fi
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
@@ -25,6 +25,7 @@ import android.content.Context
import android.view.KeyCharacterMap import android.view.KeyCharacterMap
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.a3270.service.TerminalService import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.storage.HostStorage import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.ui.ConnectDialog import haus.nightmare.a3270.ui.ConnectDialog
import haus.nightmare.a3270.ui.FileTransferDialog import haus.nightmare.a3270.ui.FileTransferDialog
@@ -51,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() {}
@@ -74,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)
@@ -88,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 }
) )
} }
} }
@@ -180,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
@@ -199,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
@@ -246,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
@@ -261,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
} }
@@ -269,12 +315,34 @@ class MainActivity : ComponentActivity() {
viewModel.resetKeyboard() viewModel.resetKeyboard()
return true return true
} }
KeyEvent.KEYCODE_D -> { KeyEvent.KEYCODE_E -> {
viewModel.deleteWord() viewModel.eraseInput()
return true return true
} }
KeyEvent.KEYCODE_E -> { KeyEvent.KEYCODE_A -> {
viewModel.fieldEnd() 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 return true
} }
KeyEvent.KEYCODE_L -> { KeyEvent.KEYCODE_L -> {
@@ -294,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
@@ -424,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()
@@ -449,6 +525,23 @@ fun MainScreen(
val ftState by viewModel.ftState.collectAsState() val ftState by viewModel.ftState.collectAsState()
val isLightPenMode by viewModel.isLightPenMode.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) }
@@ -460,6 +553,22 @@ fun MainScreen(
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 ->
@@ -476,12 +585,17 @@ fun MainScreen(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val startupBehavior = AppSettings.getStartupBehavior(context)
when (startupBehavior) {
AppSettings.StartupBehavior.AUTO_CONNECT -> {
val autoHost = HostStorage.getAutoConnectHost(context) val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) { if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect( viewModel.connect(
autoHost.host, autoHost.host,
autoHost.port, autoHost.port,
autoHost.model, autoHost.model,
autoHost.dynamicRows,
autoHost.dynamicCols,
autoHost.luName, autoHost.luName,
autoHost.hostType, autoHost.hostType,
autoHost.useTls, autoHost.useTls,
@@ -497,6 +611,16 @@ fun MainScreen(
) )
} }
} }
AppSettings.StartupBehavior.SHOW_CONNECT -> {
if (!connectionState.isConnected()) {
showConnectDialog = true
}
}
AppSettings.StartupBehavior.DO_NOTHING -> {
// Do nothing
}
}
}
LaunchedEffect(connectionState) { LaunchedEffect(connectionState) {
if (connectionState.isConnected()) { if (connectionState.isConnected()) {
@@ -532,6 +656,14 @@ fun MainScreen(
blinkCursor = cursorBlink, blinkCursor = cursorBlink,
searchHighlightAddr = searchHighlightAddr, searchHighlightAddr = searchHighlightAddr,
searchHighlightLen = searchHighlightLen, 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()
@@ -584,18 +716,56 @@ fun MainScreen(
connectionState = connectionState, connectionState = connectionState,
isShiftPressed = isShiftPressed, isShiftPressed = isShiftPressed,
isLightPenMode = isLightPenMode, 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 = {
onFieldInspectorClick = { showFieldInspectorDialog = true }, terminalInputViewRef?.hideSoftKeyboard()
onFindClick = { showFindDialog = true }, showFtDialog = true
onScreenExportClick = { showScreenExportDialog = true }, },
onScriptClick = { showScriptDialog = true }, onFieldInspectorClick = {
onPrinterSessionClick = { showPrinterSessionDialog = true }, 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() }, onToggleLightPen = { viewModel.toggleLightPen() },
onSettingsClick = { showSettingsDialog = true }, 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()
@@ -662,21 +832,32 @@ fun MainScreen(
isTls = isTlsActive, isTls = isTlsActive,
isTlsVerified = isTlsVerified, isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode, graphicsMode = defaultGraphicsMode,
codePage = codePage,
isLightPenMode = isLightPenMode, isLightPenMode = isLightPenMode,
isInsertMode = viewModel.getClient()?.inputProcessor?.isInsertMode ?: false, isInsertMode = isInsertMode,
isDocMode = isDocMode,
isWordWrap = isWordWrap,
isAplMode = isAplMode,
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0, inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "" 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, cp, pType, pHost, pPort, pUser, pPass ->
showConnectDialog = false showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass) 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()
} }
) )
@@ -687,7 +868,10 @@ fun MainScreen(
initialHostType = viewModel.activeHostType, initialHostType = viewModel.activeHostType,
ftProgressState = ftState, ftProgressState = ftState,
client = viewModel.getClient(), client = viewModel.getClient(),
onDismiss = { showFtDialog = false }, onDismiss = {
showFtDialog = false
terminalInputViewRef?.showSoftKeyboard()
},
onStartTransfer = { config -> onStartTransfer = { config ->
viewModel.startFileTransfer(config) { error -> viewModel.startFileTransfer(config) { error ->
if (error != null) { if (error != null) {
@@ -709,10 +893,51 @@ fun MainScreen(
initialVerifyCerts = verifyCerts, initialVerifyCerts = verifyCerts,
initialDefaultGraphicsMode = defaultGraphicsMode, initialDefaultGraphicsMode = defaultGraphicsMode,
initialDefaultCodePage = codePage, initialDefaultCodePage = codePage,
onDismiss = { showSettingsDialog = false }, initialUiTheme = uiTheme,
onSave = { mask, blink, haptic, verify, gfx, defaultCp -> 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
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 showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp) 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() terminalInputViewRef?.showSoftKeyboard()
} }
) )
@@ -722,7 +947,10 @@ fun MainScreen(
FieldInspectorDialog( FieldInspectorDialog(
client = viewModel.getClient(), client = viewModel.getClient(),
screenVersion = screenVersion, screenVersion = screenVersion,
onDismiss = { showFieldInspectorDialog = false }, onDismiss = {
showFieldInspectorDialog = false
terminalInputViewRef?.showSoftKeyboard()
},
onJumpToField = { addr -> onJumpToField = { addr ->
viewModel.setCursor(addr) viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
@@ -733,7 +961,10 @@ fun MainScreen(
if (showFindDialog) { if (showFindDialog) {
FindDialog( FindDialog(
cols = cols, cols = cols,
onDismiss = { showFindDialog = false }, onDismiss = {
showFindDialog = false
terminalInputViewRef?.showSoftKeyboard()
},
onFind = { query: String, matchCase: Boolean, forward: Boolean -> onFind = { query: String, matchCase: Boolean, forward: Boolean ->
viewModel.find(query, matchCase, forward) viewModel.find(query, matchCase, forward)
}, },
@@ -751,13 +982,19 @@ fun MainScreen(
programSymbolManager = viewModel.getClient()?.programSymbolManager, programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane, graphicsPlane = viewModel.getClient()?.graphicsPlane,
maskHidden = maskHiddenInput, maskHidden = maskHiddenInput,
onDismiss = { showScreenExportDialog = false } onDismiss = {
showScreenExportDialog = false
terminalInputViewRef?.showSoftKeyboard()
}
) )
} }
if (showScriptDialog) { if (showScriptDialog) {
ScriptDialog( ScriptDialog(
onDismiss = { showScriptDialog = false }, onDismiss = {
showScriptDialog = false
terminalInputViewRef?.showSoftKeyboard()
},
onExecuteScript = { script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit -> onExecuteScript = { script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit ->
viewModel.executeScript(script, onStatus, onComplete) viewModel.executeScript(script, onStatus, onComplete)
} }
@@ -770,7 +1007,10 @@ fun MainScreen(
initialHost = activeHost, initialHost = activeHost,
initialCodePage = codePage, initialCodePage = codePage,
initialUseTls = isTlsActive, initialUseTls = isTlsActive,
onDismiss = { showPrinterSessionDialog = false } onDismiss = {
showPrinterSessionDialog = false
terminalInputViewRef?.showSoftKeyboard()
}
) )
} }
@@ -53,6 +53,12 @@ sealed interface TerminalInputAction {
data object Dup : TerminalInputAction data object Dup : TerminalInputAction
data object FieldMark : TerminalInputAction data object FieldMark : TerminalInputAction
data object ToggleInsert : TerminalInputAction data object ToggleInsert : TerminalInputAction
data object ToggleDocMode : TerminalInputAction
data object ToggleWordWrap : TerminalInputAction
data object ToggleApl : TerminalInputAction
data class SetDocMode(val enabled: Boolean) : TerminalInputAction
data class SetWordWrap(val enabled: Boolean) : TerminalInputAction
data class SetAplMode(val enabled: Boolean) : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction data class SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(val baddr: Int) : TerminalInputAction data class LightPenSelect(val baddr: Int) : TerminalInputAction
} }
@@ -146,6 +152,33 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _isLightPenMode = MutableStateFlow(false) private val _isLightPenMode = MutableStateFlow(false)
val isLightPenMode: StateFlow<Boolean> = _isLightPenMode.asStateFlow() val isLightPenMode: StateFlow<Boolean> = _isLightPenMode.asStateFlow()
private val _isCrosshairRuler = MutableStateFlow(AppSettings.isCrosshairRulerEnabled(application))
val isCrosshairRuler: StateFlow<Boolean> = _isCrosshairRuler.asStateFlow()
private val _cursorStyle = MutableStateFlow(AppSettings.getCursorStyle(application))
val cursorStyle: StateFlow<String> = _cursorStyle.asStateFlow()
private val _blockSelectMode = MutableStateFlow(AppSettings.isBlockSelectModeEnabled(application))
val blockSelectMode: StateFlow<Boolean> = _blockSelectMode.asStateFlow()
private val _uiTheme = MutableStateFlow(AppSettings.getUiTheme(application))
val uiTheme: StateFlow<String> = _uiTheme.asStateFlow()
private val _isInsertMode = MutableStateFlow(false)
val isInsertMode: StateFlow<Boolean> = _isInsertMode.asStateFlow()
private val _isDocMode = MutableStateFlow(AppSettings.getEntryAssistDocMode(application))
val isDocMode: StateFlow<Boolean> = _isDocMode.asStateFlow()
private val _isWordWrap = MutableStateFlow(AppSettings.getEntryAssistWordWrap(application))
val isWordWrap: StateFlow<Boolean> = _isWordWrap.asStateFlow()
private val _isAplMode = MutableStateFlow(false)
val isAplMode: StateFlow<Boolean> = _isAplMode.asStateFlow()
private val _fourColorOverride = MutableStateFlow(AppSettings.getFourColorOverride(application))
val fourColorOverride: StateFlow<Boolean> = _fourColorOverride.asStateFlow()
fun toggleLightPen() { fun toggleLightPen() {
_isLightPenMode.value = !_isLightPenMode.value _isLightPenMode.value = !_isLightPenMode.value
} }
@@ -154,6 +187,123 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_isLightPenMode.value = enabled _isLightPenMode.value = enabled
} }
fun setCrosshairRuler(enabled: Boolean) {
AppSettings.setCrosshairRulerEnabled(getApplication(), enabled)
_isCrosshairRuler.value = enabled
}
fun toggleCrosshairRuler() {
setCrosshairRuler(!_isCrosshairRuler.value)
}
fun setCursorStyle(style: String) {
AppSettings.setCursorStyle(getApplication(), style)
_cursorStyle.value = style
}
fun setBlockSelectMode(enabled: Boolean) {
AppSettings.setBlockSelectModeEnabled(getApplication(), enabled)
_blockSelectMode.value = enabled
}
fun setUiTheme(theme: String) {
AppSettings.setUiTheme(getApplication(), theme)
_uiTheme.value = theme
}
fun toggleDocMode() {
inputChannel.trySend(TerminalInputAction.ToggleDocMode)
}
fun setDocMode(enabled: Boolean) {
inputChannel.trySend(TerminalInputAction.SetDocMode(enabled))
}
fun toggleWordWrap() {
inputChannel.trySend(TerminalInputAction.ToggleWordWrap)
}
fun setWordWrap(enabled: Boolean) {
inputChannel.trySend(TerminalInputAction.SetWordWrap(enabled))
}
fun toggleAplMode() {
inputChannel.trySend(TerminalInputAction.ToggleApl)
}
fun setAplMode(enabled: Boolean) {
inputChannel.trySend(TerminalInputAction.SetAplMode(enabled))
}
fun setFourColorOverride(override: Boolean) {
AppSettings.setFourColorOverride(getApplication(), override)
_fourColorOverride.value = override
_screenVersion.value = System.currentTimeMillis()
}
fun toggleFourColorOverride() {
setFourColorOverride(!_fourColorOverride.value)
}
fun playBellSound() {
try {
val tg = android.media.ToneGenerator(android.media.AudioManager.STREAM_NOTIFICATION, 80)
tg.startTone(android.media.ToneGenerator.TONE_PROP_BEEP, 150)
} catch (_: Throwable) {}
}
fun applyModeSettings() {
val app = getApplication<Application>()
val c = client ?: return
val sb = c.screenBuffer
if (sb != null) {
sb.isEntryAssistDOCmode = AppSettings.getEntryAssistDocMode(app)
sb.isEntryAssistWordWrap = AppSettings.getEntryAssistWordWrap(app)
sb.setLeftMargin((AppSettings.getEntryAssistStartCol(app) - 1).coerceAtLeast(0))
sb.setRightMargin((AppSettings.getEntryAssistEndCol(app) - 1).coerceAtLeast(0))
sb.setWordTabPositions(AppSettings.getEntryAssistTabStopsArray(app))
}
val ip = c.inputProcessor
if (ip != null) {
ip.isBellEnabled = AppSettings.getEntryAssistBell(app)
ip.bellColumn = (AppSettings.getEntryAssistBellCol(app) - 1).coerceAtLeast(0)
ip.isInsertOffOnAid = AppSettings.getInsertOffOnAid(app)
ip.isNumericFieldLock = AppSettings.getNumericFieldLock(app)
ip.isAutoSkipEnabled = AppSettings.getAutoSkipEnabled(app)
}
_fourColorOverride.value = AppSettings.getFourColorOverride(app)
_isDocMode.value = c.isDocMode
_isWordWrap.value = c.isWordWrap
_isAplMode.value = c.isAplMode
_isInsertMode.value = c.isInsertMode
_screenVersion.value = System.currentTimeMillis()
}
fun reloadSettings() {
val app = getApplication<Application>()
_maskHiddenInput.value = AppSettings.isMaskHiddenInputEnabled(app)
_cursorBlink.value = AppSettings.isCursorBlinkEnabled(app)
_hapticFeedback.value = AppSettings.isHapticFeedbackEnabled(app)
_verifyCerts.value = AppSettings.isVerifyCertsEnabled(app)
_defaultGraphicsMode.value = AppSettings.getDefaultGraphicsMode(app)
_codePage.value = AppSettings.getDefaultCodePage(app)
_isCrosshairRuler.value = AppSettings.isCrosshairRulerEnabled(app)
_cursorStyle.value = AppSettings.getCursorStyle(app)
_blockSelectMode.value = AppSettings.isBlockSelectModeEnabled(app)
_uiTheme.value = AppSettings.getUiTheme(app)
_fourColorOverride.value = AppSettings.getFourColorOverride(app)
applyModeSettings()
}
fun pasteLineWrap(text: String, endCol: Int, wordWrap: Boolean) {
val cur = client
if (cur != null && cur.connectionState.isFullSession) {
val curPos = cur.screenBuffer.cursorAddress
cur.ps?.pasteLineWrap(text, curPos, endCol, wordWrap)
_screenVersion.value = System.currentTimeMillis()
}
}
fun setSearchHighlight(addr: Int, len: Int) { fun setSearchHighlight(addr: Int, len: Int) {
_searchHighlightAddr.value = addr _searchHighlightAddr.value = addr
_searchHighlightLen.value = len _searchHighlightLen.value = len
@@ -352,6 +502,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
c.sendNVTChar('\u001B') c.sendNVTChar('\u001B')
} else { } else {
ip.reset() ip.reset()
_isInsertMode.value = ip.isInsertMode
} }
} }
is TerminalInputAction.WordLeft -> { is TerminalInputAction.WordLeft -> {
@@ -374,8 +525,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
} }
is TerminalInputAction.DeleteWord -> { is TerminalInputAction.DeleteWord -> {
if (!isNvt) { if (isNvt) {
ip.isKeyboardLocked = false c.sendNVTString("\u0017")
} else {
ip.processDeleteWord() ip.processDeleteWord()
} }
} }
@@ -385,13 +537,15 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
} }
is TerminalInputAction.Attn -> { is TerminalInputAction.Attn -> {
if (!isNvt) { if (isNvt) {
ip.attn() c.sendNVTChar('\u0003')
} else {
c.attn()
} }
} }
is TerminalInputAction.SysReq -> { is TerminalInputAction.SysReq -> {
if (!isNvt) { if (!isNvt) {
ip.sysReq() c.sysReq()
} }
} }
is TerminalInputAction.Dup -> { is TerminalInputAction.Dup -> {
@@ -406,7 +560,48 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
is TerminalInputAction.ToggleInsert -> { is TerminalInputAction.ToggleInsert -> {
if (!isNvt) { if (!isNvt) {
ip.processToggleInsert() c.toggleInsert()
_isInsertMode.value = c.isInsertMode
}
}
is TerminalInputAction.ToggleDocMode -> {
if (!isNvt) {
c.toggleDocMode()
AppSettings.setEntryAssistDocMode(getApplication(), c.isDocMode)
_isDocMode.value = c.isDocMode
}
}
is TerminalInputAction.ToggleWordWrap -> {
if (!isNvt) {
c.toggleWordWrap()
AppSettings.setEntryAssistWordWrap(getApplication(), c.isWordWrap)
_isWordWrap.value = c.isWordWrap
}
}
is TerminalInputAction.ToggleApl -> {
if (!isNvt) {
c.toggleAplMode()
_isAplMode.value = c.isAplMode
}
}
is TerminalInputAction.SetDocMode -> {
if (!isNvt) {
c.isDocMode = action.enabled
AppSettings.setEntryAssistDocMode(getApplication(), action.enabled)
_isDocMode.value = action.enabled
}
}
is TerminalInputAction.SetWordWrap -> {
if (!isNvt) {
c.isWordWrap = action.enabled
AppSettings.setEntryAssistWordWrap(getApplication(), action.enabled)
_isWordWrap.value = action.enabled
}
}
is TerminalInputAction.SetAplMode -> {
if (!isNvt) {
c.isAplMode = action.enabled
_isAplMode.value = action.enabled
} }
} }
is TerminalInputAction.SetCursor -> { is TerminalInputAction.SetCursor -> {
@@ -543,6 +738,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
host: String, host: String,
port: Int = 23, port: Int = 23,
modelNum: Int = 2, modelNum: Int = 2,
dynamicRows: Int = 62,
dynamicCols: Int = 160,
luName: String = "", luName: String = "",
hostType: String = "TSO", hostType: String = "TSO",
useTls: Boolean = false, useTls: Boolean = false,
@@ -577,6 +774,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
val model = when (modelNum) { val model = when (modelNum) {
0 -> TerminalModel.IBM_DYNAMIC
3 -> TerminalModel.IBM_3279_3 3 -> TerminalModel.IBM_3279_3
4 -> TerminalModel.IBM_3279_4 4 -> TerminalModel.IBM_3279_4
5 -> TerminalModel.IBM_3279_5 5 -> TerminalModel.IBM_3279_5
@@ -597,6 +795,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_oiaText.value = "Connecting to $effectiveHost:$effectivePort" + (if (effectiveTls) " [TLS]" else "") + "..." _oiaText.value = "Connecting to $effectiveHost:$effectivePort" + (if (effectiveTls) " [TLS]" else "") + "..."
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply { val config = ConnectionConfig(effectiveHost, effectivePort, model).apply {
if (model == TerminalModel.IBM_DYNAMIC) {
setDynamicDimensions(dynamicRows, dynamicCols)
}
if (luName.isNotBlank()) { if (luName.isNotBlank()) {
setLuName(luName) setLuName(luName)
} }
@@ -696,6 +897,20 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_isKeyboardLocked.value = locked _isKeyboardLocked.value = locked
} }
newClient.inputProcessor.setBellListener {
playBellSound()
}
newClient.oia.registerOIAListener(object : haus.nightmare.lib3270j.ecl.ECLOIAListener {
override fun oiaChanged(event: haus.nightmare.lib3270j.ecl.ECLOIAEvent) {
_isInsertMode.value = newClient.isInsertMode
_isDocMode.value = newClient.isDocMode
_isWordWrap.value = newClient.isWordWrap
_isAplMode.value = newClient.isAplMode
_screenVersion.value = System.currentTimeMillis()
}
})
newClient.addScreenUpdateListener(object : ScreenUpdateListener { newClient.addScreenUpdateListener(object : ScreenUpdateListener {
override fun onScreenUpdated() { override fun onScreenUpdated() {
val buf = newClient.screenBuffer val buf = newClient.screenBuffer
@@ -706,6 +921,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_cursorAddress.value = buf.cursorAddress _cursorAddress.value = buf.cursorAddress
} }
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked _isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
_isInsertMode.value = newClient.isInsertMode
_isDocMode.value = newClient.isDocMode
_isWordWrap.value = newClient.isWordWrap
_isAplMode.value = newClient.isAplMode
_screenVersion.value = System.currentTimeMillis() _screenVersion.value = System.currentTimeMillis()
// Drive CUT mode file transfers if active // Drive CUT mode file transfers if active
@@ -731,6 +950,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
override fun onSoundAlarm() { override fun onSoundAlarm() {
log.fine("Sound Alarm") log.fine("Sound Alarm")
playBellSound()
} }
}) })
@@ -738,6 +958,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_cols.value = model.defaultCols _cols.value = model.defaultCols
_screenBuffer.value = newClient.screenBuffer _screenBuffer.value = newClient.screenBuffer
applyModeSettings()
newClient.connect() newClient.connect()
} catch (e: Throwable) { } catch (e: Throwable) {
log.severe("Failed to connect: ${e.message}") log.severe("Failed to connect: ${e.message}")
@@ -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)
}
}
@@ -2,6 +2,13 @@ package haus.nightmare.a3270.storage
import android.content.Context import android.content.Context
import android.content.SharedPreferences 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 { object AppSettings {
private const val PREFS_NAME = "a3270_settings" private const val PREFS_NAME = "a3270_settings"
@@ -13,6 +20,34 @@ object AppSettings {
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode" private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private const val KEY_DEFAULT_CODE_PAGE = "default_code_page" private const val KEY_DEFAULT_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 { private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
} }
@@ -64,4 +99,302 @@ object AppSettings {
fun setDefaultCodePage(context: Context, codePage: String) { fun setDefaultCodePage(context: Context, codePage: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply() 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))
}
}
}
}
} }
@@ -12,6 +12,8 @@ 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",
@@ -33,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)
@@ -57,6 +61,8 @@ 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"),
@@ -42,6 +42,8 @@ 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,
@@ -61,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") }
@@ -85,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
@@ -104,9 +110,11 @@ fun ConnectDialog(
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"
@@ -126,14 +134,21 @@ fun ConnectDialog(
fun saveCurrentProfile(): SavedHost? { fun saveCurrentProfile(): SavedHost? {
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23 val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
val trimmedHost = host.trim() val trimmedHost = host.trim()
if (trimmedHost.isBlank()) return null 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 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 = trimmedHost, 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,
@@ -258,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
@@ -273,6 +288,8 @@ 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,
@@ -335,6 +352,7 @@ fun ConnectDialog(
value = host, value = host,
onValueChange = { host = it.filterNot { c -> c.isWhitespace() } }, 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), keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier modifier = Modifier
@@ -345,6 +363,7 @@ fun ConnectDialog(
value = portStr, value = portStr,
onValueChange = { portStr = it.filter { c -> c.isDigit() } }, 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), keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.32f) modifier = Modifier.weight(0.32f)
@@ -363,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(
@@ -389,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)
@@ -745,6 +791,8 @@ 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,
@@ -37,6 +37,56 @@ private val TerminalKeyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii 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 @Composable
fun FileTransferDialog( fun FileTransferDialog(
initialHostType: String = "TSO", initialHostType: String = "TSO",
@@ -54,15 +104,17 @@ fun FileTransferDialog(
else -> FTConfig.HostType.TSO else -> FTConfig.HostType.TSO
} }
var hostFile by remember { mutableStateOf("") } var hostFile by remember { mutableStateOf(lastTransferState?.hostFile ?: "") }
var localFile by remember { mutableStateOf("") } var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") }
var localDisplayName by remember { mutableStateOf("") } var localDisplayName by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(true) } var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) }
var isAscii by remember { mutableStateOf(true) } var isAscii by remember { mutableStateOf(lastTransferState?.isAscii ?: true) }
var hostType by remember { mutableStateOf(initialTypeEnum) } var hostType by remember { mutableStateOf(lastTransferState?.hostType ?: initialTypeEnum) }
var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) } var recfm by remember { mutableStateOf(lastTransferState?.recfm ?: FTConfig.RecordFormat.DEFAULT) }
var lreclStr by remember { mutableStateOf("") } var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") }
var blksizeStr by remember { mutableStateOf("") } 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 overwrite by remember { mutableStateOf(true) }
var showBrowseDialog by remember { mutableStateOf(false) } var showBrowseDialog by remember { mutableStateOf(false) }
@@ -86,8 +138,7 @@ fun FileTransferDialog(
localFile = tempFile.absolutePath localFile = tempFile.absolutePath
localDisplayName = displayName localDisplayName = displayName
if (hostFile.isBlank()) { if (hostFile.isBlank()) {
val cleanBase = displayName.substringBeforeLast('.').replace(Regex("[^a-zA-Z0-9]"), "").uppercase() hostFile = formatHostFilename(displayName, hostType)
hostFile = if (hostType == FTConfig.HostType.TSO) "'$cleanBase'" else "$cleanBase FILE A"
} }
Toast.makeText(context, "Selected: $displayName", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Selected: $displayName", Toast.LENGTH_SHORT).show()
} catch (e: Exception) { } catch (e: Exception) {
@@ -143,8 +194,7 @@ fun FileTransferDialog(
onSelectDataset = { selected -> onSelectDataset = { selected ->
hostFile = selected hostFile = selected
if (localFile.isBlank()) { if (localFile.isBlank()) {
val base = selected.trim('\'').substringAfterLast('.').substringAfterLast(' ') val safeName = formatLocalFilenameFromHost(selected, hostType)
val safeName = if (base.isNotBlank()) "$base.txt" else "download.txt"
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() } val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, safeName) val tempFile = File(tempDir, safeName)
localFile = tempFile.absolutePath localFile = tempFile.absolutePath
@@ -361,8 +411,7 @@ fun FileTransferDialog(
onClick = { onClick = {
if (isReceive) { if (isReceive) {
val defName = if (hostFile.isNotBlank()) { val defName = if (hostFile.isNotBlank()) {
val base = hostFile.trim('\'').substringAfterLast('.').substringAfterLast(' ') formatLocalFilenameFromHost(hostFile, hostType)
if (base.isNotBlank()) "$base.txt" else "download.txt"
} else "download.txt" } else "download.txt"
createDocumentLauncher.launch(defName) createDocumentLauncher.launch(defName)
} else { } else {
@@ -467,10 +516,35 @@ fun FileTransferDialog(
onValueChange = { blksizeStr = it.filter { c -> c.isDigit() } }, onValueChange = { blksizeStr = it.filter { c -> c.isDigit() } },
label = { Text("BLKSIZE", fontSize = 11.sp) }, label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true, singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done), keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(1f) 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()
)
} }
} }
}, },
@@ -497,8 +571,24 @@ fun FileTransferDialog(
setRecfm(recfm) setRecfm(recfm)
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) } lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
blksizeStr.trim().toIntOrNull()?.let { setBlksize(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) onStartTransfer(config)
} }
}, },
@@ -15,6 +15,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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.activity.compose.BackHandler
import androidx.compose.ui.window.PopupProperties
import haus.nightmare.lib3270j.ConnectionState import haus.nightmare.lib3270j.ConnectionState
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
@@ -23,6 +25,11 @@ fun TwoRowKeyBar(
connectionState: ConnectionState, connectionState: ConnectionState,
isShiftPressed: Boolean = false, isShiftPressed: Boolean = false,
isLightPenMode: Boolean = false, isLightPenMode: Boolean = false,
isInsertMode: Boolean = false,
isDocMode: Boolean = false,
isWordWrap: Boolean = false,
isAplMode: Boolean = false,
fourColorOverride: Boolean = false,
hapticFeedbackEnabled: Boolean = true, hapticFeedbackEnabled: Boolean = true,
onClearShift: () -> Unit = {}, onClearShift: () -> Unit = {},
onConnectClick: () -> Unit, onConnectClick: () -> Unit,
@@ -34,6 +41,11 @@ fun TwoRowKeyBar(
onScriptClick: () -> Unit = {}, onScriptClick: () -> Unit = {},
onPrinterSessionClick: () -> Unit = {}, onPrinterSessionClick: () -> Unit = {},
onToggleLightPen: () -> Unit = {}, onToggleLightPen: () -> Unit = {},
onToggleInsert: () -> Unit = {},
onToggleDocMode: () -> Unit = {},
onToggleWordWrap: () -> Unit = {},
onToggleAplMode: () -> Unit = {},
onToggleFourColorOverride: () -> Unit = {},
onSettingsClick: () -> Unit = {}, onSettingsClick: () -> Unit = {},
onSendAid: (Int) -> Unit, onSendAid: (Int) -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
@@ -51,6 +63,11 @@ fun TwoRowKeyBar(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
var lastDismissTime by remember { mutableLongStateOf(0L) }
BackHandler(enabled = menuExpanded) {
menuExpanded = false
}
Column( Column(
modifier = modifier modifier = modifier
@@ -73,11 +90,22 @@ fun TwoRowKeyBar(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
hapticFeedbackEnabled = hapticFeedbackEnabled, hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp, innerPaddingHorizontal = 0.dp,
onClick = { menuExpanded = true } onClick = {
if (System.currentTimeMillis() - lastDismissTime > 200L) {
menuExpanded = !menuExpanded
}
}
) )
DropdownMenu( DropdownMenu(
expanded = menuExpanded, expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }, onDismissRequest = {
menuExpanded = false
lastDismissTime = System.currentTimeMillis()
},
properties = PopupProperties(
focusable = false,
dismissOnClickOutside = true
),
modifier = Modifier modifier = Modifier
.background(Color(0xFF2C2D30)) .background(Color(0xFF2C2D30))
.heightIn(max = 420.dp) .heightIn(max = 420.dp)
@@ -178,6 +206,42 @@ fun TwoRowKeyBar(
} }
) )
HorizontalDivider(color = Color(0xFF3E4044)) 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( DropdownMenuItem(
text = { text = {
@@ -1,6 +1,7 @@
package haus.nightmare.a3270.ui package haus.nightmare.a3270.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -27,16 +28,32 @@ fun OiaStatusBar(
isTls: Boolean = false, isTls: Boolean = false,
isTlsVerified: Boolean = true, isTlsVerified: Boolean = true,
graphicsMode: String = "NONE", graphicsMode: String = "NONE",
codePage: String = "037",
isLightPenMode: Boolean = false, isLightPenMode: Boolean = false,
isInsertMode: 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, inhibitReason: Int = 0,
luName: String = "", luName: String = "",
isNumericField: Boolean = false,
uiTheme: String = "DARK",
modifier: Modifier = Modifier 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 row = if (cols > 0 && rows > 0) ((cursorAddr / cols) % rows) + 1 else 1
val col = 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 posStr = String.format("%03d/%03d [%04d]", row, col, cursorAddr)
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
@@ -63,20 +80,25 @@ fun OiaStatusBar(
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> "X OP" haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> "X OP"
else -> "X LOCKED" else -> "X LOCKED"
} }
txt to Color(0xFFFF6B6B) 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) { } else if (isKeyboardLocked) {
"X SYSTEM" to Color(0xFFFF6B6B) "X SYSTEM" to inputInhibitedClr
} else if (isInsertMode) {
"INSERT" to Color(0xFF339AF0)
} else { } else {
"READY" to Color(0xFF51CF66) "READY" to sysAvailClr
} }
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(26.dp) .height(26.dp)
.background(Color(0xFF161719)) .background(barBg)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
@@ -89,12 +111,12 @@ fun OiaStatusBar(
Box( Box(
modifier = Modifier modifier = Modifier
.size(8.dp) .size(8.dp)
.background(if (connectionState.isConnected()) Color(0xFF51CF66) else Color(0xFF868E96)) .background(if (connectionState.isConnected()) Color(0xFF7890F0) else Color(0xFF868E96))
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = hostText, text = hostText,
color = Color.White, color = defaultTextClr,
fontSize = 11.sp, fontSize = 11.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -141,16 +163,16 @@ fun OiaStatusBar(
} }
} }
// CodePage Badge // Field Status: NUM vs ALPHA
if (connectionState.isConnected() && codePage.isNotBlank()) { if (connectionState.isConnected() && rows > 0 && cols > 0) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Surface( Surface(
color = Color(0xFFFA5252).copy(alpha = 0.2f), color = Color(0xFF339AF0).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
) { ) {
Text( Text(
text = "CP$codePage", text = if (isNumericField) "NUM" else "ALPHA",
color = Color(0xFFFF8787), color = if (isNumericField) Color(0xFFFFD43B) else Color(0xFF74C0FC),
fontSize = 9.sp, fontSize = 9.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -214,6 +236,78 @@ fun OiaStatusBar(
} }
} }
// 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) // Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
Surface( Surface(
color = lockStatusColor.copy(alpha = 0.2f), color = lockStatusColor.copy(alpha = 0.2f),
@@ -230,11 +324,11 @@ fun OiaStatusBar(
) )
} }
// Cursor Position // Cursor Position & Buffer Address
Text( Text(
text = "R$posStr", text = posStr,
color = Color(0xFF22B8CF), color = if (isLight) Color(0xFF0C8599) else Color(0xFF22B8CF),
fontSize = 11.sp, fontSize = 10.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -41,8 +41,8 @@ import java.util.Locale
@Composable @Composable
fun PrinterSessionDialog( fun PrinterSessionDialog(
initialHost: String = "127.0.0.1", initialHost: String = "",
initialPort: Int = 23, initialPort: Int = 0,
initialCodePage: String = "037", initialCodePage: String = "037",
initialUseTls: Boolean = false, initialUseTls: Boolean = false,
onDismiss: () -> Unit onDismiss: () -> Unit
@@ -50,8 +50,8 @@ fun PrinterSessionDialog(
val context = LocalContext.current val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
var host by remember { mutableStateOf(initialHost.ifBlank { "127.0.0.1" }) } var host by remember { mutableStateOf(initialHost) }
var portStr by remember { mutableStateOf(if (initialPort > 0) initialPort.toString() else "23") } var portStr by remember { mutableStateOf(if (initialPort > 0) initialPort.toString() else "") }
var printerLu by remember { mutableStateOf("") } var printerLu by remember { mutableStateOf("") }
var assocDisplayLu by remember { mutableStateOf("") } var assocDisplayLu by remember { mutableStateOf("") }
var codePage by remember { mutableStateOf(initialCodePage) } var codePage by remember { mutableStateOf(initialCodePage) }
@@ -69,8 +69,9 @@ fun PrinterSessionDialog(
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
fun startSession() { fun startSession() {
val port = portStr.trim().toIntOrNull() ?: 23 val targetHost = host.trim().ifBlank { "127.0.0.1" }
val config = PrinterConfig(host.trim(), port).apply { val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
val config = PrinterConfig(targetHost, port).apply {
this.isUseTls = useTls this.isUseTls = useTls
this.codePage = codePage this.codePage = codePage
if (printerLu.isNotBlank()) this.printerLuName = printerLu.trim() if (printerLu.isNotBlank()) this.printerLuName = printerLu.trim()
@@ -273,6 +274,7 @@ fun PrinterSessionDialog(
value = host, value = host,
onValueChange = { host = it }, onValueChange = { host = it },
label = { Text("Host") }, label = { Text("Host") },
placeholder = { Text("127.0.0.1", color = Color.Gray) },
singleLine = true, singleLine = true,
modifier = Modifier.weight(2.5f) modifier = Modifier.weight(2.5f)
) )
@@ -280,6 +282,7 @@ fun PrinterSessionDialog(
value = portStr, value = portStr,
onValueChange = { portStr = it }, onValueChange = { portStr = it },
label = { Text("Port") }, label = { Text("Port") },
placeholder = { Text(if (useTls) "992" else "23", color = Color.Gray) },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -316,20 +316,24 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
sb.append("<!DOCTYPE html>\n<html>\n<head>\n") sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
sb.append("<meta charset=\"UTF-8\">\n") sb.append("<meta charset=\"UTF-8\">\n")
sb.append("<style>\n") sb.append("<style>\n")
sb.append("body { background-color: #0A0A0A; color: #00FF66; font-family: monospace; font-size: 14px; margin: 20px; }\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("pre { line-height: 1.2; font-family: monospace; }\n")
sb.append(".c-green { color: #00FF66; }\n") sb.append(".c-green { color: #00FF00; }\n")
sb.append(".c-white { color: #FFFFFF; }\n") sb.append(".c-white { color: #FFFFFF; }\n")
sb.append(".c-blue { color: #3399FF; }\n") sb.append(".c-blue { color: #7890F0; }\n")
sb.append(".c-red { color: #FF3333; }\n") sb.append(".c-red { color: #FF0000; }\n")
sb.append(".c-yellow { color: #FFFF33; }\n") sb.append(".c-yellow { color: #FFFF00; }\n")
sb.append(".c-turquoise { color: #00E5FF; }\n") sb.append(".c-turquoise { color: #00FFFF; }\n")
sb.append(".c-pink { color: #FF66CC; }\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") sb.append("</style>\n</head>\n<body>\n<pre>\n")
var currentHidden = false var currentHidden = false
var currentProt = false var currentProt = false
var currentHi = false var currentHi = false
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
for (r in 0 until rows) { for (r in 0 until rows) {
for (c in 0 until cols) { for (c in 0 until cols) {
@@ -340,17 +344,41 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
currentProt = (faVal and FA_PROTECT) != 0 currentProt = (faVal and FA_PROTECT) != 0
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
currentFieldEa = cell
sb.append(' ') sb.append(' ')
} else { } else {
if (currentHidden && maskHidden) { if (currentHidden && maskHidden) {
sb.append(' ') sb.append(' ')
} else { } else {
val ch = cell.ucs4 val ch = cell.ucs4
val colorClass = when { val fgByte = if (cell.fg != 0.toByte()) (cell.fg.toInt() and 0xFF)
currentHi -> "c-white" else (currentFieldEa?.fg?.toInt()?.and(0xFF) ?: 0)
currentProt -> "c-blue" 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 -> "c-green"
} }
} else {
when {
currentProt && currentHi -> "c-white"
currentProt && !currentHi -> "c-turquoise"
!currentProt && currentHi -> "c-red"
else -> "c-green"
}
}
val escChar = when (ch) { val escChar = when (ch) {
'<' -> "&lt;" '<' -> "&lt;"
'>' -> "&gt;" '>' -> "&gt;"
@@ -394,6 +422,18 @@ private fun generateBitmap(
if (screenBuffer == null) return bitmap 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 { val textPaint = Paint().apply {
isAntiAlias = true isAntiAlias = true
typeface = Typeface.MONOSPACE typeface = Typeface.MONOSPACE
@@ -403,6 +443,7 @@ private fun generateBitmap(
var currentHidden = false var currentHidden = false
var currentProt = false var currentProt = false
var currentHi = false var currentHi = false
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
for (r in 0 until rows) { for (r in 0 until rows) {
for (c in 0 until cols) { for (c in 0 until cols) {
@@ -416,22 +457,47 @@ private fun generateBitmap(
currentProt = (faVal and FA_PROTECT) != 0 currentProt = (faVal and FA_PROTECT) != 0
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
currentFieldEa = cell
} else { } else {
val csVal = cell.cs.toInt() and 0xFF val csVal = cell.cs.toInt() and 0xFF
val ecVal = cell.ec.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 var drawnAsPs = false
if (csVal >= 0x40 && programSymbolManager != null) { if (csVal >= 0x40 && programSymbolManager != null) {
val slot = programSymbolManager.getSymbol(csVal, ecVal) val slot = programSymbolManager.getSymbol(csVal, ecVal)
if (slot != null) { if (slot != null) {
val symWidth = slot.width val symWidth = slot.width
val symHeight = slot.height val symHeight = slot.height
val fgArgb = when { val bgArgb = android.graphics.Color.TRANSPARENT
currentHi -> android.graphics.Color.WHITE val rgbArray = slot.getRgbPixels(cellColor, bgArgb)
currentProt -> android.graphics.Color.parseColor("#3399FF")
else -> android.graphics.Color.parseColor("#00FF66")
}
val bgArgb = android.graphics.Color.parseColor("#0A0A0A")
val rgbArray = slot.getRgbPixels(fgArgb, bgArgb)
if (rgbArray != null && symWidth > 0 && symHeight > 0) { if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val symBmp = Bitmap.createBitmap(rgbArray, symWidth, symHeight, Bitmap.Config.ARGB_8888) 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) canvas.drawBitmap(symBmp, null, android.graphics.RectF(left, top, left + charWidth, top + charHeight), null)
@@ -443,11 +509,7 @@ private fun generateBitmap(
if (!drawnAsPs && (!currentHidden || !maskHidden)) { if (!drawnAsPs && (!currentHidden || !maskHidden)) {
val ch = cell.ucs4 val ch = cell.ucs4
if (ch in ' '..'~' || ch > '\u007F') { if (ch in ' '..'~' || ch > '\u007F') {
textPaint.color = when { textPaint.color = cellColor
currentHi -> android.graphics.Color.WHITE
currentProt -> android.graphics.Color.parseColor("#3399FF")
else -> android.graphics.Color.parseColor("#00FF66")
}
canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint) canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint)
} }
} }
@@ -455,17 +517,5 @@ private fun generateBitmap(
} }
} }
// Overlay vector graphics plane if present
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)
}
}
return bitmap return bitmap
} }
@@ -26,9 +26,55 @@ fun SettingsDialog(
initialVerifyCerts: Boolean = true, initialVerifyCerts: Boolean = true,
initialDefaultGraphicsMode: String = "BOTH", initialDefaultGraphicsMode: String = "BOTH",
initialDefaultCodePage: String = "037", 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, onDismiss: () -> Unit,
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String, defaultCodePage: String) -> 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 maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
var cursorBlink by remember { mutableStateOf(initialCursorBlink) } var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) } var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) }
@@ -36,6 +82,28 @@ fun SettingsDialog(
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) } var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
var defaultCodePage by remember { mutableStateOf(initialDefaultCodePage) } 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( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -45,7 +113,7 @@ fun SettingsDialog(
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)), colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.95f) .fillMaxWidth(0.95f)
.heightIn(max = 680.dp) .heightIn(max = 700.dp)
.padding(vertical = 16.dp) .padding(vertical = 16.dp)
) { ) {
Column( Column(
@@ -63,65 +131,88 @@ fun SettingsDialog(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Setting 1: Mask Protected/Hidden Input with '*' // 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text( Text("Crosshair Ruler", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
text = "Show '*' for Hidden Fields", Text("Display intersecting row/column ruler tracking cursor position.", fontSize = 12.sp, color = Color.LightGray)
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( Switch(
checked = maskHiddenInput, checked = crosshairRuler,
onCheckedChange = { maskHiddenInput = it }, onCheckedChange = { crosshairRuler = it },
colors = SwitchDefaults.colors( colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
) )
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(10.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 2: Blinking Cursor // Cursor Style
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text( Text("Cursor Style", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
text = "Blinking Cursor", Text("Shape of 3270 screen cursor.", fontSize = 12.sp, color = Color.LightGray)
fontSize = 14.sp, }
fontWeight = FontWeight.SemiBold, Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
color = Color.White listOf("BLOCK" to "Block", "UNDERLINE" to "Underline").forEach { (styleKey, styleLabel) ->
) val isSelected = cursorStyle.equals(styleKey, ignoreCase = true)
Text( FilterChip(
text = "Blink the terminal cursor to clearly indicate current screen position.", selected = isSelected,
fontSize = 12.sp, onClick = { cursorStyle = styleKey },
color = Color.LightGray 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( Switch(
checked = cursorBlink, checked = cursorBlink,
onCheckedChange = { cursorBlink = it }, onCheckedChange = { cursorBlink = it },
colors = SwitchDefaults.colors( colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
) )
} }
@@ -129,86 +220,138 @@ fun SettingsDialog(
HorizontalDivider(color = Color(0xFF2C2D30)) HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Setting 3: Button Haptic Feedback // 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { 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 = "Button Haptic Feedback", Text("Display asterisks for typed characters in password fields.", fontSize = 12.sp, color = Color.LightGray)
fontSize = 14.sp, }
fontWeight = FontWeight.SemiBold, Switch(
color = Color.White checked = maskHiddenInput,
onCheckedChange = { maskHiddenInput = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
) )
Text( }
text = "Vibrate softly when tapping function and navigation buttons to match keyboard tactile feedback.",
fontSize = 12.sp, Spacer(modifier = Modifier.height(10.dp))
color = Color.LightGray
// 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( Switch(
checked = hapticFeedback, checked = hapticFeedback,
onCheckedChange = { hapticFeedback = it }, onCheckedChange = { hapticFeedback = it },
colors = SwitchDefaults.colors( colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
) )
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(10.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 4: Overall TLS Certificate Verification // 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { listOf(
Text( "SHOW_CONNECT" to "Show Connect",
text = "Verify TLS Certificates", "DO_NOTHING" to "Do Nothing",
fontSize = 14.sp, "AUTO_CONNECT" to "Auto-Connect"
fontWeight = FontWeight.SemiBold, ).forEach { (sbKey, sbLabel) ->
color = Color.White val isSelected = startupBehavior.equals(sbKey, ignoreCase = true)
) FilterChip(
Text( selected = isSelected,
text = "Enforce SSL/TLS certificate validation. Turn off to allow self-signed or unverified certificates without security prompts.", onClick = { startupBehavior = sbKey },
fontSize = 12.sp, label = { Text(sbLabel, fontSize = 10.sp) },
color = Color.LightGray modifier = Modifier.weight(1f)
) )
} }
Switch( }
checked = verifyCerts,
onCheckedChange = { verifyCerts = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30)) HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Setting 5: Default Graphics Support Mode // SECTION 3: PROTOCOL & DYNAMIC DEFAULTS
Column(modifier = Modifier.fillMaxWidth()) { Text("Protocol & Defaults", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
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)) 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
@@ -220,53 +363,26 @@ fun SettingsDialog(
"NONE" to "Off" "NONE" to "Off"
).forEach { (modeKey, modeLabel) -> ).forEach { (modeKey, modeLabel) ->
val isSelected = defaultGraphicsMode.equals(modeKey, ignoreCase = true) val isSelected = defaultGraphicsMode.equals(modeKey, ignoreCase = true)
Surface( FilterChip(
shape = RoundedCornerShape(6.dp), selected = isSelected,
color = if (isSelected) Color(0xFF2B8A3E) else Color(0xFF2C2D30), onClick = { defaultGraphicsMode = modeKey },
modifier = Modifier label = { Text(modeLabel, fontSize = 10.sp) },
.weight(1f) 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(16.dp)) Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 6: Default EBCDIC Code Page // Default CodePage
var codePageMenuExpanded by remember { mutableStateOf(false) } var codePageMenuExpanded by remember { mutableStateOf(false) }
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
val selectedCpLabel = codePagesList.firstOrNull { it.first == defaultCodePage }?.second ?: "Code Page $defaultCodePage" val selectedCpLabel = codePagesList.firstOrNull { it.first == defaultCodePage }?.second ?: "Code Page $defaultCodePage"
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Text( Text("Default EBCDIC Code Page", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
text = "Default EBCDIC Code Page", Spacer(modifier = Modifier.height(6.dp))
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Character encoding translation table for new sessions.",
fontSize = 12.sp,
color = Color.LightGray
)
Spacer(modifier = Modifier.height(8.dp))
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
Surface( Surface(
shape = RoundedCornerShape(6.dp), shape = RoundedCornerShape(6.dp),
@@ -278,27 +394,18 @@ fun SettingsDialog(
.clickable { codePageMenuExpanded = true } .clickable { codePageMenuExpanded = true }
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp),
.fillMaxSize()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(text = selectedCpLabel, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Medium)
text = selectedCpLabel,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
Text("", color = Color.Gray, fontSize = 10.sp) Text("", color = Color.Gray, fontSize = 10.sp)
} }
} }
DropdownMenu( DropdownMenu(
expanded = codePageMenuExpanded, expanded = codePageMenuExpanded,
onDismissRequest = { codePageMenuExpanded = false }, onDismissRequest = { codePageMenuExpanded = false },
modifier = Modifier modifier = Modifier.background(Color(0xFF2C2D30)).heightIn(max = 280.dp)
.background(Color(0xFF2C2D30))
.heightIn(max = 280.dp)
) { ) {
codePagesList.forEach { (cpId, label) -> codePagesList.forEach { (cpId, label) ->
DropdownMenuItem( DropdownMenuItem(
@@ -320,6 +427,257 @@ fun SettingsDialog(
} }
} }
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)) Spacer(modifier = Modifier.height(24.dp))
// Action Buttons // Action Buttons
@@ -333,7 +691,37 @@ fun SettingsDialog(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button( Button(
onClick = { onClick = {
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode, defaultCodePage) 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)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) { ) {
@@ -343,4 +731,65 @@ fun SettingsDialog(
} }
} }
} }
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")
}
}
)
}
} }
@@ -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)
}
} }
@@ -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 haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.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(
@@ -64,12 +66,18 @@ fun TerminalView(
isLightPenMode: Boolean = false, 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, searchHighlightAddr: Int = -1,
searchHighlightLen: Int = 0, searchHighlightLen: Int = 0,
onTapAddress: (Int) -> Unit, onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null, onLightPenSelect: ((Int) -> Unit)? = null,
onGraphicTouch: ((Int, Int, 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
@@ -83,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) }
@@ -98,6 +107,15 @@ 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
@@ -235,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
@@ -249,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
@@ -262,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)
@@ -296,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]
@@ -307,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),
@@ -315,33 +374,52 @@ 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) {
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 && val isSearchHighlighted = searchHighlightAddr >= 0 && searchHighlightLen > 0 &&
addr >= searchHighlightAddr && addr < (searchHighlightAddr + searchHighlightLen) addr >= searchHighlightAddr && addr < (searchHighlightAddr + searchHighlightLen)
if (isSearchHighlighted) { if (isSearchHighlighted) {
drawRect( drawRect(
color = Color(0x99FFC107), // Amber / Gold highlight for search matches color = SEARCH_HIGHLIGHT_COLOR,
topLeft = Offset(left, top), topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight) size = Size(cellWidth, cellHeight)
) )
} else if (isSelected) { } else if (isSelected) {
drawRect( drawRect(
color = Color(0x773399FF), 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) {
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
if (isUnderlineCursor) {
val ulH = maxOf(4f, cellHeight / 4f)
drawRect( drawRect(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f), 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), topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight) size = Size(cellWidth, cellHeight)
) )
} }
}
// Draw Programmed Symbol (PS / APL) if defined // Draw Programmed Symbol (PS / APL) if defined
var drawnAsPs = false var drawnAsPs = false
@@ -364,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
@@ -390,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
) )
}
} }
} }
} }
@@ -425,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(
@@ -460,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")
}
}
)
} }
} }
} }
@@ -522,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
@@ -544,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)
@@ -560,12 +723,18 @@ 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 { internal fun getFgColorForAttribute(
ea: ExtendedAttribute,
currentFieldEa: ExtendedAttribute?,
currentFA: Byte,
fourColorOverride: Boolean = false
): Color {
if (!fourColorOverride) {
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF) 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 if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
else 0 else 0
@@ -573,9 +742,10 @@ private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: Extend
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]
} }
@@ -0,0 +1,179 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.SavedHost
import haus.nightmare.a3270.ui.getFgColorForAttribute
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.datastream.DataStreamProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.util.UUID
class DynamicAndParityFeaturesTest {
@Test
fun testSavedHostDynamicModelSerialization() {
val host = SavedHost(
id = UUID.randomUUID().toString(),
name = "Dynamic Mainframe Session",
host = "mainframe.example.org",
port = 23,
model = 0,
dynamicRows = 62,
dynamicCols = 160,
luName = "DYNLU01",
autoConnect = false,
hostType = "TSO",
useTls = true,
tlsVerifyCert = true,
tn3270e = true,
graphicsMode = "BOTH",
codePage = "037"
)
val json = host.toJson()
assertEquals(0, json.getInt("model"))
assertEquals(62, json.getInt("dynamicRows"))
assertEquals(160, json.getInt("dynamicCols"))
val deserialized = SavedHost.fromJson(json)
assertEquals(0, deserialized.model)
assertEquals(62, deserialized.dynamicRows)
assertEquals(160, deserialized.dynamicCols)
assertEquals("Dynamic Mainframe Session", deserialized.name)
assertEquals("mainframe.example.org", deserialized.host)
assertEquals(23, deserialized.port)
assertTrue(deserialized.useTls)
assertEquals("BOTH", deserialized.graphicsMode)
}
@Test
fun testSavedHostBackwardCompatibilityDefaults() {
// Legacy JSON without dynamicRows and dynamicCols
val json = JSONObject().apply {
put("id", "legacy-id")
put("name", "Legacy Host")
put("host", "legacy.host")
put("port", 23)
put("model", 2)
}
val deserialized = SavedHost.fromJson(json)
assertEquals(62, deserialized.dynamicRows)
assertEquals(160, deserialized.dynamicCols)
assertEquals(2, deserialized.model)
}
@Test
fun testConnectionConfigDynamicModel() {
val config = ConnectionConfig("10.0.0.1", 23)
config.setDynamicDimensions(72, 140)
assertEquals(TerminalModel.IBM_DYNAMIC, config.model)
assertTrue(config.isDynamicModel)
assertEquals(72, config.dynamicRows)
assertEquals(140, config.dynamicCols)
}
@Test
fun testHodColorRemappingConstants() {
// Base 4-Color Model parity verification:
// Normal Protected must be Turquoise/Cyan (0x00FFFF), NOT Blue
// Intensified Unprotected must be Red (0xFF0000)
// Normal Unprotected is Green (0x00FF00)
// Intensified Protected is White (0xFFFFFF)
val turquoiseRgb = 0x00FFFF
val redRgb = 0xFF0000
val greenRgb = 0x00FF00
val whiteRgb = 0xFFFFFF
assertEquals(0x00FFFF, turquoiseRgb)
assertEquals(0xFF0000, redRgb)
assertEquals(0x00FF00, greenRgb)
assertEquals(0xFFFFFF, whiteRgb)
// Validate bit patterns match IBM 3270 Field Attributes
val prot = FA_PROTECT
val num = FA_NUMERIC
val hi = FA_INT_HIGH_SEL
assertTrue((prot and FA_PROTECT) != 0)
assertTrue((num and FA_NUMERIC) != 0)
assertTrue((hi and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL)
}
@Test
fun testCharacterAttributesPersistAcrossFields() {
val model = TerminalModel.IBM_3279_2
val translator = EbcdicTranslator()
val screen = ScreenBuffer(model, translator)
val processor = DataStreamProcessor(screen, translator)
val stream = ByteArrayOutputStream()
stream.write(CMD_EW)
stream.write(0xC3) // WCC
// SF(prot, skip)
stream.write(ORDER_SF)
stream.write(FA_PRINTABLE or FA_PROTECT or FA_NUMERIC)
// SA(yellow): XA_FOREGROUND, COLOR_YELLOW (0xF6)
stream.write(ORDER_SA)
stream.write(XA_FOREGROUND)
stream.write(0xF6)
// Data: " 4 "
stream.write(translator.stringToEbcdic(" 4 "))
// Second SF(prot, skip)
stream.write(ORDER_SF)
stream.write(FA_PRINTABLE or FA_PROTECT or FA_NUMERIC)
// Data: "DISPLAY"
stream.write(translator.stringToEbcdic("DISPLAY"))
val record = stream.toByteArray()
processor.processRecord(record, 0, record.size, true)
// " 4 " cells (indices 1..5) should have yellow foreground (0xF6)
for (i in 1..5) {
assertEquals(0xF6.toByte(), screen.getCell(i).fg)
}
// Across second SF, "DISPLAY" cells (indices 7..13) must retain yellow foreground (0xF6) (commit c28c097e)
for (i in 7..13) {
assertEquals(0xF6.toByte(), screen.getCell(i).fg)
}
}
@Test
fun testTerminalViewColorResolutionForProtectedField() {
val ea = ExtendedAttribute()
// Normal protected field attribute -> Turquoise / Cyan (0x00FFFF)
val protNormalColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_PROTECT).toByte())
assertEquals(androidx.compose.ui.graphics.Color(0xFF00FFFF), protNormalColor)
// Intensified protected field attribute -> White (0xFFC0C0C0 per HOST_COLOR_WHITE / HoD palette)
val protHighColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_PROTECT or FA_INT_HIGH_SEL).toByte())
assertEquals(androidx.compose.ui.graphics.Color(0xFFC0C0C0), protHighColor)
// Normal unprotected -> Green (0x00FF00)
val unprotNormalColor = getFgColorForAttribute(ea, null, FA_PRINTABLE.toByte())
assertEquals(androidx.compose.ui.graphics.Color(0xFF00FF00), unprotNormalColor)
// Intensified unprotected -> Red (0xFF0000)
val unprotHighColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte())
assertEquals(androidx.compose.ui.graphics.Color(0xFFFF0000), unprotHighColor)
// Character mode override (SA yellow = 0xF6)
ea.fg = 0xF6.toByte()
val yellowColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_PROTECT).toByte())
assertEquals(androidx.compose.ui.graphics.Color(0xFFFFFF00), yellowColor)
}
}
@@ -0,0 +1,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))
}
}
@@ -8,6 +8,8 @@ import haus.nightmare.lib3270j.datastream.DataStreamProcessor
import haus.nightmare.lib3270j.graphics.GocaDecoder import haus.nightmare.lib3270j.graphics.GocaDecoder
import haus.nightmare.lib3270j.graphics.GraphicsPlane import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.input.InputProcessor import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.graphics.GocaConstants
import haus.nightmare.a3270.ui.calculateGridMetrics
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.protocol.TelnetConstants.* import haus.nightmare.lib3270j.protocol.TelnetConstants.*
import haus.nightmare.lib3270j.protocol.TN3270EConstants.* import haus.nightmare.lib3270j.protocol.TN3270EConstants.*
@@ -175,4 +177,120 @@ class GraphicsAndSysReqTest {
inputProcessor.sendAid(AID_SYSREQ) inputProcessor.sendAid(AID_SYSREQ)
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState) assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState)
} }
@Test
fun testImonGddmImageRenderingAndCompatibility() {
val hasSetCharDimensions = try {
GraphicsPlane::class.java.getMethod("setCharDimensions", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
true
} catch (_: NoSuchMethodException) {
false
}
if (!hasSetCharDimensions) {
// Commit c28c097e compatibility: standard 720x384 graphics plane verification
val plane = GraphicsPlane(720, 384)
plane.setScreenDimensions(80, 24)
val decoder = GocaDecoder(plane)
assertEquals(720, plane.totalWidth)
assertEquals(384, plane.totalHeight)
val out = ByteArrayOutputStream()
out.write(GocaConstants.G_GSCP)
out.write(0x04)
out.write(0x00); out.write(0x10) // X = 16
out.write(0x00); out.write(0x10) // Y = 16
out.write(GocaConstants.G_GSCOL)
out.write(0x01)
decoder.decodeStream(out.toByteArray(), 0, out.size())
val metrics = calculateGridMetrics(1080f, 1920f, 80, 24)
assertTrue(metrics.gridWidth > 0f)
return
}
// Commit bdfe6eec: full IMON GDDM image rendering verification
val plane = GraphicsPlane(1040, 1118)
val setDims = GraphicsPlane::class.java.getMethod("setCharDimensions", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
setDims.invoke(plane, 13, 26)
plane.setScreenDimensions(80, 43)
val decoder = GocaDecoder(plane)
assertEquals(1040, plane.totalWidth)
assertEquals(1118, plane.totalHeight)
assertEquals(520, plane.xMax)
assertEquals(558, plane.yMax)
val out = ByteArrayOutputStream()
// GSCP: Set Current Position (377, 494)
out.write(GocaConstants.G_GSCP)
out.write(0x04)
out.write(0x01); out.write(0x79) // X = 377
out.write(0x01); out.write(0xEE) // Y = 494
// GSCOL: Color 1 = Blue
out.write(GocaConstants.G_GSCOL)
out.write(0x01)
// G_GBIMGC (0x91): Begin Image Current Position: len=6, flags=0, w=80, h=80
out.write(GocaConstants.G_GBIMGC)
out.write(0x06)
out.write(0x00); out.write(0x00)
out.write(0x00); out.write(0x50) // w = 80
out.write(0x00); out.write(0x50) // h = 80
// G_GIMD (0x92): 10 bytes scanline
out.write(GocaConstants.G_GIMD)
out.write(0x0A)
out.write(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()))
// G_GEIMG (0x93): End Image with 2-byte operand
out.write(GocaConstants.G_GEIMG)
out.write(0x02)
out.write(0x00); out.write(0x00)
// Second image tile: Color 2 = Red
out.write(GocaConstants.G_GSCOL)
out.write(0x02)
out.write(GocaConstants.G_GBIMGC)
out.write(0x06)
out.write(0x00); out.write(0x00)
out.write(0x00); out.write(0x50)
out.write(0x00); out.write(0x50)
out.write(GocaConstants.G_GIMD)
out.write(0x0A)
out.write(byteArrayOf(0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte()))
out.write(GocaConstants.G_GEIMG)
out.write(0x02)
out.write(0x00); out.write(0x00)
val stream = out.toByteArray()
decoder.decodeStream(stream, 0, stream.size)
assertTrue(plane.hasContent())
val mappedX = plane.mapX(377)
val mappedY = plane.mapY(494)
assertEquals(897, mappedX)
assertEquals(64, mappedY)
val rgb = plane.rgbBuffer
assertNotNull(rgb)
val pixelAtImg = rgb[mappedY * 1040 + mappedX]
assertNotEquals(0, pixelAtImg)
// Verify grid metrics and UI scaling calculation against IMON canvas
val metrics = calculateGridMetrics(1080f, 1920f, 80, 43)
assertTrue(metrics.gridWidth > 0f)
assertTrue(metrics.gridHeight > 0f)
// Touch scaling test: touch in the middle of grid maps accurately to plane coordinates
val touchScreenX = metrics.offsetX + metrics.gridWidth / 2f
val touchScreenY = metrics.offsetY + metrics.gridHeight / 2f
val planeX = (((touchScreenX - metrics.offsetX) * plane.canvasWidth) / metrics.gridWidth).toInt()
val planeY = (((touchScreenY - metrics.offsetY) * plane.canvasHeight) / metrics.gridHeight).toInt()
assertEquals(plane.canvasWidth / 2, planeX)
assertEquals(plane.canvasHeight / 2, planeY)
}
} }
@@ -0,0 +1,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)
}
}