506 lines
21 KiB
Kotlin
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()
|
|
}
|