//! The parser callbacks: printing, C0 execution, CSI, ESC and OSC dispatch. //! //! This is the only place a wire byte turns into a call on the grid. The work //! each arm does lives in the subsystem modules; what is here is the mapping //! from the escape sequence to it, plus the replies the query arms send back. use crate::{CursorShape, Grid, MouseEncoding, MouseTracking}; use shop_vt::{Params, Perform}; use tracing::trace; /// One channel as OSC 10/11 want it: four hex digits, the 8-bit value /// doubled. `0x25` becomes `2525`, which is the 16-bit reading of the same /// intensity and what every terminal sends. fn osc_channel(v: u8) -> String { format!("{v:02x}{v:02x}") } fn param1(params: &Params, default: u16) -> u16 { let first = params .iter() .next() .and_then(|p| p.first().copied()) .unwrap_or(0); if first == 0 { default } else { first } } fn param2(params: &Params, defaults: (u16, u16)) -> (u16, u16) { let mut it = params.iter(); let a = it.next().and_then(|p| p.first().copied()).unwrap_or(0); let b = it.next().and_then(|p| p.first().copied()).unwrap_or(0); let a = if a == 0 { defaults.0 } else { a }; let b = if b == 0 { defaults.1 } else { b }; (a, b) } impl Perform for Grid { fn print(&mut self, c: char) { self.place_char(c); } fn execute(&mut self, byte: u8) { // Any C0 that isn't NUL/BEL moves the cursor or scrolls; invalidate // the fast-path row cache up front so we don't have to sprinkle it // across every arm. self.invalidate_cur_row(); match byte { 0x08 => { // BS if self.cursor.col > 0 { self.cursor.col -= 1; } self.cursor.wrap_next = false; } 0x09 => { // HT — advance to next multiple of 8, clamped. let next = ((self.cursor.col / 8) + 1) * 8; self.cursor.col = next.min(self.cols - 1); self.cursor.wrap_next = false; } 0x0A..=0x0C => { // LF / VT / FF self.newline(); } 0x0D => { // CR self.cursor.col = 0; self.cursor.wrap_next = false; } 0x07 => {} // BEL — ignore for now other => trace!("unhandled C0 {other:#x}"), } } fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) { // Nearly every CSI mutates cursor, scroll region, or screen; a couple // (cursor visibility, SGR) don't but the invalidation is a single // store — cheaper than branching on which arm we're taking. self.invalidate_cur_row(); let private = intermediates.first().copied() == Some(b'?'); match (action, private) { ('H' | 'f', false) => { let (row, col) = param2(params, (1, 1)); self.set_cursor(row, col); } ('A', false) => { let n = param1(params, 1) as i32; self.move_by(-n, 0); } ('B', false) => { let n = param1(params, 1) as i32; self.move_by(n, 0); } ('C', false) => { let n = param1(params, 1) as i32; self.move_by(0, n); } ('D', false) => { let n = param1(params, 1) as i32; self.move_by(0, -n); } ('E', false) => { let n = param1(params, 1) as i32; self.move_by(n, 0); self.cursor.col = 0; } ('F', false) => { let n = param1(params, 1) as i32; self.move_by(-n, 0); self.cursor.col = 0; } ('G', false) => { let col = param1(params, 1); self.cursor.col = col.saturating_sub(1).min(self.cols - 1); self.cursor.wrap_next = false; } ('d', false) => { let row = param1(params, 1); self.cursor.row = row.saturating_sub(1).min(self.rows - 1); self.cursor.wrap_next = false; } ('J', false) => { self.erase_display(param1(params, 0)); } ('K', false) => { self.erase_line(param1(params, 0)); } ('L', false) => self.insert_lines(param1(params, 1)), ('M', false) => self.delete_lines(param1(params, 1)), ('@', false) => self.insert_chars(param1(params, 1)), ('P', false) => self.delete_chars(param1(params, 1)), ('X', false) => self.erase_chars(param1(params, 1)), // DECSC/DECRC in their CSI spelling, the same pair as `ESC 7` and // `ESC 8`. `CSI s` is DECSLRM under DECLRMM, which shop does not // implement and no program can have turned on, so there is nothing // for it to be mistaken for here. ('s', false) if intermediates.is_empty() => self.save_cursor(), ('u', false) if intermediates.is_empty() => self.restore_cursor(), // DA1, "what are you". Guarded on empty intermediates because // `CSI > c` is DA2, a different question, and the private-flag // check above only screens for `?`. // // 62 is VT220, which is about what the VT side implements; 22 is // ANSI colour. Sixel is 4 and is deliberately absent: shop has no // sixel, and claiming it means a client picks sixel over kitty // graphics and draws nothing. ('c', false) if intermediates.is_empty() => self.reply(b"\x1b[?62;22c"), // DSR. Two questions share the final byte: 5 is "are you well" // and 6 is "where is the cursor" (CPR). // // CPR is not an optional courtesy. A line editor that draws a // prompt has to know which row it starts on, and reedline asks // this before it draws anything at all: nushell under a terminal // that never answers sits on a blank screen with a live cursor, // taking no input, because the shell is still waiting for us. // That is what shop did in Alloy, where nu is the login shell, // while bash — which asks nothing — hid it in daily use. // // Rows and columns are 1-based on the wire and 0-based here. // There is no origin mode to subtract: the cursor is absolute // even inside a scrolling region. ('n', false) if intermediates.is_empty() => match param1(params, 0) { 5 => self.reply(b"\x1b[0n"), 6 => { let (row, col) = (self.cursor.row + 1, self.cursor.col + 1); self.reply(format!("\x1b[{row};{col}R").as_bytes()); } other => trace!("unhandled DSR {other}"), }, // DECXCPR, the private form of the same question. The reply keeps // the `?` and carries a third parameter, the page, which is always // 1 here because shop has no page memory. ('n', true) if param1(params, 0) == 6 => { let (row, col) = (self.cursor.row + 1, self.cursor.col + 1); self.reply(format!("\x1b[?{row};{col};1R").as_bytes()); } // XTVERSION. `DCS > | name(version) ST`, the form kitty and foot // both answer in, which is what makes it parseable by the clients // that ask. ('q', false) if intermediates.first().copied() == Some(b'>') => { let reply = format!( "\x1bP>|{}({})\x1b\\", self.identity.name, self.identity.version ); self.reply(reply.as_bytes()); } // XTWINOPS reports. Only the three read-only ones: the rest of // this sequence moves and resizes windows, which is the // compositor's business and not something a program on a PTY gets // to do here. // // Sizes are physical pixels. Programs that place images need cell // size in particular, and the ioctl that also carries it // (TIOCSWINSZ) is not what all of them read. ('t', false) if intermediates.is_empty() => { let (cw, ch) = self.identity.cell_px; match param1(params, 0) { // Text area, in pixels. 14 => { let (w, h) = (self.cols * cw, self.rows * ch); self.reply(format!("\x1b[4;{h};{w}t").as_bytes()); } // One cell, in pixels. Height first, as the report orders it. 16 => self.reply(format!("\x1b[6;{ch};{cw}t").as_bytes()), // Text area, in cells. 18 => { let (rows, cols) = (self.rows, self.cols); self.reply(format!("\x1b[8;{rows};{cols}t").as_bytes()); } other => trace!("unhandled XTWINOPS {other}"), } } ('S', false) => { self.scroll_up_in_region(param1(params, 1)); } ('T', false) => { self.scroll_down_in_region(param1(params, 1)); } ('r', false) => { // DECSTBM: transition-safe. If we're currently in a partial // region with a rotated region_origin, unroll it back to // logical order first. If the new region is partial, unroll // the fullscreen ring so outside-region rows sit at their // logical physical positions. Unroll preserves logical // contents so the renderer's per-row cache stays valid — no // need to mark rows dirty here. let (top, bot) = param2(params, (1, self.rows)); let new_top = top.saturating_sub(1).min(self.rows - 1); let new_bottom = bot.saturating_sub(1).min(self.rows - 1); // A region needs at least two rows, and its top has to be // above its bottom. DEC and xterm both drop the whole request // when it does not, cursor move included, and so does this: // `region_size` is computed as `bottom - top + 1` in the scroll // paths, so an inverted pair underflows there — a panic in // debug and a region of ~65,000 rows in release, which is a // row index off the end of the ring feeding the unchecked // store in `place_char`. Found by the soak oracle on // `ESC [ 20 ; 3 r`, 2026-08-29. if new_top >= new_bottom { return; } self.unroll_region(); self.scroll_top = new_top; self.scroll_bottom = new_bottom; if self.is_partial_region() { self.unroll_active_ring(); } self.cursor.row = 0; self.cursor.col = 0; } ('m', false) => self.apply_sgr(params), ('q', false) if intermediates.first().copied() == Some(b' ') => { let shape = param1(params, 1); self.cursor_shape = match shape { 0..=2 => CursorShape::Block, 3 | 4 => CursorShape::Underline, 5 | 6 => CursorShape::Bar, _ => self.cursor_shape, }; } ('h' | 'l', true) => { for p in params.iter() { if let Some(&code) = p.first() { match code { 25 => self.cursor.visible = action == 'h', 1049 | 47 | 1047 => self.swap_alt(action == 'h'), // DECSET/DECRST 2026: synchronized update. `h` // begins a batch (renderer should hold frames // until `l` or the caller's timeout); `l` ends // it. Grid just tracks the state — the binary // is what actually defers the redraw. 1 => self.cursor_keys_application = action == 'h', 1007 => self.alternate_scroll = action == 'h', 2004 => self.bracketed_paste = action == 'h', 2026 => self.sync_update = action == 'h', // Mouse tracking. Clearing any level turns the // pointer back over to the user rather than // dropping to the next level down: a program // clearing 1002 is done with the mouse, not asking // for 1000, and it clears only what it set. 9 | 1000 | 1002 | 1003 => { let level = match code { 9 => MouseTracking::Press, 1000 => MouseTracking::Click, 1002 => MouseTracking::Drag, _ => MouseTracking::Motion, }; if action == 'h' { self.mouse_tracking = level; } else if self.mouse_tracking == level { self.mouse_tracking = MouseTracking::Off; } } 1006 => { self.mouse_encoding = if action == 'h' { MouseEncoding::Sgr } else { MouseEncoding::X10 } } // 1005 (utf-8 coordinates) and 1015 (urxvt) are // the two other answers to X10's coordinate // ceiling, and both are worse than 1006: 1005 // makes a report ambiguous with UTF-8 text, and // 1015 is ambiguous with a DSR reply. Declined // rather than unimplemented, and a program that // asks keeps whatever it had — every one of them // asks for 1006 first. 1005 | 1015 => trace!("declined mouse encoding {code}"), _ => {} } } } } _ => trace!("unhandled CSI {action} private={private}"), } } fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, byte: u8) { self.invalidate_cur_row(); match byte { b'7' => self.save_cursor(), b'8' => self.restore_cursor(), // DECKPAM / DECKPNM. Application keypad is an ESC pair rather // than a DECSET, for no reason beyond how DEC numbered things. b'=' => self.keypad_application = true, b'>' => self.keypad_application = false, b'M' => { // RI — reverse index if self.cursor.row == self.scroll_top { self.scroll_down_in_region(1); } else { self.cursor.row = self.cursor.row.saturating_sub(1); } } _ => trace!("unhandled ESC {}", byte as char), } } fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) { let Some(id) = params.first().and_then(|p| std::str::from_utf8(p).ok()) else { return; }; match id { // OSC 0 = icon + title, OSC 2 = title only. OSC 1 = icon-only, // treat as no-op (Wayland has no separate icon-name concept). "0" | "2" => { if let Some(payload) = params.get(1) && let Ok(s) = std::str::from_utf8(payload) { self.pending_title = Some(s.to_string()); } } // OSC 10 and 11, default foreground and background. Only the `?` // query form: setting them is a separate feature, and answering a // set request would be worse than ignoring it. // // Programs ask in order to tell light from dark, so this decides // whether anything that adapts to the terminal's polarity adapts // the right way. shop's theme knows the answer; nothing else does. "10" | "11" if params.get(1) == Some(&b"?".as_slice()) => { let c = if id == "10" { self.identity.fg } else { self.identity.bg }; let colour = format!( "rgb:{}/{}/{}", osc_channel(c[0]), osc_channel(c[1]), osc_channel(c[2]) ); // Terminated the way the question was. A client that asked // with BEL may well be parsing for one. let end: &str = if bell_terminated { "\x07" } else { "\x1b\\" }; self.reply(format!("\x1b]{id};{colour}{end}").as_bytes()); } _ => {} } } } #[cfg(test)] mod tests { use crate::testutil::{assert_cursor, feed, identified, reply_to, row_str}; use crate::*; // ---- cursor shape -------------------------------------------------- #[test] fn decscusr_sets_shape() { let mut g = Grid::new(10, 2); assert_eq!(g.cursor_shape(), CursorShape::Block); feed(&mut g, b"\x1b[3 q"); assert_eq!(g.cursor_shape(), CursorShape::Underline); feed(&mut g, b"\x1b[6 q"); assert_eq!(g.cursor_shape(), CursorShape::Bar); feed(&mut g, b"\x1b[1 q"); assert_eq!(g.cursor_shape(), CursorShape::Block); } // ---- synchronized update (DECSET 2026) ----------------------------- #[test] fn sync_update_toggles_on_2026() { let mut g = Grid::new(10, 1); assert!(!g.sync_update()); feed(&mut g, b"\x1b[?2026h"); assert!(g.sync_update()); feed(&mut g, b"\x1b[?2026l"); assert!(!g.sync_update()); } // ---- alt screen ---------------------------------------------------- #[test] fn alt_screen_swaps_and_restores() { let mut g = Grid::new(6, 2); feed(&mut g, b"MAIN"); assert_eq!(row_str(&g, 0), "MAIN"); feed(&mut g, b"\x1b[?1049h"); // enter alt // Alt starts blank. assert_eq!(row_str(&g, 0), ""); feed(&mut g, b"ALT"); assert_eq!(row_str(&g, 0), "ALT"); feed(&mut g, b"\x1b[?1049l"); // exit assert_eq!(row_str(&g, 0), "MAIN"); } // ---- OSC title ----------------------------------------------------- #[test] fn osc_title_sets_pending() { let mut g = Grid::new(10, 2); feed(&mut g, b"\x1b]0;hello\x07"); assert_eq!(g.take_pending_title().as_deref(), Some("hello")); // Draining clears it. assert!(g.take_pending_title().is_none()); } #[test] fn osc_two_also_sets_title() { let mut g = Grid::new(10, 2); feed(&mut g, b"\x1b]2;from OSC 2\x07"); assert_eq!(g.take_pending_title().as_deref(), Some("from OSC 2")); } // ---- device attributes --------------------------------------------- #[test] fn da1_is_answered() { let mut g = Grid::new(10, 3); assert!(g.take_pending_replies().is_empty(), "nothing owed yet"); feed(&mut g, b"\x1b[c"); assert_eq!(g.take_pending_replies(), b"\x1b[?62;22c".to_vec()); } #[test] fn da1_does_not_claim_sixel() { // Attribute 4 is sixel. Claiming it makes a client prefer sixel over // kitty graphics, and shop would then draw nothing at all. let mut g = Grid::new(10, 3); feed(&mut g, b"\x1b[c"); let reply = String::from_utf8(g.take_pending_replies()).unwrap(); let attrs: Vec<&str> = reply .trim_start_matches("\x1b[?") .trim_end_matches('c') .split(';') .collect(); assert!(!attrs.contains(&"4"), "claimed sixel in {reply:?}"); } #[test] fn da1_with_an_explicit_zero_is_the_same_question() { let mut g = Grid::new(10, 3); feed(&mut g, b"\x1b[0c"); assert_eq!(g.take_pending_replies(), b"\x1b[?62;22c".to_vec()); } #[test] fn da2_is_not_answered_with_da1() { // `CSI > c` is a different question. The private-flag check only // screens for `?`, so without the intermediates guard this arm would // answer it, and answer it wrongly. let mut g = Grid::new(10, 3); feed(&mut g, b"\x1b[>c"); assert!(g.take_pending_replies().is_empty()); } #[test] fn replies_are_drained_not_repeated() { let mut g = Grid::new(10, 3); feed(&mut g, b"\x1b[c"); assert!(!g.take_pending_replies().is_empty()); assert!(g.take_pending_replies().is_empty(), "drained once only"); } #[test] fn two_queries_in_one_parse_both_get_answers() { let mut g = Grid::new(10, 3); feed(&mut g, b"\x1b[c\x1b[c"); assert_eq!( g.take_pending_replies(), b"\x1b[?62;22c\x1b[?62;22c".to_vec() ); } #[test] fn a_query_does_not_disturb_the_screen() { let mut g = Grid::new(10, 3); feed(&mut g, b"hi\x1b[c"); let _ = g.take_pending_replies(); assert_eq!(row_str(&g, 0), "hi"); assert_cursor(&g, 0, 2); } // ---- identity queries ---------------------------------------------- #[test] fn cpr_answers_where_the_cursor_is() { // The reply a line editor is blocked on. 1-based, row first. let mut g = Grid::new(20, 5); feed(&mut g, b"\x1b[3;7H"); assert_eq!(reply_to(&mut g, b"\x1b[6n"), "\x1b[3;7R"); } #[test] fn cpr_from_the_home_position_is_one_one() { // The startup case, and the one an off-by-one would hide in: a fresh // grid is at 0,0 internally and 1,1 on the wire. let mut g = Grid::new(20, 5); assert_eq!(reply_to(&mut g, b"\x1b[6n"), "\x1b[1;1R"); } #[test] fn cpr_reports_the_absolute_row_inside_a_scrolling_region() { // No origin mode here, so a region does not renumber anything. let mut g = Grid::new(20, 10); feed(&mut g, b"\x1b[3;8r\x1b[5;2H"); assert_eq!(reply_to(&mut g, b"\x1b[6n"), "\x1b[5;2R"); } #[test] fn dsr_five_reports_good_health() { let mut g = Grid::new(20, 5); assert_eq!(reply_to(&mut g, b"\x1b[5n"), "\x1b[0n"); } #[test] fn decxcpr_keeps_the_private_marker_and_names_the_page() { let mut g = Grid::new(20, 5); feed(&mut g, b"\x1b[2;4H"); assert_eq!(reply_to(&mut g, b"\x1b[?6n"), "\x1b[?2;4;1R"); } #[test] fn an_unknown_dsr_is_answered_with_silence() { // Answering a question we did not understand is worse than not // answering: the reply lands in the program's input as text. let mut g = Grid::new(20, 5); assert_eq!(reply_to(&mut g, b"\x1b[99n"), ""); } #[test] fn xtversion_names_the_terminal() { let mut g = identified(); assert_eq!(reply_to(&mut g, b"\x1b[>q"), "\x1bP>|shop(1.2.3)\x1b\\"); } #[test] fn cell_size_is_reported_height_first() { // `CSI 6 ; height ; width t`. Getting the order wrong puts every // image preview at the wrong aspect, which is the sort of bug that // reads as a rendering problem rather than a reply problem. let mut g = identified(); assert_eq!(reply_to(&mut g, b"\x1b[16t"), "\x1b[6;20;9t"); } #[test] fn text_area_is_reported_in_both_units() { let mut g = identified(); // 80x24 cells of 9x20 px. assert_eq!(reply_to(&mut g, b"\x1b[14t"), "\x1b[4;480;720t"); assert_eq!(reply_to(&mut g, b"\x1b[18t"), "\x1b[8;24;80t"); } #[test] fn cell_size_follows_the_identity() { let mut g = identified(); g.set_identity(Identity { cell_px: (18, 40), ..Identity::default() }); assert_eq!(reply_to(&mut g, b"\x1b[16t"), "\x1b[6;40;18t"); } #[test] fn window_manipulation_is_not_obeyed() { // The same sequence resizes and moves windows. Those are the // compositor's, and a program on the PTY does not get to ask. let mut g = identified(); assert_eq!(reply_to(&mut g, b"\x1b[3;0;0t"), "", "move window"); assert_eq!(reply_to(&mut g, b"\x1b[8;50;100t"), "", "resize window"); assert_eq!(g.cols(), 80); assert_eq!(g.rows(), 24); } #[test] fn the_default_colours_are_answered_as_sixteen_bit() { let mut g = identified(); assert_eq!( reply_to(&mut g, b"\x1b]11;?\x1b\\"), "\x1b]11;rgb:2525/2323/1f1f\x1b\\" ); assert_eq!( reply_to(&mut g, b"\x1b]10;?\x1b\\"), "\x1b]10;rgb:e6e6/dede/d3d3\x1b\\" ); } #[test] fn a_colour_query_is_terminated_the_way_it_was_asked() { let mut g = identified(); let bel = reply_to(&mut g, b"\x1b]11;?\x07"); assert!(bel.ends_with('\x07'), "got {bel:?}"); let st = reply_to(&mut g, b"\x1b]11;?\x1b\\"); assert!(st.ends_with("\x1b\\"), "got {st:?}"); } #[test] fn setting_a_colour_is_not_mistaken_for_asking() { // OSC 11 with a value is a set request. shop does not implement it, // and answering it with the current colour would be a lie about // having done something. let mut g = identified(); assert_eq!(reply_to(&mut g, b"\x1b]11;#ff0000\x1b\\"), ""); } #[test] fn identity_queries_leave_the_screen_alone() { let mut g = identified(); feed(&mut g, b"hi"); let _ = reply_to(&mut g, b"\x1b[>q\x1b[16t\x1b]11;?\x1b\\"); assert_eq!(row_str(&g, 0), "hi"); assert_cursor(&g, 0, 2); } #[test] fn decckm_toggles_cursor_key_mode() { let mut g = Grid::new(10, 3); assert!(!g.cursor_keys_application()); feed(&mut g, b"\x1b[?1h"); assert!(g.cursor_keys_application()); feed(&mut g, b"\x1b[?1l"); assert!(!g.cursor_keys_application()); } #[test] fn deckpam_and_deckpnm_toggle_the_keypad() { // An ESC pair rather than a DECSET, unlike every other mode here. let mut g = Grid::new(10, 3); assert!(!g.keypad_application()); feed(&mut g, b"\x1b="); assert!(g.keypad_application()); feed(&mut g, b"\x1b>"); assert!(!g.keypad_application()); } #[test] fn bracketed_paste_toggles_on_2004() { let mut g = Grid::new(10, 3); assert!(!g.bracketed_paste(), "off until a program asks"); feed(&mut g, b"\x1b[?2004h"); assert!(g.bracketed_paste()); feed(&mut g, b"\x1b[?2004l"); assert!(!g.bracketed_paste()); } #[test] fn bracketed_paste_survives_an_alt_screen_round_trip() { // vim sets it, and leaving the alt screen is not the shell revoking // it — the shell set its own before vim ever started. let mut g = Grid::new(10, 3); feed(&mut g, b"\x1b[?2004h"); feed(&mut g, b"\x1b[?1049h"); feed(&mut g, b"\x1b[?1049l"); assert!(g.bracketed_paste()); } #[test] fn alternate_scroll_toggles_on_1007() { let mut g = Grid::new(10, 3); assert!(g.alternate_scroll(), "on until a program says otherwise"); feed(&mut g, b"\x1b[?1007l"); assert!(!g.alternate_scroll()); feed(&mut g, b"\x1b[?1007h"); assert!(g.alternate_scroll()); } #[test] fn alternate_scroll_survives_an_alt_screen_round_trip() { // The mode is the user's answer to what the wheel means, not the alt // screen's, so entering and leaving one does not restore the default. let mut g = Grid::new(10, 3); feed(&mut g, b"\x1b[?1007l"); feed(&mut g, b"\x1b[?1049h"); feed(&mut g, b"\x1b[?1049l"); assert!(!g.alternate_scroll()); } #[test] fn on_alt_follows_1049() { let mut g = Grid::new(10, 3); assert!(!g.on_alt()); feed(&mut g, b"\x1b[?1049h"); assert!(g.on_alt()); feed(&mut g, b"\x1b[?1049l"); assert!(!g.on_alt()); } }