diff --git a/Cargo.lock b/Cargo.lock index a9f7f7f..ae8f237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" dependencies = [ "base64", - "bitflags 2.13.0", + "bitflags 2.13.1", "home", "libc", "log", @@ -124,13 +124,22 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -189,6 +198,19 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -232,7 +254,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", @@ -498,7 +520,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -627,7 +649,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -676,7 +698,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -689,7 +711,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -707,6 +729,12 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -783,7 +811,9 @@ version = "0.0.1-beta.5" dependencies = [ "alacritty_terminal", "anyhow", + "bitflags 2.13.1", "clap", + "compact_str", "crossterm", "dialoguer", "dirs", @@ -855,6 +885,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -943,7 +979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "bitflags 2.13.0", + "bitflags 2.13.1", "cursor-icon", "log", "memchr", diff --git a/Cargo.toml b/Cargo.toml index d5dfc11..23a4454 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,5 @@ serde_json = "1.0.150" crossterm = "0.28" ttf-parser = { version = "0.25.1", default-features = false, features = ["std"] } dialoguer = { version = "0.11", default-features = false } +compact_str = "0.10.0" +bitflags = "2.13.1" diff --git a/SKILL.md b/SKILL.md index d417d64..87f3661 100644 --- a/SKILL.md +++ b/SKILL.md @@ -163,6 +163,7 @@ shell-use mouse drag 2 2 20 2 # drag from (2,2) to (20,2) shell-use cells 0 0 10 1 # inspect char/fg/bg/flags shell-use expect text "ERROR" --fg "#ff0000" # text present AND red shell-use expect text "OK" --fg 2 --bg 0 # ansi-256 fg/bg +shell-use expect text "plain" --fg default # asserts the cell set no color ``` ## Workflow: snapshot testing diff --git a/bindings/js/src/types.ts b/bindings/js/src/types.ts index 0831b47..8ba36b9 100644 --- a/bindings/js/src/types.ts +++ b/bindings/js/src/types.ts @@ -1,5 +1,14 @@ export type Color = "default" | number | string; +/** `"none"` is a value, not an absence: an un-underlined cell reports it. */ +export type UnderlineStyle = + | "none" + | "single" + | "double" + | "curly" + | "dotted" + | "dashed"; + export type Shell = | "bash" | "powershell" @@ -24,13 +33,27 @@ export interface Size { export interface Cell { x: number; y: number; + /** The cell's grapheme; `" "` when blank, `""` for the second column of a double-width character. */ char: string; fg: Color; bg: Color; bold: boolean; + dim: boolean; italic: boolean; - underline: boolean; inverse: boolean; + invisible: boolean; + strike: boolean; + /** Always `false` from the alacritty backend, which cannot report blink. */ + blink: boolean; + /** Shorthand for `underline_style !== "none"`. */ + underline: boolean; + underline_style: UnderlineStyle; + /** + * `"default"` means the underline follows the text color. Tracked + * independently of `underline_style`, so a cell that set SGR 58 without an + * underline still reports the color it would use. + */ + underline_color: Color; } export interface State { diff --git a/bindings/python/src/shell_use/types.py b/bindings/python/src/shell_use/types.py index 8746cae..39d5e33 100644 --- a/bindings/python/src/shell_use/types.py +++ b/bindings/python/src/shell_use/types.py @@ -1,22 +1,37 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Literal, Optional, Union Color = Union[str, int] +#: ``"none"`` is a value, not an absence: an un-underlined cell reports it. +UnderlineStyle = Literal["none", "single", "double", "curly", "dotted", "dashed"] @dataclass class Cell: x: int y: int + #: The cell's grapheme; ``" "`` when blank, ``""`` for the second column + #: of a double-width character. char: str fg: Color bg: Color bold: bool + dim: bool italic: bool - underline: bool inverse: bool + invisible: bool + strike: bool + #: Always ``False`` from the alacritty backend, which cannot report blink. + blink: bool + #: Shorthand for ``underline_style != "none"``. + underline: bool + underline_style: UnderlineStyle + #: ``"default"`` means the underline follows the text color. Tracked + #: independently of ``underline_style``, so a cell that set SGR 58 without + #: an underline still reports the color it would use. + underline_color: Color @dataclass diff --git a/src/assert/color.rs b/src/assert/color.rs index 7e0f62c..98f4f96 100644 --- a/src/assert/color.rs +++ b/src/assert/color.rs @@ -1,9 +1,11 @@ //! Color parsing and comparison for `expect --fg/--bg`. -use super::super::terminal::emu::Color; +use super::super::terminal::cell::Color; #[derive(Debug, Clone)] pub enum Expected { + /// The terminal's default color, i.e. the cell set no color of its own. + Default, Ansi256(u8), Hex(u8, u8, u8), Rgb(u8, u8, u8), @@ -12,6 +14,9 @@ pub enum Expected { impl Expected { pub fn parse(s: &str) -> anyhow::Result { let s = s.trim(); + if s.eq_ignore_ascii_case("default") { + return Ok(Expected::Default); + } if let Some(hex) = s.strip_prefix('#') { let (r, g, b) = parse_hex(hex).map_err(|_| invalid(s))?; return Ok(Expected::Hex(r, g, b)); @@ -30,6 +35,7 @@ impl Expected { pub fn describe(&self) -> String { match self { + Expected::Default => "default".to_string(), Expected::Ansi256(n) => n.to_string(), Expected::Hex(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"), Expected::Rgb(r, g, b) => format!("{r},{g},{b}"), @@ -39,7 +45,9 @@ impl Expected { /// A consistent, enumerated error for any unparseable color value. fn invalid(got: &str) -> anyhow::Error { - anyhow::anyhow!("color must be ansi256 (0-255), hex (#rrggbb), or rgb (r,g,b) (got: \"{got}\")") + anyhow::anyhow!( + "color must be \"default\", ansi256 (0-255), hex (#rrggbb), or rgb (r,g,b) (got: \"{got}\")" + ) } fn parse_hex(hex: &str) -> anyhow::Result<(u8, u8, u8)> { @@ -53,43 +61,39 @@ fn parse_hex(hex: &str) -> anyhow::Result<(u8, u8, u8)> { } /// Does a cell's resolved color match the expected color? -pub fn matches(cell: Color, expected: &Expected) -> bool { +/// +/// A cell that set no color of its own matches only `default`. It cannot match +/// a concrete value, because which value it paints is the viewer's theme's +/// choice and not something the grid knows. +pub fn matches(cell: Option, expected: &Expected) -> bool { + let Some(cell) = cell else { + return matches!(expected, Expected::Default); + }; match expected { - Expected::Ansi256(n) => match cell { - Color::Default => *n == 0, - Color::Idx(i) => i == *n, - Color::Rgb(r, g, b) => rgb_to_ansi256(r, g, b) == *n, - }, - Expected::Hex(er, eg, eb) | Expected::Rgb(er, eg, eb) => { - let (er, eg, eb) = (*er, *eg, *eb); - match cell { - Color::Default => er == 0 && eg == 0 && eb == 0, - Color::Idx(i) => { - let (r, g, b) = ansi256_to_rgb(i); - r == er && g == eg && b == eb - } - Color::Rgb(r, g, b) => r == er && g == eg && b == eb, - } - } + Expected::Default => false, + Expected::Ansi256(n) => cell.to_index() == *n, + Expected::Hex(er, eg, eb) | Expected::Rgb(er, eg, eb) => rgb_of(cell) == (*er, *eg, *eb), + } +} + +fn rgb_of(c: Color) -> (u8, u8, u8) { + match c { + Color::Rgb(r, g, b) => (r, g, b), + c => ansi256_to_rgb(c.to_index()), } } /// Render a cell's color in the same space as the expected value, for messages. -pub fn describe_cell(cell: Color, expected: &Expected) -> String { +pub fn describe_cell(cell: Option, expected: &Expected) -> String { + let Some(cell) = cell else { + return "default".to_string(); + }; match expected { - Expected::Ansi256(_) => match cell { - Color::Default => "0".to_string(), - Color::Idx(i) => i.to_string(), - Color::Rgb(r, g, b) => rgb_to_ansi256(r, g, b).to_string(), - }, - _ => match cell { - Color::Default => "#000000".to_string(), - Color::Idx(i) => { - let (r, g, b) = ansi256_to_rgb(i); - format!("#{r:02x}{g:02x}{b:02x}") - } - Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"), - }, + Expected::Default | Expected::Ansi256(_) => cell.to_index().to_string(), + _ => { + let (r, g, b) = rgb_of(cell); + format!("#{r:02x}{g:02x}{b:02x}") + } } } @@ -161,7 +165,7 @@ pub fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 { #[cfg(test)] mod tests { use super::*; - use crate::terminal::emu::Color; + use crate::terminal::cell::Color; #[test] fn parse_forms() { @@ -181,10 +185,41 @@ mod tests { #[test] fn matches_palette_and_default() { - assert!(matches(Color::Idx(9), &Expected::Ansi256(9))); - assert!(!matches(Color::Idx(2), &Expected::Ansi256(9))); - assert!(matches(Color::Default, &Expected::Ansi256(0))); - assert!(matches(Color::Rgb(255, 0, 0), &Expected::Rgb(255, 0, 0))); + let idx = |i| Some(Color::from_index(i)); + assert!(matches(idx(9), &Expected::Ansi256(9))); + assert!(!matches(idx(2), &Expected::Ansi256(9))); + assert!(matches(idx(196), &Expected::Ansi256(196))); + assert!(matches( + Some(Color::Rgb(255, 0, 0)), + &Expected::Rgb(255, 0, 0) + )); + } + + /// A default-colored cell used to match `--fg 0`, claiming it was black + /// when the theme actually paints it light gray. It now matches only the + /// `default` keyword, which is the way to assert on it. + #[test] + fn default_color_matches_only_default() { + assert!(!matches(None, &Expected::Ansi256(0))); + assert!(!matches(None, &Expected::Hex(0, 0, 0))); + assert!(matches(None, &Expected::Default)); + assert_eq!(describe_cell(None, &Expected::Ansi256(0)), "default"); + } + + #[test] + fn a_colored_cell_is_not_default() { + let red = Some(Color::from_index(1)); + assert!(!matches(red, &Expected::Default)); + assert!(matches(red, &Expected::Ansi256(1))); + assert!(matches!( + Expected::parse("default").unwrap(), + Expected::Default + )); + assert!(matches!( + Expected::parse("DEFAULT").unwrap(), + Expected::Default + )); + assert_eq!(describe_cell(red, &Expected::Default), "1"); } #[test] diff --git a/src/assert/snapshot.rs b/src/assert/snapshot.rs index 409e8c9..bc32722 100644 --- a/src/assert/snapshot.rs +++ b/src/assert/snapshot.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; -use super::super::terminal::emu::{Color, EmuCell}; +use super::super::terminal::cell::{Attrs, Color, EmuCell}; pub enum SnapshotStatus { Passed, @@ -27,11 +27,11 @@ fn snapshot_path(base: &Path, name: &str) -> PathBuf { snapshot_dir(base).join(format!("{}.snap", sanitize(name))) } -fn color_value(c: Color) -> Value { +fn color_value(c: Option) -> Value { match c { - Color::Default => Value::String("default".to_string()), - Color::Idx(i) => json!(i), - Color::Rgb(r, g, b) => Value::String(format!("#{r:02x}{g:02x}{b:02x}")), + None => Value::String("default".to_string()), + Some(Color::Rgb(r, g, b)) => Value::String(format!("#{r:02x}{g:02x}{b:02x}")), + Some(c) => json!(c.to_index()), } } @@ -43,43 +43,27 @@ fn shift(prev: &EmuCell, cur: &EmuCell) -> Map { if prev.bg != cur.bg { m.insert("bg".into(), color_value(cur.bg)); } - if prev.bold != cur.bold { - m.insert("bold".into(), json!(cur.bold)); - } - if prev.dim != cur.dim { - m.insert("dim".into(), json!(cur.dim)); - } - if prev.italic != cur.italic { - m.insert("italic".into(), json!(cur.italic)); - } - if prev.underline != cur.underline { - m.insert("underline".into(), json!(cur.underline)); - } - if prev.inverse != cur.inverse { - m.insert("inverse".into(), json!(cur.inverse)); - } - if prev.invisible != cur.invisible { - m.insert("invisible".into(), json!(cur.invisible)); + for (attr, key) in [ + (Attrs::BOLD, "bold"), + (Attrs::DIM, "dim"), + (Attrs::ITALIC, "italic"), + (Attrs::INVERSE, "inverse"), + (Attrs::INVISIBLE, "invisible"), + (Attrs::STRIKE, "strike"), + (Attrs::BLINK, "blink"), + ] { + if prev.has(attr) != cur.has(attr) { + m.insert(key.into(), json!(cur.has(attr))); + } } - if prev.strike != cur.strike { - m.insert("strike".into(), json!(cur.strike)); + if prev.underline.is_underlined() != cur.underline.is_underlined() { + m.insert("underline".into(), json!(cur.underline.is_underlined())); } m } fn baseline() -> EmuCell { - EmuCell { - ch: String::new(), - fg: Color::Default, - bg: Color::Default, - bold: false, - dim: false, - italic: false, - underline: false, - inverse: false, - invisible: false, - strike: false, - } + EmuCell::blank() } /// Serialize a grid into a boxed text view plus (optionally) a color shift map. @@ -90,7 +74,10 @@ pub fn serialize(rows: &[Vec], cols: u16, include_colors: bool) -> Stri for (y, row) in rows.iter().enumerate() { let mut line = String::with_capacity(cols as usize); for (x, cell) in row.iter().enumerate() { - line.push_str(if cell.ch.is_empty() { " " } else { &cell.ch }); + // A continuation contributes nothing, exactly as in + // `rows_to_strings`: the wide char to its left already spans this + // column, so giving it a filler widens the row past the box. + line.push_str(&cell.ch); let s = shift(&prev, cell); if !s.is_empty() { shifts.insert(format!("{x},{y}"), Value::Object(s)); @@ -153,3 +140,33 @@ pub fn compare( actual: trimmed.to_string(), }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::cell::CONTINUATION; + + fn cell(s: &str) -> EmuCell { + EmuCell { + ch: s.into(), + ..EmuCell::blank() + } + } + + /// A wide char spans two columns on its own. Rendering a filler for the + /// continuation pushed every later column right and left the content line + /// one column wider than the frame drawn around it, so any snapshot + /// holding a wide char was written misaligned and compared against that. + #[test] + fn a_wide_char_does_not_overflow_the_frame() { + let rows = vec![vec![ + cell("你"), + cell(CONTINUATION), + cell("b"), + cell(" "), + cell(" "), + cell(" "), + ]]; + assert_eq!(serialize(&rows, 6, false), "╭──────╮\n│你b │\n╰──────╯"); + } +} diff --git a/src/cli.rs b/src/cli.rs index 623ed51..1409647 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -419,10 +419,12 @@ pub enum ExpectCmd { /// Invert: assert the text is NOT present. #[arg(long)] not: bool, - /// Require this foreground color on the match. + /// Require this foreground color on the match: `default`, an ansi256 + /// index (0-255), hex (#rrggbb), or rgb (r,g,b). #[arg(long)] fg: Option, - /// Require this background color on the match. + /// Require this background color on the match: `default`, an ansi256 + /// index (0-255), hex (#rrggbb), or rgb (r,g,b). #[arg(long)] bg: Option, /// Timeout in milliseconds. diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index df7ca62..194bc36 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -20,7 +20,7 @@ use crate::input::{keys, mouse}; use crate::ipc; use crate::monitor; use crate::protocol::{ErrorKind, GetField, MouseAction, Request, Response}; -use crate::terminal::emu::{rows_to_strings, Color, EmuCell}; +use crate::terminal::cell::{rows_to_strings, Attrs, Color, EmuCell}; use crate::terminal::locator::{self, Pattern}; use logger::Logger; use session::{Session, TermState}; @@ -313,7 +313,7 @@ fn wait_ready(s: &Session) { loop { { let st = s.state.lock().unwrap(); - if st.emu.tracker().is_ready() || st.exited.is_some() { + if st.tracker.is_ready() || st.exited.is_some() { return; } } @@ -415,11 +415,11 @@ fn state(s: &Session) -> Response { "cols": cols, "rows": rows, "cursor": { "x": cx, "y": cy }, - "cwd": st.emu.tracker().cwd(), - "last_command": st.emu.tracker().last_command(), - "last_exit": st.emu.tracker().last_exit(), + "cwd": st.tracker.cwd(), + "last_command": st.tracker.last_command(), + "last_exit": st.tracker.last_exit(), "exited": st.exited, - "ready": st.emu.tracker().is_ready(), + "ready": st.tracker.is_ready(), "text": text, })) } @@ -430,38 +430,58 @@ fn cells(s: &Session, x: u16, y: u16, w: u16, h: u16) -> Response { for row in y..y.saturating_add(h.max(1)) { for col in x..x.saturating_add(w.max(1)) { if let Some(cell) = rows.get(row as usize).and_then(|r| r.get(col as usize)) { - out.push(json!({ - "x": col, - "y": row, - "char": if cell.ch.is_empty() { " ".to_string() } else { cell.ch.clone() }, - "fg": color_json(cell.fg), - "bg": color_json(cell.bg), - "bold": cell.bold, - "italic": cell.italic, - "underline": cell.underline, - "inverse": cell.inverse, - })); + out.push(cell_json(col, row, cell)); } } } Response::with(json!({ "cells": out })) } -fn color_json(c: Color) -> serde_json::Value { +/// One cell in wire form. Every attribute the neutral model carries is +/// reported, including ones no current backend can source, so a client can be +/// written against the full vocabulary rather than against alacritty. +fn cell_json(x: u16, y: u16, cell: &EmuCell) -> serde_json::Value { + json!({ + "x": x, + "y": y, + "char": cell.ch.as_str(), + "fg": color_json(cell.fg), + "bg": color_json(cell.bg), + "bold": cell.has(Attrs::BOLD), + "dim": cell.has(Attrs::DIM), + "italic": cell.has(Attrs::ITALIC), + "inverse": cell.has(Attrs::INVERSE), + "invisible": cell.has(Attrs::INVISIBLE), + "strike": cell.has(Attrs::STRIKE), + "blink": cell.has(Attrs::BLINK), + "underline": cell.underline.is_underlined(), + // Never null: an un-underlined cell is the "none" style, and an + // underline that follows the text color is "default", the same + // sentinel `fg` and `bg` use. A client can switch on the string + // without a null check. + "underline_style": cell.underline.name(), + "underline_color": color_json(cell.underline_color), + }) +} + +/// Wire form: `"default"`, a 256-color index, or `"#rrggbb"`. Named and +/// indexed colors both serialize to their palette index, so the split between +/// them stays internal and the language bindings are unaffected. +fn color_json(c: Option) -> serde_json::Value { match c { - Color::Default => json!("default"), - Color::Idx(i) => json!(i), - Color::Rgb(r, g, b) => json!(format!("#{r:02x}{g:02x}{b:02x}")), + None => json!("default"), + Some(Color::Rgb(r, g, b)) => json!(format!("#{r:02x}{g:02x}{b:02x}")), + Some(c) => json!(c.to_index()), } } fn get(s: &Session, field: GetField) -> Response { let st = s.state.lock().unwrap(); let value = match field { - GetField::Command => json!(st.emu.tracker().last_command()), - GetField::Output => json!(st.emu.tracker().last_output()), - GetField::ExitCode => json!(st.emu.tracker().last_exit()), - GetField::Cwd => json!(st.emu.tracker().cwd()), + GetField::Command => json!(st.tracker.last_command()), + GetField::Output => json!(st.tracker.last_output()), + GetField::ExitCode => json!(st.tracker.last_exit()), + GetField::Cwd => json!(st.tracker.cwd()), GetField::Cursor => { let (x, y) = st.emu.cursor(); json!({ "x": x, "y": y }) @@ -604,15 +624,15 @@ fn wait_idle(s: &Session, timeout_ms: u64) -> Response { fn wait_command(s: &Session, timeout_ms: u64) -> Response { let quiet = Duration::from_millis(300); - let baseline = s.state.lock().unwrap().emu.tracker().finished_count(); + let baseline = s.state.lock().unwrap().tracker.finished_count(); let ok = poll_until( || { let st = s.state.lock().unwrap(); - if st.exited.is_some() || st.emu.tracker().finished_count() > baseline { + if st.exited.is_some() || st.tracker.finished_count() > baseline { return true; } let idle = st.last_change.elapsed() >= quiet; - idle && (!st.emu.tracker().started() || st.emu.tracker().is_ready()) + idle && (!st.tracker.started() || st.tracker.is_ready()) }, timeout_ms, ); @@ -712,11 +732,7 @@ fn check_colors( if not { "absent" } else { "present" }, expected.describe(), color::describe_cell(c.cell.fg, &expected), - if c.cell.ch.is_empty() { - " " - } else { - &c.cell.ch - }, + c.cell.ch, c.x, c.y )); @@ -732,11 +748,7 @@ fn check_colors( if not { "absent" } else { "present" }, expected.describe(), color::describe_cell(c.cell.bg, &expected), - if c.cell.ch.is_empty() { - " " - } else { - &c.cell.ch - }, + c.cell.ch, c.x, c.y )); @@ -748,10 +760,10 @@ fn check_colors( fn expect_exit_code(s: &Session, code: i32) -> Response { poll_until( - || s.state.lock().unwrap().emu.tracker().last_exit().is_some(), + || s.state.lock().unwrap().tracker.last_exit().is_some(), config::DEFAULT_EXPECT_TIMEOUT_MS, ); - let actual = s.state.lock().unwrap().emu.tracker().last_exit(); + let actual = s.state.lock().unwrap().tracker.last_exit(); match actual { Some(a) if a == code => Response::ok(), Some(a) => Response::assertion(format!("expected exit code {code}, got {a}")), @@ -764,8 +776,7 @@ fn expect_output(s: &Session, text: &str, regex: bool) -> Response { .state .lock() .unwrap() - .emu - .tracker() + .tracker .last_output() .map(|o| o.to_string()); let Some(output) = output else { @@ -847,3 +858,66 @@ fn format_timeout(timeout_ms: u64) -> String { format!("{timeout_ms}ms") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::cell::{NamedColor, UnderlineStyle}; + + /// Every attribute in the vocabulary reaches the wire, including ones the + /// alacritty backend can never source (blink), so clients written against + /// the full model keep working when another backend starts reporting them. + #[test] + fn cell_json_reports_the_whole_vocabulary() { + let cell = EmuCell { + ch: "x".into(), + fg: Some(Color::Named(NamedColor::Red)), + bg: Some(Color::Idx(196)), + underline: UnderlineStyle::Curly, + underline_color: Some(Color::Rgb(1, 2, 3)), + attrs: Attrs::all(), + }; + let v = cell_json(3, 4, &cell); + assert_eq!(v["x"], json!(3)); + assert_eq!(v["char"], json!("x")); + assert_eq!(v["fg"], json!(1)); + assert_eq!(v["bg"], json!(196)); + for key in [ + "bold", + "dim", + "italic", + "inverse", + "invisible", + "strike", + "blink", + "underline", + ] { + assert_eq!(v[key], json!(true), "{key} must be reported"); + } + assert_eq!(v["underline_style"], json!("curly")); + assert_eq!(v["underline_color"], json!("#010203")); + } + + /// The underline fields are never null, so a client can switch on the + /// style string and compare the color the same way it does `fg`. + #[test] + fn cell_json_underline_fields_are_never_null() { + let v = cell_json(0, 0, &EmuCell::blank()); + assert_eq!(v["underline"], json!(false)); + assert_eq!(v["underline_style"], json!("none")); + assert_eq!(v["underline_color"], json!("default")); + assert_eq!(v["blink"], json!(false)); + + // Underlined, but with no color of its own: it follows the text color, + // which is the same thing `fg: "default"` means. + let cell = EmuCell { + underline: UnderlineStyle::Single, + underline_color: None, + ..EmuCell::blank() + }; + let v = cell_json(0, 0, &cell); + assert_eq!(v["underline"], json!(true)); + assert_eq!(v["underline_style"], json!("single")); + assert_eq!(v["underline_color"], json!("default")); + } +} diff --git a/src/daemon/session.rs b/src/daemon/session.rs index fd44b06..ec97827 100644 --- a/src/daemon/session.rs +++ b/src/daemon/session.rs @@ -8,12 +8,17 @@ use std::time::Instant; use crate::daemon::logger::Logger; use crate::shell::{self, Shell}; -use crate::terminal::emu::Emu; +use crate::terminal::alacritty::AlacrittyEmu; +use crate::terminal::emu::Emulator; +use crate::terminal::integration::CommandTracker; use crate::terminal::pty::{Pty, SpawnOptions}; use crate::trace::recorder::Recorder; pub struct TermState { - pub emu: Emu, + pub emu: Box, + /// Shell-integration state, derived from the raw PTY stream rather than + /// the emulator, so it is identical across backends. + pub tracker: CommandTracker, pub last_change: Instant, pub exited: Option, } @@ -60,7 +65,8 @@ impl Session { }; let state = Arc::new(Mutex::new(TermState { - emu: Emu::new(cols, rows, 5_000), + emu: Box::new(AlacrittyEmu::new(cols, rows, 5_000)), + tracker: CommandTracker::new(), last_change: Instant::now(), exited: None, })); @@ -94,6 +100,7 @@ impl Session { let pending = { let mut st = reader_state.lock().unwrap(); st.emu.process(&buf[..n]); + st.tracker.feed(&buf[..n]); st.last_change = Instant::now(); st.emu.take_pending_writes() }; diff --git a/src/monitor.rs b/src/monitor.rs index 48e4394..a110bf6 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; -use crate::terminal::emu::{Color, EmuCell}; +use crate::terminal::cell::{Attrs, Color, EmuCell, UnderlineStyle}; const BORDER: &str = "\x1b[38;5;240m"; const RESET: &str = "\x1b[0m"; @@ -80,23 +80,16 @@ fn content(out: &mut String, f: &Frame, inner_w: usize, inner_h: usize) { let row = f.grid.get(y); let mut last: Option