diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 8f40f8d..dbabe51 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -41,6 +41,12 @@ jobs: run: | chmod +x ./gradlew ./build.sh + - name: Run Unit Tests + run: | + ./gradlew testDebugUnitTest --no-daemon + env: + J3270_DIR: ${{ github.workspace }}/j3270 + - name: Build Android APK run: | ./gradlew assembleDebug --no-daemon diff --git a/.gitignore b/.gitignore index 9a06de8..ab8cd1a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ captures/ .cxx *.jar !gradle/wrapper/gradle-wrapper.jar +*.txt diff --git a/README.md b/README.md index a3780e8..500afd9 100644 --- a/README.md +++ b/README.md @@ -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 -- **Modern Jetpack Compose UI**: Responsive, touch-optimized 3270 terminal screen rendering. -- **TN3270 & TN3270E Protocol Support**: RFC 2355 compliant state machine, negotiation, Device-Type query, and SSL/TLS encryption via `lib3270j`. -- **On-Screen Function Key Bar**: Quick-access touch bar for PF1–PF24, PA1–PA3, Enter, Clear, Reset, and Attn keys. -- **IND$FILE File Transfer**: Full mobile UI for **CUT** and **DFT (DDM)** high-speed structured field file transfers with ASCII/binary translation, CRLF handling, and recfm/lrecl formatting for TSO, VM/CMS, and CICS. -- **OIA Status Line**: Real-time Operator Information Area displaying connection state, keyboard lock, insert mode, cursor row/col, and LU name. -- **Host Profile Storage**: Save and manage multiple mainframe connection profiles with custom ports, models, and SSL/TLS options. -- **Background Session Persistence**: Android foreground service keeps your terminal session alive when multitasking. -- **Terminal Models**: - - Model 2 (24x80) - - Model 3 (32x80) - - Model 4 (43x80) - - Model 5 (27x132) +### 🖥️ Terminal Emulation & Protocol +- **RFC 2355 TN3270 & TN3270E Engine**: Robust state machine, Option Negotiation, Device-Type Query, and LU name specification powered by `lib3270j`. +- **Standard & Dynamic Terminal Models**: + - Model 2 (24 × 80) + - Model 3 (32 × 80) + - Model 4 (43 × 80) + - Model 5 (27 × 132) + - **Dynamic Screen Dimensions**: Configurable custom geometry (rows & columns up to 255 × 255). +- **EBCDIC Code Pages**: Full character translation across international code pages (037, 1047, 500, etc.). + +### 📊 Graphics & Peripherals +- **GDDM & GOCA Graphics**: Native rendering of Programmed Symbols (PS) and GOCA vector graphics directly in the Compose canvas. +- **Light Pen Simulation**: Interactive touch support to simulate light pen selection and graphic cursor pick correlation. +- **IBM 3287 Printer Emulation**: Integrated printer session support for spool/report capture (via associated display LU or dedicated printer LU) with direct printing through Android PrintManager (PDF export / system print). + +### 📁 IND$FILE File Transfer +- Full mobile UI supporting both **CUT** and **DFT (DDM)** high-speed structured field transfers. +- Compatible with **TSO**, **VM/CMS**, and **CICS** environments. +- ASCII/binary translation, CRLF conversions, and configurable record format (`recfm`), record length (`lrecl`), and allocation units. + +### 🔍 Developer & Productivity Tools +- **Field Inspector**: Live interactive inspector displaying start/end addresses, length, protection, numeric status, MDT (Modified Data Tag), display intensity, and extended highlighting/color, with one-tap "Jump to Field". +- **Screen Search / Find**: On-screen text search with case sensitivity, forward/backward navigation, and visual match highlighting. +- **Screen Exporter**: Export the current screen buffer to **Plain Text**, formatted **HTML**, or **PNG image**, with one-tap clipboard copy and Android system share sheet integration. +- **Script & Macro Automation**: Built-in script runner supporting automated keystroke and wait sequences (e.g. `[enter]`, `[wait]`, `[tab]`) for login macros and repetitive tasks. + +### ✍️ Entry Assist & Typing Modes +- **Document (DOC) Mode**: Configurable left/right margins, custom tab stops, and end-of-line bell warning. +- **Word Wrap**: Automatic word wrapping across lines during input. +- **APL Mode**: Support for APL extended character sets and symbols. +- **Field Handling**: Automatic field skip (Auto-Skip), Numeric field lock, and Insert Mode auto-reset on AID transmission. + +### 🎨 Display, Themes & Ergonomics +- **Jetpack Compose UI**: Smooth, hardware-accelerated 3270 terminal screen rendering with touch-to-position cursor. +- **Color Themes**: Light and Dark application themes, 3270 extended color support, and 4-color legacy override mode. +- **Cursor Customization**: Block and Underline cursor styles with toggleable blinking. +- **Crosshair Ruler**: Toggleable horizontal and vertical ruler lines following the cursor for easy data tracking. +- **Block Selection**: Rectangular text selection and copy mode. +- **OIA Status Line**: Real-time Operator Information Area displaying connection status, keyboard lock, insert mode, cursor row/col, LU name, and inhibit reason codes. + +### 🔒 Security, Networking & Storage +- **SSL / TLS Encryption**: Secure encrypted mainframe sessions with TLS certificate verification. +- **Interactive Untrusted Certificate Prompt**: Inspect SSL certificates, view full certificate chain details, and accept/reject unverified connections. +- **Proxy Support**: Connect via HTTP CONNECT, SOCKS4, SOCKS5, and Telnet proxies with optional user/password authentication. +- **Background Session Persistence**: Android foreground service (`specialUse`) keeps your terminal TCP connection alive with status notifications when multitasking or screen is off. +- **Host Profile Manager & INI Export**: Save multiple mainframe profiles, configure auto-connect on launch, and export/import configuration in INI format (interoperable with desktop `j3270`). + +### ⌨️ Input & Keyboards +- **On-Screen Function Key Bar**: Two-row responsive touch bar providing PF1–PF24, PA1–PA3, Enter, Clear, Reset, Attn, SysReq, Erase EOF, Erase Input, Tab, BackTab, and D-pad cursor controls with haptic feedback. +- **Termux-Style Native Input**: Low-latency native input connection with permanent number row and lock protection against soft-keyboard resets. +- **Hardware Keyboard Support**: Complete physical keyboard integration with F1–F12 (mapped to PF1–PF12 / PF13–PF24 with Shift/Alt), Ctrl shortcuts, dead keys, and international character input. --- @@ -56,6 +106,7 @@ adb install -r a3270.apk ### Prerequisites - **Java Development Kit (JDK)**: JDK 17 or JDK 21 - **Android SDK**: API 34 platforms and build-tools (`ANDROID_HOME` or `ANDROID_SDK_ROOT` set) +- **Minimum Target Device**: Android 7.0+ (API 24+) ### 1. Clone Repositories Side-by-Side For the easiest setup, clone both `j3270` and `a3270` into the same parent folder: diff --git a/build.sh b/build.sh index e6e0e04..76406b6 100755 --- a/build.sh +++ b/build.sh @@ -50,7 +50,7 @@ if [ $# -gt 0 ]; then ./gradlew "$@" else echo "Building debug APK..." - ./gradlew assembleDebug + ./gradlew assembleDebug --no-daemon APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk" if [ -f "$APK_PATH" ]; then diff --git a/src/main/java/haus/nightmare/a3270/MainActivity.kt b/src/main/java/haus/nightmare/a3270/MainActivity.kt index ce67bfa..8638b20 100644 --- a/src/main/java/haus/nightmare/a3270/MainActivity.kt +++ b/src/main/java/haus/nightmare/a3270/MainActivity.kt @@ -191,6 +191,29 @@ class MainActivity : ComponentActivity() { 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) + // 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) { val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1 val pfNum = if (shift || alt) fNum + 12 else fNum @@ -339,6 +362,10 @@ class MainActivity : ComponentActivity() { // 5. Standard Navigation & Special Keys when (keyCode) { + KeyEvent.KEYCODE_INSERT -> { + viewModel.toggleInsert() + return true + } KeyEvent.KEYCODE_DPAD_LEFT -> { viewModel.cursorLeft() return true @@ -503,6 +530,10 @@ fun MainScreen( val blockSelectMode by viewModel.blockSelectMode.collectAsState() val uiTheme by viewModel.uiTheme.collectAsState() val isInsertMode by viewModel.isInsertMode.collectAsState() + val isDocMode by viewModel.isDocMode.collectAsState() + val isWordWrap by viewModel.isWordWrap.collectAsState() + val isAplMode by viewModel.isAplMode.collectAsState() + val fourColorOverride by viewModel.fourColorOverride.collectAsState() val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) { if (screenBuffer != null && screenBuffer?.isFormatted == true) { @@ -510,15 +541,6 @@ fun MainScreen( (fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0 } 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 showFtDialog by remember { mutableStateOf(false) } @@ -529,8 +551,11 @@ fun MainScreen( var showScriptDialog by remember { mutableStateOf(false) } var showPrinterSessionDialog by remember { mutableStateOf(false) } + var terminalInputViewRef by remember { mutableStateOf(null) } + LaunchedEffect(isFindDialogOpen) { if (isFindDialogOpen) { + terminalInputViewRef?.hideSoftKeyboard() showFindDialog = true onSetFindDialogOpen(false) } @@ -538,13 +563,12 @@ fun MainScreen( LaunchedEffect(isFtDialogOpen) { if (isFtDialogOpen) { + terminalInputViewRef?.hideSoftKeyboard() showFtDialog = true onSetFtDialogOpen(false) } } - var terminalInputViewRef by remember { mutableStateOf(null) } - val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -636,6 +660,7 @@ fun MainScreen( cursorStyle = cursorStyle, isInsertMode = isInsertMode, blockSelectMode = blockSelectMode, + fourColorOverride = fourColorOverride, onPasteLineWrap = { text, wrapCol, wordWrap -> viewModel.pasteLineWrap(text, wrapCol, wordWrap) }, @@ -691,18 +716,56 @@ fun MainScreen( connectionState = connectionState, isShiftPressed = isShiftPressed, isLightPenMode = isLightPenMode, + isDocMode = isDocMode, + isWordWrap = isWordWrap, + isAplMode = isAplMode, + isInsertMode = isInsertMode, + fourColorOverride = fourColorOverride, hapticFeedbackEnabled = hapticFeedback, onClearShift = onClearShift, - onConnectClick = { showConnectDialog = true }, + onConnectClick = { + terminalInputViewRef?.hideSoftKeyboard() + showConnectDialog = true + }, onDisconnectClick = { viewModel.disconnect() }, - onFtClick = { showFtDialog = true }, - onFieldInspectorClick = { showFieldInspectorDialog = true }, - onFindClick = { showFindDialog = true }, - onScreenExportClick = { showScreenExportDialog = true }, - onScriptClick = { showScriptDialog = true }, - onPrinterSessionClick = { showPrinterSessionDialog = true }, + onFtClick = { + terminalInputViewRef?.hideSoftKeyboard() + showFtDialog = true + }, + onFieldInspectorClick = { + terminalInputViewRef?.hideSoftKeyboard() + showFieldInspectorDialog = true + }, + onFindClick = { + terminalInputViewRef?.hideSoftKeyboard() + showFindDialog = true + }, + onScreenExportClick = { + terminalInputViewRef?.hideSoftKeyboard() + showScreenExportDialog = true + }, + onScriptClick = { + terminalInputViewRef?.hideSoftKeyboard() + showScriptDialog = true + }, + onPrinterSessionClick = { + terminalInputViewRef?.hideSoftKeyboard() + showPrinterSessionDialog = true + }, onToggleLightPen = { viewModel.toggleLightPen() }, - 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 -> viewModel.sendAid(aid) terminalInputViewRef?.showSoftKeyboard() @@ -769,20 +832,29 @@ fun MainScreen( isTls = isTlsActive, isTlsVerified = isTlsVerified, graphicsMode = defaultGraphicsMode, - codePage = codePage, isLightPenMode = isLightPenMode, isInsertMode = isInsertMode, + isDocMode = isDocMode, + isWordWrap = isWordWrap, + isAplMode = isAplMode, inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0, luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "", isNumericField = isNumericField, - modelName = modelName + uiTheme = uiTheme, + onToggleDocMode = { viewModel.toggleDocMode() }, + onToggleWordWrap = { viewModel.toggleWordWrap() }, + onToggleAplMode = { viewModel.toggleAplMode() }, + onToggleInsert = { viewModel.toggleInsert() } ) } } if (showConnectDialog) { 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 -> showConnectDialog = false 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, ftProgressState = ftState, client = viewModel.getClient(), - onDismiss = { showFtDialog = false }, + onDismiss = { + showFtDialog = false + terminalInputViewRef?.showSoftKeyboard() + }, onStartTransfer = { config -> viewModel.startFileTransfer(config) { error -> if (error != null) { @@ -825,8 +900,23 @@ fun MainScreen( initialStartupBehavior = AppSettings.getStartupBehavior(context).name, initialDynamicRows = AppSettings.getDynamicRows(context), initialDynamicCols = AppSettings.getDynamicCols(context), - onDismiss = { showSettingsDialog = false }, - onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, startup, dynRows, dynCols -> + initialEntryAssistDocMode = isDocMode, + initialEntryAssistWordWrap = isWordWrap, + initialEntryAssistStartCol = AppSettings.getEntryAssistStartCol(context), + initialEntryAssistEndCol = AppSettings.getEntryAssistEndCol(context), + initialEntryAssistBell = AppSettings.getEntryAssistBell(context), + initialEntryAssistBellCol = AppSettings.getEntryAssistBellCol(context), + initialEntryAssistTabStops = AppSettings.getEntryAssistTabStops(context), + initialInsertOffOnAid = AppSettings.getInsertOffOnAid(context), + initialFourColorOverride = fourColorOverride, + initialNumericFieldLock = AppSettings.getNumericFieldLock(context), + initialAutoSkipEnabled = AppSettings.getAutoSkipEnabled(context), + onDismiss = { + showSettingsDialog = false + terminalInputViewRef?.showSoftKeyboard() + }, + onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, startup, dynRows, dynCols, + doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk -> showSettingsDialog = false viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp) viewModel.setUiTheme(theme) @@ -836,6 +926,18 @@ fun MainScreen( AppSettings.setStartupBehavior(context, AppSettings.StartupBehavior.valueOf(startup)) AppSettings.setDynamicRows(context, dynRows) AppSettings.setDynamicCols(context, dynCols) + AppSettings.setEntryAssistDocMode(context, doc) + AppSettings.setEntryAssistWordWrap(context, wrap) + AppSettings.setEntryAssistStartCol(context, startCol) + AppSettings.setEntryAssistEndCol(context, endCol) + AppSettings.setEntryAssistBell(context, bell) + AppSettings.setEntryAssistBellCol(context, bellCol) + AppSettings.setEntryAssistTabStops(context, tabs) + AppSettings.setInsertOffOnAid(context, resetIns) + AppSettings.setFourColorOverride(context, fourCol) + AppSettings.setNumericFieldLock(context, numLk) + AppSettings.setAutoSkipEnabled(context, autoSk) + viewModel.reloadSettings() terminalInputViewRef?.showSoftKeyboard() } ) @@ -845,7 +947,10 @@ fun MainScreen( FieldInspectorDialog( client = viewModel.getClient(), screenVersion = screenVersion, - onDismiss = { showFieldInspectorDialog = false }, + onDismiss = { + showFieldInspectorDialog = false + terminalInputViewRef?.showSoftKeyboard() + }, onJumpToField = { addr -> viewModel.setCursor(addr) terminalInputViewRef?.showSoftKeyboard() @@ -856,7 +961,10 @@ fun MainScreen( if (showFindDialog) { FindDialog( cols = cols, - onDismiss = { showFindDialog = false }, + onDismiss = { + showFindDialog = false + terminalInputViewRef?.showSoftKeyboard() + }, onFind = { query: String, matchCase: Boolean, forward: Boolean -> viewModel.find(query, matchCase, forward) }, @@ -874,13 +982,19 @@ fun MainScreen( programSymbolManager = viewModel.getClient()?.programSymbolManager, graphicsPlane = viewModel.getClient()?.graphicsPlane, maskHidden = maskHiddenInput, - onDismiss = { showScreenExportDialog = false } + onDismiss = { + showScreenExportDialog = false + terminalInputViewRef?.showSoftKeyboard() + } ) } if (showScriptDialog) { ScriptDialog( - onDismiss = { showScriptDialog = false }, + onDismiss = { + showScriptDialog = false + terminalInputViewRef?.showSoftKeyboard() + }, onExecuteScript = { script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit -> viewModel.executeScript(script, onStatus, onComplete) } @@ -893,7 +1007,10 @@ fun MainScreen( initialHost = activeHost, initialCodePage = codePage, initialUseTls = isTlsActive, - onDismiss = { showPrinterSessionDialog = false } + onDismiss = { + showPrinterSessionDialog = false + terminalInputViewRef?.showSoftKeyboard() + } ) } diff --git a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt index 28a97f3..01690a8 100644 --- a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt +++ b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt @@ -53,6 +53,12 @@ sealed interface TerminalInputAction { data object Dup : TerminalInputAction data object FieldMark : 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 LightPenSelect(val baddr: Int) : TerminalInputAction } @@ -161,6 +167,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private val _isInsertMode = MutableStateFlow(false) val isInsertMode: StateFlow = _isInsertMode.asStateFlow() + private val _isDocMode = MutableStateFlow(AppSettings.getEntryAssistDocMode(application)) + val isDocMode: StateFlow = _isDocMode.asStateFlow() + + private val _isWordWrap = MutableStateFlow(AppSettings.getEntryAssistWordWrap(application)) + val isWordWrap: StateFlow = _isWordWrap.asStateFlow() + + private val _isAplMode = MutableStateFlow(false) + val isAplMode: StateFlow = _isAplMode.asStateFlow() + + private val _fourColorOverride = MutableStateFlow(AppSettings.getFourColorOverride(application)) + val fourColorOverride: StateFlow = _fourColorOverride.asStateFlow() + fun toggleLightPen() { _isLightPenMode.value = !_isLightPenMode.value } @@ -193,6 +211,74 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application _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() + val c = client ?: return + val sb = c.screenBuffer + if (sb != null) { + sb.isEntryAssistDOCmode = AppSettings.getEntryAssistDocMode(app) + sb.isEntryAssistWordWrap = AppSettings.getEntryAssistWordWrap(app) + sb.setLeftMargin((AppSettings.getEntryAssistStartCol(app) - 1).coerceAtLeast(0)) + sb.setRightMargin((AppSettings.getEntryAssistEndCol(app) - 1).coerceAtLeast(0)) + sb.setWordTabPositions(AppSettings.getEntryAssistTabStopsArray(app)) + } + val ip = c.inputProcessor + if (ip != null) { + ip.isBellEnabled = AppSettings.getEntryAssistBell(app) + ip.bellColumn = (AppSettings.getEntryAssistBellCol(app) - 1).coerceAtLeast(0) + ip.isInsertOffOnAid = AppSettings.getInsertOffOnAid(app) + ip.isNumericFieldLock = AppSettings.getNumericFieldLock(app) + ip.isAutoSkipEnabled = AppSettings.getAutoSkipEnabled(app) + } + _fourColorOverride.value = AppSettings.getFourColorOverride(app) + _isDocMode.value = c.isDocMode + _isWordWrap.value = c.isWordWrap + _isAplMode.value = c.isAplMode + _isInsertMode.value = c.isInsertMode + _screenVersion.value = System.currentTimeMillis() + } + fun reloadSettings() { val app = getApplication() _maskHiddenInput.value = AppSettings.isMaskHiddenInputEnabled(app) @@ -205,6 +291,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application _cursorStyle.value = AppSettings.getCursorStyle(app) _blockSelectMode.value = AppSettings.isBlockSelectModeEnabled(app) _uiTheme.value = AppSettings.getUiTheme(app) + _fourColorOverride.value = AppSettings.getFourColorOverride(app) + applyModeSettings() } fun pasteLineWrap(text: String, endCol: Int, wordWrap: Boolean) { @@ -472,8 +560,48 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application } is TerminalInputAction.ToggleInsert -> { if (!isNvt) { - ip.processToggleInsert() - _isInsertMode.value = ip.isInsertMode + c.toggleInsert() + _isInsertMode.value = c.isInsertMode + } + } + is TerminalInputAction.ToggleDocMode -> { + if (!isNvt) { + c.toggleDocMode() + AppSettings.setEntryAssistDocMode(getApplication(), c.isDocMode) + _isDocMode.value = c.isDocMode + } + } + is TerminalInputAction.ToggleWordWrap -> { + if (!isNvt) { + c.toggleWordWrap() + AppSettings.setEntryAssistWordWrap(getApplication(), c.isWordWrap) + _isWordWrap.value = c.isWordWrap + } + } + is TerminalInputAction.ToggleApl -> { + if (!isNvt) { + c.toggleAplMode() + _isAplMode.value = c.isAplMode + } + } + is TerminalInputAction.SetDocMode -> { + if (!isNvt) { + c.isDocMode = action.enabled + AppSettings.setEntryAssistDocMode(getApplication(), action.enabled) + _isDocMode.value = action.enabled + } + } + is TerminalInputAction.SetWordWrap -> { + if (!isNvt) { + c.isWordWrap = action.enabled + AppSettings.setEntryAssistWordWrap(getApplication(), action.enabled) + _isWordWrap.value = action.enabled + } + } + is TerminalInputAction.SetAplMode -> { + if (!isNvt) { + c.isAplMode = action.enabled + _isAplMode.value = action.enabled } } is TerminalInputAction.SetCursor -> { @@ -769,6 +897,20 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application _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 { override fun onScreenUpdated() { val buf = newClient.screenBuffer @@ -779,6 +921,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application _cursorAddress.value = buf.cursorAddress } _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() // Drive CUT mode file transfers if active @@ -804,6 +950,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application override fun onSoundAlarm() { log.fine("Sound Alarm") + playBellSound() } }) @@ -811,6 +958,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application _cols.value = model.defaultCols _screenBuffer.value = newClient.screenBuffer + applyModeSettings() + newClient.connect() } catch (e: Throwable) { log.severe("Failed to connect: ${e.message}") diff --git a/src/main/java/haus/nightmare/a3270/graphics/AndroidCanvasRenderer.kt b/src/main/java/haus/nightmare/a3270/graphics/AndroidCanvasRenderer.kt new file mode 100644 index 0000000..c288b44 --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/graphics/AndroidCanvasRenderer.kt @@ -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) + } +} diff --git a/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt b/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt index e87ddb4..28da9a4 100644 --- a/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt +++ b/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt @@ -29,6 +29,19 @@ object AppSettings { private const val KEY_DYNAMIC_ROWS = "dynamic_rows" private const val KEY_DYNAMIC_COLS = "dynamic_cols" + // Entry Assist & Modes aligned with j3270 + private const val KEY_ENTRY_ASSIST_DOC_MODE = "entry_assist_doc_mode" + private const val KEY_ENTRY_ASSIST_WORD_WRAP = "entry_assist_word_wrap" + private const val KEY_ENTRY_ASSIST_START_COL = "entry_assist_start_col" + private const val KEY_ENTRY_ASSIST_END_COL = "entry_assist_end_col" + private const val KEY_ENTRY_ASSIST_BELL = "entry_assist_bell" + private const val KEY_ENTRY_ASSIST_BELL_COL = "entry_assist_bell_col" + private const val KEY_ENTRY_ASSIST_TAB_STOPS = "entry_assist_tab_stops" + private const val KEY_INSERT_OFF_ON_AID = "insert_off_on_aid" + private const val KEY_FOUR_COLOR_OVERRIDE = "four_color_override" + private const val KEY_NUMERIC_FIELD_LOCK = "numeric_field_lock" + private const val KEY_AUTO_SKIP_ENABLED = "auto_skip_enabled" + enum class StartupBehavior { SHOW_CONNECT, DO_NOTHING, @@ -155,6 +168,111 @@ object AppSettings { 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() + for (p in parts) { + val v = p.trim().toIntOrNull() + if (v != null && v > 0) { + list.add(v - 1) + } + } + return list.toIntArray() + } + + // ========== Terminal Operational Modes ========== + fun getInsertOffOnAid(context: Context): Boolean { + return getPrefs(context).getBoolean(KEY_INSERT_OFF_ON_AID, true) + } + + fun setInsertOffOnAid(context: Context, enabled: Boolean) { + getPrefs(context).edit().putBoolean(KEY_INSERT_OFF_ON_AID, enabled).apply() + } + + fun getFourColorOverride(context: Context): Boolean { + return getPrefs(context).getBoolean(KEY_FOUR_COLOR_OVERRIDE, false) + } + + fun setFourColorOverride(context: Context, enabled: Boolean) { + getPrefs(context).edit().putBoolean(KEY_FOUR_COLOR_OVERRIDE, enabled).apply() + } + + fun getNumericFieldLock(context: Context): Boolean { + return getPrefs(context).getBoolean(KEY_NUMERIC_FIELD_LOCK, true) + } + + fun setNumericFieldLock(context: Context, enabled: Boolean) { + getPrefs(context).edit().putBoolean(KEY_NUMERIC_FIELD_LOCK, enabled).apply() + } + + fun getAutoSkipEnabled(context: Context): Boolean { + return getPrefs(context).getBoolean(KEY_AUTO_SKIP_ENABLED, true) + } + + fun setAutoSkipEnabled(context: Context, enabled: Boolean) { + getPrefs(context).edit().putBoolean(KEY_AUTO_SKIP_ENABLED, enabled).apply() + } + // ========== INI Config Export / Import ========== fun exportToIni(context: Context, writer: Writer) { @@ -186,6 +304,23 @@ object AppSettings { p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}") p.println() + p.println("[entryassist]") + p.println("docMode = ${getEntryAssistDocMode(context)}") + p.println("wordWrap = ${getEntryAssistWordWrap(context)}") + p.println("startCol = ${getEntryAssistStartCol(context)}") + p.println("endCol = ${getEntryAssistEndCol(context)}") + p.println("bell = ${getEntryAssistBell(context)}") + p.println("bellCol = ${getEntryAssistBellCol(context)}") + p.println("tabStops = ${getEntryAssistTabStops(context)}") + p.println() + + p.println("[modes]") + p.println("insertOffOnAid = ${getInsertOffOnAid(context)}") + p.println("fourColorOverride = ${getFourColorOverride(context)}") + p.println("numericFieldLock = ${getNumericFieldLock(context)}") + p.println("autoSkip = ${getAutoSkipEnabled(context)}") + p.println() + p.println("[graphics]") p.println("graphicsMode = ${getDefaultGraphicsMode(context)}") p.println() @@ -236,6 +371,25 @@ object AppSettings { "blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean()) } } + "entryassist" -> { + when (key.lowercase(Locale.US)) { + "docmode", "entryassistdocmode" -> setEntryAssistDocMode(context, value.toBoolean()) + "wordwrap", "entryassistwordwrap" -> setEntryAssistWordWrap(context, value.toBoolean()) + "startcol", "entryassiststartcol" -> value.toIntOrNull()?.let { setEntryAssistStartCol(context, it) } + "endcol", "entryassistendcol" -> value.toIntOrNull()?.let { setEntryAssistEndCol(context, it) } + "bell", "entryassistbell" -> setEntryAssistBell(context, value.toBoolean()) + "bellcol", "entryassistbellcol" -> value.toIntOrNull()?.let { setEntryAssistBellCol(context, it) } + "tabstops", "entryassisttabstops" -> setEntryAssistTabStops(context, value) + } + } + "modes" -> { + when (key.lowercase(Locale.US)) { + "insertoffonaid" -> setInsertOffOnAid(context, value.toBoolean()) + "fourcoloroverride" -> setFourColorOverride(context, value.toBoolean()) + "numericfieldlock" -> setNumericFieldLock(context, value.toBoolean()) + "autoskip", "autoskipenabled" -> setAutoSkipEnabled(context, value.toBoolean()) + } + } "graphics" -> { if (key.equals("graphicsmode", ignoreCase = true) || key.equals("mode", ignoreCase = true)) { setDefaultGraphicsMode(context, value.uppercase(Locale.US)) diff --git a/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt b/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt index ef056e6..e59cc21 100644 --- a/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt @@ -37,6 +37,56 @@ private val TerminalKeyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Ascii ) +data class LastTransferState( + val hostFile: String = "", + val localFile: String = "", + val isReceive: Boolean = true, + val isAscii: Boolean = true, + val hostType: FTConfig.HostType = FTConfig.HostType.TSO, + val recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT, + val lreclStr: String = "", + val blksizeStr: String = "", + val spaceStr: String = "", + val cmsOptions: String = "" +) +private var lastTransferState: LastTransferState? = null + +fun formatHostFilename(localName: String, hostType: FTConfig.HostType): String { + if (hostType == FTConfig.HostType.CMS) { + val lastDot = localName.lastIndexOf('.') + return if (lastDot > 0) { + val fn = localName.substring(0, lastDot).replace(Regex("[^a-zA-Z0-9]"), "").uppercase() + val ft = localName.substring(lastDot + 1).replace(Regex("[^a-zA-Z0-9]"), "").uppercase() + "${fn.ifBlank { "FILE" }} ${ft.ifBlank { "TEXT" }} A" + } else { + val clean = localName.replace(Regex("[^a-zA-Z0-9]"), "").uppercase() + "${clean.ifBlank { "FILE" }} FILE A" + } + } else if (hostType == FTConfig.HostType.TSO) { + val cleanBase = localName.substringBeforeLast('.').replace(Regex("[^a-zA-Z0-9]"), "").uppercase() + return "'${cleanBase.ifBlank { "DATA" }}'" + } + return localName +} + +fun formatLocalFilenameFromHost(hostFile: String, hostType: FTConfig.HostType): String { + val cleanName = hostFile.trim('\'', '"') + if (hostType == FTConfig.HostType.CMS) { + val tokens = cleanName.trim().split(Regex("\\s+")) + if (tokens.size >= 2) { + return "${tokens[0].lowercase()}.${tokens[1].lowercase()}" + } else if (tokens.size == 1 && tokens[0].isNotBlank()) { + return "${tokens[0].lowercase()}.txt" + } + } else { + val lastDot = cleanName.lastIndexOf('.') + if (lastDot > 0) { + return "${cleanName.substring(lastDot + 1).lowercase()}.txt" + } + } + return if (cleanName.isNotBlank()) "${cleanName.lowercase()}.txt" else "download.txt" +} + @Composable fun FileTransferDialog( initialHostType: String = "TSO", @@ -54,15 +104,17 @@ fun FileTransferDialog( else -> FTConfig.HostType.TSO } - var hostFile by remember { mutableStateOf("") } - var localFile by remember { mutableStateOf("") } + var hostFile by remember { mutableStateOf(lastTransferState?.hostFile ?: "") } + var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") } var localDisplayName by remember { mutableStateOf("") } - var isReceive by remember { mutableStateOf(true) } - var isAscii by remember { mutableStateOf(true) } - var hostType by remember { mutableStateOf(initialTypeEnum) } - var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) } - var lreclStr by remember { mutableStateOf("") } - var blksizeStr by remember { mutableStateOf("") } + var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) } + var isAscii by remember { mutableStateOf(lastTransferState?.isAscii ?: true) } + var hostType by remember { mutableStateOf(lastTransferState?.hostType ?: initialTypeEnum) } + var recfm by remember { mutableStateOf(lastTransferState?.recfm ?: FTConfig.RecordFormat.DEFAULT) } + var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") } + var blksizeStr by remember { mutableStateOf(lastTransferState?.blksizeStr ?: "") } + var spaceStr by remember { mutableStateOf(lastTransferState?.spaceStr ?: "") } + var cmsOptions by remember { mutableStateOf(lastTransferState?.cmsOptions ?: "") } var overwrite by remember { mutableStateOf(true) } var showBrowseDialog by remember { mutableStateOf(false) } @@ -86,8 +138,7 @@ fun FileTransferDialog( localFile = tempFile.absolutePath localDisplayName = displayName if (hostFile.isBlank()) { - val cleanBase = displayName.substringBeforeLast('.').replace(Regex("[^a-zA-Z0-9]"), "").uppercase() - hostFile = if (hostType == FTConfig.HostType.TSO) "'$cleanBase'" else "$cleanBase FILE A" + hostFile = formatHostFilename(displayName, hostType) } Toast.makeText(context, "Selected: $displayName", Toast.LENGTH_SHORT).show() } catch (e: Exception) { @@ -143,8 +194,7 @@ fun FileTransferDialog( onSelectDataset = { selected -> hostFile = selected if (localFile.isBlank()) { - val base = selected.trim('\'').substringAfterLast('.').substringAfterLast(' ') - val safeName = if (base.isNotBlank()) "$base.txt" else "download.txt" + val safeName = formatLocalFilenameFromHost(selected, hostType) val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() } val tempFile = File(tempDir, safeName) localFile = tempFile.absolutePath @@ -361,8 +411,7 @@ fun FileTransferDialog( onClick = { if (isReceive) { val defName = if (hostFile.isNotBlank()) { - val base = hostFile.trim('\'').substringAfterLast('.').substringAfterLast(' ') - if (base.isNotBlank()) "$base.txt" else "download.txt" + formatLocalFilenameFromHost(hostFile, hostType) } else "download.txt" createDocumentLauncher.launch(defName) } else { @@ -467,10 +516,35 @@ fun FileTransferDialog( onValueChange = { blksizeStr = it.filter { c -> c.isDigit() } }, label = { Text("BLKSIZE", fontSize = 11.sp) }, singleLine = true, - keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done), + keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next), modifier = Modifier.weight(1f) ) } + + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = spaceStr, + onValueChange = { spaceStr = it }, + label = { Text("SPACE (Allocation)", fontSize = 11.sp) }, + placeholder = { Text("e.g. (1,1) cyl or 5,2") }, + singleLine = true, + keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth() + ) + } + + // CMS Options + if (hostType == FTConfig.HostType.CMS) { + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = cmsOptions, + onValueChange = { cmsOptions = it }, + label = { Text("CMS Options", fontSize = 11.sp) }, + placeholder = { Text("e.g. ASCII CRLF or RECFM F LRECL 80") }, + singleLine = true, + keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth() + ) } } }, @@ -497,8 +571,24 @@ fun FileTransferDialog( setRecfm(recfm) lreclStr.trim().toIntOrNull()?.let { setLrecl(it) } blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) } + if (spaceStr.isNotBlank()) setSpace(spaceStr.trim()) + } + if (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) { + setOptions(cmsOptions.trim()) } } + lastTransferState = LastTransferState( + hostFile = hostFile.trim(), + localFile = resolvedLocal, + isReceive = isReceive, + isAscii = isAscii, + hostType = hostType, + recfm = recfm, + lreclStr = lreclStr, + blksizeStr = blksizeStr, + spaceStr = spaceStr, + cmsOptions = cmsOptions + ) onStartTransfer(config) } }, diff --git a/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt b/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt index 9cd9352..74c2467 100644 --- a/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt +++ b/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.activity.compose.BackHandler +import androidx.compose.ui.window.PopupProperties import haus.nightmare.lib3270j.ConnectionState import haus.nightmare.lib3270j.protocol.DS3270Constants.* @@ -23,6 +25,11 @@ fun TwoRowKeyBar( connectionState: ConnectionState, isShiftPressed: Boolean = false, isLightPenMode: Boolean = false, + isInsertMode: Boolean = false, + isDocMode: Boolean = false, + isWordWrap: Boolean = false, + isAplMode: Boolean = false, + fourColorOverride: Boolean = false, hapticFeedbackEnabled: Boolean = true, onClearShift: () -> Unit = {}, onConnectClick: () -> Unit, @@ -34,6 +41,11 @@ fun TwoRowKeyBar( onScriptClick: () -> Unit = {}, onPrinterSessionClick: () -> Unit = {}, onToggleLightPen: () -> Unit = {}, + onToggleInsert: () -> Unit = {}, + onToggleDocMode: () -> Unit = {}, + onToggleWordWrap: () -> Unit = {}, + onToggleAplMode: () -> Unit = {}, + onToggleFourColorOverride: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onSendAid: (Int) -> Unit, onReset: () -> Unit, @@ -51,6 +63,11 @@ fun TwoRowKeyBar( modifier: Modifier = Modifier ) { var menuExpanded by remember { mutableStateOf(false) } + var lastDismissTime by remember { mutableLongStateOf(0L) } + + BackHandler(enabled = menuExpanded) { + menuExpanded = false + } Column( modifier = modifier @@ -73,11 +90,22 @@ fun TwoRowKeyBar( modifier = Modifier.fillMaxWidth(), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, - onClick = { menuExpanded = true } + onClick = { + if (System.currentTimeMillis() - lastDismissTime > 200L) { + menuExpanded = !menuExpanded + } + } ) DropdownMenu( expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, + onDismissRequest = { + menuExpanded = false + lastDismissTime = System.currentTimeMillis() + }, + properties = PopupProperties( + focusable = false, + dismissOnClickOutside = true + ), modifier = Modifier .background(Color(0xFF2C2D30)) .heightIn(max = 420.dp) @@ -178,6 +206,42 @@ fun TwoRowKeyBar( } ) HorizontalDivider(color = Color(0xFF3E4044)) + DropdownMenuItem( + text = { Text("Insert Mode: ${if (isInsertMode) "ON" else "OFF"}", color = if (isInsertMode) Color(0xFF7890F0) else Color.White) }, + onClick = { + menuExpanded = false + onToggleInsert() + } + ) + DropdownMenuItem( + text = { Text("Document Mode (DOC): ${if (isDocMode) "ON" else "OFF"}", color = if (isDocMode) Color(0xFF38D9A9) else Color.White) }, + onClick = { + menuExpanded = false + onToggleDocMode() + } + ) + DropdownMenuItem( + text = { Text("Word Wrap: ${if (isWordWrap) "ON" else "OFF"}", color = if (isWordWrap) Color(0xFF38D9A9) else Color.White) }, + onClick = { + menuExpanded = false + onToggleWordWrap() + } + ) + DropdownMenuItem( + text = { Text("APL Keyboard Mode: ${if (isAplMode) "ON" else "OFF"}", color = if (isAplMode) Color(0xFFFFA94D) else Color.White) }, + onClick = { + menuExpanded = false + onToggleAplMode() + } + ) + DropdownMenuItem( + text = { Text("Base 4-Color Override: ${if (fourColorOverride) "ON" else "OFF"}", color = if (fourColorOverride) Color(0xFFFFD43B) else Color.White) }, + onClick = { + menuExpanded = false + onToggleFourColorOverride() + } + ) + HorizontalDivider(color = Color(0xFF3E4044)) } DropdownMenuItem( text = { diff --git a/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt b/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt index f15538f..f87d400 100644 --- a/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt +++ b/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt @@ -1,6 +1,7 @@ package haus.nightmare.a3270.ui import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface @@ -27,15 +28,29 @@ fun OiaStatusBar( isTls: Boolean = false, isTlsVerified: Boolean = true, graphicsMode: String = "NONE", - codePage: String = "037", isLightPenMode: Boolean = false, isInsertMode: Boolean = false, + isDocMode: Boolean = false, + isWordWrap: Boolean = false, + isAplMode: Boolean = false, + onToggleInsert: (() -> Unit)? = null, + onToggleDocMode: (() -> Unit)? = null, + onToggleWordWrap: (() -> Unit)? = null, + onToggleAplMode: (() -> Unit)? = null, inhibitReason: Int = 0, luName: String = "", isNumericField: Boolean = false, - modelName: String = "", + uiTheme: String = "DARK", modifier: Modifier = Modifier ) { + val isLight = uiTheme.equals("LIGHT", ignoreCase = true) + val barBg = if (isLight) Color(0xFFE9ECEF) else Color(0xFF161719) + val defaultTextClr = if (isLight) Color(0xFF212529) else Color.White + val sysAvailClr = if (isLight) Color(0xFF005AC8) else Color(0xFF7890F0) + val inputInhibitedClr = if (isLight) Color(0xFF141414) else Color(0xFFFFFFFF) + val attentionClr = if (isLight) Color(0xFFB46400) else Color(0xFFFFFF00) + val commCheckClr = if (isLight) Color(0xFFBE1414) else Color(0xFFFF0000) + val row = if (cols > 0 && rows > 0) ((cursorAddr / cols) % rows) + 1 else 1 val col = if (cols > 0) (cursorAddr % cols) + 1 else 1 val posStr = String.format("%03d/%03d [%04d]", row, col, cursorAddr) @@ -66,24 +81,24 @@ fun OiaStatusBar( else -> "X LOCKED" } val clr = when (inhibitReason) { - haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> Color(0xFFFF0000) - haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> Color(0xFFFFFF00) - else -> Color(0xFFFFFFFF) + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> commCheckClr + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> attentionClr + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> attentionClr + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> attentionClr + else -> inputInhibitedClr } txt to clr } else if (isKeyboardLocked) { - "X SYSTEM" to Color(0xFFFFFFFF) - } else if (isInsertMode) { - "INSERT" to Color(0xFF7890F0) + "X SYSTEM" to inputInhibitedClr } else { - "READY" to Color(0xFF7890F0) + "READY" to sysAvailClr } Row( modifier = modifier .fillMaxWidth() .height(26.dp) - .background(Color(0xFF161719)) + .background(barBg) .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -101,7 +116,7 @@ fun OiaStatusBar( Spacer(modifier = Modifier.width(6.dp)) Text( text = hostText, - color = Color.White, + color = defaultTextClr, fontSize = 11.sp, fontFamily = FontFamily.Monospace, 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 if (connectionState.isConnected() && rows > 0 && cols > 0) { 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 if (isLightPenMode) { 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) Surface( color = lockStatusColor.copy(alpha = 0.2f), @@ -276,7 +327,7 @@ fun OiaStatusBar( // Cursor Position & Buffer Address Text( text = posStr, - color = Color(0xFF22B8CF), + color = if (isLight) Color(0xFF0C8599) else Color(0xFF22B8CF), fontSize = 10.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold diff --git a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt index cfc2b3c..7e9bec2 100644 --- a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt @@ -33,6 +33,17 @@ fun SettingsDialog( initialStartupBehavior: String = "SHOW_CONNECT", initialDynamicRows: Int = 62, initialDynamicCols: Int = 160, + initialEntryAssistDocMode: Boolean = false, + initialEntryAssistWordWrap: Boolean = false, + initialEntryAssistStartCol: Int = 1, + initialEntryAssistEndCol: Int = 80, + initialEntryAssistBell: Boolean = false, + initialEntryAssistBellCol: Int = 75, + initialEntryAssistTabStops: String = "5,10,15,20,25,30,35,40,45,50,55,60,65,70,75", + initialInsertOffOnAid: Boolean = true, + initialFourColorOverride: Boolean = false, + initialNumericFieldLock: Boolean = true, + initialAutoSkipEnabled: Boolean = true, onDismiss: () -> Unit, onSave: ( maskHiddenInput: Boolean, @@ -47,7 +58,18 @@ fun SettingsDialog( blockSelectMode: Boolean, startupBehavior: String, 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 ) { val context = androidx.compose.ui.platform.LocalContext.current @@ -68,6 +90,18 @@ fun SettingsDialog( var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) } var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) } + var entryAssistDocMode by remember { mutableStateOf(initialEntryAssistDocMode) } + var entryAssistWordWrap by remember { mutableStateOf(initialEntryAssistWordWrap) } + var entryAssistStartColStr by remember { mutableStateOf(initialEntryAssistStartCol.toString()) } + var entryAssistEndColStr by remember { mutableStateOf(initialEntryAssistEndCol.toString()) } + var entryAssistBell by remember { mutableStateOf(initialEntryAssistBell) } + var entryAssistBellColStr by remember { mutableStateOf(initialEntryAssistBellCol.toString()) } + var entryAssistTabStops by remember { mutableStateOf(initialEntryAssistTabStops) } + var insertOffOnAid by remember { mutableStateOf(initialInsertOffOnAid) } + var fourColorOverride by remember { mutableStateOf(initialFourColorOverride) } + var numericFieldLock by remember { mutableStateOf(initialNumericFieldLock) } + var autoSkipEnabled by remember { mutableStateOf(initialAutoSkipEnabled) } + var showImportDialog by remember { mutableStateOf(false) } Dialog( @@ -416,7 +450,207 @@ fun SettingsDialog( HorizontalDivider(color = Color(0xFF2C2D30)) 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("Compatible with j3270 desktop INI configuration files.", fontSize = 12.sp, color = Color.LightGray) Spacer(modifier = Modifier.height(8.dp)) @@ -459,6 +693,9 @@ fun SettingsDialog( onClick = { val dRows = dynamicRowsStr.trim().toIntOrNull()?.coerceIn(24, 255) ?: 62 val dCols = dynamicColsStr.trim().toIntOrNull()?.coerceIn(80, 255) ?: 160 + val eaStartCol = entryAssistStartColStr.trim().toIntOrNull()?.coerceIn(1, 80) ?: 1 + val eaEndCol = entryAssistEndColStr.trim().toIntOrNull()?.coerceIn(1, 80) ?: 80 + val eaBellCol = entryAssistBellColStr.trim().toIntOrNull()?.coerceIn(1, 80) ?: 75 onSave( maskHiddenInput, cursorBlink, @@ -472,7 +709,18 @@ fun SettingsDialog( blockSelectMode, startupBehavior, dRows, - dCols + dCols, + entryAssistDocMode, + entryAssistWordWrap, + eaStartCol, + eaEndCol, + entryAssistBell, + eaBellCol, + entryAssistTabStops, + insertOffOnAid, + fourColorOverride, + numericFieldLock, + autoSkipEnabled ) }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) @@ -517,6 +765,17 @@ fun SettingsDialog( dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString() defaultCodePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context) defaultGraphicsMode = haus.nightmare.a3270.storage.AppSettings.getDefaultGraphicsMode(context) + entryAssistDocMode = haus.nightmare.a3270.storage.AppSettings.getEntryAssistDocMode(context) + entryAssistWordWrap = haus.nightmare.a3270.storage.AppSettings.getEntryAssistWordWrap(context) + entryAssistStartColStr = haus.nightmare.a3270.storage.AppSettings.getEntryAssistStartCol(context).toString() + entryAssistEndColStr = haus.nightmare.a3270.storage.AppSettings.getEntryAssistEndCol(context).toString() + entryAssistBell = haus.nightmare.a3270.storage.AppSettings.getEntryAssistBell(context) + entryAssistBellColStr = haus.nightmare.a3270.storage.AppSettings.getEntryAssistBellCol(context).toString() + entryAssistTabStops = haus.nightmare.a3270.storage.AppSettings.getEntryAssistTabStops(context) + insertOffOnAid = haus.nightmare.a3270.storage.AppSettings.getInsertOffOnAid(context) + fourColorOverride = haus.nightmare.a3270.storage.AppSettings.getFourColorOverride(context) + numericFieldLock = haus.nightmare.a3270.storage.AppSettings.getNumericFieldLock(context) + autoSkipEnabled = haus.nightmare.a3270.storage.AppSettings.getAutoSkipEnabled(context) android.widget.Toast.makeText(context, "Settings imported from INI", android.widget.Toast.LENGTH_SHORT).show() } catch (e: Exception) { android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show() diff --git a/src/main/java/haus/nightmare/a3270/ui/TerminalInputView.kt b/src/main/java/haus/nightmare/a3270/ui/TerminalInputView.kt index ee95b6b..37f42a8 100644 --- a/src/main/java/haus/nightmare/a3270/ui/TerminalInputView.kt +++ b/src/main/java/haus/nightmare/a3270/ui/TerminalInputView.kt @@ -111,4 +111,10 @@ class TerminalInputView(context: Context) : View(context) { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } + + fun hideSoftKeyboard() { + clearFocus() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(windowToken, 0) + } } diff --git a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt index f0b635b..2ecde7a 100644 --- a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt +++ b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt @@ -70,6 +70,7 @@ fun TerminalView( cursorStyle: String = "BLOCK", isInsertMode: Boolean = false, blockSelectMode: Boolean = false, + fourColorOverride: Boolean = false, searchHighlightAddr: Int = -1, searchHighlightLen: Int = 0, onTapAddress: (Int) -> Unit, @@ -256,19 +257,17 @@ fun TerminalView( if (graphicsPlane != null && graphicsPlane.hasContent()) { val gridW = metrics.gridWidth.toInt() val gridH = metrics.gridHeight.toInt() - if (gridW > 0 && gridH > 0) { - val gWidth = graphicsPlane.canvasWidth - val gHeight = graphicsPlane.canvasHeight - val rgb = graphicsPlane.rgbBuffer - if (rgb != null && gWidth > 0 && gHeight > 0) { - val bmp = android.graphics.Bitmap.createBitmap(rgb, gWidth, gHeight, android.graphics.Bitmap.Config.ARGB_8888) - drawContext.canvas.nativeCanvas.drawBitmap( - bmp, - null, - android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()), - null - ) - } + val gWidth = graphicsPlane.canvasWidth + val gHeight = graphicsPlane.canvasHeight + val rgb = graphicsPlane.rgbBuffer + if (gridW > 0 && gridH > 0 && rgb != null && gWidth > 0 && gHeight > 0) { + val bmp = android.graphics.Bitmap.createBitmap(rgb, gWidth, gHeight, android.graphics.Bitmap.Config.ARGB_8888) + drawContext.canvas.nativeCanvas.drawBitmap( + bmp, + null, + android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()), + null + ) } } @@ -321,7 +320,7 @@ fun TerminalView( } // Compute 3270 color and field intensity - fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA) + fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA, fourColorOverride) bgColor = getBgColorForAttribute(ea, currentFieldEa) csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF) @@ -729,13 +728,20 @@ private fun copySelection( } } -internal fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color { - val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF) - else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF) - else 0 +internal fun getFgColorForAttribute( + ea: ExtendedAttribute, + currentFieldEa: ExtendedAttribute?, + currentFA: Byte, + fourColorOverride: Boolean = false +): Color { + if (!fourColorOverride) { + val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF) + else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF) + else 0 - if (fg in 0xf0..0xff) { - return HOST_COLORS[fg - 0xf0] + if (fg in 0xf0..0xff) { + return HOST_COLORS[fg - 0xf0] + } } val fa = currentFA.toInt() and 0xFF return if (faIsProtected(fa)) { diff --git a/src/test/java/haus/nightmare/a3270/EntryAssistAndModesTest.kt b/src/test/java/haus/nightmare/a3270/EntryAssistAndModesTest.kt new file mode 100644 index 0000000..ab171eb --- /dev/null +++ b/src/test/java/haus/nightmare/a3270/EntryAssistAndModesTest.kt @@ -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() + + 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)) + } +}