7 Commits

Author SHA1 Message Date
rudi a7cc4b9d50 v1
Build and Test a3270 / Build Android APK (push) Successful in 7m29s
Release a3270 / Build & Publish Release (push) Successful in 7m18s
2026-08-31 00:28:47 +00:00
rudi 600b626e94 And now with more buttons
Build and Test a3270 / Build Android APK (push) Successful in 7m2s
2026-08-28 14:17:25 -04:00
rudi 18c4203c0d Add lib3270j updates
Build and Test a3270 / Build Android APK (push) Successful in 6m57s
2026-08-28 14:13:41 -04:00
rudi f6d69bf0ff Refactor for consistency
Build and Test a3270 / Build Android APK (push) Successful in 6m54s
2026-08-25 16:05:28 +00:00
rudi aa008664bc Fix bug with xedit
Build and Test a3270 / Build Android APK (push) Successful in 4m37s
2026-08-25 01:12:09 +00:00
rudi 8344b9f4f7 j3270 updates applied for lightpen
Build and Test a3270 / Build Android APK (push) Successful in 4m34s
2026-08-24 20:46:09 +00:00
rudi 052a6619ae Add some cleaning
Build and Test a3270 / Build Android APK (push) Successful in 6m39s
2026-08-21 18:56:22 -04:00
29 changed files with 3248 additions and 150 deletions
+1 -9
View File
@@ -2,7 +2,7 @@
[![Build & Test](https://git.hugfreevikings.wtf/rudi/a3270/actions/workflows/build.yaml/badge.svg)](https://git.hugfreevikings.wtf/rudi/a3270/actions)
[![Android](https://img.shields.io/badge/Android-API%2024%2B%20%7C%20Compose-green.svg)](https://developer.android.com)
[![License](https://img.shields.io/badge/License-MIT%20%2F%20BSD-green.svg)](LICENSE)
[![License: Unlicense](https://img.shields.io/badge/License-Unlicense-blue.svg)](LICENSE)
An **x3270-aligned IBM 3270 mainframe terminal emulator for Android**, written in Kotlin using **Jetpack Compose** and powered by the `lib3270j` protocol engine from [j3270](https://git.hugfreevikings.wtf/rudi/j3270). Designed for mobile access to IBM z/OS, z/VM, CMS, and TSO systems over TN3270 and TN3270E.
@@ -93,14 +93,6 @@ build/outputs/apk/debug/a3270-debug.apk
---
## 🤝 Contributing
Contributions are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
I have contributed no code to this project, it was entirely written by LLMs with my guidance only.
---
## 📜 Acknowledgements
- [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine
+5 -4
View File
@@ -4,15 +4,15 @@ plugins {
}
android {
namespace 'org.pubvm.a3270'
namespace 'haus.nightmare.a3270'
compileSdk 34
defaultConfig {
applicationId "org.pubvm.a3270"
applicationId "haus.nightmare.a3270"
minSdk 24
targetSdk 34
versionCode 4
versionName "0.1.3"
versionCode 5
versionName "1.0.0"
}
buildTypes {
@@ -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'
}
+6 -2
View File
@@ -9,9 +9,11 @@ echo "=== Building a3270 (Android APK) ==="
# Set up JAVA_HOME (prefer JDK 21 if available)
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
elif [ -d "$HOME/.sdkman/candidates/java/21.0.2-open" ]; then
export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.2-open"
elif [ -z "$JAVA_HOME" ]; then
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
if [ -d "$HOME/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="$HOME/.sdkman/candidates/java/current"
fi
fi
@@ -19,6 +21,8 @@ fi
if [ -z "$ANDROID_HOME" ]; then
if [ -d "$HOME/Library/Android/sdk" ]; then
export ANDROID_HOME="$HOME/Library/Android/sdk"
elif [ -d "$HOME/Android/Sdk" ]; then
export ANDROID_HOME="$HOME/Android/Sdk"
fi
fi
+1
View File
@@ -1,3 +1,4 @@
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
kotlin.compiler.execution.strategy=in-process
@@ -1,4 +1,4 @@
package org.pubvm.a3270
package haus.nightmare.a3270
import android.content.pm.PackageManager
import android.os.Build
@@ -23,18 +23,23 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import android.content.Context
import android.view.KeyCharacterMap
import org.lib3270j.protocol.DS3270Constants.*
import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.ui.ConnectDialog
import org.pubvm.a3270.ui.FileTransferDialog
import org.pubvm.a3270.ui.OiaStatusBar
import org.pubvm.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TerminalInputView
import org.pubvm.a3270.ui.TerminalView
import org.pubvm.a3270.ui.TwoRowKeyBar
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.ui.ConnectDialog
import haus.nightmare.a3270.ui.FileTransferDialog
import haus.nightmare.a3270.ui.OiaStatusBar
import haus.nightmare.a3270.ui.SettingsDialog
import haus.nightmare.a3270.ui.TerminalInputView
import haus.nightmare.a3270.ui.TerminalView
import haus.nightmare.a3270.ui.TwoRowKeyBar
import haus.nightmare.a3270.ui.FieldInspectorDialog
import haus.nightmare.a3270.ui.FindDialog
import haus.nightmare.a3270.ui.ScreenExporterDialog
import haus.nightmare.a3270.ui.ScriptDialog
import haus.nightmare.a3270.ui.PrinterSessionDialog
import org.pubvm.a3270.ui.UntrustedCertificateDialog
import haus.nightmare.a3270.ui.UntrustedCertificateDialog
class MainActivity : ComponentActivity() {
@@ -144,6 +149,7 @@ class MainActivity : ComponentActivity() {
return 0
}
@Suppress("DEPRECATION")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) {
@@ -243,6 +249,12 @@ class MainActivity : ComponentActivity() {
viewModel.resetKeyboard()
return true
}
KeyEvent.KEYCODE_L -> {
viewModel.toggleLightPen()
val isLp = viewModel.isLightPenMode.value
Toast.makeText(this, "Light Pen: " + (if (isLp) "ON" else "OFF"), Toast.LENGTH_SHORT).show()
return true
}
}
}
@@ -287,7 +299,12 @@ class MainActivity : ComponentActivity() {
return true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (shift) {
viewModel.eraseEof()
isShiftPressedState.value = false
} else {
viewModel.sendAid(AID_ENTER)
}
return true
}
KeyEvent.KEYCODE_TAB -> {
@@ -309,7 +326,7 @@ class MainActivity : ComponentActivity() {
}
KeyEvent.KEYCODE_ESCAPE -> {
if (shift) {
viewModel.sendAid(AID_CLEAR)
viewModel.eraseInput()
isShiftPressedState.value = false
} else {
viewModel.resetKeyboard()
@@ -395,14 +412,23 @@ 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()
val ftState by viewModel.ftState.collectAsState()
val isLightPenMode by viewModel.isLightPenMode.collectAsState()
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<TerminalInputView?>(null) }
@@ -433,7 +459,8 @@ fun MainScreen(
autoHost.useTls,
autoHost.tlsVerifyCert,
autoHost.tn3270e,
autoHost.graphicsMode
autoHost.graphicsMode,
autoHost.codePage
)
}
}
@@ -466,12 +493,23 @@ fun MainScreen(
screenVersion = screenVersion,
programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane,
gocaDecoder = viewModel.getGocaDecoder(),
isLightPenMode = isLightPenMode,
maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink,
searchHighlightAddr = searchHighlightAddr,
searchHighlightLen = searchHighlightLen,
onTapAddress = { addr ->
viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onLightPenSelect = { addr ->
viewModel.selectLightPen(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onGraphicTouch = { clickAddr, px, py ->
viewModel.handleGraphicTouch(clickAddr, px, py)
},
onPasteText = { text ->
viewModel.pasteString(text)
},
@@ -512,11 +550,18 @@ fun MainScreen(
TwoRowKeyBar(
connectionState = connectionState,
isShiftPressed = isShiftPressed,
isLightPenMode = isLightPenMode,
hapticFeedbackEnabled = hapticFeedback,
onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true },
onFieldInspectorClick = { showFieldInspectorDialog = true },
onFindClick = { showFindDialog = true },
onScreenExportClick = { showScreenExportDialog = true },
onScriptClick = { showScriptDialog = true },
onPrinterSessionClick = { showPrinterSessionDialog = true },
onToggleLightPen = { viewModel.toggleLightPen() },
onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid ->
viewModel.sendAid(aid)
@@ -534,6 +579,26 @@ fun MainScreen(
viewModel.backTab()
terminalInputViewRef?.showSoftKeyboard()
},
onEraseInput = {
viewModel.eraseInput()
terminalInputViewRef?.showSoftKeyboard()
},
onEraseEof = {
viewModel.eraseEof()
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()
@@ -563,7 +628,9 @@ fun MainScreen(
cols = cols,
isTls = isTlsActive,
isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode
graphicsMode = defaultGraphicsMode,
codePage = codePage,
isLightPenMode = isLightPenMode
)
}
}
@@ -571,9 +638,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()
}
)
@@ -583,6 +650,7 @@ fun MainScreen(
FileTransferDialog(
initialHostType = viewModel.activeHostType,
ftProgressState = ftState,
client = viewModel.getClient(),
onDismiss = { showFtDialog = false },
onStartTransfer = { config ->
viewModel.startFileTransfer(config) { error ->
@@ -604,15 +672,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(
@@ -1,4 +1,4 @@
package org.pubvm.a3270
package haus.nightmare.a3270
import android.app.Application
import androidx.lifecycle.AndroidViewModel
@@ -11,19 +11,19 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.lib3270j.ConnectionConfig
import org.lib3270j.ConnectionState
import org.lib3270j.Telnet3270Client
import org.lib3270j.TerminalModel
import org.lib3270j.ft.FTConfig
import org.lib3270j.listener.ConnectionListener
import org.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import org.lib3270j.protocol.DS3270Constants.faIsProtected
import org.lib3270j.screen.ScreenBuffer
import org.pubvm.a3270.ft.FileTransfer
import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.AppSettings
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.ConnectionState
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.listener.ConnectionListener
import haus.nightmare.lib3270j.listener.ScreenUpdateListener
import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
import haus.nightmare.lib3270j.protocol.DS3270Constants.faIsProtected
import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.a3270.ft.FileTransfer
import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings
import java.io.File
import java.util.concurrent.Executors
import java.util.logging.Logger
@@ -45,6 +45,7 @@ sealed interface TerminalInputAction {
data object Newline : TerminalInputAction
data object Reset : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(val baddr: Int) : TerminalInputAction
}
data class UntrustedCertPromptState(
@@ -115,6 +116,15 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application))
val defaultGraphicsMode: StateFlow<String> = _defaultGraphicsMode.asStateFlow()
private val _codePage = MutableStateFlow(AppSettings.getDefaultCodePage(application))
val codePage: StateFlow<String> = _codePage.asStateFlow()
private val _searchHighlightAddr = MutableStateFlow(-1)
val searchHighlightAddr: StateFlow<Int> = _searchHighlightAddr.asStateFlow()
private val _searchHighlightLen = MutableStateFlow(0)
val searchHighlightLen: StateFlow<Int> = _searchHighlightLen.asStateFlow()
private val _isTlsActive = MutableStateFlow(false)
val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow()
@@ -124,6 +134,37 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null)
val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow()
private val _isLightPenMode = MutableStateFlow(false)
val isLightPenMode: StateFlow<Boolean> = _isLightPenMode.asStateFlow()
fun toggleLightPen() {
_isLightPenMode.value = !_isLightPenMode.value
}
fun setLightPenMode(enabled: Boolean) {
_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())
@@ -175,16 +216,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
ip.setKeyboardLocked(false)
ip.sendAid(AID_ENTER)
} else if (ch >= ' ') {
var curAddr = buf.cursorAddress
if (buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(curAddr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
curAddr = buf.findNextUnprotected(curAddr)
buf.cursorAddress = curAddr
}
}
ip.typeCharacter(ch)
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(curAddr).ucs4}'")
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(buf.cursorAddress).ucs4}'")
}
}
}
@@ -240,6 +273,13 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
buf.cursorAddress = action.baddr
}
}
is TerminalInputAction.LightPenSelect -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr
val res = ip.lightPenSelect(action.baddr)
log.info("LightPenSelect at addr ${action.baddr}, result=$res")
}
}
}
} catch (e: Exception) {
log.warning("Error processing input action: ${e.message}")
@@ -258,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
val startRow: Int
val startCol: Int
if (forward) {
startCol = (curCol + 1) % cols
startRow = if (startCol == 0) (curRow + 1) % rows else curRow
} else {
startCol = (curCol - 1 + cols) % cols
startRow = if (startCol == cols - 1) (curRow - 1 + rows) % rows else curRow
}
val dir = if (forward) haus.nightmare.lib3270j.ecl.ECLConstants.SEARCH_FORWARD else haus.nightmare.lib3270j.ecl.ECLConstants.SEARCH_BACKWARD
val foundPos = c.ps.searchString(target, startRow, startCol, dir, !matchCase)
if (foundPos >= 0) {
sb.cursorAddress = foundPos
_cursorAddress.value = foundPos
setSearchHighlight(foundPos, target.length)
_screenVersion.value = System.currentTimeMillis()
}
return foundPos
}
fun executeScript(
script: String,
onStatus: (String) -> Unit = {},
onComplete: (String?) -> Unit = {}
) {
val c = client
if (c == null || !_connectionState.value.isConnected()) {
onComplete("Not connected to host.")
return
}
viewModelScope.launch(Dispatchers.IO) {
try {
val cleaned = script.replace("\r\n", "\n").replace("\r", "\n")
val lines = cleaned.split("\n")
for (line in lines) {
val trimmed = line.trim()
if (trimmed.isNotEmpty() && !trimmed.startsWith("#") && !trimmed.startsWith("//")) {
withContext(Dispatchers.Main) { onStatus("Sending: $trimmed") }
c.sendKeys(trimmed)
kotlinx.coroutines.delay(100)
}
}
_screenVersion.value = System.currentTimeMillis()
withContext(Dispatchers.Main) {
onComplete(null)
}
} catch (e: Exception) {
log.warning("Script execution error: ${e.message}")
withContext(Dispatchers.Main) {
onComplete(e.message ?: "Script execution error")
}
}
}
}
fun connect(
@@ -280,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
@@ -288,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 {
@@ -314,7 +432,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val effectiveTn3270e = tn3270e && parsedConfig.isTn3270eEnabled
val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication())
val effectiveVerify = tlsVerifyCert && globalVerify
val gMode = org.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
val gMode = haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
_isTlsActive.value = effectiveTls
_isTlsVerified.value = effectiveVerify
@@ -328,9 +446,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
isTlsVerifyCert = effectiveVerify
isTn3270eEnabled = effectiveTn3270e
graphicsMode = gMode
codePage = codePageStr
}
config.certificateVerifier = org.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
config.certificateVerifier = haus.nightmare.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
if (!AppSettings.isVerifyCertsEnabled(getApplication())) {
log.info("Certificate verification bypassed via global AppSettings")
return@TlsCertificateVerifier true
@@ -627,6 +746,31 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.CursorHome)
}
fun selectLightPen(baddr: Int) {
inputChannel.trySend(TerminalInputAction.LightPenSelect(baddr))
}
fun getGocaDecoder(): haus.nightmare.lib3270j.graphics.GocaDecoder? = client?.gocaDecoder
fun handleGraphicTouch(clickAddr: Int = -1, px: Int, py: Int, button: Int = 1, shift: Boolean = false, ctrl: Boolean = false) {
val c = client ?: return
val goca = c.gocaDecoder ?: return
if (clickAddr >= 0) {
c.screenBuffer?.cursorAddress = clickAddr
_cursorAddress.value = clickAddr
}
if (goca.isGraphicsCursorActive) {
goca.setGraphicCursorFromPixel(px, py)
c.inputProcessor.sendGraphicMouseAid(
AID_ENTER,
button,
shift,
ctrl
)
_screenVersion.value = System.currentTimeMillis()
}
}
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
if (buf == null) return "Mainframe screen update received"
val rows = buf.rows
@@ -1,12 +1,12 @@
package org.pubvm.a3270.ft
package haus.nightmare.a3270.ft
import android.os.Handler
import android.os.Looper
import org.lib3270j.Telnet3270Client
import org.lib3270j.ft.FTConfig
import org.lib3270j.ft.FTConstants.FTState
import org.lib3270j.ft.FTCut
import org.lib3270j.ft.FTDft
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.FTConstants.FTState
import haus.nightmare.lib3270j.ft.FTCut
import haus.nightmare.lib3270j.ft.FTDft
import java.io.File
import java.io.IOException
import java.util.Timer
@@ -1,4 +1,4 @@
package org.pubvm.a3270.service
package haus.nightmare.a3270.service
import android.app.Notification
import android.app.NotificationChannel
@@ -10,8 +10,8 @@ import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import org.pubvm.a3270.MainActivity
import org.pubvm.a3270.R
import haus.nightmare.a3270.MainActivity
import haus.nightmare.a3270.R
class TerminalService : Service() {
@@ -1,4 +1,4 @@
package org.pubvm.a3270.storage
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.SharedPreferences
@@ -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()
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.storage
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.SharedPreferences
@@ -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")
)
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -17,13 +18,22 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.storage.SavedHost
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.storage.SavedHost
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
@Composable
fun ConnectDialog(
@@ -37,7 +47,8 @@ fun ConnectDialog(
useTls: Boolean,
tlsVerifyCert: Boolean,
tn3270e: Boolean,
graphicsMode: String
graphicsMode: String,
codePage: String
) -> Unit
) {
val context = LocalContext.current
@@ -55,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
@@ -69,6 +81,7 @@ fun ConnectDialog(
tlsVerifyCert = saved.tlsVerifyCert
tn3270e = saved.tn3270e
graphicsMode = saved.graphicsMode
codePage = saved.codePage
}
fun clearFields() {
@@ -84,16 +97,18 @@ fun ConnectDialog(
tlsVerifyCert = true
tn3270e = true
graphicsMode = "BOTH"
codePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
}
fun saveCurrentProfile(): SavedHost? {
val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23
if (host.isBlank()) return null
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
val trimmedHost = host.trim()
if (trimmedHost.isBlank()) return null
val nameToSave = profileName.trim().ifBlank { "$trimmedHost:$port" }
val hostToSave = SavedHost(
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
name = nameToSave,
host = host.trim(),
host = trimmedHost,
port = port,
model = modelNum,
luName = luName.trim(),
@@ -102,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)
@@ -234,7 +250,8 @@ fun ConnectDialog(
hostToConn.useTls,
hostToConn.tlsVerifyCert,
hostToConn.tn3270e,
hostToConn.graphicsMode
hostToConn.graphicsMode,
hostToConn.codePage
)
},
modifier = Modifier.size(32.dp)
@@ -273,6 +290,7 @@ fun ConnectDialog(
onValueChange = { profileName = it },
label = { Text("Profile Name (Optional)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
@@ -282,18 +300,20 @@ fun ConnectDialog(
) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
onValueChange = { host = it.filterNot { c -> c.isWhitespace() } },
label = { Text("Host / IP Address") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier
.weight(0.68f)
.focusRequester(focusRequester)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
onValueChange = { portStr = it.filter { c -> c.isDigit() } },
label = { Text("Port") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.32f)
)
}
@@ -412,11 +432,96 @@ 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 },
label = { Text("LU Name / Device Pool (Optional)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
@@ -539,7 +644,8 @@ fun ConnectDialog(
saved.useTls,
saved.tlsVerifyCert,
saved.tn3270e,
saved.graphicsMode
saved.graphicsMode,
saved.codePage
)
}
},
@@ -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<List<ECLField>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var cols by remember { mutableIntStateOf(80) }
var rows by remember { mutableIntStateOf(24) }
fun refresh() {
if (client != null && client.connectionState.isFullSession) {
val fieldList = client.fieldList
fields = fieldList?.fields ?: emptyList()
cols = client.screenBuffer?.cols ?: 80
rows = client.screenBuffer?.rows ?: 24
} else {
fields = emptyList()
}
}
LaunchedEffect(screenVersion, client) {
refresh()
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Presentation Space Field Inspector",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
Text(
"${fields.size} fields detected (${rows}x$cols)",
fontSize = 11.sp,
color = Color.Gray
)
}
TextButton(onClick = { refresh() }) {
Text("Refresh", fontSize = 12.sp, color = Color(0xFF339AF0))
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
if (fields.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
"No fields detected or terminal not in 3270 session.",
color = Color.Gray,
fontSize = 12.sp
)
}
} else {
val horizScroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.horizontalScroll(horizScroll)
) {
// Header Row
Row(
modifier = Modifier
.background(Color(0xFF25262B))
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("#", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(30.dp), textAlign = TextAlign.Center)
Text("Pos", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(42.dp), textAlign = TextAlign.Center)
Text("Start", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("End", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("Len", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Prot", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("MDT", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Num", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Hi-Int", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Hidden", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center)
Text("Pen", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("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)
}
}
}
}
}
}
@@ -1,7 +1,8 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -9,15 +10,25 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.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 org.lib3270j.ft.FTConfig
import org.pubvm.a3270.FTProgressState
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.a3270.FTProgressState
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
@Composable
fun FileTransferDialog(
initialHostType: String = "TSO",
ftProgressState: FTProgressState = FTProgressState(),
client: haus.nightmare.lib3270j.Telnet3270Client? = null,
onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit,
onCancelTransfer: () -> Unit = {}
@@ -37,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,
@@ -95,14 +120,30 @@ fun FileTransferDialog(
)
}
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,
modifier = Modifier.fillMaxWidth()
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,
@@ -111,6 +152,9 @@ fun FileTransferDialog(
placeholder = { Text("sample.txt") },
supportingText = { Text("Relative names will be stored in app Downloads/Files storage", fontSize = 10.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(
imeAction = if (!isReceive && hostType == FTConfig.HostType.TSO) ImeAction.Next else ImeAction.Done
),
modifier = Modifier.fillMaxWidth()
)
@@ -208,16 +252,18 @@ fun FileTransferDialog(
) {
OutlinedTextField(
value = lreclStr,
onValueChange = { lreclStr = it },
onValueChange = { lreclStr = it.filter { c -> c.isDigit() } },
label = { Text("LRECL", fontSize = 11.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it },
onValueChange = { blksizeStr = it.filter { c -> c.isDigit() } },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.weight(1f)
)
}
@@ -237,8 +283,8 @@ fun FileTransferDialog(
setExistAction(if (overwrite) FTConfig.ExistAction.REPLACE else FTConfig.ExistAction.KEEP)
if (!isReceive && hostType == FTConfig.HostType.TSO) {
setRecfm(recfm)
lreclStr.toIntOrNull()?.let { setLrecl(it) }
blksizeStr.toIntOrNull()?.let { setBlksize(it) }
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
}
}
onStartTransfer(config)
@@ -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")
}
}
}
}
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
@@ -15,23 +15,35 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ConnectionState
import org.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.ConnectionState
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
@Composable
fun TwoRowKeyBar(
connectionState: ConnectionState,
isShiftPressed: Boolean = false,
isLightPenMode: Boolean = false,
hapticFeedbackEnabled: Boolean = true,
onClearShift: () -> Unit = {},
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 = {},
onEraseEof: () -> Unit = {},
onAttn: () -> Unit = {},
onSysReq: () -> Unit = {},
onCursorSelect: () -> Unit = {},
onCursorLeft: () -> Unit = {},
onCursorUp: () -> Unit = {},
onCursorDown: () -> Unit = {},
@@ -53,7 +65,7 @@ fun TwoRowKeyBar(
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Settings)
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Diagnostics/Tools/Settings)
Box(modifier = Modifier.weight(1f)) {
KeyButton(
label = "",
@@ -66,7 +78,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(
@@ -76,6 +90,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) },
@@ -84,14 +105,93 @@ 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 = {
Text(
text = if (isLightPenMode) "Light Pen: ON" else "Light Pen: OFF",
color = if (isLightPenMode) Color(0xFFFFFF50) else Color.White,
fontWeight = FontWeight.SemiBold
)
},
onClick = {
menuExpanded = false
onToggleLightPen()
}
)
DropdownMenuItem(
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
@@ -120,14 +220,59 @@ fun TwoRowKeyBar(
}
)
// 3. RESET
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onReset)
// 3. RESET / ERASE (Erase Input) depending on Shift state
val resetLabel = if (isShiftPressed) "ERASE" else "RESET"
KeyButton(
label = resetLabel,
color = Color(0xFFE67700),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onEraseInput()
onClearShift()
} else {
onReset()
}
}
)
// 4. ENTER
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
// 4. ENTER / EOF (Erase EOF) depending on Shift state
val enterLabel = if (isShiftPressed) "EOF" else "ENTER"
KeyButton(
label = enterLabel,
color = Color(0xFF2B8A3E),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onEraseEof()
onClearShift()
} else {
onSendAid(AID_ENTER)
}
}
)
// 5. CLEAR
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
// 5. CLEAR / ATTN (Attention) depending on Shift state
val clearLabel = if (isShiftPressed) "ATTN" else "CLEAR"
KeyButton(
label = clearLabel,
color = Color(0xFFC92A2A),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onAttn()
onClearShift()
} else {
onSendAid(AID_CLEAR)
}
}
)
// 6. PA1
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
@@ -0,0 +1,310 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
@Composable
fun HostDirectoryDialog(
client: Telnet3270Client?,
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
initialQuery: String = "",
onDismiss: () -> Unit,
onSelectDataset: (String) -> Unit
) {
var hostType by remember { mutableStateOf(initialHostType) }
var query by remember { mutableStateOf(initialQuery) }
var entries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var statusMessage by remember { mutableStateOf("") }
fun runQuery() {
val c = client
if (c == null || !c.connectionState.isFullSession) {
statusMessage = "Terminal not connected."
return
}
val xfer = c.xfer
if (xfer == null) {
statusMessage = "ECL transfer interface not available."
return
}
val q = query.trim()
if (hostType == FTConfig.HostType.CMS) {
val list = xfer.getCmsDirectory(q)
entries = list ?: emptyList()
} else {
val list = xfer.getTsoDirectory(q)
entries = list ?: emptyList()
}
selectedIndex = -1
statusMessage = "${entries.size} entries found."
}
fun parseScreen() {
val c = client
if (c == null || !c.connectionState.isFullSession) {
statusMessage = "No active screen to parse."
return
}
val xfer = c.xfer
if (xfer == null) {
statusMessage = "ECL transfer interface not available."
return
}
val ps = c.ps
val screenText = ps.getString(0, ps.size)
if (hostType == FTConfig.HostType.CMS) {
val list = xfer.getCmsDirectory(screenText)
entries = list ?: emptyList()
} else {
val list = xfer.getTsoDirectory(screenText)
entries = list ?: emptyList()
}
selectedIndex = -1
statusMessage = "Parsed ${entries.size} entries from screen."
}
LaunchedEffect(Unit) {
if (initialQuery.isNotBlank()) {
runQuery()
} else {
parseScreen()
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header & Query Bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Host Catalog & Directory Browser",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
Row {
FilterChip(
selected = (hostType == FTConfig.HostType.TSO),
onClick = { hostType = FTConfig.HostType.TSO },
label = { Text("TSO", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (hostType == FTConfig.HostType.CMS),
onClick = { hostType = FTConfig.HostType.CMS },
label = { Text("CMS", fontSize = 11.sp) }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Query Pattern") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* EXEC *") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(onSearch = { runQuery() }),
modifier = Modifier.weight(1f)
)
Button(
onClick = { runQuery() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Query")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = statusMessage.ifBlank { "Enter query or parse screen." },
fontSize = 11.sp,
color = Color.Gray
)
TextButton(onClick = { parseScreen() }) {
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Table View
if (entries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text("No entries found.", color = Color.Gray, fontSize = 12.sp)
}
} else {
val horizScroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.horizontalScroll(horizScroll)
) {
// Header Row
Row(
modifier = Modifier
.background(Color(0xFF25262B))
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (hostType == FTConfig.HostType.CMS) {
Text("Filename", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(90.dp))
Text("Filetype", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(80.dp))
Text("Fm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Fmt", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Records", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Date / Time", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(140.dp))
} else {
Text("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp))
Text("Volume", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(70.dp))
Text("Recfm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Blksize", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Tracks", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Ext", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
// Entry Rows
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(entries) { idx, item ->
val isSelected = (idx == selectedIndex)
Surface(
shape = RoundedCornerShape(4.dp),
color = if (isSelected) Color(0xFF1C7ED6).copy(alpha = 0.35f) else if (idx % 2 == 0) Color(0xFF161719) else Color(0xFF1E1F23),
modifier = Modifier
.fillMaxWidth()
.clickable { selectedIndex = idx }
) {
Row(
modifier = Modifier.padding(vertical = 5.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item is CMSDirectoryEntry) {
Text(item.filename, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(item.filetype, color = Color.Cyan, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(80.dp))
Text(item.filemode, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.numRecords}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.date} ${item.time}", color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp))
} else if (item is TSODirectoryEntry) {
Text(item.datasetName, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(220.dp))
Text(item.volume, color = Color.Cyan, fontSize = 11.sp, modifier = Modifier.width(70.dp))
Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.blksize}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.tracksAllocated}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.extents}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
}
}
}
}
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = {
if (selectedIndex in entries.indices) {
val selectedName = entries[selectedIndex].name
onSelectDataset(selectedName)
onDismiss()
}
},
enabled = selectedIndex in entries.indices,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Select Dataset")
}
}
}
}
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@@ -13,7 +13,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ConnectionState
import haus.nightmare.lib3270j.ConnectionState
@Composable
fun OiaStatusBar(
@@ -27,9 +27,11 @@ fun OiaStatusBar(
isTls: Boolean = false,
isTlsVerified: Boolean = true,
graphicsMode: String = "NONE",
codePage: String = "037",
isLightPenMode: Boolean = false,
modifier: Modifier = Modifier
) {
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
val row = if (cols > 0 && rows > 0) ((cursorAddr / cols) % rows) + 1 else 1
val col = if (cols > 0) (cursorAddr % cols) + 1 else 1
val posStr = String.format("%02d/%02d", row, col)
@@ -119,6 +121,42 @@ 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))
Surface(
color = Color(0xFFFFFF50).copy(alpha = 0.25f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "LP",
color = Color(0xFFFFFF50),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
}
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
@@ -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<Telnet3270EPClient?>(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 = "<html><body style='font-family:monospace; font-size:12px; white-space:pre;'>${spoolText.replace("<", "&lt;").replace(">", "&gt;")}</body></html>"
webView.loadDataWithBaseURL(null, htmlDoc, "text/html", "UTF-8", null)
}
} catch (e: Exception) {
Toast.makeText(context, "Print failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun saveSpoolToFile() {
if (spoolText.isBlank()) {
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
return
}
try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val file = File(downloadsDir, "3287_spool_$timeStamp.txt")
file.writeText(spoolText)
Toast.makeText(context, "Saved to Downloads/${file.name}", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Save failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun shareSpool() {
if (spoolText.isBlank()) {
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
return
}
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "3287 Printer Spool")
putExtra(Intent.EXTRA_TEXT, spoolText)
}
context.startActivity(Intent.createChooser(intent, "Share 3287 Spool"))
} catch (e: Exception) {
Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 700.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"IBM 3287 Printer Session",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
"TN3270E SCS / 3270 DS Print Emulation",
fontSize = 11.sp,
color = Color.Gray
)
}
Surface(
color = if (isConnected) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFF6B6B).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = sessionStatus,
color = if (isConnected) Color(0xFF51CF66) else Color(0xFFFF6B6B),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Config Row 1: Host & Port
if (!isConnected) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
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)
}
}
}
}
}
}
}
@@ -0,0 +1,471 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.net.Uri
import android.os.Environment
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.FileProvider
import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ScreenExporterDialog(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager? = null,
graphicsPlane: GraphicsPlane? = null,
maskHidden: Boolean = true,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var exportFormat by remember { mutableStateOf("TEXT") } // TEXT, HTML, PNG
var statusMessage by remember { mutableStateOf("") }
val plainTextContent = remember(screenBuffer) {
generatePlainText(screenBuffer, rows, cols, maskHidden)
}
val htmlContent = remember(screenBuffer) {
generateHtml(screenBuffer, rows, cols, maskHidden)
}
fun shareText(text: String, isHtml: Boolean) {
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = if (isHtml) "text/html" else "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "3270 Screen Capture")
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share 3270 Screen"))
} catch (e: Exception) {
statusMessage = "Share failed: ${e.message}"
}
}
fun sharePng(bmp: Bitmap) {
try {
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val file = File(cacheDir, "screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
val uri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Screen PNG"))
} catch (e: Exception) {
statusMessage = "PNG Share failed: ${e.message}"
}
}
fun saveFileToStorage(format: String): String {
return try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
when (format) {
"HTML" -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.html")
file.writeText(htmlContent)
"Saved to Downloads/${file.name}"
}
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
val file = File(downloadsDir, "3270_screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
"Saved to Downloads/${file.name}"
}
else -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.txt")
file.writeText(plainTextContent)
"Saved to Downloads/${file.name}"
}
}
} catch (e: Exception) {
"Save failed: ${e.message}"
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Export / Share Screen",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Row {
FilterChip(
selected = (exportFormat == "TEXT"),
onClick = { exportFormat = "TEXT" },
label = { Text("Plain Text", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "HTML"),
onClick = { exportFormat = "HTML" },
label = { Text("HTML", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "PNG"),
onClick = { exportFormat = "PNG" },
label = { Text("PNG", fontSize = 11.sp) }
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Preview Box
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
.padding(10.dp)
) {
if (exportFormat == "PNG") {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("🖼️ PNG Image Export", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White)
Spacer(modifier = Modifier.height(6.dp))
Text("Full resolution bitmap with 3270 styling and graphics plane.", fontSize = 12.sp, color = Color.LightGray)
}
} else {
val textToShow = if (exportFormat == "HTML") htmlContent else plainTextContent
Text(
text = textToShow,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = Color(0xFFA5D6A7),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
)
}
}
if (statusMessage.isNotBlank()) {
Text(
text = statusMessage,
fontSize = 12.sp,
color = Color(0xFF51CF66)
)
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (exportFormat != "PNG") {
OutlinedButton(
onClick = {
val content = if (exportFormat == "HTML") htmlContent else plainTextContent
clipboardManager.setText(AnnotatedString(content))
statusMessage = "Copied to clipboard."
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy", fontSize = 12.sp)
}
}
Button(
onClick = {
val msg = saveFileToStorage(exportFormat)
statusMessage = msg
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Save File", fontSize = 12.sp)
}
Button(
onClick = {
when (exportFormat) {
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
sharePng(bmp)
}
"HTML" -> shareText(htmlContent, true)
else -> shareText(plainTextContent, false)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Share 📤", fontSize = 12.sp)
}
}
}
}
}
}
}
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return ""
val sb = StringBuilder()
var currentHidden = false
for (r in 0 until rows) {
val rowSb = StringBuilder()
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
rowSb.append(' ')
} else {
if (currentHidden && maskHidden) {
rowSb.append(' ')
} else {
val ch = cell.ucs4
rowSb.append(if (ch in ' '..'~' || ch > '\u007F') ch else ' ')
}
}
}
sb.append(rowSb.toString().trimEnd()).append("\n")
}
return sb.toString()
}
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
val sb = StringBuilder()
sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
sb.append("<meta charset=\"UTF-8\">\n")
sb.append("<style>\n")
sb.append("body { background-color: #0A0A0A; color: #00FF66; font-family: monospace; font-size: 14px; margin: 20px; }\n")
sb.append("pre { line-height: 1.2; font-family: monospace; }\n")
sb.append(".c-green { color: #00FF66; }\n")
sb.append(".c-white { color: #FFFFFF; }\n")
sb.append(".c-blue { color: #3399FF; }\n")
sb.append(".c-red { color: #FF3333; }\n")
sb.append(".c-yellow { color: #FFFF33; }\n")
sb.append(".c-turquoise { color: #00E5FF; }\n")
sb.append(".c-pink { color: #FF66CC; }\n")
sb.append("</style>\n</head>\n<body>\n<pre>\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) {
'<' -> "&lt;"
'>' -> "&gt;"
'&' -> "&amp;"
'"' -> "&quot;"
else -> if (ch in ' '..'~' || ch > '\u007F') ch.toString() else " "
}
sb.append("<span class=\"$colorClass\">$escChar</span>")
}
}
}
sb.append("\n")
}
sb.append("</pre>\n</body>\n</html>")
return sb.toString()
}
private fun generateBitmap(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager?,
graphicsPlane: GraphicsPlane?,
maskHidden: Boolean
): Bitmap {
val charWidth = 14
val charHeight = 24
val width = cols * charWidth
val height = rows * charHeight
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// Fill background
val bgPaint = Paint().apply {
color = android.graphics.Color.parseColor("#0A0A0A")
style = Paint.Style.FILL
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
if (screenBuffer == null) return bitmap
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 {
val csVal = cell.cs.toInt() and 0xFF
val ecVal = cell.ec.toInt() and 0xFF
var drawnAsPs = false
if (csVal >= 0x40 && programSymbolManager != null) {
val slot = programSymbolManager.getSymbol(csVal, ecVal)
if (slot != null) {
val symWidth = slot.width
val symHeight = slot.height
val fgArgb = when {
currentHi -> android.graphics.Color.WHITE
currentProt -> android.graphics.Color.parseColor("#3399FF")
else -> android.graphics.Color.parseColor("#00FF66")
}
val bgArgb = android.graphics.Color.parseColor("#0A0A0A")
val rgbArray = slot.getRgbPixels(fgArgb, bgArgb)
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val symBmp = Bitmap.createBitmap(rgbArray, symWidth, symHeight, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(symBmp, null, android.graphics.RectF(left, top, left + charWidth, top + charHeight), null)
drawnAsPs = true
}
}
}
if (!drawnAsPs && (!currentHidden || !maskHidden)) {
val ch = cell.ucs4
if (ch in ' '..'~' || ch > '\u007F') {
textPaint.color = 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
}
@@ -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<String?>(null) }
fun insertToken(token: String) {
scriptText += token
}
Dialog(
onDismissRequest = { if (!isExecuting) onDismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Run Keystroke Script / Macro",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
"ECL Mnemonic Tokens",
fontSize = 11.sp,
color = Color.Gray
)
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Mnemonic Quick Insert Chips Row 1
Text("Insert Mnemonic Keys:", fontSize = 11.sp, color = Color.LightGray)
val tokenScroll1 = rememberScrollState()
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(tokenScroll1),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
listOf(
"[enter]", "[tab]", "[backtab]", "[home]", "[clear]",
"[reset]", "[eraseeof]", "[eraseinpt]", "[attn]", "[sysreq]"
).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 ▶")
}
}
}
}
}
}
}
@@ -1,5 +1,6 @@
package org.pubvm.a3270.ui
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))
) {
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import android.content.Context
import android.text.InputType
@@ -8,7 +8,7 @@ import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
/**
* Custom InputConnection for Termux-style terminal keyboard interaction.
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import android.graphics.Paint
import android.graphics.Typeface
@@ -25,9 +25,9 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ExtendedAttribute
import org.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
// Standard 3279 Host Colors (16-color palette)
private val HOST_COLORS = arrayOf(
@@ -58,11 +58,17 @@ fun TerminalView(
cols: Int,
cursorAddr: Int,
screenVersion: Long,
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
programSymbolManager: haus.nightmare.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: haus.nightmare.lib3270j.graphics.GraphicsPlane? = null,
gocaDecoder: haus.nightmare.lib3270j.graphics.GocaDecoder? = null,
isLightPenMode: Boolean = false,
maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true,
searchHighlightAddr: Int = -1,
searchHighlightLen: Int = 0,
onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null,
onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
@@ -97,7 +103,7 @@ fun TerminalView(
modifier = Modifier
.fillMaxSize()
.background(COLOR_BLACK)
.pointerInput(cols, rows) {
.pointerInput(cols, rows, isLightPenMode, gocaDecoder) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
@@ -106,6 +112,7 @@ fun TerminalView(
var currentPos = startPos
var isDragStarted = false
val slop = viewConfiguration.touchSlop
val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive
while (true) {
val event = awaitPointerEvent()
@@ -116,7 +123,7 @@ fun TerminalView(
currentPos = change.position
val distance = (currentPos - startPos).getDistance()
if (distance > slop) {
if (distance > slop && !isLightPenMode && !isGraphicCursor) {
if (!isDragStarted) {
isDragStarted = true
selectionStart = startPos
@@ -133,12 +140,12 @@ fun TerminalView(
if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop) {
// Stationary Long Press (held >= 600ms without dragging)
} else if (duration >= 600L && totalDistance <= slop && !isLightPenMode && !isGraphicCursor) {
// Stationary Long Press (held >= 600ms without dragging) - only in normal alphanumeric mode
contextMenuOffset = startPos
showContextMenu = true
} else if (totalDistance <= slop) {
// Short Tap (Move Cursor)
// Short Tap
showContextMenu = false
selectionStart = null
selectionEnd = null
@@ -146,8 +153,21 @@ fun TerminalView(
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
var addr = row * cols + col
val clickAddr = row * cols + col
if (isGraphicCursor) {
val gridW = metrics.gridWidth
val gridH = metrics.gridHeight
val gWidth = graphicsPlane?.canvasWidth ?: gridW.toInt()
val gHeight = graphicsPlane?.canvasHeight ?: gridH.toInt()
val px = if (gridW > 0 && gWidth > 0) (((startPos.x - metrics.offsetX) * gWidth) / gridW).toInt().coerceIn(0, gWidth - 1) else (startPos.x - metrics.offsetX).toInt()
val py = if (gridH > 0 && gHeight > 0) (((startPos.y - metrics.offsetY) * gHeight) / gridH).toInt().coerceIn(0, gHeight - 1) else (startPos.y - metrics.offsetY).toInt()
screenBuffer?.cursorAddress = clickAddr
onGraphicTouch?.invoke(clickAddr, px, py)
} else if (isLightPenMode) {
onLightPenSelect?.invoke(clickAddr)
} else {
var addr = clickAddr
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
@@ -161,6 +181,7 @@ fun TerminalView(
}
}
}
}
) {
val version = currentScreenVersion
val activeCursorAddr = currentCursorAddr
@@ -296,7 +317,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),
@@ -322,7 +352,7 @@ fun TerminalView(
val symHeight = slot.height
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgbArray as IntArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -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)
}
}
@@ -0,0 +1,102 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.ecl.ECLConstants
import haus.nightmare.lib3270j.ecl.ECLFieldList
import haus.nightmare.lib3270j.ecl.ECLPS
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class EclAndSearchTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: haus.nightmare.lib3270j.input.InputProcessor
private lateinit var ps: ECLPS
private lateinit var fieldList: ECLFieldList
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = haus.nightmare.lib3270j.input.InputProcessor(screenBuffer, translator, null)
ps = ECLPS(screenBuffer, inputProcessor, translator)
fieldList = ps.fieldList
}
@Test
fun testPresentationSpaceSearchForwardAndBackward() {
val text = "USERID ===> IBMUSER"
for (i in text.indices) {
screenBuffer.getCell(100 + i).ucs4 = text[i]
screenBuffer.getCell(100 + i).ec = translator.unicodeToEbcdic(text[i]).toByte()
}
// Search Forward for "IBMUSER" from row 0 col 0
val foundPos = ps.searchString("IBMUSER", 0, 0, ECLConstants.SEARCH_FORWARD, true)
assertEquals(100 + text.indexOf("IBMUSER"), foundPos)
// Case-sensitive search failure
val notFound = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, false)
assertEquals(-1, notFound)
// Case-insensitive search match
val foundCaseInsensitive = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, true)
assertEquals(100 + text.indexOf("IBMUSER"), foundCaseInsensitive)
// Search Backward from bottom of screen
val foundBackward = ps.searchString("USERID", 23, 79, ECLConstants.SEARCH_BACKWARD, true)
assertEquals(100, foundBackward)
}
@Test
fun testFieldListExtractionAndFlags() {
// Position 0: Protected label
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
val label = "COMMAND ===>"
for (i in label.indices) {
screenBuffer.getCell(1 + i).ucs4 = label[i]
screenBuffer.getCell(1 + i).ec = translator.unicodeToEbcdic(label[i]).toByte()
}
// Position 20: Unprotected input field with MDT
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
val cmd = "TSO TEST"
for (i in cmd.indices) {
screenBuffer.getCell(21 + i).ucs4 = cmd[i]
screenBuffer.getCell(21 + i).ec = translator.unicodeToEbcdic(cmd[i]).toByte()
}
// Position 40: Hidden field (zero intensity)
screenBuffer.setCellFA(40, (FA_PRINTABLE or FA_PROTECT or FA_INT_ZERO_NSEL).toByte())
fieldList.refresh()
val fields = fieldList.fields
assertEquals(3, fields.size)
// Field 0: Label
val f0 = fields[0]
assertTrue(f0.isProtected)
assertFalse(f0.isModified)
assertEquals(0, f0.start)
assertEquals(1, f0.dataStart)
assertTrue(f0.text.startsWith("COMMAND ===>"))
// Field 1: Input command
val f1 = fields[1]
assertFalse(f1.isProtected)
assertTrue(f1.isModified)
assertEquals(20, f1.start)
assertEquals(21, f1.dataStart)
assertTrue(f1.text.startsWith("TSO TEST"))
// Field 2: Hidden field
val f2 = fields[2]
assertTrue(f2.isHidden)
}
}
@@ -0,0 +1,178 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.ConnectionState
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.datastream.DataStreamProcessor
import haus.nightmare.lib3270j.graphics.GocaDecoder
import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.protocol.TelnetConstants.*
import haus.nightmare.lib3270j.protocol.TN3270EConstants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.telnet.TelnetConnection
import haus.nightmare.lib3270j.telnet.TelnetFSM
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.util.ArrayList
import java.util.concurrent.atomic.AtomicReference
class GraphicsAndSysReqTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var graphicsPlane: GraphicsPlane
private lateinit var gocaDecoder: GocaDecoder
private class MockConnection(config: ConnectionConfig, fsm: TelnetFSM) : TelnetConnection(config, fsm) {
val sentData: MutableList<ByteArray> = ArrayList()
override fun sendRaw(data: ByteArray) {
sentData.add(data.clone())
}
override fun sendRaw(data: ByteArray, offset: Int, length: Int) {
val b = ByteArray(length)
System.arraycopy(data, offset, b, 0, length)
sentData.add(b)
}
}
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
graphicsPlane = GraphicsPlane(800, 600)
gocaDecoder = GocaDecoder(graphicsPlane)
}
@Test
fun testScreenBufferSetCursorPosition() {
screenBuffer.setCursorPosition(5, 10)
assertEquals(5 * 80 + 10, screenBuffer.cursorAddress)
assertEquals(5, screenBuffer.cursorRow)
assertEquals(10, screenBuffer.cursorCol)
// Clamping check
screenBuffer.setCursorPosition(-5, -10)
assertEquals(0, screenBuffer.cursorAddress)
screenBuffer.setCursorPosition(100, 200)
assertEquals(23 * 80 + 79, screenBuffer.cursorAddress)
}
@Test
fun testGraphicsCursorFramingOnSendAid() {
screenBuffer.erase(false)
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte())
screenBuffer.getCell(1).ec = 0xC1.toByte() // 'A'
screenBuffer.setCellFA(5, (FA_PRINTABLE or FA_PROTECT).toByte())
screenBuffer.cursorAddress = 2
gocaDecoder.isGraphicsCursorActive = true
gocaDecoder.setGraphicCursorPosition(200, -100)
val sentRef = AtomicReference<ByteArray>()
val inputProcessor = object : InputProcessor(screenBuffer, translator, null) {
override fun sendAidResponse(data: ByteArray) {
sentRef.set(data)
}
}
inputProcessor.gocaDecoder = gocaDecoder
inputProcessor.sendAid(AID_ENTER)
val result = sentRef.get()
assertNotNull(result)
// Expected: AID_SF (0x88) + SF(56 bytes) + AID_ENTER(0x7D) + CursorAddr(2) + SBA(1) + FieldAddr(2) + Data 'A'(1) = 64 bytes
assertEquals(64, result.size)
assertEquals(AID_SF.toByte(), result[0])
assertEquals(0x00.toByte(), result[1])
assertEquals(0x34.toByte(), result[2]) // SF len 52
assertEquals(0x0F.toByte(), result[3])
assertEquals(0x0F.toByte(), result[4])
// Coordinates at index 25, 27
val gx = ((result[25].toInt() and 0xFF) shl 8) or (result[26].toInt() and 0xFF)
val gy = ((result[27].toInt() and 0xFF) shl 8) or (result[28].toInt() and 0xFF)
assertEquals(200, gx.toShort().toInt())
assertEquals(-100, gy.toShort().toInt())
// Trailing AID at index 57
assertEquals(AID_ENTER.toByte(), result[57])
}
@Test
fun testSysReqOutOfBandRoutingAndStateToggle() {
val config = ConnectionConfig("127.0.0.1", 23, TerminalModel.IBM_3279_2).apply {
isTn3270eEnabled = true
}
val dsProcessor = DataStreamProcessor(screenBuffer, translator)
val fsm = TelnetFSM(config, screenBuffer, dsProcessor)
val inputProcessor = InputProcessor(screenBuffer, translator, fsm)
dsProcessor.inputProcessor = inputProcessor
val connection = MockConnection(config, fsm)
fsm.setConnection(connection)
fsm.onConnected()
// 1. Complete TN3270E negotiation
fsm.feedBytes(byteArrayOf(IAC.toByte(), DO.toByte(), TELOPT_TN3270E.toByte()), 0, 3)
val devTypeIs = byteArrayOf(
IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(),
0x02, 0x04, 'I'.code.toByte(), 'B'.code.toByte(), 'M'.code.toByte(), '-'.code.toByte(),
'3'.code.toByte(), '2'.code.toByte(), '7'.code.toByte(), '9'.code.toByte(), '-'.code.toByte(),
'2'.code.toByte(), '-'.code.toByte(), 'E'.code.toByte(),
IAC.toByte(), SE.toByte()
)
fsm.feedBytes(devTypeIs, 0, devTypeIs.size)
val funcsReq = byteArrayOf(
IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(),
OP_FUNCTIONS.toByte(), OP_REQUEST.toByte(),
FUNC_BIND_IMAGE.toByte(), FUNC_RESPONSES.toByte(), FUNC_SYSREQ.toByte(),
IAC.toByte(), SE.toByte()
)
fsm.feedBytes(funcsReq, 0, funcsReq.size)
// BIND image
val bindPacket = ByteArray(EH_SIZE + 35).apply {
this[0] = DT_BIND_IMAGE.toByte()
this[4] = 1
this[EH_SIZE + 24] = 0x02
}
val bStream = ByteArrayOutputStream().apply {
write(bindPacket)
write(IAC)
write(EOR)
}
val bindBytes = bStream.toByteArray()
fsm.feedBytes(bindBytes, 0, bindBytes.size)
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState)
assertTrue(fsm.isTn3270eBound)
connection.sentData.clear()
// 2. Lock keyboard and send SYSREQ
inputProcessor.isKeyboardLocked = true
inputProcessor.sendAid(AID_SYSREQ)
assertFalse(inputProcessor.isKeyboardLocked)
var foundIacAo = false
for (pkt in connection.sentData) {
if (pkt.size == 2 && (pkt[0].toInt() and 0xFF) == IAC && (pkt[1].toInt() and 0xFF) == AO) {
foundIacAo = true
}
}
assertTrue("Expected IAC AO out-of-band telnet command", foundIacAo)
assertEquals(ConnectionState.CONNECTED_SSCP, fsm.connectionState)
// 3. Send second SYSREQ to toggle back
inputProcessor.sendAid(AID_SYSREQ)
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState)
}
}
@@ -1,13 +1,13 @@
package org.pubvm.a3270
package haus.nightmare.a3270
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.lib3270j.TerminalModel
import org.lib3270j.charset.EbcdicTranslator
import org.lib3270j.input.InputProcessor
import org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
class HardwareKeyboardInputTest {
@@ -165,4 +165,77 @@ class HardwareKeyboardInputTest {
assertFalse(inputProcessor.isKeyboardLocked)
}
}
@Test
fun testLightPenSelection() {
// Field 1 at pos 0: Selectable Unprotected (FA_INT_HIGH)
// Designator at pos 1: '?' (EBCDIC 0x6F)
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
assertTrue(haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa.toInt() and 0xFF))
screenBuffer.setCellFA(0, fa)
screenBuffer.getCell(1).ucs4 = '?'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('?').toByte()
// Field 2 at pos 20: Non-selectable (normal intensity)
screenBuffer.setCellFA(20, FA_PRINTABLE.toByte())
screenBuffer.getCell(21).ucs4 = '?'
// Selecting field 1 ('?') toggles designator to '>' and sets modified bit
val result1 = inputProcessor.lightPenSelect(1)
assertTrue(result1)
assertEquals('>', screenBuffer.getCell(1).ucs4)
assertEquals(0x6E.toByte(), screenBuffer.getCell(1).ec)
val fa1 = screenBuffer.getCell(0).fa.toInt() and 0xFF
assertTrue((fa1 and FA_MODIFY) != 0)
// Selecting field 1 again ('>') toggles back to '?' and clears modified bit
val result2 = inputProcessor.lightPenSelect(1)
assertTrue(result2)
assertEquals('?', screenBuffer.getCell(1).ucs4)
assertEquals(0x6F.toByte(), screenBuffer.getCell(1).ec)
// Selecting field 2 (non-selectable) returns false
val resultNonSelectable = inputProcessor.lightPenSelect(21)
assertFalse(resultNonSelectable)
}
@Test
fun testLightPenEnterSelection() {
// Field with '&' designator immediately sends AID_ENTER
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
screenBuffer.setCellFA(0, fa)
screenBuffer.getCell(1).ucs4 = '&'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('&').toByte()
val result = inputProcessor.lightPenSelect(1)
assertTrue(result)
assertEquals(AID_ENTER, inputProcessor.lastAid)
}
@Test
fun testEraseInputAndAttention() {
// Position 0: Unprotected FA
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte())
// Position 10: Protected FA
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
// Position 20: Unprotected FA
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
screenBuffer.getCell(1).ucs4 = 'X'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('X').toByte()
screenBuffer.getCell(21).ucs4 = 'Y'
screenBuffer.getCell(21).ec = translator.unicodeToEbcdic('Y').toByte()
inputProcessor.eraseInput()
// Unprotected fields should be cleared and reset
assertEquals(0, screenBuffer.getCell(1).ec.toInt())
assertEquals(0, screenBuffer.getCell(21).ec.toInt())
assertEquals(1, screenBuffer.cursorAddress)
// Attention AID
inputProcessor.sendAid(AID_PA1)
assertEquals(AID_PA1, inputProcessor.lastAid)
}
}
@@ -0,0 +1,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)
}
}