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>
1067 lines
34 KiB
Rust
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());
|
|
}
|
|
}
|