Files
lumbridge-code/crates/lumbridge-terminal/src/lib.rs
T
Metal AgentandClaude Opus 5 f9f4f85402 Measure the terminal cell instead of guessing it, and stop telling the PTY zero
TERMINAL_CELL_WIDTH was 8.4 — a number nobody had measured. Asking the text
system for the advance of `0` in the face actually being painted gives ~7.3, so
the guess was 13% wide and the terminal was losing eighteen columns: the same
window that reported 122 columns now reports 140. Layout and paint now read the
same measurement, so they cannot drift apart again.

The plan claimed ws_xpixel disagreed with the painted width by 0.4 px per
column. It did not: the app only ever called TerminalSize::new, which passes no
pixel dimensions, so ws_xpixel and ws_ypixel were both *zero*. Every program
doing pixel arithmetic — sixel, the kitty graphics protocol, anything sizing an
image to the viewport — was being told the window has no size at all. Both the
spawn and the resize paths now report the real extent.

TerminalDimensions gains cell_width/cell_height accessors: it was already
carrying the values and nothing could read them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 00:13:30 -07:00

1067 lines
34 KiB
Rust

//! 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, TerminalEngineError> {
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<Self, TerminalEngineError> {
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<String>,
}
#[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<TerminalCell>,
pub cursor: TerminalCursor,
pub modes: TerminalModes,
pub title: Option<String>,
}
/// 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<String> {
(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<Vec<u8>>,
pub events: Vec<TerminalEvent>,
}
#[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<Vec<u8>>,
events: Vec<TerminalEvent>,
title: Option<String>,
}
#[derive(Clone, Default)]
struct EventProxy {
state: Arc<Mutex<ProxyState>>,
}
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<String> {
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<EventProxy>,
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<Vec<u8>> {
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<u8>, alt: bool) -> Vec<u8> {
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<u8> {
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<u8> {
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<u8> {
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());
}
}