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:
parent
65df2338a7
commit
b2d8354ebf
5 changed files with 126 additions and 1 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(", ")}" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue