13 Commits

Author SHA1 Message Date
rudi e82d01003b IBM-DYNAMIC
Build and Test a3270 / Build Android APK (push) Successful in 7m24s
Release a3270 / Build & Publish Release (push) Successful in 7m53s
2026-09-03 16:49:49 -04:00
rudi 31b85f491c Overnight churning adaptations
Build and Test a3270 / Build Android APK (push) Successful in 7m23s
Release a3270 / Build & Publish Release (push) Successful in 7m48s
2026-09-01 10:11:29 -04:00
rudi 9f96555c9d Begin v1.0.1 commits
Build and Test a3270 / Build Android APK (push) Successful in 7m26s
2026-08-31 10:30:32 -04:00
rudi a7cc4b9d50 v1
Build and Test a3270 / Build Android APK (push) Successful in 7m29s
Release a3270 / Build & Publish Release (push) Successful in 7m18s
2026-08-31 00:28:47 +00:00
rudi 600b626e94 And now with more buttons
Build and Test a3270 / Build Android APK (push) Successful in 7m2s
2026-08-28 14:17:25 -04:00
rudi 18c4203c0d Add lib3270j updates
Build and Test a3270 / Build Android APK (push) Successful in 6m57s
2026-08-28 14:13:41 -04:00
rudi f6d69bf0ff Refactor for consistency
Build and Test a3270 / Build Android APK (push) Successful in 6m54s
2026-08-25 16:05:28 +00:00
rudi aa008664bc Fix bug with xedit
Build and Test a3270 / Build Android APK (push) Successful in 4m37s
2026-08-25 01:12:09 +00:00
rudi 8344b9f4f7 j3270 updates applied for lightpen
Build and Test a3270 / Build Android APK (push) Successful in 4m34s
2026-08-24 20:46:09 +00:00
rudi 052a6619ae Add some cleaning
Build and Test a3270 / Build Android APK (push) Successful in 6m39s
2026-08-21 18:56:22 -04:00
rudi 2a22a636b6 Add screen ratio fixing
Build and Test a3270 / Build Android APK (push) Successful in 6m49s
Release a3270 / Build & Publish Release (push) Successful in 4m43s
2026-08-21 17:59:41 -04:00
rudi d3c149fe4a Add hardware keyboard support
Build and Test a3270 / Build Android APK (push) Successful in 6m43s
2026-08-21 17:51:42 -04:00
rudi 8c8e1f17db Fix graphics
Release a3270 / Build & Publish Release (push) Successful in 4m39s
Build and Test a3270 / Build Android APK (push) Successful in 6m36s
2026-08-21 16:39:14 -04:00
38 changed files with 7814 additions and 2139 deletions
+1 -9
View File
@@ -2,7 +2,7 @@
[![Build & Test](https://git.hugfreevikings.wtf/rudi/a3270/actions/workflows/build.yaml/badge.svg)](https://git.hugfreevikings.wtf/rudi/a3270/actions) [![Build & Test](https://git.hugfreevikings.wtf/rudi/a3270/actions/workflows/build.yaml/badge.svg)](https://git.hugfreevikings.wtf/rudi/a3270/actions)
[![Android](https://img.shields.io/badge/Android-API%2024%2B%20%7C%20Compose-green.svg)](https://developer.android.com) [![Android](https://img.shields.io/badge/Android-API%2024%2B%20%7C%20Compose-green.svg)](https://developer.android.com)
[![License](https://img.shields.io/badge/License-MIT%20%2F%20BSD-green.svg)](LICENSE) [![License: Unlicense](https://img.shields.io/badge/License-Unlicense-blue.svg)](LICENSE)
An **x3270-aligned IBM 3270 mainframe terminal emulator for Android**, written in Kotlin using **Jetpack Compose** and powered by the `lib3270j` protocol engine from [j3270](https://git.hugfreevikings.wtf/rudi/j3270). Designed for mobile access to IBM z/OS, z/VM, CMS, and TSO systems over TN3270 and TN3270E. An **x3270-aligned IBM 3270 mainframe terminal emulator for Android**, written in Kotlin using **Jetpack Compose** and powered by the `lib3270j` protocol engine from [j3270](https://git.hugfreevikings.wtf/rudi/j3270). Designed for mobile access to IBM z/OS, z/VM, CMS, and TSO systems over TN3270 and TN3270E.
@@ -93,14 +93,6 @@ build/outputs/apk/debug/a3270-debug.apk
--- ---
## 🤝 Contributing
Contributions are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
I have contributed no code to this project, it was entirely written by LLMs with my guidance only.
---
## 📜 Acknowledgements ## 📜 Acknowledgements
- [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine - [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine
+8 -4
View File
@@ -4,15 +4,15 @@ plugins {
} }
android { android {
namespace 'org.pubvm.a3270' namespace 'haus.nightmare.a3270'
compileSdk 34 compileSdk 34
defaultConfig { defaultConfig {
applicationId "org.pubvm.a3270" applicationId "haus.nightmare.a3270"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 4 versionCode 6
versionName "0.1.3" versionName "1.0.1"
} }
buildTypes { buildTypes {
@@ -69,4 +69,8 @@ dependencies {
implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material3:material3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20231013'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
} }
+6 -2
View File
@@ -9,9 +9,11 @@ echo "=== Building a3270 (Android APK) ==="
# Set up JAVA_HOME (prefer JDK 21 if available) # Set up JAVA_HOME (prefer JDK 21 if available)
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem" export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
elif [ -d "$HOME/.sdkman/candidates/java/21.0.2-open" ]; then
export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.2-open"
elif [ -z "$JAVA_HOME" ]; then elif [ -z "$JAVA_HOME" ]; then
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then if [ -d "$HOME/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current" export JAVA_HOME="$HOME/.sdkman/candidates/java/current"
fi fi
fi fi
@@ -19,6 +21,8 @@ fi
if [ -z "$ANDROID_HOME" ]; then if [ -z "$ANDROID_HOME" ]; then
if [ -d "$HOME/Library/Android/sdk" ]; then if [ -d "$HOME/Library/Android/sdk" ]; then
export ANDROID_HOME="$HOME/Library/Android/sdk" export ANDROID_HOME="$HOME/Library/Android/sdk"
elif [ -d "$HOME/Android/Sdk" ]; then
export ANDROID_HOME="$HOME/Android/Sdk"
fi fi
fi fi
+1
View File
@@ -1,3 +1,4 @@
android.useAndroidX=true android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
kotlin.compiler.execution.strategy=in-process
@@ -0,0 +1,915 @@
package haus.nightmare.a3270
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import android.content.Context
import android.view.KeyCharacterMap
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.ui.ConnectDialog
import haus.nightmare.a3270.ui.FileTransferDialog
import haus.nightmare.a3270.ui.OiaStatusBar
import haus.nightmare.a3270.ui.SettingsDialog
import haus.nightmare.a3270.ui.TerminalInputView
import haus.nightmare.a3270.ui.TerminalView
import haus.nightmare.a3270.ui.TwoRowKeyBar
import haus.nightmare.a3270.ui.FieldInspectorDialog
import haus.nightmare.a3270.ui.FindDialog
import haus.nightmare.a3270.ui.ScreenExporterDialog
import haus.nightmare.a3270.ui.ScriptDialog
import haus.nightmare.a3270.ui.PrinterSessionDialog
import haus.nightmare.a3270.ui.UntrustedCertificateDialog
class MainActivity : ComponentActivity() {
companion object {
init {
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = java.util.logging.Level.ALL
for (h in rootLogger.handlers) {
rootLogger.removeHandler(h)
}
rootLogger.addHandler(object : java.util.logging.Handler() {
override fun publish(record: java.util.logging.LogRecord) {
val tag = "lib3270j-" + (record.loggerName?.substringAfterLast('.') ?: "core")
val msg = record.message ?: ""
when (record.level.intValue()) {
java.util.logging.Level.SEVERE.intValue() -> android.util.Log.e(tag, msg, record.thrown)
java.util.logging.Level.WARNING.intValue() -> android.util.Log.w(tag, msg, record.thrown)
java.util.logging.Level.INFO.intValue() -> android.util.Log.i(tag, msg, record.thrown)
java.util.logging.Level.CONFIG.intValue() -> android.util.Log.d(tag, msg, record.thrown)
else -> android.util.Log.v(tag, msg, record.thrown)
}
}
override fun flush() {}
override fun close() {}
})
}
}
private val viewModel: TerminalViewModel by viewModels()
private val isShiftPressedState = mutableStateOf(false)
private val isFindDialogOpenState = mutableStateOf(false)
private val isFtDialogOpenState = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
}
}
setContent {
val uiTheme by viewModel.uiTheme.collectAsState()
val colorScheme = if (uiTheme.equals("LIGHT", ignoreCase = true)) {
lightColorScheme(
background = Color(0xFFF2F2F4),
surface = Color.White,
primary = Color(0xFF1971C2)
)
} else {
darkColorScheme(
background = Color.Black,
surface = Color(0xFF1E1E1E),
primary = Color(0xFF339AF0)
)
}
MaterialTheme(colorScheme = colorScheme) {
MainScreen(
viewModel = viewModel,
isShiftPressed = isShiftPressedState.value,
onClearShift = { isShiftPressedState.value = false },
isFindDialogOpen = isFindDialogOpenState.value,
onSetFindDialogOpen = { isFindDialogOpenState.value = it },
isFtDialogOpen = isFtDialogOpenState.value,
onSetFtDialogOpen = { isFtDialogOpenState.value = it }
)
}
}
}
private val log = java.util.logging.Logger.getLogger("a3270-MainActivity")
private var deadKey: Int = 0
private fun getEffectiveUnicodeChar(event: KeyEvent): Int {
val cleanMeta = event.metaState and (
KeyEvent.META_SHIFT_ON or
KeyEvent.META_SHIFT_LEFT_ON or
KeyEvent.META_SHIFT_RIGHT_ON or
KeyEvent.META_CAPS_LOCK_ON
)
// 1. Try getUnicodeChar with cleanMeta
var u = event.getUnicodeChar(cleanMeta)
if (u > 0) return u
// 2. Try unicodeChar property
u = event.unicodeChar
if (u > 0) return u
// 3. Try KeyCharacterMap with cleanMeta
val kcm = event.keyCharacterMap ?: KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)
u = kcm.get(event.keyCode, cleanMeta)
if (u > 0) return u
// 4. Try KeyCharacterMap with meta = 0
u = kcm.get(event.keyCode, 0)
if (u > 0) {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (shift) {
val c = u.toChar()
if (c.isLowerCase()) return c.uppercaseChar().code
}
return u
}
// 5. Try displayLabel as fallback
val label = event.displayLabel
if (label >= ' ') {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
return if (shift) label.uppercaseChar().code else label.lowercaseChar().code
}
return 0
}
@Suppress("DEPRECATION")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) {
isShiftPressedState.value = shift
}
// Handle string / multi-character input (e.g. adb input text, barcode scanners, fast text input)
if (event.action == KeyEvent.ACTION_MULTIPLE) {
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN) {
val chars = event.characters
if (!chars.isNullOrEmpty()) {
viewModel.typeString(chars)
return true
}
} else {
val unicode = getEffectiveUnicodeChar(event)
if (unicode >= 32) {
val count = maxOf(1, event.repeatCount)
viewModel.typeString(unicode.toChar().toString().repeat(count))
return true
}
}
}
if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode
val ctrl = event.isCtrlPressed || (event.metaState and KeyEvent.META_CTRL_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)
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
val pfAids = intArrayOf(
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
)
viewModel.sendAid(pfAids[pfNum - 1])
if (shift) {
isShiftPressedState.value = false
}
return true
}
// 2. Ctrl Shortcuts
if (ctrl) {
when (keyCode) {
KeyEvent.KEYCODE_F -> {
isFindDialogOpenState.value = true
return true
}
KeyEvent.KEYCODE_D -> {
viewModel.disconnect()
return true
}
KeyEvent.KEYCODE_V -> {
pasteFromClipboard()
return true
}
KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard()
return true
}
KeyEvent.KEYCODE_L -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_Z -> {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_A -> {
viewModel.attn()
return true
}
KeyEvent.KEYCODE_S -> {
viewModel.sysReq()
return true
}
KeyEvent.KEYCODE_DPAD_LEFT -> {
viewModel.wordLeft()
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
viewModel.wordRight()
return true
}
KeyEvent.KEYCODE_FORWARD_DEL, KeyEvent.KEYCODE_DEL -> {
viewModel.deleteWord()
return true
}
KeyEvent.KEYCODE_HOME -> {
viewModel.cursorHome()
return true
}
KeyEvent.KEYCODE_MOVE_END -> {
viewModel.eraseEof()
return true
}
}
}
// 3. Alt Shortcuts (PA keys, Clear, Reset, Enter, Erase, Attn, SysReq, CursorSelect, Ruler, FT)
if (alt) {
when (keyCode) {
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
viewModel.sendAid(AID_ENTER)
return true
}
KeyEvent.KEYCODE_1 -> {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_2 -> {
viewModel.sendAid(AID_PA2)
return true
}
KeyEvent.KEYCODE_3 -> {
viewModel.sendAid(AID_PA3)
return true
}
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_K -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard()
return true
}
KeyEvent.KEYCODE_E -> {
viewModel.eraseInput()
return true
}
KeyEvent.KEYCODE_A -> {
viewModel.attn()
return true
}
KeyEvent.KEYCODE_S -> {
viewModel.sysReq()
return true
}
KeyEvent.KEYCODE_Q -> {
viewModel.cursorSelect()
return true
}
KeyEvent.KEYCODE_X -> {
viewModel.toggleCrosshairRuler()
val isRuler = viewModel.isCrosshairRuler.value
Toast.makeText(this, "Crosshair Ruler: " + (if (isRuler) "ON" else "OFF"), Toast.LENGTH_SHORT).show()
return true
}
KeyEvent.KEYCODE_T -> {
isFtDialogOpenState.value = true
return true
}
KeyEvent.KEYCODE_D -> {
viewModel.deleteWord()
return true
}
KeyEvent.KEYCODE_L -> {
viewModel.toggleLightPen()
val isLp = viewModel.isLightPenMode.value
Toast.makeText(this, "Light Pen: " + (if (isLp) "ON" else "OFF"), Toast.LENGTH_SHORT).show()
return true
}
}
}
// 4. Shift Shortcuts
if (shift && keyCode == KeyEvent.KEYCODE_INSERT) {
pasteFromClipboard()
return true
}
// 5. Standard Navigation & Special Keys
when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
viewModel.cursorLeft()
return true
}
KeyEvent.KEYCODE_DPAD_UP -> {
viewModel.cursorUp()
return true
}
KeyEvent.KEYCODE_DPAD_DOWN -> {
viewModel.cursorDown()
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
viewModel.cursorRight()
return true
}
KeyEvent.KEYCODE_MOVE_HOME -> {
viewModel.cursorHome()
return true
}
KeyEvent.KEYCODE_MOVE_END -> {
viewModel.eraseEof()
return true
}
KeyEvent.KEYCODE_PAGE_UP -> {
viewModel.sendAid(AID_PF7)
return true
}
KeyEvent.KEYCODE_PAGE_DOWN -> {
viewModel.sendAid(AID_PF8)
return true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (shift) {
viewModel.eraseEof()
isShiftPressedState.value = false
} else {
viewModel.sendAid(AID_ENTER)
}
return true
}
KeyEvent.KEYCODE_TAB -> {
if (shift) {
viewModel.backTab()
isShiftPressedState.value = false
} else {
viewModel.tab()
}
return true
}
KeyEvent.KEYCODE_DEL -> {
viewModel.backspace()
return true
}
KeyEvent.KEYCODE_FORWARD_DEL -> {
viewModel.deleteChar()
return true
}
KeyEvent.KEYCODE_ESCAPE -> {
if (shift) {
viewModel.eraseInput()
isShiftPressedState.value = false
} else {
viewModel.resetKeyboard()
}
return true
}
KeyEvent.KEYCODE_BREAK -> {
viewModel.sendAid(AID_PA1)
return true
}
}
// 6. Alphanumeric & Symbol Character Input
val unicode = getEffectiveUnicodeChar(event)
log.info("dispatchKeyEvent DOWN: keyCode=$keyCode, metaState=${event.metaState}, unicode=$unicode, char='${if (unicode > 0) unicode.toChar() else ' '}'")
if (unicode > 0) {
if ((unicode and KeyCharacterMap.COMBINING_ACCENT) != 0) {
deadKey = unicode and KeyCharacterMap.COMBINING_ACCENT_MASK
return true
}
val charToType = if (deadKey != 0) {
val combined = KeyCharacterMap.getDeadChar(deadKey, unicode)
deadKey = 0
if (combined != 0) combined.toChar() else unicode.toChar()
} else {
unicode.toChar()
}
if (charToType >= ' ') {
log.info("dispatchKeyEvent typing char: '$charToType'")
viewModel.typeString(charToType.toString())
return true
}
} else {
log.info("dispatchKeyEvent unhandled DOWN keyCode=$keyCode, metaState=${event.metaState}")
}
}
if (event.action == KeyEvent.ACTION_UP) {
when (event.keyCode) {
KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT,
KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT,
KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT -> {
// Let super handle modifier tracking
}
else -> {
return true
}
}
}
return super.dispatchKeyEvent(event)
}
private fun pasteFromClipboard() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
val clip = clipboard?.primaryClip
if (clip != null && clip.itemCount > 0) {
val text = clip.getItemAt(0)?.coerceToText(this)?.toString()
if (!text.isNullOrEmpty()) {
viewModel.pasteString(text)
}
}
}
}
@Composable
fun MainScreen(
viewModel: TerminalViewModel,
isShiftPressed: Boolean = false,
onClearShift: () -> Unit = {},
isFindDialogOpen: Boolean = false,
onSetFindDialogOpen: (Boolean) -> Unit = {},
isFtDialogOpen: Boolean = false,
onSetFtDialogOpen: (Boolean) -> Unit = {}
) {
val context = LocalContext.current
val connectionState by viewModel.connectionState.collectAsState()
val screenBuffer by viewModel.screenBuffer.collectAsState()
val rows by viewModel.rows.collectAsState()
val cols by viewModel.cols.collectAsState()
val cursorAddr by viewModel.cursorAddress.collectAsState()
val oiaText by viewModel.oiaText.collectAsState()
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
val screenVersion by viewModel.screenVersion.collectAsState()
val maskHiddenInput by viewModel.maskHiddenInput.collectAsState()
val cursorBlink by viewModel.cursorBlink.collectAsState()
val hapticFeedback by viewModel.hapticFeedback.collectAsState()
val verifyCerts by viewModel.verifyCerts.collectAsState()
val defaultGraphicsMode by viewModel.defaultGraphicsMode.collectAsState()
val codePage by viewModel.codePage.collectAsState()
val searchHighlightAddr by viewModel.searchHighlightAddr.collectAsState()
val searchHighlightLen by viewModel.searchHighlightLen.collectAsState()
val isTlsActive by viewModel.isTlsActive.collectAsState()
val isTlsVerified by viewModel.isTlsVerified.collectAsState()
val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState()
val ftState by viewModel.ftState.collectAsState()
val isLightPenMode by viewModel.isLightPenMode.collectAsState()
val isCrosshairRuler by viewModel.isCrosshairRuler.collectAsState()
val cursorStyle by viewModel.cursorStyle.collectAsState()
val blockSelectMode by viewModel.blockSelectMode.collectAsState()
val uiTheme by viewModel.uiTheme.collectAsState()
val isInsertMode by viewModel.isInsertMode.collectAsState()
val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) {
if (screenBuffer != null && screenBuffer?.isFormatted == true) {
val fa = screenBuffer?.getFieldAttributeAt(cursorAddr) ?: 0.toByte()
(fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0
} else false
}
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) }
var showSettingsDialog by remember { mutableStateOf(false) }
var showFieldInspectorDialog by remember { mutableStateOf(false) }
var showFindDialog by remember { mutableStateOf(false) }
var showScreenExportDialog by remember { mutableStateOf(false) }
var showScriptDialog by remember { mutableStateOf(false) }
var showPrinterSessionDialog by remember { mutableStateOf(false) }
LaunchedEffect(isFindDialogOpen) {
if (isFindDialogOpen) {
showFindDialog = true
onSetFindDialogOpen(false)
}
}
LaunchedEffect(isFtDialogOpen) {
if (isFtDialogOpen) {
showFtDialog = true
onSetFtDialogOpen(false)
}
}
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
viewModel.isAppInBackground = true
} else if (event == Lifecycle.Event.ON_START) {
viewModel.isAppInBackground = false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(Unit) {
val startupBehavior = AppSettings.getStartupBehavior(context)
when (startupBehavior) {
AppSettings.StartupBehavior.AUTO_CONNECT -> {
val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect(
autoHost.host,
autoHost.port,
autoHost.model,
autoHost.dynamicRows,
autoHost.dynamicCols,
autoHost.luName,
autoHost.hostType,
autoHost.useTls,
autoHost.tlsVerifyCert,
autoHost.tn3270e,
autoHost.graphicsMode,
autoHost.codePage,
autoHost.proxyType,
autoHost.proxyHost,
autoHost.proxyPort,
autoHost.proxyUsername,
autoHost.proxyPassword
)
}
}
AppSettings.StartupBehavior.SHOW_CONNECT -> {
if (!connectionState.isConnected()) {
showConnectDialog = true
}
}
AppSettings.StartupBehavior.DO_NOTHING -> {
// Do nothing
}
}
}
LaunchedEffect(connectionState) {
if (connectionState.isConnected()) {
val activeHost = viewModel.currentHost.ifBlank { "Mainframe" }
TerminalService.start(context, activeHost)
} else {
TerminalService.stop(context)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.windowInsetsPadding(WindowInsets.systemBars)
.imePadding()
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Main 3270 Terminal Screen View
TerminalView(
screenBuffer = screenBuffer,
rows = rows,
cols = cols,
cursorAddr = cursorAddr,
screenVersion = screenVersion,
programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane,
gocaDecoder = viewModel.getGocaDecoder(),
isLightPenMode = isLightPenMode,
maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink,
searchHighlightAddr = searchHighlightAddr,
searchHighlightLen = searchHighlightLen,
crosshairRuler = isCrosshairRuler,
cursorStyle = cursorStyle,
isInsertMode = isInsertMode,
blockSelectMode = blockSelectMode,
onPasteLineWrap = { text, wrapCol, wordWrap ->
viewModel.pasteLineWrap(text, wrapCol, wordWrap)
},
onTapAddress = { addr ->
viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onLightPenSelect = { addr ->
viewModel.selectLightPen(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onGraphicTouch = { clickAddr, px, py ->
viewModel.handleGraphicTouch(clickAddr, px, py)
},
onPasteText = { text ->
viewModel.pasteString(text)
},
modifier = Modifier.weight(1f)
)
// Termux-Style Native Input Connection View (Permanent Number Row, No Mode Reset, Lock Support)
AndroidView(
factory = { ctx ->
TerminalInputView(ctx).apply {
onInputText = { text ->
viewModel.typeString(text)
onClearShift()
}
onSendAid = { aid ->
viewModel.sendAid(aid)
}
onBackspace = {
viewModel.backspace()
onClearShift()
}
onTab = {
viewModel.tab()
}
onBackTab = {
viewModel.backTab()
}
terminalInputViewRef = this
}
},
update = { inputView ->
terminalInputViewRef = inputView
},
modifier = Modifier.size(1.dp)
)
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
TwoRowKeyBar(
connectionState = connectionState,
isShiftPressed = isShiftPressed,
isLightPenMode = isLightPenMode,
hapticFeedbackEnabled = hapticFeedback,
onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true },
onFieldInspectorClick = { showFieldInspectorDialog = true },
onFindClick = { showFindDialog = true },
onScreenExportClick = { showScreenExportDialog = true },
onScriptClick = { showScriptDialog = true },
onPrinterSessionClick = { showPrinterSessionDialog = true },
onToggleLightPen = { viewModel.toggleLightPen() },
onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid ->
viewModel.sendAid(aid)
terminalInputViewRef?.showSoftKeyboard()
},
onReset = {
viewModel.resetKeyboard()
terminalInputViewRef?.showSoftKeyboard()
},
onTab = {
viewModel.tab()
terminalInputViewRef?.showSoftKeyboard()
},
onBackTab = {
viewModel.backTab()
terminalInputViewRef?.showSoftKeyboard()
},
onEraseInput = {
viewModel.eraseInput()
terminalInputViewRef?.showSoftKeyboard()
},
onEraseEof = {
viewModel.eraseEof()
terminalInputViewRef?.showSoftKeyboard()
},
onAttn = {
viewModel.attn()
terminalInputViewRef?.showSoftKeyboard()
},
onSysReq = {
viewModel.sysReq()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorSelect = {
viewModel.cursorSelect()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorLeft = {
viewModel.cursorLeft()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorUp = {
viewModel.cursorUp()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorDown = {
viewModel.cursorDown()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorRight = {
viewModel.cursorRight()
terminalInputViewRef?.showSoftKeyboard()
}
)
// OIA Status Bar
OiaStatusBar(
connectionState = connectionState,
currentHost = viewModel.currentHost,
isKeyboardLocked = isKeyboardLocked,
oiaText = oiaText,
cursorAddr = cursorAddr,
rows = rows,
cols = cols,
isTls = isTlsActive,
isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode,
codePage = codePage,
isLightPenMode = isLightPenMode,
isInsertMode = isInsertMode,
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "",
isNumericField = isNumericField,
modelName = modelName
)
}
}
if (showConnectDialog) {
ConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass ->
showConnectDialog = false
viewModel.connect(host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showFtDialog) {
FileTransferDialog(
initialHostType = viewModel.activeHostType,
ftProgressState = ftState,
client = viewModel.getClient(),
onDismiss = { showFtDialog = false },
onStartTransfer = { config ->
viewModel.startFileTransfer(config) { error ->
if (error != null) {
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
}
}
},
onCancelTransfer = {
viewModel.cancelFileTransfer()
}
)
}
if (showSettingsDialog) {
SettingsDialog(
initialMaskHiddenInput = maskHiddenInput,
initialCursorBlink = cursorBlink,
initialHapticFeedback = hapticFeedback,
initialVerifyCerts = verifyCerts,
initialDefaultGraphicsMode = defaultGraphicsMode,
initialDefaultCodePage = codePage,
initialUiTheme = uiTheme,
initialCrosshairRuler = isCrosshairRuler,
initialCursorStyle = cursorStyle,
initialBlockSelectMode = blockSelectMode,
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 ->
showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp)
viewModel.setUiTheme(theme)
viewModel.setCrosshairRuler(crosshair)
viewModel.setCursorStyle(style)
viewModel.setBlockSelectMode(blockSel)
AppSettings.setStartupBehavior(context, AppSettings.StartupBehavior.valueOf(startup))
AppSettings.setDynamicRows(context, dynRows)
AppSettings.setDynamicCols(context, dynCols)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showFieldInspectorDialog) {
FieldInspectorDialog(
client = viewModel.getClient(),
screenVersion = screenVersion,
onDismiss = { showFieldInspectorDialog = false },
onJumpToField = { addr ->
viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showFindDialog) {
FindDialog(
cols = cols,
onDismiss = { showFindDialog = false },
onFind = { query: String, matchCase: Boolean, forward: Boolean ->
viewModel.find(query, matchCase, forward)
},
onClearHighlight = {
viewModel.clearSearchHighlight()
}
)
}
if (showScreenExportDialog) {
ScreenExporterDialog(
screenBuffer = screenBuffer,
rows = rows,
cols = cols,
programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane,
maskHidden = maskHiddenInput,
onDismiss = { showScreenExportDialog = false }
)
}
if (showScriptDialog) {
ScriptDialog(
onDismiss = { showScriptDialog = false },
onExecuteScript = { script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit ->
viewModel.executeScript(script, onStatus, onComplete)
}
)
}
if (showPrinterSessionDialog) {
val activeHost = viewModel.currentHost.ifBlank { "127.0.0.1" }
PrinterSessionDialog(
initialHost = activeHost,
initialCodePage = codePage,
initialUseTls = isTlsActive,
onDismiss = { showPrinterSessionDialog = false }
)
}
val prompt = untrustedCertPrompt
if (prompt != null) {
UntrustedCertificateDialog(
host = prompt.host,
port = prompt.port,
chain = prompt.chain,
exception = prompt.exception,
onAccept = {
viewModel.resolveUntrustedCert(true)
},
onReject = {
viewModel.resolveUntrustedCert(false)
}
)
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270 package haus.nightmare.a3270
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
@@ -11,19 +11,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.lib3270j.ConnectionConfig import haus.nightmare.lib3270j.ConnectionConfig
import org.lib3270j.ConnectionState import haus.nightmare.lib3270j.ConnectionState
import org.lib3270j.Telnet3270Client import haus.nightmare.lib3270j.Telnet3270Client
import org.lib3270j.TerminalModel import haus.nightmare.lib3270j.TerminalModel
import org.lib3270j.ft.FTConfig import haus.nightmare.lib3270j.ft.FTConfig
import org.lib3270j.listener.ConnectionListener import haus.nightmare.lib3270j.listener.ConnectionListener
import org.lib3270j.listener.ScreenUpdateListener import haus.nightmare.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.AID_ENTER import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.protocol.DS3270Constants.faIsProtected import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.lib3270j.screen.ScreenBuffer import haus.nightmare.a3270.ft.FileTransfer
import org.pubvm.a3270.ft.FileTransfer import haus.nightmare.a3270.service.TerminalService
import org.pubvm.a3270.service.TerminalService import haus.nightmare.a3270.storage.AppSettings
import org.pubvm.a3270.storage.AppSettings
import java.io.File import java.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.logging.Logger import java.util.logging.Logger
@@ -32,14 +31,30 @@ sealed interface TerminalInputAction {
data class TypeText(val text: String) : TerminalInputAction data class TypeText(val text: String) : TerminalInputAction
data class SendAid(val aidCode: Int) : TerminalInputAction data class SendAid(val aidCode: Int) : TerminalInputAction
data object Backspace : TerminalInputAction data object Backspace : TerminalInputAction
data object DeleteChar : TerminalInputAction
data object Tab : TerminalInputAction data object Tab : TerminalInputAction
data object BackTab : TerminalInputAction data object BackTab : TerminalInputAction
data object CursorLeft : TerminalInputAction data object CursorLeft : TerminalInputAction
data object CursorRight : TerminalInputAction data object CursorRight : TerminalInputAction
data object CursorUp : TerminalInputAction data object CursorUp : TerminalInputAction
data object CursorDown : TerminalInputAction data object CursorDown : TerminalInputAction
data object CursorHome : TerminalInputAction
data object EraseEof : TerminalInputAction
data object EraseInput : TerminalInputAction
data object Newline : TerminalInputAction
data object Reset : TerminalInputAction data object Reset : TerminalInputAction
data object WordLeft : TerminalInputAction
data object WordRight : TerminalInputAction
data object FieldEnd : TerminalInputAction
data object DeleteWord : TerminalInputAction
data object CursorSelect : TerminalInputAction
data object Attn : TerminalInputAction
data object SysReq : TerminalInputAction
data object Dup : TerminalInputAction
data object FieldMark : TerminalInputAction
data object ToggleInsert : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction data class SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(val baddr: Int) : TerminalInputAction
} }
data class UntrustedCertPromptState( data class UntrustedCertPromptState(
@@ -110,6 +125,15 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application)) private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application))
val defaultGraphicsMode: StateFlow<String> = _defaultGraphicsMode.asStateFlow() val defaultGraphicsMode: StateFlow<String> = _defaultGraphicsMode.asStateFlow()
private val _codePage = MutableStateFlow(AppSettings.getDefaultCodePage(application))
val codePage: StateFlow<String> = _codePage.asStateFlow()
private val _searchHighlightAddr = MutableStateFlow(-1)
val searchHighlightAddr: StateFlow<Int> = _searchHighlightAddr.asStateFlow()
private val _searchHighlightLen = MutableStateFlow(0)
val searchHighlightLen: StateFlow<Int> = _searchHighlightLen.asStateFlow()
private val _isTlsActive = MutableStateFlow(false) private val _isTlsActive = MutableStateFlow(false)
val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow() val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow()
@@ -119,11 +143,105 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null) private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null)
val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow() val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow()
private val _isLightPenMode = MutableStateFlow(false)
val isLightPenMode: StateFlow<Boolean> = _isLightPenMode.asStateFlow()
private val _isCrosshairRuler = MutableStateFlow(AppSettings.isCrosshairRulerEnabled(application))
val isCrosshairRuler: StateFlow<Boolean> = _isCrosshairRuler.asStateFlow()
private val _cursorStyle = MutableStateFlow(AppSettings.getCursorStyle(application))
val cursorStyle: StateFlow<String> = _cursorStyle.asStateFlow()
private val _blockSelectMode = MutableStateFlow(AppSettings.isBlockSelectModeEnabled(application))
val blockSelectMode: StateFlow<Boolean> = _blockSelectMode.asStateFlow()
private val _uiTheme = MutableStateFlow(AppSettings.getUiTheme(application))
val uiTheme: StateFlow<String> = _uiTheme.asStateFlow()
private val _isInsertMode = MutableStateFlow(false)
val isInsertMode: StateFlow<Boolean> = _isInsertMode.asStateFlow()
fun toggleLightPen() {
_isLightPenMode.value = !_isLightPenMode.value
}
fun setLightPenMode(enabled: Boolean) {
_isLightPenMode.value = enabled
}
fun setCrosshairRuler(enabled: Boolean) {
AppSettings.setCrosshairRulerEnabled(getApplication(), enabled)
_isCrosshairRuler.value = enabled
}
fun toggleCrosshairRuler() {
setCrosshairRuler(!_isCrosshairRuler.value)
}
fun setCursorStyle(style: String) {
AppSettings.setCursorStyle(getApplication(), style)
_cursorStyle.value = style
}
fun setBlockSelectMode(enabled: Boolean) {
AppSettings.setBlockSelectModeEnabled(getApplication(), enabled)
_blockSelectMode.value = enabled
}
fun setUiTheme(theme: String) {
AppSettings.setUiTheme(getApplication(), theme)
_uiTheme.value = theme
}
fun reloadSettings() {
val app = getApplication<Application>()
_maskHiddenInput.value = AppSettings.isMaskHiddenInputEnabled(app)
_cursorBlink.value = AppSettings.isCursorBlinkEnabled(app)
_hapticFeedback.value = AppSettings.isHapticFeedbackEnabled(app)
_verifyCerts.value = AppSettings.isVerifyCertsEnabled(app)
_defaultGraphicsMode.value = AppSettings.getDefaultGraphicsMode(app)
_codePage.value = AppSettings.getDefaultCodePage(app)
_isCrosshairRuler.value = AppSettings.isCrosshairRulerEnabled(app)
_cursorStyle.value = AppSettings.getCursorStyle(app)
_blockSelectMode.value = AppSettings.isBlockSelectModeEnabled(app)
_uiTheme.value = AppSettings.getUiTheme(app)
}
fun pasteLineWrap(text: String, endCol: Int, wordWrap: Boolean) {
val cur = client
if (cur != null && cur.connectionState.isFullSession) {
val curPos = cur.screenBuffer.cursorAddress
cur.ps?.pasteLineWrap(text, curPos, endCol, wordWrap)
_screenVersion.value = System.currentTimeMillis()
}
}
fun setSearchHighlight(addr: Int, len: Int) {
_searchHighlightAddr.value = addr
_searchHighlightLen.value = len
}
fun clearSearchHighlight() {
_searchHighlightAddr.value = -1
_searchHighlightLen.value = 0
}
fun setCodePage(cp: String) {
_codePage.value = cp
val c = client
if (c != null) {
c.codePage = cp
c.screenBuffer?.translateToUnicode()
_screenVersion.value = System.currentTimeMillis()
}
}
// File Transfer State // File Transfer State
private var fileTransferCoordinator: FileTransfer? = null private var fileTransferCoordinator: FileTransfer? = null
private val _ftState = MutableStateFlow(FTProgressState()) private val _ftState = MutableStateFlow(FTProgressState())
val ftState: StateFlow<FTProgressState> = _ftState.asStateFlow() val ftState: StateFlow<FTProgressState> = _ftState.asStateFlow()
@Volatile
private var client: Telnet3270Client? = null private var client: Telnet3270Client? = null
var currentHost: String = "" var currentHost: String = ""
private set private set
@@ -158,68 +276,236 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val c = client ?: return val c = client ?: return
val ip = c.inputProcessor val ip = c.inputProcessor
val buf = c.screenBuffer val buf = c.screenBuffer
val isNvt = c.connectionState.isNvt || ip.isNvtMode
try { try {
when (action) { when (action) {
is TerminalInputAction.TypeText -> { is TerminalInputAction.TypeText -> {
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}, isNvt=$isNvt")
ip.isKeyboardLocked = false ip.isKeyboardLocked = false
for (ch in action.text) { if (isNvt) {
if (ch == '\n' || ch == '\r') { for (ch in action.text) {
ip.setKeyboardLocked(false) if (ch == '\n' || ch == '\r') {
ip.sendAid(AID_ENTER) c.sendNVTString("\r\n")
} else if (ch >= ' ') { } else {
var curAddr = buf.cursorAddress c.sendNVTChar(ch)
if (buf.isFormatted) { }
val faVal = buf.getFieldAttributeAt(curAddr) }
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) { } else {
curAddr = buf.findNextUnprotected(curAddr) for (ch in action.text) {
buf.cursorAddress = curAddr if (ch == '\n' || ch == '\r') {
} ip.setKeyboardLocked(false)
ip.sendAid(AID_ENTER)
} else if (ch >= ' ') {
ip.typeCharacter(ch)
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(buf.cursorAddress).ucs4}'")
} }
ip.typeCharacter(ch)
} }
} }
} }
is TerminalInputAction.SendAid -> { is TerminalInputAction.SendAid -> {
ip.setKeyboardLocked(false) if (isNvt) {
ip.sendAid(action.aidCode) if (action.aidCode == AID_ENTER) {
c.sendNVTString("\r\n")
} else if (action.aidCode == AID_CLEAR) {
c.sendNVTChar('\u000C')
} else {
val pfNum = aidToPfNumber(action.aidCode)
if (pfNum > 0) {
val seq = c.nvtProcessor?.getFunctionKeySequence(pfNum) ?: ""
if (seq.isNotEmpty()) {
c.sendNVTString(seq)
}
}
}
} else {
ip.setKeyboardLocked(false)
ip.sendAid(action.aidCode)
}
} }
is TerminalInputAction.Backspace -> { is TerminalInputAction.Backspace -> {
ip.isKeyboardLocked = false if (isNvt) {
ip.backspace() c.sendNVTChar('\b')
} else {
ip.isKeyboardLocked = false
ip.backspace()
}
}
is TerminalInputAction.DeleteChar -> {
if (isNvt) {
c.sendNVTString("\u001B[3~")
} else {
ip.isKeyboardLocked = false
ip.deleteChar()
}
} }
is TerminalInputAction.Tab -> { is TerminalInputAction.Tab -> {
ip.tab() if (isNvt) {
c.sendNVTChar('\t')
} else {
ip.tab()
}
} }
is TerminalInputAction.BackTab -> { is TerminalInputAction.BackTab -> {
ip.backTab() if (isNvt) {
c.sendNVTString("\u001B[Z")
} else {
ip.backTab()
}
} }
is TerminalInputAction.CursorLeft -> { is TerminalInputAction.CursorLeft -> {
ip.cursorLeft() if (isNvt) {
c.sendNVTString("\u001B[D")
} else {
ip.cursorLeft()
}
} }
is TerminalInputAction.CursorRight -> { is TerminalInputAction.CursorRight -> {
ip.cursorRight() if (isNvt) {
c.sendNVTString("\u001B[C")
} else {
ip.cursorRight()
}
} }
is TerminalInputAction.CursorUp -> { is TerminalInputAction.CursorUp -> {
ip.cursorUp() if (isNvt) {
c.sendNVTString("\u001B[A")
} else {
ip.cursorUp()
}
} }
is TerminalInputAction.CursorDown -> { is TerminalInputAction.CursorDown -> {
ip.cursorDown() if (isNvt) {
c.sendNVTString("\u001B[B")
} else {
ip.cursorDown()
}
}
is TerminalInputAction.CursorHome -> {
if (isNvt) {
c.sendNVTString("\u001B[H")
} else {
ip.cursorHome()
}
}
is TerminalInputAction.EraseEof -> {
if (isNvt) {
c.sendNVTString("\u001B[F")
} else {
ip.isKeyboardLocked = false
ip.eraseEof()
}
}
is TerminalInputAction.EraseInput -> {
if (!isNvt) {
ip.isKeyboardLocked = false
ip.eraseInput()
}
}
is TerminalInputAction.Newline -> {
if (isNvt) {
c.sendNVTString("\r\n")
} else {
ip.newline()
}
} }
is TerminalInputAction.Reset -> { is TerminalInputAction.Reset -> {
ip.reset() if (isNvt) {
c.sendNVTChar('\u001B')
} else {
ip.reset()
_isInsertMode.value = ip.isInsertMode
}
}
is TerminalInputAction.WordLeft -> {
if (isNvt) {
c.sendNVTString("\u001Bb")
} else {
ip.processWordLeft()
}
}
is TerminalInputAction.WordRight -> {
if (isNvt) {
c.sendNVTString("\u001Bf")
} else {
ip.processWordRight()
}
}
is TerminalInputAction.FieldEnd -> {
if (!isNvt) {
ip.processFieldEnd()
}
}
is TerminalInputAction.DeleteWord -> {
if (isNvt) {
c.sendNVTString("\u0017")
} else {
ip.processDeleteWord()
}
}
is TerminalInputAction.CursorSelect -> {
if (!isNvt) {
ip.cursorSelect()
}
}
is TerminalInputAction.Attn -> {
if (isNvt) {
c.sendNVTChar('\u0003')
} else {
c.attn()
}
}
is TerminalInputAction.SysReq -> {
if (!isNvt) {
c.sysReq()
}
}
is TerminalInputAction.Dup -> {
if (!isNvt) {
ip.dup()
}
}
is TerminalInputAction.FieldMark -> {
if (!isNvt) {
ip.fieldMark()
}
}
is TerminalInputAction.ToggleInsert -> {
if (!isNvt) {
ip.processToggleInsert()
_isInsertMode.value = ip.isInsertMode
}
} }
is TerminalInputAction.SetCursor -> { is TerminalInputAction.SetCursor -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) { if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr buf.cursorAddress = action.baddr
} }
} }
is TerminalInputAction.LightPenSelect -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr
val res = ip.lightPenSelect(action.baddr)
log.info("LightPenSelect at addr ${action.baddr}, result=$res")
}
}
} }
} catch (e: Exception) { } catch (e: Exception) {
log.warning("Error processing input action: ${e.message}") log.warning("Error processing input action: ${e.message}")
} }
} }
private fun aidToPfNumber(aid: Int): Int {
return when (aid) {
AID_PF1 -> 1; AID_PF2 -> 2; AID_PF3 -> 3; AID_PF4 -> 4
AID_PF5 -> 5; AID_PF6 -> 6; AID_PF7 -> 7; AID_PF8 -> 8
AID_PF9 -> 9; AID_PF10 -> 10; AID_PF11 -> 11; AID_PF12 -> 12
AID_PF13 -> 13; AID_PF14 -> 14; AID_PF15 -> 15; AID_PF16 -> 16
AID_PF17 -> 17; AID_PF18 -> 18; AID_PF19 -> 19; AID_PF20 -> 20
AID_PF21 -> 21; AID_PF22 -> 22; AID_PF23 -> 23; AID_PF24 -> 24
else -> 0
}
}
fun resolveUntrustedCert(accept: Boolean) { fun resolveUntrustedCert(accept: Boolean) {
try { try {
val prompt = _untrustedCertPrompt.value val prompt = _untrustedCertPrompt.value
@@ -232,28 +518,112 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
} }
fun updateSettings(maskHidden: Boolean, blink: Boolean, haptic: Boolean, verifyCertsVal: Boolean = true, defaultGraphicsVal: String = "BOTH") { fun updateSettings(
maskHidden: Boolean,
blink: Boolean,
haptic: Boolean,
verifyCertsVal: Boolean = true,
defaultGraphicsVal: String = "BOTH",
defaultCodePageVal: String = "037"
) {
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden) AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
AppSettings.setCursorBlinkEnabled(getApplication(), blink) AppSettings.setCursorBlinkEnabled(getApplication(), blink)
AppSettings.setHapticFeedbackEnabled(getApplication(), haptic) AppSettings.setHapticFeedbackEnabled(getApplication(), haptic)
AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal) AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal)
AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal) AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal)
AppSettings.setDefaultCodePage(getApplication(), defaultCodePageVal)
_maskHiddenInput.value = maskHidden _maskHiddenInput.value = maskHidden
_cursorBlink.value = blink _cursorBlink.value = blink
_hapticFeedback.value = haptic _hapticFeedback.value = haptic
_verifyCerts.value = verifyCertsVal _verifyCerts.value = verifyCertsVal
_defaultGraphicsMode.value = defaultGraphicsVal _defaultGraphicsMode.value = defaultGraphicsVal
_codePage.value = defaultCodePageVal
}
fun find(target: String, matchCase: Boolean = false, forward: Boolean = true): Int {
val c = client ?: return -1
val sb = c.screenBuffer ?: return -1
val curAddr = sb.cursorAddress
val cols = sb.cols
val rows = sb.rows
if (cols <= 0 || rows <= 0) return -1
val curRow = curAddr / cols
val curCol = curAddr % cols
val startRow: Int
val startCol: Int
if (forward) {
startCol = (curCol + 1) % cols
startRow = if (startCol == 0) (curRow + 1) % rows else curRow
} else {
startCol = (curCol - 1 + cols) % cols
startRow = if (startCol == cols - 1) (curRow - 1 + rows) % rows else curRow
}
val dir = if (forward) haus.nightmare.lib3270j.ecl.ECLConstants.SEARCH_FORWARD else haus.nightmare.lib3270j.ecl.ECLConstants.SEARCH_BACKWARD
val foundPos = c.ps.searchString(target, startRow, startCol, dir, !matchCase)
if (foundPos >= 0) {
sb.cursorAddress = foundPos
_cursorAddress.value = foundPos
setSearchHighlight(foundPos, target.length)
_screenVersion.value = System.currentTimeMillis()
}
return foundPos
}
fun executeScript(
script: String,
onStatus: (String) -> Unit = {},
onComplete: (String?) -> Unit = {}
) {
val c = client
if (c == null || !_connectionState.value.isConnected()) {
onComplete("Not connected to host.")
return
}
viewModelScope.launch(Dispatchers.IO) {
try {
val cleaned = script.replace("\r\n", "\n").replace("\r", "\n")
val lines = cleaned.split("\n")
for (line in lines) {
val trimmed = line.trim()
if (trimmed.isNotEmpty() && !trimmed.startsWith("#") && !trimmed.startsWith("//")) {
withContext(Dispatchers.Main) { onStatus("Sending: $trimmed") }
c.sendKeys(trimmed)
kotlinx.coroutines.delay(100)
}
}
_screenVersion.value = System.currentTimeMillis()
withContext(Dispatchers.Main) {
onComplete(null)
}
} catch (e: Exception) {
log.warning("Script execution error: ${e.message}")
withContext(Dispatchers.Main) {
onComplete(e.message ?: "Script execution error")
}
}
}
} }
fun connect( fun connect(
host: String, host: String,
port: Int = 23, port: Int = 23,
modelNum: Int = 2, modelNum: Int = 2,
dynamicRows: Int = 62,
dynamicCols: Int = 160,
luName: String = "", luName: String = "",
hostType: String = "TSO", hostType: String = "TSO",
useTls: Boolean = false, useTls: Boolean = false,
tlsVerifyCert: Boolean = true, tlsVerifyCert: Boolean = true,
graphicsModeStr: String = "BOTH" tn3270e: Boolean = true,
graphicsModeStr: String = "BOTH",
codePageStr: String = "037",
proxyTypeStr: String = "NONE",
proxyHost: String = "",
proxyPort: Int = 0,
proxyUser: String = "",
proxyPass: String = ""
) { ) {
currentHost = host currentHost = host
activeHostType = hostType activeHostType = hostType
@@ -261,6 +631,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
hasInitialScreenLoaded = false hasInitialScreenLoaded = false
fileTransferCoordinator = null fileTransferCoordinator = null
_ftState.value = FTProgressState() _ftState.value = FTProgressState()
_codePage.value = codePageStr
clearSearchHighlight()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -274,6 +646,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
val model = when (modelNum) { val model = when (modelNum) {
0 -> TerminalModel.IBM_DYNAMIC
3 -> TerminalModel.IBM_3279_3 3 -> TerminalModel.IBM_3279_3
4 -> TerminalModel.IBM_3279_4 4 -> TerminalModel.IBM_3279_4
5 -> TerminalModel.IBM_3279_5 5 -> TerminalModel.IBM_3279_5
@@ -284,24 +657,40 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val effectiveHost = parsedConfig.host val effectiveHost = parsedConfig.host
val effectivePort = parsedConfig.port val effectivePort = parsedConfig.port
val effectiveTls = useTls || parsedConfig.isUseTls val effectiveTls = useTls || parsedConfig.isUseTls
val effectiveTn3270e = tn3270e && parsedConfig.isTn3270eEnabled
val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication()) val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication())
val effectiveVerify = tlsVerifyCert && globalVerify val effectiveVerify = tlsVerifyCert && globalVerify
val gMode = org.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr) val gMode = haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
_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 $effectiveHost:$effectivePort" + (if (effectiveTls) " [TLS]" else "") + "..."
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply { val config = ConnectionConfig(effectiveHost, effectivePort, model).apply {
if (model == TerminalModel.IBM_DYNAMIC) {
setDynamicDimensions(dynamicRows, dynamicCols)
}
if (luName.isNotBlank()) { if (luName.isNotBlank()) {
setLuName(luName) setLuName(luName)
} }
isUseTls = effectiveTls isUseTls = effectiveTls
isTlsVerifyCert = effectiveVerify isTlsVerifyCert = effectiveVerify
isTn3270eEnabled = effectiveTn3270e
graphicsMode = gMode graphicsMode = gMode
codePage = codePageStr
val pType = try {
ConnectionConfig.ProxyType.valueOf(proxyTypeStr.uppercase())
} catch (_: Exception) {
ConnectionConfig.ProxyType.NONE
}
if (pType != ConnectionConfig.ProxyType.NONE && proxyHost.isNotBlank()) {
val defaultPort = if (pType == ConnectionConfig.ProxyType.HTTP) 8080 else 1080
setProxy(pType, proxyHost.trim(), if (proxyPort > 0) proxyPort else defaultPort, proxyUser.ifBlank { null }, proxyPass.ifBlank { null })
}
} }
config.certificateVerifier = org.lib3270j.tls.TlsCertificateVerifier { chain, _, exception -> config.certificateVerifier = haus.nightmare.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
if (!AppSettings.isVerifyCertsEnabled(getApplication())) { if (!AppSettings.isVerifyCertsEnabled(getApplication())) {
log.info("Certificate verification bypassed via global AppSettings") log.info("Certificate verification bypassed via global AppSettings")
return@TlsCertificateVerifier true return@TlsCertificateVerifier true
@@ -329,6 +718,32 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val newClient = Telnet3270Client(config) val newClient = Telnet3270Client(config)
this@TerminalViewModel.client = newClient this@TerminalViewModel.client = newClient
newClient.setNvtClipboardHandler(object : haus.nightmare.lib3270j.nvt.NvtProcessor.ClipboardHandler {
override fun getClipboardText(): String {
return try {
val cm = getApplication<Application>().getSystemService(android.content.Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
cm?.primaryClip?.getItemAt(0)?.text?.toString() ?: ""
} catch (e: Exception) {
""
}
}
override fun setClipboardText(text: String?) {
if (!text.isNullOrEmpty()) {
try {
val cm = getApplication<Application>().getSystemService(android.content.Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
cm?.setPrimaryClip(android.content.ClipData.newPlainText("3270 NVT", text))
} catch (ignored: Exception) {}
}
}
})
newClient.addNvtTitleListener { title ->
if (!title.isNullOrBlank()) {
_oiaText.value = title
}
}
newClient.addConnectionListener(object : ConnectionListener { newClient.addConnectionListener(object : ConnectionListener {
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) { override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
_connectionState.value = newState _connectionState.value = newState
@@ -542,6 +957,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.Backspace) inputChannel.trySend(TerminalInputAction.Backspace)
} }
fun deleteChar() {
inputChannel.trySend(TerminalInputAction.DeleteChar)
}
fun tab() { fun tab() {
inputChannel.trySend(TerminalInputAction.Tab) inputChannel.trySend(TerminalInputAction.Tab)
} }
@@ -554,6 +973,58 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.Reset) inputChannel.trySend(TerminalInputAction.Reset)
} }
fun wordLeft() {
inputChannel.trySend(TerminalInputAction.WordLeft)
}
fun wordRight() {
inputChannel.trySend(TerminalInputAction.WordRight)
}
fun fieldEnd() {
inputChannel.trySend(TerminalInputAction.FieldEnd)
}
fun deleteWord() {
inputChannel.trySend(TerminalInputAction.DeleteWord)
}
fun cursorSelect() {
inputChannel.trySend(TerminalInputAction.CursorSelect)
}
fun attn() {
inputChannel.trySend(TerminalInputAction.Attn)
}
fun sysReq() {
inputChannel.trySend(TerminalInputAction.SysReq)
}
fun dup() {
inputChannel.trySend(TerminalInputAction.Dup)
}
fun fieldMark() {
inputChannel.trySend(TerminalInputAction.FieldMark)
}
fun toggleInsert() {
inputChannel.trySend(TerminalInputAction.ToggleInsert)
}
fun eraseEof() {
inputChannel.trySend(TerminalInputAction.EraseEof)
}
fun eraseInput() {
inputChannel.trySend(TerminalInputAction.EraseInput)
}
fun newline() {
inputChannel.trySend(TerminalInputAction.Newline)
}
fun sendAid(aidCode: Int) { fun sendAid(aidCode: Int) {
inputChannel.trySend(TerminalInputAction.SendAid(aidCode)) inputChannel.trySend(TerminalInputAction.SendAid(aidCode))
} }
@@ -578,6 +1049,35 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.CursorRight) inputChannel.trySend(TerminalInputAction.CursorRight)
} }
fun cursorHome() {
inputChannel.trySend(TerminalInputAction.CursorHome)
}
fun selectLightPen(baddr: Int) {
inputChannel.trySend(TerminalInputAction.LightPenSelect(baddr))
}
fun getGocaDecoder(): haus.nightmare.lib3270j.graphics.GocaDecoder? = client?.gocaDecoder
fun handleGraphicTouch(clickAddr: Int = -1, px: Int, py: Int, button: Int = 1, shift: Boolean = false, ctrl: Boolean = false) {
val c = client ?: return
val goca = c.gocaDecoder ?: return
if (clickAddr >= 0) {
c.screenBuffer?.cursorAddress = clickAddr
_cursorAddress.value = clickAddr
}
if (goca.isGraphicsCursorActive) {
goca.setGraphicCursorFromPixel(px, py)
c.inputProcessor.sendGraphicMouseAid(
AID_ENTER,
button,
shift,
ctrl
)
_screenVersion.value = System.currentTimeMillis()
}
}
private fun extractScreenSnippet(buf: ScreenBuffer?): String { private fun extractScreenSnippet(buf: ScreenBuffer?): String {
if (buf == null) return "Mainframe screen update received" if (buf == null) return "Mainframe screen update received"
val rows = buf.rows val rows = buf.rows
@@ -1,12 +1,12 @@
package org.pubvm.a3270.ft package haus.nightmare.a3270.ft
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import org.lib3270j.Telnet3270Client import haus.nightmare.lib3270j.Telnet3270Client
import org.lib3270j.ft.FTConfig import haus.nightmare.lib3270j.ft.FTConfig
import org.lib3270j.ft.FTConstants.FTState import haus.nightmare.lib3270j.ft.FTConstants.FTState
import org.lib3270j.ft.FTCut import haus.nightmare.lib3270j.ft.FTCut
import org.lib3270j.ft.FTDft import haus.nightmare.lib3270j.ft.FTDft
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Timer import java.util.Timer
@@ -1,4 +1,4 @@
package org.pubvm.a3270.service package haus.nightmare.a3270.service
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -10,8 +10,8 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import org.pubvm.a3270.MainActivity import haus.nightmare.a3270.MainActivity
import org.pubvm.a3270.R import haus.nightmare.a3270.R
class TerminalService : Service() { class TerminalService : Service() {
@@ -0,0 +1,246 @@
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.SharedPreferences
import java.io.BufferedReader
import java.io.PrintWriter
import java.io.Reader
import java.io.Writer
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object AppSettings {
private const val PREFS_NAME = "a3270_settings"
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
private const val KEY_CURSOR_BLINK = "cursor_blink"
private const val KEY_HAPTIC_FEEDBACK = "haptic_feedback"
private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private const val KEY_DEFAULT_CODE_PAGE = "default_code_page"
// New settings aligned with j3270
private const val KEY_UI_THEME = "ui_theme"
private const val KEY_CROSSHAIR_RULER = "crosshair_ruler"
private const val KEY_CURSOR_STYLE = "cursor_style"
private const val KEY_BLOCK_SELECT_MODE = "block_select_mode"
private const val KEY_STARTUP_BEHAVIOR = "startup_behavior"
private const val KEY_DYNAMIC_ROWS = "dynamic_rows"
private const val KEY_DYNAMIC_COLS = "dynamic_cols"
enum class StartupBehavior {
SHOW_CONNECT,
DO_NOTHING,
AUTO_CONNECT
}
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun isMaskHiddenInputEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_MASK_HIDDEN_INPUT, true)
}
fun setMaskHiddenInputEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_MASK_HIDDEN_INPUT, enabled).apply()
}
fun isCursorBlinkEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_CURSOR_BLINK, true)
}
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply()
}
fun isHapticFeedbackEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HAPTIC_FEEDBACK, true)
}
fun setHapticFeedbackEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HAPTIC_FEEDBACK, enabled).apply()
}
fun isVerifyCertsEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_VERIFY_CERTS, true)
}
fun setVerifyCertsEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_VERIFY_CERTS, enabled).apply()
}
fun getDefaultGraphicsMode(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_GRAPHICS_MODE, "BOTH") ?: "BOTH"
}
fun setDefaultGraphicsMode(context: Context, mode: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_GRAPHICS_MODE, mode).apply()
}
fun getDefaultCodePage(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_CODE_PAGE, "037") ?: "037"
}
fun setDefaultCodePage(context: Context, codePage: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply()
}
// ========== UI Theme ==========
fun getUiTheme(context: Context): String {
return getPrefs(context).getString(KEY_UI_THEME, "DARK") ?: "DARK"
}
fun setUiTheme(context: Context, theme: String) {
getPrefs(context).edit().putString(KEY_UI_THEME, if (theme.equals("LIGHT", ignoreCase = true)) "LIGHT" else "DARK").apply()
}
// ========== Crosshair Ruler ==========
fun isCrosshairRulerEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_CROSSHAIR_RULER, false)
}
fun setCrosshairRulerEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_CROSSHAIR_RULER, enabled).apply()
}
// ========== Cursor Style ==========
fun getCursorStyle(context: Context): String {
return getPrefs(context).getString(KEY_CURSOR_STYLE, "BLOCK") ?: "BLOCK"
}
fun setCursorStyle(context: Context, style: String) {
val s = if (style.equals("UNDERLINE", ignoreCase = true)) "UNDERLINE" else "BLOCK"
getPrefs(context).edit().putString(KEY_CURSOR_STYLE, s).apply()
}
// ========== Block Select Mode ==========
fun isBlockSelectModeEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_BLOCK_SELECT_MODE, false)
}
fun setBlockSelectModeEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_BLOCK_SELECT_MODE, enabled).apply()
}
// ========== Startup Behavior ==========
fun getStartupBehavior(context: Context): StartupBehavior {
val s = getPrefs(context).getString(KEY_STARTUP_BEHAVIOR, StartupBehavior.SHOW_CONNECT.name)
return try {
StartupBehavior.valueOf(s ?: StartupBehavior.SHOW_CONNECT.name)
} catch (_: IllegalArgumentException) {
StartupBehavior.SHOW_CONNECT
}
}
fun setStartupBehavior(context: Context, behavior: StartupBehavior) {
getPrefs(context).edit().putString(KEY_STARTUP_BEHAVIOR, behavior.name).apply()
}
// ========== Dynamic Screen Dimensions ==========
fun getDynamicRows(context: Context): Int {
return getPrefs(context).getInt(KEY_DYNAMIC_ROWS, 62).coerceIn(24, 255)
}
fun setDynamicRows(context: Context, rows: Int) {
getPrefs(context).edit().putInt(KEY_DYNAMIC_ROWS, rows.coerceIn(24, 255)).apply()
}
fun getDynamicCols(context: Context): Int {
return getPrefs(context).getInt(KEY_DYNAMIC_COLS, 160).coerceIn(80, 255)
}
fun setDynamicCols(context: Context, cols: Int) {
getPrefs(context).edit().putInt(KEY_DYNAMIC_COLS, cols.coerceIn(80, 255)).apply()
}
// ========== INI Config Export / Import ==========
fun exportToIni(context: Context, writer: Writer) {
val p = PrintWriter(writer)
val timeStamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
p.println("; a3270 / j3270 Configuration File")
p.println("; Exported on $timeStamp")
p.println()
p.println("[appearance]")
p.println("javaUiTheme = ${getUiTheme(context)}")
p.println("crosshairRuler = ${isCrosshairRulerEnabled(context)}")
p.println("cursorStyle = ${getCursorStyle(context)}")
p.println()
p.println("[behavior]")
p.println("startupBehavior = ${getStartupBehavior(context).name}")
val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null) {
p.println("autoConnectHost = ${autoHost.host}")
p.println("autoConnectPort = ${autoHost.port}")
p.println("autoConnectTls = ${autoHost.useTls}")
p.println("autoConnectVerifyCert = ${autoHost.tlsVerifyCert}")
p.println("autoConnectTn3270e = ${autoHost.tn3270e}")
}
p.println("codePage = ${getDefaultCodePage(context)}")
p.println("dynamicRows = ${getDynamicRows(context)}")
p.println("dynamicCols = ${getDynamicCols(context)}")
p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}")
p.println()
p.println("[graphics]")
p.println("graphicsMode = ${getDefaultGraphicsMode(context)}")
p.println()
p.flush()
}
fun loadFromIni(context: Context, reader: Reader) {
val bufferedReader = BufferedReader(reader)
var currentSection = ""
bufferedReader.forEachLine { rawLine ->
val line = rawLine.trim()
if (line.isNotEmpty() && !line.startsWith(";") && !line.startsWith("#")) {
if (line.startsWith("[") && line.endsWith("]")) {
currentSection = line.substring(1, line.length - 1).lowercase(Locale.US).trim()
} else {
val eq = line.indexOf('=')
if (eq >= 0) {
val key = line.substring(0, eq).trim()
val value = line.substring(eq + 1).trim()
applyConfigEntry(context, currentSection, key, value)
}
}
}
}
}
private fun applyConfigEntry(context: Context, section: String, key: String, value: String) {
when (section) {
"appearance" -> {
when (key.lowercase(Locale.US)) {
"javauitheme", "theme", "uitheme" -> setUiTheme(context, value)
"crosshairruler", "ruler" -> setCrosshairRulerEnabled(context, value.toBoolean())
"cursorstyle" -> setCursorStyle(context, value)
}
}
"behavior", "connection" -> {
when (key.lowercase(Locale.US)) {
"startupbehavior" -> {
try {
setStartupBehavior(context, StartupBehavior.valueOf(value.uppercase(Locale.US)))
} catch (_: Exception) {}
}
"codepage", "charset" -> setDefaultCodePage(context, value)
"dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) }
"dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) }
"blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean())
}
}
"graphics" -> {
if (key.equals("graphicsmode", ignoreCase = true) || key.equals("mode", ignoreCase = true)) {
setDefaultGraphicsMode(context, value.uppercase(Locale.US))
}
}
}
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.storage package haus.nightmare.a3270.storage
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
@@ -12,12 +12,21 @@ data class SavedHost(
val host: String, val host: String,
val port: Int = 23, val port: Int = 23,
val model: Int = 2, val model: Int = 2,
val dynamicRows: Int = 62,
val dynamicCols: Int = 160,
val luName: String = "", val luName: String = "",
val autoConnect: Boolean = false, val autoConnect: Boolean = false,
val hostType: String = "TSO", val hostType: String = "TSO",
val useTls: Boolean = false, val useTls: Boolean = false,
val tlsVerifyCert: Boolean = true, val tlsVerifyCert: Boolean = true,
val graphicsMode: String = "BOTH" val tn3270e: Boolean = true,
val graphicsMode: String = "BOTH",
val codePage: String = "037",
val proxyType: String = "NONE",
val proxyHost: String = "",
val proxyPort: Int = 0,
val proxyUsername: String = "",
val proxyPassword: String = ""
) { ) {
fun toJson(): JSONObject { fun toJson(): JSONObject {
return JSONObject().apply { return JSONObject().apply {
@@ -26,12 +35,21 @@ data class SavedHost(
put("host", host) put("host", host)
put("port", port) put("port", port)
put("model", model) put("model", model)
put("dynamicRows", dynamicRows)
put("dynamicCols", dynamicCols)
put("luName", luName) put("luName", luName)
put("autoConnect", autoConnect) put("autoConnect", autoConnect)
put("hostType", hostType) put("hostType", hostType)
put("useTls", useTls) put("useTls", useTls)
put("tlsVerifyCert", tlsVerifyCert) put("tlsVerifyCert", tlsVerifyCert)
put("tn3270e", tn3270e)
put("graphicsMode", graphicsMode) put("graphicsMode", graphicsMode)
put("codePage", codePage)
put("proxyType", proxyType)
put("proxyHost", proxyHost)
put("proxyPort", proxyPort)
put("proxyUsername", proxyUsername)
put("proxyPassword", proxyPassword)
} }
} }
@@ -43,12 +61,21 @@ data class SavedHost(
host = json.optString("host", "127.0.0.1"), host = json.optString("host", "127.0.0.1"),
port = json.optInt("port", 23), port = json.optInt("port", 23),
model = json.optInt("model", 2), model = json.optInt("model", 2),
dynamicRows = json.optInt("dynamicRows", 62),
dynamicCols = json.optInt("dynamicCols", 160),
luName = json.optString("luName", ""), luName = json.optString("luName", ""),
autoConnect = json.optBoolean("autoConnect", false), autoConnect = json.optBoolean("autoConnect", false),
hostType = json.optString("hostType", "TSO"), hostType = json.optString("hostType", "TSO"),
useTls = json.optBoolean("useTls", false), useTls = json.optBoolean("useTls", false),
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true), tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
graphicsMode = json.optString("graphicsMode", "BOTH") tn3270e = json.optBoolean("tn3270e", true),
graphicsMode = json.optString("graphicsMode", "BOTH"),
codePage = json.optString("codePage", "037"),
proxyType = json.optString("proxyType", "NONE"),
proxyHost = json.optString("proxyHost", ""),
proxyPort = json.optInt("proxyPort", 0),
proxyUsername = json.optString("proxyUsername", ""),
proxyPassword = json.optString("proxyPassword", "")
) )
} }
} }
@@ -111,6 +138,51 @@ object HostStorage {
saveAll(context, currentHosts) saveAll(context, currentHosts)
} }
val AVAILABLE_CODE_PAGES = listOf(
"037" to "037 - US / Canada / Brazil",
"1047" to "1047 - IBM Open Systems / z/OS Unix",
"500" to "500 - International Latin-1",
"273" to "273 - Germany / Austria",
"277" to "277 - Denmark / Norway",
"278" to "278 - Sweden / Finland",
"280" to "280 - Italy",
"284" to "284 - Spain / Latin America",
"285" to "285 - United Kingdom",
"297" to "297 - France",
"420" to "420 - Arabic Bilingual",
"424" to "424 - Hebrew (with Latin)",
"803" to "803 - Hebrew Character Set",
"838" to "838 - Thai Extended",
"1160" to "1160 - Thai Euro",
"870" to "870 - Eastern Europe / Latin-2",
"871" to "871 - Iceland",
"875" to "875 - Greece (Greek)",
"880" to "880 - Cyrillic (Russian)",
"1025" to "1025 - Cyrillic Multilingual",
"1123" to "1123 - Cyrillic Ukraine",
"1154" to "1154 - Cyrillic Euro",
"905" to "905 - Turkey (Latin-5)",
"1026" to "1026 - Turkey (Turkish)",
"1155" to "1155 - Turkey Euro",
"1140" to "1140 - US / Canada (Euro €)",
"1141" to "1141 - Germany / Austria (Euro €)",
"1142" to "1142 - Denmark / Norway (Euro €)",
"1143" to "1143 - Sweden / Finland (Euro €)",
"1144" to "1144 - Italy (Euro €)",
"1145" to "1145 - Spain / Latin America (Euro €)",
"1146" to "1146 - United Kingdom (Euro €)",
"1147" to "1147 - France (Euro €)",
"1148" to "1148 - International (Euro €)",
"1149" to "1149 - Iceland (Euro €)",
"930" to "930 - Japanese Katakana Mixed DBCS",
"939" to "939 - Japanese Latin Mixed DBCS",
"935" to "935 - Simplified Chinese Mixed DBCS",
"937" to "937 - Traditional Chinese Mixed DBCS",
"1388" to "1388 - Simplified Chinese Extended DBCS",
"1371" to "1371 - Traditional Chinese Extended DBCS",
"933" to "933 - Korean Mixed DBCS"
)
fun getAutoConnectHost(context: Context): SavedHost? { fun getAutoConnectHost(context: Context): SavedHost? {
return getSavedHosts(context).firstOrNull { it.autoConnect } return getSavedHosts(context).firstOrNull { it.autoConnect }
} }
@@ -0,0 +1,819 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.storage.SavedHost
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
@Composable
fun ConnectDialog(
onDismiss: () -> Unit,
onConnect: (
host: String,
port: Int,
model: Int,
dynamicRows: Int,
dynamicCols: Int,
luName: String,
hostType: String,
useTls: Boolean,
tlsVerifyCert: Boolean,
tn3270e: Boolean,
graphicsMode: String,
codePage: String,
proxyType: String,
proxyHost: String,
proxyPort: Int,
proxyUser: String,
proxyPass: String
) -> Unit
) {
val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
var selectedHostId by remember { mutableStateOf<String?>(null) }
var profileName by remember { mutableStateOf("") }
var host by remember { mutableStateOf("") }
var portStr by remember { mutableStateOf("") }
var modelNum by remember { mutableIntStateOf(2) }
var dynamicRowsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()) }
var dynamicColsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()) }
var luName by remember { mutableStateOf("") }
var autoConnect by remember { mutableStateOf(false) }
var hostType by remember { mutableStateOf("TSO") }
var useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) }
var tn3270e by remember { mutableStateOf(true) }
var graphicsMode by remember { mutableStateOf("BOTH") }
var codePage by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)) }
var proxyType by remember { mutableStateOf("NONE") }
var proxyHost by remember { mutableStateOf("") }
var proxyPortStr by remember { mutableStateOf("") }
var proxyUsername by remember { mutableStateOf("") }
var proxyPassword by remember { mutableStateOf("") }
var showProxySettings by remember { mutableStateOf(false) }
fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id
profileName = saved.name
host = saved.host
portStr = saved.port.toString()
modelNum = saved.model
dynamicRowsStr = saved.dynamicRows.toString()
dynamicColsStr = saved.dynamicCols.toString()
luName = saved.luName
autoConnect = saved.autoConnect
hostType = saved.hostType
useTls = saved.useTls
tlsVerifyCert = saved.tlsVerifyCert
tn3270e = saved.tn3270e
graphicsMode = saved.graphicsMode
codePage = saved.codePage
proxyType = saved.proxyType
proxyHost = saved.proxyHost
proxyPortStr = if (saved.proxyPort > 0) saved.proxyPort.toString() else ""
proxyUsername = saved.proxyUsername
proxyPassword = saved.proxyPassword
showProxySettings = saved.proxyType != "NONE"
}
fun clearFields() {
selectedHostId = null
profileName = ""
host = ""
portStr = ""
modelNum = 2
dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
luName = ""
autoConnect = false
hostType = "TSO"
useTls = false
tlsVerifyCert = true
tn3270e = true
graphicsMode = "BOTH"
codePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
proxyType = "NONE"
proxyHost = ""
proxyPortStr = ""
proxyUsername = ""
proxyPassword = ""
showProxySettings = false
}
fun saveCurrentProfile(): SavedHost? {
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
val trimmedHost = host.trim()
if (trimmedHost.isBlank()) {
android.widget.Toast.makeText(context, "Please enter a Host / IP Address", android.widget.Toast.LENGTH_SHORT).show()
return null
}
val nameToSave = profileName.trim().ifBlank { "$trimmedHost:$port" }
val dynRows = dynamicRowsStr.trim().toIntOrNull()?.coerceIn(24, 255) ?: 62
val dynCols = dynamicColsStr.trim().toIntOrNull()?.coerceIn(80, 255) ?: 160
val hostToSave = SavedHost(
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
name = nameToSave,
host = trimmedHost,
port = port,
model = modelNum,
dynamicRows = dynRows,
dynamicCols = dynCols,
luName = luName.trim(),
autoConnect = autoConnect,
hostType = hostType,
useTls = useTls,
tlsVerifyCert = tlsVerifyCert,
tn3270e = tn3270e,
graphicsMode = graphicsMode,
codePage = codePage,
proxyType = proxyType,
proxyHost = proxyHost.trim(),
proxyPort = proxyPortStr.trim().toIntOrNull() ?: 0,
proxyUsername = proxyUsername.trim(),
proxyPassword = proxyPassword.trim()
)
HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context)
selectedHostId = hostToSave.id
return hostToSave
}
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
focusRequester.requestFocus()
keyboardController?.show()
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color(0xFF1E1E1E),
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Title Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Connect / Manage Hosts",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
TextButton(
onClick = { clearFields() },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Text("+ New Profile", fontSize = 12.sp, color = Color(0xFF339AF0))
}
}
// Scrollable Content Container
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Saved Profiles List Section
if (savedHosts.isNotEmpty()) {
Text(
"Saved Host Profiles:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
savedHosts.forEach { saved ->
val isSelected = (saved.id == selectedHostId)
val tlsTag = if (saved.useTls) {
if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/NoVerify]"
} else ""
val eTag = if (!saved.tn3270e) " [Non-E]" else ""
val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
Surface(
shape = RoundedCornerShape(8.dp),
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null,
modifier = Modifier
.fillMaxWidth()
.clickable { loadProfile(saved) }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = saved.name,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = Color.White
)
Text(
text = "${saved.host}:${saved.port} (${if (saved.model == 0) "Dyn ${saved.dynamicRows}x${saved.dynamicCols}" else "M" + saved.model} - ${saved.hostType})$tlsTag$eTag$gfxTag" +
if (saved.autoConnect) " [Auto]" else "",
fontSize = 10.sp,
color = Color.LightGray
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = {
loadProfile(saved)
val hostToConn = saveCurrentProfile() ?: saved
onConnect(
hostToConn.host,
hostToConn.port,
hostToConn.model,
hostToConn.dynamicRows,
hostToConn.dynamicCols,
hostToConn.luName,
hostToConn.hostType,
hostToConn.useTls,
hostToConn.tlsVerifyCert,
hostToConn.tn3270e,
hostToConn.graphicsMode,
hostToConn.codePage,
hostToConn.proxyType,
hostToConn.proxyHost,
hostToConn.proxyPort,
hostToConn.proxyUsername,
hostToConn.proxyPassword
)
},
modifier = Modifier.size(32.dp)
) {
Text("", fontSize = 14.sp, color = Color(0xFF51CF66))
}
IconButton(
onClick = {
HostStorage.deleteHost(context, saved.id)
savedHosts = HostStorage.getSavedHosts(context)
if (selectedHostId == saved.id) {
clearFields()
}
},
modifier = Modifier.size(32.dp)
) {
Text("", fontSize = 13.sp, color = Color(0xFFFF6B6B))
}
}
}
}
}
}
HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 4.dp))
}
Text(
text = if (selectedHostId != null) "Editing Profile:" else "Host Connection Details:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
OutlinedTextField(
value = profileName,
onValueChange = { profileName = it },
label = { Text("Profile Name (Optional)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = host,
onValueChange = { host = it.filterNot { c -> c.isWhitespace() } },
label = { Text("Host / IP Address") },
placeholder = { Text("127.0.0.1", color = Color.Gray) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier
.weight(0.68f)
.focusRequester(focusRequester)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it.filter { c -> c.isDigit() } },
label = { Text("Port") },
placeholder = { Text(if (useTls) "992" else "23", color = Color.Gray) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.32f)
)
}
// Terminal Model Selector
Column(modifier = Modifier.fillMaxWidth()) {
Text("Terminal Model:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
2 to "M2",
3 to "M3",
4 to "M4",
5 to "M5",
0 to "DYN"
).forEach { (m, label) ->
val isSelected = (modelNum == m)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { modelNum = m }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = label,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center
)
}
}
}
}
// If Dynamic Model selected, allow specifying Rows and Columns
if (modelNum == 0) {
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = dynamicRowsStr,
onValueChange = { dynamicRowsStr = it },
label = { Text("Dyn Rows (24-255)") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
OutlinedTextField(
value = dynamicColsStr,
onValueChange = { dynamicColsStr = it },
label = { Text("Dyn Cols (80-255)") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
}
}
// Host System Type (TSO, CMS, CICS)
Column(modifier = Modifier.fillMaxWidth()) {
Text("Host System Type:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf("TSO", "CMS", "CICS").forEach { ht ->
val isSelected = (hostType == ht)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { hostType = ht }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = ht,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
}
}
}
// Graphics Mode (Both, Vector, Symbols, Off)
Column(modifier = Modifier.fillMaxWidth()) {
Text("Graphics Mode:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "Off"
).forEach { (modeKey, modeLabel) ->
val isSelected = graphicsMode.equals(modeKey, ignoreCase = true)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2B8A3E) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { graphicsMode = modeKey }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = modeLabel,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center
)
}
}
}
}
}
// Code Page Selector
var codePageMenuExpanded by remember { mutableStateOf(false) }
val codePagesList = HostStorage.AVAILABLE_CODE_PAGES
val selectedCpLabel = codePagesList.firstOrNull { it.first == codePage }?.second ?: "Code Page $codePage"
Column(modifier = Modifier.fillMaxWidth()) {
Text("EBCDIC Code Page:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Box(modifier = Modifier.fillMaxWidth()) {
Surface(
shape = RoundedCornerShape(6.dp),
color = Color(0xFF2C2D30),
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF495057)),
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
.clickable { codePageMenuExpanded = true }
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = selectedCpLabel,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
Text("", color = Color.Gray, fontSize = 10.sp)
}
}
DropdownMenu(
expanded = codePageMenuExpanded,
onDismissRequest = { codePageMenuExpanded = false },
modifier = Modifier
.background(Color(0xFF2C2D30))
.heightIn(max = 280.dp)
) {
codePagesList.forEach { (cpId, label) ->
DropdownMenuItem(
text = {
Text(
text = label,
fontSize = 12.sp,
color = if (codePage == cpId) Color(0xFF339AF0) else Color.White,
fontWeight = if (codePage == cpId) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
codePage = cpId
codePageMenuExpanded = false
}
)
}
}
}
}
OutlinedTextField(
value = luName,
onValueChange = { luName = it },
label = { Text("LU Name / Device Pool (Optional)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
// Protocol & Security Options
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// TLS / SSL Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val newTls = !useTls
useTls = newTls
if (newTls && portStr.trim() == "23") {
portStr = "992"
} else if (!newTls && portStr.trim() == "992") {
portStr = "23"
}
}
) {
Checkbox(
checked = useTls,
onCheckedChange = { checked ->
useTls = checked
if (checked && portStr.trim() == "23") {
portStr = "992"
} else if (!checked && portStr.trim() == "992") {
portStr = "23"
}
}
)
Spacer(modifier = Modifier.width(4.dp))
Text("Enable TLS / SSL Connection", fontSize = 12.sp, color = Color.White)
}
// Verify Certificate Checkbox (only when TLS is active)
if (useTls) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp)
.clickable { tlsVerifyCert = !tlsVerifyCert }
) {
Checkbox(
checked = tlsVerifyCert,
onCheckedChange = { tlsVerifyCert = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text(
"Verify Server Certificate",
fontSize = 12.sp,
color = if (tlsVerifyCert) Color.LightGray else Color(0xFFFFB450)
)
}
}
// TN3270E Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { tn3270e = !tn3270e }
) {
Checkbox(
checked = tn3270e,
onCheckedChange = { tn3270e = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Enable TN3270E (Extended 3270)", fontSize = 12.sp, color = Color.White)
}
// Auto-connect Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { autoConnect = !autoConnect }
) {
Checkbox(
checked = autoConnect,
onCheckedChange = { autoConnect = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White)
}
// Proxy Settings Expandable Section
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { showProxySettings = !showProxySettings }
) {
Checkbox(
checked = showProxySettings || proxyType != "NONE",
onCheckedChange = { checked ->
showProxySettings = checked
if (!checked) proxyType = "NONE"
}
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (proxyType != "NONE") "Proxy Active ($proxyType)" else "Configure Network Proxy",
fontSize = 12.sp,
color = if (proxyType != "NONE") Color(0xFF339AF0) else Color.White
)
}
if (showProxySettings || proxyType != "NONE") {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 4.dp, top = 4.dp)
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text("Proxy Protocol:", fontSize = 11.sp, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
listOf("NONE", "HTTP", "SOCKS4", "SOCKS5").forEach { pType ->
val isSel = proxyType == pType
FilterChip(
selected = isSel,
onClick = { proxyType = pType },
label = { Text(pType, fontSize = 10.sp) }
)
}
}
if (proxyType != "NONE") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = proxyHost,
onValueChange = { proxyHost = it },
label = { Text("Proxy Host") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.7f)
)
OutlinedTextField(
value = proxyPortStr,
onValueChange = { proxyPortStr = it },
label = { Text("Port") },
placeholder = { Text(if (proxyType == "HTTP") "8080" else "1080") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(0.3f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = proxyUsername,
onValueChange = { proxyUsername = it },
label = { Text("User (Opt)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.5f)
)
OutlinedTextField(
value = proxyPassword,
onValueChange = { proxyPassword = it },
label = { Text("Pass (Opt)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.5f)
)
}
}
}
}
}
}
}
}
// Action Buttons Row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
OutlinedButton(onClick = { saveCurrentProfile() }) {
Text("Save")
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = {
val saved = saveCurrentProfile()
if (saved != null) {
onConnect(
saved.host,
saved.port,
saved.model,
saved.dynamicRows,
saved.dynamicCols,
saved.luName,
saved.hostType,
saved.useTls,
saved.tlsVerifyCert,
saved.tn3270e,
saved.graphicsMode,
saved.codePage,
saved.proxyType,
saved.proxyHost,
saved.proxyPort,
saved.proxyUsername,
saved.proxyPassword
)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Connect", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
@@ -0,0 +1,214 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ecl.ECLField
@Composable
fun FieldInspectorDialog(
client: Telnet3270Client?,
screenVersion: Long,
onDismiss: () -> Unit,
onJumpToField: (Int) -> Unit
) {
var fields by remember { mutableStateOf<List<ECLField>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var cols by remember { mutableIntStateOf(80) }
var rows by remember { mutableIntStateOf(24) }
fun refresh() {
if (client != null && client.connectionState.isFullSession) {
val fieldList = client.fieldList
fields = fieldList?.fields ?: emptyList()
cols = client.screenBuffer?.cols ?: 80
rows = client.screenBuffer?.rows ?: 24
} else {
fields = emptyList()
}
}
LaunchedEffect(screenVersion, client) {
refresh()
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Presentation Space Field Inspector",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
Text(
"${fields.size} fields detected (${rows}x$cols)",
fontSize = 11.sp,
color = Color.Gray
)
}
TextButton(onClick = { refresh() }) {
Text("Refresh", fontSize = 12.sp, color = Color(0xFF339AF0))
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
if (fields.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
"No fields detected or terminal not in 3270 session.",
color = Color.Gray,
fontSize = 12.sp
)
}
} else {
val horizScroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.horizontalScroll(horizScroll)
) {
// Header Row
Row(
modifier = Modifier
.background(Color(0xFF25262B))
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("#", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(30.dp), textAlign = TextAlign.Center)
Text("Pos", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(42.dp), textAlign = TextAlign.Center)
Text("Start", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("End", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("Len", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Prot", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("MDT", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Num", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Hi-Int", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Hidden", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center)
Text("Pen", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Wrap", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Content Text", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp), textAlign = TextAlign.Start)
}
// Data Rows
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(fields) { idx, field ->
val isSelected = (idx == selectedIndex)
val startRow = field.startRow + 1
val startCol = field.startCol + 1
val endRow = field.endRow + 1
val endCol = field.endCol + 1
val displayText = if (field.isHidden) "••••••••" else field.text
Surface(
shape = RoundedCornerShape(4.dp),
color = if (isSelected) Color(0xFF1C7ED6).copy(alpha = 0.35f) else if (idx % 2 == 0) Color(0xFF161719) else Color(0xFF1E1F23),
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedIndex = idx
onJumpToField(field.dataStart)
}
) {
Row(
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("${idx + 1}", color = Color.White, fontSize = 11.sp, modifier = Modifier.width(30.dp), textAlign = TextAlign.Center)
Text("${field.start}", color = Color.Cyan, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(42.dp), textAlign = TextAlign.Center)
Text(String.format("%02d/%02d", startRow, startCol), color = Color.LightGray, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text(String.format("%02d/%02d", endRow, endCol), color = Color.LightGray, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("${field.length}", color = Color.White, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isProtected) "Y" else "-", color = if (field.isProtected) Color.Gray else Color(0xFF51CF66), fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isModified) "Y" else "-", color = if (field.isModified) Color(0xFFFFB450) else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isNumeric) "Y" else "-", color = Color.LightGray, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isHighIntensity) "Y" else "-", color = if (field.isHighIntensity) Color.White else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text(if (field.isHidden) "Y" else "-", color = if (field.isHidden) Color(0xFFFF6B6B) else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center)
Text(if (field.isPenSelectable) "Y" else "-", color = if (field.isPenSelectable) Color.Yellow else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isWrapped) "" else "-", color = if (field.isWrapped) Color(0xFF339AF0) else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(displayText, color = if (field.isProtected) Color(0xFF90CAF9) else Color(0xFFA5D6A7), fontSize = 11.sp, fontFamily = FontFamily.Monospace, maxLines = 1, modifier = Modifier.width(220.dp))
}
}
}
}
}
}
// Bottom Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (selectedIndex in fields.indices) {
Button(
onClick = {
onJumpToField(fields[selectedIndex].dataStart)
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Jump to Field")
}
Spacer(modifier = Modifier.width(8.dp))
}
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
}
}
}
}
}
@@ -0,0 +1,535 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.provider.OpenableColumns
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.a3270.FTProgressState
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
@Composable
fun FileTransferDialog(
initialHostType: String = "TSO",
ftProgressState: FTProgressState = FTProgressState(),
client: haus.nightmare.lib3270j.Telnet3270Client? = null,
onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit,
onCancelTransfer: () -> Unit = {}
) {
val context = LocalContext.current
val initialTypeEnum = when (initialHostType.uppercase()) {
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
"CICS" -> FTConfig.HostType.CICS
else -> FTConfig.HostType.TSO
}
var hostFile by remember { mutableStateOf("") }
var localFile by remember { mutableStateOf("") }
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 overwrite by remember { mutableStateOf(true) }
var showBrowseDialog by remember { mutableStateOf(false) }
var targetSaveUri by remember { mutableStateOf<Uri?>(null) }
var lastSavedFile by remember { mutableStateOf<File?>(null) }
// SAF Launcher for uploading (Send / PUT)
val openDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null) {
try {
val displayName = getFileNameFromUri(context, uri).ifBlank { "upload.bin" }
val tempDir = File(context.cacheDir, "ft_upload").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, displayName)
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
localFile = tempFile.absolutePath
localDisplayName = displayName
if (hostFile.isBlank()) {
val cleanBase = displayName.substringBeforeLast('.').replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
hostFile = if (hostType == FTConfig.HostType.TSO) "'$cleanBase'" else "$cleanBase FILE A"
}
Toast.makeText(context, "Selected: $displayName", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Failed to read file: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
// SAF Launcher for choosing download destination (Receive / GET)
val createDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("*/*")
) { uri: Uri? ->
if (uri != null) {
targetSaveUri = uri
val displayName = getFileNameFromUri(context, uri).ifBlank { "download.bin" }
localDisplayName = displayName
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, displayName)
localFile = tempFile.absolutePath
Toast.makeText(context, "Destination: $displayName", Toast.LENGTH_SHORT).show()
}
}
// Watch for completed transfers to export to destination URI if chosen
LaunchedEffect(ftProgressState.isActive, ftProgressState.isError) {
if (!ftProgressState.isActive && !ftProgressState.isError && ftProgressState.bytesTransferred > 0 && isReceive && localFile.isNotBlank()) {
val src = File(localFile)
if (src.exists()) {
lastSavedFile = src
val uri = targetSaveUri
if (uri != null) {
try {
context.contentResolver.openOutputStream(uri)?.use { out ->
src.inputStream().use { input ->
input.copyTo(out)
}
}
Toast.makeText(context, "Saved download to destination file", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Failed writing to destination: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
}
if (showBrowseDialog) {
HostDirectoryDialog(
client = client,
initialHostType = hostType,
initialQuery = hostFile,
onDismiss = { showBrowseDialog = false },
onSelectDataset = { selected ->
hostFile = selected
if (localFile.isBlank()) {
val base = selected.trim('\'').substringAfterLast('.').substringAfterLast(' ')
val safeName = if (base.isNotBlank()) "$base.txt" else "download.txt"
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, safeName)
localFile = tempFile.absolutePath
localDisplayName = safeName
}
showBrowseDialog = false
}
)
}
fun exportToDownloads() {
val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null
if (src == null || !src.exists()) {
Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show()
return
}
try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val targetName = (if (localDisplayName.isNotBlank()) localDisplayName else src.name).let {
val base = it.substringBeforeLast('.')
val ext = if (it.contains('.')) "." + it.substringAfterLast('.') else ""
"${base}_$timeStamp$ext"
}
val dst = File(downloadsDir, targetName)
src.copyTo(dst, overwrite = true)
Toast.makeText(context, "Exported to Downloads/${dst.name}", Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Toast.makeText(context, "Export failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun shareDownloadedFile() {
val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null
if (src == null || !src.exists()) {
Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show()
return
}
try {
val text = src.readText()
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, localDisplayName.ifBlank { src.name })
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share Transferred File"))
} catch (e: Exception) {
Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Active Transfer Progress Banner
if (ftProgressState.isActive || ftProgressState.isRunning) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = ftProgressState.statusMessage.ifBlank { "Transferring..." },
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
if (ftProgressState.bytesTransferred > 0) {
Text(
text = "${ftProgressState.bytesTransferred} bytes transferred",
fontSize = 11.sp,
color = Color.LightGray
)
}
Button(
onClick = onCancelTransfer,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.align(Alignment.End)
) {
Text("Cancel Transfer", fontSize = 11.sp)
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
} else if (ftProgressState.statusMessage.isNotBlank()) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = ftProgressState.statusMessage,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
if (!ftProgressState.isError && isReceive && lastSavedFile?.exists() == true) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedButton(
onClick = { exportToDownloads() },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
modifier = Modifier.weight(1f)
) {
Text("Save to Downloads 📥", fontSize = 11.sp)
}
OutlinedButton(
onClick = { shareDownloadedFile() },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
modifier = Modifier.weight(1f)
) {
Text("Share 📤", fontSize = 11.sp)
}
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
}
// Transfer Direction
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isReceive,
onClick = {
isReceive = true
targetSaveUri = null
},
label = { Text("Receive (GET)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isReceive,
onClick = {
isReceive = false
targetSaveUri = null
},
label = { Text("Send (PUT)", fontSize = 11.sp) }
)
}
}
// Host File Name
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = hostFile,
onValueChange = { hostFile = it },
label = { Text("Host Dataset / File Name") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "'USER.DATA'" else "PROFILE EXEC A") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(1f)
)
if (client != null && client.connectionState.isFullSession) {
OutlinedButton(
onClick = { showBrowseDialog = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
modifier = Modifier.padding(top = 6.dp)
) {
Text("Browse...", fontSize = 11.sp)
}
}
}
// Local File Selection (SAF Document Picker integration)
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = if (localDisplayName.isNotBlank()) localDisplayName else localFile,
onValueChange = {
localDisplayName = it
localFile = it
},
label = { Text(if (isReceive) "Local Destination" else "Local File to Upload") },
placeholder = { Text(if (isReceive) "download.txt" else "sample.txt") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(
imeAction = if (!isReceive && hostType == FTConfig.HostType.TSO) ImeAction.Next else ImeAction.Done
),
modifier = Modifier.weight(1f)
)
Button(
onClick = {
if (isReceive) {
val defName = if (hostFile.isNotBlank()) {
val base = hostFile.trim('\'').substringAfterLast('.').substringAfterLast(' ')
if (base.isNotBlank()) "$base.txt" else "download.txt"
} else "download.txt"
createDocumentLauncher.launch(defName)
} else {
openDocumentLauncher.launch(arrayOf("*/*"))
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.padding(top = 6.dp)
) {
Text(if (isReceive) "Save As..." else "Pick File...", fontSize = 11.sp)
}
}
Text(
text = if (isReceive) "Pick a destination or enter a file name to save in app storage" else "Use 'Pick File' to select any file from Downloads / Storage",
fontSize = 10.sp,
color = Color.LightGray,
modifier = Modifier.padding(start = 4.dp, top = 2.dp)
)
}
// Transfer Mode
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isAscii,
onClick = { isAscii = true },
label = { Text("ASCII (Text)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isAscii,
onClick = { isAscii = false },
label = { Text("Binary", fontSize = 11.sp) }
)
}
}
// Host Type (TSO, CMS, CICS)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FTConfig.HostType.values().forEach { ht ->
FilterChip(
selected = (hostType == ht),
onClick = { hostType = ht },
label = { Text(ht.name, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
// TSO Send Options
if (!isReceive && hostType == FTConfig.HostType.TSO) {
Text("TSO Dataset Allocation:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Record Format:", fontSize = 11.sp)
Row {
listOf(
FTConfig.RecordFormat.DEFAULT to "Def",
FTConfig.RecordFormat.FIXED to "F",
FTConfig.RecordFormat.VARIABLE to "V"
).forEach { (rf, label) ->
FilterChip(
selected = (recfm == rf),
onClick = { recfm = rf },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lreclStr,
onValueChange = { lreclStr = it.filter { c -> c.isDigit() } },
label = { Text("LRECL", fontSize = 11.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it.filter { c -> c.isDigit() } },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.weight(1f)
)
}
}
}
},
confirmButton = {
Button(
onClick = {
val resolvedLocal = if (localFile.isNotBlank()) {
localFile.trim()
} else if (localDisplayName.isNotBlank()) {
val tempDir = File(context.cacheDir, if (isReceive) "ft_download" else "ft_upload").apply { if (!exists()) mkdirs() }
File(tempDir, localDisplayName.trim()).absolutePath
} else ""
if (hostFile.isNotBlank() && resolvedLocal.isNotBlank()) {
localFile = resolvedLocal
val config = FTConfig().apply {
setHostFilename(hostFile.trim())
setLocalFilename(resolvedLocal)
setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND)
setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY)
setHostType(hostType)
setExistAction(if (overwrite) FTConfig.ExistAction.REPLACE else FTConfig.ExistAction.KEEP)
if (!isReceive && hostType == FTConfig.HostType.TSO) {
setRecfm(recfm)
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
}
}
onStartTransfer(config)
}
},
enabled = hostFile.isNotBlank() && (localFile.isNotBlank() || localDisplayName.isNotBlank()) && !ftProgressState.isRunning
) {
Text("Start Transfer")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
private fun getFileNameFromUri(context: Context, uri: Uri): String {
var name = ""
try {
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
name = it.getString(nameIndex) ?: ""
}
}
}
} catch (_: Exception) {}
if (name.isBlank()) {
name = uri.lastPathSegment ?: "file"
}
return name
}
@@ -0,0 +1,183 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun FindDialog(
cols: Int = 80,
onDismiss: () -> Unit,
onFind: (query: String, matchCase: Boolean, forward: Boolean) -> Int,
onClearHighlight: () -> Unit
) {
var query by remember { mutableStateOf("") }
var matchCase by remember { mutableStateOf(false) }
var forward by remember { mutableStateOf(true) }
var statusMessage by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(100)
focusRequester.requestFocus()
}
fun doFind(searchForward: Boolean) {
val trimmed = query.trim()
if (trimmed.isEmpty()) {
statusMessage = "Please enter search text."
isError = true
return
}
val pos = onFind(trimmed, matchCase, searchForward)
if (pos >= 0) {
val effectiveCols = if (cols > 0) cols else 80
val row = (pos / effectiveCols) + 1
val col = (pos % effectiveCols) + 1
statusMessage = "Found at position $pos ($row/$col)"
isError = false
} else {
statusMessage = "Text not found: \"$trimmed\""
isError = true
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.92f)
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Find on Screen",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
OutlinedTextField(
value = query,
onValueChange = {
query = it
if (statusMessage.isNotBlank()) statusMessage = ""
},
label = { Text("Find What") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = { doFind(forward) }
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
// Options Row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { matchCase = !matchCase }
) {
Checkbox(
checked = matchCase,
onCheckedChange = { matchCase = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Match Case", fontSize = 12.sp, color = Color.White)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
FilterChip(
selected = forward,
onClick = { forward = true },
label = { Text("Down ↓", fontSize = 11.sp) }
)
FilterChip(
selected = !forward,
onClick = { forward = false },
label = { Text("Up ↑", fontSize = 11.sp) }
)
}
}
if (statusMessage.isNotBlank()) {
Text(
text = statusMessage,
fontSize = 12.sp,
color = if (isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = {
onClearHighlight()
onDismiss()
}) {
Text("Close", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
OutlinedButton(onClick = {
onClearHighlight()
statusMessage = ""
}) {
Text("Clear")
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = { doFind(forward) },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Find Next")
}
}
}
}
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui package haus.nightmare.a3270.ui
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -15,23 +15,35 @@ 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 org.lib3270j.ConnectionState import haus.nightmare.lib3270j.ConnectionState
import org.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
@Composable @Composable
fun TwoRowKeyBar( fun TwoRowKeyBar(
connectionState: ConnectionState, connectionState: ConnectionState,
isShiftPressed: Boolean = false, isShiftPressed: Boolean = false,
isLightPenMode: Boolean = false,
hapticFeedbackEnabled: Boolean = true, hapticFeedbackEnabled: Boolean = true,
onClearShift: () -> Unit = {}, onClearShift: () -> Unit = {},
onConnectClick: () -> Unit, onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit, onDisconnectClick: () -> Unit,
onFtClick: () -> Unit, onFtClick: () -> Unit,
onFieldInspectorClick: () -> Unit = {},
onFindClick: () -> Unit = {},
onScreenExportClick: () -> Unit = {},
onScriptClick: () -> Unit = {},
onPrinterSessionClick: () -> Unit = {},
onToggleLightPen: () -> Unit = {},
onSettingsClick: () -> Unit = {}, onSettingsClick: () -> Unit = {},
onSendAid: (Int) -> Unit, onSendAid: (Int) -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
onTab: () -> Unit, onTab: () -> Unit,
onBackTab: () -> Unit, onBackTab: () -> Unit,
onEraseInput: () -> Unit = {},
onEraseEof: () -> Unit = {},
onAttn: () -> Unit = {},
onSysReq: () -> Unit = {},
onCursorSelect: () -> Unit = {},
onCursorLeft: () -> Unit = {}, onCursorLeft: () -> Unit = {},
onCursorUp: () -> Unit = {}, onCursorUp: () -> Unit = {},
onCursorDown: () -> Unit = {}, onCursorDown: () -> Unit = {},
@@ -53,7 +65,7 @@ fun TwoRowKeyBar(
.padding(horizontal = 1.dp, vertical = 1.dp), .padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Settings) // 1. Menu Dropdown Button (Connect/Disconnect/FT/Diagnostics/Tools/Settings)
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
KeyButton( KeyButton(
label = "", label = "",
@@ -66,7 +78,9 @@ fun TwoRowKeyBar(
DropdownMenu( DropdownMenu(
expanded = menuExpanded, expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }, onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30)) modifier = Modifier
.background(Color(0xFF2C2D30))
.heightIn(max = 420.dp)
) { ) {
if (!connectionState.isConnected()) { if (!connectionState.isConnected()) {
DropdownMenuItem( DropdownMenuItem(
@@ -76,6 +90,13 @@ fun TwoRowKeyBar(
onConnectClick() onConnectClick()
} }
) )
DropdownMenuItem(
text = { Text("3287 Printer Session", color = Color.White) },
onClick = {
menuExpanded = false
onPrinterSessionClick()
}
)
} else { } else {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) }, text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
@@ -84,14 +105,93 @@ fun TwoRowKeyBar(
onDisconnectClick() onDisconnectClick()
} }
) )
HorizontalDivider(color = Color(0xFF3E4044))
DropdownMenuItem( DropdownMenuItem(
text = { Text("File Transfer (FT)", color = Color.White, fontWeight = FontWeight.SemiBold) }, text = { Text("Find on Screen", color = Color.White) },
onClick = {
menuExpanded = false
onFindClick()
}
)
DropdownMenuItem(
text = { Text("Field Inspector", color = Color.White) },
onClick = {
menuExpanded = false
onFieldInspectorClick()
}
)
DropdownMenuItem(
text = { Text("File Transfer (IND\$FILE)", color = Color.White) },
onClick = { onClick = {
menuExpanded = false menuExpanded = false
onFtClick() onFtClick()
} }
) )
DropdownMenuItem(
text = { Text("Save / Share Screen", color = Color.White) },
onClick = {
menuExpanded = false
onScreenExportClick()
}
)
DropdownMenuItem(
text = { Text("Run Keystroke Script", color = Color.White) },
onClick = {
menuExpanded = false
onScriptClick()
}
)
DropdownMenuItem(
text = { Text("3287 Printer Session", color = Color.White) },
onClick = {
menuExpanded = false
onPrinterSessionClick()
}
)
HorizontalDivider(color = Color(0xFF3E4044))
DropdownMenuItem(
text = { Text("Attention (Attn)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onAttn()
}
)
DropdownMenuItem(
text = { Text("System Request (SysReq)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onSysReq()
}
)
DropdownMenuItem(
text = { Text("Erase Input (ErInp)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onEraseInput()
}
)
DropdownMenuItem(
text = { Text("Cursor Select (CurSel)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onCursorSelect()
}
)
HorizontalDivider(color = Color(0xFF3E4044))
} }
DropdownMenuItem(
text = {
Text(
text = if (isLightPenMode) "Light Pen: ON" else "Light Pen: OFF",
color = if (isLightPenMode) Color(0xFFFFFF50) else Color.White,
fontWeight = FontWeight.SemiBold
)
},
onClick = {
menuExpanded = false
onToggleLightPen()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) }, text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = { onClick = {
@@ -120,14 +220,59 @@ fun TwoRowKeyBar(
} }
) )
// 3. RESET // 3. RESET / ERASE (Erase Input) depending on Shift state
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onReset) val resetLabel = if (isShiftPressed) "ERASE" else "RESET"
KeyButton(
label = resetLabel,
color = Color(0xFFE67700),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onEraseInput()
onClearShift()
} else {
onReset()
}
}
)
// 4. ENTER // 4. ENTER / EOF (Erase EOF) depending on Shift state
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) }) val enterLabel = if (isShiftPressed) "EOF" else "ENTER"
KeyButton(
label = enterLabel,
color = Color(0xFF2B8A3E),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onEraseEof()
onClearShift()
} else {
onSendAid(AID_ENTER)
}
}
)
// 5. CLEAR // 5. CLEAR / ATTN (Attention) depending on Shift state
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) }) val clearLabel = if (isShiftPressed) "ATTN" else "CLEAR"
KeyButton(
label = clearLabel,
color = Color(0xFFC92A2A),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onAttn()
onClearShift()
} else {
onSendAid(AID_CLEAR)
}
}
)
// 6. PA1 // 6. PA1
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) }) KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
@@ -0,0 +1,310 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
@Composable
fun HostDirectoryDialog(
client: Telnet3270Client?,
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
initialQuery: String = "",
onDismiss: () -> Unit,
onSelectDataset: (String) -> Unit
) {
var hostType by remember { mutableStateOf(initialHostType) }
var query by remember { mutableStateOf(initialQuery) }
var entries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var statusMessage by remember { mutableStateOf("") }
fun runQuery() {
val c = client
if (c == null || !c.connectionState.isFullSession) {
statusMessage = "Terminal not connected."
return
}
val xfer = c.xfer
if (xfer == null) {
statusMessage = "ECL transfer interface not available."
return
}
val q = query.trim()
if (hostType == FTConfig.HostType.CMS) {
val list = xfer.getCmsDirectory(q)
entries = list ?: emptyList()
} else {
val list = xfer.getTsoDirectory(q)
entries = list ?: emptyList()
}
selectedIndex = -1
statusMessage = "${entries.size} entries found."
}
fun parseScreen() {
val c = client
if (c == null || !c.connectionState.isFullSession) {
statusMessage = "No active screen to parse."
return
}
val xfer = c.xfer
if (xfer == null) {
statusMessage = "ECL transfer interface not available."
return
}
val ps = c.ps
val screenText = ps.getString(0, ps.size)
if (hostType == FTConfig.HostType.CMS) {
val list = xfer.getCmsDirectory(screenText)
entries = list ?: emptyList()
} else {
val list = xfer.getTsoDirectory(screenText)
entries = list ?: emptyList()
}
selectedIndex = -1
statusMessage = "Parsed ${entries.size} entries from screen."
}
LaunchedEffect(Unit) {
if (initialQuery.isNotBlank()) {
runQuery()
} else {
parseScreen()
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header & Query Bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Host Catalog & Directory Browser",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
Row {
FilterChip(
selected = (hostType == FTConfig.HostType.TSO),
onClick = { hostType = FTConfig.HostType.TSO },
label = { Text("TSO", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (hostType == FTConfig.HostType.CMS),
onClick = { hostType = FTConfig.HostType.CMS },
label = { Text("CMS", fontSize = 11.sp) }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Query Pattern") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* EXEC *") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(onSearch = { runQuery() }),
modifier = Modifier.weight(1f)
)
Button(
onClick = { runQuery() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Query")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = statusMessage.ifBlank { "Enter query or parse screen." },
fontSize = 11.sp,
color = Color.Gray
)
TextButton(onClick = { parseScreen() }) {
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Table View
if (entries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text("No entries found.", color = Color.Gray, fontSize = 12.sp)
}
} else {
val horizScroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.horizontalScroll(horizScroll)
) {
// Header Row
Row(
modifier = Modifier
.background(Color(0xFF25262B))
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (hostType == FTConfig.HostType.CMS) {
Text("Filename", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(90.dp))
Text("Filetype", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(80.dp))
Text("Fm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Fmt", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Records", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Date / Time", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(140.dp))
} else {
Text("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp))
Text("Volume", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(70.dp))
Text("Recfm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Blksize", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Tracks", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Ext", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
// Entry Rows
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(entries) { idx, item ->
val isSelected = (idx == selectedIndex)
Surface(
shape = RoundedCornerShape(4.dp),
color = if (isSelected) Color(0xFF1C7ED6).copy(alpha = 0.35f) else if (idx % 2 == 0) Color(0xFF161719) else Color(0xFF1E1F23),
modifier = Modifier
.fillMaxWidth()
.clickable { selectedIndex = idx }
) {
Row(
modifier = Modifier.padding(vertical = 5.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item is CMSDirectoryEntry) {
Text(item.filename, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(item.filetype, color = Color.Cyan, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(80.dp))
Text(item.filemode, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.numRecords}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.date} ${item.time}", color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp))
} else if (item is TSODirectoryEntry) {
Text(item.datasetName, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(220.dp))
Text(item.volume, color = Color.Cyan, fontSize = 11.sp, modifier = Modifier.width(70.dp))
Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.blksize}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.tracksAllocated}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.extents}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
}
}
}
}
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = {
if (selectedIndex in entries.indices) {
val selectedName = entries[selectedIndex].name
onSelectDataset(selectedName)
onDismiss()
}
},
enabled = selectedIndex in entries.indices,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Select Dataset")
}
}
}
}
}
}
@@ -0,0 +1,285 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import haus.nightmare.lib3270j.ConnectionState
@Composable
fun OiaStatusBar(
connectionState: ConnectionState,
currentHost: String,
isKeyboardLocked: Boolean,
oiaText: String,
cursorAddr: Int,
rows: Int,
cols: Int,
isTls: Boolean = false,
isTlsVerified: Boolean = true,
graphicsMode: String = "NONE",
codePage: String = "037",
isLightPenMode: Boolean = false,
isInsertMode: Boolean = false,
inhibitReason: Int = 0,
luName: String = "",
isNumericField: Boolean = false,
modelName: String = "",
modifier: Modifier = Modifier
) {
val row = if (cols > 0 && rows > 0) ((cursorAddr / cols) % rows) + 1 else 1
val col = if (cols > 0) (cursorAddr % cols) + 1 else 1
val posStr = String.format("%03d/%03d [%04d]", row, col, cursorAddr)
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
val connTypeBadge = when (connectionState) {
ConnectionState.CONNECTED_3270 -> "TN3270"
ConnectionState.CONNECTED_TN3270E -> "TN3270E"
ConnectionState.CONNECTED_SSCP -> "SSCP-LU"
ConnectionState.CONNECTED_NVT, ConnectionState.CONNECTED_NVT_CHAR, ConnectionState.CONNECTED_E_NVT -> "NVT"
ConnectionState.CONNECTED_UNBOUND -> "UNBOUND"
ConnectionState.TCP_PENDING, ConnectionState.TELNET_PENDING -> "CONNECTING"
ConnectionState.NOT_CONNECTED -> "OFFLINE"
else -> connectionState.name
}
val (lockStatusText, lockStatusColor) = if (!connectionState.isConnected()) {
"OFFLINE" to Color(0xFF868E96)
} else if (inhibitReason != 0) {
val txt = when (inhibitReason) {
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_SYSTEM_LOCK -> "X SYSTEM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> "X COMM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> "X NUM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_PROTECTED_FIELD -> "X PROT"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> "X >"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> "X OP"
else -> "X LOCKED"
}
val clr = when (inhibitReason) {
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> Color(0xFFFF0000)
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> Color(0xFFFFFF00)
else -> Color(0xFFFFFFFF)
}
txt to clr
} else if (isKeyboardLocked) {
"X SYSTEM" to Color(0xFFFFFFFF)
} else if (isInsertMode) {
"INSERT" to Color(0xFF7890F0)
} else {
"READY" to Color(0xFF7890F0)
}
Row(
modifier = modifier
.fillMaxWidth()
.height(26.dp)
.background(Color(0xFF161719))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Connected Host & Status Indicator
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(if (connectionState.isConnected()) Color(0xFF7890F0) else Color(0xFF868E96))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = hostText,
color = Color.White,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1
)
// TLS Badge
if (connectionState.isConnected() && isTls) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = if (isTlsVerified) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFFB450).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = if (isTlsVerified) "🔒 TLS" else "🔓 TLS",
color = if (isTlsVerified) Color(0xFF51CF66) else Color(0xFFFFB450),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// GFX Mode Badge
if (connectionState.isConnected() && graphicsMode != "NONE" && graphicsMode.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF22B8CF).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = when (graphicsMode) {
"VECTOR_GRAPHICS" -> "GOCA"
"PROGRAMMED_SYMBOLS" -> "PS"
else -> "GFX"
},
color = Color(0xFF22B8CF),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// 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))
Surface(
color = Color(0xFF339AF0).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = if (isNumericField) "NUM" else "ALPHA",
color = if (isNumericField) Color(0xFFFFD43B) else Color(0xFF74C0FC),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// 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))
Surface(
color = Color(0xFFFFFF50).copy(alpha = 0.25f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "LP",
color = Color(0xFFFFFF50),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// Connection Type Badge (TN3270, TN3270E, SSCP, NVT)
if (connectionState.isConnected()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF7950F2).copy(alpha = 0.25f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = connTypeBadge,
color = Color(0xFFB197FC),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// LU Name Badge
if (connectionState.isConnected() && luName.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF40C057).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "LU:$luName",
color = Color(0xFF69DB7C),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
}
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
Surface(
color = lockStatusColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.padding(horizontal = 4.dp)
) {
Text(
text = lockStatusText,
color = lockStatusColor,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
// Cursor Position & Buffer Address
Text(
text = posStr,
color = Color(0xFF22B8CF),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
@@ -0,0 +1,490 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.os.Environment
import android.print.PrintAttributes
import android.print.PrintDocumentAdapter
import android.print.PrintManager
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.printer.*
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun PrinterSessionDialog(
initialHost: String = "",
initialPort: Int = 0,
initialCodePage: String = "037",
initialUseTls: Boolean = false,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var host by remember { mutableStateOf(initialHost) }
var portStr by remember { mutableStateOf(if (initialPort > 0) initialPort.toString() else "") }
var printerLu by remember { mutableStateOf("") }
var assocDisplayLu by remember { mutableStateOf("") }
var codePage by remember { mutableStateOf(initialCodePage) }
var useTls by remember { mutableStateOf(initialUseTls) }
var pdtType by remember { mutableStateOf("DEFAULT") }
var isConnected by remember { mutableStateOf(false) }
var sessionStatus by remember { mutableStateOf("Disconnected") }
var spoolText by remember { mutableStateOf("") }
var pageCount by remember { mutableIntStateOf(0) }
var byteCount by remember { mutableIntStateOf(0) }
var printerClient by remember { mutableStateOf<Telnet3270EPClient?>(null) }
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
fun startSession() {
val targetHost = host.trim().ifBlank { "127.0.0.1" }
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
val config = PrinterConfig(targetHost, port).apply {
this.isUseTls = useTls
this.codePage = codePage
if (printerLu.isNotBlank()) this.printerLuName = printerLu.trim()
if (assocDisplayLu.isNotBlank()) this.associatedDisplayLuName = assocDisplayLu.trim()
this.destinationType = PrinterConfig.DestinationType.MEMORY
val pdt = when (pdtType) {
"PCL5" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPcl5PDT()
"EPSON_ESC_P" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createEpsonEscPPDT()
"POSTSCRIPT" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPostScriptPDT()
else -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPlainTextPDT()
}
if (pdt != null) {
this.printerDefinitionTable = pdt
}
}
val client = Telnet3270EPClient(config)
client.addPrintListener(object : PrintSessionListener {
override fun onPrintJobStarted(event: PrintSessionEvent) {
sessionStatus = "Printing job: ${event.jobName ?: "Job"}"
}
override fun onPrintJobData(event: PrintSessionEvent) {
val captured = client.pd?.capturedText
if (captured != null) spoolText = captured
byteCount = event.byteCount.toInt()
}
override fun onPrintJobPageComplete(event: PrintSessionEvent) {
pageCount = event.pageNumber
byteCount = event.byteCount.toInt()
val captured = client.pd?.capturedText
if (captured != null) spoolText = captured
}
override fun onPrintJobComplete(event: PrintSessionEvent) {
pageCount = event.totalPages
byteCount = event.byteCount.toInt()
val captured = client.pd?.capturedText
if (captured != null) spoolText = captured
sessionStatus = "Print Job Complete"
}
override fun onPrinterStatusChanged(event: PrintSessionEvent) {
val status = event.statusCode
isConnected = (status == PrinterConstants.STATUS_CONNECTED || status == PrinterConstants.STATUS_PRINTING)
sessionStatus = when (status) {
PrinterConstants.STATUS_CONNECTED -> "Connected"
PrinterConstants.STATUS_CONNECTING -> "Connecting..."
PrinterConstants.STATUS_PRINTING -> "Printing..."
else -> "Disconnected"
}
}
override fun onPrinterError(event: PrintSessionEvent) {
sessionStatus = "Error: ${event.message ?: "Code ${event.statusCode}"}"
}
})
printerClient = client
sessionStatus = "Connecting..."
val ok = client.connect()
if (!ok) {
sessionStatus = "Failed to initiate printer connection."
}
}
fun stopSession() {
printerClient?.disconnect()
printerClient = null
isConnected = false
sessionStatus = "Disconnected"
}
DisposableEffect(Unit) {
onDispose {
printerClient?.disconnect()
printerClient = null
}
}
fun printViaAndroidService() {
if (spoolText.isBlank()) {
Toast.makeText(context, "No printer spool to print.", Toast.LENGTH_SHORT).show()
return
}
try {
val printManager = context.getSystemService(Context.PRINT_SERVICE) as? PrintManager
if (printManager != null) {
val webView = WebView(context)
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
val printAdapter: PrintDocumentAdapter = webView.createPrintDocumentAdapter("3287_Print_Job")
printManager.print("3287 Print Job", printAdapter, PrintAttributes.Builder().build())
}
}
val htmlDoc = "<html><body style='font-family:monospace; font-size:12px; white-space:pre;'>${spoolText.replace("<", "&lt;").replace(">", "&gt;")}</body></html>"
webView.loadDataWithBaseURL(null, htmlDoc, "text/html", "UTF-8", null)
}
} catch (e: Exception) {
Toast.makeText(context, "Print failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun saveSpoolToFile() {
if (spoolText.isBlank()) {
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
return
}
try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val file = File(downloadsDir, "3287_spool_$timeStamp.txt")
file.writeText(spoolText)
Toast.makeText(context, "Saved to Downloads/${file.name}", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Save failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun shareSpool() {
if (spoolText.isBlank()) {
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
return
}
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "3287 Printer Spool")
putExtra(Intent.EXTRA_TEXT, spoolText)
}
context.startActivity(Intent.createChooser(intent, "Share 3287 Spool"))
} catch (e: Exception) {
Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 700.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"IBM 3287 Printer Session",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
"TN3270E SCS / 3270 DS Print Emulation",
fontSize = 11.sp,
color = Color.Gray
)
}
Surface(
color = if (isConnected) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFF6B6B).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = sessionStatus,
color = if (isConnected) Color(0xFF51CF66) else Color(0xFFFF6B6B),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Config Row 1: Host & Port
if (!isConnected) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
placeholder = { Text("127.0.0.1", color = Color.Gray) },
singleLine = true,
modifier = Modifier.weight(2.5f)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
label = { Text("Port") },
placeholder = { Text(if (useTls) "992" else "23", color = Color.Gray) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
}
// Config Row 2: Printer LU & Assoc Display LU
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = printerLu,
onValueChange = { printerLu = it },
label = { Text("Printer LU (Opt)") },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = assocDisplayLu,
onValueChange = { assocDisplayLu = it },
label = { Text("Display LU (Opt)") },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
// PDT, Code Page & TLS Row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
var pdtExpanded by remember { mutableStateOf(false) }
val pdtList = listOf(
"DEFAULT" to "Plain Text",
"PCL5" to "HP PCL 5/6",
"EPSON_ESC_P" to "Epson ESC/P",
"POSTSCRIPT" to "PostScript"
)
val selectedPdtLabel = pdtList.firstOrNull { it.first == pdtType }?.second ?: "PDT"
Box(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = { pdtExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text("PDT: $selectedPdtLabel", fontSize = 10.sp, maxLines = 1)
}
DropdownMenu(
expanded = pdtExpanded,
onDismissRequest = { pdtExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30))
) {
pdtList.forEach { (type, label) ->
DropdownMenuItem(
text = { Text(label, fontSize = 12.sp, color = Color.White) },
onClick = {
pdtType = type
pdtExpanded = false
}
)
}
}
}
var cpExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = { cpExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text("CP$codePage", fontSize = 10.sp, maxLines = 1)
}
DropdownMenu(
expanded = cpExpanded,
onDismissRequest = { cpExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30)).heightIn(max = 240.dp)
) {
codePagesList.forEach { (cpId, label) ->
DropdownMenuItem(
text = { Text(label, fontSize = 12.sp, color = Color.White) },
onClick = {
codePage = cpId
cpExpanded = false
}
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { useTls = !useTls }
) {
Checkbox(checked = useTls, onCheckedChange = { useTls = it })
Text("TLS", fontSize = 11.sp, color = Color.White)
}
Button(
onClick = { startSession() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Connect", fontSize = 11.sp)
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Pages: $pageCount | Bytes: $byteCount",
fontSize = 12.sp,
color = Color.LightGray,
fontFamily = FontFamily.Monospace
)
Button(
onClick = { stopSession() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A))
) {
Text("Disconnect Printer")
}
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Spool Display Box
Text("Print Spool Buffer:", fontSize = 12.sp, color = Color.Gray)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
.padding(10.dp)
) {
if (spoolText.isBlank()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No printed output spooled yet.", color = Color.DarkGray, fontSize = 12.sp)
}
} else {
Text(
text = spoolText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFFA5D6A7),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
)
}
}
// Spool Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(
onClick = {
spoolText = ""
pageCount = 0
byteCount = 0
printerClient?.pd?.resetCapture()
}
) {
Text("Clear", fontSize = 11.sp)
}
OutlinedButton(
onClick = {
clipboardManager.setText(AnnotatedString(spoolText))
Toast.makeText(context, "Copied spool to clipboard", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy", fontSize = 11.sp)
}
Button(
onClick = { saveSpoolToFile() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Save", fontSize = 11.sp)
}
Button(
onClick = { printViaAndroidService() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Print 🖨️", fontSize = 11.sp)
}
}
}
}
}
}
}
@@ -0,0 +1,521 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.net.Uri
import android.os.Environment
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.FileProvider
import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ScreenExporterDialog(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager? = null,
graphicsPlane: GraphicsPlane? = null,
maskHidden: Boolean = true,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var exportFormat by remember { mutableStateOf("TEXT") } // TEXT, HTML, PNG
var statusMessage by remember { mutableStateOf("") }
val plainTextContent = remember(screenBuffer) {
generatePlainText(screenBuffer, rows, cols, maskHidden)
}
val htmlContent = remember(screenBuffer) {
generateHtml(screenBuffer, rows, cols, maskHidden)
}
fun shareText(text: String, isHtml: Boolean) {
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = if (isHtml) "text/html" else "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "3270 Screen Capture")
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share 3270 Screen"))
} catch (e: Exception) {
statusMessage = "Share failed: ${e.message}"
}
}
fun sharePng(bmp: Bitmap) {
try {
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val file = File(cacheDir, "screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
val uri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Screen PNG"))
} catch (e: Exception) {
statusMessage = "PNG Share failed: ${e.message}"
}
}
fun saveFileToStorage(format: String): String {
return try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
when (format) {
"HTML" -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.html")
file.writeText(htmlContent)
"Saved to Downloads/${file.name}"
}
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
val file = File(downloadsDir, "3270_screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
"Saved to Downloads/${file.name}"
}
else -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.txt")
file.writeText(plainTextContent)
"Saved to Downloads/${file.name}"
}
}
} catch (e: Exception) {
"Save failed: ${e.message}"
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Export / Share Screen",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Row {
FilterChip(
selected = (exportFormat == "TEXT"),
onClick = { exportFormat = "TEXT" },
label = { Text("Plain Text", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "HTML"),
onClick = { exportFormat = "HTML" },
label = { Text("HTML", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "PNG"),
onClick = { exportFormat = "PNG" },
label = { Text("PNG", fontSize = 11.sp) }
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Preview Box
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
.padding(10.dp)
) {
if (exportFormat == "PNG") {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("🖼️ PNG Image Export", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White)
Spacer(modifier = Modifier.height(6.dp))
Text("Full resolution bitmap with 3270 styling and graphics plane.", fontSize = 12.sp, color = Color.LightGray)
}
} else {
val textToShow = if (exportFormat == "HTML") htmlContent else plainTextContent
Text(
text = textToShow,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = Color(0xFFA5D6A7),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
)
}
}
if (statusMessage.isNotBlank()) {
Text(
text = statusMessage,
fontSize = 12.sp,
color = Color(0xFF51CF66)
)
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (exportFormat != "PNG") {
OutlinedButton(
onClick = {
val content = if (exportFormat == "HTML") htmlContent else plainTextContent
clipboardManager.setText(AnnotatedString(content))
statusMessage = "Copied to clipboard."
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy", fontSize = 12.sp)
}
}
Button(
onClick = {
val msg = saveFileToStorage(exportFormat)
statusMessage = msg
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Save File", fontSize = 12.sp)
}
Button(
onClick = {
when (exportFormat) {
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
sharePng(bmp)
}
"HTML" -> shareText(htmlContent, true)
else -> shareText(plainTextContent, false)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Share 📤", fontSize = 12.sp)
}
}
}
}
}
}
}
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return ""
val sb = StringBuilder()
var currentHidden = false
for (r in 0 until rows) {
val rowSb = StringBuilder()
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
rowSb.append(' ')
} else {
if (currentHidden && maskHidden) {
rowSb.append(' ')
} else {
val ch = cell.ucs4
rowSb.append(if (ch in ' '..'~' || ch > '\u007F') ch else ' ')
}
}
}
sb.append(rowSb.toString().trimEnd()).append("\n")
}
return sb.toString()
}
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
val sb = StringBuilder()
sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
sb.append("<meta charset=\"UTF-8\">\n")
sb.append("<style>\n")
sb.append("body { background-color: #0A0A0A; color: #00FF00; font-family: monospace; font-size: 14px; margin: 20px; }\n")
sb.append("pre { line-height: 1.2; font-family: monospace; }\n")
sb.append(".c-green { color: #00FF00; }\n")
sb.append(".c-white { color: #FFFFFF; }\n")
sb.append(".c-blue { color: #7890F0; }\n")
sb.append(".c-red { color: #FF0000; }\n")
sb.append(".c-yellow { color: #FFFF00; }\n")
sb.append(".c-turquoise { color: #00FFFF; }\n")
sb.append(".c-pink { color: #FF00FF; }\n")
sb.append(".c-orange { color: #FFA200; }\n")
sb.append(".c-mustard { color: #A0A000; }\n")
sb.append(".c-grey { color: #C0C0C0; }\n")
sb.append("</style>\n</head>\n<body>\n<pre>\n")
var currentHidden = false
var currentProt = false
var currentHi = false
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentProt = (faVal and FA_PROTECT) != 0
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
currentFieldEa = cell
sb.append(' ')
} else {
if (currentHidden && maskHidden) {
sb.append(' ')
} else {
val ch = cell.ucs4
val fgByte = if (cell.fg != 0.toByte()) (cell.fg.toInt() and 0xFF)
else (currentFieldEa?.fg?.toInt()?.and(0xFF) ?: 0)
val colorClass = if (fgByte in 0xf0..0xff) {
when (fgByte - 0xf0) {
1 -> "c-blue"
2 -> "c-red"
3 -> "c-pink"
4 -> "c-green"
5 -> "c-turquoise"
6 -> "c-yellow"
7 -> "c-white"
9 -> "c-blue"
10 -> "c-orange"
11 -> "c-pink"
12 -> "c-green"
13 -> "c-turquoise"
14 -> "c-mustard"
15 -> "c-grey"
else -> "c-green"
}
} else {
when {
currentProt && currentHi -> "c-white"
currentProt && !currentHi -> "c-turquoise"
!currentProt && currentHi -> "c-red"
else -> "c-green"
}
}
val escChar = when (ch) {
'<' -> "&lt;"
'>' -> "&gt;"
'&' -> "&amp;"
'"' -> "&quot;"
else -> if (ch in ' '..'~' || ch > '\u007F') ch.toString() else " "
}
sb.append("<span class=\"$colorClass\">$escChar</span>")
}
}
}
sb.append("\n")
}
sb.append("</pre>\n</body>\n</html>")
return sb.toString()
}
private fun generateBitmap(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager?,
graphicsPlane: GraphicsPlane?,
maskHidden: Boolean
): Bitmap {
val charWidth = 14
val charHeight = 24
val width = cols * charWidth
val height = rows * charHeight
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// Fill background
val bgPaint = Paint().apply {
color = android.graphics.Color.parseColor("#0A0A0A")
style = Paint.Style.FILL
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
if (screenBuffer == null) return bitmap
// Layer 1: Vector graphics plane (under text)
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val rgb = graphicsPlane.rgbBuffer
val gWidth = graphicsPlane.canvasWidth
val gHeight = graphicsPlane.canvasHeight
if (rgb != null && gWidth > 0 && gHeight > 0) {
val gfxBmp = Bitmap.createBitmap(rgb, gWidth, gHeight, Bitmap.Config.ARGB_8888)
val scaledGfx = Bitmap.createScaledBitmap(gfxBmp, width, height, true)
canvas.drawBitmap(scaledGfx, 0f, 0f, null)
}
}
val textPaint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textSize = 18f
}
var currentHidden = false
var currentProt = false
var currentHi = false
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
val left = (c * charWidth).toFloat()
val top = (r * charHeight).toFloat()
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentProt = (faVal and FA_PROTECT) != 0
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
currentFieldEa = cell
} else {
val csVal = cell.cs.toInt() and 0xFF
val ecVal = cell.ec.toInt() and 0xFF
val fgByte = if (cell.fg != 0.toByte()) (cell.fg.toInt() and 0xFF)
else (currentFieldEa?.fg?.toInt()?.and(0xFF) ?: 0)
val cellColor = if (fgByte in 0xf0..0xff) {
when (fgByte - 0xf0) {
1 -> android.graphics.Color.parseColor("#7890F0")
2 -> android.graphics.Color.parseColor("#FF0000")
3 -> android.graphics.Color.parseColor("#FF00FF")
4 -> android.graphics.Color.parseColor("#00FF00")
5 -> android.graphics.Color.parseColor("#00FFFF")
6 -> android.graphics.Color.parseColor("#FFFF00")
7 -> android.graphics.Color.WHITE
9 -> android.graphics.Color.parseColor("#000080")
10 -> android.graphics.Color.parseColor("#FFA200")
11 -> android.graphics.Color.parseColor("#800080")
12 -> android.graphics.Color.parseColor("#008000")
13 -> android.graphics.Color.parseColor("#008080")
14 -> android.graphics.Color.parseColor("#A0A000")
15 -> android.graphics.Color.parseColor("#C0C0C0")
else -> android.graphics.Color.parseColor("#00FF00")
}
} else {
when {
currentProt && currentHi -> android.graphics.Color.WHITE
currentProt && !currentHi -> android.graphics.Color.parseColor("#00FFFF")
!currentProt && currentHi -> android.graphics.Color.parseColor("#FF0000")
else -> android.graphics.Color.parseColor("#00FF00")
}
}
var drawnAsPs = false
if (csVal >= 0x40 && programSymbolManager != null) {
val slot = programSymbolManager.getSymbol(csVal, ecVal)
if (slot != null) {
val symWidth = slot.width
val symHeight = slot.height
val bgArgb = android.graphics.Color.TRANSPARENT
val rgbArray = slot.getRgbPixels(cellColor, bgArgb)
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val symBmp = Bitmap.createBitmap(rgbArray, symWidth, symHeight, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(symBmp, null, android.graphics.RectF(left, top, left + charWidth, top + charHeight), null)
drawnAsPs = true
}
}
}
if (!drawnAsPs && (!currentHidden || !maskHidden)) {
val ch = cell.ucs4
if (ch in ' '..'~' || ch > '\u007F') {
textPaint.color = cellColor
canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint)
}
}
}
}
}
return bitmap
}
@@ -0,0 +1,211 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun ScriptDialog(
initialScript: String = "",
onDismiss: () -> Unit,
onExecuteScript: (script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit) -> Unit
) {
var scriptText by remember { mutableStateOf(initialScript.ifBlank { "# Sample 3270 Script\nLOGON APPLID(TSO)[enter]\n[wait]\nUSERID[tab]PASSWORD[enter]" }) }
var isExecuting by remember { mutableStateOf(false) }
var executionStatus by remember { mutableStateOf("") }
var executionError by remember { mutableStateOf<String?>(null) }
fun insertToken(token: String) {
scriptText += token
}
Dialog(
onDismissRequest = { if (!isExecuting) onDismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Run Keystroke Script / Macro",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
"ECL Mnemonic Tokens",
fontSize = 11.sp,
color = Color.Gray
)
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Mnemonic Quick Insert Chips Row 1
Text("Insert Mnemonic Keys:", fontSize = 11.sp, color = Color.LightGray)
val tokenScroll1 = rememberScrollState()
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(tokenScroll1),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
listOf(
"[enter]", "[tab]", "[backtab]", "[home]", "[clear]",
"[reset]", "[eraseeof]", "[eraseinpt]", "[attn]", "[sysreq]",
"[wordleft]", "[wordright]", "[fieldend]", "[deleteword]", "[cursel]", "[lightpen]"
).forEach { tok ->
SuggestionChip(
onClick = { insertToken(tok) },
label = { Text(tok, fontSize = 10.sp, fontFamily = FontFamily.Monospace) }
)
}
}
// Mnemonic Quick Insert Chips Row 2 (PF Keys)
val tokenScroll2 = rememberScrollState()
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(tokenScroll2),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
(1..24).forEach { pf ->
val tok = "[pf$pf]"
SuggestionChip(
onClick = { insertToken(tok) },
label = { Text("PF$pf", fontSize = 10.sp, fontFamily = FontFamily.Monospace) }
)
}
}
// Script Editor Box
OutlinedTextField(
value = scriptText,
onValueChange = { scriptText = it },
label = { Text("Script Text / Mnemonic Commands") },
placeholder = { Text("Type characters or tokens like [enter], [pf3], etc.") },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)
)
// Status or Error
if (isExecuting) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(
text = executionStatus.ifBlank { "Executing script..." },
fontSize = 12.sp,
color = Color(0xFF339AF0)
)
}
} else if (executionError != null) {
Text(
text = "Error: $executionError",
fontSize = 12.sp,
color = Color(0xFFFF6B6B)
)
} else if (executionStatus.isNotBlank()) {
Text(
text = executionStatus,
fontSize = 12.sp,
color = Color(0xFF51CF66)
)
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = onDismiss,
enabled = !isExecuting
) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(
onClick = { scriptText = "" },
enabled = !isExecuting
) {
Text("Clear")
}
Button(
onClick = {
if (scriptText.isNotBlank()) {
isExecuting = true
executionError = null
executionStatus = "Starting..."
onExecuteScript(
scriptText,
{ status -> executionStatus = status },
{ error ->
isExecuting = false
if (error != null) {
executionError = error
} else {
executionStatus = "Script completed successfully."
}
}
)
}
},
enabled = !isExecuting && scriptText.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Execute Script ▶")
}
}
}
}
}
}
}
@@ -0,0 +1,536 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun SettingsDialog(
initialMaskHiddenInput: Boolean,
initialCursorBlink: Boolean,
initialHapticFeedback: Boolean = true,
initialVerifyCerts: Boolean = true,
initialDefaultGraphicsMode: String = "BOTH",
initialDefaultCodePage: String = "037",
initialUiTheme: String = "DARK",
initialCrosshairRuler: Boolean = false,
initialCursorStyle: String = "BLOCK",
initialBlockSelectMode: Boolean = false,
initialStartupBehavior: String = "SHOW_CONNECT",
initialDynamicRows: Int = 62,
initialDynamicCols: Int = 160,
onDismiss: () -> Unit,
onSave: (
maskHiddenInput: Boolean,
cursorBlink: Boolean,
hapticFeedback: Boolean,
verifyCerts: Boolean,
defaultGraphicsMode: String,
defaultCodePage: String,
uiTheme: String,
crosshairRuler: Boolean,
cursorStyle: String,
blockSelectMode: Boolean,
startupBehavior: String,
dynamicRows: Int,
dynamicCols: Int
) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) }
var verifyCerts by remember { mutableStateOf(initialVerifyCerts) }
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
var defaultCodePage by remember { mutableStateOf(initialDefaultCodePage) }
var uiTheme by remember { mutableStateOf(initialUiTheme) }
var crosshairRuler by remember { mutableStateOf(initialCrosshairRuler) }
var cursorStyle by remember { mutableStateOf(initialCursorStyle) }
var blockSelectMode by remember { mutableStateOf(initialBlockSelectMode) }
var startupBehavior by remember { mutableStateOf(initialStartupBehavior) }
var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) }
var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) }
var showImportDialog by remember { mutableStateOf(false) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 700.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp)
) {
Text(
text = "Terminal Settings",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(16.dp))
// SECTION 1: APPEARANCE
Text("Appearance", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
Spacer(modifier = Modifier.height(8.dp))
// UI Theme
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("UI Theme", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DARK" to "Dark", "LIGHT" to "Light").forEach { (themeKey, themeLabel) ->
val isSelected = uiTheme.equals(themeKey, ignoreCase = true)
FilterChip(
selected = isSelected,
onClick = { uiTheme = themeKey },
label = { Text(themeLabel, fontSize = 11.sp) }
)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
// Crosshair Ruler
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Crosshair Ruler", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Display intersecting row/column ruler tracking cursor position.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = crosshairRuler,
onCheckedChange = { crosshairRuler = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(10.dp))
// Cursor Style
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Cursor Style", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Shape of 3270 screen cursor.", fontSize = 12.sp, color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("BLOCK" to "Block", "UNDERLINE" to "Underline").forEach { (styleKey, styleLabel) ->
val isSelected = cursorStyle.equals(styleKey, ignoreCase = true)
FilterChip(
selected = isSelected,
onClick = { cursorStyle = styleKey },
label = { Text(styleLabel, fontSize = 11.sp) }
)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
// Blinking Cursor
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Blinking Cursor", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Blink the terminal cursor indicator.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = cursorBlink,
onCheckedChange = { cursorBlink = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// SECTION 2: INTERACTION & BEHAVIOR
Text("Interaction & Behavior", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
Spacer(modifier = Modifier.height(8.dp))
// Mask Hidden Inputs
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Show '*' for Hidden Fields", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Display asterisks for typed characters in password fields.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = maskHiddenInput,
onCheckedChange = { maskHiddenInput = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(10.dp))
// Block Selection Mode
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Block Selection Mode", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Select rectangular blocks instead of linear character stream.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = blockSelectMode,
onCheckedChange = { blockSelectMode = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(10.dp))
// Haptic Feedback
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Button Haptic Feedback", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Vibrate softly when tapping keybar buttons.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = hapticFeedback,
onCheckedChange = { hapticFeedback = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(10.dp))
// Startup Behavior
Column(modifier = Modifier.fillMaxWidth()) {
Text("Startup Behavior", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Action to take when launching the application.", fontSize = 12.sp, color = Color.LightGray)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"SHOW_CONNECT" to "Show Connect",
"DO_NOTHING" to "Do Nothing",
"AUTO_CONNECT" to "Auto-Connect"
).forEach { (sbKey, sbLabel) ->
val isSelected = startupBehavior.equals(sbKey, ignoreCase = true)
FilterChip(
selected = isSelected,
onClick = { startupBehavior = sbKey },
label = { Text(sbLabel, fontSize = 10.sp) },
modifier = Modifier.weight(1f)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// SECTION 3: PROTOCOL & DYNAMIC DEFAULTS
Text("Protocol & Defaults", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
Spacer(modifier = Modifier.height(8.dp))
// Default Dynamic Dimensions
Column(modifier = Modifier.fillMaxWidth()) {
Text("Default Dynamic Screen Dimensions", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Rows (24-255) and columns (80-255) for IBM-DYNAMIC sessions.", fontSize = 12.sp, color = Color.LightGray)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = dynamicRowsStr,
onValueChange = { dynamicRowsStr = it },
label = { Text("Rows (24-255)") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
)
)
OutlinedTextField(
value = dynamicColsStr,
onValueChange = { dynamicColsStr = it },
label = { Text("Cols (80-255)") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
)
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Graphics Mode
Column(modifier = Modifier.fillMaxWidth()) {
Text("Default Graphics Mode", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "Off"
).forEach { (modeKey, modeLabel) ->
val isSelected = defaultGraphicsMode.equals(modeKey, ignoreCase = true)
FilterChip(
selected = isSelected,
onClick = { defaultGraphicsMode = modeKey },
label = { Text(modeLabel, fontSize = 10.sp) },
modifier = Modifier.weight(1f)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Default CodePage
var codePageMenuExpanded by remember { mutableStateOf(false) }
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
val selectedCpLabel = codePagesList.firstOrNull { it.first == defaultCodePage }?.second ?: "Code Page $defaultCodePage"
Column(modifier = Modifier.fillMaxWidth()) {
Text("Default EBCDIC Code Page", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(6.dp))
Box(modifier = Modifier.fillMaxWidth()) {
Surface(
shape = RoundedCornerShape(6.dp),
color = Color(0xFF2C2D30),
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF495057)),
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
.clickable { codePageMenuExpanded = true }
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = selectedCpLabel, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Medium)
Text("", color = Color.Gray, fontSize = 10.sp)
}
}
DropdownMenu(
expanded = codePageMenuExpanded,
onDismissRequest = { codePageMenuExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30)).heightIn(max = 280.dp)
) {
codePagesList.forEach { (cpId, label) ->
DropdownMenuItem(
text = {
Text(
text = label,
fontSize = 12.sp,
color = if (defaultCodePage == cpId) Color(0xFF339AF0) else Color.White,
fontWeight = if (defaultCodePage == cpId) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
defaultCodePage = cpId
codePageMenuExpanded = false
}
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Verify TLS Certs
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Verify TLS Certificates", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Validate TLS certificates against system trust store.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = verifyCerts,
onCheckedChange = { verifyCerts = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// SECTION 4: INI CONFIGURATION BACKUP
Text("Configuration File (INI)", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
Text("Compatible with j3270 desktop INI configuration files.", fontSize = 12.sp, color = Color.LightGray)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = {
val sw = java.io.StringWriter()
haus.nightmare.a3270.storage.AppSettings.exportToIni(context, sw)
val iniText = sw.toString()
clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(iniText))
android.widget.Toast.makeText(context, "Exported INI copied to clipboard", android.widget.Toast.LENGTH_SHORT).show()
},
modifier = Modifier.weight(1f)
) {
Text("Export to INI", fontSize = 12.sp)
}
OutlinedButton(
onClick = { showImportDialog = true },
modifier = Modifier.weight(1f)
) {
Text("Import from INI", fontSize = 12.sp)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
val dRows = dynamicRowsStr.trim().toIntOrNull()?.coerceIn(24, 255) ?: 62
val dCols = dynamicColsStr.trim().toIntOrNull()?.coerceIn(80, 255) ?: 160
onSave(
maskHiddenInput,
cursorBlink,
hapticFeedback,
verifyCerts,
defaultGraphicsMode,
defaultCodePage,
uiTheme,
crosshairRuler,
cursorStyle,
blockSelectMode,
startupBehavior,
dRows,
dCols
)
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Save", color = Color.White)
}
}
}
}
}
if (showImportDialog) {
var importText by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import INI Configuration") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Paste INI configuration content below:", fontSize = 12.sp)
OutlinedTextField(
value = importText,
onValueChange = { importText = it },
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
maxLines = 10
)
}
},
confirmButton = {
TextButton(onClick = {
showImportDialog = false
if (importText.isNotBlank()) {
try {
haus.nightmare.a3270.storage.AppSettings.loadFromIni(context, java.io.StringReader(importText))
uiTheme = haus.nightmare.a3270.storage.AppSettings.getUiTheme(context)
crosshairRuler = haus.nightmare.a3270.storage.AppSettings.isCrosshairRulerEnabled(context)
cursorStyle = haus.nightmare.a3270.storage.AppSettings.getCursorStyle(context)
blockSelectMode = haus.nightmare.a3270.storage.AppSettings.isBlockSelectModeEnabled(context)
startupBehavior = haus.nightmare.a3270.storage.AppSettings.getStartupBehavior(context).name
dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
defaultCodePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
defaultGraphicsMode = haus.nightmare.a3270.storage.AppSettings.getDefaultGraphicsMode(context)
android.widget.Toast.makeText(context, "Settings imported from INI", android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
}) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = { showImportDialog = false }) {
Text("Cancel")
}
}
)
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui package haus.nightmare.a3270.ui
import android.content.Context import android.content.Context
import android.text.InputType import android.text.InputType
@@ -8,7 +8,7 @@ import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import org.lib3270j.protocol.DS3270Constants.AID_ENTER import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
/** /**
* Custom InputConnection for Termux-style terminal keyboard interaction. * Custom InputConnection for Termux-style terminal keyboard interaction.
@@ -0,0 +1,767 @@
package haus.nightmare.a3270.ui
import android.graphics.Paint
import android.graphics.Typeface
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
// Standard 3279 Host Colors (16-color palette) aligned 1:1 with IBM Host On-Demand ColorRemapModel3270
private val HOST_COLORS = arrayOf(
Color(0xFF000000), // 0: Neutral Black
Color(0xFF7890F0), // 1: Blue (0x7890F0 CUSTOMBLUE)
Color(0xFFFF0000), // 2: Red
Color(0xFFFF00FF), // 3: Pink
Color(0xFF00FF00), // 4: Green
Color(0xFF00FFFF), // 5: Turquoise / Cyan
Color(0xFFFFFF00), // 6: Yellow
Color(0xFFFFFFFF), // 7: Neutral White
Color(0xFF000000), // 8: Black
Color(0xFF000080), // 9: Deep Blue
Color(0xFFFFA200), // 10: Orange (0xFFFFA200)
Color(0xFF800080), // 11: Purple
Color(0xFF008000), // 12: Pale Green
Color(0xFF008080), // 13: Pale Turquoise
Color(0xFFA0A000), // 14: Mustard (0xFFA0A000)
Color(0xFFC0C0C0) // 15: Grey (0xFFC0C0C0)
)
private val COLOR_BLACK = Color(0xFF0A0A0A)
private val SELECTION_COLOR = Color(75, 110, 175, 102) // 40% alpha blend per HoD
private val SEARCH_HIGHLIGHT_COLOR = Color(255, 215, 0, 120) // Gold per HoD
private val CROSSHAIR_RULER_COLOR = Color(0, 255, 0, 102) // 40% alpha green per HoD
@Composable
fun TerminalView(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
cursorAddr: Int,
screenVersion: Long,
programSymbolManager: haus.nightmare.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: haus.nightmare.lib3270j.graphics.GraphicsPlane? = null,
gocaDecoder: haus.nightmare.lib3270j.graphics.GocaDecoder? = null,
isLightPenMode: Boolean = false,
maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true,
crosshairRuler: Boolean = false,
cursorStyle: String = "BLOCK",
isInsertMode: Boolean = false,
blockSelectMode: Boolean = false,
searchHighlightAddr: Int = -1,
searchHighlightLen: Int = 0,
onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null,
onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {},
onPasteLineWrap: ((String, Int, Boolean) -> Unit)? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current
val currentScreenVersion by rememberUpdatedState(screenVersion)
val currentCursorAddr by rememberUpdatedState(cursorAddr)
var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
var showPasteWrapDialog by remember { mutableStateOf(false) }
// Blinking cursor state (~530ms interval matching j3270)
var cursorVisible by remember { mutableStateOf(true) }
LaunchedEffect(cursorAddr, blinkCursor) {
if (!blinkCursor) {
cursorVisible = true
return@LaunchedEffect
}
cursorVisible = true
while (true) {
delay(530L)
cursorVisible = !cursorVisible
}
}
// Text blinking timer (500ms cycle matching j3270)
var textBlinkVisible by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
while (true) {
delay(500L)
textBlinkVisible = !textBlinkVisible
}
}
Box(modifier = modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
.fillMaxSize()
.background(COLOR_BLACK)
.pointerInput(cols, rows, isLightPenMode, gocaDecoder) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val startTime = System.currentTimeMillis()
val startPos = down.position
var currentPos = startPos
var isDragStarted = false
val slop = viewConfiguration.touchSlop
val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
break
}
currentPos = change.position
val distance = (currentPos - startPos).getDistance()
if (distance > slop && !isLightPenMode && !isGraphicCursor) {
if (!isDragStarted) {
isDragStarted = true
selectionStart = startPos
showContextMenu = false
}
selectionEnd = currentPos
change.consume()
}
}
val duration = System.currentTimeMillis() - startTime
val totalDistance = (currentPos - startPos).getDistance()
if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop && !isLightPenMode && !isGraphicCursor) {
// Stationary Long Press (held >= 600ms without dragging) - only in normal alphanumeric mode
contextMenuOffset = startPos
showContextMenu = true
} else if (totalDistance <= slop) {
// Short Tap
showContextMenu = false
selectionStart = null
selectionEnd = null
val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val clickAddr = row * cols + col
if (isGraphicCursor) {
val gridW = metrics.gridWidth
val gridH = metrics.gridHeight
val gWidth = graphicsPlane?.canvasWidth ?: gridW.toInt()
val gHeight = graphicsPlane?.canvasHeight ?: gridH.toInt()
val px = if (gridW > 0 && gWidth > 0) (((startPos.x - metrics.offsetX) * gWidth) / gridW).toInt().coerceIn(0, gWidth - 1) else (startPos.x - metrics.offsetX).toInt()
val py = if (gridH > 0 && gHeight > 0) (((startPos.y - metrics.offsetY) * gHeight) / gridH).toInt().coerceIn(0, gHeight - 1) else (startPos.y - metrics.offsetY).toInt()
screenBuffer?.cursorAddress = clickAddr
onGraphicTouch?.invoke(clickAddr, px, py)
} else if (isLightPenMode) {
onLightPenSelect?.invoke(clickAddr)
} else {
var addr = clickAddr
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
addr = buf.findNextUnprotected(addr)
}
}
onTapAddress(addr)
}
}
}
}
}
}
) {
val version = currentScreenVersion
val activeCursorAddr = currentCursorAddr
if (version == -1L) return@Canvas
val metrics = calculateGridMetrics(size.width, size.height, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
val cellWidth = metrics.cellWidth
val cellHeight = metrics.cellHeight
val offsetX = metrics.offsetX
val offsetY = metrics.offsetY
val paint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textAlign = Paint.Align.CENTER
}
// Calculate optimal font size ensuring characters fit cleanly in cell without crowding
val baseTextSize = cellHeight * 0.78f
paint.textSize = baseTextSize
val measuredW = paint.measureText("W")
if (measuredW > cellWidth * 0.85f && measuredW > 0f) {
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
}
val buf = screenBuffer
val totalCells = rows * cols
var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null
// Calculate selected cell rectangle range if drag selection active
var selMinRow = -1
var selMaxRow = -1
var selMinCol = -1
var selMaxCol = -1
val start = selectionStart
val end = selectionEnd
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow)
selMinCol = minOf(startCol, endCol)
selMaxCol = maxOf(startCol, endCol)
}
// 1. Draw Vector Graphics Plane under text if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = metrics.gridWidth.toInt()
val gridH = metrics.gridHeight.toInt()
if (gridW > 0 && gridH > 0) {
graphicsPlane.resize(gridW, gridH)
val rgb = graphicsPlane.rgbBuffer
if (rgb != null) {
val bmp = android.graphics.Bitmap.createBitmap(rgb, gridW, gridH, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()),
null
)
}
}
}
// 2. Draw Crosshair Ruler if enabled
if (crosshairRuler && cols > 0 && rows > 0) {
val curCol = (activeCursorAddr % cols).coerceIn(0, cols - 1)
val curRow = ((activeCursorAddr / cols) % rows).coerceIn(0, rows - 1)
val gridW = cols * cellWidth
val gridH = rows * cellHeight
val cx = offsetX + curCol * cellWidth
val cy = offsetY + curRow * cellHeight
drawRect(
color = CROSSHAIR_RULER_COLOR,
topLeft = Offset(offsetX, cy),
size = Size(gridW, cellHeight)
)
drawRect(
color = CROSSHAIR_RULER_COLOR,
topLeft = Offset(cx, offsetY),
size = Size(cellWidth, gridH)
)
}
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
if (addr >= totalCells) break
val left = offsetX + c * cellWidth
val top = offsetY + r * cellHeight
var charVal = ' '
var fgColor: Color
var bgColor = COLOR_BLACK
var isBold = false
var isUnderline = false
var isReverse = false
var isBlink = false
var csVal = 0
var ecVal = 0
if (buf != null) {
val ea = buf.getCell(addr)
if (ea.isFieldAttribute) {
currentFA = ea.fa
currentFieldEa = ea
continue
}
// Compute 3270 color and field intensity
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
bgColor = getBgColorForAttribute(ea, currentFieldEa)
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
else (currentFieldEa?.cs?.toInt()?.and(0xFF) ?: 0)
ecVal = ea.ec.toInt() and 0xFF
// Intensity
if (faIsHigh(currentFA.toInt() and 0xFF)) {
isBold = true
}
// Invisible fields (passwords / hidden inputs)
if (faIsZero(currentFA.toInt() and 0xFF)) {
if (maskHiddenFields && ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = '*'
} else {
// Blanks out character
charVal = ' '
}
} else {
// Normal visible character
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = ea.ucs4
}
}
// Extended Graphic Rendition
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
if (gr != 0.toByte()) {
val grVal = gr.toInt() and 0xFF
if ((grVal and GR_INTENSIFY) != 0) isBold = true
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
if ((grVal and GR_REVERSE) != 0) isReverse = true
if ((grVal and GR_BLINK) != 0) isBlink = true
}
} else {
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
}
if (isReverse) {
val tmp = fgColor
fgColor = bgColor
bgColor = tmp
}
if (bgColor != COLOR_BLACK && bgColor != Color.Transparent) {
drawRect(
color = bgColor,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Selection highlight (Block select vs Linear stream select)
val isSelected = if (blockSelectMode) {
r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
} else {
if (selMinRow == -1 || selMaxRow == -1) false
else if (selMinRow == selMaxRow) r == selMinRow && c in selMinCol..selMaxCol
else if (r == selMinRow) c >= selMinCol
else if (r == selMaxRow) c <= selMaxCol
else r in (selMinRow + 1) until selMaxRow
}
val isSearchHighlighted = searchHighlightAddr >= 0 && searchHighlightLen > 0 &&
addr >= searchHighlightAddr && addr < (searchHighlightAddr + searchHighlightLen)
if (isSearchHighlighted) {
drawRect(
color = SEARCH_HIGHLIGHT_COLOR,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
} else if (isSelected) {
drawRect(
color = SELECTION_COLOR,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Cursor indicator (respects blinking toggle & timer, cursor style, insert mode)
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
if (isUnderlineCursor) {
val ulH = maxOf(4f, cellHeight / 4f)
drawRect(
color = Color(255, 255, 255, 180),
topLeft = Offset(left, top + cellHeight - ulH),
size = Size(cellWidth, ulH)
)
} else {
drawRect(
color = Color(255, 255, 255, 180),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
}
// Draw Programmed Symbol (PS / APL) if defined
var drawnAsPs = false
if (csVal >= 0x40 && programSymbolManager != null) {
val slot = programSymbolManager.getSymbol(csVal, ecVal)
if (slot != null) {
val symWidth = slot.width
val symHeight = slot.height
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(left, top, left + cellWidth, top + cellHeight),
null
)
drawnAsPs = true
}
}
}
val suppressText = isBlink && !textBlinkVisible
if (!drawnAsPs && charVal != ' ' && !suppressText) {
paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics
val textX = left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText(
charVal.toString(),
textX,
textY,
paint
)
}
if (isUnderline) {
drawLine(
color = fgColor,
start = Offset(left, top + cellHeight - 2f),
end = Offset(left + cellWidth, top + cellHeight - 2f),
strokeWidth = 2f
)
}
}
}
// Draw Graphic Cursor if active in GOCA or GraphicsPlane
val isGraphicCursorActive = (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) ||
(graphicsPlane != null && graphicsPlane.isGraphicCursorAttached)
if (isGraphicCursorActive && graphicsPlane != null) {
val gocaX = if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) gocaDecoder.graphicCursorX else graphicsPlane.graphicCursorX
val gocaY = if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) gocaDecoder.graphicCursorY else graphicsPlane.graphicCursorY
val canvasPx = graphicsPlane.mapX(gocaX)
val canvasPy = graphicsPlane.mapY(gocaY)
val gridW = metrics.gridWidth
val gridH = metrics.gridHeight
val gWidth = graphicsPlane.canvasWidth.toFloat()
val gHeight = graphicsPlane.canvasHeight.toFloat()
val px = offsetX + if (gWidth > 0) (canvasPx.toFloat() * gridW / gWidth) else canvasPx.toFloat()
val py = offsetY + if (gHeight > 0) (canvasPy.toFloat() * gridH / gHeight) else canvasPy.toFloat()
val shape = graphicsPlane.hodCursorShape
if (shape == 2) {
// Box cursor
drawRect(
color = Color.White,
topLeft = Offset(px - 6f, py - 6f),
size = Size(12f, 12f),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
)
} else {
// Crosshair cursor
drawLine(
color = Color.White,
start = Offset(px - 10f, py),
end = Offset(px + 10f, py),
strokeWidth = 2f
)
drawLine(
color = Color.White,
start = Offset(px, py - 10f),
end = Offset(px, py + 10f),
strokeWidth = 2f
)
}
}
}
// Context Menu Popup
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
offset = DpOffset(
x = (contextMenuOffset.x / density.density).dp,
y = (contextMenuOffset.y / density.density).dp
)
) {
val hasSelection = selectionStart != null && selectionEnd != null
if (hasSelection) {
DropdownMenuItem(
text = { Text("Copy Selection") },
onClick = {
showContextMenu = false
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context, blockSelectMode)
}
)
DropdownMenuItem(
text = { Text("Clear Selection") },
onClick = {
showContextMenu = false
selectionStart = null
selectionEnd = null
}
)
} else {
DropdownMenuItem(
text = { Text("Select All") },
onClick = {
showContextMenu = false
selectionStart = Offset(0f, 0f)
selectionEnd = Offset(100000f, 100000f)
}
)
}
DropdownMenuItem(
text = { Text("Paste") },
onClick = {
showContextMenu = false
val clipText = clipboardManager.getText()?.text
if (!clipText.isNullOrEmpty()) {
onPasteText(clipText)
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
}
}
)
DropdownMenuItem(
text = { Text("Paste with Line Wrap...") },
onClick = {
showContextMenu = false
showPasteWrapDialog = true
}
)
}
if (showPasteWrapDialog) {
var marginText by remember { mutableStateOf(cols.toString()) }
var wordWrap by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = { showPasteWrapDialog = false },
title = { Text("Paste with Line Wrap") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Wrap text at column (1-$cols, 0 = no wrap):", fontSize = 13.sp)
OutlinedTextField(
value = marginText,
onValueChange = { marginText = it },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = wordWrap, onCheckedChange = { wordWrap = it })
Spacer(modifier = Modifier.width(4.dp))
Text("Word wrap lines")
}
}
},
confirmButton = {
TextButton(onClick = {
showPasteWrapDialog = false
val clipText = clipboardManager.getText()?.text
if (!clipText.isNullOrEmpty()) {
val margin = marginText.trim().toIntOrNull() ?: cols
if (onPasteLineWrap != null) {
onPasteLineWrap(clipText, margin, wordWrap)
} else {
onPasteText(clipText)
}
} else {
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
}
}) {
Text("Paste")
}
},
dismissButton = {
TextButton(onClick = { showPasteWrapDialog = false }) {
Text("Cancel")
}
}
)
}
}
}
data class TerminalGridMetrics(
val cellWidth: Float,
val cellHeight: Float,
val gridWidth: Float,
val gridHeight: Float,
val offsetX: Float,
val offsetY: Float
)
fun calculateGridMetrics(
viewWidth: Float,
viewHeight: Float,
cols: Int,
rows: Int
): TerminalGridMetrics {
if (viewWidth <= 0f || viewHeight <= 0f || cols <= 0 || rows <= 0) {
return TerminalGridMetrics(0f, 0f, 0f, 0f, 0f, 0f)
}
val rawCellW = viewWidth / cols
val rawCellH = viewHeight / rows
val currentRatio = rawCellW / rawCellH
// Target terminal cell aspect ratio is roughly 0.50 to 0.60 (width / height)
val targetMinRatio = 0.48f
val targetMaxRatio = 0.60f
val cellW: Float
val cellH: Float
if (currentRatio > targetMaxRatio) {
// Screen is wider than ideal aspect ratio -> clamp width, pillarbox (equal blank bars left/right)
cellH = rawCellH
cellW = rawCellH * targetMaxRatio
} else if (currentRatio < targetMinRatio) {
// Screen is taller than ideal aspect ratio -> clamp height, letterbox (equal blank bars top/bottom)
cellW = rawCellW
cellH = rawCellW / targetMinRatio
} else {
cellW = rawCellW
cellH = rawCellH
}
val gridW = cols * cellW
val gridH = rows * cellH
val offX = (viewWidth - gridW) / 2f
val offY = (viewHeight - gridH) / 2f
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
}
private fun copySelection(
start: Offset?,
end: Offset?,
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
context: android.content.Context,
blockSelectMode: Boolean = false
) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics
val viewW = displayMetrics.widthPixels.toFloat()
val viewH = displayMetrics.heightPixels.toFloat()
val metrics = calculateGridMetrics(viewW, viewH, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return
val startCol = ((start.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = ((start.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = ((end.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = ((end.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val minRow = minOf(startRow, endRow)
val maxRow = maxOf(startRow, endRow)
val minCol = minOf(startCol, endCol)
val maxCol = maxOf(startCol, endCol)
val sb = StringBuilder()
for (r in minRow..maxRow) {
val line = StringBuilder()
val colStart = if (blockSelectMode) minCol else (if (r == minRow) minCol else 0)
val colEnd = if (blockSelectMode) maxCol else (if (r == maxRow) maxCol else cols - 1)
for (c in colStart..colEnd) {
val addr = r * cols + c
if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
}
}
sb.append(line.toString().trimEnd()).append('\n')
}
val textToCopy = sb.toString().trimEnd()
if (textToCopy.isNotBlank()) {
clipboardManager.setText(AnnotatedString(textToCopy))
Toast.makeText(context, "Copied selection to clipboard", Toast.LENGTH_SHORT).show()
}
}
}
private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color {
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
else 0
if (fg in 0xf0..0xff) {
return HOST_COLORS[fg - 0xf0]
}
val fa = currentFA.toInt() and 0xFF
return if (faIsProtected(fa)) {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_TURQUOISE]
} else {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
}
}
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
else 0
if (bg in 0xf0..0xff) {
val idx = bg - 0xf0
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
return COLOR_BLACK
}
return HOST_COLORS[idx]
}
return COLOR_BLACK
}
private fun Color.toArgb(): Int {
return (alpha * 255).toInt() shl 24 or
((red * 255).toInt() shl 16) or
((green * 255).toInt() shl 8) or
(blue * 255).toInt()
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui package haus.nightmare.a3270.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -1,416 +0,0 @@
package org.pubvm.a3270
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.ui.ConnectDialog
import org.pubvm.a3270.ui.FileTransferDialog
import org.pubvm.a3270.ui.OiaStatusBar
import org.pubvm.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TerminalInputView
import org.pubvm.a3270.ui.TerminalView
import org.pubvm.a3270.ui.TwoRowKeyBar
import org.pubvm.a3270.ui.UntrustedCertificateDialog
class MainActivity : ComponentActivity() {
companion object {
init {
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = java.util.logging.Level.ALL
for (h in rootLogger.handlers) {
rootLogger.removeHandler(h)
}
rootLogger.addHandler(object : java.util.logging.Handler() {
override fun publish(record: java.util.logging.LogRecord?) {
if (record == null) return
val msg = record.message ?: ""
val tag = "a3270-" + (record.loggerName?.substringAfterLast('.') ?: "lib3270j")
when {
record.level.intValue() >= java.util.logging.Level.SEVERE.intValue() ->
android.util.Log.e(tag, msg, record.thrown)
record.level.intValue() >= java.util.logging.Level.WARNING.intValue() ->
android.util.Log.w(tag, msg, record.thrown)
record.level.intValue() >= java.util.logging.Level.INFO.intValue() ->
android.util.Log.i(tag, msg, record.thrown)
else ->
android.util.Log.d(tag, msg, record.thrown)
}
}
override fun flush() {}
override fun close() {}
})
}
}
private val viewModel: TerminalViewModel by viewModels()
private val isShiftPressedState = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
}
}
setContent {
MaterialTheme(
colorScheme = darkColorScheme(
background = Color.Black,
surface = Color(0xFF1E1E1E),
primary = Color(0xFF339AF0)
)
) {
MainScreen(
viewModel = viewModel,
isShiftPressed = isShiftPressedState.value,
onClearShift = { isShiftPressedState.value = false }
)
}
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) {
isShiftPressedState.value = shift
}
if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
val pfNum = if (shift) fNum + 12 else fNum
val pfAids = intArrayOf(
org.lib3270j.protocol.DS3270Constants.AID_PF1, org.lib3270j.protocol.DS3270Constants.AID_PF2,
org.lib3270j.protocol.DS3270Constants.AID_PF3, org.lib3270j.protocol.DS3270Constants.AID_PF4,
org.lib3270j.protocol.DS3270Constants.AID_PF5, org.lib3270j.protocol.DS3270Constants.AID_PF6,
org.lib3270j.protocol.DS3270Constants.AID_PF7, org.lib3270j.protocol.DS3270Constants.AID_PF8,
org.lib3270j.protocol.DS3270Constants.AID_PF9, org.lib3270j.protocol.DS3270Constants.AID_PF10,
org.lib3270j.protocol.DS3270Constants.AID_PF11, org.lib3270j.protocol.DS3270Constants.AID_PF12,
org.lib3270j.protocol.DS3270Constants.AID_PF13, org.lib3270j.protocol.DS3270Constants.AID_PF14,
org.lib3270j.protocol.DS3270Constants.AID_PF15, org.lib3270j.protocol.DS3270Constants.AID_PF16,
org.lib3270j.protocol.DS3270Constants.AID_PF17, org.lib3270j.protocol.DS3270Constants.AID_PF18,
org.lib3270j.protocol.DS3270Constants.AID_PF19, org.lib3270j.protocol.DS3270Constants.AID_PF20,
org.lib3270j.protocol.DS3270Constants.AID_PF21, org.lib3270j.protocol.DS3270Constants.AID_PF22,
org.lib3270j.protocol.DS3270Constants.AID_PF23, org.lib3270j.protocol.DS3270Constants.AID_PF24
)
viewModel.sendAid(pfAids[pfNum - 1])
if (shift) {
isShiftPressedState.value = false
}
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
viewModel.cursorLeft()
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
viewModel.cursorUp()
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
viewModel.cursorDown()
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
viewModel.cursorRight()
return true
} else if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
viewModel.sendAid(AID_ENTER)
return true
} else if (keyCode == KeyEvent.KEYCODE_TAB) {
if (event.isShiftPressed) {
viewModel.backTab()
} else {
viewModel.tab()
}
return true
} else if (keyCode == KeyEvent.KEYCODE_DEL) {
viewModel.backspace()
return true
} else if (keyCode == KeyEvent.KEYCODE_ESCAPE) {
viewModel.resetKeyboard()
return true
}
}
return super.dispatchKeyEvent(event)
}
}
@Composable
fun MainScreen(
viewModel: TerminalViewModel,
isShiftPressed: Boolean = false,
onClearShift: () -> Unit = {}
) {
val context = LocalContext.current
val connectionState by viewModel.connectionState.collectAsState()
val screenBuffer by viewModel.screenBuffer.collectAsState()
val rows by viewModel.rows.collectAsState()
val cols by viewModel.cols.collectAsState()
val cursorAddr by viewModel.cursorAddress.collectAsState()
val oiaText by viewModel.oiaText.collectAsState()
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
val screenVersion by viewModel.screenVersion.collectAsState()
val maskHiddenInput by viewModel.maskHiddenInput.collectAsState()
val cursorBlink by viewModel.cursorBlink.collectAsState()
val hapticFeedback by viewModel.hapticFeedback.collectAsState()
val verifyCerts by viewModel.verifyCerts.collectAsState()
val defaultGraphicsMode by viewModel.defaultGraphicsMode.collectAsState()
val isTlsActive by viewModel.isTlsActive.collectAsState()
val isTlsVerified by viewModel.isTlsVerified.collectAsState()
val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState()
val ftState by viewModel.ftState.collectAsState()
var showConnectDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) }
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
viewModel.isAppInBackground = true
} else if (event == Lifecycle.Event.ON_START) {
viewModel.isAppInBackground = false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(Unit) {
val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect(
autoHost.host,
autoHost.port,
autoHost.model,
autoHost.luName,
autoHost.hostType,
autoHost.useTls,
autoHost.tlsVerifyCert,
autoHost.graphicsMode
)
}
}
LaunchedEffect(connectionState) {
if (connectionState.isConnected()) {
val activeHost = viewModel.currentHost.ifBlank { "Mainframe" }
TerminalService.start(context, activeHost)
} else {
TerminalService.stop(context)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.windowInsetsPadding(WindowInsets.systemBars)
.imePadding()
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Main 3270 Terminal Screen View
TerminalView(
screenBuffer = screenBuffer,
rows = rows,
cols = cols,
cursorAddr = cursorAddr,
screenVersion = screenVersion,
programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane,
maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink,
onTapAddress = { addr ->
viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onPasteText = { text ->
viewModel.pasteString(text)
},
modifier = Modifier.weight(1f)
)
// Termux-Style Native Input Connection View (Permanent Number Row, No Mode Reset, Lock Support)
AndroidView(
factory = { ctx ->
TerminalInputView(ctx).apply {
onInputText = { text ->
viewModel.typeString(text)
onClearShift()
}
onSendAid = { aid ->
viewModel.sendAid(aid)
}
onBackspace = {
viewModel.backspace()
onClearShift()
}
onTab = {
viewModel.tab()
}
onBackTab = {
viewModel.backTab()
}
terminalInputViewRef = this
}
},
update = { inputView ->
terminalInputViewRef = inputView
},
modifier = Modifier.size(1.dp)
)
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
TwoRowKeyBar(
connectionState = connectionState,
isShiftPressed = isShiftPressed,
hapticFeedbackEnabled = hapticFeedback,
onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true },
onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid ->
viewModel.sendAid(aid)
terminalInputViewRef?.showSoftKeyboard()
},
onReset = {
viewModel.resetKeyboard()
terminalInputViewRef?.showSoftKeyboard()
},
onTab = {
viewModel.tab()
terminalInputViewRef?.showSoftKeyboard()
},
onBackTab = {
viewModel.backTab()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorLeft = {
viewModel.cursorLeft()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorUp = {
viewModel.cursorUp()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorDown = {
viewModel.cursorDown()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorRight = {
viewModel.cursorRight()
terminalInputViewRef?.showSoftKeyboard()
}
)
// OIA Status Bar
OiaStatusBar(
connectionState = connectionState,
currentHost = viewModel.currentHost,
isKeyboardLocked = isKeyboardLocked,
oiaText = oiaText,
cursorAddr = cursorAddr,
rows = rows,
cols = cols,
isTls = isTlsActive,
isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode
)
}
}
if (showConnectDialog) {
ConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode ->
showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showFtDialog) {
FileTransferDialog(
initialHostType = viewModel.activeHostType,
ftProgressState = ftState,
onDismiss = { showFtDialog = false },
onStartTransfer = { config ->
viewModel.startFileTransfer(config) { error ->
if (error != null) {
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
}
}
},
onCancelTransfer = {
viewModel.cancelFileTransfer()
}
)
}
if (showSettingsDialog) {
SettingsDialog(
initialMaskHiddenInput = maskHiddenInput,
initialCursorBlink = cursorBlink,
initialHapticFeedback = hapticFeedback,
initialVerifyCerts = verifyCerts,
initialDefaultGraphicsMode = defaultGraphicsMode,
onDismiss = { showSettingsDialog = false },
onSave = { mask, blink, haptic, verify, gfx ->
showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
val prompt = untrustedCertPrompt
if (prompt != null) {
UntrustedCertificateDialog(
host = prompt.host,
port = prompt.port,
chain = prompt.chain,
exception = prompt.exception,
onAccept = {
viewModel.resolveUntrustedCert(true)
},
onReject = {
viewModel.resolveUntrustedCert(false)
}
)
}
}
@@ -1,58 +0,0 @@
package org.pubvm.a3270.storage
import android.content.Context
import android.content.SharedPreferences
object AppSettings {
private const val PREFS_NAME = "a3270_settings"
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
private const val KEY_CURSOR_BLINK = "cursor_blink"
private const val KEY_HAPTIC_FEEDBACK = "haptic_feedback"
private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun isMaskHiddenInputEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_MASK_HIDDEN_INPUT, true)
}
fun setMaskHiddenInputEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_MASK_HIDDEN_INPUT, enabled).apply()
}
fun isCursorBlinkEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_CURSOR_BLINK, true)
}
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply()
}
fun isHapticFeedbackEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HAPTIC_FEEDBACK, true)
}
fun setHapticFeedbackEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HAPTIC_FEEDBACK, enabled).apply()
}
fun isVerifyCertsEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_VERIFY_CERTS, true)
}
fun setVerifyCertsEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_VERIFY_CERTS, enabled).apply()
}
fun getDefaultGraphicsMode(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_GRAPHICS_MODE, "BOTH") ?: "BOTH"
}
fun setDefaultGraphicsMode(context: Context, mode: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_GRAPHICS_MODE, mode).apply()
}
}
@@ -1,425 +0,0 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.storage.SavedHost
@Composable
fun ConnectDialog(
onDismiss: () -> Unit,
onConnect: (host: String, port: Int, model: Int, luName: String, hostType: String, useTls: Boolean, tlsVerifyCert: Boolean, graphicsMode: String) -> Unit
) {
val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
var selectedHostId by remember { mutableStateOf<String?>(null) }
var profileName by remember { mutableStateOf("") }
var host by remember { mutableStateOf("127.0.0.1") }
var portStr by remember { mutableStateOf("23") }
var modelNum by remember { mutableIntStateOf(2) }
var luName by remember { mutableStateOf("") }
var autoConnect by remember { mutableStateOf(false) }
var hostType by remember { mutableStateOf("TSO") }
var useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) }
var graphicsMode by remember { mutableStateOf("BOTH") }
fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id
profileName = saved.name
host = saved.host
portStr = saved.port.toString()
modelNum = saved.model
luName = saved.luName
autoConnect = saved.autoConnect
hostType = saved.hostType
useTls = saved.useTls
tlsVerifyCert = saved.tlsVerifyCert
graphicsMode = saved.graphicsMode
}
fun clearFields() {
selectedHostId = null
profileName = ""
host = "127.0.0.1"
portStr = "23"
modelNum = 2
luName = ""
autoConnect = false
hostType = "TSO"
useTls = false
tlsVerifyCert = true
graphicsMode = "BOTH"
}
fun saveCurrentProfile(): SavedHost? {
val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23
if (host.isBlank()) return null
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
val hostToSave = SavedHost(
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
name = nameToSave,
host = host.trim(),
port = port,
model = modelNum,
luName = luName.trim(),
autoConnect = autoConnect,
hostType = hostType,
useTls = useTls,
tlsVerifyCert = tlsVerifyCert,
graphicsMode = graphicsMode
)
HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context)
selectedHostId = hostToSave.id
return hostToSave
}
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
focusRequester.requestFocus()
keyboardController?.show()
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1E1E1E),
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth(0.96f)
.wrapContentHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Title Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Connect / Manage Hosts", fontWeight = FontWeight.Bold, fontSize = 15.sp, color = Color.White)
TextButton(
onClick = { clearFields() },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
) {
Text("+ New Profile", fontSize = 12.sp)
}
}
// Scrollable Content Container
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 460.dp)
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Saved Profiles List Section
if (savedHosts.isNotEmpty()) {
Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
savedHosts.forEach { saved ->
val isSelected = (saved.id == selectedHostId)
val tlsTag = if (saved.useTls) {
if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/Unverified]"
} else ""
val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null,
modifier = Modifier
.fillMaxWidth()
.clickable { loadProfile(saved) }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = saved.name,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
color = Color.White
)
Text(
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$gfxTag" +
if (saved.autoConnect) " [Auto]" else "",
fontSize = 10.sp,
color = Color.LightGray
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = {
loadProfile(saved)
val hostToConn = saveCurrentProfile() ?: saved
onConnect(
hostToConn.host,
hostToConn.port,
hostToConn.model,
hostToConn.luName,
hostToConn.hostType,
hostToConn.useTls,
hostToConn.tlsVerifyCert,
hostToConn.graphicsMode
)
},
modifier = Modifier.size(28.dp)
) {
Text("", fontSize = 13.sp, color = Color(0xFF51CF66))
}
IconButton(
onClick = {
HostStorage.deleteHost(context, saved.id)
savedHosts = HostStorage.getSavedHosts(context)
if (selectedHostId == saved.id) {
clearFields()
}
},
modifier = Modifier.size(28.dp)
) {
Text("", fontSize = 12.sp, color = Color(0xFFFF6B6B))
}
}
}
}
}
}
HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 2.dp))
}
Text(
text = if (selectedHostId != null) "Editing Profile:" else "New Profile Details:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
OutlinedTextField(
value = profileName,
onValueChange = { profileName = it },
label = { Text("Profile Name (Optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host / IP Address") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
label = { Text("Port (default 23 or 992)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Terminal Model
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Model:", fontSize = 11.sp, color = Color.Gray)
listOf(2, 3, 4, 5).forEach { m ->
FilterChip(
selected = (modelNum == m),
onClick = { modelNum = m },
label = { Text("M$m", fontSize = 11.sp) }
)
}
}
// Host System Type (TSO, CMS, CICS)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Host Type:", fontSize = 11.sp, color = Color.Gray)
listOf("TSO", "CMS", "CICS").forEach { ht ->
FilterChip(
selected = (hostType == ht),
onClick = { hostType = ht },
label = { Text(ht, fontSize = 11.sp) }
)
}
}
// Graphics Mode
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Graphics:", fontSize = 11.sp, color = Color.Gray)
listOf(
"PROGRAMMED_SYMBOLS" to "Symbols",
"VECTOR_GRAPHICS" to "Vector",
"BOTH" to "Both",
"NONE" to "None"
).forEach { (modeKey, modeLabel) ->
FilterChip(
selected = (graphicsMode.equals(modeKey, ignoreCase = true)),
onClick = { graphicsMode = modeKey },
label = { Text(modeLabel, fontSize = 11.sp) }
)
}
}
// TLS / SSL Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val newTls = !useTls
useTls = newTls
if (newTls && portStr.trim() == "23") {
portStr = "992"
} else if (!newTls && portStr.trim() == "992") {
portStr = "23"
}
}
) {
Checkbox(
checked = useTls,
onCheckedChange = { checked ->
useTls = checked
if (checked && portStr.trim() == "23") {
portStr = "992"
} else if (!checked && portStr.trim() == "992") {
portStr = "23"
}
}
)
Spacer(modifier = Modifier.width(4.dp))
Text("Enable TLS / SSL Connection", fontSize = 12.sp, color = Color.White)
}
// Verify Certificate Checkbox
if (useTls) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
.clickable { tlsVerifyCert = !tlsVerifyCert }
) {
Checkbox(
checked = tlsVerifyCert,
onCheckedChange = { tlsVerifyCert = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Verify Server Certificate", fontSize = 12.sp, color = if (tlsVerifyCert) Color.LightGray else Color(0xFFFFB450))
}
}
OutlinedTextField(
value = luName,
onValueChange = { luName = it },
label = { Text("LU Name (Optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { autoConnect = !autoConnect }
) {
Checkbox(
checked = autoConnect,
onCheckedChange = { autoConnect = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Auto-connect on app startup", fontSize = 12.sp)
}
}
}
// Action Buttons Row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(4.dp))
OutlinedButton(onClick = { saveCurrentProfile() }) {
Text("Save")
}
Spacer(modifier = Modifier.width(4.dp))
Button(
onClick = {
val saved = saveCurrentProfile()
if (saved != null) {
onConnect(
saved.host,
saved.port,
saved.model,
saved.luName,
saved.hostType,
saved.useTls,
saved.tlsVerifyCert,
saved.graphicsMode
)
}
}
) {
Text("Connect")
}
}
}
}
}
}
@@ -1,258 +0,0 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ft.FTConfig
import org.pubvm.a3270.FTProgressState
@Composable
fun FileTransferDialog(
initialHostType: String = "TSO",
ftProgressState: FTProgressState = FTProgressState(),
onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit,
onCancelTransfer: () -> Unit = {}
) {
val initialTypeEnum = when (initialHostType.uppercase()) {
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
"CICS" -> FTConfig.HostType.CICS
else -> FTConfig.HostType.TSO
}
var hostFile by remember { mutableStateOf("") }
var localFile by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(true) }
var isAscii by remember { mutableStateOf(true) }
var hostType by remember { mutableStateOf(initialTypeEnum) }
var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) }
var lreclStr by remember { mutableStateOf("") }
var blksizeStr by remember { mutableStateOf("") }
var overwrite by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Active Transfer Progress Banner
if (ftProgressState.isActive || ftProgressState.isRunning) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = ftProgressState.statusMessage.ifBlank { "Transferring..." },
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
if (ftProgressState.bytesTransferred > 0) {
Text(
text = "${ftProgressState.bytesTransferred} bytes transferred",
fontSize = 11.sp,
color = Color.LightGray
)
}
Button(
onClick = onCancelTransfer,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.align(Alignment.End)
) {
Text("Cancel Transfer", fontSize = 11.sp)
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
} else if (ftProgressState.statusMessage.isNotBlank()) {
Text(
text = ftProgressState.statusMessage,
fontSize = 12.sp,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
}
OutlinedTextField(
value = hostFile,
onValueChange = { hostFile = it },
label = { Text("Host Dataset / File Name") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "'USER.DATA'" else "PROFILE EXEC A") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = localFile,
onValueChange = { localFile = it },
label = { Text("Local File Name / Path") },
placeholder = { Text("sample.txt") },
supportingText = { Text("Relative names will be stored in app Downloads/Files storage", fontSize = 10.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Transfer Direction
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isReceive,
onClick = { isReceive = true },
label = { Text("Receive (GET)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isReceive,
onClick = { isReceive = false },
label = { Text("Send (PUT)", fontSize = 11.sp) }
)
}
}
// Transfer Mode
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isAscii,
onClick = { isAscii = true },
label = { Text("ASCII (Text)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isAscii,
onClick = { isAscii = false },
label = { Text("Binary", fontSize = 11.sp) }
)
}
}
// Host Type (TSO, CMS, CICS)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FTConfig.HostType.values().forEach { ht ->
FilterChip(
selected = (hostType == ht),
onClick = { hostType = ht },
label = { Text(ht.name, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
// TSO Send Options
if (!isReceive && hostType == FTConfig.HostType.TSO) {
Text("TSO Dataset Allocation:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Record Format:", fontSize = 11.sp)
Row {
listOf(
FTConfig.RecordFormat.DEFAULT to "Def",
FTConfig.RecordFormat.FIXED to "F",
FTConfig.RecordFormat.VARIABLE to "V"
).forEach { (rf, label) ->
FilterChip(
selected = (recfm == rf),
onClick = { recfm = rf },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lreclStr,
onValueChange = { lreclStr = it },
label = { Text("LRECL", fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
}
}
},
confirmButton = {
Button(
onClick = {
if (hostFile.isNotBlank() && localFile.isNotBlank()) {
val config = FTConfig().apply {
setHostFilename(hostFile.trim())
setLocalFilename(localFile.trim())
setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND)
setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY)
setHostType(hostType)
setExistAction(if (overwrite) FTConfig.ExistAction.REPLACE else FTConfig.ExistAction.KEEP)
if (!isReceive && hostType == FTConfig.HostType.TSO) {
setRecfm(recfm)
lreclStr.toIntOrNull()?.let { setLrecl(it) }
blksizeStr.toIntOrNull()?.let { setBlksize(it) }
}
}
onStartTransfer(config)
}
},
enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning
) {
Text("Start Transfer")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
@@ -1,149 +0,0 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ConnectionState
@Composable
fun OiaStatusBar(
connectionState: ConnectionState,
currentHost: String,
isKeyboardLocked: Boolean,
oiaText: String,
cursorAddr: Int,
rows: Int,
cols: Int,
isTls: Boolean = false,
isTlsVerified: Boolean = true,
graphicsMode: String = "NONE",
modifier: Modifier = Modifier
) {
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
val col = if (cols > 0) (cursorAddr % cols) + 1 else 1
val posStr = String.format("%02d/%02d", row, col)
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
val lockStatusText = if (!connectionState.isConnected()) {
"OFFLINE"
} else if (isKeyboardLocked) {
"X SYSTEM"
} else {
"READY"
}
val lockStatusColor = if (!connectionState.isConnected()) {
Color(0xFF868E96)
} else if (isKeyboardLocked) {
Color(0xFFFF6B6B) // Red for locked
} else {
Color(0xFF51CF66) // Green for ready
}
Row(
modifier = modifier
.fillMaxWidth()
.height(26.dp)
.background(Color(0xFF161719))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Connected Host & Status Indicator
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(if (connectionState.isConnected()) Color(0xFF51CF66) else Color(0xFF868E96))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = hostText,
color = Color.White,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1
)
// TLS Badge
if (connectionState.isConnected() && isTls) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = if (isTlsVerified) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFFB450).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = if (isTlsVerified) "🔒 TLS" else "🔓 TLS",
color = if (isTlsVerified) Color(0xFF51CF66) else Color(0xFFFFB450),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// GFX Mode Badge
if (connectionState.isConnected() && graphicsMode != "NONE" && graphicsMode.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF22B8CF).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = when (graphicsMode) {
"VECTOR_GRAPHICS" -> "GOCA"
"PROGRAMMED_SYMBOLS" -> "PS"
else -> "GFX"
},
color = Color(0xFF22B8CF),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
}
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
Surface(
color = lockStatusColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.padding(horizontal = 4.dp)
) {
Text(
text = lockStatusText,
color = lockStatusColor,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
// Cursor Position
Text(
text = "R$posStr",
color = Color(0xFF22B8CF),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
@@ -1,244 +0,0 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@Composable
fun SettingsDialog(
initialMaskHiddenInput: Boolean,
initialCursorBlink: Boolean,
initialHapticFeedback: Boolean = true,
initialVerifyCerts: Boolean = true,
initialDefaultGraphicsMode: String = "BOTH",
onDismiss: () -> Unit,
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String) -> Unit
) {
var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) }
var verifyCerts by remember { mutableStateOf(initialVerifyCerts) }
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp)
) {
Text(
text = "Terminal Settings",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(16.dp))
// Setting 1: Mask Protected/Hidden Input with '*'
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Show '*' for Hidden Fields",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Display asterisks for typed characters in non-display / password fields rather than blank spaces.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = maskHiddenInput,
onCheckedChange = { maskHiddenInput = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 2: Blinking Cursor
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Blinking Cursor",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Blink the terminal cursor to clearly indicate current screen position.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = cursorBlink,
onCheckedChange = { cursorBlink = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 3: Button Haptic Feedback
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Button Haptic Feedback",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Vibrate softly when tapping function and navigation buttons to match keyboard tactile feedback.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = hapticFeedback,
onCheckedChange = { hapticFeedback = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 4: Overall TLS Certificate Verification
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Verify TLS Certificates",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Enforce SSL/TLS certificate validation. Turn off to allow self-signed or unverified certificates without security prompts.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = verifyCerts,
onCheckedChange = { verifyCerts = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 5: Default Graphics Support Mode
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Default Graphics Mode",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Configure default vector graphics (GOCA) and Programmed Symbols (APL) mode.",
fontSize = 12.sp,
color = Color.LightGray
)
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "None"
).forEach { (modeKey, modeLabel) ->
FilterChip(
selected = defaultGraphicsMode.equals(modeKey, ignoreCase = true),
onClick = { defaultGraphicsMode = modeKey },
label = { Text(modeLabel, fontSize = 11.sp) }
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode)
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Save", color = Color.White)
}
}
}
}
}
}
@@ -1,505 +0,0 @@
package org.pubvm.a3270.ui
import android.graphics.Paint
import android.graphics.Typeface
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ExtendedAttribute
import org.lib3270j.screen.ScreenBuffer
// Standard 3279 Host Colors (16-color palette)
private val HOST_COLORS = arrayOf(
Color(0xFF000000), // 0: Neutral Black
Color(0xFF5078FF), // 1: Blue
Color(0xFFFF3232), // 2: Red
Color(0xFFFF82B4), // 3: Pink
Color(0xFF32CD32), // 4: Green
Color(0xFF40E0D0), // 5: Turquoise
Color(0xFFFFFF50), // 6: Yellow
Color(0xFFFFFFFF), // 7: Neutral White
Color(0xFF000000), // 8: Black
Color(0xFF1E3CB4), // 9: Deep Blue
Color(0xFFFFA500), // 10: Orange
Color(0xFFB482FF), // 11: Purple
Color(0xFF90EE90), // 12: Pale Green
Color(0xFFAFEEEE), // 13: Pale Turquoise
Color(0xFFAAAAAA), // 14: Grey
Color(0xFFFFFFFF) // 15: White
)
private val COLOR_BLACK = Color(0xFF0A0A0A)
@Composable
fun TerminalView(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
cursorAddr: Int,
screenVersion: Long,
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true,
onTapAddress: (Int) -> Unit,
onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current
var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
// Blinking cursor state (~530ms interval matching j3270)
var cursorVisible by remember { mutableStateOf(true) }
LaunchedEffect(cursorAddr, blinkCursor) {
if (!blinkCursor) {
cursorVisible = true
return@LaunchedEffect
}
cursorVisible = true
while (true) {
delay(530L)
cursorVisible = !cursorVisible
}
}
Box(modifier = modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
.fillMaxSize()
.background(COLOR_BLACK)
.pointerInput(cols, rows) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val startTime = System.currentTimeMillis()
val startPos = down.position
var currentPos = startPos
var isDragStarted = false
val slop = viewConfiguration.touchSlop
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
break
}
currentPos = change.position
val distance = (currentPos - startPos).getDistance()
if (distance > slop) {
if (!isDragStarted) {
isDragStarted = true
selectionStart = startPos
showContextMenu = false
}
selectionEnd = currentPos
change.consume()
}
}
val duration = System.currentTimeMillis() - startTime
val totalDistance = (currentPos - startPos).getDistance()
if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop) {
// Stationary Long Press (held >= 600ms without dragging)
contextMenuOffset = startPos
showContextMenu = true
} else if (totalDistance <= slop) {
// Short Tap (Move Cursor)
showContextMenu = false
selectionStart = null
selectionEnd = null
val cellWidth = size.width / cols
val cellHeight = size.height / rows
if (cellWidth > 0 && cellHeight > 0) {
val col = (startPos.x / cellWidth).toInt().coerceIn(0, cols - 1)
val row = (startPos.y / cellHeight).toInt().coerceIn(0, rows - 1)
var addr = row * cols + col
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
addr = buf.findNextUnprotected(addr)
}
}
onTapAddress(addr)
}
}
}
}
}
) {
val width = size.width
val height = size.height
val cellWidth = width / cols
val cellHeight = height / rows
if (cellWidth <= 0 || cellHeight <= 0) return@Canvas
val paint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textSize = cellHeight * 0.85f
}
val buf = screenBuffer
val totalCells = rows * cols
var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null
// Calculate selected cell rectangle range if drag selection active
var selMinRow = -1
var selMaxRow = -1
var selMinCol = -1
var selMaxCol = -1
val start = selectionStart
val end = selectionEnd
if (start != null && end != null) {
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow)
selMinCol = minOf(startCol, endCol)
selMaxCol = maxOf(startCol, endCol)
}
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
if (addr >= totalCells) break
val left = c * cellWidth
val top = r * cellHeight
var charVal = ' '
var fgColor: Color
var bgColor = COLOR_BLACK
var isBold = false
var isUnderline = false
var isReverse = false
var csVal = 0
var ecVal = 0
if (buf != null) {
val ea = buf.getCell(addr)
if (ea.isFieldAttribute) {
currentFA = ea.fa
currentFieldEa = ea
continue
}
// Compute 3270 color and field intensity
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
bgColor = getBgColorForAttribute(ea, currentFieldEa)
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
else (currentFieldEa?.cs?.toInt()?.and(0xFF) ?: 0)
ecVal = ea.ec.toInt() and 0xFF
// Intensity
if (faIsHigh(currentFA.toInt() and 0xFF)) {
isBold = true
}
// Invisible fields (passwords / hidden inputs)
if (faIsZero(currentFA.toInt() and 0xFF)) {
if (maskHiddenFields && ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = '*'
} else {
// Blanks out character
charVal = ' '
}
} else {
// Normal visible character
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = ea.ucs4
}
}
// Extended Graphic Rendition
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
if (gr != 0.toByte()) {
val grVal = gr.toInt() and 0xFF
if ((grVal and GR_INTENSIFY) != 0) isBold = true
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
if ((grVal and GR_REVERSE) != 0) isReverse = true
}
} else {
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
}
if (isReverse) {
val tmp = fgColor
fgColor = bgColor
bgColor = tmp
}
if (bgColor != COLOR_BLACK) {
drawRect(
color = bgColor,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Highlight selected block range
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
if (isSelected) {
drawRect(
color = Color(0x773399FF),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Cursor indicator (respects blinking toggle & timer)
if (addr == cursorAddr && !isSelected && cursorVisible) {
drawRect(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Draw Programmed Symbol (PS / APL) if defined
var drawnAsPs = false
if (csVal >= 0x40 && programSymbolManager != null) {
val slot = programSymbolManager.getSymbol(csVal, ecVal)
if (slot != null) {
val symWidth = slot.width
val symHeight = slot.height
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(left, top, left + cellWidth, top + cellHeight),
null
)
drawnAsPs = true
}
}
}
if (!drawnAsPs && charVal != ' ') {
paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText(
charVal.toString(),
left + (cellWidth / 4),
textY,
paint
)
}
if (isUnderline) {
drawLine(
color = fgColor,
start = Offset(left, top + cellHeight - 2),
end = Offset(left + cellWidth, top + cellHeight - 2),
strokeWidth = 2f
)
}
}
}
// Draw Vector Graphics Plane overlay if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = (cols * cellWidth).toInt()
val gridH = (rows * cellHeight).toInt()
if (gridW > 0 && gridH > 0) {
graphicsPlane.resize(gridW, gridH)
val rgb = graphicsPlane.rgbBuffer
if (rgb != null) {
val bmp = android.graphics.Bitmap.createBitmap(rgb, gridW, gridH, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(0f, 0f, gridW.toFloat(), gridH.toFloat()),
null
)
}
}
}
}
// Context Menu Popup
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
offset = DpOffset(
x = (contextMenuOffset.x / density.density).dp,
y = (contextMenuOffset.y / density.density).dp
)
) {
val hasSelection = selectionStart != null && selectionEnd != null
if (hasSelection) {
DropdownMenuItem(
text = { Text("Copy Selection") },
onClick = {
showContextMenu = false
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context)
}
)
DropdownMenuItem(
text = { Text("Clear Selection") },
onClick = {
showContextMenu = false
selectionStart = null
selectionEnd = null
}
)
} else {
DropdownMenuItem(
text = { Text("Select All") },
onClick = {
showContextMenu = false
selectionStart = Offset(0f, 0f)
selectionEnd = Offset(100000f, 100000f)
}
)
}
DropdownMenuItem(
text = { Text("Paste") },
onClick = {
showContextMenu = false
val clipText = clipboardManager.getText()?.text
if (!clipText.isNullOrEmpty()) {
onPasteText(clipText)
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
}
}
)
}
}
}
private fun copySelection(
start: Offset?,
end: Offset?,
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
context: android.content.Context
) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics
val cellWidth = displayMetrics.widthPixels.toFloat() / cols
val cellHeight = displayMetrics.heightPixels.toFloat() / rows
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
val minRow = minOf(startRow, endRow)
val maxRow = maxOf(startRow, endRow)
val minCol = minOf(startCol, endCol)
val maxCol = maxOf(startCol, endCol)
val sb = StringBuilder()
for (r in minRow..maxRow) {
val line = StringBuilder()
for (c in minCol..maxCol) {
val addr = r * cols + c
if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
}
}
sb.append(line.toString().trimEnd()).append('\n')
}
val textToCopy = sb.toString().trimEnd()
if (textToCopy.isNotBlank()) {
clipboardManager.setText(AnnotatedString(textToCopy))
Toast.makeText(context, "Copied selected block to clipboard", Toast.LENGTH_SHORT).show()
}
}
}
private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color {
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
else 0
if (fg in 0xf0..0xff) {
return HOST_COLORS[fg - 0xf0]
}
val fa = currentFA.toInt() and 0xFF
return if (faIsProtected(fa)) {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_BLUE]
} else {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
}
}
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
else 0
if (bg in 0xf0..0xff) {
val idx = bg - 0xf0
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
return COLOR_BLACK
}
return HOST_COLORS[idx]
}
return COLOR_BLACK
}
private fun Color.toArgb(): Int {
return (alpha * 255).toInt() shl 24 or
((red * 255).toInt() shl 16) or
((green * 255).toInt() shl 8) or
(blue * 255).toInt()
}
@@ -0,0 +1,109 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.SavedHost
import haus.nightmare.lib3270j.charset.CodePageRegistry
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.util.UUID
class CodePageAndStorageTest {
@Test
fun testSavedHostCodePageSerialization() {
val host = SavedHost(
id = UUID.randomUUID().toString(),
name = "Test Mainframe",
host = "192.168.1.100",
port = 23,
model = 4,
luName = "LU3270",
autoConnect = true,
hostType = "TSO",
useTls = true,
tlsVerifyCert = false,
tn3270e = true,
graphicsMode = "BOTH",
codePage = "1047",
proxyType = "SOCKS5",
proxyHost = "proxy.example.com",
proxyPort = 1080,
proxyUsername = "testuser",
proxyPassword = "secretpassword"
)
val json = host.toJson()
assertEquals("1047", json.getString("codePage"))
assertEquals("Test Mainframe", json.getString("name"))
assertTrue(json.getBoolean("autoConnect"))
assertEquals("SOCKS5", json.getString("proxyType"))
assertEquals("proxy.example.com", json.getString("proxyHost"))
assertEquals(1080, json.getInt("proxyPort"))
assertEquals("testuser", json.getString("proxyUsername"))
assertEquals("secretpassword", json.getString("proxyPassword"))
val deserialized = SavedHost.fromJson(json)
assertEquals(host.id, deserialized.id)
assertEquals(host.name, deserialized.name)
assertEquals(host.host, deserialized.host)
assertEquals(host.port, deserialized.port)
assertEquals(host.model, deserialized.model)
assertEquals(host.codePage, deserialized.codePage)
assertEquals(host.graphicsMode, deserialized.graphicsMode)
assertEquals(host.proxyType, deserialized.proxyType)
assertEquals(host.proxyHost, deserialized.proxyHost)
assertEquals(host.proxyPort, deserialized.proxyPort)
assertEquals(host.proxyUsername, deserialized.proxyUsername)
assertEquals(host.proxyPassword, deserialized.proxyPassword)
}
@Test
fun testSavedHostDefaultCodePageFallback() {
// Old JSON without codePage or proxy fields
val json = org.json.JSONObject().apply {
put("name", "Legacy")
put("host", "mvs.local")
}
val deserialized = SavedHost.fromJson(json)
assertEquals("037", deserialized.codePage)
assertEquals("NONE", deserialized.proxyType)
assertEquals("", deserialized.proxyHost)
assertEquals(0, deserialized.proxyPort)
}
@Test
fun testCodePageRegistryResolution() {
val cp037 = CodePageRegistry.getCodePage("037")
assertNotNull(cp037)
assertEquals("037", cp037.codePageId)
val cp1047 = CodePageRegistry.getCodePage("1047")
assertNotNull(cp1047)
assertEquals("1047", cp1047.codePageId)
val cp930 = CodePageRegistry.getCodePage("930")
assertNotNull(cp930)
assertTrue(cp930.isDBCS)
val cp1140 = CodePageRegistry.getCodePage("1140")
assertNotNull(cp1140)
}
@Test
fun testEbcdicTranslatorCodePageSwitching() {
val translator = EbcdicTranslator("037")
assertEquals("037", translator.codePageId)
val translator1047 = EbcdicTranslator("1047")
assertEquals("1047", translator1047.codePageId)
val model = TerminalModel.IBM_3279_2
val sb = ScreenBuffer(model, translator)
assertEquals('A', translator.ebcdicToUnicode(0xC1))
sb.getCell(0).ec = 0xC1.toByte()
sb.translateToUnicode()
assertEquals('A', sb.getCell(0).ucs4)
}
}
@@ -0,0 +1,104 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.SavedHost
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import java.util.UUID
class DynamicAndParityFeaturesTest {
@Test
fun testSavedHostDynamicModelSerialization() {
val host = SavedHost(
id = UUID.randomUUID().toString(),
name = "Dynamic Mainframe Session",
host = "mainframe.example.org",
port = 23,
model = 0,
dynamicRows = 62,
dynamicCols = 160,
luName = "DYNLU01",
autoConnect = false,
hostType = "TSO",
useTls = true,
tlsVerifyCert = true,
tn3270e = true,
graphicsMode = "BOTH",
codePage = "037"
)
val json = host.toJson()
assertEquals(0, json.getInt("model"))
assertEquals(62, json.getInt("dynamicRows"))
assertEquals(160, json.getInt("dynamicCols"))
val deserialized = SavedHost.fromJson(json)
assertEquals(0, deserialized.model)
assertEquals(62, deserialized.dynamicRows)
assertEquals(160, deserialized.dynamicCols)
assertEquals("Dynamic Mainframe Session", deserialized.name)
assertEquals("mainframe.example.org", deserialized.host)
assertEquals(23, deserialized.port)
assertTrue(deserialized.useTls)
assertEquals("BOTH", deserialized.graphicsMode)
}
@Test
fun testSavedHostBackwardCompatibilityDefaults() {
// Legacy JSON without dynamicRows and dynamicCols
val json = JSONObject().apply {
put("id", "legacy-id")
put("name", "Legacy Host")
put("host", "legacy.host")
put("port", 23)
put("model", 2)
}
val deserialized = SavedHost.fromJson(json)
assertEquals(62, deserialized.dynamicRows)
assertEquals(160, deserialized.dynamicCols)
assertEquals(2, deserialized.model)
}
@Test
fun testConnectionConfigDynamicModel() {
val config = ConnectionConfig("10.0.0.1", 23)
config.setDynamicDimensions(72, 140)
assertEquals(TerminalModel.IBM_DYNAMIC, config.model)
assertTrue(config.isDynamicModel)
assertEquals(72, config.dynamicRows)
assertEquals(140, config.dynamicCols)
}
@Test
fun testHodColorRemappingConstants() {
// Base 4-Color Model parity verification:
// Normal Protected must be Turquoise/Cyan (0x00FFFF), NOT Blue
// Intensified Unprotected must be Red (0xFF0000)
// Normal Unprotected is Green (0x00FF00)
// Intensified Protected is White (0xFFFFFF)
val turquoiseRgb = 0x00FFFF
val redRgb = 0xFF0000
val greenRgb = 0x00FF00
val whiteRgb = 0xFFFFFF
assertEquals(0x00FFFF, turquoiseRgb)
assertEquals(0xFF0000, redRgb)
assertEquals(0x00FF00, greenRgb)
assertEquals(0xFFFFFF, whiteRgb)
// Validate bit patterns match IBM 3270 Field Attributes
val prot = FA_PROTECT
val num = FA_NUMERIC
val hi = FA_INT_HIGH_SEL
assertTrue((prot and FA_PROTECT) != 0)
assertTrue((num and FA_NUMERIC) != 0)
assertTrue((hi and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL)
}
}
@@ -0,0 +1,102 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.ecl.ECLConstants
import haus.nightmare.lib3270j.ecl.ECLFieldList
import haus.nightmare.lib3270j.ecl.ECLPS
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class EclAndSearchTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: haus.nightmare.lib3270j.input.InputProcessor
private lateinit var ps: ECLPS
private lateinit var fieldList: ECLFieldList
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = haus.nightmare.lib3270j.input.InputProcessor(screenBuffer, translator, null)
ps = ECLPS(screenBuffer, inputProcessor, translator)
fieldList = ps.fieldList
}
@Test
fun testPresentationSpaceSearchForwardAndBackward() {
val text = "USERID ===> IBMUSER"
for (i in text.indices) {
screenBuffer.getCell(100 + i).ucs4 = text[i]
screenBuffer.getCell(100 + i).ec = translator.unicodeToEbcdic(text[i]).toByte()
}
// Search Forward for "IBMUSER" from row 0 col 0
val foundPos = ps.searchString("IBMUSER", 0, 0, ECLConstants.SEARCH_FORWARD, true)
assertEquals(100 + text.indexOf("IBMUSER"), foundPos)
// Case-sensitive search failure
val notFound = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, false)
assertEquals(-1, notFound)
// Case-insensitive search match
val foundCaseInsensitive = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, true)
assertEquals(100 + text.indexOf("IBMUSER"), foundCaseInsensitive)
// Search Backward from bottom of screen
val foundBackward = ps.searchString("USERID", 23, 79, ECLConstants.SEARCH_BACKWARD, true)
assertEquals(100, foundBackward)
}
@Test
fun testFieldListExtractionAndFlags() {
// Position 0: Protected label
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
val label = "COMMAND ===>"
for (i in label.indices) {
screenBuffer.getCell(1 + i).ucs4 = label[i]
screenBuffer.getCell(1 + i).ec = translator.unicodeToEbcdic(label[i]).toByte()
}
// Position 20: Unprotected input field with MDT
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
val cmd = "TSO TEST"
for (i in cmd.indices) {
screenBuffer.getCell(21 + i).ucs4 = cmd[i]
screenBuffer.getCell(21 + i).ec = translator.unicodeToEbcdic(cmd[i]).toByte()
}
// Position 40: Hidden field (zero intensity)
screenBuffer.setCellFA(40, (FA_PRINTABLE or FA_PROTECT or FA_INT_ZERO_NSEL).toByte())
fieldList.refresh()
val fields = fieldList.fields
assertEquals(3, fields.size)
// Field 0: Label
val f0 = fields[0]
assertTrue(f0.isProtected)
assertFalse(f0.isModified)
assertEquals(0, f0.start)
assertEquals(1, f0.dataStart)
assertTrue(f0.text.startsWith("COMMAND ===>"))
// Field 1: Input command
val f1 = fields[1]
assertFalse(f1.isProtected)
assertTrue(f1.isModified)
assertEquals(20, f1.start)
assertEquals(21, f1.dataStart)
assertTrue(f1.text.startsWith("TSO TEST"))
// Field 2: Hidden field
val f2 = fields[2]
assertTrue(f2.isHidden)
}
}
@@ -0,0 +1,178 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.ConnectionState
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.datastream.DataStreamProcessor
import haus.nightmare.lib3270j.graphics.GocaDecoder
import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.protocol.TelnetConstants.*
import haus.nightmare.lib3270j.protocol.TN3270EConstants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.telnet.TelnetConnection
import haus.nightmare.lib3270j.telnet.TelnetFSM
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.util.ArrayList
import java.util.concurrent.atomic.AtomicReference
class GraphicsAndSysReqTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var graphicsPlane: GraphicsPlane
private lateinit var gocaDecoder: GocaDecoder
private class MockConnection(config: ConnectionConfig, fsm: TelnetFSM) : TelnetConnection(config, fsm) {
val sentData: MutableList<ByteArray> = ArrayList()
override fun sendRaw(data: ByteArray) {
sentData.add(data.clone())
}
override fun sendRaw(data: ByteArray, offset: Int, length: Int) {
val b = ByteArray(length)
System.arraycopy(data, offset, b, 0, length)
sentData.add(b)
}
}
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
graphicsPlane = GraphicsPlane(800, 600)
gocaDecoder = GocaDecoder(graphicsPlane)
}
@Test
fun testScreenBufferSetCursorPosition() {
screenBuffer.setCursorPosition(5, 10)
assertEquals(5 * 80 + 10, screenBuffer.cursorAddress)
assertEquals(5, screenBuffer.cursorRow)
assertEquals(10, screenBuffer.cursorCol)
// Clamping check
screenBuffer.setCursorPosition(-5, -10)
assertEquals(0, screenBuffer.cursorAddress)
screenBuffer.setCursorPosition(100, 200)
assertEquals(23 * 80 + 79, screenBuffer.cursorAddress)
}
@Test
fun testGraphicsCursorFramingOnSendAid() {
screenBuffer.erase(false)
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte())
screenBuffer.getCell(1).ec = 0xC1.toByte() // 'A'
screenBuffer.setCellFA(5, (FA_PRINTABLE or FA_PROTECT).toByte())
screenBuffer.cursorAddress = 2
gocaDecoder.isGraphicsCursorActive = true
gocaDecoder.setGraphicCursorPosition(200, -100)
val sentRef = AtomicReference<ByteArray>()
val inputProcessor = object : InputProcessor(screenBuffer, translator, null) {
override fun sendAidResponse(data: ByteArray) {
sentRef.set(data)
}
}
inputProcessor.gocaDecoder = gocaDecoder
inputProcessor.sendAid(AID_ENTER)
val result = sentRef.get()
assertNotNull(result)
// Expected: AID_SF (0x88) + SF(56 bytes) + AID_ENTER(0x7D) + CursorAddr(2) + SBA(1) + FieldAddr(2) + Data 'A'(1) = 64 bytes
assertEquals(64, result.size)
assertEquals(AID_SF.toByte(), result[0])
assertEquals(0x00.toByte(), result[1])
assertEquals(0x34.toByte(), result[2]) // SF len 52
assertEquals(0x0F.toByte(), result[3])
assertEquals(0x0F.toByte(), result[4])
// Coordinates at index 25, 27
val gx = ((result[25].toInt() and 0xFF) shl 8) or (result[26].toInt() and 0xFF)
val gy = ((result[27].toInt() and 0xFF) shl 8) or (result[28].toInt() and 0xFF)
assertEquals(200, gx.toShort().toInt())
assertEquals(-100, gy.toShort().toInt())
// Trailing AID at index 57
assertEquals(AID_ENTER.toByte(), result[57])
}
@Test
fun testSysReqOutOfBandRoutingAndStateToggle() {
val config = ConnectionConfig("127.0.0.1", 23, TerminalModel.IBM_3279_2).apply {
isTn3270eEnabled = true
}
val dsProcessor = DataStreamProcessor(screenBuffer, translator)
val fsm = TelnetFSM(config, screenBuffer, dsProcessor)
val inputProcessor = InputProcessor(screenBuffer, translator, fsm)
dsProcessor.inputProcessor = inputProcessor
val connection = MockConnection(config, fsm)
fsm.setConnection(connection)
fsm.onConnected()
// 1. Complete TN3270E negotiation
fsm.feedBytes(byteArrayOf(IAC.toByte(), DO.toByte(), TELOPT_TN3270E.toByte()), 0, 3)
val devTypeIs = byteArrayOf(
IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(),
0x02, 0x04, 'I'.code.toByte(), 'B'.code.toByte(), 'M'.code.toByte(), '-'.code.toByte(),
'3'.code.toByte(), '2'.code.toByte(), '7'.code.toByte(), '9'.code.toByte(), '-'.code.toByte(),
'2'.code.toByte(), '-'.code.toByte(), 'E'.code.toByte(),
IAC.toByte(), SE.toByte()
)
fsm.feedBytes(devTypeIs, 0, devTypeIs.size)
val funcsReq = byteArrayOf(
IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(),
OP_FUNCTIONS.toByte(), OP_REQUEST.toByte(),
FUNC_BIND_IMAGE.toByte(), FUNC_RESPONSES.toByte(), FUNC_SYSREQ.toByte(),
IAC.toByte(), SE.toByte()
)
fsm.feedBytes(funcsReq, 0, funcsReq.size)
// BIND image
val bindPacket = ByteArray(EH_SIZE + 35).apply {
this[0] = DT_BIND_IMAGE.toByte()
this[4] = 1
this[EH_SIZE + 24] = 0x02
}
val bStream = ByteArrayOutputStream().apply {
write(bindPacket)
write(IAC)
write(EOR)
}
val bindBytes = bStream.toByteArray()
fsm.feedBytes(bindBytes, 0, bindBytes.size)
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState)
assertTrue(fsm.isTn3270eBound)
connection.sentData.clear()
// 2. Lock keyboard and send SYSREQ
inputProcessor.isKeyboardLocked = true
inputProcessor.sendAid(AID_SYSREQ)
assertFalse(inputProcessor.isKeyboardLocked)
var foundIacAo = false
for (pkt in connection.sentData) {
if (pkt.size == 2 && (pkt[0].toInt() and 0xFF) == IAC && (pkt[1].toInt() and 0xFF) == AO) {
foundIacAo = true
}
}
assertTrue("Expected IAC AO out-of-band telnet command", foundIacAo)
assertEquals(ConnectionState.CONNECTED_SSCP, fsm.connectionState)
// 3. Send second SYSREQ to toggle back
inputProcessor.sendAid(AID_SYSREQ)
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState)
}
}
@@ -0,0 +1,241 @@
package haus.nightmare.a3270
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
class HardwareKeyboardInputTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: InputProcessor
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = InputProcessor(screenBuffer, translator, null)
}
@Test
fun testUnformattedTypingAndNavigation() {
assertEquals(0, screenBuffer.cursorAddress)
// Type alphanumeric string
val text = "LOGON APPLID(TSO)"
for (ch in text) {
inputProcessor.typeCharacter(ch)
}
assertEquals(text.length, screenBuffer.cursorAddress)
for (i in text.indices) {
assertEquals(text[i], screenBuffer.getCell(i).ucs4)
}
// Navigation
inputProcessor.cursorLeft()
assertEquals(text.length - 1, screenBuffer.cursorAddress)
inputProcessor.cursorRight()
assertEquals(text.length, screenBuffer.cursorAddress)
inputProcessor.cursorDown()
assertEquals(80 + text.length, screenBuffer.cursorAddress)
inputProcessor.cursorUp()
assertEquals(text.length, screenBuffer.cursorAddress)
inputProcessor.cursorHome()
assertEquals(0, screenBuffer.cursorAddress)
}
@Test
fun testBackspaceAndForwardDelete() {
val text = "HELLO"
for (ch in text) {
inputProcessor.typeCharacter(ch)
}
assertEquals(5, screenBuffer.cursorAddress)
// Backspace moves left and deletes char
inputProcessor.backspace()
assertEquals(4, screenBuffer.cursorAddress)
}
@Test
fun testFormattedFieldInputAndTab() {
// Create formatted screen with protected label and unprotected input field
// Position 0: Protected FA
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
// Set label "USERID:" at positions 1..7
val label = "USERID:"
for (i in label.indices) {
screenBuffer.getCell(i + 1).ucs4 = label[i]
screenBuffer.getCell(i + 1).ec = translator.unicodeToEbcdic(label[i]).toByte()
}
// Position 10: Unprotected FA
screenBuffer.setCellFA(10, FA_PRINTABLE.toByte())
// Position 20: Protected FA
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_PROTECT).toByte())
// Position 30: Unprotected FA
screenBuffer.setCellFA(30, FA_PRINTABLE.toByte())
screenBuffer.cursorAddress = 11
// Type into first field
val userid = "IBMUSER"
for (ch in userid) {
inputProcessor.typeCharacter(ch)
}
assertEquals(18, screenBuffer.cursorAddress)
for (i in userid.indices) {
assertEquals(userid[i], screenBuffer.getCell(11 + i).ucs4)
}
// Tab should jump to position 31 (next unprotected field)
inputProcessor.tab()
assertEquals(31, screenBuffer.cursorAddress)
// BackTab should jump back to position 11
inputProcessor.backTab()
assertEquals(11, screenBuffer.cursorAddress)
// Home should jump to first unprotected field (position 11)
screenBuffer.cursorAddress = 50
inputProcessor.cursorHome()
assertEquals(11, screenBuffer.cursorAddress)
}
@Test
fun testFormattedDeleteCharAndEraseEof() {
// Position 0: Unprotected FA
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
// Position 10: Protected FA
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
screenBuffer.cursorAddress = 1
val text = "ABCDE"
for (ch in text) {
inputProcessor.typeCharacter(ch)
}
// Move cursor to 'C' at pos 3
screenBuffer.cursorAddress = 3
inputProcessor.deleteChar()
// "ABDE "
assertEquals('A', screenBuffer.getCell(1).ucs4)
assertEquals('B', screenBuffer.getCell(2).ucs4)
assertEquals('D', screenBuffer.getCell(3).ucs4)
assertEquals('E', screenBuffer.getCell(4).ucs4)
assertEquals(0, screenBuffer.getCell(5).ec.toInt())
// Erase EOF from pos 3
inputProcessor.eraseEof()
assertEquals('A', screenBuffer.getCell(1).ucs4)
assertEquals('B', screenBuffer.getCell(2).ucs4)
assertEquals(0, screenBuffer.getCell(3).ec.toInt())
assertEquals(0, screenBuffer.getCell(4).ec.toInt())
}
@Test
fun testPfKeyAids() {
val pfAids = intArrayOf(
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
)
for (i in 1..24) {
val aid = pfAids[i - 1]
inputProcessor.sendAid(aid)
assertEquals(aid, inputProcessor.lastAid)
assertTrue(inputProcessor.isKeyboardLocked)
inputProcessor.reset()
assertFalse(inputProcessor.isKeyboardLocked)
}
}
@Test
fun testLightPenSelection() {
// Field 1 at pos 0: Selectable Unprotected (FA_INT_HIGH)
// Designator at pos 1: '?' (EBCDIC 0x6F)
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
assertTrue(haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa.toInt() and 0xFF))
screenBuffer.setCellFA(0, fa)
screenBuffer.getCell(1).ucs4 = '?'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('?').toByte()
// Field 2 at pos 20: Non-selectable (normal intensity)
screenBuffer.setCellFA(20, FA_PRINTABLE.toByte())
screenBuffer.getCell(21).ucs4 = '?'
// Selecting field 1 ('?') toggles designator to '>' and sets modified bit
val result1 = inputProcessor.lightPenSelect(1)
assertTrue(result1)
assertEquals('>', screenBuffer.getCell(1).ucs4)
assertEquals(0x6E.toByte(), screenBuffer.getCell(1).ec)
val fa1 = screenBuffer.getCell(0).fa.toInt() and 0xFF
assertTrue((fa1 and FA_MODIFY) != 0)
// Selecting field 1 again ('>') toggles back to '?' and clears modified bit
val result2 = inputProcessor.lightPenSelect(1)
assertTrue(result2)
assertEquals('?', screenBuffer.getCell(1).ucs4)
assertEquals(0x6F.toByte(), screenBuffer.getCell(1).ec)
// Selecting field 2 (non-selectable) returns false
val resultNonSelectable = inputProcessor.lightPenSelect(21)
assertFalse(resultNonSelectable)
}
@Test
fun testLightPenEnterSelection() {
// Field with '&' designator immediately sends AID_ENTER
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
screenBuffer.setCellFA(0, fa)
screenBuffer.getCell(1).ucs4 = '&'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('&').toByte()
val result = inputProcessor.lightPenSelect(1)
assertTrue(result)
assertEquals(AID_ENTER, inputProcessor.lastAid)
}
@Test
fun testEraseInputAndAttention() {
// Position 0: Unprotected FA
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte())
// Position 10: Protected FA
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
// Position 20: Unprotected FA
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
screenBuffer.getCell(1).ucs4 = 'X'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('X').toByte()
screenBuffer.getCell(21).ucs4 = 'Y'
screenBuffer.getCell(21).ec = translator.unicodeToEbcdic('Y').toByte()
inputProcessor.eraseInput()
// Unprotected fields should be cleared and reset
assertEquals(0, screenBuffer.getCell(1).ec.toInt())
assertEquals(0, screenBuffer.getCell(21).ec.toInt())
assertEquals(1, screenBuffer.cursorAddress)
// Attention AID
inputProcessor.sendAid(AID_PA1)
assertEquals(AID_PA1, inputProcessor.lastAid)
}
}
@@ -0,0 +1,179 @@
package haus.nightmare.a3270
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.CodePageRegistry
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.ecl.ECLConstants
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.nvt.NvtProcessor
import haus.nightmare.lib3270j.printer.PrinterDefinitionTable
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
class NvtAndExtendedFeaturesTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: InputProcessor
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = InputProcessor(screenBuffer, translator, null)
}
@Test
fun testExtendedCodePagesAvailability() {
val testCps = listOf(
"420", "424", "803", "838", "1160",
"1025", "1123", "1154", "880", "905", "1026", "1155",
"1140", "1141", "1142", "1143", "1144", "1145", "1146", "1147", "1148", "1149",
"930", "939", "935", "937", "1388", "1371", "933"
)
for (cp in testCps) {
val trans = CodePageRegistry.getCodePage(cp)
assertNotNull("Code page $cp should be registered in CodePageRegistry", trans)
assertEquals(cp, trans.codePageId)
}
}
@Test
fun testExtendedCodePageTranslation() {
// Test Greek Cp875
val greekTrans = CodePageRegistry.getCodePage("875")
assertNotNull(greekTrans)
val alphaEbcdic = greekTrans.unicodeToEbcdic('α')
val alphaUnicode = greekTrans.ebcdicToUnicode(alphaEbcdic.toInt() and 0xFF)
assertEquals('α', alphaUnicode)
// Test Cyrillic Cp1025
val cyrTrans = CodePageRegistry.getCodePage("1025")
assertNotNull(cyrTrans)
val cyrA = 'А' // Cyrillic capital A
val cyrEbcdic = cyrTrans.unicodeToEbcdic(cyrA)
val cyrResult = cyrTrans.ebcdicToUnicode(cyrEbcdic.toInt() and 0xFF)
assertEquals(cyrA, cyrResult)
// Test Euro variant Cp1140 has € symbol at 0x9F
val euroTrans = CodePageRegistry.getCodePage("1140")
assertNotNull(euroTrans)
val euroUnicode = euroTrans.ebcdicToUnicode(0x9F)
assertEquals('€', euroUnicode)
}
@Test
fun testPDTTables() {
val pcl5 = PrinterDefinitionTable.createPcl5PDT()
assertNotNull(pcl5)
assertEquals("PCL_5", pcl5.name)
assertNotNull(pcl5.getControlCode(PrinterDefinitionTable.CMD_START_JOB))
val escp = PrinterDefinitionTable.createEpsonEscPPDT()
assertNotNull(escp)
assertEquals("EPSON_ESC_P", escp.name)
assertNotNull(escp.getControlCode(PrinterDefinitionTable.CMD_START_BOLD))
val ps = PrinterDefinitionTable.createPostScriptPDT()
assertNotNull(ps)
assertEquals("POSTSCRIPT", ps.name)
val psStart = String(ps.getControlCode(PrinterDefinitionTable.CMD_START_JOB) ?: ByteArray(0), java.nio.charset.StandardCharsets.ISO_8859_1)
assertTrue(psStart.contains("%!PS"))
val plain = PrinterDefinitionTable.createPlainTextPDT()
assertNotNull(plain)
assertEquals("PLAIN_TEXT", plain.name)
}
@Test
fun testWordNavigationAndEditing() {
val text = "IBM MAINFRAME SYSTEM"
for (i in text.indices) {
screenBuffer.getCell(i).ucs4 = text[i]
screenBuffer.getCell(i).ec = translator.unicodeToEbcdic(text[i]).toByte()
}
screenBuffer.cursorAddress = 0
inputProcessor.processWordRight()
assertEquals(5, screenBuffer.cursorAddress)
inputProcessor.processWordRight()
assertEquals(16, screenBuffer.cursorAddress)
inputProcessor.processWordLeft()
assertEquals(5, screenBuffer.cursorAddress)
inputProcessor.processWordLeft()
assertEquals(0, screenBuffer.cursorAddress)
}
@Test
fun testFieldEndAndToggleInsert() {
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_PROTECT).toByte())
val text = "HELLO"
for (i in text.indices) {
screenBuffer.getCell(1 + i).ucs4 = text[i]
screenBuffer.getCell(1 + i).ec = translator.unicodeToEbcdic(text[i]).toByte()
}
screenBuffer.cursorAddress = 1
inputProcessor.processFieldEnd()
assertEquals(6, screenBuffer.cursorAddress)
assertFalse(inputProcessor.isInsertMode)
inputProcessor.processToggleInsert()
assertTrue(inputProcessor.isInsertMode)
inputProcessor.processToggleInsert()
assertFalse(inputProcessor.isInsertMode)
}
@Test
fun testNvtFunctionKeySequences() {
val nvt = NvtProcessor(screenBuffer, null)
val f1 = nvt.getFunctionKeySequence(1)
val f5 = nvt.getFunctionKeySequence(5)
val f10 = nvt.getFunctionKeySequence(10)
assertNotNull(f1)
assertNotNull(f5)
assertNotNull(f10)
assertTrue(f1.startsWith("\u001B"))
assertTrue(f5.startsWith("\u001B"))
assertTrue(f10.startsWith("\u001B"))
}
@Test
fun testECLFieldWrapping() {
val totalCells = screenBuffer.rows * screenBuffer.cols
val faPos = totalCells - 10
screenBuffer.setCellFA(faPos, FA_PRINTABLE.toByte())
screenBuffer.setCellFA(15, (FA_PRINTABLE or FA_PROTECT).toByte())
val fieldList = screenBuffer.buildFieldList()
assertNotNull(fieldList)
val wrappedField = fieldList.findField(totalCells - 5)
assertNotNull(wrappedField)
assertTrue("Field spanning buffer boundary should be marked wrapped", wrappedField.isWrapped)
assertTrue(wrappedField.contains(totalCells - 2))
assertTrue(wrappedField.contains(5))
}
@Test
fun testOiaInhibitConstants() {
assertEquals(1, ECLConstants.INHIBIT_SYSTEM_LOCK)
assertEquals(0, ECLConstants.INHIBIT_NOT_INHIBITED)
assertEquals(5, ECLConstants.INHIBIT_COMM_CHECK)
assertEquals(3, ECLConstants.INHIBIT_PROTECTED_FIELD)
assertEquals(2, ECLConstants.INHIBIT_NUMERIC_ONLY)
assertEquals(4, ECLConstants.INHIBIT_OVERFLOW)
assertEquals(6, ECLConstants.INHIBIT_OPERATOR_DUE)
}
}
@@ -0,0 +1,67 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.printer.PrinterConfig
import haus.nightmare.lib3270j.printer.PrinterConstants
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class ScriptAndPrinterTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: InputProcessor
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = InputProcessor(screenBuffer, translator, null)
}
@Test
fun testEmulateInputMnemonicTokens() {
// Position 0: Unprotected input field
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
screenBuffer.cursorAddress = 1
inputProcessor.sendKeys("HELLO[enter]")
assertEquals('H', screenBuffer.getCell(1).ucs4)
assertEquals('E', screenBuffer.getCell(2).ucs4)
assertEquals('L', screenBuffer.getCell(3).ucs4)
assertEquals('L', screenBuffer.getCell(4).ucs4)
assertEquals('O', screenBuffer.getCell(5).ucs4)
assertEquals(AID_ENTER, inputProcessor.lastAid)
inputProcessor.reset()
inputProcessor.sendKeys("[pf3]")
assertEquals(AID_PF3, inputProcessor.lastAid)
}
@Test
fun testPrinterConfigParameters() {
val config = PrinterConfig("10.0.0.1", 992, "PRT01", true).apply {
associatedDisplayLuName = "DSP01"
codePage = "1047"
mpp = 132
mpl = 66
destinationType = PrinterConfig.DestinationType.MEMORY
}
assertEquals("10.0.0.1", config.host)
assertEquals(992, config.port)
assertEquals("PRT01", config.printerLuName)
assertEquals("DSP01", config.associatedDisplayLuName)
assertTrue(config.isUseTls)
assertEquals("1047", config.codePage)
assertEquals(132, config.mpp)
assertEquals(66, config.mpl)
assertEquals(PrinterConfig.DestinationType.MEMORY, config.destinationType)
}
}