From 18c4203c0d5f95d1852864d2574cefa52b4cbebb Mon Sep 17 00:00:00 2001 From: Rudi Date: Fri, 28 Aug 2026 14:13:41 -0400 Subject: [PATCH] Add lib3270j updates --- build.gradle | 1 + .../java/haus/nightmare/a3270/MainActivity.kt | 105 +++- .../haus/nightmare/a3270/TerminalViewModel.kt | 112 ++++- .../nightmare/a3270/storage/AppSettings.kt | 9 + .../nightmare/a3270/storage/HostStorage.kt | 7 +- .../haus/nightmare/a3270/ui/ConnectDialog.kt | 99 +++- .../a3270/ui/FieldInspectorDialog.kt | 212 ++++++++ .../nightmare/a3270/ui/FileTransferDialog.kt | 48 +- .../haus/nightmare/a3270/ui/FindDialog.kt | 181 +++++++ .../haus/nightmare/a3270/ui/FunctionKeyBar.kt | 90 +++- .../nightmare/a3270/ui/HostDirectoryDialog.kt | 310 ++++++++++++ .../haus/nightmare/a3270/ui/OiaStatusBar.kt | 19 + .../a3270/ui/PrinterSessionDialog.kt | 459 ++++++++++++++++++ .../a3270/ui/ScreenExporterDialog.kt | 446 +++++++++++++++++ .../haus/nightmare/a3270/ui/ScriptDialog.kt | 210 ++++++++ .../haus/nightmare/a3270/ui/SettingsDialog.kt | 105 +++- .../haus/nightmare/a3270/ui/TerminalView.kt | 13 +- .../nightmare/a3270/CodePageAndStorageTest.kt | 91 ++++ .../haus/nightmare/a3270/EclAndSearchTest.kt | 102 ++++ .../nightmare/a3270/ScriptAndPrinterTest.kt | 67 +++ 20 files changed, 2658 insertions(+), 28 deletions(-) create mode 100644 src/main/java/haus/nightmare/a3270/ui/FieldInspectorDialog.kt create mode 100644 src/main/java/haus/nightmare/a3270/ui/FindDialog.kt create mode 100644 src/main/java/haus/nightmare/a3270/ui/HostDirectoryDialog.kt create mode 100644 src/main/java/haus/nightmare/a3270/ui/PrinterSessionDialog.kt create mode 100644 src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt create mode 100644 src/main/java/haus/nightmare/a3270/ui/ScriptDialog.kt create mode 100644 src/test/java/haus/nightmare/a3270/CodePageAndStorageTest.kt create mode 100644 src/test/java/haus/nightmare/a3270/EclAndSearchTest.kt create mode 100644 src/test/java/haus/nightmare/a3270/ScriptAndPrinterTest.kt diff --git a/build.gradle b/build.gradle index 962da0b..ab49bec 100644 --- a/build.gradle +++ b/build.gradle @@ -71,5 +71,6 @@ dependencies { 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' } diff --git a/src/main/java/haus/nightmare/a3270/MainActivity.kt b/src/main/java/haus/nightmare/a3270/MainActivity.kt index c9586c2..95a622c 100644 --- a/src/main/java/haus/nightmare/a3270/MainActivity.kt +++ b/src/main/java/haus/nightmare/a3270/MainActivity.kt @@ -33,6 +33,11 @@ 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 @@ -401,6 +406,9 @@ fun MainScreen( 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() @@ -410,6 +418,11 @@ fun MainScreen( 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) } var terminalInputViewRef by remember { mutableStateOf(null) } @@ -440,7 +453,8 @@ fun MainScreen( autoHost.useTls, autoHost.tlsVerifyCert, autoHost.tn3270e, - autoHost.graphicsMode + autoHost.graphicsMode, + autoHost.codePage ) } } @@ -477,6 +491,8 @@ fun MainScreen( isLightPenMode = isLightPenMode, maskHiddenFields = maskHiddenInput, blinkCursor = cursorBlink, + searchHighlightAddr = searchHighlightAddr, + searchHighlightLen = searchHighlightLen, onTapAddress = { addr -> viewModel.setCursor(addr) terminalInputViewRef?.showSoftKeyboard() @@ -534,6 +550,11 @@ fun MainScreen( 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 -> @@ -552,6 +573,22 @@ fun MainScreen( viewModel.backTab() terminalInputViewRef?.showSoftKeyboard() }, + onEraseInput = { + viewModel.eraseInput() + terminalInputViewRef?.showSoftKeyboard() + }, + onAttn = { + viewModel.sendAid(AID_PA1) + terminalInputViewRef?.showSoftKeyboard() + }, + onSysReq = { + viewModel.sendAid(AID_SYSREQ) + terminalInputViewRef?.showSoftKeyboard() + }, + onCursorSelect = { + viewModel.sendAid(AID_SELECT) + terminalInputViewRef?.showSoftKeyboard() + }, onCursorLeft = { viewModel.cursorLeft() terminalInputViewRef?.showSoftKeyboard() @@ -582,6 +619,7 @@ fun MainScreen( isTls = isTlsActive, isTlsVerified = isTlsVerified, graphicsMode = defaultGraphicsMode, + codePage = codePage, isLightPenMode = isLightPenMode ) } @@ -590,9 +628,9 @@ fun MainScreen( if (showConnectDialog) { ConnectDialog( onDismiss = { showConnectDialog = false }, - onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode -> + onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp -> showConnectDialog = false - viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode) + viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp) terminalInputViewRef?.showSoftKeyboard() } ) @@ -602,6 +640,7 @@ fun MainScreen( FileTransferDialog( initialHostType = viewModel.activeHostType, ftProgressState = ftState, + client = viewModel.getClient(), onDismiss = { showFtDialog = false }, onStartTransfer = { config -> viewModel.startFileTransfer(config) { error -> @@ -623,15 +662,71 @@ fun MainScreen( initialHapticFeedback = hapticFeedback, initialVerifyCerts = verifyCerts, initialDefaultGraphicsMode = defaultGraphicsMode, + initialDefaultCodePage = codePage, onDismiss = { showSettingsDialog = false }, - onSave = { mask, blink, haptic, verify, gfx -> + onSave = { mask, blink, haptic, verify, gfx, defaultCp -> showSettingsDialog = false - viewModel.updateSettings(mask, blink, haptic, verify, gfx) + viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp) terminalInputViewRef?.showSoftKeyboard() } ) } + if (showFieldInspectorDialog) { + FieldInspectorDialog( + client = viewModel.getClient(), + screenVersion = screenVersion, + onDismiss = { showFieldInspectorDialog = false }, + onJumpToField = { addr -> + viewModel.setCursor(addr) + terminalInputViewRef?.showSoftKeyboard() + } + ) + } + + if (showFindDialog) { + FindDialog( + 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( diff --git a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt index f432224..9763b48 100644 --- a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt +++ b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt @@ -116,6 +116,15 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application)) val defaultGraphicsMode: StateFlow = _defaultGraphicsMode.asStateFlow() + private val _codePage = MutableStateFlow(AppSettings.getDefaultCodePage(application)) + val codePage: StateFlow = _codePage.asStateFlow() + + private val _searchHighlightAddr = MutableStateFlow(-1) + val searchHighlightAddr: StateFlow = _searchHighlightAddr.asStateFlow() + + private val _searchHighlightLen = MutableStateFlow(0) + val searchHighlightLen: StateFlow = _searchHighlightLen.asStateFlow() + private val _isTlsActive = MutableStateFlow(false) val isTlsActive: StateFlow = _isTlsActive.asStateFlow() @@ -136,6 +145,26 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application _isLightPenMode.value = enabled } + 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 private var fileTransferCoordinator: FileTransfer? = null private val _ftState = MutableStateFlow(FTProgressState()) @@ -269,17 +298,92 @@ 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.setCursorBlinkEnabled(getApplication(), blink) AppSettings.setHapticFeedbackEnabled(getApplication(), haptic) AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal) AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal) + AppSettings.setDefaultCodePage(getApplication(), defaultCodePageVal) _maskHiddenInput.value = maskHidden _cursorBlink.value = blink _hapticFeedback.value = haptic _verifyCerts.value = verifyCertsVal _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 + + var startRow = curRow + var startCol = curCol + if (forward) { + startCol = (curCol + 1) % cols + if (startCol == 0) startRow = (curRow + 1) % rows + } else { + startCol = (curCol - 1 + cols) % cols + if (startCol == cols - 1) startRow = (curRow - 1 + rows) % rows + } + + 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( @@ -291,7 +395,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application useTls: Boolean = false, tlsVerifyCert: Boolean = true, tn3270e: Boolean = true, - graphicsModeStr: String = "BOTH" + graphicsModeStr: String = "BOTH", + codePageStr: String = "037" ) { currentHost = host activeHostType = hostType @@ -299,6 +404,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application hasInitialScreenLoaded = false fileTransferCoordinator = null _ftState.value = FTProgressState() + _codePage.value = codePageStr + clearSearchHighlight() viewModelScope.launch(Dispatchers.IO) { try { @@ -339,6 +446,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application isTlsVerifyCert = effectiveVerify isTn3270eEnabled = effectiveTn3270e graphicsMode = gMode + codePage = codePageStr } config.certificateVerifier = haus.nightmare.lib3270j.tls.TlsCertificateVerifier { chain, _, exception -> diff --git a/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt b/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt index e41201c..d78b9a1 100644 --- a/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt +++ b/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt @@ -11,6 +11,7 @@ object AppSettings { 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" private fun getPrefs(context: Context): SharedPreferences { return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -55,4 +56,12 @@ object AppSettings { 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() + } } diff --git a/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt b/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt index d252f4e..98685c7 100644 --- a/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt +++ b/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt @@ -18,7 +18,8 @@ data class SavedHost( val useTls: Boolean = false, val tlsVerifyCert: Boolean = true, val tn3270e: Boolean = true, - val graphicsMode: String = "BOTH" + val graphicsMode: String = "BOTH", + val codePage: String = "037" ) { fun toJson(): JSONObject { return JSONObject().apply { @@ -34,6 +35,7 @@ data class SavedHost( put("tlsVerifyCert", tlsVerifyCert) put("tn3270e", tn3270e) put("graphicsMode", graphicsMode) + put("codePage", codePage) } } @@ -51,7 +53,8 @@ data class SavedHost( useTls = json.optBoolean("useTls", false), tlsVerifyCert = json.optBoolean("tlsVerifyCert", true), tn3270e = json.optBoolean("tn3270e", true), - graphicsMode = json.optString("graphicsMode", "BOTH") + graphicsMode = json.optString("graphicsMode", "BOTH"), + codePage = json.optString("codePage", "037") ) } } diff --git a/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt b/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt index ce292eb..086b3ee 100644 --- a/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt @@ -47,7 +47,8 @@ fun ConnectDialog( useTls: Boolean, tlsVerifyCert: Boolean, tn3270e: Boolean, - graphicsMode: String + graphicsMode: String, + codePage: String ) -> Unit ) { val context = LocalContext.current @@ -65,6 +66,7 @@ fun ConnectDialog( 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)) } fun loadProfile(saved: SavedHost) { selectedHostId = saved.id @@ -79,6 +81,7 @@ fun ConnectDialog( tlsVerifyCert = saved.tlsVerifyCert tn3270e = saved.tn3270e graphicsMode = saved.graphicsMode + codePage = saved.codePage } fun clearFields() { @@ -94,6 +97,7 @@ fun ConnectDialog( tlsVerifyCert = true tn3270e = true graphicsMode = "BOTH" + codePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context) } fun saveCurrentProfile(): SavedHost? { @@ -113,7 +117,8 @@ fun ConnectDialog( useTls = useTls, tlsVerifyCert = tlsVerifyCert, tn3270e = tn3270e, - graphicsMode = graphicsMode + graphicsMode = graphicsMode, + codePage = codePage ) HostStorage.saveHost(context, hostToSave) savedHosts = HostStorage.getSavedHosts(context) @@ -245,7 +250,8 @@ fun ConnectDialog( hostToConn.useTls, hostToConn.tlsVerifyCert, hostToConn.tn3270e, - hostToConn.graphicsMode + hostToConn.graphicsMode, + hostToConn.codePage ) }, modifier = Modifier.size(32.dp) @@ -426,6 +432,90 @@ fun ConnectDialog( } } + // Code Page Selector + var codePageMenuExpanded by remember { mutableStateOf(false) } + val codePagesList = 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", + "870" to "870 - Eastern Europe / Latin-2", + "871" to "871 - Iceland", + "875" to "875 - Greece (Greek)", + "1026" to "1026 - Turkey (Turkish)", + "1140" to "1140 - US / Canada (Euro €)", + "1141" to "1141 - Germany / Austria (Euro €)", + "1148" to "1148 - International (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", + "933" to "933 - Korean Mixed DBCS" + ) + 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 }, @@ -554,7 +644,8 @@ fun ConnectDialog( saved.useTls, saved.tlsVerifyCert, saved.tn3270e, - saved.graphicsMode + saved.graphicsMode, + saved.codePage ) } }, diff --git a/src/main/java/haus/nightmare/a3270/ui/FieldInspectorDialog.kt b/src/main/java/haus/nightmare/a3270/ui/FieldInspectorDialog.kt new file mode 100644 index 0000000..c272800 --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/FieldInspectorDialog.kt @@ -0,0 +1,212 @@ +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>(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("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(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) + } + } + } + } + } +} diff --git a/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt b/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt index eeabe4b..01aa4c5 100644 --- a/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt @@ -28,6 +28,7 @@ private val TerminalKeyboardOptions = KeyboardOptions( fun FileTransferDialog( initialHostType: String = "TSO", ftProgressState: FTProgressState = FTProgressState(), + client: haus.nightmare.lib3270j.Telnet3270Client? = null, onDismiss: () -> Unit, onStartTransfer: (FTConfig) -> Unit, onCancelTransfer: () -> Unit = {} @@ -47,6 +48,20 @@ fun FileTransferDialog( var lreclStr by remember { mutableStateOf("") } var blksizeStr by remember { mutableStateOf("") } var overwrite by remember { mutableStateOf(true) } + var showBrowseDialog by remember { mutableStateOf(false) } + + if (showBrowseDialog) { + HostDirectoryDialog( + client = client, + initialHostType = hostType, + initialQuery = hostFile, + onDismiss = { showBrowseDialog = false }, + onSelectDataset = { selected -> + hostFile = selected + showBrowseDialog = false + } + ) + } AlertDialog( onDismissRequest = onDismiss, @@ -105,15 +120,30 @@ fun FileTransferDialog( ) } - 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.fillMaxWidth() - ) + 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) + } + } + } OutlinedTextField( value = localFile, diff --git a/src/main/java/haus/nightmare/a3270/ui/FindDialog.kt b/src/main/java/haus/nightmare/a3270/ui/FindDialog.kt new file mode 100644 index 0000000..bd24923 --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/FindDialog.kt @@ -0,0 +1,181 @@ +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( + 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 row = (pos / 80) + 1 + val col = (pos % 80) + 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") + } + } + } + } + } +} diff --git a/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt b/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt index 57c5bbd..6f3fe8b 100644 --- a/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt +++ b/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt @@ -28,12 +28,21 @@ fun TwoRowKeyBar( onConnectClick: () -> Unit, onDisconnectClick: () -> Unit, onFtClick: () -> Unit, + onFieldInspectorClick: () -> Unit = {}, + onFindClick: () -> Unit = {}, + onScreenExportClick: () -> Unit = {}, + onScriptClick: () -> Unit = {}, + onPrinterSessionClick: () -> Unit = {}, onToggleLightPen: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onSendAid: (Int) -> Unit, onReset: () -> Unit, onTab: () -> Unit, onBackTab: () -> Unit, + onEraseInput: () -> Unit = {}, + onAttn: () -> Unit = {}, + onSysReq: () -> Unit = {}, + onCursorSelect: () -> Unit = {}, onCursorLeft: () -> Unit = {}, onCursorUp: () -> Unit = {}, onCursorDown: () -> Unit = {}, @@ -55,7 +64,7 @@ fun TwoRowKeyBar( .padding(horizontal = 1.dp, vertical = 1.dp), verticalAlignment = Alignment.CenterVertically ) { - // 1. Menu Dropdown Button (Connect/Disconnect/FT/Light Pen/Settings) + // 1. Menu Dropdown Button (Connect/Disconnect/FT/Diagnostics/Tools/Settings) Box(modifier = Modifier.weight(1f)) { KeyButton( label = "⋮", @@ -68,7 +77,9 @@ fun TwoRowKeyBar( DropdownMenu( expanded = menuExpanded, onDismissRequest = { menuExpanded = false }, - modifier = Modifier.background(Color(0xFF2C2D30)) + modifier = Modifier + .background(Color(0xFF2C2D30)) + .heightIn(max = 420.dp) ) { if (!connectionState.isConnected()) { DropdownMenuItem( @@ -78,6 +89,13 @@ fun TwoRowKeyBar( onConnectClick() } ) + DropdownMenuItem( + text = { Text("3287 Printer Session", color = Color.White) }, + onClick = { + menuExpanded = false + onPrinterSessionClick() + } + ) } else { DropdownMenuItem( text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) }, @@ -86,13 +104,79 @@ fun TwoRowKeyBar( onDisconnectClick() } ) + HorizontalDivider(color = Color(0xFF3E4044)) 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 = { menuExpanded = false 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 = { diff --git a/src/main/java/haus/nightmare/a3270/ui/HostDirectoryDialog.kt b/src/main/java/haus/nightmare/a3270/ui/HostDirectoryDialog.kt new file mode 100644 index 0000000..7ad2b84 --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/HostDirectoryDialog.kt @@ -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>(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") + } + } + } + } + } +} diff --git a/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt b/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt index 613c28f..0548e15 100644 --- a/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt +++ b/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt @@ -27,6 +27,7 @@ fun OiaStatusBar( isTls: Boolean = false, isTlsVerified: Boolean = true, graphicsMode: String = "NONE", + codePage: String = "037", isLightPenMode: Boolean = false, modifier: Modifier = Modifier ) { @@ -121,6 +122,24 @@ fun OiaStatusBar( } } + // CodePage Badge + if (connectionState.isConnected() && codePage.isNotBlank()) { + Spacer(modifier = Modifier.width(6.dp)) + Surface( + color = Color(0xFFFA5252).copy(alpha = 0.2f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "CP$codePage", + color = Color(0xFFFF8787), + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp) + ) + } + } + // Light Pen Badge if (isLightPenMode) { Spacer(modifier = Modifier.width(6.dp)) diff --git a/src/main/java/haus/nightmare/a3270/ui/PrinterSessionDialog.kt b/src/main/java/haus/nightmare/a3270/ui/PrinterSessionDialog.kt new file mode 100644 index 0000000..044ee58 --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/PrinterSessionDialog.kt @@ -0,0 +1,459 @@ +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 = "127.0.0.1", + initialPort: Int = 23, + initialCodePage: String = "037", + initialUseTls: Boolean = false, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + + var host by remember { mutableStateOf(initialHost.ifBlank { "127.0.0.1" }) } + var portStr by remember { mutableStateOf(if (initialPort > 0) initialPort.toString() else "23") } + var printerLu by remember { mutableStateOf("") } + var assocDisplayLu by remember { mutableStateOf("") } + var codePage by remember { mutableStateOf(initialCodePage) } + var useTls by remember { mutableStateOf(initialUseTls) } + + 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(null) } + + val codePagesList = listOf( + "037" to "037 - US / Canada", + "1047" to "1047 - Open Systems / Unix", + "500" to "500 - International", + "273" to "273 - Germany", + "277" to "277 - Denmark / Norway", + "278" to "278 - Sweden / Finland", + "280" to "280 - Italy", + "284" to "284 - Spain", + "285" to "285 - United Kingdom", + "297" to "297 - France", + "870" to "870 - Eastern Europe", + "1140" to "1140 - US (Euro €)", + "1141" to "1141 - Germany (Euro €)", + "1148" to "1148 - Intl (Euro €)", + "930" to "930 - Japanese Katakana DBCS", + "939" to "939 - Japanese Latin DBCS" + ) + + fun startSession() { + val port = portStr.trim().toIntOrNull() ?: 23 + val config = PrinterConfig(host.trim(), 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 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 = "${spoolText.replace("<", "<").replace(">", ">")}" + 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") }, + singleLine = true, + modifier = Modifier.weight(2.5f) + ) + OutlinedTextField( + value = portStr, + onValueChange = { portStr = it }, + label = { Text("Port") }, + 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) + ) + } + + // Code Page & TLS Row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + var cpExpanded by remember { mutableStateOf(false) } + Box { + OutlinedButton(onClick = { cpExpanded = true }) { + Text("Code Page: CP$codePage", fontSize = 11.sp) + } + 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 }) + Spacer(modifier = Modifier.width(4.dp)) + Text("TLS", fontSize = 12.sp, color = Color.White) + } + + Button( + onClick = { startSession() }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) + ) { + Text("Connect Printer") + } + } + } 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) + } + } + } + } + } + } +} diff --git a/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt b/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt new file mode 100644 index 0000000..a451687 --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt @@ -0,0 +1,446 @@ +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 "
"
+    val sb = StringBuilder()
+    sb.append("\n\n\n")
+    sb.append("\n")
+    sb.append("\n\n\n
\n")
+
+    var currentHidden = false
+    var currentProt = false
+    var currentHi = false
+
+    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
+                sb.append(' ')
+            } else {
+                if (currentHidden && maskHidden) {
+                    sb.append(' ')
+                } else {
+                    val ch = cell.ucs4
+                    val colorClass = when {
+                        currentHi -> "c-white"
+                        currentProt -> "c-blue"
+                        else -> "c-green"
+                    }
+                    val escChar = when (ch) {
+                        '<' -> "<"
+                        '>' -> ">"
+                        '&' -> "&"
+                        '"' -> """
+                        else -> if (ch in ' '..'~' || ch > '\u007F') ch.toString() else " "
+                    }
+                    sb.append("$escChar")
+                }
+            }
+        }
+        sb.append("\n")
+    }
+
+    sb.append("
\n\n") + 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 + + val textPaint = Paint().apply { + isAntiAlias = true + typeface = Typeface.MONOSPACE + textSize = 18f + } + + var currentHidden = false + var currentProt = false + var currentHi = false + + 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 + } else if (!currentHidden || !maskHidden) { + val ch = cell.ucs4 + if (ch in ' '..'~' || ch > '\u007F') { + textPaint.color = when { + currentHi -> android.graphics.Color.WHITE + currentProt -> android.graphics.Color.parseColor("#3399FF") + else -> android.graphics.Color.parseColor("#00FF66") + } + canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint) + } + } + } + } + + // Overlay vector graphics plane if present + if (graphicsPlane != null && graphicsPlane.hasContent()) { + val rgb = graphicsPlane.rgbBuffer + val gWidth = graphicsPlane.canvasWidth + val gHeight = graphicsPlane.canvasHeight + if (rgb != null && gWidth > 0 && gHeight > 0) { + val gfxBmp = Bitmap.createBitmap(rgb, gWidth, gHeight, Bitmap.Config.ARGB_8888) + val scaledGfx = Bitmap.createScaledBitmap(gfxBmp, width, height, true) + canvas.drawBitmap(scaledGfx, 0f, 0f, null) + } + } + + return bitmap +} diff --git a/src/main/java/haus/nightmare/a3270/ui/ScriptDialog.kt b/src/main/java/haus/nightmare/a3270/ui/ScriptDialog.kt new file mode 100644 index 0000000..5375fed --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/ScriptDialog.kt @@ -0,0 +1,210 @@ +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(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]" + ).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 ▶") + } + } + } + } + } + } +} diff --git a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt index 6208c2c..0069b46 100644 --- a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt @@ -1,5 +1,6 @@ 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 @@ -24,14 +25,16 @@ fun SettingsDialog( initialHapticFeedback: Boolean = true, initialVerifyCerts: Boolean = true, initialDefaultGraphicsMode: String = "BOTH", + initialDefaultCodePage: String = "037", onDismiss: () -> Unit, - onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String) -> Unit + onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String, defaultCodePage: 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) } + var defaultCodePage by remember { mutableStateOf(initialDefaultCodePage) } Dialog( onDismissRequest = onDismiss, @@ -242,6 +245,104 @@ fun SettingsDialog( } } + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(color = Color(0xFF2C2D30)) + Spacer(modifier = Modifier.height(16.dp)) + + // Setting 6: Default EBCDIC Code Page + var codePageMenuExpanded by remember { mutableStateOf(false) } + val codePagesList = 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", + "870" to "870 - Eastern Europe / Latin-2", + "871" to "871 - Iceland", + "875" to "875 - Greece (Greek)", + "1026" to "1026 - Turkey (Turkish)", + "1140" to "1140 - US / Canada (Euro €)", + "1141" to "1141 - Germany / Austria (Euro €)", + "1148" to "1148 - International (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", + "933" to "933 - Korean Mixed DBCS" + ) + val selectedCpLabel = codePagesList.firstOrNull { it.first == defaultCodePage }?.second ?: "Code Page $defaultCodePage" + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Default EBCDIC Code Page", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Text( + text = "Character encoding translation table for new sessions.", + fontSize = 12.sp, + color = Color.LightGray + ) + Spacer(modifier = Modifier.height(8.dp)) + Box(modifier = Modifier.fillMaxWidth()) { + 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(24.dp)) // Action Buttons @@ -255,7 +356,7 @@ fun SettingsDialog( Spacer(modifier = Modifier.width(8.dp)) Button( onClick = { - onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode) + onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode, defaultCodePage) }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) ) { diff --git a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt index af3e5b2..108273d 100644 --- a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt +++ b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt @@ -64,6 +64,8 @@ fun TerminalView( isLightPenMode: Boolean = false, maskHiddenFields: Boolean = true, blinkCursor: Boolean = true, + searchHighlightAddr: Int = -1, + searchHighlightLen: Int = 0, onTapAddress: (Int) -> Unit, onLightPenSelect: ((Int) -> Unit)? = null, onGraphicTouch: ((Int, Int) -> Unit)? = null, @@ -314,7 +316,16 @@ fun TerminalView( // Draw selection box highlight if selected val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol - if (isSelected) { + val isSearchHighlighted = searchHighlightAddr >= 0 && searchHighlightLen > 0 && + addr >= searchHighlightAddr && addr < (searchHighlightAddr + searchHighlightLen) + + if (isSearchHighlighted) { + drawRect( + color = Color(0x99FFC107), // Amber / Gold highlight for search matches + topLeft = Offset(left, top), + size = Size(cellWidth, cellHeight) + ) + } else if (isSelected) { drawRect( color = Color(0x773399FF), topLeft = Offset(left, top), diff --git a/src/test/java/haus/nightmare/a3270/CodePageAndStorageTest.kt b/src/test/java/haus/nightmare/a3270/CodePageAndStorageTest.kt new file mode 100644 index 0000000..2fe70b9 --- /dev/null +++ b/src/test/java/haus/nightmare/a3270/CodePageAndStorageTest.kt @@ -0,0 +1,91 @@ +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" + ) + + val json = host.toJson() + assertEquals("1047", json.getString("codePage")) + assertEquals("Test Mainframe", json.getString("name")) + assertTrue(json.getBoolean("autoConnect")) + + 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) + } + + @Test + fun testSavedHostDefaultCodePageFallback() { + // Old JSON without codePage field + val json = org.json.JSONObject().apply { + put("name", "Legacy") + put("host", "mvs.local") + } + val deserialized = SavedHost.fromJson(json) + assertEquals("037", deserialized.codePage) + } + + @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) + } +} diff --git a/src/test/java/haus/nightmare/a3270/EclAndSearchTest.kt b/src/test/java/haus/nightmare/a3270/EclAndSearchTest.kt new file mode 100644 index 0000000..b7434db --- /dev/null +++ b/src/test/java/haus/nightmare/a3270/EclAndSearchTest.kt @@ -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) + } +} diff --git a/src/test/java/haus/nightmare/a3270/ScriptAndPrinterTest.kt b/src/test/java/haus/nightmare/a3270/ScriptAndPrinterTest.kt new file mode 100644 index 0000000..bc48579 --- /dev/null +++ b/src/test/java/haus/nightmare/a3270/ScriptAndPrinterTest.kt @@ -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) + } +}