//! Framework-neutral terminal state for Lumbridge. //! //! This crate consumes raw PTY bytes, maintains VT state, creates immutable //! render snapshots, and encodes human input. It deliberately owns neither a //! PTY nor a UI framework. #![forbid(unsafe_code)] use alacritty_terminal::event::{Event, EventListener, WindowSize}; use alacritty_terminal::grid::{Dimensions, Scroll}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::{Config, Osc52, Term, TermMode, point_to_viewport}; use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor}; use std::fmt; use std::sync::{Arc, Mutex, MutexGuard}; use thiserror::Error; const DEFAULT_SCROLLBACK_LINES: usize = 10_000; const MAX_TITLE_BYTES: usize = 512; /// Validated terminal grid and cell dimensions. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TerminalDimensions { rows: u16, columns: u16, cell_width: u16, cell_height: u16, } impl TerminalDimensions { /// Creates dimensions without pixel-cell measurements. /// /// # Errors /// /// Returns [`TerminalEngineError::InvalidDimensions`] for a zero row or /// column count. pub fn new(rows: u16, columns: u16) -> Result { Self::with_cell_size(rows, columns, 0, 0) } /// Creates dimensions with the cell size used for terminal size queries. /// /// # Errors /// /// Returns [`TerminalEngineError::InvalidDimensions`] for a zero row or /// column count. pub fn with_cell_size( rows: u16, columns: u16, cell_width: u16, cell_height: u16, ) -> Result { if rows == 0 || columns == 0 { return Err(TerminalEngineError::InvalidDimensions); } Ok(Self { rows, columns, cell_width, cell_height, }) } #[must_use] pub const fn rows(self) -> u16 { self.rows } /// The measured width of one cell, in pixels. /// /// Carried so a caller can report the viewport's pixel extent to a PTY. /// `ws_xpixel` is the whole window, not one cell, so it is this times the /// column count — and a terminal that reports zero there tells every program /// doing pixel arithmetic that the window has no size. #[must_use] pub const fn cell_width(self) -> u16 { self.cell_width } /// The height of one row, in pixels. #[must_use] pub const fn cell_height(self) -> u16 { self.cell_height } #[must_use] pub const fn columns(self) -> u16 { self.columns } const fn window_size(self) -> WindowSize { WindowSize { num_lines: self.rows, num_cols: self.columns, cell_width: self.cell_width, cell_height: self.cell_height, } } } impl Default for TerminalDimensions { fn default() -> Self { Self { rows: 24, columns: 80, cell_width: 0, cell_height: 0, } } } impl Dimensions for TerminalDimensions { fn total_lines(&self) -> usize { usize::from(self.rows) } fn screen_lines(&self) -> usize { usize::from(self.rows) } fn columns(&self) -> usize { usize::from(self.columns) } } /// Configuration for a terminal state engine. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TerminalEngineOptions { pub dimensions: TerminalDimensions, pub scrollback_lines: usize, } impl Default for TerminalEngineOptions { fn default() -> Self { Self { dimensions: TerminalDimensions::default(), scrollback_lines: DEFAULT_SCROLLBACK_LINES, } } } #[derive(Debug, Error, Eq, PartialEq)] pub enum TerminalEngineError { #[error("terminal rows and columns must both be greater than zero")] InvalidDimensions, } /// Color identity retained by an immutable terminal snapshot. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TerminalColor { Named(TerminalNamedColor), Indexed(u8), Rgb { red: u8, green: u8, blue: u8 }, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TerminalNamedColor { Black, Red, Green, Yellow, Blue, Magenta, Cyan, White, BrightBlack, BrightRed, BrightGreen, BrightYellow, BrightBlue, BrightMagenta, BrightCyan, BrightWhite, Foreground, Background, Cursor, DimBlack, DimRed, DimGreen, DimYellow, DimBlue, DimMagenta, DimCyan, DimWhite, BrightForeground, DimForeground, } /// Render attributes for one terminal cell. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct TerminalCellStyle(u16); impl TerminalCellStyle { 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 DOUBLE_UNDERLINE: Self = Self(1 << 4); pub const UNDERCURL: Self = Self(1 << 5); pub const DOTTED_UNDERLINE: Self = Self(1 << 6); pub const DASHED_UNDERLINE: Self = Self(1 << 7); pub const INVERSE: Self = Self(1 << 8); pub const HIDDEN: Self = Self(1 << 9); pub const STRIKEOUT: Self = Self(1 << 10); #[must_use] pub const fn contains(self, other: Self) -> bool { self.0 & other.0 == other.0 } const fn with(mut self, flag: Self, enabled: bool) -> Self { if enabled { self.0 |= flag.0; } self } } /// One immutable cell. It intentionally has no `Debug` implementation so a /// transcript cannot be accidentally dumped through snapshot diagnostics. #[derive(Clone, Eq, PartialEq)] pub struct TerminalCell { pub text: String, pub foreground: TerminalColor, pub background: TerminalColor, pub style: TerminalCellStyle, pub wide: bool, pub wide_spacer: bool, pub hyperlink: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TerminalCursorShape { Block, Underline, Beam, HollowBlock, Hidden, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TerminalCursor { pub row: u16, pub column: u16, pub shape: TerminalCursorShape, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct TerminalModes(u16); impl TerminalModes { pub const ALTERNATE_SCREEN: Self = Self(1 << 0); pub const APPLICATION_CURSOR: Self = Self(1 << 1); pub const APPLICATION_KEYPAD: Self = Self(1 << 2); pub const BRACKETED_PASTE: Self = Self(1 << 3); pub const MOUSE_REPORTING: Self = Self(1 << 4); pub const FOCUS_REPORTING: Self = Self(1 << 5); pub const KITTY_KEYBOARD: Self = Self(1 << 6); #[must_use] pub const fn contains(self, other: Self) -> bool { self.0 & other.0 == other.0 } const fn with(mut self, flag: Self, enabled: bool) -> Self { if enabled { self.0 |= flag.0; } self } } /// Immutable, framework-neutral render state. It intentionally omits `Debug` /// because cells and titles may contain private terminal content. #[derive(Clone, Eq, PartialEq)] pub struct TerminalSnapshot { pub revision: u64, pub dimensions: TerminalDimensions, pub display_offset: usize, pub cells: Vec, pub cursor: TerminalCursor, pub modes: TerminalModes, pub title: Option, } /// Framework-neutral movement through the retained terminal history. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TerminalScroll { Delta(i32), PageUp, PageDown, Top, Bottom, } impl TerminalSnapshot { #[must_use] pub fn cell(&self, row: u16, column: u16) -> Option<&TerminalCell> { if row >= self.dimensions.rows() || column >= self.dimensions.columns() { return None; } let index = usize::from(row) * usize::from(self.dimensions.columns()) + usize::from(column); self.cells.get(index) } /// Returns visible plain-text rows for simple UI adapters and tests. #[must_use] pub fn plain_rows(&self) -> Vec { (0..self.dimensions.rows()) .map(|row| { let mut text = String::new(); for column in 0..self.dimensions.columns() { let Some(cell) = self.cell(row, column) else { continue; }; if cell.wide_spacer { continue; } if cell.text.is_empty() || cell.style.contains(TerminalCellStyle::HIDDEN) { text.push(' '); } else { text.push_str(&cell.text); } } text.trim_end().to_owned() }) .collect() } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TerminalQueryKind { Color, Clipboard, } /// Semantic terminal event safe to pass to the application boundary. /// Transcript-bearing title values are redacted from `Debug`. #[derive(Clone, Eq, PartialEq)] pub enum TerminalEvent { Bell, TitleChanged(String), TitleReset, ClipboardOperationBlocked, UnsupportedQuery(TerminalQueryKind), ExitRequested, } impl fmt::Debug for TerminalEvent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Bell => formatter.write_str("Bell"), Self::TitleChanged(title) => formatter .debug_struct("TitleChanged") .field("bytes", &title.len()) .finish_non_exhaustive(), Self::TitleReset => formatter.write_str("TitleReset"), Self::ClipboardOperationBlocked => formatter.write_str("ClipboardOperationBlocked"), Self::UnsupportedQuery(kind) => formatter .debug_tuple("UnsupportedQuery") .field(kind) .finish(), Self::ExitRequested => formatter.write_str("ExitRequested"), } } } /// Result of processing PTY output. `outbound` contains terminal-protocol /// responses that must be serialized through the same actor input queue as /// human input. pub struct TerminalUpdate { pub revision: u64, pub outbound: Vec>, pub events: Vec, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct KeyModifiers(u8); impl KeyModifiers { pub const SHIFT: Self = Self(1 << 0); pub const ALT: Self = Self(1 << 1); pub const CONTROL: Self = Self(1 << 2); pub const PLATFORM: Self = Self(1 << 3); #[must_use] pub const fn contains(self, other: Self) -> bool { self.0 & other.0 == other.0 } #[must_use] pub const fn union(self, other: Self) -> Self { Self(self.0 | other.0) } } #[derive(Clone, Debug, Eq, PartialEq)] pub enum TerminalKey { Text(String), Enter, Backspace, Tab, Escape, Up, Down, Left, Right, Home, End, Insert, Delete, PageUp, PageDown, Function(u8), } #[derive(Clone, Debug, Eq, PartialEq)] pub struct TerminalKeyEvent { pub key: TerminalKey, pub modifiers: KeyModifiers, } #[derive(Default)] struct ProxyState { dimensions: TerminalDimensions, outbound: Vec>, events: Vec, title: Option, } #[derive(Clone, Default)] struct EventProxy { state: Arc>, } impl EventProxy { fn lock(&self) -> MutexGuard<'_, ProxyState> { self.state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } fn set_dimensions(&self, dimensions: TerminalDimensions) { self.lock().dimensions = dimensions; } fn drain(&self, revision: u64) -> TerminalUpdate { let mut state = self.lock(); TerminalUpdate { revision, outbound: std::mem::take(&mut state.outbound), events: std::mem::take(&mut state.events), } } fn title(&self) -> Option { self.lock().title.clone() } } impl EventListener for EventProxy { fn send_event(&self, event: Event) { let mut state = self.lock(); match event { Event::PtyWrite(text) => state.outbound.push(text.into_bytes()), Event::TextAreaSizeRequest(formatter) => { let window_size = state.dimensions.window_size(); state.outbound.push(formatter(window_size).into_bytes()); } Event::Title(title) => { let title = sanitize_title(&title); state.title = Some(title.clone()); state.events.push(TerminalEvent::TitleChanged(title)); } Event::ResetTitle => { state.title = None; state.events.push(TerminalEvent::TitleReset); } Event::Bell => state.events.push(TerminalEvent::Bell), Event::ClipboardStore(_, _) | Event::ClipboardLoad(_, _) => { state.events.push(TerminalEvent::ClipboardOperationBlocked); } Event::ColorRequest(_, _) => state .events .push(TerminalEvent::UnsupportedQuery(TerminalQueryKind::Color)), Event::Exit => state.events.push(TerminalEvent::ExitRequested), Event::MouseCursorDirty | Event::CursorBlinkingChange | Event::Wakeup | Event::ChildExit(_) => {} } } } /// Single-owner terminal state engine. pub struct TerminalEngine { parser: Processor, terminal: Term, proxy: EventProxy, dimensions: TerminalDimensions, revision: u64, } impl TerminalEngine { #[must_use] pub fn new(options: TerminalEngineOptions) -> Self { let proxy = EventProxy::default(); proxy.set_dimensions(options.dimensions); let config = Config { scrolling_history: options.scrollback_lines, osc52: Osc52::Disabled, ..Config::default() }; Self { parser: Processor::new(), terminal: Term::new(config, &options.dimensions, proxy.clone()), proxy, dimensions: options.dimensions, revision: 0, } } /// Applies ordered raw PTY bytes and returns protocol responses/events. pub fn process(&mut self, bytes: &[u8]) -> TerminalUpdate { if !bytes.is_empty() { self.parser.advance(&mut self.terminal, bytes); self.revision = self.revision.saturating_add(1); } self.proxy.drain(self.revision) } /// Resizes terminal state. The caller must send the equivalent PTY resize /// through the runtime actor to keep both sides synchronized. pub fn resize(&mut self, dimensions: TerminalDimensions) -> bool { if self.dimensions == dimensions { return false; } self.terminal.resize(dimensions); self.proxy.set_dimensions(dimensions); self.dimensions = dimensions; self.revision = self.revision.saturating_add(1); true } #[must_use] pub const fn dimensions(&self) -> TerminalDimensions { self.dimensions } #[must_use] pub const fn revision(&self) -> u64 { self.revision } /// Encodes a key using the terminal's current input modes. #[must_use] pub fn encode_key(&self, event: &TerminalKeyEvent) -> Vec { encode_key(event, *self.terminal.mode()) } /// Encodes paste delimiters only when the child enabled bracketed paste. #[must_use] pub fn encode_paste(&self, text: &str) -> Vec { if self.terminal.mode().contains(TermMode::BRACKETED_PASTE) { let mut bytes = Vec::with_capacity(text.len() + 12); bytes.extend_from_slice(b"\x1b[200~"); bytes.extend_from_slice(text.as_bytes()); bytes.extend_from_slice(b"\x1b[201~"); bytes } else { text.as_bytes().to_vec() } } /// Moves the visible viewport without writing bytes to the child PTY. /// /// Returns `true` only when the retained-history offset changed. pub fn scroll_display(&mut self, scroll: TerminalScroll) -> bool { let previous_offset = self.terminal.grid().display_offset(); let scroll = match scroll { TerminalScroll::Delta(lines) => Scroll::Delta(lines), TerminalScroll::PageUp => Scroll::PageUp, TerminalScroll::PageDown => Scroll::PageDown, TerminalScroll::Top => Scroll::Top, TerminalScroll::Bottom => Scroll::Bottom, }; self.terminal.scroll_display(scroll); if self.terminal.grid().display_offset() == previous_offset { return false; } self.revision = self.revision.saturating_add(1); true } #[must_use] pub fn display_offset(&self) -> usize { self.terminal.grid().display_offset() } #[must_use] pub fn snapshot(&self) -> TerminalSnapshot { let renderable = self.terminal.renderable_content(); let cursor_point = point_to_viewport(renderable.display_offset, renderable.cursor.point) .unwrap_or_default(); let mut cells = vec![ blank_cell(); usize::from(self.dimensions.rows) * usize::from(self.dimensions.columns) ]; for indexed in renderable.display_iter { let Some(point) = point_to_viewport(renderable.display_offset, indexed.point) else { continue; }; if point.line >= usize::from(self.dimensions.rows) || point.column.0 >= usize::from(self.dimensions.columns) { continue; } let index = point.line * usize::from(self.dimensions.columns) + point.column.0; cells[index] = snapshot_cell(indexed.cell); } TerminalSnapshot { revision: self.revision, dimensions: self.dimensions, display_offset: renderable.display_offset, cells, cursor: TerminalCursor { row: u16::try_from(cursor_point.line).unwrap_or(u16::MAX), column: u16::try_from(cursor_point.column.0).unwrap_or(u16::MAX), shape: map_cursor_shape(renderable.cursor.shape), }, modes: snapshot_modes(renderable.mode), title: self.proxy.title(), } } } impl Default for TerminalEngine { fn default() -> Self { Self::new(TerminalEngineOptions::default()) } } fn sanitize_title(title: &str) -> String { title .chars() .filter(|character| !character.is_control()) .scan(0_usize, |bytes, character| { let next = *bytes + character.len_utf8(); if next > MAX_TITLE_BYTES { None } else { *bytes = next; Some(character) } }) .collect() } fn snapshot_cell(cell: &alacritty_terminal::term::cell::Cell) -> TerminalCell { let mut text = String::new(); if !cell .flags .contains(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { text.push(cell.c); if let Some(zero_width) = cell.zerowidth() { text.extend(zero_width); } } TerminalCell { text, foreground: map_color(cell.fg), background: map_color(cell.bg), style: TerminalCellStyle::default() .with(TerminalCellStyle::BOLD, cell.flags.contains(Flags::BOLD)) .with(TerminalCellStyle::DIM, cell.flags.contains(Flags::DIM)) .with( TerminalCellStyle::ITALIC, cell.flags.contains(Flags::ITALIC), ) .with( TerminalCellStyle::UNDERLINE, cell.flags.contains(Flags::UNDERLINE), ) .with( TerminalCellStyle::DOUBLE_UNDERLINE, cell.flags.contains(Flags::DOUBLE_UNDERLINE), ) .with( TerminalCellStyle::UNDERCURL, cell.flags.contains(Flags::UNDERCURL), ) .with( TerminalCellStyle::DOTTED_UNDERLINE, cell.flags.contains(Flags::DOTTED_UNDERLINE), ) .with( TerminalCellStyle::DASHED_UNDERLINE, cell.flags.contains(Flags::DASHED_UNDERLINE), ) .with( TerminalCellStyle::INVERSE, cell.flags.contains(Flags::INVERSE), ) .with( TerminalCellStyle::HIDDEN, cell.flags.contains(Flags::HIDDEN), ) .with( TerminalCellStyle::STRIKEOUT, cell.flags.contains(Flags::STRIKEOUT), ), wide: cell.flags.contains(Flags::WIDE_CHAR), wide_spacer: cell.flags.contains(Flags::WIDE_CHAR_SPACER), hyperlink: cell.hyperlink().map(|link| link.uri().to_owned()), } } fn blank_cell() -> TerminalCell { snapshot_cell(&alacritty_terminal::term::cell::Cell::default()) } fn snapshot_modes(mode: TermMode) -> TerminalModes { TerminalModes::default() .with( TerminalModes::ALTERNATE_SCREEN, mode.contains(TermMode::ALT_SCREEN), ) .with( TerminalModes::APPLICATION_CURSOR, mode.contains(TermMode::APP_CURSOR), ) .with( TerminalModes::APPLICATION_KEYPAD, mode.contains(TermMode::APP_KEYPAD), ) .with( TerminalModes::BRACKETED_PASTE, mode.contains(TermMode::BRACKETED_PASTE), ) .with( TerminalModes::MOUSE_REPORTING, mode.intersects(TermMode::MOUSE_MODE), ) .with( TerminalModes::FOCUS_REPORTING, mode.contains(TermMode::FOCUS_IN_OUT), ) .with( TerminalModes::KITTY_KEYBOARD, mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL), ) } const fn map_cursor_shape(shape: CursorShape) -> TerminalCursorShape { match shape { CursorShape::Block => TerminalCursorShape::Block, CursorShape::Underline => TerminalCursorShape::Underline, CursorShape::Beam => TerminalCursorShape::Beam, CursorShape::HollowBlock => TerminalCursorShape::HollowBlock, CursorShape::Hidden => TerminalCursorShape::Hidden, } } const fn map_color(color: Color) -> TerminalColor { match color { Color::Named(named) => TerminalColor::Named(map_named_color(named)), Color::Indexed(index) => TerminalColor::Indexed(index), Color::Spec(rgb) => TerminalColor::Rgb { red: rgb.r, green: rgb.g, blue: rgb.b, }, } } const fn map_named_color(color: NamedColor) -> TerminalNamedColor { match color { NamedColor::Black => TerminalNamedColor::Black, NamedColor::Red => TerminalNamedColor::Red, NamedColor::Green => TerminalNamedColor::Green, NamedColor::Yellow => TerminalNamedColor::Yellow, NamedColor::Blue => TerminalNamedColor::Blue, NamedColor::Magenta => TerminalNamedColor::Magenta, NamedColor::Cyan => TerminalNamedColor::Cyan, NamedColor::White => TerminalNamedColor::White, NamedColor::BrightBlack => TerminalNamedColor::BrightBlack, NamedColor::BrightRed => TerminalNamedColor::BrightRed, NamedColor::BrightGreen => TerminalNamedColor::BrightGreen, NamedColor::BrightYellow => TerminalNamedColor::BrightYellow, NamedColor::BrightBlue => TerminalNamedColor::BrightBlue, NamedColor::BrightMagenta => TerminalNamedColor::BrightMagenta, NamedColor::BrightCyan => TerminalNamedColor::BrightCyan, NamedColor::BrightWhite => TerminalNamedColor::BrightWhite, NamedColor::Foreground => TerminalNamedColor::Foreground, NamedColor::Background => TerminalNamedColor::Background, NamedColor::Cursor => TerminalNamedColor::Cursor, NamedColor::DimBlack => TerminalNamedColor::DimBlack, NamedColor::DimRed => TerminalNamedColor::DimRed, NamedColor::DimGreen => TerminalNamedColor::DimGreen, NamedColor::DimYellow => TerminalNamedColor::DimYellow, NamedColor::DimBlue => TerminalNamedColor::DimBlue, NamedColor::DimMagenta => TerminalNamedColor::DimMagenta, NamedColor::DimCyan => TerminalNamedColor::DimCyan, NamedColor::DimWhite => TerminalNamedColor::DimWhite, NamedColor::BrightForeground => TerminalNamedColor::BrightForeground, NamedColor::DimForeground => TerminalNamedColor::DimForeground, } } fn encode_key(event: &TerminalKeyEvent, mode: TermMode) -> Vec { let modifiers = event.modifiers; match &event.key { TerminalKey::Text(text) => encode_text(text, modifiers), TerminalKey::Enter => with_alt(b"\r".to_vec(), modifiers.contains(KeyModifiers::ALT)), TerminalKey::Backspace => with_alt(vec![0x7f], modifiers.contains(KeyModifiers::ALT)), TerminalKey::Tab if modifiers.contains(KeyModifiers::SHIFT) => b"\x1b[Z".to_vec(), TerminalKey::Tab => with_alt(b"\t".to_vec(), modifiers.contains(KeyModifiers::ALT)), TerminalKey::Escape => b"\x1b".to_vec(), TerminalKey::Up => encode_cursor_key('A', modifiers, mode), TerminalKey::Down => encode_cursor_key('B', modifiers, mode), TerminalKey::Right => encode_cursor_key('C', modifiers, mode), TerminalKey::Left => encode_cursor_key('D', modifiers, mode), TerminalKey::Home => encode_cursor_key('H', modifiers, mode), TerminalKey::End => encode_cursor_key('F', modifiers, mode), TerminalKey::Insert => encode_csi_tilde(2, modifiers), TerminalKey::Delete => encode_csi_tilde(3, modifiers), TerminalKey::PageUp => encode_csi_tilde(5, modifiers), TerminalKey::PageDown => encode_csi_tilde(6, modifiers), TerminalKey::Function(number) => encode_function(*number, modifiers), } } fn encode_text(text: &str, modifiers: KeyModifiers) -> Vec { let mut bytes = if modifiers.contains(KeyModifiers::CONTROL) { control_text(text).unwrap_or_else(|| text.as_bytes().to_vec()) } else { text.as_bytes().to_vec() }; if modifiers.contains(KeyModifiers::ALT) { bytes.insert(0, 0x1b); } bytes } fn control_text(text: &str) -> Option> { let mut characters = text.chars(); let character = characters.next()?; if characters.next().is_some() || !character.is_ascii() { return None; } let byte = character as u8; let control = match byte { b'@'..=b'_' => byte - b'@', b'a'..=b'z' => byte - b'a' + 1, b'?' => 0x7f, b' ' => 0, _ => return None, }; Some(vec![control]) } fn with_alt(mut bytes: Vec, alt: bool) -> Vec { if alt { bytes.insert(0, 0x1b); } bytes } fn modifier_parameter(modifiers: KeyModifiers) -> u8 { 1 + u8::from(modifiers.contains(KeyModifiers::SHIFT)) + 2 * u8::from(modifiers.contains(KeyModifiers::ALT)) + 4 * u8::from(modifiers.contains(KeyModifiers::CONTROL)) + 8 * u8::from(modifiers.contains(KeyModifiers::PLATFORM)) } fn encode_cursor_key(final_byte: char, modifiers: KeyModifiers, mode: TermMode) -> Vec { let parameter = modifier_parameter(modifiers); if parameter == 1 && mode.contains(TermMode::APP_CURSOR) { format!("\x1bO{final_byte}").into_bytes() } else if parameter == 1 { format!("\x1b[{final_byte}").into_bytes() } else { format!("\x1b[1;{parameter}{final_byte}").into_bytes() } } fn encode_csi_tilde(number: u8, modifiers: KeyModifiers) -> Vec { let parameter = modifier_parameter(modifiers); if parameter == 1 { format!("\x1b[{number}~").into_bytes() } else { format!("\x1b[{number};{parameter}~").into_bytes() } } fn encode_function(number: u8, modifiers: KeyModifiers) -> Vec { let parameter = modifier_parameter(modifiers); if let Some(final_byte) = [None, Some('P'), Some('Q'), Some('R'), Some('S')] .get(usize::from(number)) .copied() .flatten() { if parameter == 1 { return format!("\x1bO{final_byte}").into_bytes(); } return format!("\x1b[1;{parameter}{final_byte}").into_bytes(); } let code = match number { 5 => 15, 6 => 17, 7 => 18, 8 => 19, 9 => 20, 10 => 21, 11 => 23, 12 => 24, _ => return Vec::new(), }; encode_csi_tilde(code, modifiers) } #[cfg(test)] mod tests { use super::{ KeyModifiers, TerminalCellStyle, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalEvent, TerminalKey, TerminalKeyEvent, TerminalModes, TerminalNamedColor, TerminalScroll, }; fn engine(rows: u16, columns: u16) -> TerminalEngine { TerminalEngine::new(TerminalEngineOptions { dimensions: TerminalDimensions::new(rows, columns).unwrap(), scrollback_lines: 32, }) } #[test] fn parses_cells_styles_unicode_cursor_and_title() { let mut engine = engine(3, 8); let update = engine.process(b"\x1b]2;safe\x07\x1b[31;1mhi\x1b[0m \xf0\x9f\x91\xa9\r\nnext"); assert_eq!(update.events, [TerminalEvent::TitleChanged("safe".into())]); let snapshot = engine.snapshot(); assert_eq!(snapshot.title.as_deref(), Some("safe")); let first = snapshot.cell(0, 0).unwrap(); assert_eq!(first.text, "h"); assert!(first.style.contains(TerminalCellStyle::BOLD)); assert_eq!( first.foreground, super::TerminalColor::Named(TerminalNamedColor::Red) ); assert_eq!(snapshot.cell(0, 3).unwrap().text, "👩"); assert!(snapshot.cell(0, 3).unwrap().wide); assert!(snapshot.cell(0, 4).unwrap().wide_spacer); assert_eq!(snapshot.plain_rows()[1], "next"); assert_eq!((snapshot.cursor.row, snapshot.cursor.column), (1, 4)); } #[test] fn tracks_alternate_screen_and_bracketed_paste() { let mut engine = engine(2, 8); engine.process(b"primary\x1b[?1049h\x1b[H\x1b[?2004halt"); let snapshot = engine.snapshot(); assert!(snapshot.modes.contains(TerminalModes::ALTERNATE_SCREEN)); assert!(snapshot.modes.contains(TerminalModes::BRACKETED_PASTE)); assert_eq!(snapshot.plain_rows()[0], "alt"); assert_eq!(engine.encode_paste("hello"), b"\x1b[200~hello\x1b[201~"); engine.process(b"\x1b[?1049l"); assert_eq!(engine.snapshot().plain_rows()[0], "primary"); } #[test] fn scrolls_retained_history_without_touching_the_pty_input_path() { let mut engine = engine(2, 8); engine.process(b"one\r\ntwo\r\nthree\r\nfour"); assert_eq!(engine.display_offset(), 0); let revision = engine.revision(); assert!(engine.scroll_display(TerminalScroll::PageUp)); assert!(engine.display_offset() > 0); assert!(engine.revision() > revision); assert_eq!(engine.snapshot().display_offset, engine.display_offset()); assert!(engine.scroll_display(TerminalScroll::Bottom)); assert_eq!(engine.display_offset(), 0); assert!(!engine.scroll_display(TerminalScroll::Bottom)); } #[test] fn emits_protocol_replies_without_logging_input_content() { let mut engine = engine(4, 10); let update = engine.process(b"\x1b[6n\x1b[18t"); assert!(update.outbound.iter().any(|reply| reply == b"\x1b[1;1R")); assert!(update.outbound.iter().any(|reply| reply == b"\x1b[8;4;10t")); assert!(update.events.is_empty()); } #[test] fn sanitizes_titles_and_blocks_clipboard_protocol() { let mut engine = engine(2, 8); let update = engine.process(b"\x1b]2;hello\x01world\x07\x1b]52;c;aGVsbG8=\x07"); assert_eq!(engine.snapshot().title.as_deref(), Some("helloworld")); assert!( update .events .contains(&TerminalEvent::TitleChanged("helloworld".into())) ); assert!(update.outbound.is_empty()); } #[test] fn encodes_text_control_cursor_modifiers_and_function_keys() { let mut engine = engine(2, 8); let text = TerminalKeyEvent { key: TerminalKey::Text("c".into()), modifiers: KeyModifiers::CONTROL, }; assert_eq!(engine.encode_key(&text), [3]); let up = TerminalKeyEvent { key: TerminalKey::Up, modifiers: KeyModifiers::default(), }; assert_eq!(engine.encode_key(&up), b"\x1b[A"); engine.process(b"\x1b[?1h"); assert_eq!(engine.encode_key(&up), b"\x1bOA"); let modified = TerminalKeyEvent { key: TerminalKey::Left, modifiers: KeyModifiers::SHIFT.union(KeyModifiers::CONTROL), }; assert_eq!(engine.encode_key(&modified), b"\x1b[1;6D"); let function = TerminalKeyEvent { key: TerminalKey::Function(12), modifiers: KeyModifiers::default(), }; assert_eq!(engine.encode_key(&function), b"\x1b[24~"); } #[test] fn resize_updates_snapshots_and_terminal_size_queries() { let mut engine = engine(2, 8); let dimensions = TerminalDimensions::with_cell_size(5, 12, 9, 18).unwrap(); assert!(engine.resize(dimensions)); assert!(!engine.resize(dimensions)); assert_eq!(engine.snapshot().dimensions, dimensions); let update = engine.process(b"\x1b[14t\x1b[18t"); assert!( update .outbound .iter() .any(|reply| reply == b"\x1b[4;90;108t") ); assert!(update.outbound.iter().any(|reply| reply == b"\x1b[8;5;12t")); } #[test] fn rejects_zero_dimensions() { assert!(TerminalDimensions::new(0, 80).is_err()); assert!(TerminalDimensions::new(24, 0).is_err()); } }