Files
a3270/src/main/java/org/pubvm/a3270/ui/TerminalView.kt
T
rudi 17f8cae925
Build and Test a3270 / Build Android APK (push) Successful in 4m37s
Release a3270 / Build & Publish Release (push) Successful in 6m53s
Debugging v0.2 features from j3270
2026-08-21 12:52:29 -04:00

506 lines
21 KiB
Kotlin

package org.pubvm.a3270.ui
import android.graphics.Paint
import android.graphics.Typeface
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ExtendedAttribute
import org.lib3270j.screen.ScreenBuffer
// Standard 3279 Host Colors (16-color palette)
private val HOST_COLORS = arrayOf(
Color(0xFF000000), // 0: Neutral Black
Color(0xFF5078FF), // 1: Blue
Color(0xFFFF3232), // 2: Red
Color(0xFFFF82B4), // 3: Pink
Color(0xFF32CD32), // 4: Green
Color(0xFF40E0D0), // 5: Turquoise
Color(0xFFFFFF50), // 6: Yellow
Color(0xFFFFFFFF), // 7: Neutral White
Color(0xFF000000), // 8: Black
Color(0xFF1E3CB4), // 9: Deep Blue
Color(0xFFFFA500), // 10: Orange
Color(0xFFB482FF), // 11: Purple
Color(0xFF90EE90), // 12: Pale Green
Color(0xFFAFEEEE), // 13: Pale Turquoise
Color(0xFFAAAAAA), // 14: Grey
Color(0xFFFFFFFF) // 15: White
)
private val COLOR_BLACK = Color(0xFF0A0A0A)
@Composable
fun TerminalView(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
cursorAddr: Int,
screenVersion: Long,
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true,
onTapAddress: (Int) -> Unit,
onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current
var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
// Blinking cursor state (~530ms interval matching j3270)
var cursorVisible by remember { mutableStateOf(true) }
LaunchedEffect(cursorAddr, blinkCursor) {
if (!blinkCursor) {
cursorVisible = true
return@LaunchedEffect
}
cursorVisible = true
while (true) {
delay(530L)
cursorVisible = !cursorVisible
}
}
Box(modifier = modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
.fillMaxSize()
.background(COLOR_BLACK)
.pointerInput(cols, rows) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val startTime = System.currentTimeMillis()
val startPos = down.position
var currentPos = startPos
var isDragStarted = false
val slop = viewConfiguration.touchSlop
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
break
}
currentPos = change.position
val distance = (currentPos - startPos).getDistance()
if (distance > slop) {
if (!isDragStarted) {
isDragStarted = true
selectionStart = startPos
showContextMenu = false
}
selectionEnd = currentPos
change.consume()
}
}
val duration = System.currentTimeMillis() - startTime
val totalDistance = (currentPos - startPos).getDistance()
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)
contextMenuOffset = startPos
showContextMenu = true
} else if (totalDistance <= slop) {
// Short Tap (Move Cursor)
showContextMenu = false
selectionStart = null
selectionEnd = null
val cellWidth = size.width / cols
val cellHeight = size.height / rows
if (cellWidth > 0 && cellHeight > 0) {
val col = (startPos.x / cellWidth).toInt().coerceIn(0, cols - 1)
val row = (startPos.y / cellHeight).toInt().coerceIn(0, rows - 1)
var addr = 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)
}
}
onTapAddress(addr)
}
}
}
}
}
) {
val width = size.width
val height = size.height
val cellWidth = width / cols
val cellHeight = height / rows
if (cellWidth <= 0 || cellHeight <= 0) return@Canvas
val paint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textSize = cellHeight * 0.85f
}
val buf = screenBuffer
val totalCells = rows * cols
var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null
// Calculate selected cell rectangle range if drag selection active
var selMinRow = -1
var selMaxRow = -1
var selMinCol = -1
var selMaxCol = -1
val start = selectionStart
val end = selectionEnd
if (start != null && end != null) {
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow)
selMinCol = minOf(startCol, endCol)
selMaxCol = maxOf(startCol, endCol)
}
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
if (addr >= totalCells) break
val left = c * cellWidth
val top = r * cellHeight
var charVal = ' '
var fgColor: Color
var bgColor = COLOR_BLACK
var isBold = false
var isUnderline = false
var isReverse = false
var csVal = 0
var ecVal = 0
if (buf != null) {
val ea = buf.getCell(addr)
if (ea.isFieldAttribute) {
currentFA = ea.fa
currentFieldEa = ea
continue
}
// Compute 3270 color and field intensity
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
bgColor = getBgColorForAttribute(ea, currentFieldEa)
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
else (currentFieldEa?.cs?.toInt()?.and(0xFF) ?: 0)
ecVal = ea.ec.toInt() and 0xFF
// Intensity
if (faIsHigh(currentFA.toInt() and 0xFF)) {
isBold = true
}
// Invisible fields (passwords / hidden inputs)
if (faIsZero(currentFA.toInt() and 0xFF)) {
if (maskHiddenFields && ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = '*'
} else {
// Blanks out character
charVal = ' '
}
} else {
// Normal visible character
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = ea.ucs4
}
}
// Extended Graphic Rendition
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
if (gr != 0.toByte()) {
val grVal = gr.toInt() and 0xFF
if ((grVal and GR_INTENSIFY) != 0) isBold = true
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
if ((grVal and GR_REVERSE) != 0) isReverse = true
}
} else {
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
}
if (isReverse) {
val tmp = fgColor
fgColor = bgColor
bgColor = tmp
}
if (bgColor != COLOR_BLACK) {
drawRect(
color = bgColor,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Highlight selected block range
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
if (isSelected) {
drawRect(
color = Color(0x773399FF),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Cursor indicator (respects blinking toggle & timer)
if (addr == cursorAddr && !isSelected && cursorVisible) {
drawRect(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Draw Programmed Symbol (PS / APL) if defined
var drawnAsPs = false
if (csVal >= 0x40 && programSymbolManager != null) {
val slot = programSymbolManager.getSymbol(csVal, ecVal)
if (slot != null) {
val symWidth = slot.width
val symHeight = slot.height
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(left, top, left + cellWidth, top + cellHeight),
null
)
drawnAsPs = true
}
}
}
if (!drawnAsPs && charVal != ' ') {
paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText(
charVal.toString(),
left + (cellWidth / 4),
textY,
paint
)
}
if (isUnderline) {
drawLine(
color = fgColor,
start = Offset(left, top + cellHeight - 2),
end = Offset(left + cellWidth, top + cellHeight - 2),
strokeWidth = 2f
)
}
}
}
// Draw Vector Graphics Plane overlay if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = (cols * cellWidth).toInt()
val gridH = (rows * cellHeight).toInt()
if (gridW > 0 && gridH > 0) {
graphicsPlane.resize(gridW, gridH)
val rgb = graphicsPlane.rgbBuffer
if (rgb != null) {
val bmp = android.graphics.Bitmap.createBitmap(rgb, gridW, gridH, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(0f, 0f, gridW.toFloat(), gridH.toFloat()),
null
)
}
}
}
}
// Context Menu Popup
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
offset = DpOffset(
x = (contextMenuOffset.x / density.density).dp,
y = (contextMenuOffset.y / density.density).dp
)
) {
val hasSelection = selectionStart != null && selectionEnd != null
if (hasSelection) {
DropdownMenuItem(
text = { Text("Copy Selection") },
onClick = {
showContextMenu = false
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context)
}
)
DropdownMenuItem(
text = { Text("Clear Selection") },
onClick = {
showContextMenu = false
selectionStart = null
selectionEnd = null
}
)
} else {
DropdownMenuItem(
text = { Text("Select All") },
onClick = {
showContextMenu = false
selectionStart = Offset(0f, 0f)
selectionEnd = Offset(100000f, 100000f)
}
)
}
DropdownMenuItem(
text = { Text("Paste") },
onClick = {
showContextMenu = false
val clipText = clipboardManager.getText()?.text
if (!clipText.isNullOrEmpty()) {
onPasteText(clipText)
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
}
}
)
}
}
}
private fun copySelection(
start: Offset?,
end: Offset?,
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
context: android.content.Context
) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics
val cellWidth = displayMetrics.widthPixels.toFloat() / cols
val cellHeight = displayMetrics.heightPixels.toFloat() / rows
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
val minRow = minOf(startRow, endRow)
val maxRow = maxOf(startRow, endRow)
val minCol = minOf(startCol, endCol)
val maxCol = maxOf(startCol, endCol)
val sb = StringBuilder()
for (r in minRow..maxRow) {
val line = StringBuilder()
for (c in minCol..maxCol) {
val addr = r * cols + c
if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
}
}
sb.append(line.toString().trimEnd()).append('\n')
}
val textToCopy = sb.toString().trimEnd()
if (textToCopy.isNotBlank()) {
clipboardManager.setText(AnnotatedString(textToCopy))
Toast.makeText(context, "Copied selected block to clipboard", Toast.LENGTH_SHORT).show()
}
}
}
private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color {
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
else 0
if (fg in 0xf0..0xff) {
return HOST_COLORS[fg - 0xf0]
}
val fa = currentFA.toInt() and 0xFF
return if (faIsProtected(fa)) {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_BLUE]
} else {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
}
}
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
else 0
if (bg in 0xf0..0xff) {
val idx = bg - 0xf0
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
return COLOR_BLACK
}
return HOST_COLORS[idx]
}
return COLOR_BLACK
}
private fun Color.toArgb(): Int {
return (alpha * 255).toInt() shl 24 or
((red * 255).toInt() shl 16) or
((green * 255).toInt() shl 8) or
(blue * 255).toInt()
}