3 Commits

Author SHA1 Message Date
rudi 69ebe6bfb5 merge
Release a3270 / Build & Publish Release (push) Successful in 4m43s
Build and Test a3270 / Build Android APK (push) Successful in 5m42s
2026-09-18 18:49:09 +00:00
rudi 63d14bd2d9 Hotspot detection and screen buffer 2026-09-18 18:47:39 +00:00
rudi 377626013d Bump version to 1.2.1
Build and Test a3270 / Build Android APK (push) Successful in 5m35s
Release a3270 / Build & Publish Release (push) Successful in 4m35s
2026-09-16 17:17:29 -04:00
12 changed files with 1794 additions and 18 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270" applicationId "haus.nightmare.a3270"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 6 versionCode 7
versionName "1.0.1" versionName "1.2.2"
} }
buildTypes { buildTypes {
+3 -1
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="7"
android:versionName="1.2.2">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -22,6 +22,8 @@ import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.KeyCharacterMap import android.view.KeyCharacterMap
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.a3270.service.TerminalService import haus.nightmare.a3270.service.TerminalService
@@ -39,7 +41,8 @@ import haus.nightmare.a3270.ui.FindDialog
import haus.nightmare.a3270.ui.ScreenExporterDialog import haus.nightmare.a3270.ui.ScreenExporterDialog
import haus.nightmare.a3270.ui.ScriptDialog import haus.nightmare.a3270.ui.ScriptDialog
import haus.nightmare.a3270.ui.PrinterSessionDialog import haus.nightmare.a3270.ui.PrinterSessionDialog
import haus.nightmare.a3270.ui.ScreenHistoryDialog
import haus.nightmare.a3270.ui.MacroDialog
import haus.nightmare.a3270.ui.UntrustedCertificateDialog import haus.nightmare.a3270.ui.UntrustedCertificateDialog
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -535,6 +538,13 @@ fun MainScreen(
val isWordWrap by viewModel.isWordWrap.collectAsState() val isWordWrap by viewModel.isWordWrap.collectAsState()
val isAplMode by viewModel.isAplMode.collectAsState() val isAplMode by viewModel.isAplMode.collectAsState()
val fourColorOverride by viewModel.fourColorOverride.collectAsState() val fourColorOverride by viewModel.fourColorOverride.collectAsState()
val trimRectSizingHandles by viewModel.trimRectSizingHandles.collectAsState()
val trimRectRemainAfterEdit by viewModel.trimRectRemainAfterEdit.collectAsState()
val pasteWordBreak by viewModel.pasteWordBreak.collectAsState()
val hotspotsEnabled by viewModel.hotspotsEnabled.collectAsState()
val hotspotsUrlEnabled by viewModel.hotspotsUrlEnabled.collectAsState()
val hotspotsPfEnabled by viewModel.hotspotsPfEnabled.collectAsState()
val hotspotsMenuChoicesEnabled by viewModel.hotspotsMenuChoicesEnabled.collectAsState()
val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) { val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) {
if (screenBuffer != null && screenBuffer?.isFormatted == true) { if (screenBuffer != null && screenBuffer?.isFormatted == true) {
@@ -551,6 +561,8 @@ fun MainScreen(
var showScreenExportDialog by remember { mutableStateOf(false) } var showScreenExportDialog by remember { mutableStateOf(false) }
var showScriptDialog by remember { mutableStateOf(false) } var showScriptDialog by remember { mutableStateOf(false) }
var showPrinterSessionDialog by remember { mutableStateOf(false) } var showPrinterSessionDialog by remember { mutableStateOf(false) }
var showScreenHistoryDialog by remember { mutableStateOf(false) }
var showMacroDialog by remember { mutableStateOf(false) }
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) } var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
@@ -664,6 +676,22 @@ fun MainScreen(
blockPaste = blockPaste, blockPaste = blockPaste,
onToggleBlockSelectMode = { viewModel.setBlockSelectMode(!blockSelectMode) }, onToggleBlockSelectMode = { viewModel.setBlockSelectMode(!blockSelectMode) },
onToggleBlockPaste = { viewModel.setBlockPaste(!blockPaste) }, onToggleBlockPaste = { viewModel.setBlockPaste(!blockPaste) },
trimRectSizingHandles = trimRectSizingHandles,
trimRectRemainAfterEdit = trimRectRemainAfterEdit,
hotspotsEnabled = hotspotsEnabled,
hotspotEngine = viewModel.hotspotEngine,
onSendHotspotAction = { action -> viewModel.sendKeys(action) },
onOpenUrl = { url ->
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, "Cannot open URL: ${e.message}", Toast.LENGTH_SHORT).show()
}
},
onToggleHotspots = { viewModel.toggleHotspots() },
fourColorOverride = fourColorOverride, fourColorOverride = fourColorOverride,
onPasteLineWrap = { text, wrapCol, wordWrap -> onPasteLineWrap = { text, wrapCol, wordWrap ->
viewModel.pasteLineWrap(text, wrapCol, wordWrap) viewModel.pasteLineWrap(text, wrapCol, wordWrap)
@@ -748,10 +776,18 @@ fun MainScreen(
terminalInputViewRef?.hideSoftKeyboard() terminalInputViewRef?.hideSoftKeyboard()
showScreenExportDialog = true showScreenExportDialog = true
}, },
onScreenHistoryClick = {
terminalInputViewRef?.hideSoftKeyboard()
showScreenHistoryDialog = true
},
onScriptClick = { onScriptClick = {
terminalInputViewRef?.hideSoftKeyboard() terminalInputViewRef?.hideSoftKeyboard()
showScriptDialog = true showScriptDialog = true
}, },
onMacroRuntimeClick = {
terminalInputViewRef?.hideSoftKeyboard()
showMacroDialog = true
},
onPrinterSessionClick = { onPrinterSessionClick = {
terminalInputViewRef?.hideSoftKeyboard() terminalInputViewRef?.hideSoftKeyboard()
showPrinterSessionDialog = true showPrinterSessionDialog = true
@@ -902,6 +938,13 @@ fun MainScreen(
initialCursorStyle = cursorStyle, initialCursorStyle = cursorStyle,
initialBlockSelectMode = blockSelectMode, initialBlockSelectMode = blockSelectMode,
initialBlockPaste = blockPaste, initialBlockPaste = blockPaste,
initialTrimRectSizingHandles = trimRectSizingHandles,
initialTrimRectRemainAfterEdit = trimRectRemainAfterEdit,
initialPasteWordBreak = pasteWordBreak,
initialHotspotsEnabled = hotspotsEnabled,
initialHotspotsUrlEnabled = hotspotsUrlEnabled,
initialHotspotsPfEnabled = hotspotsPfEnabled,
initialHotspotsMenuChoicesEnabled = hotspotsMenuChoicesEnabled,
initialStartupBehavior = AppSettings.getStartupBehavior(context).name, initialStartupBehavior = AppSettings.getStartupBehavior(context).name,
initialDynamicRows = AppSettings.getDynamicRows(context), initialDynamicRows = AppSettings.getDynamicRows(context),
initialDynamicCols = AppSettings.getDynamicCols(context), initialDynamicCols = AppSettings.getDynamicCols(context),
@@ -920,8 +963,9 @@ fun MainScreen(
showSettingsDialog = false showSettingsDialog = false
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
}, },
onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, blockPst, startup, dynRows, dynCols, onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, blockPst,
doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk -> sizingHandles, remainAfterEdit, wordBreak, hsEnabled, hsUrl, hsPf, hsChoices,
startup, dynRows, dynCols, doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk ->
showSettingsDialog = false showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp) viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp)
viewModel.setUiTheme(theme) viewModel.setUiTheme(theme)
@@ -929,6 +973,13 @@ fun MainScreen(
viewModel.setCursorStyle(style) viewModel.setCursorStyle(style)
viewModel.setBlockSelectMode(blockSel) viewModel.setBlockSelectMode(blockSel)
viewModel.setBlockPaste(blockPst) viewModel.setBlockPaste(blockPst)
viewModel.setTrimRectSizingHandles(sizingHandles)
viewModel.setTrimRectRemainAfterEdit(remainAfterEdit)
viewModel.setPasteWordBreak(wordBreak)
viewModel.setHotspotsEnabled(hsEnabled)
viewModel.setHotspotsUrlEnabled(hsUrl)
viewModel.setHotspotsPfEnabled(hsPf)
viewModel.setHotspotsMenuChoicesEnabled(hsChoices)
AppSettings.setStartupBehavior(context, AppSettings.StartupBehavior.valueOf(startup)) AppSettings.setStartupBehavior(context, AppSettings.StartupBehavior.valueOf(startup))
AppSettings.setDynamicRows(context, dynRows) AppSettings.setDynamicRows(context, dynRows)
AppSettings.setDynamicCols(context, dynCols) AppSettings.setDynamicCols(context, dynCols)
@@ -1020,6 +1071,26 @@ fun MainScreen(
) )
} }
if (showScreenHistoryDialog) {
ScreenHistoryDialog(
historyBuffer = viewModel.getScreenHistoryBuffer(),
onDismiss = {
showScreenHistoryDialog = false
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showMacroDialog) {
MacroDialog(
client = viewModel.getClient(),
onDismiss = {
showMacroDialog = false
terminalInputViewRef?.showSoftKeyboard()
}
)
}
val prompt = untrustedCertPrompt val prompt = untrustedCertPrompt
if (prompt != null) { if (prompt != null) {
UntrustedCertificateDialog( UntrustedCertificateDialog(
@@ -20,9 +20,11 @@ import haus.nightmare.lib3270j.listener.ConnectionListener
import haus.nightmare.lib3270j.listener.ScreenUpdateListener import haus.nightmare.lib3270j.listener.ScreenUpdateListener
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
import haus.nightmare.a3270.ft.FileTransfer import haus.nightmare.a3270.ft.FileTransfer
import haus.nightmare.a3270.service.TerminalService import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.ui.HotspotEngine
import java.io.File import java.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.logging.Logger import java.util.logging.Logger
@@ -62,6 +64,7 @@ sealed interface TerminalInputAction {
data class SetCursor(val baddr: Int) : TerminalInputAction data class SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(val baddr: Int) : TerminalInputAction data class LightPenSelect(val baddr: Int) : TerminalInputAction
data class PasteText(val text: String) : TerminalInputAction data class PasteText(val text: String) : TerminalInputAction
data class SendKeysAction(val keys: String) : TerminalInputAction
} }
data class UntrustedCertPromptState( data class UntrustedCertPromptState(
@@ -183,6 +186,80 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _fourColorOverride = MutableStateFlow(AppSettings.getFourColorOverride(application)) private val _fourColorOverride = MutableStateFlow(AppSettings.getFourColorOverride(application))
val fourColorOverride: StateFlow<Boolean> = _fourColorOverride.asStateFlow() val fourColorOverride: StateFlow<Boolean> = _fourColorOverride.asStateFlow()
private val _hotspotsEnabled = MutableStateFlow(AppSettings.isHotspotsEnabled(application))
val hotspotsEnabled: StateFlow<Boolean> = _hotspotsEnabled.asStateFlow()
private val _hotspotsUrlEnabled = MutableStateFlow(AppSettings.isHotspotsUrlEnabled(application))
val hotspotsUrlEnabled: StateFlow<Boolean> = _hotspotsUrlEnabled.asStateFlow()
private val _hotspotsPfEnabled = MutableStateFlow(AppSettings.isHotspotsPfEnabled(application))
val hotspotsPfEnabled: StateFlow<Boolean> = _hotspotsPfEnabled.asStateFlow()
private val _hotspotsMenuChoicesEnabled = MutableStateFlow(AppSettings.isHotspotsMenuChoicesEnabled(application))
val hotspotsMenuChoicesEnabled: StateFlow<Boolean> = _hotspotsMenuChoicesEnabled.asStateFlow()
private val _trimRectSizingHandles = MutableStateFlow(AppSettings.isTrimRectSizingHandlesEnabled(application))
val trimRectSizingHandles: StateFlow<Boolean> = _trimRectSizingHandles.asStateFlow()
private val _trimRectRemainAfterEdit = MutableStateFlow(AppSettings.isTrimRectRemainAfterEditEnabled(application))
val trimRectRemainAfterEdit: StateFlow<Boolean> = _trimRectRemainAfterEdit.asStateFlow()
private val _pasteWordBreak = MutableStateFlow(AppSettings.isPasteWordBreakEnabled(application))
val pasteWordBreak: StateFlow<Boolean> = _pasteWordBreak.asStateFlow()
val hotspotEngine = HotspotEngine().apply {
urlEnabled = _hotspotsUrlEnabled.value
pfKeysEnabled = _hotspotsPfEnabled.value
menuChoicesEnabled = _hotspotsMenuChoicesEnabled.value
}
fun setHotspotsEnabled(enabled: Boolean) {
AppSettings.setHotspotsEnabled(getApplication(), enabled)
_hotspotsEnabled.value = enabled
}
fun toggleHotspots() {
setHotspotsEnabled(!_hotspotsEnabled.value)
}
fun setHotspotsUrlEnabled(enabled: Boolean) {
AppSettings.setHotspotsUrlEnabled(getApplication(), enabled)
_hotspotsUrlEnabled.value = enabled
hotspotEngine.urlEnabled = enabled
hotspotEngine.invalidateCache()
}
fun setHotspotsPfEnabled(enabled: Boolean) {
AppSettings.setHotspotsPfEnabled(getApplication(), enabled)
_hotspotsPfEnabled.value = enabled
hotspotEngine.pfKeysEnabled = enabled
hotspotEngine.invalidateCache()
}
fun setHotspotsMenuChoicesEnabled(enabled: Boolean) {
AppSettings.setHotspotsMenuChoicesEnabled(getApplication(), enabled)
_hotspotsMenuChoicesEnabled.value = enabled
hotspotEngine.menuChoicesEnabled = enabled
hotspotEngine.invalidateCache()
}
fun setTrimRectSizingHandles(enabled: Boolean) {
AppSettings.setTrimRectSizingHandlesEnabled(getApplication(), enabled)
_trimRectSizingHandles.value = enabled
}
fun setTrimRectRemainAfterEdit(enabled: Boolean) {
AppSettings.setTrimRectRemainAfterEditEnabled(getApplication(), enabled)
_trimRectRemainAfterEdit.value = enabled
}
fun setPasteWordBreak(enabled: Boolean) {
val app = getApplication<Application>()
AppSettings.setPasteWordBreakEnabled(app, enabled)
_pasteWordBreak.value = enabled
applyModeSettings()
}
fun toggleLightPen() { fun toggleLightPen() {
_isLightPenMode.value = !_isLightPenMode.value _isLightPenMode.value = !_isLightPenMode.value
} }
@@ -286,8 +363,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
ip.isNumericFieldLock = AppSettings.getNumericFieldLock(app) ip.isNumericFieldLock = AppSettings.getNumericFieldLock(app)
ip.isAutoSkipEnabled = AppSettings.getAutoSkipEnabled(app) ip.isAutoSkipEnabled = AppSettings.getAutoSkipEnabled(app)
ip.isBlockPaste = AppSettings.isBlockPasteEnabled(app) ip.isBlockPaste = AppSettings.isBlockPasteEnabled(app)
ip.isPasteWordBreak = AppSettings.isPasteWordBreakEnabled(app)
} }
c.ps?.isBlockPaste = AppSettings.isBlockPasteEnabled(app) c.ps?.isBlockPaste = AppSettings.isBlockPasteEnabled(app)
c.ps?.isPasteWordBreak = AppSettings.isPasteWordBreakEnabled(app)
c.isPasteWordBreak = AppSettings.isPasteWordBreakEnabled(app)
_fourColorOverride.value = AppSettings.getFourColorOverride(app) _fourColorOverride.value = AppSettings.getFourColorOverride(app)
_isDocMode.value = c.isDocMode _isDocMode.value = c.isDocMode
_isWordWrap.value = c.isWordWrap _isWordWrap.value = c.isWordWrap
@@ -310,6 +390,17 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_blockPaste.value = AppSettings.isBlockPasteEnabled(app) _blockPaste.value = AppSettings.isBlockPasteEnabled(app)
_uiTheme.value = AppSettings.getUiTheme(app) _uiTheme.value = AppSettings.getUiTheme(app)
_fourColorOverride.value = AppSettings.getFourColorOverride(app) _fourColorOverride.value = AppSettings.getFourColorOverride(app)
_hotspotsEnabled.value = AppSettings.isHotspotsEnabled(app)
_hotspotsUrlEnabled.value = AppSettings.isHotspotsUrlEnabled(app)
_hotspotsPfEnabled.value = AppSettings.isHotspotsPfEnabled(app)
_hotspotsMenuChoicesEnabled.value = AppSettings.isHotspotsMenuChoicesEnabled(app)
hotspotEngine.urlEnabled = _hotspotsUrlEnabled.value
hotspotEngine.pfKeysEnabled = _hotspotsPfEnabled.value
hotspotEngine.menuChoicesEnabled = _hotspotsMenuChoicesEnabled.value
hotspotEngine.invalidateCache()
_trimRectSizingHandles.value = AppSettings.isTrimRectSizingHandlesEnabled(app)
_trimRectRemainAfterEdit.value = AppSettings.isTrimRectRemainAfterEditEnabled(app)
_pasteWordBreak.value = AppSettings.isPasteWordBreakEnabled(app)
applyModeSettings() applyModeSettings()
} }
@@ -356,6 +447,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
fun getClient(): Telnet3270Client? = client fun getClient(): Telnet3270Client? = client
fun getScreenHistoryBuffer(): ScreenHistoryBuffer? = client?.screenBuffer?.historyBuffer
fun sendKeys(keys: String) {
inputChannel.trySend(TerminalInputAction.SendKeysAction(keys))
}
private var lastScreenContentHash: Int = 0 private var lastScreenContentHash: Int = 0
private var hasInitialScreenLoaded: Boolean = false private var hasInitialScreenLoaded: Boolean = false
@@ -423,10 +520,17 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} else { } else {
val app = getApplication<Application>() val app = getApplication<Application>()
val blockPaste = AppSettings.isBlockPasteEnabled(app) val blockPaste = AppSettings.isBlockPasteEnabled(app)
ip.pasteText(action.text, true, false, blockPaste) val pasteWordBreak = AppSettings.isPasteWordBreakEnabled(app)
ip.pasteText(action.text, true, false, blockPaste, pasteWordBreak)
_screenVersion.value = System.currentTimeMillis() _screenVersion.value = System.currentTimeMillis()
} }
} }
is TerminalInputAction.SendKeysAction -> {
log.info("SendKeysAction: keys='${action.keys}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}, isNvt=$isNvt")
ip.isKeyboardLocked = false
c.ps?.SendKeys(action.keys)
_screenVersion.value = System.currentTimeMillis()
}
is TerminalInputAction.SendAid -> { is TerminalInputAction.SendAid -> {
if (isNvt) { if (isNvt) {
if (action.aidCode == AID_ENTER) { if (action.aidCode == AID_ENTER) {
@@ -43,6 +43,17 @@ object AppSettings {
private const val KEY_NUMERIC_FIELD_LOCK = "numeric_field_lock" private const val KEY_NUMERIC_FIELD_LOCK = "numeric_field_lock"
private const val KEY_AUTO_SKIP_ENABLED = "auto_skip_enabled" private const val KEY_AUTO_SKIP_ENABLED = "auto_skip_enabled"
// Hotspots aligned with j3270 v1.2.2
private const val KEY_HOTSPOTS_ENABLED = "hotspots_enabled"
private const val KEY_HOTSPOTS_URL_ENABLED = "hotspots_url_enabled"
private const val KEY_HOTSPOTS_PF_ENABLED = "hotspots_pf_enabled"
private const val KEY_HOTSPOTS_MENU_CHOICES_ENABLED = "hotspots_menu_choices_enabled"
// Selection & Trim aligned with j3270 v1.2.2
private const val KEY_TRIM_RECT_SIZING_HANDLES = "trim_rect_sizing_handles"
private const val KEY_TRIM_RECT_REMAIN_AFTER_EDIT = "trim_rect_remain_after_edit"
private const val KEY_PASTE_WORD_BREAK = "paste_word_break"
enum class StartupBehavior { enum class StartupBehavior {
SHOW_CONNECT, SHOW_CONNECT,
DO_NOTHING, DO_NOTHING,
@@ -283,6 +294,64 @@ object AppSettings {
getPrefs(context).edit().putBoolean(KEY_AUTO_SKIP_ENABLED, enabled).apply() getPrefs(context).edit().putBoolean(KEY_AUTO_SKIP_ENABLED, enabled).apply()
} }
// ========== Hotspots (Default DISABLED for touch screens) ==========
fun isHotspotsEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_ENABLED, false)
}
fun setHotspotsEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_ENABLED, enabled).apply()
}
fun isHotspotsUrlEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_URL_ENABLED, true)
}
fun setHotspotsUrlEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_URL_ENABLED, enabled).apply()
}
fun isHotspotsPfEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_PF_ENABLED, true)
}
fun setHotspotsPfEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_PF_ENABLED, enabled).apply()
}
fun isHotspotsMenuChoicesEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_MENU_CHOICES_ENABLED, true)
}
fun setHotspotsMenuChoicesEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_MENU_CHOICES_ENABLED, enabled).apply()
}
// ========== Selection Handles & Trim ==========
fun isTrimRectSizingHandlesEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_TRIM_RECT_SIZING_HANDLES, false)
}
fun setTrimRectSizingHandlesEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_TRIM_RECT_SIZING_HANDLES, enabled).apply()
}
fun isTrimRectRemainAfterEditEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_TRIM_RECT_REMAIN_AFTER_EDIT, false)
}
fun setTrimRectRemainAfterEditEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_TRIM_RECT_REMAIN_AFTER_EDIT, enabled).apply()
}
fun isPasteWordBreakEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_PASTE_WORD_BREAK, true)
}
fun setPasteWordBreakEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_PASTE_WORD_BREAK, enabled).apply()
}
// ========== INI Config Export / Import ========== // ========== INI Config Export / Import ==========
fun exportToIni(context: Context, writer: Writer) { fun exportToIni(context: Context, writer: Writer) {
@@ -313,6 +382,16 @@ object AppSettings {
p.println("dynamicCols = ${getDynamicCols(context)}") p.println("dynamicCols = ${getDynamicCols(context)}")
p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}") p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}")
p.println("blockPaste = ${isBlockPasteEnabled(context)}") p.println("blockPaste = ${isBlockPasteEnabled(context)}")
p.println("trimRectSizingHandles = ${isTrimRectSizingHandlesEnabled(context)}")
p.println("trimRectRemainAfterEdit = ${isTrimRectRemainAfterEditEnabled(context)}")
p.println("pasteWordBreak = ${isPasteWordBreakEnabled(context)}")
p.println()
p.println("[hotspots]")
p.println("hotspotsEnabled = ${isHotspotsEnabled(context)}")
p.println("hotspotsUrlEnabled = ${isHotspotsUrlEnabled(context)}")
p.println("hotspotsPfEnabled = ${isHotspotsPfEnabled(context)}")
p.println("hotspotsMenuChoicesEnabled = ${isHotspotsMenuChoicesEnabled(context)}")
p.println() p.println()
p.println("[entryassist]") p.println("[entryassist]")
@@ -339,7 +418,7 @@ object AppSettings {
p.flush() p.flush()
} }
fun loadFromIni(context: Context, reader: Reader) { fun parseIni(reader: Reader, callback: (section: String, key: String, value: String) -> Unit) {
val bufferedReader = BufferedReader(reader) val bufferedReader = BufferedReader(reader)
var currentSection = "" var currentSection = ""
@@ -353,13 +432,19 @@ object AppSettings {
if (eq >= 0) { if (eq >= 0) {
val key = line.substring(0, eq).trim() val key = line.substring(0, eq).trim()
val value = line.substring(eq + 1).trim() val value = line.substring(eq + 1).trim()
applyConfigEntry(context, currentSection, key, value) callback(currentSection, key, value)
} }
} }
} }
} }
} }
fun loadFromIni(context: Context, reader: Reader) {
parseIni(reader) { section, key, value ->
applyConfigEntry(context, section, key, value)
}
}
private fun applyConfigEntry(context: Context, section: String, key: String, value: String) { private fun applyConfigEntry(context: Context, section: String, key: String, value: String) {
when (section) { when (section) {
"appearance" -> { "appearance" -> {
@@ -381,11 +466,26 @@ object AppSettings {
"dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) } "dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) }
"blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean()) "blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean())
"blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean()) "blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean())
"trimrectsizinghandles", "sizinghandles" -> setTrimRectSizingHandlesEnabled(context, value.toBoolean())
"trimrectremainafteredit", "remainafteredit" -> setTrimRectRemainAfterEditEnabled(context, value.toBoolean())
"pastewordbreak", "wordbreak" -> setPasteWordBreakEnabled(context, value.toBoolean())
"hotspotsenabled" -> setHotspotsEnabled(context, value.toBoolean())
} }
} }
"clipboard" -> { "clipboard" -> {
when (key.lowercase(Locale.US)) { when (key.lowercase(Locale.US)) {
"blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean()) "blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean())
"trimrectsizinghandles", "sizinghandles" -> setTrimRectSizingHandlesEnabled(context, value.toBoolean())
"trimrectremainafteredit", "remainafteredit" -> setTrimRectRemainAfterEditEnabled(context, value.toBoolean())
"pastewordbreak", "wordbreak" -> setPasteWordBreakEnabled(context, value.toBoolean())
}
}
"hotspots" -> {
when (key.lowercase(Locale.US)) {
"hotspotsenabled", "enabled" -> setHotspotsEnabled(context, value.toBoolean())
"hotspotsurlenabled", "urlenabled", "url" -> setHotspotsUrlEnabled(context, value.toBoolean())
"hotspotspfenabled", "pfenabled", "pf" -> setHotspotsPfEnabled(context, value.toBoolean())
"hotspotsmenuchoicesenabled", "menuchoicesenabled", "menuchoices", "menu" -> setHotspotsMenuChoicesEnabled(context, value.toBoolean())
} }
} }
"entryassist" -> { "entryassist" -> {
@@ -38,7 +38,9 @@ fun TwoRowKeyBar(
onFieldInspectorClick: () -> Unit = {}, onFieldInspectorClick: () -> Unit = {},
onFindClick: () -> Unit = {}, onFindClick: () -> Unit = {},
onScreenExportClick: () -> Unit = {}, onScreenExportClick: () -> Unit = {},
onScreenHistoryClick: () -> Unit = {},
onScriptClick: () -> Unit = {}, onScriptClick: () -> Unit = {},
onMacroRuntimeClick: () -> Unit = {},
onPrinterSessionClick: () -> Unit = {}, onPrinterSessionClick: () -> Unit = {},
onToggleLightPen: () -> Unit = {}, onToggleLightPen: () -> Unit = {},
onToggleInsert: () -> Unit = {}, onToggleInsert: () -> Unit = {},
@@ -162,6 +164,13 @@ fun TwoRowKeyBar(
onScreenExportClick() onScreenExportClick()
} }
) )
DropdownMenuItem(
text = { Text("Screen History", color = Color.White) },
onClick = {
menuExpanded = false
onScreenHistoryClick()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Run Keystroke Script", color = Color.White) }, text = { Text("Run Keystroke Script", color = Color.White) },
onClick = { onClick = {
@@ -169,6 +178,13 @@ fun TwoRowKeyBar(
onScriptClick() onScriptClick()
} }
) )
DropdownMenuItem(
text = { Text("Macro Runtime (<HASCRIPT>)", color = Color.White) },
onClick = {
menuExpanded = false
onMacroRuntimeClick()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("3287 Printer Session", color = Color.White) }, text = { Text("3287 Printer Session", color = Color.White) },
onClick = { onClick = {
@@ -0,0 +1,351 @@
package haus.nightmare.a3270.ui
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.util.Collections
import java.util.regex.Pattern
/**
* Real-time hotspot and URL auto-detection engine matching IBM Host On-Demand
* ShowURLOptions and ScreenText parity.
*
* Scans screen text using regular expressions for:
* 1. URLs (http://, https://, ftp://, file://, mailto:, www.)
* 2. Function keys (PF1..PF24, F1..F24, PA1..PA3, [ENTER], ENTER)
* 3. Menu choices ("1.", "2.", "===> 3", "=> 4")
*
* Provides hit-testing for touch/mouse taps and generates keystroke actions.
*/
class HotspotEngine {
enum class HotspotType {
URL,
PF_KEY,
ENTER,
MENU_CHOICE
}
data class Hotspot(
val type: HotspotType,
val startRow: Int,
val startCol: Int,
val endRow: Int,
val endCol: Int,
val displayText: String,
val action: String,
val tooltip: String
) {
val text: String get() = displayText
/**
* Test whether the given row/col coordinate falls within this hotspot.
*/
fun contains(row: Int, col: Int): Boolean {
if (row < startRow || row > endRow) {
return false
}
if (startRow == endRow) {
return col in startCol..endCol
}
if (row == startRow) {
return col >= startCol
}
if (row == endRow) {
return col <= endCol
}
return true // Intermediate rows
}
}
var urlEnabled: Boolean = true
set(value) {
field = value
invalidateCache()
}
var pfKeysEnabled: Boolean = true
set(value) {
field = value
invalidateCache()
}
var menuChoicesEnabled: Boolean = true
set(value) {
field = value
invalidateCache()
}
companion object {
private val URL_PATTERN = Pattern.compile(
"(?:https?|ftp|file)://[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|www\\.[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|mailto:[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+",
Pattern.CASE_INSENSITIVE
)
private val PF_KEY_PATTERN = Pattern.compile(
"(?:(?<=\\W)|^)(PF(?:[1-9]|1[0-9]|2[0-4])|F(?:[1-9]|1[0-9]|2[0-4])|PA[1-3]|\\[ENTER\\]|ENTER)(?:(?=\\W)|$)",
Pattern.CASE_INSENSITIVE
)
private val MENU_NUMBER_PATTERN = Pattern.compile(
"(?:(?<=\\s)|^)(\\d{1,2})\\.(?:(?=\\s)|$)"
)
private val MENU_PROMPT_PATTERN = Pattern.compile(
"(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)"
)
private fun markOccupied(arr: BooleanArray, start: Int, end: Int) {
val s = start.coerceAtLeast(0)
val e = end.coerceAtMost(arr.size)
for (i in s until e) {
arr[i] = true
}
}
private fun isRangeOccupied(arr: BooleanArray, start: Int, end: Int): Boolean {
val s = start.coerceAtLeast(0)
val e = end.coerceAtMost(arr.size)
for (i in s until e) {
if (arr[i]) return true
}
return false
}
}
private var cachedScreenText: String = ""
private var cachedRows: Int = 0
private var cachedCols: Int = 0
private var cachedHotspots: List<Hotspot> = emptyList()
fun invalidateCache() {
synchronized(this) {
cachedScreenText = ""
cachedHotspots = emptyList()
}
}
/**
* Scan the given ScreenBuffer for interactive hotspots.
*/
fun scan(sb: ScreenBuffer?): List<Hotspot> {
if (sb == null) return emptyList()
val rows = sb.displayRows
val cols = sb.displayCols
val fullText = StringBuilder(rows * (cols + 1))
for (r in 0 until rows) {
for (c in 0 until cols) {
val baddr = r * cols + c
val ea = sb.getDisplayCell(baddr)
val ch = ea?.ucs4 ?: ' '
if (ea == null || ea.isFieldAttribute || ch <= ' ' || ch == '\u00FF') {
fullText.append(' ')
} else {
fullText.append(ch)
}
}
if (r < rows - 1) {
fullText.append('\n')
}
}
return scanText(fullText.toString(), rows, cols)
}
/**
* Scan formatted screen text (lines separated by '\n') for hotspots.
*/
fun scanText(screenText: String?, rows: Int, cols: Int, enabled: Boolean = true): List<Hotspot> {
if (!enabled || screenText == null || rows <= 0 || cols <= 0) {
return emptyList()
}
synchronized(this) {
if (screenText == cachedScreenText && rows == cachedRows && cols == cachedCols) {
return cachedHotspots
}
val hotspots = mutableListOf<Hotspot>()
val lines = screenText.split("\n")
val actualRows = minOf(rows, lines.size)
for (r in 0 until actualRows) {
val line = lines[r]
val occupied = BooleanArray(line.length + 1)
// 1. Scan URLs
if (urlEnabled) {
val mUrl = URL_PATTERN.matcher(line)
while (mUrl.find()) {
val startCol = mUrl.start()
var endCol = mUrl.end()
var match = mUrl.group()
while (match.length > 1) {
val last = match[match.length - 1]
if (last == '.' || last == ',' || last == ';' || last == ':' || last == ')' || last == ']' || last == '>') {
match = match.substring(0, match.length - 1)
endCol--
} else {
break
}
}
if (match.isEmpty()) continue
var actionUrl = match
if (match.startsWith("www.", ignoreCase = true)) {
actionUrl = "http://$match"
}
val h = Hotspot(
type = HotspotType.URL,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = match,
action = actionUrl,
tooltip = "Open URL: $actionUrl"
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
}
// 2. Scan PF / Function keys
if (pfKeysEnabled) {
val mPf = PF_KEY_PATTERN.matcher(line)
while (mPf.find()) {
val startCol = mPf.start(1)
val endCol = mPf.end(1)
if (isRangeOccupied(occupied, startCol, endCol)) continue
val matchStr = mPf.group(1) ?: continue
val keyText = matchStr.uppercase()
val action: String
val tooltip: String
if (keyText == "ENTER" || keyText == "[ENTER]") {
action = "[enter]"
tooltip = "Send [ENTER]"
} else if (keyText.startsWith("PF")) {
val numStr = keyText.substring(2)
action = "[pf$numStr]"
tooltip = "Send ${action.uppercase()}"
} else if (keyText.startsWith("F")) {
val numStr = keyText.substring(1)
action = "[pf$numStr]"
tooltip = "Send ${action.uppercase()}"
} else if (keyText.startsWith("PA")) {
val numStr = keyText.substring(2)
action = "[pa$numStr]"
tooltip = "Send ${action.uppercase()}"
} else {
continue
}
val h = Hotspot(
type = if (keyText.contains("ENTER")) HotspotType.ENTER else HotspotType.PF_KEY,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = matchStr,
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
}
// 3. Scan Menu choices ("1.", "2.", "===> 3", "=> 4")
if (menuChoicesEnabled) {
val mNum = MENU_NUMBER_PATTERN.matcher(line)
while (mNum.find()) {
val startCol = mNum.start(1)
val endCol = mNum.end()
if (isRangeOccupied(occupied, startCol, endCol)) continue
val choiceNum = mNum.group(1)
val action = "$choiceNum[enter]"
val tooltip = "Select option $choiceNum"
val h = Hotspot(
type = HotspotType.MENU_CHOICE,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = "$choiceNum.",
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
val mPrompt = MENU_PROMPT_PATTERN.matcher(line)
while (mPrompt.find()) {
val startCol = mPrompt.start()
val endCol = mPrompt.end()
if (isRangeOccupied(occupied, startCol, endCol)) continue
val choiceNum = mPrompt.group(1)
val action = "$choiceNum[enter]"
val tooltip = "Execute choice $choiceNum"
val h = Hotspot(
type = HotspotType.MENU_CHOICE,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = mPrompt.group(),
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
}
}
this.cachedScreenText = screenText
this.cachedRows = rows
this.cachedCols = cols
this.cachedHotspots = Collections.unmodifiableList(hotspots)
return cachedHotspots
}
}
/**
* Hit-test for a hotspot at the specified terminal row and column.
*/
fun findHotspot(row: Int, col: Int): Hotspot? {
synchronized(this) {
for (h in cachedHotspots) {
if (h.contains(row, col)) {
return h
}
}
return null
}
}
fun getHotspots(): List<Hotspot> {
synchronized(this) {
return cachedHotspots
}
}
fun findHotspotAt(hotspots: List<Hotspot>, row: Int, col: Int): Hotspot? {
for (h in hotspots) {
if (h.contains(row, col)) {
return h
}
}
return null
}
}
@@ -0,0 +1,365 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.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 haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.macro.Macro
import haus.nightmare.lib3270j.macro.MacroScreen
import haus.nightmare.lib3270j.macro.action.MacroAction
import haus.nightmare.lib3270j.macro.action.MacroActionPrompt
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicReference
private const val DEFAULT_MACRO_XML = """<HASCRIPT name="SampleLogon" timeout="15000" usevars="true">
<vars>
<create name="${'$'}userId" type="string" value="USER01" />
<create name="${'$'}count" type="integer" value="0" />
</vars>
<screen name="LogonScreen" entryscreen="true">
<description>
<oia status="NOTINHIBITED" />
</description>
<actions>
<input value="${'$'}userId + '[enter]'" movecursor="true" xlatehostkeys="true" />
</actions>
<nextscreens timeout="10000">
<nextscreen name="ReadyScreen" />
</nextscreens>
</screen>
<screen name="ReadyScreen" exitscreen="true">
<description>
<oia status="NOTINHIBITED" />
</description>
<actions>
<message title="Success" value="Logon macro completed successfully!" />
</actions>
</screen>
</HASCRIPT>"""
data class MacroPromptState(
val prompt: MacroActionPrompt,
val deferred: CompletableDeferred<String?>
)
@Composable
fun MacroDialog(
client: Telnet3270Client?,
onDismiss: () -> Unit
) {
var macroXml by remember { mutableStateOf(DEFAULT_MACRO_XML) }
var executionLog by remember { mutableStateOf("") }
var statusText by remember { mutableStateOf("Status: Stopped") }
var isPlaying by remember { mutableStateOf(false) }
var isPaused by remember { mutableStateOf(false) }
var activePrompt by remember { mutableStateOf<MacroPromptState?>(null) }
val coroutineScope = rememberCoroutineScope()
val macroRef = remember { AtomicReference<Macro?>(null) }
fun appendLog(msg: String) {
val line = "[${java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US).format(java.util.Date())}] $msg\n"
executionLog += line
}
DisposableEffect(client) {
val macro = if (client != null) Macro(client) else null
macroRef.set(macro)
if (macro != null) {
val listener = object : Macro.MacroListener {
override fun macroStateChanged(m: Macro, oldState: Int, newState: Int) {
when (newState) {
Macro.STATE_PLAYING -> {
isPlaying = true
isPaused = false
statusText = "Status: Playing"
appendLog("Macro playing...")
}
Macro.STATE_PAUSED -> {
isPaused = true
statusText = "Status: Paused"
appendLog("Macro paused.")
}
Macro.STATE_ERROR -> {
isPlaying = false
isPaused = false
statusText = "Status: Error"
appendLog("Macro error.")
}
Macro.STATE_STOPPED -> {
isPlaying = false
isPaused = false
statusText = "Status: Stopped"
appendLog("Macro stopped.")
}
}
}
override fun macroScreenMatching(screen: MacroScreen) {
appendLog("Matched screen: [${screen.name}]")
}
override fun macroActionExecuting(action: MacroAction) {
appendLog(" -> Executing: ${action.javaClass.simpleName}")
}
override fun macroMessage(title: String, message: String) {
appendLog("MESSAGE [$title]: $message")
}
override fun macroError(error: String) {
isPlaying = false
isPaused = false
statusText = "Status: Error - $error"
appendLog("ERROR: $error")
}
}
macro.addMacroListener(listener)
macro.setPromptHandler { prompt ->
val deferred = CompletableDeferred<String?>()
activePrompt = MacroPromptState(prompt, deferred)
kotlinx.coroutines.runBlocking {
deferred.await() ?: ""
}
}
}
onDispose {
macro?.stop()
macroRef.set(null)
}
}
Dialog(
onDismissRequest = {
if (!isPlaying) onDismiss()
},
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.90f)
.padding(vertical = 10.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"XML Macro Runtime (<HASCRIPT>)",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
statusText,
fontSize = 11.sp,
color = if (isPlaying) Color(0xFF51CF66) else Color.LightGray
)
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Macro XML Editor Pane
Text("Macro Definition XML:", fontSize = 12.sp, color = Color.LightGray)
OutlinedTextField(
value = macroXml,
onValueChange = { macroXml = it },
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color.White
),
modifier = Modifier
.fillMaxWidth()
.weight(0.55f),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF339AF0),
unfocusedBorderColor = Color(0xFF495057),
focusedContainerColor = Color(0xFF121212),
unfocusedContainerColor = Color(0xFF121212)
)
)
// Execution Log Pane
Text("Execution Log:", fontSize = 12.sp, color = Color.LightGray)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.45f)
.background(Color(0xFF0F0F0F), RoundedCornerShape(6.dp))
.padding(8.dp)
) {
val logScroll = rememberScrollState()
Text(
text = executionLog.ifBlank { "Ready to execute macro..." },
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = if (executionLog.isBlank()) Color.Gray else Color(0xFF69DB7C),
modifier = Modifier
.fillMaxSize()
.verticalScroll(logScroll)
)
}
// Control Buttons: Play, Pause, Stop, Close
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
val macro = macroRef.get()
if (macro != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
val screens = haus.nightmare.lib3270j.macro.MacroXmlParser.parse(macroXml)
macro.setScreens(screens)
macro.setClient(client)
macro.play()
} catch (ex: Exception) {
appendLog("Execution exception: ${ex.message}")
}
}
}
},
enabled = !isPlaying && client != null && client.connectionState?.isFullSession == true,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)),
modifier = Modifier.weight(1f)
) {
Text("▶ Play", fontSize = 12.sp)
}
Button(
onClick = {
val macro = macroRef.get()
if (macro != null) {
if (macro.state == Macro.STATE_PLAYING) {
macro.pause()
} else if (macro.state == Macro.STATE_PAUSED) {
macro.resume()
}
}
},
enabled = isPlaying,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE67700)),
modifier = Modifier.weight(1f)
) {
Text(if (isPaused) "Resume" else "Pause", fontSize = 12.sp)
}
Button(
onClick = {
val macro = macroRef.get()
macro?.stop()
},
enabled = isPlaying,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.weight(1f)
) {
Text("■ Stop", fontSize = 12.sp)
}
Button(
onClick = {
val macro = macroRef.get()
macro?.stop()
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF495057)),
modifier = Modifier.width(75.dp)
) {
Text("Close", fontSize = 12.sp)
}
}
}
}
}
// Modal prompt dialog when macro requests <prompt> parameter input
val promptState = activePrompt
if (promptState != null) {
var inputValue by remember { mutableStateOf(promptState.prompt.defaultValue ?: "") }
AlertDialog(
onDismissRequest = {
promptState.deferred.complete(null)
activePrompt = null
},
title = {
Text(
text = promptState.prompt.title.ifBlank { "Macro Input Prompt" },
fontSize = 15.sp,
fontWeight = FontWeight.Bold
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = promptState.prompt.description.ifBlank { "Enter parameter:" },
fontSize = 13.sp,
color = Color.LightGray
)
OutlinedTextField(
value = inputValue,
onValueChange = { inputValue = it },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
promptState.deferred.complete(inputValue)
activePrompt = null
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(
onClick = {
promptState.deferred.complete(null)
activePrompt = null
}
) {
Text("Cancel")
}
}
)
}
}
@@ -0,0 +1,248 @@
package haus.nightmare.a3270.ui
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 haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer.ScreenSnapshot
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ScreenHistoryDialog(
historyBuffer: ScreenHistoryBuffer?,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) }
var historySize by remember { mutableIntStateOf(historyBuffer?.size() ?: 0) }
var selectedIndex by remember { mutableIntStateOf((historyBuffer?.size() ?: 1) - 1) }
// Listen for live updates if host writes new screens
DisposableEffect(historyBuffer) {
if (historyBuffer == null) return@DisposableEffect onDispose {}
val listener = ScreenHistoryBuffer.ScreenHistoryListener { buf, _ ->
val newSize = buf.size()
historySize = newSize
if (selectedIndex >= newSize - 2) {
selectedIndex = newSize - 1
}
}
historyBuffer.addListener(listener)
historySize = historyBuffer.size()
if (historySize > 0 && selectedIndex < 0) {
selectedIndex = historySize - 1
}
onDispose {
historyBuffer.removeListener(listener)
}
}
val currentSnapshot: ScreenSnapshot? = remember(historyBuffer, historySize, selectedIndex) {
if (historyBuffer != null && historySize > 0 && selectedIndex in 0 until historySize) {
try {
historyBuffer.get(selectedIndex)
} catch (_: Exception) {
null
}
} else {
null
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.85f)
.padding(vertical = 12.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Top Header: Title & Info
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Screen History Buffer",
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
if (currentSnapshot != null) {
Text(
dateFormat.format(Date(currentSnapshot.timestamp)),
fontSize = 12.sp,
color = Color.LightGray
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Scrub navigation slider & buttons
if (historySize > 0 && currentSnapshot != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = { if (selectedIndex > 0) selectedIndex-- },
enabled = selectedIndex > 0,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF343A40)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.height(36.dp)
) {
Text("◄ Prev", fontSize = 12.sp)
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Screen ${selectedIndex + 1} of $historySize",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
if (historySize > 1) {
Slider(
value = (selectedIndex + 1).toFloat(),
onValueChange = { selectedIndex = (it.toInt() - 1).coerceIn(0, historySize - 1) },
valueRange = 1f..historySize.toFloat(),
steps = if (historySize > 2) historySize - 2 else 0,
modifier = Modifier.fillMaxWidth().height(24.dp)
)
}
}
Button(
onClick = { if (selectedIndex < historySize - 1) selectedIndex++ },
enabled = selectedIndex < historySize - 1,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF343A40)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.height(36.dp)
) {
Text("Next ►", fontSize = 12.sp)
}
}
}
// Terminal Preview Pane
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(Color.Black, RoundedCornerShape(8.dp))
.padding(8.dp)
) {
if (currentSnapshot != null) {
val verticalScroll = rememberScrollState()
val horizontalScroll = rememberScrollState()
Text(
text = currentSnapshot.text,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFF00FF41), // Classic 3270 Green Phosphor
lineHeight = 14.sp,
modifier = Modifier
.fillMaxSize()
.verticalScroll(verticalScroll)
.horizontalScroll(horizontalScroll)
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"No history frames captured yet.",
color = Color.Gray,
fontSize = 14.sp
)
}
}
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
if (currentSnapshot != null) {
clipboardManager.setText(AnnotatedString(currentSnapshot.text))
Toast.makeText(context, "Full screen copied to clipboard", Toast.LENGTH_SHORT).show()
}
},
enabled = currentSnapshot != null,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C2D30)),
modifier = Modifier.weight(1f)
) {
Text("Copy Screen", fontSize = 12.sp)
}
Button(
onClick = {
if (currentSnapshot != null) {
clipboardManager.setText(AnnotatedString(currentSnapshot.formattedTable))
Toast.makeText(context, "Table text copied to clipboard", Toast.LENGTH_SHORT).show()
}
},
enabled = currentSnapshot != null,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C2D30)),
modifier = Modifier.weight(1f)
) {
Text("Copy Table", fontSize = 12.sp)
}
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF495057)),
modifier = Modifier.width(80.dp)
) {
Text("Close", fontSize = 12.sp)
}
}
}
}
}
}
@@ -31,6 +31,13 @@ fun SettingsDialog(
initialCursorStyle: String = "BLOCK", initialCursorStyle: String = "BLOCK",
initialBlockSelectMode: Boolean = false, initialBlockSelectMode: Boolean = false,
initialBlockPaste: Boolean = true, initialBlockPaste: Boolean = true,
initialTrimRectSizingHandles: Boolean = false,
initialTrimRectRemainAfterEdit: Boolean = false,
initialPasteWordBreak: Boolean = true,
initialHotspotsEnabled: Boolean = false,
initialHotspotsUrlEnabled: Boolean = true,
initialHotspotsPfEnabled: Boolean = true,
initialHotspotsMenuChoicesEnabled: Boolean = true,
initialStartupBehavior: String = "SHOW_CONNECT", initialStartupBehavior: String = "SHOW_CONNECT",
initialDynamicRows: Int = 62, initialDynamicRows: Int = 62,
initialDynamicCols: Int = 160, initialDynamicCols: Int = 160,
@@ -58,6 +65,13 @@ fun SettingsDialog(
cursorStyle: String, cursorStyle: String,
blockSelectMode: Boolean, blockSelectMode: Boolean,
blockPaste: Boolean, blockPaste: Boolean,
trimRectSizingHandles: Boolean,
trimRectRemainAfterEdit: Boolean,
pasteWordBreak: Boolean,
hotspotsEnabled: Boolean,
hotspotsUrlEnabled: Boolean,
hotspotsPfEnabled: Boolean,
hotspotsMenuChoicesEnabled: Boolean,
startupBehavior: String, startupBehavior: String,
dynamicRows: Int, dynamicRows: Int,
dynamicCols: Int, dynamicCols: Int,
@@ -89,6 +103,13 @@ fun SettingsDialog(
var cursorStyle by remember { mutableStateOf(initialCursorStyle) } var cursorStyle by remember { mutableStateOf(initialCursorStyle) }
var blockSelectMode by remember { mutableStateOf(initialBlockSelectMode) } var blockSelectMode by remember { mutableStateOf(initialBlockSelectMode) }
var blockPaste by remember { mutableStateOf(initialBlockPaste) } var blockPaste by remember { mutableStateOf(initialBlockPaste) }
var trimRectSizingHandles by remember { mutableStateOf(initialTrimRectSizingHandles) }
var trimRectRemainAfterEdit by remember { mutableStateOf(initialTrimRectRemainAfterEdit) }
var pasteWordBreak by remember { mutableStateOf(initialPasteWordBreak) }
var hotspotsEnabled by remember { mutableStateOf(initialHotspotsEnabled) }
var hotspotsUrlEnabled by remember { mutableStateOf(initialHotspotsUrlEnabled) }
var hotspotsPfEnabled by remember { mutableStateOf(initialHotspotsPfEnabled) }
var hotspotsMenuChoicesEnabled by remember { mutableStateOf(initialHotspotsMenuChoicesEnabled) }
var startupBehavior by remember { mutableStateOf(initialStartupBehavior) } var startupBehavior by remember { mutableStateOf(initialStartupBehavior) }
var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) } var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) }
var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) } var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) }
@@ -284,6 +305,147 @@ fun SettingsDialog(
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
// Trim Rectangle Sizing Handles
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Trim Rectangle Sizing Handles", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Show interactive drag handles around block selection.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = trimRectSizingHandles,
onCheckedChange = { trimRectSizingHandles = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(10.dp))
// Trim Rectangle Remains After Edit
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Keep Selection After Edit", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Trim rectangle remains after cut/copy/paste.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = trimRectRemainAfterEdit,
onCheckedChange = { trimRectRemainAfterEdit = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(10.dp))
// Paste Word-Break
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Paste Without Splitting Words", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Avoid splitting words across fields when pasting.", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = pasteWordBreak,
onCheckedChange = { pasteWordBreak = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// SECTION: INTERACTIVE HOTSPOTS
Text("Interactive Hotspots", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF339AF0))
Spacer(modifier = Modifier.height(8.dp))
// Master Hotspots Switch (Default DISABLED)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Enable Interactive Hotspots", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Tappable URLs, function keys, and menu choices (disabled by default for touch screens).", fontSize = 12.sp, color = Color.LightGray)
}
Switch(
checked = hotspotsEnabled,
onCheckedChange = { hotspotsEnabled = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
if (hotspotsEnabled) {
Spacer(modifier = Modifier.height(10.dp))
// Hotspots: URL Detection
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Auto-Detect URLs", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White)
Text("http://, https://, ftp://, www., mailto:", fontSize = 11.sp, color = Color.LightGray)
}
Switch(
checked = hotspotsUrlEnabled,
onCheckedChange = { hotspotsUrlEnabled = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(8.dp))
// Hotspots: Function Key Detection
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Auto-Detect Function Keys", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White)
Text("PF1..PF24, F1..F24, PA1..PA3, [ENTER]", fontSize = 11.sp, color = Color.LightGray)
}
Switch(
checked = hotspotsPfEnabled,
onCheckedChange = { hotspotsPfEnabled = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
Spacer(modifier = Modifier.height(8.dp))
// Hotspots: Menu Choice Detection
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Auto-Detect Menu Choices", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White)
Text("\"1.\", \"2.\", \"===> 3\", \"=> 4\"", fontSize = 11.sp, color = Color.LightGray)
}
Switch(
checked = hotspotsMenuChoicesEnabled,
onCheckedChange = { hotspotsMenuChoicesEnabled = it },
colors = SwitchDefaults.colors(checkedThumbColor = Color.White, checkedTrackColor = Color(0xFF2B8A3E))
)
}
}
Spacer(modifier = Modifier.height(10.dp))
// Haptic Feedback // Haptic Feedback
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -730,6 +892,13 @@ fun SettingsDialog(
cursorStyle, cursorStyle,
blockSelectMode, blockSelectMode,
blockPaste, blockPaste,
trimRectSizingHandles,
trimRectRemainAfterEdit,
pasteWordBreak,
hotspotsEnabled,
hotspotsUrlEnabled,
hotspotsPfEnabled,
hotspotsMenuChoicesEnabled,
startupBehavior, startupBehavior,
dRows, dRows,
dCols, dCols,
@@ -784,6 +953,13 @@ fun SettingsDialog(
cursorStyle = haus.nightmare.a3270.storage.AppSettings.getCursorStyle(context) cursorStyle = haus.nightmare.a3270.storage.AppSettings.getCursorStyle(context)
blockSelectMode = haus.nightmare.a3270.storage.AppSettings.isBlockSelectModeEnabled(context) blockSelectMode = haus.nightmare.a3270.storage.AppSettings.isBlockSelectModeEnabled(context)
blockPaste = haus.nightmare.a3270.storage.AppSettings.isBlockPasteEnabled(context) blockPaste = haus.nightmare.a3270.storage.AppSettings.isBlockPasteEnabled(context)
trimRectSizingHandles = haus.nightmare.a3270.storage.AppSettings.isTrimRectSizingHandlesEnabled(context)
trimRectRemainAfterEdit = haus.nightmare.a3270.storage.AppSettings.isTrimRectRemainAfterEditEnabled(context)
pasteWordBreak = haus.nightmare.a3270.storage.AppSettings.isPasteWordBreakEnabled(context)
hotspotsEnabled = haus.nightmare.a3270.storage.AppSettings.isHotspotsEnabled(context)
hotspotsUrlEnabled = haus.nightmare.a3270.storage.AppSettings.isHotspotsUrlEnabled(context)
hotspotsPfEnabled = haus.nightmare.a3270.storage.AppSettings.isHotspotsPfEnabled(context)
hotspotsMenuChoicesEnabled = haus.nightmare.a3270.storage.AppSettings.isHotspotsMenuChoicesEnabled(context)
startupBehavior = haus.nightmare.a3270.storage.AppSettings.getStartupBehavior(context).name startupBehavior = haus.nightmare.a3270.storage.AppSettings.getStartupBehavior(context).name
dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString() dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString() dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
@@ -71,16 +71,23 @@ fun TerminalView(
isInsertMode: Boolean = false, isInsertMode: Boolean = false,
blockSelectMode: Boolean = false, blockSelectMode: Boolean = false,
blockPaste: Boolean = true, blockPaste: Boolean = true,
trimRectSizingHandles: Boolean = false,
trimRectRemainAfterEdit: Boolean = false,
hotspotsEnabled: Boolean = false,
hotspotEngine: HotspotEngine? = null,
fourColorOverride: Boolean = false, fourColorOverride: Boolean = false,
searchHighlightAddr: Int = -1, searchHighlightAddr: Int = -1,
searchHighlightLen: Int = 0, searchHighlightLen: Int = 0,
onTapAddress: (Int) -> Unit, onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null, onLightPenSelect: ((Int) -> Unit)? = null,
onSendHotspotAction: ((String) -> Unit)? = null,
onOpenUrl: ((String) -> Unit)? = null,
onGraphicTouch: ((Int, Int, Int) -> Unit)? = null, onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {}, onPasteText: (String) -> Unit = {},
onPasteLineWrap: ((String, Int, Boolean) -> Unit)? = null, onPasteLineWrap: ((String, Int, Boolean) -> Unit)? = null,
onToggleBlockSelectMode: (() -> Unit)? = null, onToggleBlockSelectMode: (() -> Unit)? = null,
onToggleBlockPaste: (() -> Unit)? = null, onToggleBlockPaste: (() -> Unit)? = null,
onToggleHotspots: (() -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -168,8 +175,10 @@ fun TerminalView(
} else if (totalDistance <= slop) { } else if (totalDistance <= slop) {
// Short Tap // Short Tap
showContextMenu = false showContextMenu = false
selectionStart = null if (!trimRectRemainAfterEdit) {
selectionEnd = null selectionStart = null
selectionEnd = null
}
val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows) val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) { if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1) val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
@@ -188,15 +197,48 @@ fun TerminalView(
} else if (isLightPenMode) { } else if (isLightPenMode) {
onLightPenSelect?.invoke(clickAddr) onLightPenSelect?.invoke(clickAddr)
} else { } else {
var addr = clickAddr // 1. Hotspot tap check when enabled
val buf = screenBuffer var handledHotspot = false
if (buf != null && buf.isFormatted) { if (hotspotsEnabled && hotspotEngine != null) {
val faVal = buf.getFieldAttributeAt(addr) val h = hotspotEngine.findHotspot(row, col)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) { if (h != null) {
addr = buf.findNextUnprotected(addr) if (h.type == HotspotEngine.HotspotType.URL) {
onOpenUrl?.invoke(h.action)
handledHotspot = true
} else {
onSendHotspotAction?.invoke(h.action)
handledHotspot = true
}
}
}
if (!handledHotspot) {
// 2. Detectable field check (HoD parity)
val buf = screenBuffer
var isDetectable = false
if (buf != null && buf.isFormatted) {
val faPos = buf.findFieldAttribute(clickAddr)
if (faPos >= 0) {
val fa = buf.getCell(faPos).fa.toInt() and 0xFF
if (faIsSelectable(fa) && !faIsZero(fa)) {
onLightPenSelect?.invoke(clickAddr)
isDetectable = true
}
}
}
// 3. Normal tap cursor positioning
if (!isDetectable) {
var addr = clickAddr
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
addr = buf.findNextUnprotected(addr)
}
}
onTapAddress(addr)
} }
} }
onTapAddress(addr)
} }
} }
} }
@@ -469,6 +511,102 @@ fun TerminalView(
strokeWidth = 2f strokeWidth = 2f
) )
} }
// Field Outlining (0xC2 / TY_FOUTLINE)
val cellEa = if (buf != null) buf.getCell(addr) else null
var outline = if (cellEa != null) cellEa.ol.toInt() and 0xFF else 0
val fEa = currentFieldEa
val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF
val olOverline = ExtendedAttribute.OL_OVERLINE.toInt() and 0xFF
val olLeft = ExtendedAttribute.OL_LEFT.toInt() and 0xFF
val olRight = ExtendedAttribute.OL_RIGHT.toInt() and 0xFF
if (outline == 0 && fEa != null && fEa.ol != 0.toByte()) {
val fol = fEa.ol.toInt() and 0xFF
if ((fol and olUnderline) != 0) outline = outline or olUnderline
if ((fol and olOverline) != 0) outline = outline or olOverline
val size = totalCells
val prevAddr = (addr - 1 + size) % size
if (buf != null && (buf.getDisplayCell(prevAddr)?.isFieldAttribute == true || c == 0)) {
if ((fol and olLeft) != 0) outline = outline or olLeft
}
val nextAddr = (addr + 1) % size
if (buf != null && (buf.getDisplayCell(nextAddr)?.isFieldAttribute == true || c == cols - 1)) {
if ((fol and olRight) != 0) outline = outline or olRight
}
}
if (outline != 0) {
if ((outline and olUnderline) != 0) {
drawLine(fgColor, Offset(left, top + cellHeight - 1f), Offset(left + cellWidth, top + cellHeight - 1f), strokeWidth = 1.5f)
}
if ((outline and olRight) != 0) {
drawLine(fgColor, Offset(left + cellWidth - 1f, top), Offset(left + cellWidth - 1f, top + cellHeight), strokeWidth = 1.5f)
}
if ((outline and olOverline) != 0) {
drawLine(fgColor, Offset(left, top), Offset(left + cellWidth, top), strokeWidth = 1.5f)
}
if ((outline and olLeft) != 0) {
drawLine(fgColor, Offset(left, top), Offset(left, top + cellHeight), strokeWidth = 1.5f)
}
}
}
}
// Rectangular Block Selection Sizing Handles & Box Outline
if (trimRectSizingHandles && selMinRow != -1 && selMinCol != -1) {
val x1 = offsetX + selMinCol * cellWidth
val y1 = offsetY + selMinRow * cellHeight
val x2 = offsetX + (selMaxCol + 1) * cellWidth
val y2 = offsetY + (selMaxRow + 1) * cellHeight
val w = x2 - x1
val h = y2 - y1
val xm = (x1 + x2) / 2f
val ym = (y1 + y2) / 2f
drawRect(
color = Color.Yellow,
topLeft = Offset(x1, y1),
size = Size(w, h),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
)
val handleSize = 8f
val handleHalf = handleSize / 2f
val handles = listOf(
Offset(x1, y1), Offset(xm, y1), Offset(x2, y1),
Offset(x2, ym), Offset(x2, y2), Offset(xm, y2),
Offset(x1, y2), Offset(x1, ym)
)
for (pt in handles) {
drawRect(
color = Color.Yellow,
topLeft = Offset(pt.x - handleHalf, pt.y - handleHalf),
size = Size(handleSize, handleSize)
)
}
}
// Interactive Hotspot Underline Rendering
if (hotspotsEnabled && hotspotEngine != null && buf != null) {
val activeHotspots = hotspotEngine.scan(buf)
for (h in activeHotspots) {
val hr0 = h.startRow
val hr1 = h.endRow
for (r in hr0..hr1) {
val c0 = if (r == hr0) h.startCol else 0
val c1 = if (r == hr1) h.endCol else cols - 1
val x1 = offsetX + c0 * cellWidth
val x2 = offsetX + (c1 + 1) * cellWidth
val y = offsetY + (r + 1) * cellHeight - 2f
drawLine(
color = Color(0xFF00DCFF),
start = Offset(x1, y),
end = Offset(x2, y),
strokeWidth = 2f
)
}
} }
} }
@@ -592,6 +730,16 @@ fun TerminalView(
} }
) )
} }
if (onToggleHotspots != null) {
DropdownMenuItem(
text = { Text(if (hotspotsEnabled) "✓ Interactive Hotspots" else " Interactive Hotspots") },
onClick = {
showContextMenu = false
onToggleHotspots()
}
)
}
} }
if (showPasteWrapDialog) { if (showPasteWrapDialog) {
@@ -0,0 +1,195 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.ui.HotspotEngine
import haus.nightmare.a3270.ui.HotspotEngine.HotspotType
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
import org.junit.Assert.*
import org.junit.Test
class HotspotAndHistoryFeaturesTest {
@Test
fun testHotspotUrlDetection() {
val engine = HotspotEngine()
val text = "Visit https://ibm.com/support or http://example.org:8080/test for help"
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
val urls = hotspots.filter { it.type == HotspotType.URL }
assertEquals(2, urls.size)
assertEquals("https://ibm.com/support", urls[0].action)
assertEquals("http://example.org:8080/test", urls[1].action)
}
@Test
fun testHotspotPfAndPaKeyDetection() {
val engine = HotspotEngine()
val text = "Press PF3 to Exit, PF12 to Cancel, F1 for Help, and PA1 for Attention"
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
val pfHotspots = hotspots.filter { it.type == HotspotType.PF_KEY }
assertTrue(pfHotspots.any { it.action == "[pf3]" && it.text.equals("PF3", ignoreCase = true) })
assertTrue(pfHotspots.any { it.action == "[pf12]" && it.text.equals("PF12", ignoreCase = true) })
assertTrue(pfHotspots.any { it.action == "[pf1]" && it.text.equals("F1", ignoreCase = true) })
assertTrue(pfHotspots.any { it.action == "[pa1]" && it.text.equals("PA1", ignoreCase = true) })
}
@Test
fun testHotspotMenuChoiceAndEnterDetection() {
val engine = HotspotEngine()
val text = "1. Logon\n2. Logoff\n===> 3\n[Enter] to submit"
val hotspots = engine.scanText(text, rows = 4, cols = 30)
val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE }
assertTrue(menuChoices.any { it.action == "1[enter]" })
assertTrue(menuChoices.any { it.action == "2[enter]" })
assertTrue(menuChoices.any { it.action == "3[enter]" })
val enterHotspots = hotspots.filter { it.type == HotspotType.ENTER }
assertTrue(enterHotspots.any { it.action == "[enter]" })
}
@Test
fun testHotspotHitTesting() {
val engine = HotspotEngine()
val text = "Row 0: PF7 Backward PF8 Forward "
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
val pf7 = hotspots.find { it.action == "[pf7]" }
assertNotNull(pf7)
val hit = engine.findHotspotAt(hotspots, 0, pf7!!.startCol + 1)
assertNotNull(hit)
assertEquals("[pf7]", hit?.action)
// Pointing outside any hotspot returns null
val miss = engine.findHotspotAt(hotspots, 0, 0)
assertNull(miss)
}
@Test
fun testHotspotCategoryDisabling() {
val engine = HotspotEngine()
val text = "Visit https://example.com or press PF3 or choose 1. Option"
// Master toggle disabled
val disabledScan = engine.scanText(text, rows = 1, cols = text.length, enabled = false)
assertTrue(disabledScan.isEmpty())
// Disable URLs only
engine.urlEnabled = false
engine.pfKeysEnabled = true
engine.menuChoicesEnabled = true
val noUrls = engine.scanText(text, rows = 1, cols = text.length, enabled = true)
assertTrue(noUrls.none { it.type == HotspotType.URL })
assertTrue(noUrls.any { it.type == HotspotType.PF_KEY })
// Disable PF keys only
engine.urlEnabled = true
engine.pfKeysEnabled = false
val noPfs = engine.scanText(text, rows = 1, cols = text.length, enabled = true)
assertTrue(noPfs.none { it.type == HotspotType.PF_KEY })
assertTrue(noPfs.any { it.type == HotspotType.URL })
}
@Test
fun testScreenHistoryBufferCapturesSnapshots() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
val history = screen.historyBuffer
assertNotNull(history)
assertEquals(0, history.size())
// Write first screen
screen.setCursorPosition(0, 0)
screen.setChar(0, 0, 'A')
screen.setChar(0, 1, 'B')
screen.setChar(0, 2, 'C')
history.snapshot(screen)
assertEquals(1, history.size())
val snap0 = history.get(0)
assertNotNull(snap0)
assertEquals('A', snap0?.getChar(0, 0))
assertEquals('B', snap0?.getChar(0, 1))
assertEquals('C', snap0?.getChar(0, 2))
// Consecutive identical snapshot is suppressed
history.snapshot(screen)
assertEquals(1, history.size())
// Modify screen and take second snapshot
screen.setChar(0, 0, 'X')
history.snapshot(screen)
assertEquals(2, history.size())
assertEquals('X', history.get(1)?.getChar(0, 0))
// Verify table formatting
val formattedTable = snap0?.formattedTable
assertNotNull(formattedTable)
assertTrue(formattedTable!!.startsWith("ABC"))
}
@Test
fun testFieldOutliningConstantsAndAttribute() {
assertEquals(0x01.toByte(), ExtendedAttribute.OL_UNDERLINE)
assertEquals(0x02.toByte(), ExtendedAttribute.OL_RIGHT)
assertEquals(0x04.toByte(), ExtendedAttribute.OL_OVERLINE)
assertEquals(0x08.toByte(), ExtendedAttribute.OL_LEFT)
assertEquals(0x0F.toByte(), ExtendedAttribute.OL_BOX)
val ea = ExtendedAttribute()
ea.ol = ExtendedAttribute.OL_BOX
assertEquals(ExtendedAttribute.OL_BOX, ea.ol)
}
@Test
fun testAppSettingsIniRoundtripWithHotspotsAndSelectionPreferences() {
// Check that default for hotspots is false (disabled for touch screens)
val testIni = """
[behavior]
hotspots_enabled=false
trim_rect_sizing_handles=true
trim_rect_remain_after_edit=true
paste_word_break=false
[hotspots]
url=true
pf_keys=true
menu_choices=false
""".trimIndent()
var hsEnabled: Boolean? = null
var trimHandles: Boolean? = null
var trimRemain: Boolean? = null
var wordBreak: Boolean? = null
var hsUrl: Boolean? = null
var hsPf: Boolean? = null
var hsChoices: Boolean? = null
AppSettings.parseIni(java.io.StringReader(testIni)) { _, k, v ->
when (k) {
"hotspots_enabled" -> hsEnabled = v.toBoolean()
"trim_rect_sizing_handles" -> trimHandles = v.toBoolean()
"trim_rect_remain_after_edit" -> trimRemain = v.toBoolean()
"paste_word_break" -> wordBreak = v.toBoolean()
"hotspots_url_enabled", "url" -> hsUrl = v.toBoolean()
"hotspots_pf_enabled", "pf_keys" -> hsPf = v.toBoolean()
"hotspots_menu_choices_enabled", "menu_choices" -> hsChoices = v.toBoolean()
}
}
assertEquals(false, hsEnabled)
assertEquals(true, trimHandles)
assertEquals(true, trimRemain)
assertEquals(false, wordBreak)
assertEquals(true, hsUrl)
assertEquals(true, hsPf)
assertEquals(false, hsChoices)
}
}