Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
69ebe6bfb5
|
|||
|
63d14bd2d9
|
|||
|
377626013d
|
+2
-2
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?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.ACCESS_NETWORK_STATE" />
|
||||
|
||||
@@ -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<TerminalInputView?>(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(
|
||||
|
||||
@@ -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<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() {
|
||||
_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<Application>()
|
||||
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) {
|
||||
|
||||
@@ -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" -> {
|
||||
|
||||
@@ -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 (<HASCRIPT>)", color = Color.White) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onMacroRuntimeClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("3287 Printer Session", color = Color.White) },
|
||||
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",
|
||||
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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user