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:
parent
e72c4de55d
commit
40650286dc
7 changed files with 1203 additions and 25 deletions
|
|
@ -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
471
docs/TERMINAL_PARSER.md
Normal 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 |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 VT100→VT220→XTerm 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue