Skip to main content

kronos_tide/
vte.rs

1/// VTE escape-sequence parser for Kronos TIDE.
2///
3/// Supported CSI final bytes and their semantics
4/// ─────────────────────────────────────────────
5///  A  CUU   — cursor up N
6///  B  CUD   — cursor down N
7///  C  CUF   — cursor forward (right) N
8///  D  CUB   — cursor backward (left) N
9///  d  VPA   — cursor to absolute row N
10///  G  CHA   — cursor to absolute column N
11///  H  CUP   — cursor position (row; col) — 1-indexed
12///  f  HVP   — same as H
13///  J  ED    — erase display (0=below 1=above 2=all 3=all+scrollback)
14///  K  EL    — erase line    (0=right 1=left 2=all)
15///  L        — insert N blank lines (stub: no-op, consumed)
16///  M        — delete N lines (stub)
17///  P  DCH   — delete N characters (stub)
18///  @  ICH   — insert N blank characters (stub)
19///  S        — scroll up N   (CSI Ps S — scroll region up)
20///  T        — scroll down N (CSI Ps T — scroll region down)
21///  r  DECSTBM — set scroll region (top; bottom) 1-indexed
22///  m  SGR   — select graphic rendition (colors, bold, italic, underline)
23///  h  SM / DECSET (private `?` prefix)
24///     ?1h  — application cursor keys (DECCKM)   [stub]
25///     ?7h  — auto-wrap on                        [stub]
26///     ?12h — cursor blink on                     [stub]
27///     ?25h — cursor visible
28///     ?1000h — mouse button events (X10)         [stub]
29///     ?1002h — mouse button+motion events        [stub]
30///     ?1006h — SGR mouse encoding                [stub]
31///     ?1049h — alt screen (enter)                [stub]
32///  l  RM / DECRST (private `?` prefix) — same params, inverse effects
33///  n  DSR   — device status report (0x6 = CPR, 0x5 = alive)  [stub]
34///  t  XTWINOPS — window manipulation (partial, most sub-ops are stubs)
35///
36/// OSC (Operating System Command)  — terminated by BEL (0x07) or ST (ESC \)
37///  0/1/2  — set icon name / window title / both
38///  11/12  — query background/cursor colour (ignored — no response sent)
39///  52     — clipboard base64 (consumed, not implemented)
40///  *      — unknown OSC: silently consumed
41///
42/// DCS (Device Control String)  — consume until ST; do not crash
43///
44/// ESC sequences
45///  ESC [ — enter CSI
46///  ESC ] — enter OSC
47///  ESC P — enter DCS
48///  ESC M — reverse index (RI)
49///  ESC c — RIS (reset to initial state)
50///  ESC 7 — DECSC (save cursor) [stub]
51///  ESC 8 — DECRC (restore cursor) [stub]
52///  ESC = / > — application/normal keypad mode [stubs]
53use crate::terminal::{Color, TerminalBuffer};
54
55#[derive(Debug, Clone, PartialEq)]
56enum State {
57    Ground,
58    Escape,
59    CsiEntry,
60    CsiParam,
61    OscString,
62    /// DCS: consume bytes until String Terminator (ESC \).
63    DcsString,
64}
65
66pub struct VteParser {
67    state: State,
68    params: Vec<u16>,
69    current_param: u16,
70    osc_string: String,
71    /// Set when `?` is seen in CSI — indicates DEC private mode.
72    private_mode: bool,
73    fg: Color,
74    bg: Color,
75    bold: bool,
76    italic: bool,
77    underline: bool,
78    /// Whether the cursor is currently visible.
79    pub cursor_visible: bool,
80}
81
82impl VteParser {
83    pub fn new() -> Self {
84        Self {
85            state: State::Ground,
86            params: Vec::new(),
87            current_param: 0,
88            osc_string: String::new(),
89            private_mode: false,
90            fg: Color::WHITE,
91            bg: Color::BLACK,
92            bold: false,
93            italic: false,
94            underline: false,
95            cursor_visible: true,
96        }
97    }
98
99    pub fn feed(&mut self, buf: &mut TerminalBuffer, byte: u8) {
100        match self.state {
101            State::Ground => self.ground(buf, byte),
102            State::Escape => self.escape(buf, byte),
103            State::CsiEntry => self.csi_entry(buf, byte),
104            State::CsiParam => self.csi_param(buf, byte),
105            State::OscString => self.osc_string_state(byte),
106            State::DcsString => self.dcs_string_state(byte),
107        }
108    }
109
110    pub fn feed_str(&mut self, buf: &mut TerminalBuffer, data: &[u8]) {
111        for &b in data {
112            self.feed(buf, b);
113        }
114    }
115
116    // ─── state handlers ──────────────────────────────────────────────────────
117
118    fn ground(&mut self, buf: &mut TerminalBuffer, byte: u8) {
119        match byte {
120            0x1b => {
121                self.state = State::Escape;
122            }
123            b'\n' | b'\r' | b'\x08' | b'\t' => {
124                buf.write_char(byte as char);
125            }
126            0x07 => {}        // BEL — ignore
127            0x00..=0x1f => {} // other C0 controls — ignore
128            _ => {
129                // Attempt UTF-8 multi-byte decoding.  Because we receive raw
130                // bytes one at a time we re-encode the byte as a char for
131                // single-byte ASCII, and for high bytes we fall back to the
132                // lossy replacement path (same as before).
133                if byte >= 0x80 {
134                    // High bytes: pass through as replacement char for now.
135                    // Full UTF-8 reassembly requires a small accumulator; that
136                    // is left as a follow-up (tracked as TODO below).
137                    // TODO: add utf8_acc: Vec<u8> to accumulate multi-byte seqs.
138                    buf.write_char(byte as char);
139                } else {
140                    let ch = byte as char;
141                    let (row, col) = buf.cursor();
142                    let (rows, cols) = buf.dimensions();
143                    if col < cols {
144                        buf.set_cell_styled(
145                            row,
146                            col,
147                            ch,
148                            self.fg,
149                            self.bg,
150                            self.bold,
151                            self.italic,
152                            self.underline,
153                        );
154                        buf.set_cursor(row, col + 1);
155                    } else {
156                        buf.set_cursor(row, 0);
157                        if row + 1 >= rows {
158                            buf.scroll_up();
159                        } else {
160                            buf.set_cursor(row + 1, 0);
161                        }
162                        let (row2, _) = buf.cursor();
163                        buf.set_cell_styled(
164                            row2,
165                            0,
166                            ch,
167                            self.fg,
168                            self.bg,
169                            self.bold,
170                            self.italic,
171                            self.underline,
172                        );
173                        buf.set_cursor(row2, 1);
174                    }
175                }
176            }
177        }
178    }
179
180    fn escape(&mut self, buf: &mut TerminalBuffer, byte: u8) {
181        match byte {
182            b'[' => {
183                self.state = State::CsiEntry;
184                self.params.clear();
185                self.current_param = 0;
186                self.private_mode = false;
187            }
188            b']' => {
189                self.state = State::OscString;
190                self.osc_string.clear();
191            }
192            b'P' => {
193                // DCS — consume until ST
194                self.state = State::DcsString;
195            }
196            b'M' => {
197                // RI — reverse index: move up one row; scroll if at top.
198                let (row, col) = buf.cursor();
199                if row > buf.scroll_top() {
200                    buf.set_cursor(row - 1, col);
201                } else {
202                    buf.scroll_region_down(1);
203                }
204                self.state = State::Ground;
205            }
206            b'c' => {
207                // RIS — reset to initial state
208                buf.clear();
209                self.reset_style();
210                self.cursor_visible = true;
211                self.state = State::Ground;
212            }
213            b'7' => {
214                // DECSC — save cursor (stub: no storage yet)
215                self.state = State::Ground;
216            }
217            b'8' => {
218                // DECRC — restore cursor (stub)
219                self.state = State::Ground;
220            }
221            b'=' | b'>' => {
222                // Application / normal keypad mode (stubs)
223                self.state = State::Ground;
224            }
225            b'\\' => {
226                // ST (String Terminator) when received bare — end any open string
227                self.state = State::Ground;
228            }
229            _ => {
230                self.state = State::Ground;
231            }
232        }
233    }
234
235    fn csi_entry(&mut self, buf: &mut TerminalBuffer, byte: u8) {
236        match byte {
237            b'0'..=b'9' => {
238                self.current_param = (byte - b'0') as u16;
239                self.state = State::CsiParam;
240            }
241            b';' => {
242                self.params.push(0);
243                self.state = State::CsiParam;
244            }
245            b'?' => {
246                // DEC private mode prefix
247                self.private_mode = true;
248                self.state = State::CsiParam;
249            }
250            _ => {
251                self.dispatch_csi(buf, byte);
252                self.state = State::Ground;
253            }
254        }
255    }
256
257    fn csi_param(&mut self, buf: &mut TerminalBuffer, byte: u8) {
258        match byte {
259            b'0'..=b'9' => {
260                self.current_param = self
261                    .current_param
262                    .saturating_mul(10)
263                    .saturating_add((byte - b'0') as u16);
264            }
265            b';' => {
266                self.params.push(self.current_param);
267                self.current_param = 0;
268            }
269            _ => {
270                self.params.push(self.current_param);
271                self.current_param = 0;
272                self.dispatch_csi(buf, byte);
273                self.state = State::Ground;
274            }
275        }
276    }
277
278    fn osc_string_state(&mut self, byte: u8) {
279        match byte {
280            0x07 => {
281                // BEL terminates OSC
282                self.handle_osc();
283                self.state = State::Ground;
284            }
285            0x1b => {
286                // ESC may start ST (ESC \); handle next byte in Ground which
287                // will catch b'\\' via the Escape handler.
288                self.handle_osc();
289                self.state = State::Escape;
290            }
291            _ => {
292                self.osc_string.push(byte as char);
293            }
294        }
295    }
296
297    fn dcs_string_state(&mut self, byte: u8) {
298        // Consume all DCS content until ST (ESC \) or BEL.
299        // We just drop the bytes — no DCS processing needed for TIDE's use case.
300        match byte {
301            0x07 => {
302                self.state = State::Ground;
303            }
304            0x1b => {
305                self.state = State::Escape;
306            } // will see b'\\' next
307            _ => {}
308        }
309    }
310
311    // ─── CSI dispatcher ──────────────────────────────────────────────────────
312
313    /// Dispatch a complete CSI sequence.
314    ///
315    /// `byte` is the final byte (the letter).  `self.params` holds the
316    /// accumulated numeric parameters.  `self.private_mode` is set when `?`
317    /// was seen after ESC [.
318    fn dispatch_csi(&mut self, buf: &mut TerminalBuffer, byte: u8) {
319        // Take a snapshot so we can pass params by value without borrowing `self`.
320        let params: Vec<u16> = self.params.clone();
321        let private = self.private_mode;
322        self.private_mode = false;
323
324        match byte {
325            // ── cursor movement ────────────────────────────────────────────
326            b'A' => {
327                // CUU — cursor up N
328                let n = p1(&params, 1);
329                let (row, col) = buf.cursor();
330                buf.set_cursor(row.saturating_sub(n), col);
331            }
332            b'B' => {
333                // CUD — cursor down N
334                let n = p1(&params, 1);
335                let (row, col) = buf.cursor();
336                let (rows, _) = buf.dimensions();
337                buf.set_cursor((row + n).min(rows - 1), col);
338            }
339            b'C' => {
340                // CUF — cursor forward N
341                let n = p1(&params, 1);
342                let (row, col) = buf.cursor();
343                let (_, cols) = buf.dimensions();
344                buf.set_cursor(row, (col + n).min(cols - 1));
345            }
346            b'D' => {
347                // CUB — cursor backward N
348                let n = p1(&params, 1);
349                let (row, col) = buf.cursor();
350                buf.set_cursor(row, col.saturating_sub(n));
351            }
352            b'E' => {
353                // CNL — cursor next line N
354                let n = p1(&params, 1);
355                let (row, _) = buf.cursor();
356                let (rows, _) = buf.dimensions();
357                buf.set_cursor((row + n).min(rows - 1), 0);
358            }
359            b'F' => {
360                // CPL — cursor previous line N
361                let n = p1(&params, 1);
362                let (row, _) = buf.cursor();
363                buf.set_cursor(row.saturating_sub(n), 0);
364            }
365            b'G' => {
366                // CHA — cursor horizontal absolute
367                let col = p1(&params, 1).saturating_sub(1);
368                let (row, _) = buf.cursor();
369                let (_, cols) = buf.dimensions();
370                buf.set_cursor(row, col.min(cols - 1));
371            }
372            b'H' | b'f' => {
373                // CUP / HVP — cursor position (1-indexed)
374                let row = p1(&params, 1).saturating_sub(1);
375                let col = p2(&params, 1).saturating_sub(1);
376                let (rows, cols) = buf.dimensions();
377                buf.set_cursor(row.min(rows - 1), col.min(cols - 1));
378            }
379            b'd' => {
380                // VPA — vertical position absolute (1-indexed row)
381                let row = p1(&params, 1).saturating_sub(1);
382                let (_, col) = buf.cursor();
383                let (rows, _) = buf.dimensions();
384                buf.set_cursor(row.min(rows - 1), col);
385            }
386
387            // ── erase ──────────────────────────────────────────────────────
388            b'J' => {
389                // ED — erase display
390                match p1(&params, 0) {
391                    0 => buf.clear_below(),
392                    1 => buf.clear_above(),
393                    2 | 3 => buf.clear(),
394                    _ => {}
395                }
396            }
397            b'K' => {
398                // EL — erase in line
399                match p1(&params, 0) {
400                    0 => buf.clear_line_right(),
401                    1 => buf.clear_line_left(),
402                    2 => buf.clear_line(),
403                    _ => {}
404                }
405            }
406
407            // ── insert/delete lines & chars (stubs: consumed without action) ─
408            b'L' => {
409                // IL — insert N blank lines (stub)
410            }
411            b'M' => {
412                // DL — delete N lines (stub)
413            }
414            b'P' => {
415                // DCH — delete N characters (stub)
416            }
417            b'@' => {
418                // ICH — insert N blank characters (stub)
419            }
420
421            // ── scrolling ─────────────────────────────────────────────────
422            b'S' => {
423                // SU — scroll up N lines
424                let n = p1(&params, 1).max(1);
425                buf.scroll_region_up(n);
426            }
427            b'T' => {
428                // SD — scroll down N lines
429                let n = p1(&params, 1).max(1);
430                buf.scroll_region_down(n);
431            }
432
433            // ── attributes ────────────────────────────────────────────────
434            b'm' => {
435                // SGR — select graphic rendition
436                self.dispatch_sgr(&params);
437            }
438
439            // ── scroll region ─────────────────────────────────────────────
440            b'r' if !private => {
441                // DECSTBM — set top/bottom scroll region (1-indexed)
442                let (rows, _) = buf.dimensions();
443                let top = p1(&params, 1).saturating_sub(1);
444                let bot = p2(&params, rows).saturating_sub(1);
445                buf.set_scroll_region(top, bot);
446            }
447            // private `?r` has no standard meaning — ignore
448
449            // ── mode set/reset ────────────────────────────────────────────
450            b'h' if private => {
451                self.handle_decset(&params, buf, true);
452            }
453            // Public SM modes: mostly ignored for now
454            b'l' if private => {
455                self.handle_decset(&params, buf, false);
456            }
457
458            // ── device status ─────────────────────────────────────────────
459            b'n' => {
460                // DSR — device status report (stub: no response written)
461                // Real impl would write CSI R or CSI 0 n to the PTY stdin.
462            }
463
464            // ── window manipulation ───────────────────────────────────────
465            b't' => {
466                // XTWINOPS — window manipulation
467                // Most sub-ops require OS cooperation; we handle the benign ones.
468                match p1(&params, 0) {
469                    1 => {}  // de-iconify (stub)
470                    2 => {}  // iconify (stub)
471                    3 => {}  // move window (stub)
472                    4 => {}  // resize in pixels (stub)
473                    8 => {}  // resize in chars (stub)
474                    11 => {} // report window state (stub — no response)
475                    13 => {} // report window position (stub)
476                    14 => {} // report window size in pixels (stub)
477                    18 => {} // report text-area size in chars (stub)
478                    _ => {}  // all other sub-ops: consume silently
479                }
480            }
481
482            _ => {
483                // Unknown CSI final byte — silently consume.
484            }
485        }
486    }
487
488    // ─── DEC private mode set/reset ──────────────────────────────────────────
489
490    fn handle_decset(&mut self, params: &[u16], _buf: &mut TerminalBuffer, set: bool) {
491        for &p in params {
492            match p {
493                1 => {}  // DECCKM — application cursor keys (stub)
494                7 => {}  // DECAWM — auto-wrap (stub)
495                12 => {} // cursor blink (stub)
496                25 => {
497                    // DECTCEM — text cursor enable
498                    self.cursor_visible = set;
499                }
500                1000 => {} // X10 mouse reporting (stub)
501                1002 => {} // button-event mouse (stub)
502                1006 => {} // SGR mouse encoding (stub)
503                1049 => {} // alternate screen buffer (stub)
504                2004 => {} // bracketed paste (stub)
505                _ => {}    // unknown private mode — ignore
506            }
507        }
508    }
509
510    // ─── OSC handler ─────────────────────────────────────────────────────────
511
512    fn handle_osc(&mut self) {
513        // OSC format: "<ps>;<string>" where ps is the command number.
514        let s = self.osc_string.clone();
515        let mut parts = s.splitn(2, ';');
516        let cmd: u32 = parts
517            .next()
518            .and_then(|c| c.parse().ok())
519            .unwrap_or(u32::MAX);
520        let arg = parts.next().unwrap_or("");
521
522        match cmd {
523            // OSC 0 — set icon name AND window title
524            // OSC 1 — set icon name only
525            // OSC 2 — set window title only
526            0..=2 => {
527                // We store the title; the renderer can query cursor_visible / window_title.
528                // For now we just expose it via the buffer's window_title field.
529                // NOTE: TerminalBuffer.window_title is set directly here via the
530                // osc_string scratch space — we have &mut VteParser only, so we
531                // cannot reach &mut TerminalBuffer.  The caller's feed() loop
532                // passes buf as a parameter, but osc_string_state() does not.
533                // We store in osc_string temporarily; the Pane/renderer can read
534                // buf.window_title after a feed_str() call.
535                //
536                // TODO: refactor osc_string_state to accept &mut TerminalBuffer
537                // so we can write buf.window_title = arg.to_string() directly.
538                let _ = arg; // title stored below at call site after refactor
539            }
540            11 | 12 => {
541                // Query background / cursor colour — no response for now (stub).
542            }
543            52 => {
544                // Clipboard set (base64-encoded) — not implemented.
545            }
546            _ => {
547                // Unknown OSC — silently consumed.
548            }
549        }
550    }
551
552    // ─── SGR ─────────────────────────────────────────────────────────────────
553
554    fn dispatch_sgr(&mut self, params: &[u16]) {
555        if params.is_empty() || (params.len() == 1 && params[0] == 0) {
556            self.reset_style();
557            return;
558        }
559
560        let mut i = 0;
561        while i < params.len() {
562            match params[i] {
563                0 => self.reset_style(),
564                1 => self.bold = true,
565                3 => self.italic = true,
566                4 => self.underline = true,
567                22 => self.bold = false,
568                23 => self.italic = false,
569                24 => self.underline = false,
570                // Normal foreground (ANSI 30-37, bright 90-97)
571                30..=37 => self.fg = Color::from_ansi((params[i] - 30) as u8),
572                38 => {
573                    if i + 2 < params.len() && params[i + 1] == 5 {
574                        // 256-colour foreground
575                        self.fg = Color::from_ansi(params[i + 2] as u8);
576                        i += 2;
577                    } else if i + 4 < params.len() && params[i + 1] == 2 {
578                        // Truecolour foreground
579                        self.fg = Color::rgb(
580                            params[i + 2] as u8,
581                            params[i + 3] as u8,
582                            params[i + 4] as u8,
583                        );
584                        i += 4;
585                    }
586                }
587                39 => self.fg = Color::WHITE,
588                // Normal background (ANSI 40-47, bright 100-107)
589                40..=47 => self.bg = Color::from_ansi((params[i] - 40) as u8),
590                48 => {
591                    if i + 2 < params.len() && params[i + 1] == 5 {
592                        self.bg = Color::from_ansi(params[i + 2] as u8);
593                        i += 2;
594                    } else if i + 4 < params.len() && params[i + 1] == 2 {
595                        self.bg = Color::rgb(
596                            params[i + 2] as u8,
597                            params[i + 3] as u8,
598                            params[i + 4] as u8,
599                        );
600                        i += 4;
601                    }
602                }
603                49 => self.bg = Color::BLACK,
604                // Bright foreground
605                90..=97 => self.fg = Color::from_ansi((params[i] - 90 + 8) as u8),
606                // Bright background
607                100..=107 => self.bg = Color::from_ansi((params[i] - 100 + 8) as u8),
608                _ => {}
609            }
610            i += 1;
611        }
612    }
613
614    fn reset_style(&mut self) {
615        self.fg = Color::WHITE;
616        self.bg = Color::BLACK;
617        self.bold = false;
618        self.italic = false;
619        self.underline = false;
620    }
621}
622
623// ─── parameter helpers ───────────────────────────────────────────────────────
624
625/// First parameter, defaulting to `default` if absent or zero.
626fn p1(params: &[u16], default: usize) -> usize {
627    let v = params.first().copied().unwrap_or(0) as usize;
628    if v == 0 {
629        default
630    } else {
631        v
632    }
633}
634
635/// Second parameter, defaulting to `default` if absent or zero.
636fn p2(params: &[u16], default: usize) -> usize {
637    let v = params.get(1).copied().unwrap_or(0) as usize;
638    if v == 0 {
639        default
640    } else {
641        v
642    }
643}