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)
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
+20 -1
View File
@@ -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
)
}
}
@@ -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<UntrustedCertPromptState?>(null)
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
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
@@ -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 = {
@@ -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)
@@ -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)
}
}
}
@@ -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)
}
}