diff --git a/build.gradle b/build.gradle index 8947d6d..70235bd 100644 --- a/build.gradle +++ b/build.gradle @@ -11,8 +11,8 @@ android { applicationId "haus.nightmare.a3270" minSdk 24 targetSdk 34 - versionCode 6 - versionName "1.0.1" + versionCode 7 + versionName "1.2.2" } buildTypes { diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 541d60a..47ae0a9 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ - + diff --git a/src/main/java/haus/nightmare/a3270/MainActivity.kt b/src/main/java/haus/nightmare/a3270/MainActivity.kt index ba9235b..ce1ff85 100644 --- a/src/main/java/haus/nightmare/a3270/MainActivity.kt +++ b/src/main/java/haus/nightmare/a3270/MainActivity.kt @@ -22,6 +22,8 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import android.content.Context +import android.content.Intent +import android.net.Uri import android.view.KeyCharacterMap import haus.nightmare.lib3270j.protocol.DS3270Constants.* 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.ScriptDialog import haus.nightmare.a3270.ui.PrinterSessionDialog - +import haus.nightmare.a3270.ui.ScreenHistoryDialog +import haus.nightmare.a3270.ui.MacroDialog import haus.nightmare.a3270.ui.UntrustedCertificateDialog class MainActivity : ComponentActivity() { @@ -535,6 +538,13 @@ fun MainScreen( val isWordWrap by viewModel.isWordWrap.collectAsState() val isAplMode by viewModel.isAplMode.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) { if (screenBuffer != null && screenBuffer?.isFormatted == true) { @@ -551,6 +561,8 @@ fun MainScreen( var showScreenExportDialog by remember { mutableStateOf(false) } var showScriptDialog 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(null) } @@ -664,6 +676,22 @@ fun MainScreen( blockPaste = blockPaste, onToggleBlockSelectMode = { viewModel.setBlockSelectMode(!blockSelectMode) }, 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, onPasteLineWrap = { text, wrapCol, wordWrap -> viewModel.pasteLineWrap(text, wrapCol, wordWrap) @@ -748,10 +776,18 @@ fun MainScreen( terminalInputViewRef?.hideSoftKeyboard() showScreenExportDialog = true }, + onScreenHistoryClick = { + terminalInputViewRef?.hideSoftKeyboard() + showScreenHistoryDialog = true + }, onScriptClick = { terminalInputViewRef?.hideSoftKeyboard() showScriptDialog = true }, + onMacroRuntimeClick = { + terminalInputViewRef?.hideSoftKeyboard() + showMacroDialog = true + }, onPrinterSessionClick = { terminalInputViewRef?.hideSoftKeyboard() showPrinterSessionDialog = true @@ -902,6 +938,13 @@ fun MainScreen( initialCursorStyle = cursorStyle, initialBlockSelectMode = blockSelectMode, initialBlockPaste = blockPaste, + initialTrimRectSizingHandles = trimRectSizingHandles, + initialTrimRectRemainAfterEdit = trimRectRemainAfterEdit, + initialPasteWordBreak = pasteWordBreak, + initialHotspotsEnabled = hotspotsEnabled, + initialHotspotsUrlEnabled = hotspotsUrlEnabled, + initialHotspotsPfEnabled = hotspotsPfEnabled, + initialHotspotsMenuChoicesEnabled = hotspotsMenuChoicesEnabled, initialStartupBehavior = AppSettings.getStartupBehavior(context).name, initialDynamicRows = AppSettings.getDynamicRows(context), initialDynamicCols = AppSettings.getDynamicCols(context), @@ -920,8 +963,9 @@ fun MainScreen( showSettingsDialog = false terminalInputViewRef?.showSoftKeyboard() }, - onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, blockPst, startup, dynRows, dynCols, - doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk -> + onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, blockPst, + sizingHandles, remainAfterEdit, wordBreak, hsEnabled, hsUrl, hsPf, hsChoices, + startup, dynRows, dynCols, doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk -> showSettingsDialog = false viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp) viewModel.setUiTheme(theme) @@ -929,6 +973,13 @@ fun MainScreen( viewModel.setCursorStyle(style) viewModel.setBlockSelectMode(blockSel) 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.setDynamicRows(context, dynRows) 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 if (prompt != null) { UntrustedCertificateDialog( diff --git a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt index 29b53f5..05ffda3 100644 --- a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt +++ b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt @@ -20,9 +20,11 @@ import haus.nightmare.lib3270j.listener.ConnectionListener import haus.nightmare.lib3270j.listener.ScreenUpdateListener import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.screen.ScreenBuffer +import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer import haus.nightmare.a3270.ft.FileTransfer import haus.nightmare.a3270.service.TerminalService import haus.nightmare.a3270.storage.AppSettings +import haus.nightmare.a3270.ui.HotspotEngine import java.io.File import java.util.concurrent.Executors import java.util.logging.Logger @@ -62,6 +64,7 @@ sealed interface TerminalInputAction { data class SetCursor(val baddr: Int) : TerminalInputAction data class LightPenSelect(val baddr: Int) : TerminalInputAction data class PasteText(val text: String) : TerminalInputAction + data class SendKeysAction(val keys: String) : TerminalInputAction } data class UntrustedCertPromptState( @@ -183,6 +186,80 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private val _fourColorOverride = MutableStateFlow(AppSettings.getFourColorOverride(application)) val fourColorOverride: StateFlow = _fourColorOverride.asStateFlow() + private val _hotspotsEnabled = MutableStateFlow(AppSettings.isHotspotsEnabled(application)) + val hotspotsEnabled: StateFlow = _hotspotsEnabled.asStateFlow() + + private val _hotspotsUrlEnabled = MutableStateFlow(AppSettings.isHotspotsUrlEnabled(application)) + val hotspotsUrlEnabled: StateFlow = _hotspotsUrlEnabled.asStateFlow() + + private val _hotspotsPfEnabled = MutableStateFlow(AppSettings.isHotspotsPfEnabled(application)) + val hotspotsPfEnabled: StateFlow = _hotspotsPfEnabled.asStateFlow() + + private val _hotspotsMenuChoicesEnabled = MutableStateFlow(AppSettings.isHotspotsMenuChoicesEnabled(application)) + val hotspotsMenuChoicesEnabled: StateFlow = _hotspotsMenuChoicesEnabled.asStateFlow() + + private val _trimRectSizingHandles = MutableStateFlow(AppSettings.isTrimRectSizingHandlesEnabled(application)) + val trimRectSizingHandles: StateFlow = _trimRectSizingHandles.asStateFlow() + + private val _trimRectRemainAfterEdit = MutableStateFlow(AppSettings.isTrimRectRemainAfterEditEnabled(application)) + val trimRectRemainAfterEdit: StateFlow = _trimRectRemainAfterEdit.asStateFlow() + + private val _pasteWordBreak = MutableStateFlow(AppSettings.isPasteWordBreakEnabled(application)) + val pasteWordBreak: StateFlow = _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() + AppSettings.setPasteWordBreakEnabled(app, enabled) + _pasteWordBreak.value = enabled + applyModeSettings() + } + fun toggleLightPen() { _isLightPenMode.value = !_isLightPenMode.value } @@ -286,8 +363,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application ip.isNumericFieldLock = AppSettings.getNumericFieldLock(app) ip.isAutoSkipEnabled = AppSettings.getAutoSkipEnabled(app) ip.isBlockPaste = AppSettings.isBlockPasteEnabled(app) + ip.isPasteWordBreak = AppSettings.isPasteWordBreakEnabled(app) } c.ps?.isBlockPaste = AppSettings.isBlockPasteEnabled(app) + c.ps?.isPasteWordBreak = AppSettings.isPasteWordBreakEnabled(app) + c.isPasteWordBreak = AppSettings.isPasteWordBreakEnabled(app) _fourColorOverride.value = AppSettings.getFourColorOverride(app) _isDocMode.value = c.isDocMode _isWordWrap.value = c.isWordWrap @@ -310,6 +390,17 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application _blockPaste.value = AppSettings.isBlockPasteEnabled(app) _uiTheme.value = AppSettings.getUiTheme(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() } @@ -356,6 +447,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application 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 hasInitialScreenLoaded: Boolean = false @@ -423,10 +520,17 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application } else { val app = getApplication() 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() } } + 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 -> { if (isNvt) { if (action.aidCode == AID_ENTER) { diff --git a/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt b/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt index 53a7ae9..90b7ce3 100644 --- a/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt +++ b/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt @@ -43,6 +43,17 @@ object AppSettings { private const val KEY_NUMERIC_FIELD_LOCK = "numeric_field_lock" 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 { SHOW_CONNECT, DO_NOTHING, @@ -283,6 +294,64 @@ object AppSettings { 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 ========== fun exportToIni(context: Context, writer: Writer) { @@ -313,6 +382,16 @@ object AppSettings { p.println("dynamicCols = ${getDynamicCols(context)}") p.println("blockSelectMode = ${isBlockSelectModeEnabled(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("[entryassist]") @@ -339,7 +418,7 @@ object AppSettings { p.flush() } - fun loadFromIni(context: Context, reader: Reader) { + fun parseIni(reader: Reader, callback: (section: String, key: String, value: String) -> Unit) { val bufferedReader = BufferedReader(reader) var currentSection = "" @@ -353,13 +432,19 @@ object AppSettings { if (eq >= 0) { val key = line.substring(0, eq).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) { when (section) { "appearance" -> { @@ -381,11 +466,26 @@ object AppSettings { "dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) } "blockselectmode" -> setBlockSelectModeEnabled(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" -> { when (key.lowercase(Locale.US)) { "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" -> { diff --git a/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt b/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt index 74c2467..2e990f7 100644 --- a/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt +++ b/src/main/java/haus/nightmare/a3270/ui/FunctionKeyBar.kt @@ -38,7 +38,9 @@ fun TwoRowKeyBar( onFieldInspectorClick: () -> Unit = {}, onFindClick: () -> Unit = {}, onScreenExportClick: () -> Unit = {}, + onScreenHistoryClick: () -> Unit = {}, onScriptClick: () -> Unit = {}, + onMacroRuntimeClick: () -> Unit = {}, onPrinterSessionClick: () -> Unit = {}, onToggleLightPen: () -> Unit = {}, onToggleInsert: () -> Unit = {}, @@ -162,6 +164,13 @@ fun TwoRowKeyBar( onScreenExportClick() } ) + DropdownMenuItem( + text = { Text("Screen History", color = Color.White) }, + onClick = { + menuExpanded = false + onScreenHistoryClick() + } + ) DropdownMenuItem( text = { Text("Run Keystroke Script", color = Color.White) }, onClick = { @@ -169,6 +178,13 @@ fun TwoRowKeyBar( onScriptClick() } ) + DropdownMenuItem( + text = { Text("Macro Runtime ()", color = Color.White) }, + onClick = { + menuExpanded = false + onMacroRuntimeClick() + } + ) DropdownMenuItem( text = { Text("3287 Printer Session", color = Color.White) }, onClick = { diff --git a/src/main/java/haus/nightmare/a3270/ui/HotspotEngine.kt b/src/main/java/haus/nightmare/a3270/ui/HotspotEngine.kt new file mode 100644 index 0000000..621dbd3 --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/HotspotEngine.kt @@ -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 = emptyList() + + fun invalidateCache() { + synchronized(this) { + cachedScreenText = "" + cachedHotspots = emptyList() + } + } + + /** + * Scan the given ScreenBuffer for interactive hotspots. + */ + fun scan(sb: ScreenBuffer?): List { + 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 { + if (!enabled || screenText == null || rows <= 0 || cols <= 0) { + return emptyList() + } + + synchronized(this) { + if (screenText == cachedScreenText && rows == cachedRows && cols == cachedCols) { + return cachedHotspots + } + + val hotspots = mutableListOf() + 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 { + synchronized(this) { + return cachedHotspots + } + } + + fun findHotspotAt(hotspots: List, row: Int, col: Int): Hotspot? { + for (h in hotspots) { + if (h.contains(row, col)) { + return h + } + } + return null + } +} diff --git a/src/main/java/haus/nightmare/a3270/ui/MacroDialog.kt b/src/main/java/haus/nightmare/a3270/ui/MacroDialog.kt new file mode 100644 index 0000000..9a170fd --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/MacroDialog.kt @@ -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 = """ + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +data class MacroPromptState( + val prompt: MacroActionPrompt, + val deferred: CompletableDeferred +) + +@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(null) } + + val coroutineScope = rememberCoroutineScope() + val macroRef = remember { AtomicReference(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() + 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 ()", + 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 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") + } + } + ) + } +} diff --git a/src/main/java/haus/nightmare/a3270/ui/ScreenHistoryDialog.kt b/src/main/java/haus/nightmare/a3270/ui/ScreenHistoryDialog.kt new file mode 100644 index 0000000..55a0c25 --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/ScreenHistoryDialog.kt @@ -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) + } + } + } + } + } +} diff --git a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt index adf4bc3..367081e 100644 --- a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt @@ -31,6 +31,13 @@ fun SettingsDialog( initialCursorStyle: String = "BLOCK", initialBlockSelectMode: Boolean = false, 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", initialDynamicRows: Int = 62, initialDynamicCols: Int = 160, @@ -58,6 +65,13 @@ fun SettingsDialog( cursorStyle: String, blockSelectMode: Boolean, blockPaste: Boolean, + trimRectSizingHandles: Boolean, + trimRectRemainAfterEdit: Boolean, + pasteWordBreak: Boolean, + hotspotsEnabled: Boolean, + hotspotsUrlEnabled: Boolean, + hotspotsPfEnabled: Boolean, + hotspotsMenuChoicesEnabled: Boolean, startupBehavior: String, dynamicRows: Int, dynamicCols: Int, @@ -89,6 +103,13 @@ fun SettingsDialog( var cursorStyle by remember { mutableStateOf(initialCursorStyle) } var blockSelectMode by remember { mutableStateOf(initialBlockSelectMode) } 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 dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) } var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) } @@ -284,6 +305,147 @@ fun SettingsDialog( 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 Row( modifier = Modifier.fillMaxWidth(), @@ -730,6 +892,13 @@ fun SettingsDialog( cursorStyle, blockSelectMode, blockPaste, + trimRectSizingHandles, + trimRectRemainAfterEdit, + pasteWordBreak, + hotspotsEnabled, + hotspotsUrlEnabled, + hotspotsPfEnabled, + hotspotsMenuChoicesEnabled, startupBehavior, dRows, dCols, @@ -784,6 +953,13 @@ fun SettingsDialog( cursorStyle = haus.nightmare.a3270.storage.AppSettings.getCursorStyle(context) blockSelectMode = haus.nightmare.a3270.storage.AppSettings.isBlockSelectModeEnabled(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 dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString() dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString() diff --git a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt index 2b7af88..46b68aa 100644 --- a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt +++ b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt @@ -71,16 +71,23 @@ fun TerminalView( isInsertMode: Boolean = false, blockSelectMode: Boolean = false, blockPaste: Boolean = true, + trimRectSizingHandles: Boolean = false, + trimRectRemainAfterEdit: Boolean = false, + hotspotsEnabled: Boolean = false, + hotspotEngine: HotspotEngine? = null, fourColorOverride: Boolean = false, searchHighlightAddr: Int = -1, searchHighlightLen: Int = 0, onTapAddress: (Int) -> Unit, onLightPenSelect: ((Int) -> Unit)? = null, + onSendHotspotAction: ((String) -> Unit)? = null, + onOpenUrl: ((String) -> Unit)? = null, onGraphicTouch: ((Int, Int, Int) -> Unit)? = null, onPasteText: (String) -> Unit = {}, onPasteLineWrap: ((String, Int, Boolean) -> Unit)? = null, onToggleBlockSelectMode: (() -> Unit)? = null, onToggleBlockPaste: (() -> Unit)? = null, + onToggleHotspots: (() -> Unit)? = null, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -168,8 +175,10 @@ fun TerminalView( } else if (totalDistance <= slop) { // Short Tap showContextMenu = false - selectionStart = null - selectionEnd = null + if (!trimRectRemainAfterEdit) { + selectionStart = null + selectionEnd = null + } val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows) if (metrics.cellWidth > 0 && metrics.cellHeight > 0) { val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1) @@ -188,15 +197,48 @@ fun TerminalView( } else if (isLightPenMode) { onLightPenSelect?.invoke(clickAddr) } else { - var addr = clickAddr - val buf = screenBuffer - if (buf != null && buf.isFormatted) { - val faVal = buf.getFieldAttributeAt(addr) - if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) { - addr = buf.findNextUnprotected(addr) + // 1. Hotspot tap check when enabled + var handledHotspot = false + if (hotspotsEnabled && hotspotEngine != null) { + val h = hotspotEngine.findHotspot(row, col) + if (h != null) { + 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 ) } + + // 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) { diff --git a/src/test/java/haus/nightmare/a3270/HotspotAndHistoryFeaturesTest.kt b/src/test/java/haus/nightmare/a3270/HotspotAndHistoryFeaturesTest.kt new file mode 100644 index 0000000..ec64e21 --- /dev/null +++ b/src/test/java/haus/nightmare/a3270/HotspotAndHistoryFeaturesTest.kt @@ -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) + } +}