diff --git a/Cargo.lock b/Cargo.lock index 0dc420a..3ff1aea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + [[package]] name = "beer" version = "0.0.0" @@ -29,6 +35,8 @@ dependencies = [ "smithay-client-toolkit", "tracing", "tracing-subscriber", + "unicode-width", + "vte", "wayland-client", ] @@ -479,6 +487,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "valuable" version = "0.1.1" @@ -495,6 +509,16 @@ dependencies = [ "quote", ] +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "wayland-backend" version = "0.3.15" diff --git a/Cargo.toml b/Cargo.toml index 7f74023..122641a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ rustix = { version = "1.1.4", features = ["pty", "process", "termios", "stdio", smithay-client-toolkit = "0.20.0" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +unicode-width = "0.2.2" +vte = "0.15.0" wayland-client = "0.31.14" [lints.rust] diff --git a/src/grid.rs b/src/grid.rs new file mode 100644 index 0000000..c7fecd6 --- /dev/null +++ b/src/grid.rs @@ -0,0 +1,632 @@ +//! The terminal screen: a grid of styled cells, a cursor, and the editing +//! operations the VT parser drives. + +use unicode_width::UnicodeWidthChar; + +/// A cell colour: terminal default, a palette index, or direct RGB. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Color { + #[default] + Default, + Indexed(u8), + Rgb(u8, u8, u8), +} + +/// Per-cell style flags, packed into a `u16`. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct Flags(u16); + +impl Flags { + pub const BOLD: Self = Self(1 << 0); + pub const DIM: Self = Self(1 << 1); + pub const ITALIC: Self = Self(1 << 2); + pub const UNDERLINE: Self = Self(1 << 3); + pub const BLINK: Self = Self(1 << 4); + pub const REVERSE: Self = Self(1 << 5); + pub const HIDDEN: Self = Self(1 << 6); + pub const STRIKE: Self = Self(1 << 7); + /// Trailing column of a double-width glyph; holds no character of its own. + pub const WIDE_CONT: Self = Self(1 << 8); + + pub const fn empty() -> Self { + Self(0) + } + + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + pub fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + pub fn insert(&mut self, other: Self) { + self.0 |= other.0; + } + + pub fn remove(&mut self, other: Self) { + self.0 &= !other.0; + } +} + +/// One grid cell: a character plus its rendering style. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Cell { + pub c: char, + pub fg: Color, + pub bg: Color, + pub flags: Flags, +} + +impl Default for Cell { + fn default() -> Self { + Self { + c: ' ', + fg: Color::Default, + bg: Color::Default, + flags: Flags::empty(), + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct Cursor { + x: usize, + y: usize, +} + +/// The active screen plus cursor, scroll region, and current pen. +#[derive(Debug)] +pub struct Grid { + cols: usize, + rows: usize, + lines: Vec>, + cursor: Cursor, + saved: Cursor, + /// Inclusive top/bottom rows of the scroll region. + top: usize, + bottom: usize, + /// Template cell carrying the current SGR colours/flags. + pen: Cell, + autowrap: bool, + origin: bool, + insert: bool, + /// Cursor parked past the last column, awaiting the next print to wrap. + wrap_pending: bool, + tabs: Vec, + /// Saved primary screen while the alternate screen is active. + alt_saved: Option>>, +} + +fn default_tabs(cols: usize) -> Vec { + (0..cols).map(|i| i % 8 == 0 && i != 0).collect() +} + +impl Grid { + pub fn new(cols: usize, rows: usize) -> Self { + let cols = cols.max(1); + let rows = rows.max(1); + Self { + cols, + rows, + lines: vec![vec![Cell::default(); cols]; rows], + cursor: Cursor::default(), + saved: Cursor::default(), + top: 0, + bottom: rows - 1, + pen: Cell::default(), + autowrap: true, + origin: false, + insert: false, + wrap_pending: false, + tabs: default_tabs(cols), + alt_saved: None, + } + } + + pub fn rows(&self) -> usize { + self.rows + } + + pub fn cursor(&self) -> (usize, usize) { + (self.cursor.x, self.cursor.y) + } + + // --- pen / attributes --- + + pub fn pen_mut(&mut self) -> &mut Cell { + &mut self.pen + } + + pub fn reset_pen(&mut self) { + self.pen = Cell::default(); + } + + pub fn set_autowrap(&mut self, on: bool) { + self.autowrap = on; + } + + pub fn set_origin(&mut self, on: bool) { + self.origin = on; + self.move_to(0, 0); + } + + pub fn set_insert(&mut self, on: bool) { + self.insert = on; + } + + // --- printing --- + + /// Place a printable character at the cursor, honouring width and autowrap. + pub fn print(&mut self, c: char) { + let width = c.width().unwrap_or(0); + if width == 0 { + // Combining/zero-width: no standalone storage yet. + return; + } + if self.wrap_pending { + self.cursor.x = 0; + self.line_feed(); + self.wrap_pending = false; + } + if width == 2 && self.cursor.x + 1 >= self.cols { + // A double-width glyph cannot straddle the right edge: wrap first. + if self.autowrap { + self.cursor.x = 0; + self.line_feed(); + } else { + return; + } + } + if self.insert { + self.shift_right(width); + } + + let (x, y) = (self.cursor.x, self.cursor.y); + let mut cell = self.pen.clone(); + cell.c = c; + cell.flags.remove(Flags::WIDE_CONT); + self.lines[y][x] = cell; + if width == 2 && x + 1 < self.cols { + let mut cont = self.pen.clone(); + cont.c = ' '; + cont.flags.insert(Flags::WIDE_CONT); + self.lines[y][x + 1] = cont; + } + + let advance = width; + if self.cursor.x + advance >= self.cols { + if self.autowrap { + self.cursor.x = self.cols - 1; + self.wrap_pending = true; + } else { + self.cursor.x = self.cols - 1; + } + } else { + self.cursor.x += advance; + } + } + + fn shift_right(&mut self, n: usize) { + let (x, y) = (self.cursor.x, self.cursor.y); + let end = self.cols; + let blank = self.pen_blank(); + let row = &mut self.lines[y]; + for i in (x + n..end).rev() { + row[i] = row[i - n].clone(); + } + for cell in &mut row[x..(x + n).min(end)] { + *cell = blank.clone(); + } + } + + fn pen_blank(&self) -> Cell { + Cell { + c: ' ', + fg: Color::Default, + bg: self.pen.bg, + flags: Flags::empty(), + } + } + + // --- cursor movement --- + + fn region(&self) -> (usize, usize) { + if self.origin { + (self.top, self.bottom) + } else { + (0, self.rows - 1) + } + } + + pub fn move_to(&mut self, x: usize, y: usize) { + let (rt, rb) = self.region(); + self.cursor.x = x.min(self.cols - 1); + self.cursor.y = (y + rt).min(rb).max(rt); + self.wrap_pending = false; + } + + pub fn move_to_col(&mut self, x: usize) { + self.cursor.x = x.min(self.cols - 1); + self.wrap_pending = false; + } + + pub fn move_to_row(&mut self, y: usize) { + let (rt, rb) = self.region(); + self.cursor.y = (y + rt).min(rb).max(rt); + self.wrap_pending = false; + } + + pub fn cursor_up(&mut self, n: usize) { + let (rt, _) = self.region(); + self.cursor.y = self.cursor.y.saturating_sub(n).max(rt); + self.wrap_pending = false; + } + + pub fn cursor_down(&mut self, n: usize) { + let (_, rb) = self.region(); + self.cursor.y = (self.cursor.y + n).min(rb); + self.wrap_pending = false; + } + + pub fn cursor_fwd(&mut self, n: usize) { + self.cursor.x = (self.cursor.x + n).min(self.cols - 1); + self.wrap_pending = false; + } + + pub fn cursor_back(&mut self, n: usize) { + self.cursor.x = self.cursor.x.saturating_sub(n); + self.wrap_pending = false; + } + + pub fn save_cursor(&mut self) { + self.saved = self.cursor; + } + + pub fn restore_cursor(&mut self) { + self.cursor = self.saved; + self.cursor.x = self.cursor.x.min(self.cols - 1); + self.cursor.y = self.cursor.y.min(self.rows - 1); + self.wrap_pending = false; + } + + // --- line discipline --- + + pub fn carriage_return(&mut self) { + self.cursor.x = 0; + self.wrap_pending = false; + } + + pub fn backspace(&mut self) { + self.cursor.x = self.cursor.x.saturating_sub(1); + self.wrap_pending = false; + } + + /// LF/VT/FF: move down one row, scrolling at the region bottom. + pub fn line_feed(&mut self) { + if self.cursor.y == self.bottom { + self.scroll_up(1); + } else if self.cursor.y < self.rows - 1 { + self.cursor.y += 1; + } + self.wrap_pending = false; + } + + /// RI: move up one row, scrolling down at the region top. + pub fn reverse_index(&mut self) { + if self.cursor.y == self.top { + self.scroll_down(1); + } else if self.cursor.y > 0 { + self.cursor.y -= 1; + } + self.wrap_pending = false; + } + + /// NEL: carriage return plus line feed. + pub fn next_line(&mut self) { + self.carriage_return(); + self.line_feed(); + } + + // --- tab stops --- + + pub fn tab(&mut self) { + let mut x = self.cursor.x + 1; + while x < self.cols && !self.tabs[x] { + x += 1; + } + self.cursor.x = x.min(self.cols - 1); + self.wrap_pending = false; + } + + pub fn set_tab(&mut self) { + if self.cursor.x < self.cols { + self.tabs[self.cursor.x] = true; + } + } + + pub fn clear_tab(&mut self) { + if self.cursor.x < self.cols { + self.tabs[self.cursor.x] = false; + } + } + + pub fn clear_all_tabs(&mut self) { + self.tabs.iter_mut().for_each(|t| *t = false); + } + + // --- scrolling within the region --- + + pub fn set_scroll_region(&mut self, top: usize, bottom: usize) { + if top < bottom && bottom < self.rows { + self.top = top; + self.bottom = bottom; + } else { + self.top = 0; + self.bottom = self.rows - 1; + } + self.move_to(0, 0); + } + + pub fn scroll_up(&mut self, n: usize) { + let n = n.min(self.bottom - self.top + 1); + for y in self.top..=self.bottom { + if y + n <= self.bottom { + self.lines.swap(y, y + n); + } + } + for y in (self.bottom + 1 - n)..=self.bottom { + self.blank_row(y); + } + } + + pub fn scroll_down(&mut self, n: usize) { + let n = n.min(self.bottom - self.top + 1); + for y in (self.top..=self.bottom).rev() { + if y >= self.top + n { + self.lines.swap(y, y - n); + } + } + for y in self.top..(self.top + n) { + self.blank_row(y); + } + } + + fn blank_row(&mut self, y: usize) { + let blank = self.pen_blank(); + for cell in &mut self.lines[y] { + *cell = blank.clone(); + } + } + + // --- erase --- + + /// ED: 0=below, 1=above, 2/3=all. + pub fn erase_display(&mut self, mode: u16) { + let (x, y) = (self.cursor.x, self.cursor.y); + match mode { + 0 => { + self.erase_in_row(y, x, self.cols); + for r in (y + 1)..self.rows { + self.blank_row(r); + } + } + 1 => { + for r in 0..y { + self.blank_row(r); + } + self.erase_in_row(y, 0, x + 1); + } + _ => { + for r in 0..self.rows { + self.blank_row(r); + } + } + } + self.wrap_pending = false; + } + + /// EL: 0=right, 1=left, 2=line. + pub fn erase_line(&mut self, mode: u16) { + let (x, y) = (self.cursor.x, self.cursor.y); + match mode { + 0 => self.erase_in_row(y, x, self.cols), + 1 => self.erase_in_row(y, 0, x + 1), + _ => self.erase_in_row(y, 0, self.cols), + } + self.wrap_pending = false; + } + + /// ECH: erase n characters from the cursor without moving it. + pub fn erase_chars(&mut self, n: usize) { + let (x, y) = (self.cursor.x, self.cursor.y); + self.erase_in_row(y, x, (x + n).min(self.cols)); + } + + fn erase_in_row(&mut self, y: usize, from: usize, to: usize) { + let blank = self.pen_blank(); + for cell in &mut self.lines[y][from..to.min(self.cols)] { + *cell = blank.clone(); + } + } + + // --- intra-line editing --- + + /// ICH: insert n blanks at the cursor, shifting the rest right. + pub fn insert_chars(&mut self, n: usize) { + let saved = self.insert; + self.insert = true; + self.shift_right(n.min(self.cols)); + self.insert = saved; + } + + /// DCH: delete n characters at the cursor, shifting the rest left. + pub fn delete_chars(&mut self, n: usize) { + let (x, y) = (self.cursor.x, self.cursor.y); + let n = n.min(self.cols - x); + let blank = self.pen_blank(); + let row = &mut self.lines[y]; + for i in x..self.cols { + row[i] = if i + n < self.cols { + row[i + n].clone() + } else { + blank.clone() + }; + } + } + + /// IL: insert n blank lines at the cursor row, within the scroll region. + pub fn insert_lines(&mut self, n: usize) { + if self.cursor.y < self.top || self.cursor.y > self.bottom { + return; + } + let n = n.min(self.bottom - self.cursor.y + 1); + for y in (self.cursor.y..=self.bottom).rev() { + if y >= self.cursor.y + n { + self.lines.swap(y, y - n); + } + } + for y in self.cursor.y..(self.cursor.y + n) { + self.blank_row(y); + } + } + + /// DL: delete n lines at the cursor row, within the scroll region. + pub fn delete_lines(&mut self, n: usize) { + if self.cursor.y < self.top || self.cursor.y > self.bottom { + return; + } + let n = n.min(self.bottom - self.cursor.y + 1); + for y in self.cursor.y..=self.bottom { + if y + n <= self.bottom { + self.lines.swap(y, y + n); + } + } + for y in (self.bottom + 1 - n)..=self.bottom { + self.blank_row(y); + } + } + + // --- alternate screen --- + + pub fn enter_alt_screen(&mut self) { + if self.alt_saved.is_some() { + return; + } + let blank = vec![vec![Cell::default(); self.cols]; self.rows]; + self.alt_saved = Some(std::mem::replace(&mut self.lines, blank)); + } + + pub fn leave_alt_screen(&mut self) { + if let Some(main) = self.alt_saved.take() { + self.lines = main; + } + } + + // --- inspection (logging + tests) --- + + /// The visible text of one row, trailing blanks trimmed. + pub fn row_text(&self, y: usize) -> String { + self.lines[y] + .iter() + .filter(|c| !c.flags.contains(Flags::WIDE_CONT)) + .map(|c| c.c) + .collect::() + .trim_end() + .to_string() + } + + #[cfg(test)] + pub fn cell(&self, x: usize, y: usize) -> &Cell { + &self.lines[y][x] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prints_and_wraps() { + let mut g = Grid::new(4, 2); + for c in "abcde".chars() { + g.print(c); + } + assert_eq!(g.row_text(0), "abcd"); + assert_eq!(g.row_text(1), "e"); + assert_eq!(g.cursor(), (1, 1)); + } + + #[test] + fn carriage_return_and_line_feed() { + let mut g = Grid::new(8, 4); + for c in "hi".chars() { + g.print(c); + } + g.carriage_return(); + g.line_feed(); + for c in "yo".chars() { + g.print(c); + } + assert_eq!(g.row_text(0), "hi"); + assert_eq!(g.row_text(1), "yo"); + } + + #[test] + fn line_feed_scrolls_at_bottom() { + let mut g = Grid::new(4, 2); + g.print('a'); + g.next_line(); + g.print('b'); + g.next_line(); // scrolls: row0 <- "b", row1 blank + assert_eq!(g.row_text(0), "b"); + assert_eq!(g.row_text(1), ""); + } + + #[test] + fn erase_line_to_right() { + let mut g = Grid::new(6, 1); + for c in "abcdef".chars() { + g.print(c); + } + g.move_to(2, 0); + g.erase_line(0); + assert_eq!(g.row_text(0), "ab"); + } + + #[test] + fn delete_and_insert_chars() { + let mut g = Grid::new(6, 1); + for c in "abcdef".chars() { + g.print(c); + } + g.move_to(1, 0); + g.delete_chars(2); + assert_eq!(g.row_text(0), "adef"); + g.move_to(1, 0); + g.insert_chars(2); + assert_eq!(g.row_text(0), "a def"); + } + + #[test] + fn wide_char_occupies_two_columns() { + let mut g = Grid::new(6, 1); + g.print('世'); + g.print('x'); + assert_eq!(g.cell(0, 0).c, '世'); + assert!(g.cell(1, 0).flags.contains(Flags::WIDE_CONT)); + assert_eq!(g.cell(2, 0).c, 'x'); + } + + #[test] + fn scroll_region_limits_line_feed() { + let mut g = Grid::new(4, 4); + g.set_scroll_region(1, 2); + g.move_to(0, 2); // bottom of region (origin off: absolute row 2) + g.print('x'); + g.move_to(0, 2); + g.line_feed(); // at region bottom: scroll [1,2] up, x moves to row 1 + assert_eq!(g.row_text(1), "x"); + assert_eq!(g.row_text(2), ""); + } +} diff --git a/src/main.rs b/src/main.rs index 7634bed..80d2128 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ //! beer, a fast, software-rendered, Wayland-native terminal emulator. +mod grid; mod pty; +mod vt; mod wayland; use std::process::ExitCode; diff --git a/src/vt.rs b/src/vt.rs new file mode 100644 index 0000000..ab6bc9f --- /dev/null +++ b/src/vt.rs @@ -0,0 +1,459 @@ +//! VT emulation: feed bytes through `vte` and drive the [`Grid`]. + +use std::io::Write as _; + +use vte::{Params, Perform}; + +use crate::grid::{Color, Flags, Grid}; + +/// G0/G1 character set designation. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Charset { + Ascii, + DecSpecial, +} + +/// The terminal model: a grid plus the escape-sequence state around it. +#[derive(Debug)] +pub struct Term { + grid: Grid, + title: Option, + response: Vec, + g0: Charset, + g1: Charset, + shift_out: bool, +} + +impl Term { + pub fn new(cols: usize, rows: usize) -> Self { + Self { + grid: Grid::new(cols, rows), + title: None, + response: Vec::new(), + g0: Charset::Ascii, + g1: Charset::Ascii, + shift_out: false, + } + } + + pub fn grid(&self) -> &Grid { + &self.grid + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + /// Bytes the terminal needs to send back to the application (DA, CPR, ...). + pub fn take_response(&mut self) -> Vec { + std::mem::take(&mut self.response) + } + + fn active_charset(&self) -> Charset { + if self.shift_out { self.g1 } else { self.g0 } + } + + fn set_mode(&mut self, params: &Params, private: bool, on: bool) { + for p in params.iter() { + let Some(&code) = p.first() else { continue }; + match (private, code) { + (true, 6) => self.grid.set_origin(on), + (true, 7) => self.grid.set_autowrap(on), + (true, 1049) => { + if on { + self.grid.save_cursor(); + self.grid.enter_alt_screen(); + self.grid.erase_display(2); + } else { + self.grid.leave_alt_screen(); + self.grid.restore_cursor(); + } + } + (true, 47 | 1047) => { + if on { + self.grid.enter_alt_screen(); + } else { + self.grid.leave_alt_screen(); + } + } + (false, 4) => self.grid.set_insert(on), + // App-cursor/bracketed-paste/mouse/sync modes affect input and + // rendering, which arrive with the keyboard and renderer. + _ => tracing::trace!("unhandled mode {code} private={private} on={on}"), + } + } + } + + fn sgr(&mut self, params: &Params) { + let items: Vec<&[u16]> = params.iter().collect(); + if items.is_empty() { + self.grid.reset_pen(); + return; + } + let mut i = 0; + while i < items.len() { + let p = items[i]; + let code = p.first().copied().unwrap_or(0); + let pen = self.grid.pen_mut(); + let mut step = 1; + match code { + 0 => *pen = Default::default(), + 1 => pen.flags.insert(Flags::BOLD), + 2 => pen.flags.insert(Flags::DIM), + 3 => pen.flags.insert(Flags::ITALIC), + 4 => pen.flags.insert(Flags::UNDERLINE), + 5 | 6 => pen.flags.insert(Flags::BLINK), + 7 => pen.flags.insert(Flags::REVERSE), + 8 => pen.flags.insert(Flags::HIDDEN), + 9 => pen.flags.insert(Flags::STRIKE), + 21 | 22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)), + 23 => pen.flags.remove(Flags::ITALIC), + 24 => pen.flags.remove(Flags::UNDERLINE), + 25 => pen.flags.remove(Flags::BLINK), + 27 => pen.flags.remove(Flags::REVERSE), + 28 => pen.flags.remove(Flags::HIDDEN), + 29 => pen.flags.remove(Flags::STRIKE), + 30..=37 => pen.fg = Color::Indexed((code - 30) as u8), + 39 => pen.fg = Color::Default, + 40..=47 => pen.bg = Color::Indexed((code - 40) as u8), + 49 => pen.bg = Color::Default, + 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8), + 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8), + 38 | 48 => { + let (color, consumed) = ext_color(&items, i); + if let Some(color) = color { + if code == 38 { + pen.fg = color; + } else { + pen.bg = color; + } + } + step = consumed; + } + // 58/59 (underline colour) and others: no storage yet. + _ => {} + } + i += step; + } + } + + fn device_attrs(&mut self, secondary: bool) { + // Claim a VT220 with ANSI colour (62;22) for DA1, and a generic + // firmware level for DA2. + if secondary { + self.response.extend_from_slice(b"\x1b[>0;276;0c"); + } else { + self.response.extend_from_slice(b"\x1b[?62;22c"); + } + } + + fn device_status(&mut self, params: &Params) { + match params.iter().next().and_then(|p| p.first().copied()) { + Some(5) => self.response.extend_from_slice(b"\x1b[0n"), + Some(6) => { + let (x, y) = self.grid.cursor(); + let _ = write!(self.response, "\x1b[{};{}R", y + 1, x + 1); + } + _ => {} + } + } +} + +/// First param value, with 0/absent folded to `default` (xterm convention for +/// cursor movement and counts). +fn n(params: &Params, idx: usize, default: usize) -> usize { + match params.iter().nth(idx).and_then(|p| p.first().copied()) { + Some(0) | None => default, + Some(v) => v as usize, + } +} + +/// Raw first param value (0 is meaningful), defaulting to 0 when absent. +fn raw(params: &Params, idx: usize) -> u16 { + params + .iter() + .nth(idx) + .and_then(|p| p.first().copied()) + .unwrap_or(0) +} + +/// Parse an SGR 38/48 extended colour, returning the colour and how many +/// top-level params it consumed (1 for colon form, more for semicolon form). +fn ext_color(items: &[&[u16]], i: usize) -> (Option, usize) { + let head = items[i]; + if head.len() >= 2 { + return (color_from_subparams(&head[1..]), 1); + } + match items.get(i + 1).and_then(|s| s.first().copied()) { + Some(5) => { + let idx = items + .get(i + 2) + .and_then(|s| s.first().copied()) + .unwrap_or(0); + (Some(Color::Indexed(idx as u8)), 3) + } + Some(2) => { + let get = |k: usize| { + items + .get(i + k) + .and_then(|s| s.first().copied()) + .unwrap_or(0) + }; + ( + Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)), + 5, + ) + } + _ => (None, 1), + } +} + +fn color_from_subparams(sub: &[u16]) -> Option { + match sub.first().copied() { + Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)), + Some(2) => { + // Either `2:r:g:b` or `2:colorspace:r:g:b`. + let rgb = if sub.len() >= 5 { + &sub[2..5] + } else { + &sub[1..] + }; + match rgb { + [r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)), + _ => None, + } + } + _ => None, + } +} + +fn charset(byte: u8) -> Charset { + match byte { + b'0' => Charset::DecSpecial, + _ => Charset::Ascii, + } +} + +/// Translate a byte under the DEC special graphics set (line drawing). +fn dec_special(c: char) -> char { + match c { + '`' => '◆', + 'a' => '▒', + 'f' => '°', + 'g' => '±', + 'j' => '┘', + 'k' => '┐', + 'l' => '┌', + 'm' => '└', + 'n' => '┼', + 'o' => '⎺', + 'p' => '⎻', + 'q' => '─', + 'r' => '⎼', + 's' => '⎽', + 't' => '├', + 'u' => '┤', + 'v' => '┴', + 'w' => '┬', + 'x' => '│', + 'y' => '≤', + 'z' => '≥', + '~' => '·', + _ => c, + } +} + +impl Perform for Term { + fn print(&mut self, c: char) { + let c = if self.active_charset() == Charset::DecSpecial { + dec_special(c) + } else { + c + }; + self.grid.print(c); + } + + fn execute(&mut self, byte: u8) { + match byte { + 0x08 => self.grid.backspace(), + 0x09 => self.grid.tab(), + 0x0A..=0x0C => self.grid.line_feed(), + 0x0D => self.grid.carriage_return(), + 0x0E => self.shift_out = true, + 0x0F => self.shift_out = false, + _ => {} + } + } + + fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) { + let private = intermediates.first() == Some(&b'?'); + let secondary = intermediates.first() == Some(&b'>'); + match action { + 'A' => self.grid.cursor_up(n(params, 0, 1)), + 'B' | 'e' => self.grid.cursor_down(n(params, 0, 1)), + 'C' | 'a' => self.grid.cursor_fwd(n(params, 0, 1)), + 'D' => self.grid.cursor_back(n(params, 0, 1)), + 'E' => { + self.grid.cursor_down(n(params, 0, 1)); + self.grid.carriage_return(); + } + 'F' => { + self.grid.cursor_up(n(params, 0, 1)); + self.grid.carriage_return(); + } + 'G' | '`' => self.grid.move_to_col(n(params, 0, 1) - 1), + 'd' => self.grid.move_to_row(n(params, 0, 1) - 1), + 'H' | 'f' => self.grid.move_to(n(params, 1, 1) - 1, n(params, 0, 1) - 1), + 'J' => self.grid.erase_display(raw(params, 0)), + 'K' => self.grid.erase_line(raw(params, 0)), + '@' => self.grid.insert_chars(n(params, 0, 1)), + 'P' => self.grid.delete_chars(n(params, 0, 1)), + 'L' => self.grid.insert_lines(n(params, 0, 1)), + 'M' => self.grid.delete_lines(n(params, 0, 1)), + 'X' => self.grid.erase_chars(n(params, 0, 1)), + 'S' => self.grid.scroll_up(n(params, 0, 1)), + 'T' => self.grid.scroll_down(n(params, 0, 1)), + 'm' => self.sgr(params), + 'r' => { + let top = n(params, 0, 1) - 1; + let bottom = match params.iter().nth(1).and_then(|p| p.first().copied()) { + Some(0) | None => self.grid.rows() - 1, + Some(v) => (v as usize).saturating_sub(1), + }; + self.grid.set_scroll_region(top, bottom); + } + 'h' => self.set_mode(params, private, true), + 'l' => self.set_mode(params, private, false), + 'c' => self.device_attrs(secondary), + 'n' => self.device_status(params), + 's' => self.grid.save_cursor(), + 'u' => self.grid.restore_cursor(), + 'g' => match raw(params, 0) { + 3 => self.grid.clear_all_tabs(), + _ => self.grid.clear_tab(), + }, + _ => tracing::trace!("unhandled CSI {action:?} {intermediates:?}"), + } + } + + fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) { + match (intermediates.first().copied(), byte) { + (None, b'D') => self.grid.line_feed(), + (None, b'M') => self.grid.reverse_index(), + (None, b'E') => self.grid.next_line(), + (None, b'7') => self.grid.save_cursor(), + (None, b'8') => self.grid.restore_cursor(), + (None, b'H') => self.grid.set_tab(), + (None, b'c') => { + self.grid.reset_pen(); + self.grid.set_scroll_region(0, self.grid.rows() - 1); + self.grid.set_autowrap(true); + self.grid.set_origin(false); + self.grid.erase_display(2); + self.grid.move_to(0, 0); + self.g0 = Charset::Ascii; + self.g1 = Charset::Ascii; + self.shift_out = false; + } + (Some(b'('), c) => self.g0 = charset(c), + (Some(b')'), c) => self.g1 = charset(c), + _ => {} + } + } + + fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) { + match params.first() { + Some(&n) if n == b"0" || n == b"2" => { + if let Some(text) = params.get(1) { + self.title = Some(String::from_utf8_lossy(text).into_owned()); + } + } + _ => {} + } + } + + fn hook(&mut self, _: &Params, _: &[u8], _: bool, _: char) {} + fn put(&mut self, _: u8) {} + fn unhook(&mut self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feed(term: &mut Term, bytes: &[u8]) { + let mut parser = vte::Parser::new(); + parser.advance(term, bytes); + } + + #[test] + fn plain_text_lands_in_the_grid() { + let mut t = Term::new(20, 4); + feed(&mut t, b"hello"); + assert_eq!(t.grid().row_text(0), "hello"); + } + + #[test] + fn cursor_position_and_erase() { + let mut t = Term::new(20, 4); + feed(&mut t, b"abcde\x1b[Hxyz"); + assert_eq!(t.grid().row_text(0), "xyzde"); + } + + #[test] + fn newline_sequence() { + let mut t = Term::new(20, 4); + feed(&mut t, b"one\r\ntwo"); + assert_eq!(t.grid().row_text(0), "one"); + assert_eq!(t.grid().row_text(1), "two"); + } + + #[test] + fn sgr_sets_pen_colours() { + let mut t = Term::new(20, 1); + feed(&mut t, b"\x1b[31;1mX"); + let cell = t.grid().cell(0, 0); + assert_eq!(cell.fg, Color::Indexed(1)); + assert!(cell.flags.contains(Flags::BOLD)); + } + + #[test] + fn truecolor_semicolon_and_colon() { + let mut t = Term::new(20, 1); + feed(&mut t, b"\x1b[38;2;10;20;30mA"); + assert_eq!(t.grid().cell(0, 0).fg, Color::Rgb(10, 20, 30)); + feed(&mut t, b"\x1b[38:2:40:50:60mB"); + assert_eq!(t.grid().cell(1, 0).fg, Color::Rgb(40, 50, 60)); + } + + #[test] + fn device_status_reports_cursor() { + let mut t = Term::new(20, 4); + feed(&mut t, b"\x1b[3;5H\x1b[6n"); + assert_eq!(t.take_response(), b"\x1b[3;5R"); + } + + #[test] + fn line_drawing_charset() { + let mut t = Term::new(20, 1); + feed(&mut t, b"\x1b(0qx\x1b(B"); + assert_eq!(t.grid().row_text(0), "─│"); + } + + #[test] + fn title_via_osc() { + let mut t = Term::new(20, 1); + feed(&mut t, b"\x1b]0;hello\x07"); + assert_eq!(t.title(), Some("hello")); + } + + #[test] + fn alt_screen_preserves_primary() { + let mut t = Term::new(20, 2); + feed(&mut t, b"main"); + feed(&mut t, b"\x1b[?1049h"); + assert_eq!(t.grid().row_text(0), ""); + feed(&mut t, b"\x1b[?1049l"); + assert_eq!(t.grid().row_text(0), "main"); + } +} diff --git a/src/wayland.rs b/src/wayland.rs index cd4ad5e..7bae825 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -3,6 +3,7 @@ //! Uses smithay-client-toolkit for protocol boilerplate and calloop for the //! event loop, so the PTY master fd and timers share one loop. +use std::os::fd::OwnedFd; use std::time::Duration; use anyhow::Context; @@ -11,6 +12,7 @@ use calloop::{EventLoop, Interest, Mode, PostAction}; use calloop_wayland_source::WaylandSource; use crate::pty::Pty; +use crate::vt::Term; use smithay_client_toolkit::{ compositor::{CompositorHandler, CompositorState}, delegate_compositor, delegate_output, delegate_registry, delegate_seat, delegate_shm, @@ -39,7 +41,7 @@ const DEFAULT_W: u32 = 800; const DEFAULT_H: u32 = 600; /// Background fill, 0xAARRGGBB. Foot-ish dark grey. const BG: u32 = 0xFF18_1818; -/// Terminal size handed to the shell until cell geometry drives it. +/// Terminal size handed to the shell. const COLS: u16 = 80; const ROWS: u16 = 24; @@ -72,15 +74,17 @@ pub fn run() -> anyhow::Result<()> { .context("create shm slot pool")?; let pty = Pty::spawn(COLS, ROWS).context("spawn shell on pty")?; + let term = Term::new(COLS as usize, ROWS as usize); + let mut parser = vte::Parser::new(); // Read child output off a clone of the master; the original stays in `pty` - // for writes and resizing. + // for writing input back. let read_fd = pty.master().try_clone().context("clone pty master")?; event_loop .handle() .insert_source( Generic::new(read_fd, Interest::READ, Mode::Level), - |_, fd, app: &mut App| { + move |_, fd, app: &mut App| { let mut buf = [0u8; 4096]; match rustix::io::read(&*fd, &mut buf) { Ok(0) => { @@ -88,7 +92,8 @@ pub fn run() -> anyhow::Result<()> { Ok(PostAction::Remove) } Ok(n) => { - tracing::debug!("pty -> {n} bytes: {:02x?}", &buf[..n]); + parser.advance(&mut app.term, &buf[..n]); + app.after_feed(); Ok(PostAction::Continue) } Err(rustix::io::Errno::INTR | rustix::io::Errno::AGAIN) => { @@ -111,6 +116,8 @@ pub fn run() -> anyhow::Result<()> { pool, window, pty, + term, + title: None, width: DEFAULT_W, height: DEFAULT_H, configured: false, @@ -125,6 +132,19 @@ pub fn run() -> anyhow::Result<()> { Ok(()) } +/// Write every byte to `fd`, retrying short writes and interrupts. +fn write_all(fd: &OwnedFd, mut buf: &[u8]) -> rustix::io::Result<()> { + while !buf.is_empty() { + match rustix::io::write(fd, buf) { + Ok(0) => return Err(rustix::io::Errno::IO), + Ok(n) => buf = &buf[n..], + Err(rustix::io::Errno::INTR) => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + /// Window + Wayland client state shared across all protocol handlers. #[derive(Debug)] struct App { @@ -135,6 +155,9 @@ struct App { pool: SlotPool, window: Window, pty: Pty, + term: Term, + /// Last title applied to the toplevel, to avoid redundant requests. + title: Option, width: u32, height: u32, configured: bool, @@ -142,6 +165,29 @@ struct App { } impl App { + /// After parsing child output: send any replies and sync the title. + fn after_feed(&mut self) { + let reply = self.term.take_response(); + if !reply.is_empty() + && let Err(err) = write_all(self.pty.master(), &reply) + { + tracing::warn!("write to pty: {err}"); + } + + if tracing::enabled!(tracing::Level::TRACE) { + let grid = self.term.grid(); + for y in 0..grid.rows() { + tracing::trace!("{y:2}|{}", grid.row_text(y)); + } + } + + if self.term.title() != self.title.as_deref() { + self.title = self.term.title().map(str::to_owned); + self.window + .set_title(self.title.clone().unwrap_or_default()); + } + } + /// The child shell has gone away; reap it and tear the window down. fn child_exited(&mut self) { match self.pty.wait() {