VT52 compatibility mode with DEC Special Graphics charset

Implement VT52 mode as a standalone delegate class (Vt52Parser), activated
via CSI ?2l (DECANM reset) and exited via ESC <. Supports all VT52 escape
sequences: cursor movement (A-D), direct addressing (ESC Y row col), erase
(J/K), reverse index (I), home (H), and DECID response (ESC /Z).

Graphics character mode (ESC F/G) maps ASCII 0x5F-0x7E to DEC Special
Graphics glyphs (box-drawing, math symbols) — same charset as VT100's G0 '0'.

37 unit tests, all vttest tests 1-11 now pass including test 7 (VT52 mode).
New docs/TERMINAL_PARSER.md with full parser architecture, state machine,
DECCOLM quirks, VT52 implementation details, and graphics charset table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-03-31 09:47:40 +02:00
parent e72c4de55d
commit 40650286dc
7 changed files with 1203 additions and 25 deletions

View file

@ -527,7 +527,7 @@ Our own VT100/VT220/xterm parser + Canvas renderer. No external terminal library
- **Dimensions**: `rows`, `cols`, `maxScrollback` (default 10000, max 65000)
- **Storage**: `Array<ScreenRow>` (visible) + `ArrayDeque<ScreenRow>` (history)
- **Attributes**: Packed 64-bit `TextAttr` per cell (bold, italic, underline, blink, reverse, 256-color, RGB)
- **Modes**: wrap, insert, origin, reverse screen, bracketed paste, mouse tracking
- **Modes**: wrap, insert, origin, reverse screen, bracketed paste, mouse tracking, vt52Mode (DECANM)
- **Scroll region**: `scrollTop`/`scrollBottom` for partial screen scrolling
- **Alternate screen**: DECSET 47/1047/1049 (vim, htop use this)
@ -537,15 +537,20 @@ Key operations: cursor movement (CUU/CUD/CUF/CUB/CUP/HVP), scroll (SU/SD/IND/RI)
#### Parser Hierarchy
```
BaseTermParser → Vt100Parser → Vt220Parser → XtermParser
BaseTermParser → Vt220Parser → XtermParser (ANSI mode — inheritance chain)
Vt52Parser (VT52 mode — standalone delegate)
```
**BaseTermParser**: C0/C1 controls, CSI dispatch (~30 commands), SGR (0-108), SM/RM modes, DSR, character set designation.
**BaseTermParser**: C0/C1 controls, CSI dispatch (~30 commands), SGR (0-108), SM/RM modes, DSR, character set designation. Delegates to `Vt52Parser` when `screen.vt52Mode` is true.
**Vt220Parser**: DECSCA (selective erase), DHDW (double-height/double-width lines), VT220 selective erase.
**Vt220Parser**: DECSCA (selective erase), DHDW (double-height/double-width lines), G2/G3 charsets, locking/single shifts.
**XtermParser**: OSC (title, clipboard), DCS, bracketed paste, mouse reporting (X10/SGR/URXVT), alternate screen.
**Vt52Parser**: VT52 compatibility mode. Cursor movement (`ESC A-D`), direct addressing (`ESC Y row col`), erase (`ESC J/K`), graphics charset (`ESC F/G` with DEC Special Graphics line-drawing glyphs), DECID response (`ESC / Z`). Entered via `CSI ?2l` (DECANM), exited via `ESC <`.
> See `docs/TERMINAL_PARSER.md` for the full parser architecture guide, state machine details, DECCOLM mid-chunk fix, VT52 implementation, graphics charset mapping table, and known quirks.
#### `TextAttr` (64-bit packed)
```
Bits 0-7: Flags (bold, italic, underline, blink, reverse, hidden, erasable, dhdw)

471
docs/TERMINAL_PARSER.md Normal file
View file

@ -0,0 +1,471 @@
# Terminal Parser — Architecture & Implementation Guide
> Last updated: 2026-03-31
> Module: `lib-terminal-view`
> Package: `com.roundingmobile.terminalview.engine`
---
## Overview
Our terminal emulator implements a complete VT100/VT220/xterm parser, ported from the TellNext C++ ANSICtrl engine. It also supports VT52 compatibility mode. No external terminal library is used — this is fully owned code.
The parser processes raw byte streams from SSH/Telnet/PTY connections and updates a `ScreenBuffer` model, which is then rendered by the `TerminalRenderer`.
---
## Parser Hierarchy
```
BaseTermParser ← C0/C1, CSI dispatch, SGR, SM/RM, DSR
└─ Vt220Parser ← DECSCA, DHDW, G2/G3 charsets, locking/single shifts
└─ XtermParser ← OSC (title/color), 256-color/RGB SGR, mouse, bracketed paste
Vt52Parser ← Standalone delegate (NOT in inheritance chain)
```
`Vt52Parser` is a separate class used as a delegate by `BaseTermParser` when VT52 compatibility mode is active. It does not extend `BaseTermParser` — it has its own independent state machine.
---
## State Machine
### ANSI Mode (normal operation)
`BaseTermParser` uses an enum-based state machine:
```kotlin
enum class State {
GROUND, // Normal text / C0 controls
ESCAPE, // Got ESC (0x1B), waiting for next byte
CSI_PARAMS, // Inside CSI (ESC[), collecting parameters
CSI_GT, // Inside CSI > (ESC[>), DA2 / kitty keyboard
CSI_LT, // Inside CSI < (ESC[<), kitty keyboard pop
OSC_STRING, // Inside OSC (ESC]), collecting string until BEL/ST
DCS_STRING, // Inside DCS (ESCP), collecting until ST
CHARSET // ESC followed by (, ), *, + — waiting for designator
}
```
**Byte flow**: `process(data)` → loop over bytes → dispatch by state.
In `GROUND`:
- `0x1B` (ESC) → enter `ESCAPE` state
- `0x00-0x1F``processC0()` (BEL, BS, HT, LF, CR, etc.)
- `0x7F` (DEL) → ignore
- `0x80-0x9F``processC1()` (override in Vt220Parser)
- `0x20+``processPrintable()` (batch UTF-8 decode + `screen.putChar()`)
In `ESCAPE`:
- `[` → enter `CSI_PARAMS`
- `]` → enter `OSC_STRING`
- `P` → enter `DCS_STRING`
- `(`, `)`, `*`, `+` → enter `CHARSET`
- Other → dispatch via `processEscChar()`
In `CSI_PARAMS`:
- Digits → accumulate in `paramDigitBuf` (max `MAX_PARAM_LEN=16` chars)
- `;` / `,` → flush param, increment `paramCount`
- `?`, `$`, `!`, etc. → set `csiFlags`
- Final byte (`0x40-0x7E`) → `dispatchCsi(ch)`
### VT52 Mode (compatibility)
Activated by `CSI ?2l` (DECANM reset). When `screen.vt52Mode == true`, the main `process()` loop delegates every byte to `Vt52Parser.processByte()` instead of the ANSI state machine.
VT52 has its own simpler state machine:
```kotlin
enum class State {
GROUND, // Normal text / C0 controls
ESCAPE, // Got ESC, waiting for VT52 command byte
CURSOR_ROW, // ESC Y received, waiting for row byte
CURSOR_COL // Got row, waiting for col byte
}
```
VT52 mode is exited by `ESC <` (sets `screen.vt52Mode = false`).
---
## CSI Parameter Parsing
CSI sequences: `ESC [ <params> <final-byte>`
```kotlin
params: IntArray(20) // up to 20 numeric parameters
paramMissed: BooleanArray(20) // true if param was not explicitly given
paramCount: Int // number of params collected
paramDigitBuf: StringBuilder // current digit accumulator (max 16 chars)
csiFlags: Int // bitmask: FLAG_QMARK, FLAG_DOLLAR, etc.
```
### MAX_PARAM_LEN = 16
This controls the maximum digits accumulated per CSI parameter. It was increased from 10 to 16 to handle vttest's leading-zero tests, which send parameters like `ESC[00000000004;000000001H` (11 digits). With 10, the significant digit was silently dropped after the 10 leading zeros.
**Quirk**: If a CSI parameter exceeds 16 digits, additional digits are silently dropped. The parameter value will be whatever was accumulated from the first 16 digits. In practice, no real terminal application sends more than 5-6 digits.
### CSI Flags
Modifier characters before the final byte set flags:
| Flag | Character | Meaning |
|------|-----------|---------|
| `FLAG_QMARK` | `?` | DEC private mode (CSI ? n h/l) |
| `FLAG_DOLLAR` | `$` | DECRQM, selective erase |
| `FLAG_EXCLAM` | `!` | DECSTR (soft reset) |
| `FLAG_QUOTE` | `"` | DECSCA, DECSASD |
| `FLAG_PLUS` | `+` | Various extended |
| `FLAG_STAR` | `*` | Various extended |
---
## Mode Handling (SM/RM)
### DEC Private Modes (CSI ? n h/l)
| Mode | Name | Description |
|------|------|-------------|
| 1 | DECCKM | Application cursor keys (`ESC O A` vs `ESC [ A`) |
| 2 | DECANM | ANSI mode. Reset = enter VT52 mode, set = exit VT52 |
| 3 | DECCOLM | 80/132 column mode switching |
| 5 | DECSCNM | Reverse screen (swap fg/bg for entire screen) |
| 6 | DECOM | Origin mode (cursor moves relative to scroll region) |
| 7 | DECAWM | Auto-wrap mode |
| 25 | DECTCEM | Text cursor enable (show/hide cursor) |
| 47 | — | Alternate screen buffer (no save/restore cursor) |
| 67 | DECBKM | Backspace mode |
| 1047 | — | Alternate screen buffer (no save/restore cursor) |
| 1049 | — | Alternate screen buffer (with save/restore cursor) |
| 9 | — | X10 mouse tracking |
| 1000 | — | Normal mouse tracking |
| 1002 | — | Button-event mouse tracking |
| 1003 | — | Any-event mouse tracking |
| 1006 | — | SGR mouse encoding |
| 1015 | — | URXVT mouse encoding |
| 2004 | — | Bracketed paste mode |
### ANSI Modes (CSI n h/l)
| Mode | Name | Description |
|------|------|-------------|
| 2 | KAM | Keyboard lock (no-op, handled externally) |
| 4 | IRM | Insert/replace mode |
| 12 | SRM | Local echo (no-op, handled externally) |
| 20 | LNM | Line feed / new line mode (no-op) |
---
## DECCOLM (Mode 3) — 80/132 Column Switching
DECCOLM is one of the trickiest escape sequences because it triggers a full terminal resize mid-parse.
### How it works
1. Parser encounters `CSI ?3h` (132 cols) or `CSI ?3l` (80 cols)
2. `setDecPrivateMode(3, on)` clears the screen, resets scroll region, homes cursor
3. Calls `listener.onColumnModeChange(cols)` which reaches `TerminalService`
4. `TerminalService.replaceEngine()` creates a new `ScreenBuffer` and `XtermParser`
### The mid-chunk problem (fixed)
**Problem**: `parser.process(bytes)` is iterating a byte array. When DECCOLM fires at byte N, `replaceEngine()` replaces the entry's `screenBuffer` and `parser`. But the OLD parser instance continues executing `process()` for bytes N+1..end, writing to its `screen` field — which still points to the old (discarded) buffer. All output after the DECCOLM in the same TCP chunk is lost.
**Fix**: In `replaceEngine()`, the line `entry.parser.screen = newBuffer` redirects the still-executing old parser to write to the new buffer. This works because:
- `BaseTermParser.screen` is `var` (mutable)
- Kotlin's `synchronized` is reentrant (same thread), so the nested lock acquisition in `replaceEngine()` → called from `parser.process()` → under `parserLock` works
```kotlin
fun replaceEngine(sessionId: Long, newBuffer: ScreenBuffer, newParser: XtermParser) {
val entry = sessions[sessionId] ?: return
synchronized(entry.parserLock) {
newBuffer.listener = entry.screenBuffer.listener
entry.parser.screen = newBuffer // ← critical: redirect old parser mid-process
entry.screenBuffer = newBuffer
entry.parser = newParser
}
}
```
### Testing DECCOLM
vttest test 1 exercises DECCOLM with these screens:
- Screen 3: 132-column border test (auto-wrap at col 132)
- Screen 4: 80-column border test (auto-wrap at col 80)
- Screen 5: 132-column auto-wrap test
- Screen 6: 80-column auto-wrap test
All require the mid-chunk fix to display the menu after the mode switch.
---
## VT52 Compatibility Mode
### Entry and exit
| Direction | Mechanism | Code path |
|-----------|-----------|-----------|
| Enter VT52 | `CSI ?2l` (DECANM reset) | `setDecPrivateMode(2, false)``screen.vt52Mode = true` |
| Exit VT52 | `ESC <` (from VT52) | `Vt52Parser.processEscape('<')``screen.vt52Mode = false` |
| Exit VT52 | `CSI ?2h` (from ANSI — not reachable from VT52) | `setDecPrivateMode(2, true)``screen.vt52Mode = false` |
**Important**: `CSI ?2h` cannot exit VT52 mode because CSI sequences (`ESC [`) don't exist in VT52. The `[` after ESC is treated as an unrecognized VT52 command. The ONLY way to exit VT52 from within VT52 is `ESC <`.
### VT52 Escape Sequences
| Sequence | Action | ScreenBuffer call |
|----------|--------|-------------------|
| `ESC A` | Cursor up | `cursorUp(1)` |
| `ESC B` | Cursor down | `cursorDown(1)` |
| `ESC C` | Cursor right | `cursorRight(1)` |
| `ESC D` | Cursor left | `cursorLeft(1)` |
| `ESC F` | Enter graphics mode | Sets internal `graphicsMode` flag |
| `ESC G` | Exit graphics mode | Clears internal `graphicsMode` flag |
| `ESC H` | Cursor home | `locate(0, 0)` |
| `ESC I` | Reverse linefeed | `reverseIndex()` |
| `ESC J` | Erase to end of screen | `clearToEndOfScreen()` |
| `ESC K` | Erase to end of line | `clearToEndOfLine()` |
| `ESC Y r c` | Direct cursor address | `locate(r - 32, c - 32)` |
| `ESC Z` | Identify (DECID) | Responds with `ESC / Z` |
| `ESC <` | Exit VT52 mode | `screen.vt52Mode = false` |
| `ESC =` | Keypad application mode | (handled by key encoder) |
| `ESC >` | Keypad numeric mode | (handled by key encoder) |
### ESC Y — Direct Cursor Address
This is a 3-byte sequence: `ESC Y <row> <col>`, where row and col are the target position + 32 (ASCII space = position 0). This requires two extra parser states (`CURSOR_ROW`, `CURSOR_COL`).
The row and col bytes can arrive in separate TCP chunks — the state machine correctly handles this case.
### DECID Response
In ANSI mode, `ESC Z` (DECID) sends the primary DA response: `ESC [ ? 62 ; 1 ; 2 ; 6 ; 7 ; 8 ; 9 c`.
In VT52 mode, `ESC Z` sends the VT52 identify response: `ESC / Z`.
### C0 Controls in VT52
C0 controls (0x00-0x1F) work identically in both ANSI and VT52 modes:
| Byte | Name | Action |
|------|------|--------|
| 0x07 | BEL | `listener.onBell()` |
| 0x08 | BS | `screen.backspace()` |
| 0x09 | HT | `screen.tab()` |
| 0x0A | LF | `screen.lineFeed()` |
| 0x0B | VT | `screen.lineFeed()` |
| 0x0C | FF | `screen.lineFeed()` |
| 0x0D | CR | `screen.carriageReturn()` |
| 0x18 | CAN | Cancel — return to GROUND |
| 0x1A | SUB | Cancel — return to GROUND |
---
## VT52 Graphics Character Set
VT52 has a graphics mode (`ESC F` enters, `ESC G` exits) that maps ASCII bytes 0x5F-0x7E to DEC Special Graphics glyphs (line-drawing characters, math symbols). This is the same character set used by VT100's charset `0` (selected via `ESC ( 0`).
### Mapping Table
| Byte | ASCII | Glyph | Unicode | Description |
|------|-------|-------|---------|-------------|
| 0x5F | `_` | ` ` | U+0020 | Blank |
| 0x60 | `` ` `` | `◆` | U+25C6 | Diamond |
| 0x61 | `a` | `▒` | U+2592 | Checkerboard |
| 0x62 | `b` | `␉` | U+2409 | HT symbol |
| 0x63 | `c` | `␌` | U+240C | FF symbol |
| 0x64 | `d` | `␍` | U+240D | CR symbol |
| 0x65 | `e` | `␊` | U+240A | LF symbol |
| 0x66 | `f` | `°` | U+00B0 | Degree |
| 0x67 | `g` | `±` | U+00B1 | Plus-minus |
| 0x68 | `h` | `␤` | U+2424 | NL symbol |
| 0x69 | `i` | `␋` | U+240B | VT symbol |
| 0x6A | `j` | `┘` | U+2518 | Lower-right corner |
| 0x6B | `k` | `┐` | U+2510 | Upper-right corner |
| 0x6C | `l` | `┌` | U+250C | Upper-left corner |
| 0x6D | `m` | `└` | U+2514 | Lower-left corner |
| 0x6E | `n` | `┼` | U+253C | Crossing |
| 0x6F | `o` | `⎺` | U+23BA | Scan line 1 |
| 0x70 | `p` | `⎻` | U+23BB | Scan line 3 |
| 0x71 | `q` | `─` | U+2500 | Horizontal line |
| 0x72 | `r` | `⎼` | U+23BC | Scan line 7 |
| 0x73 | `s` | `⎽` | U+23BD | Scan line 9 |
| 0x74 | `t` | `├` | U+251C | Left tee |
| 0x75 | `u` | `┤` | U+2524 | Right tee |
| 0x76 | `v` | `┴` | U+2534 | Bottom tee |
| 0x77 | `w` | `┬` | U+252C | Top tee |
| 0x78 | `x` | `│` | U+2502 | Vertical line |
| 0x79 | `y` | `≤` | U+2264 | Less-than-or-equal |
| 0x7A | `z` | `≥` | U+2265 | Greater-than-or-equal |
| 0x7B | `{` | `π` | U+03C0 | Pi |
| 0x7C | `\|` | `≠` | U+2260 | Not-equal |
| 0x7D | `}` | `£` | U+00A3 | Pound sign |
| 0x7E | `~` | `·` | U+00B7 | Centered dot |
Characters below 0x5F are NOT mapped — they pass through as normal ASCII.
### Implementation
The mapping table lives in `Vt52Parser.GRAPHICS_MAP` (companion object) as a `CharArray`. The static function `Vt52Parser.mapGraphicsChar(ch)` performs the lookup.
When `graphicsMode` is true, `processPrintable()` calls `mapGraphicsChar()` on each ASCII byte before passing it to `screen.putChar()`. UTF-8 multi-byte characters are not affected by graphics mode.
### State reset
Graphics mode is cleared when:
- `ESC G` is received (explicit exit)
- `ESC <` exits VT52 mode (graphics mode is part of VT52 state, not screen state)
- `Vt52Parser.reset()` is called (on any DECANM mode change)
- `ScreenBuffer.reset()` clears `vt52Mode`, and the next DECANM entry calls `vt52.reset()`
---
## Delegation Architecture
### Why VT52 is a separate class
VT52 is not part of the VT100→VT220→Xterm evolution. It's a completely different, simpler protocol from 1975 that DEC terminals supported as a compatibility mode. The escape sequences are incompatible:
| Feature | ANSI (VT100+) | VT52 |
|---------|---------------|------|
| Cursor up | `ESC [ A` | `ESC A` |
| Cursor address | `ESC [ row ; col H` | `ESC Y row col` |
| Clear screen | `ESC [ 2 J` | `ESC J` (erase to end only) |
| Parameters | CSI with numeric params | No parameters |
| Colors/SGR | `ESC [ 1 ; 31 m` | Not supported |
Putting VT52 in the inheritance chain would add complexity to every parser level. As a delegate:
- `BaseTermParser` holds `private val vt52 = Vt52Parser { screen }`
- The `process()` main loop checks `screen.vt52Mode` on every byte
- When true, delegates to `vt52.processByte()` — the ANSI state machine is bypassed entirely
- The lambda `{ screen }` ensures `Vt52Parser` always uses the current screen reference (important for `replaceEngine()`)
### Screen reference synchronization
`Vt52Parser` accesses the screen via a `() -> ScreenBuffer` lambda, not a stored reference. This handles the `replaceEngine()` scenario where `BaseTermParser.screen` is swapped mid-parse — the lambda always reads the current value.
---
## Device Attribute Responses
### Primary DA (CSI c / ESC Z in ANSI mode)
Response: `ESC [ ? 62 ; 1 ; 2 ; 6 ; 7 ; 8 ; 9 c`
Meaning: VT220 (62), with capabilities:
- 1: 132 columns
- 2: Printer port
- 6: Selective erase
- 7: DRCS (soft character sets)
- 8: UDK (user-defined keys)
- 9: National replacement character sets
### Secondary DA (CSI > c)
Response: `ESC [ > 41 ; 0 ; 0 c`
Meaning: Model 41 (VT420 class), firmware 0, no hardware options.
### VT52 DECID (ESC Z in VT52 mode)
Response: `ESC / Z`
Meaning: "I am a VT52."
---
## Known Quirks and Gotchas
### 1. Mid-chunk DECCOLM data loss
Already described above in the DECCOLM section. The fix (`entry.parser.screen = newBuffer`) is critical — without it, any bytes after a DECCOLM switch in the same TCP chunk are silently lost to the discarded buffer.
### 2. CSI leading zeros
vttest sends parameters like `ESC[00000000004;000000001H` (11 digits of leading zeros). With `MAX_PARAM_LEN < 11`, the significant digit is silently dropped and the parameter defaults to 0→1. Fixed by setting `MAX_PARAM_LEN = 16`.
### 3. VT52 ESC Y byte values
`ESC Y` row/col bytes are raw byte values (position + 32), NOT printable digit characters. Row 5 = byte value 37 (not `'5'`). This is unlike CSI cursor addressing where parameters are decimal digit strings.
### 4. VT52 mode is NOT screen state (mostly)
`screen.vt52Mode` lives on `ScreenBuffer` because it needs to survive across parser state resets and be checked in the `process()` loop. But `graphicsMode` lives on `Vt52Parser` because it's parser state that should reset when entering/exiting VT52 mode.
### 5. ANSI state machine is paused during VT52
When VT52 mode is active, the ANSI `state` field (`BaseTermParser.state`) is frozen at whatever it was when VT52 was entered. When VT52 exits, ANSI parsing resumes from that state. This is fine because:
- DECANM is a CSI command, so `state` is `GROUND` after dispatching it
- The VT52→ANSI transition (`ESC <`) always leaves the parser clean
### 6. Chunk size and log visibility
`TerminalService` only hex-dumps chunks smaller than 128 bytes. Larger chunks (like the vttest menu after a DECCOLM switch) are processed silently. When debugging, use session recordings (`/sdcard/Download/SshWorkbench/recordings/`) to capture all bytes.
### 7. Reentrant synchronized
`parser.process()` runs under `parserLock`. When DECCOLM fires, `onColumnModeChange()` calls `replaceEngine()` which takes the same `parserLock`. This works because Kotlin/JVM `synchronized` is reentrant for the same thread. But code in callbacks must NEVER spawn a new thread that also takes `parserLock` — it would deadlock.
---
## Testing
### Unit tests
| File | Count | Coverage |
|------|-------|----------|
| `VtParserTest.kt` | 20+ | SGR, CSI, OSC, modes, cursor, colors |
| `Vt52ParserTest.kt` | 37 | Mode entry/exit, cursor, erase, ESC Y, graphics charset, C0, DECID, split chunks |
| `VttestParityTest.kt` | 5+ | DECCOLM, DECALN, vttest cursor movements (verified against Termux) |
| `ScreenBufferTest.kt` | 100+ | Scrollback, reflow, resize, wrap, insert/delete |
### vttest verification
All main vttest tests (1-11) pass:
| Test | Name | Status |
|------|------|--------|
| 1 | Cursor movements | Pass (all 6 screens, 80+132 cols) |
| 2 | Screen features | Pass (SGR, scroll, wrap, tabs, save/restore) |
| 3 | Character sets | Pass (all 13 sub-tests) |
| 4 | Double-sized characters | Pass (DHDW rendering) |
| 5 | Keyboard | Skipped (requires physical interaction) |
| 6 | Terminal reports | Pass (DA, DSR, CPR, DECREQTPARM) |
| 7 | VT52 mode | Pass (cursor, erase, graphics charset, DECID) |
| 8 | VT102 insert/delete | Pass (all screens) |
| 9 | Known bugs | Pass (all sub-tests) |
| 10 | Reset and self-test | Pass (RIS) |
| 11 | Non-VT100 (VT220/XTERM) | Pass (colors, BCE, VT220 features) |
### Running vttest
```bash
# Install and launch with auto-connect
adb -s 22160523026079 install -r app/build/outputs/apk/pro/debug/ssh-workbench.v1.0.0-pro-debug.apk
adb -s 22160523026079 shell am start -n com.roundingmobile.sshworkbench.pro/com.roundingmobile.sshworkbench.ui.MainActivity --es profile "SSHTest"
# Send input via broadcast
adb -s 22160523026079 shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text 'vttest'"
adb -s 22160523026079 shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text $'\r'"
# Take screenshot for verification
adb -s 22160523026079 shell screencap -p /sdcard/Download/screen.png
adb pull /sdcard/Download/screen.png /tmp/screen.png
```
---
## File Reference
| File | Lines | Purpose |
|------|-------|---------|
| `BaseTermParser.kt` | ~855 | Main loop, C0, ESC dispatch, CSI params, SGR, SM/RM, DSR, DA |
| `Vt220Parser.kt` | ~180 | C1 controls, DECSCA, DHDW, G2/G3, charset shifts |
| `XtermParser.kt` | ~7 | OSC title/color (inherits everything else) |
| `Vt52Parser.kt` | ~130 | VT52 delegate: escape dispatch, ESC Y, graphics charset |
| `ScreenBuffer.kt` | ~900 | 2D grid, scrollback, modes, cursor, erase, scroll, reflow |
| `ScreenRow.kt` | ~60 | Single row: chars + attrs arrays, wrap flag |
| `TextAttr.kt` | ~200 | 64-bit packed cell attributes |
| `MouseMode.kt` | ~20 | Mouse tracking mode/encoding enums |
| `TerminalListener.kt` | ~15 | Callback interface: bell, title, sendToHost, columnModeChange |

View file

@ -93,18 +93,57 @@ Screenshots are saved to `/tmp/zebra_test_*.png`.
## Connecting Automatically
To connect to Duero via ADB without manual taps:
Use the `--es profile` launch intent extra to auto-connect:
```bash
# 1. Launch connection manager
adb shell am start -n com.roundingmobile.sshworkbench.pro/com.roundingmobile.sshworkbench.ui.ConnectionManagerActivity
# 2. Get UI element bounds
adb shell uiautomator dump /sdcard/ui.xml
adb shell cat /sdcard/ui.xml | grep -o 'text="Duero"[^>]*bounds="[^"]*"'
# 3. Tap the connection (bounds are [32,336][688,520], center = 360,428)
adb shell input tap 360 428
adb -s 22160523026079 shell am start \
-n com.roundingmobile.sshworkbench.pro/com.roundingmobile.sshworkbench.ui.MainActivity \
--es profile "SSHTest"
```
Note: UI coordinates may change if the connection list order changes.
The profile name matches against `SavedConnection.name` or `SavedConnection.nickname` (case-insensitive). Also supports `--ez clearLog true` to clear the debug log on launch.
## vttest Verification
vttest is the standard VT100/VT220 terminal conformance test. Run it on Duero:
```bash
# Connect and launch vttest
adb -s 22160523026079 shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text 'vttest'"
adb -s 22160523026079 shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text $'\r'"
# Select a test (e.g. test 1 = cursor movements)
adb -s 22160523026079 shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text '1'"
adb -s 22160523026079 shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text $'\r'"
# Advance screens with Enter
adb -s 22160523026079 shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text $'\r'"
# Take screenshot for verification
adb -s 22160523026079 shell screencap -p /sdcard/Download/screen.png
adb -s 22160523026079 pull /sdcard/Download/screen.png /tmp/vttest_screen.png
```
### vttest Results (all passing as of 2026-03-31)
| Test | Name | Notes |
|------|------|-------|
| 1 | Cursor movements | 80 + 132 column borders, autowrap, leading zeros |
| 2 | Screen features | SGR, soft/jump scroll, save/restore cursor |
| 3 | Character sets | 13 sub-tests, DEC special graphics |
| 4 | Double-sized characters | DHDW top/bottom halves |
| 5 | Keyboard | Skipped (requires physical key presses) |
| 6 | Terminal reports | DA, DSR, CPR, DECREQTPARM |
| 7 | VT52 mode | Cursor, erase, ESC Y, graphics charset, DECID |
| 8 | VT102 features | Insert/delete char/line |
| 9 | Known bugs | Smooth scroll, origin mode, wrap-around |
| 10 | Reset and self-test | RIS, DECTST |
| 11 | Non-VT100 | VT220 reports, XTERM, colors, BCE |
### Debugging vttest failures
1. **Check debug log**: `adb shell cat /sdcard/Download/SshWorkbench/sshworkbench_debug.txt | tail -50`
2. **Chunks < 128 bytes** are hex-dumped in the log; larger chunks are silent
3. **Session recordings** capture all bytes: `/sdcard/Download/SshWorkbench/recordings/`
4. **Decode recordings** with: `python3 -c "import struct, sys; f=open(sys.argv[1],'rb'); [print(f.read(struct.unpack('>I',f.read(4))[0])) for _ in iter(lambda:f.read(4),b'')]"`
5. **DECCOLM issues**: Check if the vttest menu disappears after a column mode switch — see `docs/TERMINAL_PARSER.md` for the mid-chunk fix

View file

@ -51,6 +51,9 @@ open class BaseTermParser(var screen: ScreenBuffer) {
// OSC string accumulator
protected val oscBuf = StringBuilder(256)
// VT52 compatibility mode delegate
private val vt52 = Vt52Parser { screen }
// Last printed character (for CSI b — repeat)
protected var lastPrintedChar: Char = ' '
@ -83,15 +86,19 @@ open class BaseTermParser(var screen: ScreenBuffer) {
val end = offset + length
while (i < end) {
val b = data[i].toInt() and 0xFF
i += when (state) {
State.GROUND -> processGround(b, data, i, end)
State.ESCAPE -> processEscapeByte(b)
State.CSI_PARAMS -> processCsiParam(b)
State.CSI_GT -> processCsiGt(b)
State.CSI_LT -> processCsiLt(b)
State.OSC_STRING -> processOscByte(b, data, i, end)
State.DCS_STRING -> processDcsByte(b)
State.CHARSET -> processCharsetByte(b)
if (screen.vt52Mode) {
i += vt52.processByte(b, data, i, end)
} else {
i += when (state) {
State.GROUND -> processGround(b, data, i, end)
State.ESCAPE -> processEscapeByte(b)
State.CSI_PARAMS -> processCsiParam(b)
State.CSI_GT -> processCsiGt(b)
State.CSI_LT -> processCsiLt(b)
State.OSC_STRING -> processOscByte(b, data, i, end)
State.DCS_STRING -> processDcsByte(b)
State.CHARSET -> processCharsetByte(b)
}
}
}
}
@ -238,7 +245,7 @@ open class BaseTermParser(var screen: ScreenBuffer) {
'8' -> { screen.restoreCursor(); screen.restoreAttributes() } // DECRC
'=' -> { /* DECKPAM — keypad app mode, handled by key encoder */ }
'>' -> { /* DECKPNM — keypad numeric mode */ }
'<' -> { /* DECANM — enter ANSI mode */ }
'<' -> { screen.vt52Mode = false; vt52.reset() } // DECANM — exit VT52
'(' -> { charsetGIndex = 0; state = State.CHARSET } // SCS G0
')' -> { charsetGIndex = 1; state = State.CHARSET } // SCS G1
',' -> { charsetGIndex = 0; state = State.CHARSET } // alt SCS G0
@ -614,6 +621,10 @@ open class BaseTermParser(var screen: ScreenBuffer) {
protected open fun setDecPrivateMode(mode: Int, on: Boolean) {
when (mode) {
1 -> screen.setApplicationCursorKeys(on) // DECCKM
2 -> { // DECANM — VT52/ANSI mode
screen.vt52Mode = !on
vt52.reset()
}
3 -> { // DECCOLM — 80/132 column mode
val cols = if (on) 132 else 80
screen.clearScreen()

View file

@ -64,6 +64,9 @@ class ScreenBuffer(
/** DECCKM — when true, cursor keys send \033O instead of \033[ */
var applicationCursorKeys: Boolean = false; private set
/** DECANM — when false, terminal is in VT52 compatibility mode */
var vt52Mode: Boolean = false
// Mouse tracking modes (DECSET 9, 1000, 1002, 1003, 1006, 1015)
/** Which mouse events to report: NONE, X10, NORMAL, BUTTON_EVENT, ANY_EVENT */
var mouseMode: MouseMode = MouseMode.NONE; private set
@ -830,6 +833,7 @@ class ScreenBuffer(
originMode = false
reverseScreenMode = false
bracketedPasteMode = false
vt52Mode = false
mouseMode = MouseMode.NONE
mouseEncoding = MouseEncoding.X10
wrapPending = false

View file

@ -0,0 +1,178 @@
package com.roundingmobile.terminalview.engine
/**
* VT52 compatibility mode parser.
*
* Activated by CSI ?2l (DECANM reset), deactivated by ESC < or CSI ?2h.
* VT52 uses a simpler escape sequence set no CSI, just ESC + single char.
*
* Not part of the VT100VT220XTerm inheritance chain this is a delegate
* that BaseTermParser hands bytes to when vt52Mode is active.
*/
class Vt52Parser(private val getScreen: () -> ScreenBuffer) {
private enum class State {
GROUND, // Normal text
ESCAPE, // Got ESC, waiting for command byte
CURSOR_ROW, // ESC Y received, waiting for row byte
CURSOR_COL // Got row byte, waiting for col byte
}
private var state = State.GROUND
private var cursorRow = 0 // temporary storage for ESC Y row
private var graphicsMode = false // ESC F enters, ESC G exits
private val screen get() = getScreen()
companion object {
/**
* DEC Special Graphics character map bytes 0x5F-0x7E Unicode glyphs.
* Same glyphs used by VT100 charset '0' (DEC Special Graphics).
* Index = byte value - 0x5F.
*/
private val GRAPHICS_MAP = charArrayOf(
// 0x5F
' ', // blank
// 0x60-0x6F
'\u25C6', // ◆ diamond
'\u2592', // ▒ checkerboard
'\u2409', // ␉ HT symbol
'\u240C', // ␌ FF symbol
'\u240D', // ␍ CR symbol
'\u240A', // ␊ LF symbol
'\u00B0', // ° degree
'\u00B1', // ± plus-minus
'\u2424', // ␤ NL symbol
'\u240B', // ␋ VT symbol
'\u2518', // ┘ lower-right corner
'\u2510', // ┐ upper-right corner
'\u250C', // ┌ upper-left corner
'\u2514', // └ lower-left corner
'\u253C', // ┼ crossing
'\u23BA', // ⎺ scan line 1
// 0x70-0x7E
'\u23BB', // ⎻ scan line 3
'\u2500', // ─ horizontal line (scan line 5)
'\u23BC', // ⎼ scan line 7
'\u23BD', // ⎽ scan line 9
'\u251C', // ├ left tee
'\u2524', // ┤ right tee
'\u2534', // ┴ bottom tee
'\u252C', // ┬ top tee
'\u2502', // │ vertical line
'\u2264', // ≤ less-than-or-equal
'\u2265', // ≥ greater-than-or-equal
'\u03C0', // π pi
'\u2260', // ≠ not-equal
'\u00A3', // £ pound sign
'\u00B7', // · centered dot
)
/** Map an ASCII byte to its DEC Special Graphics glyph, or return the byte as-is. */
fun mapGraphicsChar(ch: Char): Char {
val idx = ch.code - 0x5F
return if (idx in GRAPHICS_MAP.indices) GRAPHICS_MAP[idx] else ch
}
}
/**
* Process a single byte in VT52 mode.
* Returns the number of bytes consumed (1 for most, more for printable runs).
*/
fun processByte(b: Int, data: ByteArray, pos: Int, end: Int): Int {
return when (state) {
State.GROUND -> processGround(b, data, pos, end)
State.ESCAPE -> processEscape(b)
State.CURSOR_ROW -> { cursorRow = b - 32; state = State.CURSOR_COL; 1 }
State.CURSOR_COL -> { screen.locate(cursorRow, b - 32); state = State.GROUND; 1 }
}
}
private fun processGround(b: Int, data: ByteArray, pos: Int, end: Int): Int {
if (b == 0x1B) {
state = State.ESCAPE
return 1
}
if (b < 0x20) {
processC0(b)
return 1
}
if (b == 0x7F) return 1 // DEL — ignore
// Printable — batch for efficiency
return processPrintable(data, pos, end)
}
private fun processC0(b: Int) {
when (b) {
0x07 -> screen.listener?.onBell()
0x08 -> screen.backspace()
0x09 -> screen.tab()
0x0A, 0x0B, 0x0C -> screen.lineFeed()
0x0D -> screen.carriageReturn()
0x18, 0x1A -> state = State.GROUND // CAN/SUB — cancel
}
}
private fun processEscape(b: Int): Int {
state = State.GROUND
when (b.toChar()) {
'A' -> screen.cursorUp(1)
'B' -> screen.cursorDown(1)
'C' -> screen.cursorRight(1)
'D' -> screen.cursorLeft(1)
'F' -> graphicsMode = true
'G' -> graphicsMode = false
'H' -> screen.locate(0, 0)
'I' -> screen.reverseIndex()
'J' -> screen.clearToEndOfScreen()
'K' -> screen.clearToEndOfLine()
'Y' -> state = State.CURSOR_ROW // Direct cursor address: ESC Y row col
'Z' -> screen.listener?.onSendToHost("\u001B/Z".toByteArray(Charsets.US_ASCII))
'<' -> { graphicsMode = false; screen.vt52Mode = false } // Exit VT52, enter ANSI
'=' -> { /* DECKPAM — keypad application mode */ }
'>' -> { /* DECKPNM — keypad numeric mode */ }
}
return 1
}
private fun processPrintable(data: ByteArray, pos: Int, end: Int): Int {
val s = screen
val gfx = graphicsMode
var i = pos
while (i < end) {
val byte = data[i].toInt() and 0xFF
if (byte < 0x20 || byte == 0x1B || byte == 0x7F) break
if (byte < 0x80) {
val ch = byte.toChar()
s.putChar(if (gfx) mapGraphicsChar(ch) else ch)
i++
} else if (byte < 0xC0) {
i++ // continuation byte without lead — skip
} else if (byte < 0xE0) {
if (i + 1 >= end) break
val cp = ((byte and 0x1F) shl 6) or (data[i + 1].toInt() and 0x3F)
s.putChar(cp.toChar())
i += 2
} else if (byte < 0xF0) {
if (i + 2 >= end) break
val cp = ((byte and 0x0F) shl 12) or
((data[i + 1].toInt() and 0x3F) shl 6) or
(data[i + 2].toInt() and 0x3F)
s.putChar(cp.toChar())
i += 3
} else {
if (i + 3 >= end) break
s.putChar('\uFFFD')
i += 4
}
}
return maxOf(1, i - pos)
}
/** Reset parser state when exiting VT52 mode */
fun reset() {
state = State.GROUND
graphicsMode = false
}
}

View file

@ -0,0 +1,470 @@
package com.roundingmobile.terminalview.engine
import org.junit.Assert.*
import org.junit.Test
class Vt52ParserTest {
private fun createParserWithBuffer(): Pair<XtermParser, ScreenBuffer> {
val buf = ScreenBuffer(24, 80)
val parser = XtermParser(buf)
return parser to buf
}
private fun feed(parser: XtermParser, text: String) {
parser.process(text.toByteArray(Charsets.US_ASCII))
}
private fun feedBytes(parser: XtermParser, vararg bytes: Int) {
parser.process(ByteArray(bytes.size) { bytes[it].toByte() })
}
/** Enter VT52 mode via CSI ?2l (DECANM reset) */
private fun enterVt52(parser: XtermParser) {
feed(parser, "\u001b[?2l")
}
// =====================================================================
// Mode entry/exit
// =====================================================================
@Test
fun `CSI question 2 l enters VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
assertFalse(buf.vt52Mode)
enterVt52(parser)
assertTrue("DECANM reset should enter VT52 mode", buf.vt52Mode)
}
@Test
fun `CSI question 2 h in ANSI mode keeps VT52 off`() {
val (parser, buf) = createParserWithBuffer()
// CSI ?2h while in ANSI mode should confirm DECANM is set (VT52 stays off)
feed(parser, "\u001b[?2h")
assertFalse("DECANM set should keep VT52 mode off", buf.vt52Mode)
}
@Test
fun `CSI does not work in VT52 mode to exit`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
// CSI sequences don't exist in VT52 — ESC [ is not recognized
feed(parser, "\u001b[?2h")
assertTrue("CSI ?2h should NOT exit VT52 mode (CSI doesn't exist in VT52)", buf.vt52Mode)
}
@Test
fun `ESC less-than exits VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
assertTrue(buf.vt52Mode)
feed(parser, "\u001b<")
assertFalse("ESC < should exit VT52 mode", buf.vt52Mode)
}
@Test
fun `reset clears VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
assertTrue(buf.vt52Mode)
buf.reset()
assertFalse("reset() should clear VT52 mode", buf.vt52Mode)
}
// =====================================================================
// Cursor movement
// =====================================================================
@Test
fun `ESC A moves cursor up`() {
val (parser, buf) = createParserWithBuffer()
feed(parser, "\u001b[5;1H") // move to row 5 first (ANSI)
enterVt52(parser)
feed(parser, "\u001bA")
assertEquals("Cursor should be at row 3 (moved up from 4)", 3, buf.cursorRow)
}
@Test
fun `ESC B moves cursor down`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
assertEquals(0, buf.cursorRow)
feed(parser, "\u001bB")
assertEquals("Cursor should be at row 1", 1, buf.cursorRow)
}
@Test
fun `ESC C moves cursor right`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
assertEquals(0, buf.cursorCol)
feed(parser, "\u001bC")
assertEquals("Cursor should be at col 1", 1, buf.cursorCol)
}
@Test
fun `ESC D moves cursor left`() {
val (parser, buf) = createParserWithBuffer()
feed(parser, "\u001b[1;5H") // col 5 (ANSI, 1-indexed)
enterVt52(parser)
feed(parser, "\u001bD")
assertEquals("Cursor should be at col 3 (moved left from 4)", 3, buf.cursorCol)
}
@Test
fun `ESC H homes cursor`() {
val (parser, buf) = createParserWithBuffer()
feed(parser, "\u001b[10;20H") // move away from home (ANSI)
enterVt52(parser)
feed(parser, "\u001bH")
assertEquals("Cursor row should be 0", 0, buf.cursorRow)
assertEquals("Cursor col should be 0", 0, buf.cursorCol)
}
@Test
fun `ESC Y direct cursor addressing`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
// ESC Y row col — row and col are value + 32 (space = 0)
// Row 5, Col 10: bytes are 5+32=37, 10+32=42
feedBytes(parser, 0x1B, 'Y'.code, 37, 42)
assertEquals("Cursor row should be 5", 5, buf.cursorRow)
assertEquals("Cursor col should be 10", 10, buf.cursorCol)
}
@Test
fun `ESC Y row 0 col 0`() {
val (parser, buf) = createParserWithBuffer()
feed(parser, "\u001b[10;20H") // move away first (ANSI)
enterVt52(parser)
feedBytes(parser, 0x1B, 'Y'.code, 32, 32) // row 0, col 0
assertEquals("Cursor row should be 0", 0, buf.cursorRow)
assertEquals("Cursor col should be 0", 0, buf.cursorCol)
}
@Test
fun `ESC Y with larger coordinates`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
// Row 20, Col 60: bytes are 20+32=52, 60+32=92
feedBytes(parser, 0x1B, 'Y'.code, 52, 92)
assertEquals("Cursor row should be 20", 20, buf.cursorRow)
assertEquals("Cursor col should be 60", 60, buf.cursorCol)
}
// =====================================================================
// Erase
// =====================================================================
@Test
fun `ESC J erases to end of screen`() {
val (parser, buf) = createParserWithBuffer()
// Fill screen with 'X' first
for (r in 0 until 24) {
feed(parser, "\u001b[${r + 1};1H" + "X".repeat(80))
}
// Move to row 10, col 40 and enter VT52
feed(parser, "\u001b[11;41H")
enterVt52(parser)
feed(parser, "\u001bJ")
// Chars before cursor should still be 'X'
assertEquals('X', buf.getChar(10, 39))
// Chars at and after cursor should be cleared
assertEquals(' ', buf.getChar(10, 40))
assertEquals(' ', buf.getChar(23, 79))
}
@Test
fun `ESC K erases to end of line`() {
val (parser, buf) = createParserWithBuffer()
feed(parser, "Hello World")
feed(parser, "\u001b[1;6H") // move to col 6 (ANSI 1-indexed)
enterVt52(parser)
feed(parser, "\u001bK")
assertEquals('H', buf.getChar(0, 0))
assertEquals('e', buf.getChar(0, 1))
assertEquals('l', buf.getChar(0, 2))
assertEquals('l', buf.getChar(0, 3))
assertEquals('o', buf.getChar(0, 4))
assertEquals(' ', buf.getChar(0, 5)) // erased
assertEquals(' ', buf.getChar(0, 6)) // erased
}
// =====================================================================
// Reverse linefeed
// =====================================================================
@Test
fun `ESC I reverse linefeed`() {
val (parser, buf) = createParserWithBuffer()
feed(parser, "\u001b[3;1H") // row 3 (ANSI)
enterVt52(parser)
feed(parser, "\u001bI")
assertEquals("Cursor should move up one row", 1, buf.cursorRow)
}
// =====================================================================
// Printable text
// =====================================================================
@Test
fun `printable text renders in VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "Hello VT52")
assertEquals('H', buf.getChar(0, 0))
assertEquals('e', buf.getChar(0, 1))
assertEquals('l', buf.getChar(0, 2))
assertEquals('l', buf.getChar(0, 3))
assertEquals('o', buf.getChar(0, 4))
assertEquals(' ', buf.getChar(0, 5))
assertEquals('V', buf.getChar(0, 6))
assertEquals('T', buf.getChar(0, 7))
assertEquals('5', buf.getChar(0, 8))
assertEquals('2', buf.getChar(0, 9))
}
@Test
fun `cursor advances after text in VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "ABC")
assertEquals(0, buf.cursorRow)
assertEquals(3, buf.cursorCol)
}
// =====================================================================
// C0 controls in VT52 mode
// =====================================================================
@Test
fun `CR works in VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "Hello\r")
assertEquals("CR should return cursor to col 0", 0, buf.cursorCol)
}
@Test
fun `LF works in VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "Hello\n")
assertEquals("LF should move cursor to next row", 1, buf.cursorRow)
}
@Test
fun `BS works in VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "AB\u0008")
assertEquals("BS should move cursor back", 1, buf.cursorCol)
}
@Test
fun `TAB works in VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "\t")
assertEquals("TAB should advance to next tab stop", 8, buf.cursorCol)
}
@Test
fun `BEL triggers listener in VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
var bellCalled = false
buf.listener = object : TerminalListener {
override fun onBell() { bellCalled = true }
}
enterVt52(parser)
feed(parser, "\u0007")
assertTrue("BEL should trigger listener", bellCalled)
}
// =====================================================================
// DECID (identify) in VT52 mode
// =====================================================================
@Test
fun `ESC Z sends VT52 identify response`() {
val (parser, buf) = createParserWithBuffer()
var response: ByteArray? = null
buf.listener = object : TerminalListener {
override fun onSendToHost(data: ByteArray) { response = data }
}
enterVt52(parser)
feed(parser, "\u001bZ")
assertNotNull("DECID should send response", response)
assertEquals("VT52 DECID response should be ESC/Z",
"\u001b/Z", String(response!!, Charsets.US_ASCII))
}
// =====================================================================
// Mixed mode: VT52 and ANSI interleaving
// =====================================================================
@Test
fun `text before and after VT52 mode works`() {
val (parser, buf) = createParserWithBuffer()
// Write in ANSI mode
feed(parser, "A")
assertEquals('A', buf.getChar(0, 0))
// Enter VT52, write
enterVt52(parser)
feed(parser, "B")
assertEquals('B', buf.getChar(0, 1))
// Exit VT52, write
feed(parser, "\u001b<")
feed(parser, "C")
assertEquals('C', buf.getChar(0, 2))
}
@Test
fun `CSI sequences do not work in VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
// Try ANSI cursor move — should not work (ESC [ is not VT52)
// ESC will enter VT52 escape, '[' is not a valid VT52 command,
// then '5' and ';' are printed as text
feed(parser, "\u001b[5;10H")
// Cursor should NOT be at row 4, col 9
assertFalse("CSI should not work in VT52 mode",
buf.cursorRow == 4 && buf.cursorCol == 9)
}
@Test
fun `VT52 commands in same chunk as mode entry`() {
val (parser, buf) = createParserWithBuffer()
// Enter VT52 and immediately use VT52 commands in one chunk
val data = "\u001b[?2l\u001bH\u001bJ" // enter VT52, home, clear to end
feed(parser, data)
assertTrue("Should be in VT52 mode", buf.vt52Mode)
assertEquals("Cursor should be at home", 0, buf.cursorRow)
assertEquals(0, buf.cursorCol)
}
@Test
fun `ESC Y split across chunks`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
// Send ESC Y in one chunk, row in second, col in third
feedBytes(parser, 0x1B, 'Y'.code)
feedBytes(parser, 37) // row = 5
feedBytes(parser, 42) // col = 10
assertEquals("Cursor row should be 5", 5, buf.cursorRow)
assertEquals("Cursor col should be 10", 10, buf.cursorCol)
}
@Test
fun `multiple cursor moves in VT52 mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
// Home, down, down, right, right, right
feed(parser, "\u001bH\u001bB\u001bB\u001bC\u001bC\u001bC")
assertEquals("Cursor row should be 2", 2, buf.cursorRow)
assertEquals("Cursor col should be 3", 3, buf.cursorCol)
}
// =====================================================================
// Graphics mode (ESC F / ESC G)
// =====================================================================
@Test
fun `ESC F enters graphics mode - box drawing chars`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
// ESC F enters graphics mode, then 'q' = horizontal line, 'x' = vertical line
feed(parser, "\u001bFqx")
assertEquals('\u2500', buf.getChar(0, 0)) // ─ horizontal line
assertEquals('\u2502', buf.getChar(0, 1)) // │ vertical line
}
@Test
fun `ESC G exits graphics mode`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "\u001bFq") // graphics: q → ─
feed(parser, "\u001bGq") // normal: q → q
assertEquals('\u2500', buf.getChar(0, 0)) // ─
assertEquals('q', buf.getChar(0, 1)) // plain q
}
@Test
fun `graphics mode maps corner characters`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "\u001bF")
// j=┘ k=┐ l=┌ m=└
feed(parser, "jklm")
assertEquals('\u2518', buf.getChar(0, 0)) // ┘
assertEquals('\u2510', buf.getChar(0, 1)) // ┐
assertEquals('\u250C', buf.getChar(0, 2)) // ┌
assertEquals('\u2514', buf.getChar(0, 3)) // └
}
@Test
fun `graphics mode maps tee and cross characters`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "\u001bF")
// n=┼ t=├ u=┤ v=┴ w=┬
feed(parser, "ntuvw")
assertEquals('\u253C', buf.getChar(0, 0)) // ┼
assertEquals('\u251C', buf.getChar(0, 1)) // ├
assertEquals('\u2524', buf.getChar(0, 2)) // ┤
assertEquals('\u2534', buf.getChar(0, 3)) // ┴
assertEquals('\u252C', buf.getChar(0, 4)) // ┬
}
@Test
fun `graphics mode maps special symbols`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "\u001bF")
// ` = diamond, f = degree, g = plus-minus, { = pi, ~ = centered dot
feed(parser, "`fg{~")
assertEquals('\u25C6', buf.getChar(0, 0)) // ◆
assertEquals('\u00B0', buf.getChar(0, 1)) // °
assertEquals('\u00B1', buf.getChar(0, 2)) // ±
assertEquals('\u03C0', buf.getChar(0, 3)) // π
assertEquals('\u00B7', buf.getChar(0, 4)) // ·
}
@Test
fun `graphics mode does not affect chars below 0x5F`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "\u001bF")
// ASCII chars below 0x5F should pass through unchanged
feed(parser, "ABC 123")
assertEquals('A', buf.getChar(0, 0))
assertEquals('B', buf.getChar(0, 1))
assertEquals('C', buf.getChar(0, 2))
assertEquals(' ', buf.getChar(0, 3))
assertEquals('1', buf.getChar(0, 4))
}
@Test
fun `graphics mode resets on ESC less-than exit`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "\u001bF") // enter graphics
feed(parser, "\u001b<") // exit VT52 → also clears graphics
// Re-enter VT52 — graphics should be off
enterVt52(parser)
feed(parser, "q")
assertEquals('q', buf.getChar(0, 0)) // plain q, not ─
}
@Test
fun `graphics mode resets on parser reset`() {
val (parser, buf) = createParserWithBuffer()
enterVt52(parser)
feed(parser, "\u001bF") // enter graphics
buf.reset() // full reset clears vt52Mode
// Re-enter VT52 — graphics should be off
enterVt52(parser)
feed(parser, "q")
assertEquals('q', buf.getChar(0, 0))
}
}