This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "lumbridge-terminal"
|
||||
description = "Framework-neutral terminal state, snapshots, and input encoding for Lumbridge"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
alacritty_terminal = { version = "0.26.0", default-features = false }
|
||||
thiserror = "2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,994 @@
|
||||
//! 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;
|
||||
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
|
||||
}
|
||||
|
||||
#[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 cells: Vec<TerminalCell>,
|
||||
pub cursor: TerminalCursor,
|
||||
pub modes: TerminalModes,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
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,
|
||||
};
|
||||
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user