DECCOLM support: 80/132 column mode switching for vttest compatibility

- Handle CSI?3h (132-col) and CSI?3l (80-col) in setDecPrivateMode:
  clear screen, reset scroll margins, home cursor, fire listener callback
- Add onColumnModeChange callback to TerminalListener interface
- TerminalService handles DECCOLM by reflowing ScreenBuffer to requested
  column count and sending PTY resize to the SSH server
- Add onBufferReplaced callback so TerminalSurfaceView picks up the new
  ScreenBuffer immediately (not just on next Compose recomposition)
- Add DECCOLM parity tests verifying match with Termux behavior
- vttest test 1 now renders correctly on terminals wider than 80 columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-03-31 00:33:10 +02:00
parent 65df2338a7
commit b2d8354ebf
5 changed files with 126 additions and 1 deletions

View file

@ -109,6 +109,8 @@ class TerminalService : Service() {
var autoReconnectEnabled: Boolean = false,
var wifiLockEnabled: Boolean = false,
var onScreenUpdated: (() -> Unit)? = null,
/** Called when the ScreenBuffer is replaced (e.g. DECCOLM resize) — view must update its reference */
var onBufferReplaced: ((ScreenBuffer) -> Unit)? = null,
var outputJob: Job? = null,
var stateJob: Job? = null,
/** Per-session font size (zoom level). 0 = use default. Survives Activity destruction. */
@ -275,6 +277,19 @@ class TerminalService : Service() {
lastTitleLogTime = now
}
}
override fun onColumnModeChange(cols: Int) {
val sid = entry.sessionId
val curBuf = entry.screenBuffer
val curRows = curBuf.rows
if (cols == curBuf.cols) return
FileLogger.log(TAG, "DECCOLM[$sid]: ${curBuf.cols}$cols cols")
val newBuf = curBuf.reflowResize(curRows, cols)
val newParser = com.roundingmobile.terminalview.engine.XtermParser(newBuf)
replaceEngine(sid, newBuf, newParser)
entry.onBufferReplaced?.invoke(newBuf)
entry.onScreenUpdated?.invoke()
resizePty(sid, cols, curRows)
}
override val onDsrLog: ((String) -> Unit) = { msg ->
FileLogger.log(TAG, msg)
}

View file

@ -297,6 +297,9 @@ fun TerminalPane(
tags.terminalView.screenBuffer = currentEntry.screenBuffer
}
currentEntry.onScreenUpdated = { tags.terminalView.invalidateTerminal() }
currentEntry.onBufferReplaced = { newBuf ->
tags.terminalView.screenBuffer = newBuf
}
} else {
FileLogger.log(TAG, "update[$sessionId]: entry NULL, visible=$visible")
}

View file

@ -614,6 +614,13 @@ open class BaseTermParser(val screen: ScreenBuffer) {
protected open fun setDecPrivateMode(mode: Int, on: Boolean) {
when (mode) {
1 -> screen.setApplicationCursorKeys(on) // DECCKM
3 -> { // DECCOLM — 80/132 column mode
val cols = if (on) 132 else 80
screen.clearScreen()
screen.setScrollRegion(0, 0xFF)
screen.locate(0, 0)
screen.listener?.onColumnModeChange(cols)
}
5 -> screen.setReverseScreenMode(on) // DECSCNM
6 -> screen.setOriginMode(on) // DECOM
7 -> screen.setWrapMode(on) // DECAWM

View file

@ -14,6 +14,8 @@ interface TerminalListener {
*/
fun onSendToHost(data: ByteArray) {}
fun onScreenChanged() {}
/** DECCOLM: terminal requests 80 (on=false) or 132 (on=true) column mode. */
fun onColumnModeChange(cols: Int) {}
/** Optional diagnostic callback for DSR exchanges — set to log cursor position queries */
val onDsrLog: ((String) -> Unit)? get() = null
}

View file

@ -178,10 +178,108 @@ class VttestParityTest {
ourParser = XtermParser(ourScreen)
val data = readRecording("vttest_cursor_movements.bin")
ourParser.process(data, 0, data.size)
// Print rows around the bottom border to see the issue
for (row in 20..25) {
val line = (0 until 102).map { c -> val ch = ourScreen.getChar(row, c); if (ch == '\u0000') ' ' else ch }.joinToString("")
println("Row $row: |${line}|")
}
}
// =========================================================================
// DECCOLM parity tests — does Termux handle CSI?3h / CSI?3l ?
// =========================================================================
private val ESC = "\u001b"
private val CSI = "\u001b["
private fun initBoth(rows: Int, cols: Int) {
ourScreen = ScreenBuffer(rows, cols, maxScrollback = 100)
ourParser = XtermParser(ourScreen)
val output = object : TerminalOutput() {
override fun write(data: ByteArray, offset: Int, count: Int) {}
override fun titleChanged(oldTitle: String?, newTitle: String?) {}
override fun onCopyTextToClipboard(text: String?) {}
override fun onPasteTextFromClipboard() {}
override fun onBell() {}
override fun onColorsChanged() {}
}
val client = object : TerminalSessionClient {
override fun onTextChanged(session: TerminalSession) {}
override fun onTitleChanged(session: TerminalSession) {}
override fun onSessionFinished(session: TerminalSession) {}
override fun onCopyTextToClipboard(session: TerminalSession, text: String) {}
override fun onPasteTextFromClipboard(session: TerminalSession?) {}
override fun onBell(session: TerminalSession) {}
override fun onColorsChanged(session: TerminalSession) {}
override fun onTerminalCursorStateChange(state: Boolean) {}
override fun getTerminalCursorStyle(): Int = 0
override fun logError(tag: String, message: String) {}
override fun logWarn(tag: String, message: String) {}
override fun logInfo(tag: String, message: String) {}
override fun logDebug(tag: String, message: String) {}
override fun logVerbose(tag: String, message: String) {}
override fun logStackTraceWithMessage(tag: String, message: String, e: Exception) {}
override fun logStackTrace(tag: String, e: Exception) {}
}
termuxEmulator = TerminalEmulator(output, cols, rows, null, client)
}
private fun feed(input: String) {
val bytes = input.toByteArray(Charsets.ISO_8859_1)
ourParser.process(bytes, 0, bytes.size)
termuxEmulator.append(bytes, bytes.size)
}
private fun compareAndReport(cols: Int, rows: Int): List<String> {
val mm = mutableListOf<String>()
val ourR = ourScreen.cursorRow; val ourC = ourScreen.cursorCol
val txR = termuxEmulator.cursorRow; val txC = termuxEmulator.cursorCol
if (ourR != txR || ourC != txC) {
println("CURSOR: ours=($ourR,$ourC) termux=($txR,$txC)")
mm.add("CURSOR")
}
for (row in 0 until rows) {
val ours = (0 until cols).map { c -> val ch = ourScreen.getChar(row, c); if (ch == '\u0000') ' ' else ch }.joinToString("")
val termux = (0 until cols).map { termuxCharAt(row, it) }.joinToString("")
if (ours != termux) {
mm.add("Row $row")
println("ROW $row MISMATCH:")
println(" OURS: |${ours.trimEnd()}|")
println(" TERMUX: |${termux.trimEnd()}|")
}
}
return mm
}
@Test
fun `DECCOLM off - CSI?3l clears screen and homes cursor`() {
initBoth(24, 120)
feed("X".repeat(120 * 24))
feed("${CSI}?3l")
val mm = compareAndReport(120, 24)
if (mm.isEmpty()) println("✓ DECCOLM off: match") else println("${mm.size} differences")
assert(mm.isEmpty()) { "DECCOLM off mismatch: ${mm.joinToString(", ")}" }
}
@Test
fun `DECCOLM on - CSI?3h clears screen and homes cursor`() {
initBoth(24, 80)
feed("X".repeat(80 * 24))
feed("${CSI}?3h")
val mm = compareAndReport(80, 24)
if (mm.isEmpty()) println("✓ DECCOLM on: match") else println("${mm.size} differences")
assert(mm.isEmpty()) { "DECCOLM on mismatch: ${mm.joinToString(", ")}" }
}
@Test
fun `DECCOLM off then DECALN - vttest pattern on wide terminal`() {
initBoth(24, 120)
feed("${CSI}?3l") // DECCOLM off (80 cols)
feed("${ESC}#8") // DECALN — fill screen with E
feed("${CSI}9;10H") // CUP row 9, col 10
feed("${CSI}1J") // ED — erase from start to cursor
val mm = compareAndReport(120, 24)
if (mm.isEmpty()) println("✓ DECCOLM + DECALN: match") else println("${mm.size} differences")
assert(mm.isEmpty()) { "DECCOLM + DECALN mismatch: ${mm.joinToString(", ")}" }
}
}