From 8344b9f4f7ed6ef97aae339126267b2850398f83 Mon Sep 17 00:00:00 2001 From: Rudi Date: Mon, 24 Aug 2026 20:46:09 +0000 Subject: [PATCH] j3270 updates applied for lightpen --- build.sh | 8 +++- src/main/java/org/pubvm/a3270/MainActivity.kt | 21 ++++++++- .../java/org/pubvm/a3270/TerminalViewModel.kt | 40 ++++++++++++++++ .../java/org/pubvm/a3270/ui/FunctionKeyBar.kt | 17 ++++++- .../java/org/pubvm/a3270/ui/OiaStatusBar.kt | 19 ++++++++ .../java/org/pubvm/a3270/ui/TerminalView.kt | 42 ++++++++++++----- .../pubvm/a3270/HardwareKeyboardInputTest.kt | 47 +++++++++++++++++++ 7 files changed, 178 insertions(+), 16 deletions(-) diff --git a/build.sh b/build.sh index 1289199..62a21d3 100755 --- a/build.sh +++ b/build.sh @@ -9,9 +9,11 @@ echo "=== Building a3270 (Android APK) ===" # Set up JAVA_HOME (prefer JDK 21 if available) if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem" +elif [ -d "$HOME/.sdkman/candidates/java/21.0.2-open" ]; then + export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.2-open" elif [ -z "$JAVA_HOME" ]; then - if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then - export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current" + if [ -d "$HOME/.sdkman/candidates/java/current" ]; then + export JAVA_HOME="$HOME/.sdkman/candidates/java/current" fi fi @@ -19,6 +21,8 @@ fi if [ -z "$ANDROID_HOME" ]; then if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk" + elif [ -d "$HOME/Android/Sdk" ]; then + export ANDROID_HOME="$HOME/Android/Sdk" fi fi diff --git a/src/main/java/org/pubvm/a3270/MainActivity.kt b/src/main/java/org/pubvm/a3270/MainActivity.kt index 3afd959..198abe5 100644 --- a/src/main/java/org/pubvm/a3270/MainActivity.kt +++ b/src/main/java/org/pubvm/a3270/MainActivity.kt @@ -243,6 +243,12 @@ class MainActivity : ComponentActivity() { viewModel.resetKeyboard() return true } + KeyEvent.KEYCODE_L -> { + viewModel.toggleLightPen() + val isLp = viewModel.isLightPenMode.value + Toast.makeText(this, "Light Pen: " + (if (isLp) "ON" else "OFF"), Toast.LENGTH_SHORT).show() + return true + } } } @@ -399,6 +405,7 @@ fun MainScreen( val isTlsVerified by viewModel.isTlsVerified.collectAsState() val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState() val ftState by viewModel.ftState.collectAsState() + val isLightPenMode by viewModel.isLightPenMode.collectAsState() var showConnectDialog by remember { mutableStateOf(false) } var showFtDialog by remember { mutableStateOf(false) } @@ -466,12 +473,21 @@ fun MainScreen( screenVersion = screenVersion, programSymbolManager = viewModel.getClient()?.programSymbolManager, graphicsPlane = viewModel.getClient()?.graphicsPlane, + gocaDecoder = viewModel.getGocaDecoder(), + isLightPenMode = isLightPenMode, maskHiddenFields = maskHiddenInput, blinkCursor = cursorBlink, onTapAddress = { addr -> viewModel.setCursor(addr) terminalInputViewRef?.showSoftKeyboard() }, + onLightPenSelect = { addr -> + viewModel.selectLightPen(addr) + terminalInputViewRef?.showSoftKeyboard() + }, + onGraphicTouch = { px, py -> + viewModel.handleGraphicTouch(px, py) + }, onPasteText = { text -> viewModel.pasteString(text) }, @@ -512,11 +528,13 @@ fun MainScreen( TwoRowKeyBar( connectionState = connectionState, isShiftPressed = isShiftPressed, + isLightPenMode = isLightPenMode, hapticFeedbackEnabled = hapticFeedback, onClearShift = onClearShift, onConnectClick = { showConnectDialog = true }, onDisconnectClick = { viewModel.disconnect() }, onFtClick = { showFtDialog = true }, + onToggleLightPen = { viewModel.toggleLightPen() }, onSettingsClick = { showSettingsDialog = true }, onSendAid = { aid -> viewModel.sendAid(aid) @@ -563,7 +581,8 @@ fun MainScreen( cols = cols, isTls = isTlsActive, isTlsVerified = isTlsVerified, - graphicsMode = defaultGraphicsMode + graphicsMode = defaultGraphicsMode, + isLightPenMode = isLightPenMode ) } } diff --git a/src/main/java/org/pubvm/a3270/TerminalViewModel.kt b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt index 87e9d2e..807163a 100644 --- a/src/main/java/org/pubvm/a3270/TerminalViewModel.kt +++ b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt @@ -45,6 +45,7 @@ sealed interface TerminalInputAction { data object Newline : TerminalInputAction data object Reset : TerminalInputAction data class SetCursor(val baddr: Int) : TerminalInputAction + data class LightPenSelect(val baddr: Int) : TerminalInputAction } data class UntrustedCertPromptState( @@ -124,6 +125,17 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private val _untrustedCertPrompt = MutableStateFlow(null) val untrustedCertPrompt: StateFlow = _untrustedCertPrompt.asStateFlow() + private val _isLightPenMode = MutableStateFlow(false) + val isLightPenMode: StateFlow = _isLightPenMode.asStateFlow() + + fun toggleLightPen() { + _isLightPenMode.value = !_isLightPenMode.value + } + + fun setLightPenMode(enabled: Boolean) { + _isLightPenMode.value = enabled + } + // File Transfer State private var fileTransferCoordinator: FileTransfer? = null private val _ftState = MutableStateFlow(FTProgressState()) @@ -240,6 +252,13 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application buf.cursorAddress = action.baddr } } + is TerminalInputAction.LightPenSelect -> { + if (action.baddr in 0 until (buf.rows * buf.cols)) { + buf.cursorAddress = action.baddr + val res = ip.lightPenSelect(action.baddr) + log.info("LightPenSelect at addr ${action.baddr}, result=$res") + } + } } } catch (e: Exception) { log.warning("Error processing input action: ${e.message}") @@ -627,6 +646,27 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application inputChannel.trySend(TerminalInputAction.CursorHome) } + fun selectLightPen(baddr: Int) { + inputChannel.trySend(TerminalInputAction.LightPenSelect(baddr)) + } + + fun getGocaDecoder(): org.lib3270j.graphics.GocaDecoder? = client?.gocaDecoder + + fun handleGraphicTouch(px: Int, py: Int, button: Int = 1, shift: Boolean = false, ctrl: Boolean = false) { + val c = client ?: return + val goca = c.gocaDecoder ?: return + if (goca.isGraphicsCursorActive) { + goca.setGraphicCursorFromPixel(px, py) + c.inputProcessor.sendGraphicMouseAid( + AID_ENTER, + button, + shift, + ctrl + ) + _screenVersion.value = System.currentTimeMillis() + } + } + private fun extractScreenSnippet(buf: ScreenBuffer?): String { if (buf == null) return "Mainframe screen update received" val rows = buf.rows diff --git a/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt b/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt index bad2f66..10306a5 100644 --- a/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt +++ b/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt @@ -22,11 +22,13 @@ import org.lib3270j.protocol.DS3270Constants.* fun TwoRowKeyBar( connectionState: ConnectionState, isShiftPressed: Boolean = false, + isLightPenMode: Boolean = false, hapticFeedbackEnabled: Boolean = true, onClearShift: () -> Unit = {}, onConnectClick: () -> Unit, onDisconnectClick: () -> Unit, onFtClick: () -> Unit, + onToggleLightPen: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onSendAid: (Int) -> Unit, onReset: () -> Unit, @@ -53,7 +55,7 @@ fun TwoRowKeyBar( .padding(horizontal = 1.dp, vertical = 1.dp), verticalAlignment = Alignment.CenterVertically ) { - // 1. Menu Dropdown Button (Connect/Disconnect/FT/Settings) + // 1. Menu Dropdown Button (Connect/Disconnect/FT/Light Pen/Settings) Box(modifier = Modifier.weight(1f)) { KeyButton( label = "⋮", @@ -92,6 +94,19 @@ fun TwoRowKeyBar( } ) } + DropdownMenuItem( + text = { + Text( + text = if (isLightPenMode) "Light Pen: ON" else "Light Pen: OFF", + color = if (isLightPenMode) Color(0xFFFFFF50) else Color.White, + fontWeight = FontWeight.SemiBold + ) + }, + onClick = { + menuExpanded = false + onToggleLightPen() + } + ) DropdownMenuItem( text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) }, onClick = { diff --git a/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt b/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt index 129bea4..4703dd2 100644 --- a/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt +++ b/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt @@ -27,6 +27,7 @@ fun OiaStatusBar( isTls: Boolean = false, isTlsVerified: Boolean = true, graphicsMode: String = "NONE", + isLightPenMode: Boolean = false, modifier: Modifier = Modifier ) { val row = if (cols > 0) (cursorAddr / cols) + 1 else 1 @@ -119,6 +120,24 @@ fun OiaStatusBar( ) } } + + // Light Pen Badge + if (isLightPenMode) { + Spacer(modifier = Modifier.width(6.dp)) + Surface( + color = Color(0xFFFFFF50).copy(alpha = 0.25f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "LP", + color = Color(0xFFFFFF50), + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp) + ) + } + } } // Terminal Keyboard Lock Status Badge (X SYSTEM vs READY) diff --git a/src/main/java/org/pubvm/a3270/ui/TerminalView.kt b/src/main/java/org/pubvm/a3270/ui/TerminalView.kt index 304d978..99ebc2a 100644 --- a/src/main/java/org/pubvm/a3270/ui/TerminalView.kt +++ b/src/main/java/org/pubvm/a3270/ui/TerminalView.kt @@ -60,9 +60,13 @@ fun TerminalView( screenVersion: Long, programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null, graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null, + gocaDecoder: org.lib3270j.graphics.GocaDecoder? = null, + isLightPenMode: Boolean = false, maskHiddenFields: Boolean = true, blinkCursor: Boolean = true, onTapAddress: (Int) -> Unit, + onLightPenSelect: ((Int) -> Unit)? = null, + onGraphicTouch: ((Int, Int) -> Unit)? = null, onPasteText: (String) -> Unit = {}, modifier: Modifier = Modifier ) { @@ -97,7 +101,7 @@ fun TerminalView( modifier = Modifier .fillMaxSize() .background(COLOR_BLACK) - .pointerInput(cols, rows) { + .pointerInput(cols, rows, isLightPenMode, gocaDecoder) { awaitPointerEventScope { while (true) { val down = awaitFirstDown(requireUnconsumed = false) @@ -106,6 +110,7 @@ fun TerminalView( var currentPos = startPos var isDragStarted = false val slop = viewConfiguration.touchSlop + val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive while (true) { val event = awaitPointerEvent() @@ -116,7 +121,7 @@ fun TerminalView( currentPos = change.position val distance = (currentPos - startPos).getDistance() - if (distance > slop) { + if (distance > slop && !isLightPenMode && !isGraphicCursor) { if (!isDragStarted) { isDragStarted = true selectionStart = startPos @@ -133,12 +138,12 @@ fun TerminalView( if (isDragStarted) { // Drag completed: Keep selection box highlighted, DO NOT open context menu showContextMenu = false - } else if (duration >= 600L && totalDistance <= slop) { - // Stationary Long Press (held >= 600ms without dragging) + } else if (duration >= 600L && totalDistance <= slop && !isLightPenMode && !isGraphicCursor) { + // Stationary Long Press (held >= 600ms without dragging) - only in normal alphanumeric mode contextMenuOffset = startPos showContextMenu = true } else if (totalDistance <= slop) { - // Short Tap (Move Cursor) + // Short Tap showContextMenu = false selectionStart = null selectionEnd = null @@ -146,16 +151,29 @@ fun TerminalView( if (metrics.cellWidth > 0 && metrics.cellHeight > 0) { val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1) val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1) - var addr = row * cols + col + val clickAddr = row * cols + col - 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) + if (isGraphicCursor) { + val gridW = metrics.gridWidth + val gridH = metrics.gridHeight + val gWidth = graphicsPlane?.canvasWidth ?: gridW.toInt() + val gHeight = graphicsPlane?.canvasHeight ?: gridH.toInt() + val px = if (gridW > 0 && gWidth > 0) (((startPos.x - metrics.offsetX) * gWidth) / gridW).toInt().coerceIn(0, gWidth - 1) else (startPos.x - metrics.offsetX).toInt() + val py = if (gridH > 0 && gHeight > 0) (((startPos.y - metrics.offsetY) * gHeight) / gridH).toInt().coerceIn(0, gHeight - 1) else (startPos.y - metrics.offsetY).toInt() + onGraphicTouch?.invoke(px, py) + } else if (isLightPenMode) { + onLightPenSelect?.invoke(clickAddr) + } else { + var addr = clickAddr + val buf = screenBuffer + if (buf != null && buf.isFormatted) { + val faVal = buf.getFieldAttributeAt(addr) + if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) { + addr = buf.findNextUnprotected(addr) + } } + onTapAddress(addr) } - onTapAddress(addr) } } } diff --git a/src/test/java/org/pubvm/a3270/HardwareKeyboardInputTest.kt b/src/test/java/org/pubvm/a3270/HardwareKeyboardInputTest.kt index 223e865..260a04e 100644 --- a/src/test/java/org/pubvm/a3270/HardwareKeyboardInputTest.kt +++ b/src/test/java/org/pubvm/a3270/HardwareKeyboardInputTest.kt @@ -165,4 +165,51 @@ class HardwareKeyboardInputTest { assertFalse(inputProcessor.isKeyboardLocked) } } + + @Test + fun testLightPenSelection() { + // Field 1 at pos 0: Selectable Unprotected (FA_INT_HIGH) + // Designator at pos 1: '?' (EBCDIC 0x6F) + val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte() + assertTrue(org.lib3270j.protocol.DS3270Constants.faIsSelectable(fa.toInt() and 0xFF)) + + screenBuffer.setCellFA(0, fa) + screenBuffer.getCell(1).ucs4 = '?' + screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('?').toByte() + + // Field 2 at pos 20: Non-selectable (normal intensity) + screenBuffer.setCellFA(20, FA_PRINTABLE.toByte()) + screenBuffer.getCell(21).ucs4 = '?' + + // Selecting field 1 ('?') toggles designator to '>' and sets modified bit + val result1 = inputProcessor.lightPenSelect(1) + assertTrue(result1) + assertEquals('>', screenBuffer.getCell(1).ucs4) + assertEquals(0x6E.toByte(), screenBuffer.getCell(1).ec) + val fa1 = screenBuffer.getCell(0).fa.toInt() and 0xFF + assertTrue((fa1 and FA_MODIFY) != 0) + + // Selecting field 1 again ('>') toggles back to '?' and clears modified bit + val result2 = inputProcessor.lightPenSelect(1) + assertTrue(result2) + assertEquals('?', screenBuffer.getCell(1).ucs4) + assertEquals(0x6F.toByte(), screenBuffer.getCell(1).ec) + + // Selecting field 2 (non-selectable) returns false + val resultNonSelectable = inputProcessor.lightPenSelect(21) + assertFalse(resultNonSelectable) + } + + @Test + fun testLightPenEnterSelection() { + // Field with '&' designator immediately sends AID_ENTER + val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte() + screenBuffer.setCellFA(0, fa) + screenBuffer.getCell(1).ucs4 = '&' + screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('&').toByte() + + val result = inputProcessor.lightPenSelect(1) + assertTrue(result) + assertEquals(AID_ENTER, inputProcessor.lastAid) + } }