3 Commits

Author SHA1 Message Date
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
8 changed files with 898 additions and 238 deletions
+3
View File
@@ -69,4 +69,7 @@ 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.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
} }
+254 -39
View File
@@ -21,7 +21,9 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import org.lib3270j.protocol.DS3270Constants.AID_ENTER import android.content.Context
import android.view.KeyCharacterMap
import org.lib3270j.protocol.DS3270Constants.*
import org.pubvm.a3270.service.TerminalService import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.HostStorage import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.ui.ConnectDialog import org.pubvm.a3270.ui.ConnectDialog
@@ -97,68 +99,280 @@ class MainActivity : ComponentActivity() {
} }
} }
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
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0) val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) { if (isShiftPressedState.value != shift) {
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) { if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode 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) { if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1 val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
val pfNum = if (shift) fNum + 12 else fNum val pfNum = if (shift || alt) fNum + 12 else fNum
val pfAids = intArrayOf( val pfAids = intArrayOf(
org.lib3270j.protocol.DS3270Constants.AID_PF1, org.lib3270j.protocol.DS3270Constants.AID_PF2, AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
org.lib3270j.protocol.DS3270Constants.AID_PF3, org.lib3270j.protocol.DS3270Constants.AID_PF4, AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
org.lib3270j.protocol.DS3270Constants.AID_PF5, org.lib3270j.protocol.DS3270Constants.AID_PF6, AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
org.lib3270j.protocol.DS3270Constants.AID_PF7, org.lib3270j.protocol.DS3270Constants.AID_PF8, AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
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]) viewModel.sendAid(pfAids[pfNum - 1])
if (shift) { if (shift) {
isShiftPressedState.value = false isShiftPressedState.value = false
} }
return true 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()
} }
// 2. Ctrl Shortcuts
if (ctrl) {
when (keyCode) {
KeyEvent.KEYCODE_V -> {
pasteFromClipboard()
return true return true
} else if (keyCode == KeyEvent.KEYCODE_DEL) { }
viewModel.backspace() KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard()
return true return true
} else if (keyCode == KeyEvent.KEYCODE_ESCAPE) { }
KeyEvent.KEYCODE_L -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_Z -> {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_HOME -> {
viewModel.cursorHome()
return true
}
KeyEvent.KEYCODE_MOVE_END -> {
viewModel.eraseEof()
return true
}
}
}
// 3. Alt Shortcuts (PA keys, Clear, Reset)
if (alt) {
when (keyCode) {
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 -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard() viewModel.resetKeyboard()
return true 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 -> {
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.sendAid(AID_CLEAR)
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) 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 @Composable
@@ -218,6 +432,7 @@ fun MainScreen(
autoHost.hostType, autoHost.hostType,
autoHost.useTls, autoHost.useTls,
autoHost.tlsVerifyCert, autoHost.tlsVerifyCert,
autoHost.tn3270e,
autoHost.graphicsMode autoHost.graphicsMode
) )
} }
@@ -356,9 +571,9 @@ fun MainScreen(
if (showConnectDialog) { if (showConnectDialog) {
ConnectDialog( ConnectDialog(
onDismiss = { showConnectDialog = false }, onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode -> onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode ->
showConnectDialog = false showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode) viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode)
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
} }
) )
@@ -32,12 +32,17 @@ 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 class SetCursor(val baddr: Int) : TerminalInputAction data class SetCursor(val baddr: Int) : TerminalInputAction
} }
@@ -124,6 +129,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
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
@@ -162,6 +168,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
try { try {
when (action) { when (action) {
is TerminalInputAction.TypeText -> { is TerminalInputAction.TypeText -> {
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}")
ip.isKeyboardLocked = false ip.isKeyboardLocked = false
for (ch in action.text) { for (ch in action.text) {
if (ch == '\n' || ch == '\r') { if (ch == '\n' || ch == '\r') {
@@ -177,6 +184,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
} }
ip.typeCharacter(ch) ip.typeCharacter(ch)
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(curAddr).ucs4}'")
} }
} }
} }
@@ -188,6 +196,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
ip.isKeyboardLocked = false ip.isKeyboardLocked = false
ip.backspace() ip.backspace()
} }
is TerminalInputAction.DeleteChar -> {
ip.isKeyboardLocked = false
ip.deleteChar()
}
is TerminalInputAction.Tab -> { is TerminalInputAction.Tab -> {
ip.tab() ip.tab()
} }
@@ -206,6 +218,20 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
is TerminalInputAction.CursorDown -> { is TerminalInputAction.CursorDown -> {
ip.cursorDown() ip.cursorDown()
} }
is TerminalInputAction.CursorHome -> {
ip.cursorHome()
}
is TerminalInputAction.EraseEof -> {
ip.isKeyboardLocked = false
ip.eraseEof()
}
is TerminalInputAction.EraseInput -> {
ip.isKeyboardLocked = false
ip.eraseInput()
}
is TerminalInputAction.Newline -> {
ip.newline()
}
is TerminalInputAction.Reset -> { is TerminalInputAction.Reset -> {
ip.reset() ip.reset()
} }
@@ -253,6 +279,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
hostType: String = "TSO", hostType: String = "TSO",
useTls: Boolean = false, useTls: Boolean = false,
tlsVerifyCert: Boolean = true, tlsVerifyCert: Boolean = true,
tn3270e: Boolean = true,
graphicsModeStr: String = "BOTH" graphicsModeStr: String = "BOTH"
) { ) {
currentHost = host currentHost = host
@@ -284,6 +311,7 @@ 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 = org.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
@@ -298,6 +326,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
isUseTls = effectiveTls isUseTls = effectiveTls
isTlsVerifyCert = effectiveVerify isTlsVerifyCert = effectiveVerify
isTn3270eEnabled = effectiveTn3270e
graphicsMode = gMode graphicsMode = gMode
} }
@@ -542,6 +571,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 +587,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.Reset) inputChannel.trySend(TerminalInputAction.Reset)
} }
fun eraseEof() {
inputChannel.trySend(TerminalInputAction.EraseEof)
}
fun eraseInput() {
inputChannel.trySend(TerminalInputAction.EraseInput)
}
fun newline() {
inputChannel.trySend(TerminalInputAction.Newline)
}
fun sendAid(aidCode: Int) { fun sendAid(aidCode: Int) {
inputChannel.trySend(TerminalInputAction.SendAid(aidCode)) inputChannel.trySend(TerminalInputAction.SendAid(aidCode))
} }
@@ -578,6 +623,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.CursorRight) inputChannel.trySend(TerminalInputAction.CursorRight)
} }
fun cursorHome() {
inputChannel.trySend(TerminalInputAction.CursorHome)
}
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
@@ -17,6 +17,7 @@ data class SavedHost(
val hostType: String = "TSO", val hostType: String = "TSO",
val useTls: Boolean = false, val useTls: Boolean = false,
val tlsVerifyCert: Boolean = true, val tlsVerifyCert: Boolean = true,
val tn3270e: Boolean = true,
val graphicsMode: String = "BOTH" val graphicsMode: String = "BOTH"
) { ) {
fun toJson(): JSONObject { fun toJson(): JSONObject {
@@ -31,6 +32,7 @@ data class SavedHost(
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)
} }
} }
@@ -48,6 +50,7 @@ data class SavedHost(
hostType = json.optString("hostType", "TSO"), hostType = json.optString("hostType", "TSO"),
useTls = json.optBoolean("useTls", false), useTls = json.optBoolean("useTls", false),
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true), tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
tn3270e = json.optBoolean("tn3270e", true),
graphicsMode = json.optString("graphicsMode", "BOTH") graphicsMode = json.optString("graphicsMode", "BOTH")
) )
} }
+214 -85
View File
@@ -1,5 +1,6 @@
package org.pubvm.a3270.ui package org.pubvm.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -10,22 +11,34 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import org.pubvm.a3270.storage.HostStorage import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.storage.SavedHost import org.pubvm.a3270.storage.SavedHost
@Composable @Composable
fun ConnectDialog( fun ConnectDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConnect: (host: String, port: Int, model: Int, luName: String, hostType: String, useTls: Boolean, tlsVerifyCert: Boolean, graphicsMode: String) -> Unit onConnect: (
host: String,
port: Int,
model: Int,
luName: String,
hostType: String,
useTls: Boolean,
tlsVerifyCert: Boolean,
tn3270e: Boolean,
graphicsMode: String
) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) } var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
@@ -40,6 +53,7 @@ fun ConnectDialog(
var hostType by remember { mutableStateOf("TSO") } var hostType by remember { mutableStateOf("TSO") }
var useTls by remember { mutableStateOf(false) } var useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) } var tlsVerifyCert by remember { mutableStateOf(true) }
var tn3270e by remember { mutableStateOf(true) }
var graphicsMode by remember { mutableStateOf("BOTH") } var graphicsMode by remember { mutableStateOf("BOTH") }
fun loadProfile(saved: SavedHost) { fun loadProfile(saved: SavedHost) {
@@ -53,6 +67,7 @@ fun ConnectDialog(
hostType = saved.hostType hostType = saved.hostType
useTls = saved.useTls useTls = saved.useTls
tlsVerifyCert = saved.tlsVerifyCert tlsVerifyCert = saved.tlsVerifyCert
tn3270e = saved.tn3270e
graphicsMode = saved.graphicsMode graphicsMode = saved.graphicsMode
} }
@@ -67,6 +82,7 @@ fun ConnectDialog(
hostType = "TSO" hostType = "TSO"
useTls = false useTls = false
tlsVerifyCert = true tlsVerifyCert = true
tn3270e = true
graphicsMode = "BOTH" graphicsMode = "BOTH"
} }
@@ -85,6 +101,7 @@ fun ConnectDialog(
hostType = hostType, hostType = hostType,
useTls = useTls, useTls = useTls,
tlsVerifyCert = tlsVerifyCert, tlsVerifyCert = tlsVerifyCert,
tn3270e = tn3270e,
graphicsMode = graphicsMode graphicsMode = graphicsMode
) )
HostStorage.saveHost(context, hostToSave) HostStorage.saveHost(context, hostToSave)
@@ -102,20 +119,24 @@ fun ConnectDialog(
keyboardController?.show() keyboardController?.show()
} }
Dialog(onDismissRequest = onDismiss) { Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(16.dp),
color = Color(0xFF1E1E1E), color = Color(0xFF1E1E1E),
tonalElevation = 6.dp, tonalElevation = 6.dp,
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.96f) .fillMaxWidth(0.95f)
.wrapContentHeight() .heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(14.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Title Row // Title Row
Row( Row(
@@ -123,12 +144,17 @@ fun ConnectDialog(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Connect / Manage Hosts", fontWeight = FontWeight.Bold, fontSize = 15.sp, color = Color.White) Text(
"Connect / Manage Hosts",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
TextButton( TextButton(
onClick = { clearFields() }, onClick = { clearFields() },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp) contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) { ) {
Text("+ New Profile", fontSize = 12.sp) Text("+ New Profile", fontSize = 12.sp, color = Color(0xFF339AF0))
} }
} }
@@ -136,18 +162,23 @@ fun ConnectDialog(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 460.dp) .weight(1f, fill = false)
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(scrollState), .verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Saved Profiles List Section // Saved Profiles List Section
if (savedHosts.isNotEmpty()) { if (savedHosts.isNotEmpty()) {
Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray) Text(
"Saved Host Profiles:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -155,12 +186,13 @@ fun ConnectDialog(
savedHosts.forEach { saved -> savedHosts.forEach { saved ->
val isSelected = (saved.id == selectedHostId) val isSelected = (saved.id == selectedHostId)
val tlsTag = if (saved.useTls) { val tlsTag = if (saved.useTls) {
if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/Unverified]" if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/NoVerify]"
} else "" } else ""
val eTag = if (!saved.tn3270e) " [Non-E]" else ""
val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else "" val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
Surface( Surface(
shape = RoundedCornerShape(6.dp), shape = RoundedCornerShape(8.dp),
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B), color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null, border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null,
modifier = Modifier modifier = Modifier
@@ -170,7 +202,7 @@ fun ConnectDialog(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -178,11 +210,11 @@ fun ConnectDialog(
Text( Text(
text = saved.name, text = saved.name,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, fontSize = 13.sp,
color = Color.White color = Color.White
) )
Text( Text(
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$gfxTag" + text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$eTag$gfxTag" +
if (saved.autoConnect) " [Auto]" else "", if (saved.autoConnect) " [Auto]" else "",
fontSize = 10.sp, fontSize = 10.sp,
color = Color.LightGray color = Color.LightGray
@@ -201,12 +233,13 @@ fun ConnectDialog(
hostToConn.hostType, hostToConn.hostType,
hostToConn.useTls, hostToConn.useTls,
hostToConn.tlsVerifyCert, hostToConn.tlsVerifyCert,
hostToConn.tn3270e,
hostToConn.graphicsMode hostToConn.graphicsMode
) )
}, },
modifier = Modifier.size(28.dp) modifier = Modifier.size(32.dp)
) { ) {
Text("", fontSize = 13.sp, color = Color(0xFF51CF66)) Text("", fontSize = 14.sp, color = Color(0xFF51CF66))
} }
IconButton( IconButton(
onClick = { onClick = {
@@ -216,20 +249,20 @@ fun ConnectDialog(
clearFields() clearFields()
} }
}, },
modifier = Modifier.size(28.dp) modifier = Modifier.size(32.dp)
) { ) {
Text("", fontSize = 12.sp, color = Color(0xFFFF6B6B)) Text("", fontSize = 13.sp, color = Color(0xFFFF6B6B))
} }
} }
} }
} }
} }
} }
HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 2.dp)) HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 4.dp))
} }
Text( Text(
text = if (selectedHostId != null) "Editing Profile:" else "New Profile Details:", text = if (selectedHostId != null) "Editing Profile:" else "Host Connection Details:",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Gray color = Color.Gray
@@ -243,74 +276,155 @@ fun ConnectDialog(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField( OutlinedTextField(
value = host, value = host,
onValueChange = { host = it }, onValueChange = { host = it },
label = { Text("Host / IP Address") }, label = { Text("Host / IP Address") },
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(0.68f)
.focusRequester(focusRequester) .focusRequester(focusRequester)
) )
OutlinedTextField( OutlinedTextField(
value = portStr, value = portStr,
onValueChange = { portStr = it }, onValueChange = { portStr = it },
label = { Text("Port (default 23 or 992)") }, label = { Text("Port") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.weight(0.32f)
) )
}
// Terminal Model // Terminal Model Selector
Column(modifier = Modifier.fillMaxWidth()) {
Text("Terminal Model:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text("Model:", fontSize = 11.sp, color = Color.Gray) listOf(
listOf(2, 3, 4, 5).forEach { m -> 2 to "M2",
FilterChip( 3 to "M3",
selected = (modelNum == m), 4 to "M4",
onClick = { modelNum = m }, 5 to "M5"
label = { Text("M$m", fontSize = 11.sp) } ).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
) )
} }
} }
}
}
}
// Host System Type (TSO, CMS, CICS) // 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( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text("Host Type:", fontSize = 11.sp, color = Color.Gray)
listOf("TSO", "CMS", "CICS").forEach { ht -> listOf("TSO", "CMS", "CICS").forEach { ht ->
FilterChip( val isSelected = (hostType == ht)
selected = (hostType == ht), Surface(
onClick = { hostType = ht }, shape = RoundedCornerShape(6.dp),
label = { Text(ht, fontSize = 11.sp) } color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
) modifier = Modifier
} .weight(1f)
} .height(34.dp)
.clickable { hostType = ht }
// Graphics Mode
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Graphics:", fontSize = 11.sp, color = Color.Gray) Box(
listOf( contentAlignment = Alignment.Center,
"PROGRAMMED_SYMBOLS" to "Symbols", modifier = Modifier.fillMaxSize()
"VECTOR_GRAPHICS" to "Vector", ) {
"BOTH" to "Both", Text(
"NONE" to "None" text = ht,
).forEach { (modeKey, modeLabel) -> color = Color.White,
FilterChip( fontSize = 11.sp,
selected = (graphicsMode.equals(modeKey, ignoreCase = true)), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
onClick = { graphicsMode = modeKey },
label = { Text(modeLabel, fontSize = 11.sp) }
) )
} }
} }
}
}
}
// 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
)
}
}
}
}
}
OutlinedTextField(
value = luName,
onValueChange = { luName = it },
label = { Text("LU Name / Device Pool (Optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Protocol & Security Options
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// TLS / SSL Checkbox // TLS / SSL Checkbox
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -341,13 +455,13 @@ fun ConnectDialog(
Text("Enable TLS / SSL Connection", fontSize = 12.sp, color = Color.White) Text("Enable TLS / SSL Connection", fontSize = 12.sp, color = Color.White)
} }
// Verify Certificate Checkbox // Verify Certificate Checkbox (only when TLS is active)
if (useTls) { if (useTls) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp) .padding(start = 24.dp)
.clickable { tlsVerifyCert = !tlsVerifyCert } .clickable { tlsVerifyCert = !tlsVerifyCert }
) { ) {
Checkbox( Checkbox(
@@ -355,18 +469,30 @@ fun ConnectDialog(
onCheckedChange = { tlsVerifyCert = it } onCheckedChange = { tlsVerifyCert = it }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text("Verify Server Certificate", fontSize = 12.sp, color = if (tlsVerifyCert) Color.LightGray else Color(0xFFFFB450)) 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()
) )
}
}
// 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -378,7 +504,8 @@ fun ConnectDialog(
onCheckedChange = { autoConnect = it } onCheckedChange = { autoConnect = it }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text("Auto-connect on app startup", fontSize = 12.sp) Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White)
}
} }
} }
} }
@@ -392,13 +519,13 @@ fun ConnectDialog(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
Text("Cancel") Text("Cancel", color = Color.LightGray)
} }
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(6.dp))
OutlinedButton(onClick = { saveCurrentProfile() }) { OutlinedButton(onClick = { saveCurrentProfile() }) {
Text("Save") Text("Save")
} }
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(6.dp))
Button( Button(
onClick = { onClick = {
val saved = saveCurrentProfile() val saved = saveCurrentProfile()
@@ -411,12 +538,14 @@ fun ConnectDialog(
saved.hostType, saved.hostType,
saved.useTls, saved.useTls,
saved.tlsVerifyCert, saved.tlsVerifyCert,
saved.tn3270e,
saved.graphicsMode saved.graphicsMode
) )
} }
} },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) { ) {
Text("Connect") Text("Connect", color = Color.White, fontWeight = FontWeight.Bold)
} }
} }
} }
@@ -1,5 +1,6 @@
package org.pubvm.a3270.ui package org.pubvm.a3270.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -14,6 +15,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable @Composable
fun SettingsDialog( fun SettingsDialog(
initialMaskHiddenInput: Boolean, initialMaskHiddenInput: Boolean,
@@ -30,13 +33,17 @@ fun SettingsDialog(
var verifyCerts by remember { mutableStateOf(initialVerifyCerts) } var verifyCerts by remember { mutableStateOf(initialVerifyCerts) }
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) } var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
Dialog(onDismissRequest = onDismiss) { Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)), colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.95f)
.padding(16.dp) .heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -200,23 +207,40 @@ fun SettingsDialog(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
listOf( listOf(
"BOTH" to "Both", "BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector", "VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols", "PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "None" "NONE" to "Off"
).forEach { (modeKey, modeLabel) -> ).forEach { (modeKey, modeLabel) ->
FilterChip( val isSelected = defaultGraphicsMode.equals(modeKey, ignoreCase = true)
selected = defaultGraphicsMode.equals(modeKey, ignoreCase = true), Surface(
onClick = { defaultGraphicsMode = modeKey }, shape = RoundedCornerShape(6.dp),
label = { Text(modeLabel, fontSize = 11.sp) } color = if (isSelected) Color(0xFF2B8A3E) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { defaultGraphicsMode = modeKey }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = modeLabel,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
) )
} }
} }
} }
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
+103 -34
View File
@@ -70,6 +70,9 @@ fun TerminalView(
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current val density = LocalDensity.current
val currentScreenVersion by rememberUpdatedState(screenVersion)
val currentCursorAddr by rememberUpdatedState(cursorAddr)
var selectionStart by remember { mutableStateOf<Offset?>(null) } var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionEnd by remember { mutableStateOf<Offset?>(null) } var selectionEnd by remember { mutableStateOf<Offset?>(null) }
var showContextMenu by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf(false) }
@@ -139,11 +142,10 @@ fun TerminalView(
showContextMenu = false showContextMenu = false
selectionStart = null selectionStart = null
selectionEnd = null selectionEnd = null
val cellWidth = size.width / cols val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
val cellHeight = size.height / rows if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
if (cellWidth > 0 && cellHeight > 0) { val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val col = (startPos.x / cellWidth).toInt().coerceIn(0, cols - 1) val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val row = (startPos.y / cellHeight).toInt().coerceIn(0, rows - 1)
var addr = row * cols + col var addr = row * cols + col
val buf = screenBuffer val buf = screenBuffer
@@ -160,17 +162,30 @@ fun TerminalView(
} }
} }
) { ) {
val width = size.width val version = currentScreenVersion
val height = size.height val activeCursorAddr = currentCursorAddr
val cellWidth = width / cols if (version == -1L) return@Canvas
val cellHeight = height / rows
if (cellWidth <= 0 || cellHeight <= 0) 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 { val paint = Paint().apply {
isAntiAlias = true isAntiAlias = true
typeface = Typeface.MONOSPACE typeface = Typeface.MONOSPACE
textSize = cellHeight * 0.85f 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 buf = screenBuffer
@@ -187,11 +202,11 @@ fun TerminalView(
val start = selectionStart val start = selectionStart
val end = selectionEnd val end = selectionEnd
if (start != null && end != null) { if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1) val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1) val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1) val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1) val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
selMinRow = minOf(startRow, endRow) selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow) selMaxRow = maxOf(startRow, endRow)
@@ -204,8 +219,8 @@ fun TerminalView(
val addr = r * cols + c val addr = r * cols + c
if (addr >= totalCells) break if (addr >= totalCells) break
val left = c * cellWidth val left = offsetX + c * cellWidth
val top = r * cellHeight val top = offsetY + r * cellHeight
var charVal = ' ' var charVal = ' '
var fgColor: Color var fgColor: Color
@@ -279,7 +294,7 @@ fun TerminalView(
) )
} }
// Highlight selected block range // Draw selection box highlight if selected
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
if (isSelected) { if (isSelected) {
drawRect( drawRect(
@@ -290,7 +305,7 @@ fun TerminalView(
} }
// Cursor indicator (respects blinking toggle & timer) // Cursor indicator (respects blinking toggle & timer)
if (addr == cursorAddr && !isSelected && cursorVisible) { if (addr == activeCursorAddr && !isSelected && cursorVisible) {
drawRect( drawRect(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f), color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
topLeft = Offset(left, top), topLeft = Offset(left, top),
@@ -307,7 +322,7 @@ fun TerminalView(
val symHeight = slot.height val symHeight = slot.height
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb()) val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
if (rgbArray != null && symWidth > 0 && symHeight > 0) { if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888) val bmp = android.graphics.Bitmap.createBitmap(rgbArray as IntArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap( drawContext.canvas.nativeCanvas.drawBitmap(
bmp, bmp,
null, null,
@@ -323,11 +338,12 @@ fun TerminalView(
paint.color = fgColor.toArgb() paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics val fontMetrics = paint.fontMetrics
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top val textX = left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
charVal.toString(), charVal.toString(),
left + (cellWidth / 4), textX,
textY, textY,
paint paint
) )
@@ -336,8 +352,8 @@ fun TerminalView(
if (isUnderline) { if (isUnderline) {
drawLine( drawLine(
color = fgColor, color = fgColor,
start = Offset(left, top + cellHeight - 2), start = Offset(left, top + cellHeight - 2f),
end = Offset(left + cellWidth, top + cellHeight - 2), end = Offset(left + cellWidth, top + cellHeight - 2f),
strokeWidth = 2f strokeWidth = 2f
) )
} }
@@ -346,8 +362,8 @@ fun TerminalView(
// Draw Vector Graphics Plane overlay if present // Draw Vector Graphics Plane overlay if present
if (graphicsPlane != null && graphicsPlane.hasContent()) { if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = (cols * cellWidth).toInt() val gridW = metrics.gridWidth.toInt()
val gridH = (rows * cellHeight).toInt() val gridH = metrics.gridHeight.toInt()
if (gridW > 0 && gridH > 0) { if (gridW > 0 && gridH > 0) {
graphicsPlane.resize(gridW, gridH) graphicsPlane.resize(gridW, gridH)
val rgb = graphicsPlane.rgbBuffer val rgb = graphicsPlane.rgbBuffer
@@ -356,7 +372,7 @@ fun TerminalView(
drawContext.canvas.nativeCanvas.drawBitmap( drawContext.canvas.nativeCanvas.drawBitmap(
bmp, bmp,
null, null,
android.graphics.RectF(0f, 0f, gridW.toFloat(), gridH.toFloat()), android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()),
null null
) )
} }
@@ -418,6 +434,57 @@ fun TerminalView(
} }
} }
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( private fun copySelection(
start: Offset?, start: Offset?,
end: Offset?, end: Offset?,
@@ -429,13 +496,15 @@ private fun copySelection(
) { ) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) { if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics val displayMetrics = context.resources.displayMetrics
val cellWidth = displayMetrics.widthPixels.toFloat() / cols val viewW = displayMetrics.widthPixels.toFloat()
val cellHeight = displayMetrics.heightPixels.toFloat() / rows val viewH = displayMetrics.heightPixels.toFloat()
val metrics = calculateGridMetrics(viewW, viewH, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1) val startCol = ((start.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1) val startRow = ((start.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1) val endCol = ((end.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1) val endRow = ((end.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val minRow = minOf(startRow, endRow) val minRow = minOf(startRow, endRow)
val maxRow = maxOf(startRow, endRow) val maxRow = maxOf(startRow, endRow)
@@ -0,0 +1,168 @@
package org.pubvm.a3270
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.lib3270j.TerminalModel
import org.lib3270j.charset.EbcdicTranslator
import org.lib3270j.input.InputProcessor
import org.lib3270j.protocol.DS3270Constants.*
import org.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)
}
}
}