Update Status Bar, Settings, Terminal alignment and codepage testing
Build and Test a3270 / Build Android APK (push) Successful in 1m58s

This commit is contained in:
2026-09-22 13:41:10 +00:00
parent 69ebe6bfb5
commit ee44759668
8 changed files with 286 additions and 25 deletions
@@ -546,11 +546,34 @@ fun MainScreen(
val hotspotsPfEnabled by viewModel.hotspotsPfEnabled.collectAsState() val hotspotsPfEnabled by viewModel.hotspotsPfEnabled.collectAsState()
val hotspotsMenuChoicesEnabled by viewModel.hotspotsMenuChoicesEnabled.collectAsState() val hotspotsMenuChoicesEnabled by viewModel.hotspotsMenuChoicesEnabled.collectAsState()
val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) { val isDbcsSession = remember(screenBuffer, screenVersion) {
if (screenBuffer != null && screenBuffer?.isFormatted == true) { screenBuffer?.translator?.isDBCS == true
val fa = screenBuffer?.getFieldAttributeAt(cursorAddr) ?: 0.toByte() }
(fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0
} else false val fieldStatus = remember(screenBuffer, cursorAddr, screenVersion) {
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val cell = buf.getCell(cursorAddr)
val isDbcs = (cell != null && (cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_LEFT ||
cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_RIGHT ||
cell.cs == haus.nightmare.lib3270j.screen.ExtendedAttribute.CS_DBCS))
if (isDbcs) {
"DBCS"
} else {
val fa = buf.getFieldAttributeAt(cursorAddr)
if ((fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0) {
"NUM"
} else {
"ALPHA"
}
}
} else {
"ALPHA"
}
}
val isNumericField = remember(fieldStatus) {
fieldStatus == "NUM"
} }
var showConnectDialog by remember { mutableStateOf(false) } var showConnectDialog by remember { mutableStateOf(false) }
@@ -717,6 +740,7 @@ fun MainScreen(
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
TerminalInputView(ctx).apply { TerminalInputView(ctx).apply {
this.isDbcsSession = isDbcsSession
onInputText = { text -> onInputText = { text ->
viewModel.typeString(text) viewModel.typeString(text)
onClearShift() onClearShift()
@@ -738,6 +762,7 @@ fun MainScreen(
} }
}, },
update = { inputView -> update = { inputView ->
inputView.isDbcsSession = isDbcsSession
terminalInputViewRef = inputView terminalInputViewRef = inputView
}, },
modifier = Modifier.size(1.dp) modifier = Modifier.size(1.dp)
@@ -880,6 +905,7 @@ fun MainScreen(
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0, inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "", luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "",
isNumericField = isNumericField, isNumericField = isNumericField,
fieldStatus = fieldStatus,
uiTheme = uiTheme, uiTheme = uiTheme,
onToggleDocMode = { viewModel.toggleDocMode() }, onToggleDocMode = { viewModel.toggleDocMode() },
onToggleWordWrap = { viewModel.toggleWordWrap() }, onToggleWordWrap = { viewModel.toggleWordWrap() },
@@ -176,6 +176,10 @@ object HostStorage {
"1149" to "1149 - Iceland (Euro €)", "1149" to "1149 - Iceland (Euro €)",
"930" to "930 - Japanese Katakana Mixed DBCS", "930" to "930 - Japanese Katakana Mixed DBCS",
"939" to "939 - Japanese Latin Mixed DBCS", "939" to "939 - Japanese Latin Mixed DBCS",
"290" to "290 - Japanese Katakana SBCS",
"1027" to "1027 - Japanese Latin Extended SBCS",
"1390" to "1390 - Japanese Katakana DBCS Euro",
"1399" to "1399 - Japanese Latin DBCS Euro",
"935" to "935 - Simplified Chinese Mixed DBCS", "935" to "935 - Simplified Chinese Mixed DBCS",
"937" to "937 - Traditional Chinese Mixed DBCS", "937" to "937 - Traditional Chinese Mixed DBCS",
"1388" to "1388 - Simplified Chinese Extended DBCS", "1388" to "1388 - Simplified Chinese Extended DBCS",
@@ -40,6 +40,7 @@ fun OiaStatusBar(
inhibitReason: Int = 0, inhibitReason: Int = 0,
luName: String = "", luName: String = "",
isNumericField: Boolean = false, isNumericField: Boolean = false,
fieldStatus: String = "",
uiTheme: String = "DARK", uiTheme: String = "DARK",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -163,16 +164,22 @@ fun OiaStatusBar(
} }
} }
// Field Status: NUM vs ALPHA // Field Status: DBCS vs NUM vs ALPHA
if (connectionState.isConnected() && rows > 0 && cols > 0) { if (connectionState.isConnected() && rows > 0 && cols > 0) {
val effectiveFieldStatus = if (fieldStatus.isNotBlank()) fieldStatus else (if (isNumericField) "NUM" else "ALPHA")
val badgeColor = when (effectiveFieldStatus) {
"DBCS" -> sysAvailClr
"NUM" -> Color(0xFFFFD43B)
else -> Color(0xFF74C0FC)
}
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Surface( Surface(
color = Color(0xFF339AF0).copy(alpha = 0.2f), color = badgeColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
) { ) {
Text( Text(
text = if (isNumericField) "NUM" else "ALPHA", text = effectiveFieldStatus,
color = if (isNumericField) Color(0xFFFFD43B) else Color(0xFF74C0FC), color = badgeColor,
fontSize = 9.sp, fontSize = 9.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -33,6 +33,7 @@ import androidx.core.content.FileProvider
import haus.nightmare.lib3270j.graphics.GraphicsPlane import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -296,6 +297,10 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
val faVal = cell.fa.toInt() and 0xFF val faVal = cell.fa.toInt() and 0xFF
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
rowSb.append(' ') rowSb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
if (c > 0) continue else rowSb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
rowSb.append(' ')
} else { } else {
if (currentHidden && maskHidden) { if (currentHidden && maskHidden) {
rowSb.append(' ') rowSb.append(' ')
@@ -346,6 +351,10 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
currentFieldEa = cell currentFieldEa = cell
sb.append(' ') sb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
if (c > 0) continue else sb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
sb.append(' ')
} else { } else {
if (currentHidden && maskHidden) { if (currentHidden && maskHidden) {
sb.append(' ') sb.append(' ')
@@ -111,6 +111,10 @@ fun SettingsDialog(
var hotspotsPfEnabled by remember { mutableStateOf(initialHotspotsPfEnabled) } var hotspotsPfEnabled by remember { mutableStateOf(initialHotspotsPfEnabled) }
var hotspotsMenuChoicesEnabled by remember { mutableStateOf(initialHotspotsMenuChoicesEnabled) } var hotspotsMenuChoicesEnabled by remember { mutableStateOf(initialHotspotsMenuChoicesEnabled) }
var startupBehavior by remember { mutableStateOf(initialStartupBehavior) } var startupBehavior by remember { mutableStateOf(initialStartupBehavior) }
val savedHosts = remember { haus.nightmare.a3270.storage.HostStorage.getSavedHosts(context) }
var selectedAutoHostId by remember {
mutableStateOf(haus.nightmare.a3270.storage.HostStorage.getAutoConnectHost(context)?.id ?: savedHosts.firstOrNull()?.id ?: "")
}
var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) } var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) }
var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) } var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) }
@@ -488,6 +492,47 @@ fun SettingsDialog(
) )
} }
} }
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true) && savedHosts.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text("Auto-Connect Target Profile", fontSize = 12.sp, color = Color.LightGray)
Spacer(modifier = Modifier.height(4.dp))
var hostDropdownExpanded by remember { mutableStateOf(false) }
val selectedHost = savedHosts.find { it.id == selectedAutoHostId } ?: savedHosts.first()
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { hostDropdownExpanded = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "${selectedHost.name} (${selectedHost.host}:${selectedHost.port})",
fontSize = 12.sp,
color = Color.White
)
}
DropdownMenu(
expanded = hostDropdownExpanded,
onDismissRequest = { hostDropdownExpanded = false }
) {
savedHosts.forEach { host ->
DropdownMenuItem(
text = {
Text(
text = "${host.name} (${host.host}:${host.port})",
fontSize = 12.sp,
fontWeight = if (host.id == selectedAutoHostId) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
selectedAutoHostId = host.id
hostDropdownExpanded = false
}
)
}
}
}
}
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -914,6 +959,18 @@ fun SettingsDialog(
numericFieldLock, numericFieldLock,
autoSkipEnabled autoSkipEnabled
) )
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true)) {
val targetHost = savedHosts.find { it.id == selectedAutoHostId }
if (targetHost != null) {
haus.nightmare.a3270.storage.HostStorage.saveHost(context, targetHost.copy(autoConnect = true))
}
} else {
val autoHost = haus.nightmare.a3270.storage.HostStorage.getAutoConnectHost(context)
if (autoHost != null) {
haus.nightmare.a3270.storage.HostStorage.saveHost(context, autoHost.copy(autoConnect = false))
}
}
}, },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) { ) {
@@ -88,6 +88,14 @@ class TerminalInputView(context: Context) : View(context) {
var onBackspace: () -> Unit = {} var onBackspace: () -> Unit = {}
var onTab: () -> Unit = {} var onTab: () -> Unit = {}
var onBackTab: () -> Unit = {} var onBackTab: () -> Unit = {}
var isDbcsSession: Boolean = false
set(value) {
if (field != value) {
field = value
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.restartInput(this)
}
}
init { init {
isFocusable = true isFocusable = true
@@ -97,9 +105,13 @@ class TerminalInputView(context: Context) : View(context) {
override fun onCheckIsTextEditor(): Boolean = true override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or outAttrs.inputType = if (isDbcsSession) {
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS } else {
InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
}
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_ACTION_NONE EditorInfo.IME_ACTION_NONE
@@ -355,9 +355,9 @@ fun TerminalView(
var csVal = 0 var csVal = 0
var ecVal = 0 var ecVal = 0
if (buf != null) { val ea = if (buf != null) buf.getCell(addr) else null
val ea = buf.getCell(addr)
if (ea != null) {
if (ea.isFieldAttribute) { if (ea.isFieldAttribute) {
currentFA = ea.fa currentFA = ea.fa
currentFieldEa = ea currentFieldEa = ea
@@ -411,7 +411,10 @@ fun TerminalView(
bgColor = tmp bgColor = tmp
} }
if (bgColor != COLOR_BLACK && bgColor != Color.Transparent) { val tr = if (ea != null && ea.tr != 0.toByte()) ea.tr else (currentFieldEa?.tr ?: 0)
val isOpaque = ((tr.toInt() and 0xFF) == (ExtendedAttribute.TR_OPAQUE.toInt() and 0xFF))
if (isOpaque || (bgColor != COLOR_BLACK && bgColor != Color.Transparent)) {
drawRect( drawRect(
color = bgColor, color = bgColor,
topLeft = Offset(left, top), topLeft = Offset(left, top),
@@ -449,19 +452,22 @@ fun TerminalView(
// Cursor indicator (respects blinking toggle & timer, cursor style, insert mode) // Cursor indicator (respects blinking toggle & timer, cursor style, insert mode)
if (addr == activeCursorAddr && !isSelected && cursorVisible) { if (addr == activeCursorAddr && !isSelected && cursorVisible) {
val isDbcsCur = ea != null && (ea.db == ExtendedAttribute.DB_LEFT || ea.db == ExtendedAttribute.DB_RIGHT)
val curWidth = if (isDbcsCur) cellWidth * 2f else cellWidth
val curLeft = if (isDbcsCur && ea?.db == ExtendedAttribute.DB_RIGHT && c > 0) left - cellWidth else left
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
if (isUnderlineCursor) { if (isUnderlineCursor) {
val ulH = maxOf(4f, cellHeight / 4f) val ulH = maxOf(4f, cellHeight / 4f)
drawRect( drawRect(
color = Color(255, 255, 255, 180), color = Color(255, 255, 255, 180),
topLeft = Offset(left, top + cellHeight - ulH), topLeft = Offset(curLeft, top + cellHeight - ulH),
size = Size(cellWidth, ulH) size = Size(curWidth, ulH)
) )
} else { } else {
drawRect( drawRect(
color = Color(255, 255, 255, 180), color = Color(255, 255, 255, 180),
topLeft = Offset(left, top), topLeft = Offset(curLeft, top),
size = Size(cellWidth, cellHeight) size = Size(curWidth, cellHeight)
) )
} }
} }
@@ -487,12 +493,14 @@ fun TerminalView(
} }
} }
val suppressText = isBlink && !textBlinkVisible val isDbcsRight = ea?.db == ExtendedAttribute.DB_RIGHT
val isDbcsSoSi = ea?.db == ExtendedAttribute.DB_SO || ea?.db == ExtendedAttribute.DB_SI
val suppressText = (isBlink && !textBlinkVisible) || isDbcsRight || isDbcsSoSi
if (!drawnAsPs && charVal != ' ' && !suppressText) { if (!drawnAsPs && charVal != ' ' && !suppressText) {
paint.color = fgColor.toArgb() paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics val fontMetrics = paint.fontMetrics
val textX = left + cellWidth / 2f val textX = if (ea?.db == ExtendedAttribute.DB_LEFT) left + cellWidth else left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
@@ -840,8 +848,8 @@ fun calculateGridMetrics(
val gridW = cols * cellW val gridW = cols * cellW
val gridH = rows * cellH val gridH = rows * cellH
val offX = (viewWidth - gridW) / 2f val offX = maxOf(0f, (viewWidth - gridW) / 2f)
val offY = (viewHeight - gridH) / 2f val offY = maxOf(0f, (viewHeight - gridH) / 2f)
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY) return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
} }
@@ -882,8 +890,21 @@ private fun copySelection(
val addr = r * cols + c val addr = r * cols + c
if (addr < (rows * cols)) { if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr) val cell = screenBuffer.getCell(addr)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) { if (cell != null && !cell.isFieldAttribute) {
line.append(cell.ucs4) if (cell.db == ExtendedAttribute.DB_RIGHT) {
val isSubsequent = if (blockSelectMode) c > minCol else (addr > (minRow * cols + minCol))
if (isSubsequent) {
continue // Character already added by DB_LEFT
} else {
line.append(' ')
}
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
line.append(' ')
} else if (cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
} else { } else {
line.append(' ') line.append(' ')
} }
@@ -0,0 +1,125 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.CodePageRegistry
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.lang.reflect.Method
class DBCSAndParityCompatibilityTest {
@Test
fun testNewJapaneseCodePagesAvailableAndResolvable() {
val availableKeys = HostStorage.AVAILABLE_CODE_PAGES.map { it.first }
assertTrue("290 should be available in HostStorage", availableKeys.contains("290"))
assertTrue("1027 should be available in HostStorage", availableKeys.contains("1027"))
assertTrue("1390 should be available in HostStorage", availableKeys.contains("1390"))
assertTrue("1399 should be available in HostStorage", availableKeys.contains("1399"))
val cp290 = CodePageRegistry.getCodePage("290")
assertNotNull("Cp290 must resolve", cp290)
assertFalse("Cp290 is SBCS", cp290.isDBCS)
val cp1027 = CodePageRegistry.getCodePage("1027")
assertNotNull("Cp1027 must resolve", cp1027)
assertFalse("Cp1027 is SBCS", cp1027.isDBCS)
val cp1390 = CodePageRegistry.getCodePage("1390")
assertNotNull("Cp1390 must resolve", cp1390)
assertTrue("Cp1390 is DBCS", cp1390.isDBCS)
val cp1399 = CodePageRegistry.getCodePage("1399")
assertNotNull("Cp1399 must resolve", cp1399)
assertTrue("Cp1399 is DBCS", cp1399.isDBCS)
val trans1390 = EbcdicTranslator("1390")
assertTrue("Translator for 1390 must report isDBCS", trans1390.isDBCS)
}
@Test
fun testScreenExporterDBCSNoDuplication() {
val trans = EbcdicTranslator("930")
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, trans)
screen.erase(false)
// Put a 2-cell DBCS character at cells 0 and 1: '東' (\u6771)
val cell0 = screen.getCell(0)
cell0.db = ExtendedAttribute.DB_LEFT
cell0.ucs4 = '\u6771'
val cell1 = screen.getCell(1)
cell1.db = ExtendedAttribute.DB_RIGHT
cell1.ucs4 = '\u6771'
// Put SBCS 'X' at cell 2
val cell2 = screen.getCell(2)
cell2.db = ExtendedAttribute.DB_NONE
cell2.ucs4 = 'X'
// Invoke private generatePlainText via reflection
val exporterClass = Class.forName("haus.nightmare.a3270.ui.ScreenExporterDialogKt")
val method: Method = exporterClass.getDeclaredMethod(
"generatePlainText",
ScreenBuffer::class.java,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType
)
method.isAccessible = true
val plainText = method.invoke(null, screen, 1, 80, true) as String
assertTrue("Output should start with single DBCS char + X: expected '東X', got: '$plainText'", plainText.startsWith("東X"))
assertFalse("DBCS char must not be doubled into '東東X'", plainText.startsWith("東東"))
}
@Test
fun testTerminalGridMetricsCenteringClampedAtZero() {
val terminalViewClass = Class.forName("haus.nightmare.a3270.ui.TerminalViewKt")
val method = terminalViewClass.getDeclaredMethod(
"calculateGridMetrics",
Float::class.javaPrimitiveType,
Float::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType
)
method.isAccessible = true
// View is constrained / non-negative offsets
val constrainedMetrics = method.invoke(null, 400f, 300f, 80, 24)
val offXField = constrainedMetrics.javaClass.getDeclaredField("offsetX")
offXField.isAccessible = true
val offX = offXField.getFloat(constrainedMetrics)
val offYField = constrainedMetrics.javaClass.getDeclaredField("offsetY")
offYField.isAccessible = true
val offY = offYField.getFloat(constrainedMetrics)
assertTrue("Constrained offX must be non-negative", offX >= 0f)
assertTrue("Constrained offY must be non-negative", offY >= 0f)
// Wide view (pillarbox with positive offX)
val wideMetrics = method.invoke(null, 3000f, 1000f, 80, 24)
val wideOffX = offXField.getFloat(wideMetrics)
val wideOffY = offYField.getFloat(wideMetrics)
assertTrue("Wide view offX should be positive", wideOffX > 0f)
assertEquals(0f, wideOffY, 0.001f)
// Tall view (letterbox with positive offY)
val tallMetrics = method.invoke(null, 1000f, 3000f, 80, 24)
val tallOffX = offXField.getFloat(tallMetrics)
val tallOffY = offYField.getFloat(tallMetrics)
assertEquals(0f, tallOffX, 0.001f)
assertTrue("Tall view offY should be positive", tallOffY > 0f)
}
@Test
fun testExtendedAttributeTransparencyConstants() {
assertEquals(0x00.toByte(), ExtendedAttribute.TR_DEFAULT)
assertEquals(0xF0.toByte(), ExtendedAttribute.TR_OR)
assertEquals(0xFF.toByte(), ExtendedAttribute.TR_OPAQUE)
}
}