8 Commits

Author SHA1 Message Date
rudi b81a244bb9 Add bugfixes from testing
Build and Test a3270 / Build Android APK (push) Successful in 2m36s
Release a3270 / Build & Publish Release (push) Successful in 3m58s
2026-09-12 17:22:19 -04:00
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
18 changed files with 1626 additions and 143 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
+2
View File
@@ -9,3 +9,5 @@ captures/
.cxx .cxx
*.jar *.jar
!gradle/wrapper/gradle-wrapper.jar !gradle/wrapper/gradle-wrapper.jar
*.txt
*.log*
+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
@@ -191,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
@@ -339,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
@@ -503,6 +530,10 @@ fun MainScreen(
val blockSelectMode by viewModel.blockSelectMode.collectAsState() val blockSelectMode by viewModel.blockSelectMode.collectAsState()
val uiTheme by viewModel.uiTheme.collectAsState() val uiTheme by viewModel.uiTheme.collectAsState()
val isInsertMode by viewModel.isInsertMode.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) { val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) {
if (screenBuffer != null && screenBuffer?.isFormatted == true) { if (screenBuffer != null && screenBuffer?.isFormatted == true) {
@@ -510,15 +541,6 @@ fun MainScreen(
(fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0 (fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0
} else false } else false
} }
val modelName = remember(rows, cols) {
when {
rows == 24 && cols == 80 -> "3279-2"
rows == 32 && cols == 80 -> "3279-3"
rows == 43 && cols == 80 -> "3279-4"
rows == 27 && cols == 132 -> "3279-5"
else -> "DYNAMIC"
}
}
var showConnectDialog by remember { mutableStateOf(false) } var showConnectDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) } var showFtDialog by remember { mutableStateOf(false) }
@@ -529,8 +551,11 @@ fun MainScreen(
var showScriptDialog by remember { mutableStateOf(false) } var showScriptDialog by remember { mutableStateOf(false) }
var showPrinterSessionDialog by remember { mutableStateOf(false) } var showPrinterSessionDialog by remember { mutableStateOf(false) }
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
LaunchedEffect(isFindDialogOpen) { LaunchedEffect(isFindDialogOpen) {
if (isFindDialogOpen) { if (isFindDialogOpen) {
terminalInputViewRef?.hideSoftKeyboard()
showFindDialog = true showFindDialog = true
onSetFindDialogOpen(false) onSetFindDialogOpen(false)
} }
@@ -538,13 +563,12 @@ fun MainScreen(
LaunchedEffect(isFtDialogOpen) { LaunchedEffect(isFtDialogOpen) {
if (isFtDialogOpen) { if (isFtDialogOpen) {
terminalInputViewRef?.hideSoftKeyboard()
showFtDialog = true showFtDialog = true
onSetFtDialogOpen(false) onSetFtDialogOpen(false)
} }
} }
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
@@ -636,6 +660,7 @@ fun MainScreen(
cursorStyle = cursorStyle, cursorStyle = cursorStyle,
isInsertMode = isInsertMode, isInsertMode = isInsertMode,
blockSelectMode = blockSelectMode, blockSelectMode = blockSelectMode,
fourColorOverride = fourColorOverride,
onPasteLineWrap = { text, wrapCol, wordWrap -> onPasteLineWrap = { text, wrapCol, wordWrap ->
viewModel.pasteLineWrap(text, wrapCol, wordWrap) viewModel.pasteLineWrap(text, wrapCol, wordWrap)
}, },
@@ -691,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()
@@ -769,20 +832,29 @@ fun MainScreen(
isTls = isTlsActive, isTls = isTlsActive,
isTlsVerified = isTlsVerified, isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode, graphicsMode = defaultGraphicsMode,
codePage = codePage,
isLightPenMode = isLightPenMode, isLightPenMode = isLightPenMode,
isInsertMode = isInsertMode, 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, isNumericField = isNumericField,
modelName = modelName uiTheme = uiTheme,
onToggleDocMode = { viewModel.toggleDocMode() },
onToggleWordWrap = { viewModel.toggleWordWrap() },
onToggleAplMode = { viewModel.toggleAplMode() },
onToggleInsert = { viewModel.toggleInsert() }
) )
} }
} }
if (showConnectDialog) { if (showConnectDialog) {
ConnectDialog( ConnectDialog(
onDismiss = { showConnectDialog = false }, onDismiss = {
showConnectDialog = false
terminalInputViewRef?.showSoftKeyboard()
},
onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass -> onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass ->
showConnectDialog = false showConnectDialog = false
viewModel.connect(host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass) viewModel.connect(host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass)
@@ -796,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) {
@@ -825,8 +900,23 @@ fun MainScreen(
initialStartupBehavior = AppSettings.getStartupBehavior(context).name, initialStartupBehavior = AppSettings.getStartupBehavior(context).name,
initialDynamicRows = AppSettings.getDynamicRows(context), initialDynamicRows = AppSettings.getDynamicRows(context),
initialDynamicCols = AppSettings.getDynamicCols(context), initialDynamicCols = AppSettings.getDynamicCols(context),
onDismiss = { showSettingsDialog = false }, initialEntryAssistDocMode = isDocMode,
onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, startup, dynRows, dynCols -> 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.setUiTheme(theme)
@@ -836,6 +926,18 @@ fun MainScreen(
AppSettings.setStartupBehavior(context, AppSettings.StartupBehavior.valueOf(startup)) AppSettings.setStartupBehavior(context, AppSettings.StartupBehavior.valueOf(startup))
AppSettings.setDynamicRows(context, dynRows) AppSettings.setDynamicRows(context, dynRows)
AppSettings.setDynamicCols(context, dynCols) 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()
} }
) )
@@ -845,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()
@@ -856,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)
}, },
@@ -874,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)
} }
@@ -893,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
} }
@@ -161,6 +167,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _isInsertMode = MutableStateFlow(false) private val _isInsertMode = MutableStateFlow(false)
val isInsertMode: StateFlow<Boolean> = _isInsertMode.asStateFlow() 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
} }
@@ -193,6 +211,74 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_uiTheme.value = 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() { fun reloadSettings() {
val app = getApplication<Application>() val app = getApplication<Application>()
_maskHiddenInput.value = AppSettings.isMaskHiddenInputEnabled(app) _maskHiddenInput.value = AppSettings.isMaskHiddenInputEnabled(app)
@@ -205,6 +291,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_cursorStyle.value = AppSettings.getCursorStyle(app) _cursorStyle.value = AppSettings.getCursorStyle(app)
_blockSelectMode.value = AppSettings.isBlockSelectModeEnabled(app) _blockSelectMode.value = AppSettings.isBlockSelectModeEnabled(app)
_uiTheme.value = AppSettings.getUiTheme(app) _uiTheme.value = AppSettings.getUiTheme(app)
_fourColorOverride.value = AppSettings.getFourColorOverride(app)
applyModeSettings()
} }
fun pasteLineWrap(text: String, endCol: Int, wordWrap: Boolean) { fun pasteLineWrap(text: String, endCol: Int, wordWrap: Boolean) {
@@ -472,8 +560,48 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
is TerminalInputAction.ToggleInsert -> { is TerminalInputAction.ToggleInsert -> {
if (!isNvt) { if (!isNvt) {
ip.processToggleInsert() c.toggleInsert()
_isInsertMode.value = ip.isInsertMode _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 -> {
@@ -661,21 +789,26 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication()) val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication())
val effectiveVerify = tlsVerifyCert && globalVerify val effectiveVerify = tlsVerifyCert && globalVerify
val gMode = haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr) val gMode = haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
val effectiveLu = if (luName.isNotBlank()) luName.trim() else (parsedConfig.luName ?: "").trim()
_isTlsActive.value = effectiveTls _isTlsActive.value = effectiveTls
_isTlsVerified.value = effectiveVerify _isTlsVerified.value = effectiveVerify
_oiaText.value = "Connecting to $effectiveHost:$effectivePort" + (if (effectiveTls) " [TLS]" else "") + "..." _oiaText.value = "Connecting to " +
(if (effectiveLu.isNotBlank()) "$effectiveLu@" else "") +
"$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) { if (model == TerminalModel.IBM_DYNAMIC) {
setDynamicDimensions(dynamicRows, dynamicCols) setDynamicDimensions(dynamicRows, dynamicCols)
} }
if (luName.isNotBlank()) { if (effectiveLu.isNotBlank()) {
setLuName(luName) setLuName(effectiveLu)
} }
isUseTls = effectiveTls isUseTls = effectiveTls
isTlsVerifyCert = effectiveVerify isTlsVerifyCert = effectiveVerify
isTn3270eEnabled = effectiveTn3270e isTn3270eEnabled = effectiveTn3270e
isAutoSysUnlock = true
graphicsMode = gMode graphicsMode = gMode
codePage = codePageStr codePage = codePageStr
@@ -769,6 +902,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
@@ -779,6 +926,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
@@ -804,6 +955,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
override fun onSoundAlarm() { override fun onSoundAlarm() {
log.fine("Sound Alarm") log.fine("Sound Alarm")
playBellSound()
} }
}) })
@@ -811,6 +963,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)
}
}
@@ -29,6 +29,19 @@ object AppSettings {
private const val KEY_DYNAMIC_ROWS = "dynamic_rows" private const val KEY_DYNAMIC_ROWS = "dynamic_rows"
private const val KEY_DYNAMIC_COLS = "dynamic_cols" 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 { enum class StartupBehavior {
SHOW_CONNECT, SHOW_CONNECT,
DO_NOTHING, DO_NOTHING,
@@ -155,6 +168,111 @@ object AppSettings {
getPrefs(context).edit().putInt(KEY_DYNAMIC_COLS, cols.coerceIn(80, 255)).apply() 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 ========== // ========== INI Config Export / Import ==========
fun exportToIni(context: Context, writer: Writer) { fun exportToIni(context: Context, writer: Writer) {
@@ -186,6 +304,23 @@ object AppSettings {
p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}") p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}")
p.println() 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("[graphics]")
p.println("graphicsMode = ${getDefaultGraphicsMode(context)}") p.println("graphicsMode = ${getDefaultGraphicsMode(context)}")
p.println() p.println()
@@ -236,6 +371,25 @@ object AppSettings {
"blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean()) "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" -> { "graphics" -> {
if (key.equals("graphicsmode", ignoreCase = true) || key.equals("mode", ignoreCase = true)) { if (key.equals("graphicsmode", ignoreCase = true) || key.equals("mode", ignoreCase = true)) {
setDefaultGraphicsMode(context, value.uppercase(Locale.US)) setDefaultGraphicsMode(context, value.uppercase(Locale.US))
@@ -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,15 +28,29 @@ 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, isNumericField: Boolean = false,
modelName: String = "", 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("%03d/%03d [%04d]", row, col, cursorAddr) val posStr = String.format("%03d/%03d [%04d]", row, col, cursorAddr)
@@ -66,24 +81,24 @@ fun OiaStatusBar(
else -> "X LOCKED" else -> "X LOCKED"
} }
val clr = when (inhibitReason) { val clr = when (inhibitReason) {
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> Color(0xFFFF0000) haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> commCheckClr
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> Color(0xFFFFFF00) haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> attentionClr
else -> Color(0xFFFFFFFF) haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> attentionClr
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> attentionClr
else -> inputInhibitedClr
} }
txt to clr txt to clr
} else if (isKeyboardLocked) { } else if (isKeyboardLocked) {
"X SYSTEM" to Color(0xFFFFFFFF) "X SYSTEM" to inputInhibitedClr
} else if (isInsertMode) {
"INSERT" to Color(0xFF7890F0)
} else { } else {
"READY" to Color(0xFF7890F0) "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
@@ -101,7 +116,7 @@ fun OiaStatusBar(
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,
@@ -148,24 +163,6 @@ fun OiaStatusBar(
} }
} }
// CodePage Badge
if (connectionState.isConnected() && codePage.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFFFA5252).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "CP$codePage",
color = Color(0xFFFF8787),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// Field Status: NUM vs ALPHA // Field Status: NUM vs ALPHA
if (connectionState.isConnected() && rows > 0 && cols > 0) { if (connectionState.isConnected() && rows > 0 && cols > 0) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
@@ -184,24 +181,6 @@ fun OiaStatusBar(
} }
} }
// Model / Dimensions Badge
if (connectionState.isConnected() && rows > 0 && cols > 0) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF495057).copy(alpha = 0.3f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = if (modelName.isNotBlank()) modelName else "${rows}x${cols}",
color = Color(0xFFCED4DA),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// Light Pen Badge // Light Pen Badge
if (isLightPenMode) { if (isLightPenMode) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
@@ -257,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),
@@ -276,7 +327,7 @@ fun OiaStatusBar(
// Cursor Position & Buffer Address // Cursor Position & Buffer Address
Text( Text(
text = posStr, text = posStr,
color = Color(0xFF22B8CF), color = if (isLight) Color(0xFF0C8599) else Color(0xFF22B8CF),
fontSize = 10.sp, fontSize = 10.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -33,6 +33,17 @@ fun SettingsDialog(
initialStartupBehavior: String = "SHOW_CONNECT", initialStartupBehavior: String = "SHOW_CONNECT",
initialDynamicRows: Int = 62, initialDynamicRows: Int = 62,
initialDynamicCols: Int = 160, 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: ( onSave: (
maskHiddenInput: Boolean, maskHiddenInput: Boolean,
@@ -47,7 +58,18 @@ fun SettingsDialog(
blockSelectMode: Boolean, blockSelectMode: Boolean,
startupBehavior: String, startupBehavior: String,
dynamicRows: Int, dynamicRows: Int,
dynamicCols: 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 ) -> Unit
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
@@ -68,6 +90,18 @@ fun SettingsDialog(
var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) } var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) }
var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.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) } var showImportDialog by remember { mutableStateOf(false) }
Dialog( Dialog(
@@ -416,7 +450,207 @@ fun SettingsDialog(
HorizontalDivider(color = Color(0xFF2C2D30)) HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// SECTION 4: INI CONFIGURATION BACKUP // 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("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) Text("Compatible with j3270 desktop INI configuration files.", fontSize = 12.sp, color = Color.LightGray)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -459,6 +693,9 @@ fun SettingsDialog(
onClick = { onClick = {
val dRows = dynamicRowsStr.trim().toIntOrNull()?.coerceIn(24, 255) ?: 62 val dRows = dynamicRowsStr.trim().toIntOrNull()?.coerceIn(24, 255) ?: 62
val dCols = dynamicColsStr.trim().toIntOrNull()?.coerceIn(80, 255) ?: 160 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( onSave(
maskHiddenInput, maskHiddenInput,
cursorBlink, cursorBlink,
@@ -472,7 +709,18 @@ fun SettingsDialog(
blockSelectMode, blockSelectMode,
startupBehavior, startupBehavior,
dRows, dRows,
dCols dCols,
entryAssistDocMode,
entryAssistWordWrap,
eaStartCol,
eaEndCol,
entryAssistBell,
eaBellCol,
entryAssistTabStops,
insertOffOnAid,
fourColorOverride,
numericFieldLock,
autoSkipEnabled
) )
}, },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
@@ -517,6 +765,17 @@ fun SettingsDialog(
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString() dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
defaultCodePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context) defaultCodePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
defaultGraphicsMode = haus.nightmare.a3270.storage.AppSettings.getDefaultGraphicsMode(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() android.widget.Toast.makeText(context, "Settings imported from INI", android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) { } catch (e: Exception) {
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
@@ -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)
}
} }
@@ -70,6 +70,7 @@ fun TerminalView(
cursorStyle: String = "BLOCK", cursorStyle: String = "BLOCK",
isInsertMode: Boolean = false, isInsertMode: Boolean = false,
blockSelectMode: 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,
@@ -256,11 +257,11 @@ fun TerminalView(
if (graphicsPlane != null && graphicsPlane.hasContent()) { if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = metrics.gridWidth.toInt() val gridW = metrics.gridWidth.toInt()
val gridH = metrics.gridHeight.toInt() val gridH = metrics.gridHeight.toInt()
if (gridW > 0 && gridH > 0) { val gWidth = graphicsPlane.canvasWidth
graphicsPlane.resize(gridW, gridH) val gHeight = graphicsPlane.canvasHeight
val rgb = graphicsPlane.rgbBuffer val rgb = graphicsPlane.rgbBuffer
if (rgb != null) { if (gridW > 0 && gridH > 0 && rgb != null && gWidth > 0 && gHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgb, gridW, gridH, android.graphics.Bitmap.Config.ARGB_8888) val bmp = android.graphics.Bitmap.createBitmap(rgb, gWidth, gHeight, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap( drawContext.canvas.nativeCanvas.drawBitmap(
bmp, bmp,
null, null,
@@ -269,7 +270,6 @@ fun TerminalView(
) )
} }
} }
}
// 2. Draw Crosshair Ruler if enabled // 2. Draw Crosshair Ruler if enabled
if (crosshairRuler && cols > 0 && rows > 0) { if (crosshairRuler && cols > 0 && rows > 0) {
@@ -320,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)
@@ -728,7 +728,13 @@ private fun copySelection(
} }
} }
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
@@ -736,6 +742,7 @@ 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_TURQUOISE] if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_TURQUOISE]
@@ -1,12 +1,18 @@
package haus.nightmare.a3270 package haus.nightmare.a3270
import haus.nightmare.a3270.storage.SavedHost import haus.nightmare.a3270.storage.SavedHost
import haus.nightmare.a3270.ui.getFgColorForAttribute
import haus.nightmare.lib3270j.ConnectionConfig import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.TerminalModel 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.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Test import org.junit.Test
import java.io.ByteArrayOutputStream
import java.util.UUID import java.util.UUID
class DynamicAndParityFeaturesTest { class DynamicAndParityFeaturesTest {
@@ -101,4 +107,73 @@ class DynamicAndParityFeaturesTest {
assertTrue((num and FA_NUMERIC) != 0) assertTrue((num and FA_NUMERIC) != 0)
assertTrue((hi and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL) 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)
}
} }