j3270 updates applied for lightpen
Build and Test a3270 / Build Android APK (push) Successful in 4m34s

This commit is contained in:
2026-08-24 20:46:09 +00:00
parent 052a6619ae
commit 8344b9f4f7
7 changed files with 178 additions and 16 deletions
+6 -2
View File
@@ -9,9 +9,11 @@ echo "=== Building a3270 (Android APK) ==="
# Set up JAVA_HOME (prefer JDK 21 if available) # Set up JAVA_HOME (prefer JDK 21 if available)
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem" 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 elif [ -z "$JAVA_HOME" ]; then
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then if [ -d "$HOME/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current" export JAVA_HOME="$HOME/.sdkman/candidates/java/current"
fi fi
fi fi
@@ -19,6 +21,8 @@ fi
if [ -z "$ANDROID_HOME" ]; then if [ -z "$ANDROID_HOME" ]; then
if [ -d "$HOME/Library/Android/sdk" ]; then if [ -d "$HOME/Library/Android/sdk" ]; then
export ANDROID_HOME="$HOME/Library/Android/sdk" export ANDROID_HOME="$HOME/Library/Android/sdk"
elif [ -d "$HOME/Android/Sdk" ]; then
export ANDROID_HOME="$HOME/Android/Sdk"
fi fi
fi fi
+20 -1
View File
@@ -243,6 +243,12 @@ class MainActivity : ComponentActivity() {
viewModel.resetKeyboard() viewModel.resetKeyboard()
return true 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 isTlsVerified by viewModel.isTlsVerified.collectAsState()
val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState() val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState()
val ftState by viewModel.ftState.collectAsState() val ftState by viewModel.ftState.collectAsState()
val isLightPenMode by viewModel.isLightPenMode.collectAsState()
var showConnectDialog by remember { mutableStateOf(false) } var showConnectDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) } var showFtDialog by remember { mutableStateOf(false) }
@@ -466,12 +473,21 @@ fun MainScreen(
screenVersion = screenVersion, screenVersion = screenVersion,
programSymbolManager = viewModel.getClient()?.programSymbolManager, programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane, graphicsPlane = viewModel.getClient()?.graphicsPlane,
gocaDecoder = viewModel.getGocaDecoder(),
isLightPenMode = isLightPenMode,
maskHiddenFields = maskHiddenInput, maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink, blinkCursor = cursorBlink,
onTapAddress = { addr -> onTapAddress = { addr ->
viewModel.setCursor(addr) viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
}, },
onLightPenSelect = { addr ->
viewModel.selectLightPen(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onGraphicTouch = { px, py ->
viewModel.handleGraphicTouch(px, py)
},
onPasteText = { text -> onPasteText = { text ->
viewModel.pasteString(text) viewModel.pasteString(text)
}, },
@@ -512,11 +528,13 @@ fun MainScreen(
TwoRowKeyBar( TwoRowKeyBar(
connectionState = connectionState, connectionState = connectionState,
isShiftPressed = isShiftPressed, isShiftPressed = isShiftPressed,
isLightPenMode = isLightPenMode,
hapticFeedbackEnabled = hapticFeedback, hapticFeedbackEnabled = hapticFeedback,
onClearShift = onClearShift, onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true }, onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() }, onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true }, onFtClick = { showFtDialog = true },
onToggleLightPen = { viewModel.toggleLightPen() },
onSettingsClick = { showSettingsDialog = true }, onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid -> onSendAid = { aid ->
viewModel.sendAid(aid) viewModel.sendAid(aid)
@@ -563,7 +581,8 @@ fun MainScreen(
cols = cols, cols = cols,
isTls = isTlsActive, isTls = isTlsActive,
isTlsVerified = isTlsVerified, isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode graphicsMode = defaultGraphicsMode,
isLightPenMode = isLightPenMode
) )
} }
} }
@@ -45,6 +45,7 @@ sealed interface TerminalInputAction {
data object Newline : TerminalInputAction data object Newline : TerminalInputAction
data object Reset : TerminalInputAction data object Reset : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction data class SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(val baddr: Int) : TerminalInputAction
} }
data class UntrustedCertPromptState( data class UntrustedCertPromptState(
@@ -124,6 +125,17 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null) private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null)
val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow() val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow()
private val _isLightPenMode = MutableStateFlow(false)
val isLightPenMode: StateFlow<Boolean> = _isLightPenMode.asStateFlow()
fun toggleLightPen() {
_isLightPenMode.value = !_isLightPenMode.value
}
fun setLightPenMode(enabled: Boolean) {
_isLightPenMode.value = enabled
}
// File Transfer State // File Transfer State
private var fileTransferCoordinator: FileTransfer? = null private var fileTransferCoordinator: FileTransfer? = null
private val _ftState = MutableStateFlow(FTProgressState()) private val _ftState = MutableStateFlow(FTProgressState())
@@ -240,6 +252,13 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
buf.cursorAddress = action.baddr 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) { } catch (e: Exception) {
log.warning("Error processing input action: ${e.message}") log.warning("Error processing input action: ${e.message}")
@@ -627,6 +646,27 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.CursorHome) 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 { private fun extractScreenSnippet(buf: ScreenBuffer?): String {
if (buf == null) return "Mainframe screen update received" if (buf == null) return "Mainframe screen update received"
val rows = buf.rows val rows = buf.rows
@@ -22,11 +22,13 @@ import org.lib3270j.protocol.DS3270Constants.*
fun TwoRowKeyBar( fun TwoRowKeyBar(
connectionState: ConnectionState, connectionState: ConnectionState,
isShiftPressed: Boolean = false, isShiftPressed: Boolean = false,
isLightPenMode: Boolean = false,
hapticFeedbackEnabled: Boolean = true, hapticFeedbackEnabled: Boolean = true,
onClearShift: () -> Unit = {}, onClearShift: () -> Unit = {},
onConnectClick: () -> Unit, onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit, onDisconnectClick: () -> Unit,
onFtClick: () -> Unit, onFtClick: () -> Unit,
onToggleLightPen: () -> Unit = {},
onSettingsClick: () -> Unit = {}, onSettingsClick: () -> Unit = {},
onSendAid: (Int) -> Unit, onSendAid: (Int) -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
@@ -53,7 +55,7 @@ fun TwoRowKeyBar(
.padding(horizontal = 1.dp, vertical = 1.dp), .padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically 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)) { Box(modifier = Modifier.weight(1f)) {
KeyButton( KeyButton(
label = "", 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( DropdownMenuItem(
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) }, text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = { onClick = {
@@ -27,6 +27,7 @@ fun OiaStatusBar(
isTls: Boolean = false, isTls: Boolean = false,
isTlsVerified: Boolean = true, isTlsVerified: Boolean = true,
graphicsMode: String = "NONE", graphicsMode: String = "NONE",
isLightPenMode: Boolean = false,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1 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) // Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
@@ -60,9 +60,13 @@ fun TerminalView(
screenVersion: Long, screenVersion: Long,
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null, programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null, graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
gocaDecoder: org.lib3270j.graphics.GocaDecoder? = null,
isLightPenMode: Boolean = false,
maskHiddenFields: Boolean = true, maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true, blinkCursor: Boolean = true,
onTapAddress: (Int) -> Unit, onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null,
onGraphicTouch: ((Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {}, onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -97,7 +101,7 @@ fun TerminalView(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(COLOR_BLACK) .background(COLOR_BLACK)
.pointerInput(cols, rows) { .pointerInput(cols, rows, isLightPenMode, gocaDecoder) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
@@ -106,6 +110,7 @@ fun TerminalView(
var currentPos = startPos var currentPos = startPos
var isDragStarted = false var isDragStarted = false
val slop = viewConfiguration.touchSlop val slop = viewConfiguration.touchSlop
val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive
while (true) { while (true) {
val event = awaitPointerEvent() val event = awaitPointerEvent()
@@ -116,7 +121,7 @@ fun TerminalView(
currentPos = change.position currentPos = change.position
val distance = (currentPos - startPos).getDistance() val distance = (currentPos - startPos).getDistance()
if (distance > slop) { if (distance > slop && !isLightPenMode && !isGraphicCursor) {
if (!isDragStarted) { if (!isDragStarted) {
isDragStarted = true isDragStarted = true
selectionStart = startPos selectionStart = startPos
@@ -133,12 +138,12 @@ fun TerminalView(
if (isDragStarted) { if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu // Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop) { } else if (duration >= 600L && totalDistance <= slop && !isLightPenMode && !isGraphicCursor) {
// Stationary Long Press (held >= 600ms without dragging) // Stationary Long Press (held >= 600ms without dragging) - only in normal alphanumeric mode
contextMenuOffset = startPos contextMenuOffset = startPos
showContextMenu = true showContextMenu = true
} else if (totalDistance <= slop) { } else if (totalDistance <= slop) {
// Short Tap (Move Cursor) // Short Tap
showContextMenu = false showContextMenu = false
selectionStart = null selectionStart = null
selectionEnd = null selectionEnd = null
@@ -146,16 +151,29 @@ fun TerminalView(
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) { if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1) val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 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 (isGraphicCursor) {
if (buf != null && buf.isFormatted) { val gridW = metrics.gridWidth
val faVal = buf.getFieldAttributeAt(addr) val gridH = metrics.gridHeight
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) { val gWidth = graphicsPlane?.canvasWidth ?: gridW.toInt()
addr = buf.findNextUnprotected(addr) 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)
} }
} }
} }
@@ -165,4 +165,51 @@ class HardwareKeyboardInputTest {
assertFalse(inputProcessor.isKeyboardLocked) 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)
}
} }