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