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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 1).saturating_sub(1);
375 let col = p2(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 1).max(1);
425 buf.scroll_region_up(n);
426 }
427 b'T' => {
428 // SD — scroll down N lines
429 let n = p1(¶ms, 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(¶ms);
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(¶ms, 1).saturating_sub(1);
444 let bot = p2(¶ms, 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(¶ms, buf, true);
452 }
453 // Public SM modes: mostly ignored for now
454 b'l' if private => {
455 self.handle_decset(¶ms, 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(¶ms, 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}