feat(vt): port Ghostty's VT parsing layer
Ports the parsing half of ZIG/src/terminal into the ghostty-vt crate: - Parser state machine with a compile-time generated parse table and the UTF-8 decoder - OSC parser incl. all sub-parsers (titles, colors, clipboard, hyperlinks, semantic prompts/OSC 133, OSC 9 / ConEmu, iTerm2, kitty clipboard, color and text-sizing protocols, context signals) - CSI, SGR, modes table with save/restore, charsets, ansi, device status, mouse shapes, APC, DCS (incl. XTGETTCAP), tmux control-mode parser - color palette / X11 color names, kitty color and graphics command parser Deviations from Zig (documented in docs/rewrite/notes/ghostty-vt-W*.md): parser actions own their payloads instead of borrowing parser buffers; OSC strings are Vec<u8>; non-exhaustive Zig enums gain an Other(raw) variant; tmux regexes replaced by equivalent hand-written matchers. Known follow-up: semantic_prompt.rs carries local copies of the printf-q and percent decoders that belong in ghostty-os. Test Plan: - cargo test -p ghostty-vt: 395 passed, 1 ignored (skipped in Zig too) - cargo clippy -p ghostty-vt --all-targets -- -D warnings: clean
This commit is contained in:
1 parent
864ef82acb
commit
b2b248e003
44 files changed
+15040
-6
No files matched your search
@@ -0,0 +1,156 @@
|
||||
//! ANSI types (ZIG/src/terminal/ansi.zig).
|
||||
|
||||
/// C0 (7-bit) control characters from ANSI.
|
||||
///
|
||||
/// This is not complete, control characters are only added to this
|
||||
/// as the terminal emulator handles them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum C0 {
|
||||
/// Null
|
||||
Nul = 0x00,
|
||||
/// Start of heading
|
||||
Soh = 0x01,
|
||||
/// Start of text
|
||||
Stx = 0x02,
|
||||
/// Enquiry
|
||||
Enq = 0x05,
|
||||
/// Bell
|
||||
Bel = 0x07,
|
||||
/// Backspace
|
||||
Bs = 0x08,
|
||||
/// Horizontal tab
|
||||
Ht = 0x09,
|
||||
/// Line feed
|
||||
Lf = 0x0A,
|
||||
/// Vertical Tab
|
||||
Vt = 0x0B,
|
||||
/// Form feed
|
||||
Ff = 0x0C,
|
||||
/// Carriage return
|
||||
Cr = 0x0D,
|
||||
/// Shift out
|
||||
So = 0x0E,
|
||||
/// Shift in
|
||||
Si = 0x0F,
|
||||
}
|
||||
|
||||
impl C0 {
|
||||
/// Convert from a raw byte; unknown values return `None` (the Zig enum
|
||||
/// is non-exhaustive).
|
||||
pub fn from_int(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0x00 => Self::Nul,
|
||||
0x01 => Self::Soh,
|
||||
0x02 => Self::Stx,
|
||||
0x05 => Self::Enq,
|
||||
0x07 => Self::Bel,
|
||||
0x08 => Self::Bs,
|
||||
0x09 => Self::Ht,
|
||||
0x0A => Self::Lf,
|
||||
0x0B => Self::Vt,
|
||||
0x0C => Self::Ff,
|
||||
0x0D => Self::Cr,
|
||||
0x0E => Self::So,
|
||||
0x0F => Self::Si,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The SGR rendition aspects that can be set, sometimes known as attributes.
|
||||
/// The value corresponds to the parameter value for the SGR command (ESC [ m).
|
||||
/// Non-exhaustive in Zig; `Other` carries unknown values.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RenditionAspect {
|
||||
Default,
|
||||
Bold,
|
||||
DefaultFg,
|
||||
DefaultBg,
|
||||
Other(u16),
|
||||
}
|
||||
|
||||
impl RenditionAspect {
|
||||
pub fn from_int(v: u16) -> Self {
|
||||
match v {
|
||||
0 => Self::Default,
|
||||
1 => Self::Bold,
|
||||
39 => Self::DefaultFg,
|
||||
49 => Self::DefaultBg,
|
||||
v => Self::Other(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The device attribute request type (ESC [ c).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeviceAttributeReq {
|
||||
/// Blank
|
||||
Primary,
|
||||
/// >
|
||||
Secondary,
|
||||
/// =
|
||||
Tertiary,
|
||||
}
|
||||
|
||||
/// Possible cursor styles (ESC [ q)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CursorStyle {
|
||||
Default,
|
||||
BlinkingBlock,
|
||||
SteadyBlock,
|
||||
BlinkingUnderline,
|
||||
SteadyUnderline,
|
||||
BlinkingBar,
|
||||
SteadyBar,
|
||||
}
|
||||
|
||||
/// The status line type for DECSSDT. Non-exhaustive in Zig.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StatusLineType {
|
||||
None,
|
||||
Indicator,
|
||||
HostWritable,
|
||||
Other(u16),
|
||||
}
|
||||
|
||||
impl StatusLineType {
|
||||
pub fn from_int(v: u16) -> Self {
|
||||
match v {
|
||||
0 => Self::None,
|
||||
1 => Self::Indicator,
|
||||
2 => Self::HostWritable,
|
||||
v => Self::Other(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The display to target for status updates (DECSASD).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StatusDisplay {
|
||||
Main,
|
||||
StatusLine,
|
||||
}
|
||||
|
||||
/// The possible modify key formats to ESC[>{a};{b}m
|
||||
/// Note: this is not complete, we should add more as we support more
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModifyKeyFormat {
|
||||
Legacy,
|
||||
CursorKeys,
|
||||
FunctionKeys,
|
||||
OtherKeysNone,
|
||||
OtherKeysNumericExcept,
|
||||
OtherKeysNumeric,
|
||||
}
|
||||
|
||||
/// The protection modes that can be set for the terminal. See DECSCA and
|
||||
/// ESC V, W.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProtectedMode {
|
||||
Off,
|
||||
/// ESC V, W
|
||||
Iso,
|
||||
/// CSI Ps " q
|
||||
Dec,
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! APC command handler (ZIG/src/terminal/apc.zig). This should be hooked
|
||||
//! into a stream. The start/feed/end functions are meant to be called from
|
||||
//! the stream's apcStart, apcPut, and apcEnd functions, respectively.
|
||||
|
||||
use crate::kitty::graphics_command as kitty_gfx;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub enum State {
|
||||
/// We're not in the middle of an APC command yet.
|
||||
#[default]
|
||||
Inactive,
|
||||
|
||||
/// We got an unrecognized APC sequence or the APC sequence we
|
||||
/// recognized became invalid. We're just dropping bytes.
|
||||
Ignore,
|
||||
|
||||
/// We're waiting to identify the APC sequence. This is done by
|
||||
/// inspecting the first byte of the sequence.
|
||||
Identify,
|
||||
|
||||
/// Kitty graphics protocol
|
||||
Kitty(kitty_gfx::Parser),
|
||||
}
|
||||
|
||||
/// Possible APC commands.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Kitty(kitty_gfx::Command),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Handler {
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
self.state = State::Identify;
|
||||
}
|
||||
|
||||
pub fn feed(&mut self, byte: u8) {
|
||||
match &mut self.state {
|
||||
State::Inactive => unreachable!("APC feed while inactive"),
|
||||
|
||||
// We're ignoring this APC command, likely because we don't
|
||||
// recognize it so there is no need to store the data in memory.
|
||||
State::Ignore => {}
|
||||
|
||||
// We identify the APC command by the first byte.
|
||||
State::Identify => {
|
||||
self.state = match byte {
|
||||
// Kitty graphics protocol
|
||||
b'G' => State::Kitty(kitty_gfx::Parser::new()),
|
||||
|
||||
// Unknown
|
||||
_ => State::Ignore,
|
||||
};
|
||||
}
|
||||
|
||||
State::Kitty(p) => {
|
||||
if let Err(err) = p.feed(byte) {
|
||||
log::warn!("kitty graphics protocol error: {err}");
|
||||
self.state = State::Ignore;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end(&mut self) -> Option<Command> {
|
||||
let state = std::mem::take(&mut self.state);
|
||||
match state {
|
||||
State::Inactive => unreachable!("APC end while inactive"),
|
||||
State::Ignore | State::Identify => None,
|
||||
State::Kitty(mut p) => match p.complete() {
|
||||
Ok(command) => Some(Command::Kitty(command)),
|
||||
Err(err) => {
|
||||
log::warn!("kitty graphics protocol error: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> Option<Command> {
|
||||
let mut h = Handler::new();
|
||||
h.start();
|
||||
for c in input.bytes() {
|
||||
h.feed(c);
|
||||
}
|
||||
h.end()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_apc_command() {
|
||||
assert!(run("Xabcdef1234").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_kitty_command() {
|
||||
assert!(run("Gabcdef1234").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_command_with_overflow_u32() {
|
||||
assert!(run("Ga=p,i=10000000000").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_command_with_overflow_i32() {
|
||||
assert!(run("Ga=p,i=1,z=-9999999999").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_kitty_command() {
|
||||
let cmd = run("Gf=24,s=10,v=20,hello=world").unwrap();
|
||||
assert!(matches!(cmd, Command::Kitty(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_resets_to_inactive() {
|
||||
let mut h = Handler::new();
|
||||
h.start();
|
||||
h.feed(b'X');
|
||||
assert!(h.end().is_none());
|
||||
assert!(matches!(h.state, State::Inactive));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Character sets (ZIG/src/terminal/charsets.zig).
|
||||
|
||||
/// The available charset slots for a terminal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Slots {
|
||||
G0,
|
||||
G1,
|
||||
G2,
|
||||
G3,
|
||||
}
|
||||
|
||||
/// The name of the active slots.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ActiveSlot {
|
||||
GL,
|
||||
GR,
|
||||
}
|
||||
|
||||
/// The list of supported character sets and their associated tables.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Charset {
|
||||
Utf8,
|
||||
Ascii,
|
||||
British,
|
||||
DecSpecial,
|
||||
}
|
||||
|
||||
/// Our table length is 256 so we can contain all ASCII chars.
|
||||
pub const TABLE_LEN: usize = 256;
|
||||
|
||||
/// The table for the given charset. The returned table is guaranteed to be
|
||||
/// 256 entries that can be used to map ASCII to the given charset.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics for `Charset::Utf8`: utf8 is not a table, callers should
|
||||
/// double-check if the charset is utf8 and NOT use tables.
|
||||
pub fn table(set: Charset) -> &'static [u16; TABLE_LEN] {
|
||||
match set {
|
||||
Charset::British => &BRITISH,
|
||||
Charset::DecSpecial => &DEC_SPECIAL,
|
||||
Charset::Utf8 => unreachable!("utf8 has no table"),
|
||||
Charset::Ascii => &ASCII,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a table that maps ASCII to ASCII as a getting started point.
|
||||
const fn init_table() -> [u16; TABLE_LEN] {
|
||||
let mut result = [0u16; TABLE_LEN];
|
||||
let mut i = 0;
|
||||
while i < TABLE_LEN {
|
||||
result[i] = i as u16;
|
||||
i += 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Just a basic c => c ascii table
|
||||
static ASCII: [u16; TABLE_LEN] = init_table();
|
||||
|
||||
/// https://vt100.net/docs/vt220-rm/chapter2.html
|
||||
static BRITISH: [u16; TABLE_LEN] = {
|
||||
let mut t = init_table();
|
||||
t[0x23] = 0x00a3;
|
||||
t
|
||||
};
|
||||
|
||||
/// https://en.wikipedia.org/wiki/DEC_Special_Graphics
|
||||
static DEC_SPECIAL: [u16; TABLE_LEN] = {
|
||||
let mut t = init_table();
|
||||
t[0x60] = 0x25C6;
|
||||
t[0x61] = 0x2592;
|
||||
t[0x62] = 0x2409;
|
||||
t[0x63] = 0x240C;
|
||||
t[0x64] = 0x240D;
|
||||
t[0x65] = 0x240A;
|
||||
t[0x66] = 0x00B0;
|
||||
t[0x67] = 0x00B1;
|
||||
t[0x68] = 0x2424;
|
||||
t[0x69] = 0x240B;
|
||||
t[0x6a] = 0x2518;
|
||||
t[0x6b] = 0x2510;
|
||||
t[0x6c] = 0x250C;
|
||||
t[0x6d] = 0x2514;
|
||||
t[0x6e] = 0x253C;
|
||||
t[0x6f] = 0x23BA;
|
||||
t[0x70] = 0x23BB;
|
||||
t[0x71] = 0x2500;
|
||||
t[0x72] = 0x23BC;
|
||||
t[0x73] = 0x23BD;
|
||||
t[0x74] = 0x251C;
|
||||
t[0x75] = 0x2524;
|
||||
t[0x76] = 0x2534;
|
||||
t[0x77] = 0x252C;
|
||||
t[0x78] = 0x2502;
|
||||
t[0x79] = 0x2264;
|
||||
t[0x7a] = 0x2265;
|
||||
t[0x7b] = 0x03C0;
|
||||
t[0x7c] = 0x2260;
|
||||
t[0x7d] = 0x00A3;
|
||||
t[0x7e] = 0x00B7;
|
||||
t
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_lengths() {
|
||||
for set in [Charset::Ascii, Charset::British, Charset::DecSpecial] {
|
||||
assert_eq!(table(set).len(), 256);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_contents() {
|
||||
assert_eq!(table(Charset::British)[0x23], 0xa3);
|
||||
assert_eq!(table(Charset::Ascii)[0x41], 0x41);
|
||||
assert_eq!(table(Charset::DecSpecial)[0x71], 0x2500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1220 @@
|
||||
//! Terminal colors (ZIG/src/terminal/color.zig).
|
||||
|
||||
use crate::x11_color;
|
||||
|
||||
/// The default palette.
|
||||
pub const DEFAULT: Palette = default_palette();
|
||||
|
||||
const fn default_palette() -> Palette {
|
||||
let mut result = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||||
|
||||
// Named values
|
||||
let mut i: usize = 0;
|
||||
while i < 16 {
|
||||
result[i] = match Name(i as u8).default() {
|
||||
Ok(c) => c,
|
||||
Err(_) => unreachable!(),
|
||||
};
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Cube
|
||||
assert!(i == 16);
|
||||
let mut r: u8 = 0;
|
||||
while r < 6 {
|
||||
let mut g: u8 = 0;
|
||||
while g < 6 {
|
||||
let mut b: u8 = 0;
|
||||
while b < 6 {
|
||||
result[i] = Rgb {
|
||||
r: if r == 0 { 0 } else { r * 40 + 55 },
|
||||
g: if g == 0 { 0 } else { g * 40 + 55 },
|
||||
b: if b == 0 { 0 } else { b * 40 + 55 },
|
||||
};
|
||||
i += 1;
|
||||
b += 1;
|
||||
}
|
||||
g += 1;
|
||||
}
|
||||
r += 1;
|
||||
}
|
||||
|
||||
// Gray ramp
|
||||
assert!(i == 232);
|
||||
while i < 256 {
|
||||
let value = ((i as u8 - 232) * 10) + 8;
|
||||
result[i] = Rgb {
|
||||
r: value,
|
||||
g: value,
|
||||
b: value,
|
||||
};
|
||||
i += 1;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Palette is the 256 color palette.
|
||||
pub type Palette = [Rgb; 256];
|
||||
|
||||
/// Mask that can be used to set which palette indexes were set.
|
||||
/// (Zig `std.StaticBitSet(256)`.)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct PaletteMask([u64; 4]);
|
||||
|
||||
impl PaletteMask {
|
||||
pub const fn init_empty() -> Self {
|
||||
Self([0; 4])
|
||||
}
|
||||
|
||||
pub fn is_set(&self, idx: usize) -> bool {
|
||||
self.0[idx / 64] & (1 << (idx % 64)) != 0
|
||||
}
|
||||
|
||||
pub fn set(&mut self, idx: usize) {
|
||||
self.0[idx / 64] |= 1 << (idx % 64);
|
||||
}
|
||||
|
||||
pub fn unset(&mut self, idx: usize) {
|
||||
self.0[idx / 64] &= !(1 << (idx % 64));
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.0.iter().map(|w| w.count_ones() as usize).sum()
|
||||
}
|
||||
|
||||
/// Iterate over set indexes in ascending order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
(0..256).filter(|&i| self.is_set(i))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the 256-color palette from the user's base16 theme colors,
|
||||
/// terminal background, and terminal foreground.
|
||||
///
|
||||
/// Motivation: The default 256-color palette uses fixed, fully-saturated
|
||||
/// colors that clash with custom base16 themes, have poor readability in
|
||||
/// dark shades (the first non-black shade jumps to 37% intensity instead
|
||||
/// of the expected 20%), and exhibit inconsistent perceived brightness
|
||||
/// across hues of the same shade (e.g., blue appears darker than green).
|
||||
/// By generating the extended palette from the user's chosen colors,
|
||||
/// programs can use the richer 256-color range without requiring their
|
||||
/// own theme configuration, and light/dark switching works automatically.
|
||||
///
|
||||
/// The 216-color cube (indices 16–231) is built via trilinear
|
||||
/// interpolation in CIELAB space over the 8 base colors. The base16
|
||||
/// palette maps to the 8 corners of a 6×6×6 RGB cube as follows:
|
||||
///
|
||||
/// R=0 edge: bg → base[1] (red)
|
||||
/// R=5 edge: base[6] → fg
|
||||
/// G=0 edge: bg/base[6] (via R) → base[2]/base[4] (green/blue via R)
|
||||
/// G=5 edge: base[1]/fg (via R) → base[3]/base[5] (yellow/magenta via R)
|
||||
///
|
||||
/// For each R slice, four corner colors (c0–c3) are interpolated along
|
||||
/// the R axis, then for each G row two edge colors (c4–c5) are
|
||||
/// interpolated along G, and finally each B cell is interpolated along B
|
||||
/// to produce the final color. CIELAB interpolation ensures perceptually
|
||||
/// uniform brightness transitions across different hues.
|
||||
///
|
||||
/// The 24-step grayscale ramp (indices 232–255) is a simple linear
|
||||
/// interpolation in CIELAB from the background to the foreground,
|
||||
/// excluding pure black and white (available in the cube at (0,0,0)
|
||||
/// and (5,5,5)). The interpolation parameter runs from 1/25 to 24/25.
|
||||
///
|
||||
/// Fill `skip` with user-defined color indexes to avoid replacing them.
|
||||
pub fn generate_256_color(
|
||||
base: Palette,
|
||||
skip: PaletteMask,
|
||||
bg: Rgb,
|
||||
fg: Rgb,
|
||||
harmonious: bool,
|
||||
) -> Palette {
|
||||
// Convert the background, foreground, and 8 base theme colors into
|
||||
// CIELAB space so that all interpolation is perceptually uniform.
|
||||
let base8_lab: [Lab; 8] = {
|
||||
let mut base8 = [
|
||||
Lab::from_rgb(bg),
|
||||
Lab::from_rgb(base[1]),
|
||||
Lab::from_rgb(base[2]),
|
||||
Lab::from_rgb(base[3]),
|
||||
Lab::from_rgb(base[4]),
|
||||
Lab::from_rgb(base[5]),
|
||||
Lab::from_rgb(base[6]),
|
||||
Lab::from_rgb(fg),
|
||||
];
|
||||
|
||||
// For light themes (where the foreground is darker than the
|
||||
// background), the cube's dark-to-light orientation is inverted
|
||||
// relative to the base color mapping. When `harmonious` is false,
|
||||
// swap bg and fg so the cube still runs from black (16) to
|
||||
// white (231).
|
||||
let is_light_theme = base8[7].l < base8[0].l;
|
||||
let invert = is_light_theme && !harmonious;
|
||||
if invert {
|
||||
base8.swap(0, 7);
|
||||
}
|
||||
|
||||
base8
|
||||
};
|
||||
|
||||
// Start from the base palette so indices 0-15 are preserved as-is.
|
||||
let mut result = base;
|
||||
|
||||
// Build the 216-color cube (indices 16-231) via trilinear interpolation
|
||||
// in CIELAB. The three nested loops correspond to the R, G, and B axes
|
||||
// of a 6x6x6 cube.
|
||||
let mut idx: usize = 16;
|
||||
for ri in 0..6 {
|
||||
// R-axis corners: blend base colors along the red dimension.
|
||||
let tr = ri as f32 / 5.0;
|
||||
let c0 = Lab::lerp(tr, base8_lab[0], base8_lab[1]);
|
||||
let c1 = Lab::lerp(tr, base8_lab[2], base8_lab[3]);
|
||||
let c2 = Lab::lerp(tr, base8_lab[4], base8_lab[5]);
|
||||
let c3 = Lab::lerp(tr, base8_lab[6], base8_lab[7]);
|
||||
for gi in 0..6 {
|
||||
// G-axis edges: blend the R-interpolated corners along green.
|
||||
let tg = gi as f32 / 5.0;
|
||||
let c4 = Lab::lerp(tg, c0, c1);
|
||||
let c5 = Lab::lerp(tg, c2, c3);
|
||||
for bi in 0..6 {
|
||||
// B-axis: final interpolation along blue, then convert back to RGB.
|
||||
if !skip.is_set(idx) {
|
||||
let c6 = Lab::lerp(bi as f32 / 5.0, c4, c5);
|
||||
result[idx] = c6.to_rgb();
|
||||
}
|
||||
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the 24-step grayscale ramp (indices 232-255) by linearly
|
||||
// interpolating in CIELAB from background to foreground. The parameter
|
||||
// runs from 1/25 to 24/25, excluding the endpoints which are already
|
||||
// available in the cube at (0,0,0) and (5,5,5).
|
||||
for i in 0..24 {
|
||||
let t = (i + 1) as f32 / 25.0;
|
||||
if !skip.is_set(idx) {
|
||||
let c = Lab::lerp(t, base8_lab[0], base8_lab[7]);
|
||||
result[idx] = c.to_rgb();
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// A palette that can have its colors changed and reset. Purposely built
|
||||
/// for terminal color operations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DynamicPalette {
|
||||
/// The current palette including any user modifications.
|
||||
pub current: Palette,
|
||||
|
||||
/// The original/default palette values.
|
||||
pub original: Palette,
|
||||
|
||||
/// A bitset where each bit represents whether the corresponding
|
||||
/// palette index has been modified from its default value.
|
||||
pub mask: PaletteMask,
|
||||
}
|
||||
|
||||
impl DynamicPalette {
|
||||
pub const DEFAULT: DynamicPalette = DynamicPalette::init(DEFAULT);
|
||||
|
||||
/// Initialize a dynamic palette with a default palette.
|
||||
pub const fn init(def: Palette) -> DynamicPalette {
|
||||
DynamicPalette {
|
||||
current: def,
|
||||
original: def,
|
||||
mask: PaletteMask::init_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a custom color at the given palette index.
|
||||
pub fn set(&mut self, idx: u8, color: Rgb) {
|
||||
self.current[idx as usize] = color;
|
||||
self.mask.set(idx as usize);
|
||||
}
|
||||
|
||||
/// Reset the color at the given palette index to its original value.
|
||||
pub fn reset(&mut self, idx: u8) {
|
||||
self.current[idx as usize] = self.original[idx as usize];
|
||||
self.mask.unset(idx as usize);
|
||||
}
|
||||
|
||||
/// Reset all colors to their original values.
|
||||
pub fn reset_all(&mut self) {
|
||||
*self = Self::init(self.original);
|
||||
}
|
||||
|
||||
/// Change the default palette, but preserve the changed values.
|
||||
pub fn change_default(&mut self, def: Palette) {
|
||||
self.original = def;
|
||||
|
||||
// Fast path, the palette is usually not changed.
|
||||
if self.mask.count() == 0 {
|
||||
self.current = self.original;
|
||||
return;
|
||||
}
|
||||
|
||||
// There are usually less set than unset, so iterate over the changed
|
||||
// values and override them.
|
||||
let mut current = def;
|
||||
for idx in self.mask.iter() {
|
||||
current[idx] = self.current[idx];
|
||||
}
|
||||
self.current = current;
|
||||
}
|
||||
}
|
||||
|
||||
/// RGB value that can be changed and reset. This can also be totally unset
|
||||
/// in every way, in which case the caller can determine their own ultimate
|
||||
/// default.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct DynamicRgb {
|
||||
pub override_: Option<Rgb>,
|
||||
pub default: Option<Rgb>,
|
||||
}
|
||||
|
||||
impl DynamicRgb {
|
||||
pub const UNSET: DynamicRgb = DynamicRgb {
|
||||
override_: None,
|
||||
default: None,
|
||||
};
|
||||
|
||||
pub fn init(def: Rgb) -> DynamicRgb {
|
||||
DynamicRgb {
|
||||
override_: None,
|
||||
default: Some(def),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self) -> Option<Rgb> {
|
||||
self.override_.or(self.default)
|
||||
}
|
||||
|
||||
pub fn set(&mut self, color: Rgb) {
|
||||
self.override_ = Some(color);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.override_ = self.default;
|
||||
}
|
||||
}
|
||||
|
||||
/// Color names in the standard 8 or 16 color palette. Remainders (any other
|
||||
/// `u8`) are valid unnamed values in the 256 color palette, so this is a
|
||||
/// newtype rather than an enum.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Name(pub u8);
|
||||
|
||||
/// Error returned by [`Name::default`] for unnamed palette values.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NoDefaultValue;
|
||||
|
||||
impl Name {
|
||||
pub const BLACK: Name = Name(0);
|
||||
pub const RED: Name = Name(1);
|
||||
pub const GREEN: Name = Name(2);
|
||||
pub const YELLOW: Name = Name(3);
|
||||
pub const BLUE: Name = Name(4);
|
||||
pub const MAGENTA: Name = Name(5);
|
||||
pub const CYAN: Name = Name(6);
|
||||
pub const WHITE: Name = Name(7);
|
||||
|
||||
pub const BRIGHT_BLACK: Name = Name(8);
|
||||
pub const BRIGHT_RED: Name = Name(9);
|
||||
pub const BRIGHT_GREEN: Name = Name(10);
|
||||
pub const BRIGHT_YELLOW: Name = Name(11);
|
||||
pub const BRIGHT_BLUE: Name = Name(12);
|
||||
pub const BRIGHT_MAGENTA: Name = Name(13);
|
||||
pub const BRIGHT_CYAN: Name = Name(14);
|
||||
pub const BRIGHT_WHITE: Name = Name(15);
|
||||
|
||||
/// Default colors for tagged values.
|
||||
pub const fn default(self) -> Result<Rgb, NoDefaultValue> {
|
||||
let (r, g, b) = match self.0 {
|
||||
0 => (0x1D, 0x1F, 0x21),
|
||||
1 => (0xCC, 0x66, 0x66),
|
||||
2 => (0xB5, 0xBD, 0x68),
|
||||
3 => (0xF0, 0xC6, 0x74),
|
||||
4 => (0x81, 0xA2, 0xBE),
|
||||
5 => (0xB2, 0x94, 0xBB),
|
||||
6 => (0x8A, 0xBE, 0xB7),
|
||||
7 => (0xC5, 0xC8, 0xC6),
|
||||
|
||||
8 => (0x66, 0x66, 0x66),
|
||||
9 => (0xD5, 0x4E, 0x53),
|
||||
10 => (0xB9, 0xCA, 0x4A),
|
||||
11 => (0xE7, 0xC5, 0x47),
|
||||
12 => (0x7A, 0xA6, 0xDA),
|
||||
13 => (0xC3, 0x97, 0xD8),
|
||||
14 => (0x70, 0xC0, 0xB1),
|
||||
15 => (0xEA, 0xEA, 0xEA),
|
||||
|
||||
_ => return Err(NoDefaultValue),
|
||||
};
|
||||
Ok(Rgb { r, g, b })
|
||||
}
|
||||
}
|
||||
|
||||
/// The "special colors" as denoted by xterm. These can be set via
|
||||
/// OSC 5 or via OSC 4 by adding the palette length to it.
|
||||
///
|
||||
/// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum Special {
|
||||
Bold = 0,
|
||||
Underline = 1,
|
||||
Blink = 2,
|
||||
Reverse = 3,
|
||||
Italic = 4,
|
||||
}
|
||||
|
||||
impl Special {
|
||||
pub fn osc4(self) -> u16 {
|
||||
// "The special colors can also be set by adding the maximum
|
||||
// number of colors (e.g., 88 or 256) to these codes in an
|
||||
// OSC 4 control" - xterm ctlseqs
|
||||
self as u16 + 256
|
||||
}
|
||||
|
||||
/// Convert from the raw value (Zig `@enumFromInt` / `intToEnum`).
|
||||
pub fn from_u8(v: u8) -> Option<Special> {
|
||||
Some(match v {
|
||||
0 => Special::Bold,
|
||||
1 => Special::Underline,
|
||||
2 => Special::Blink,
|
||||
3 => Special::Reverse,
|
||||
4 => Special::Italic,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The "dynamic colors" as denoted by xterm. These can be set via
|
||||
/// OSC 10 through 19.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum Dynamic {
|
||||
Foreground = 10,
|
||||
Background = 11,
|
||||
Cursor = 12,
|
||||
PointerForeground = 13,
|
||||
PointerBackground = 14,
|
||||
TektronixForeground = 15,
|
||||
TektronixBackground = 16,
|
||||
HighlightBackground = 17,
|
||||
TektronixCursor = 18,
|
||||
HighlightForeground = 19,
|
||||
}
|
||||
|
||||
impl Dynamic {
|
||||
/// Convert from the raw value (10..=19).
|
||||
pub fn from_u8(v: u8) -> Option<Dynamic> {
|
||||
Some(match v {
|
||||
10 => Dynamic::Foreground,
|
||||
11 => Dynamic::Background,
|
||||
12 => Dynamic::Cursor,
|
||||
13 => Dynamic::PointerForeground,
|
||||
14 => Dynamic::PointerBackground,
|
||||
15 => Dynamic::TektronixForeground,
|
||||
16 => Dynamic::TektronixBackground,
|
||||
17 => Dynamic::HighlightBackground,
|
||||
18 => Dynamic::TektronixCursor,
|
||||
19 => Dynamic::HighlightForeground,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The next dynamic color sequentially. This is required because
|
||||
/// specifying colors sequentially without their index will automatically
|
||||
/// use the next dynamic color.
|
||||
///
|
||||
/// "Each successive parameter changes the next color in the list. The
|
||||
/// value of Ps tells the starting point in the list."
|
||||
pub fn next(self) -> Option<Dynamic> {
|
||||
Dynamic::from_u8(self as u8 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when parsing a color specification fails.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InvalidFormat;
|
||||
|
||||
impl std::fmt::Display for InvalidFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("invalid color format")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidFormat {}
|
||||
|
||||
/// RGB
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct Rgb {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
/// Alias matching the Zig name.
|
||||
pub type RGB = Rgb;
|
||||
|
||||
/// Zig `std.fmt.parseUnsigned(u16, value, 16)` semantics (optional leading
|
||||
/// `+`; a leading `-` is only valid if the value is zero; no underscores).
|
||||
fn parse_unsigned_hex_u16(value: &[u8]) -> Option<u16> {
|
||||
let (neg, digits) = match value.first()? {
|
||||
b'+' => (false, &value[1..]),
|
||||
b'-' => (true, &value[1..]),
|
||||
_ => (false, value),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for &c in digits {
|
||||
let d = (c as char).to_digit(16)?;
|
||||
acc = acc * 16 + d;
|
||||
if acc > u16::MAX as u32 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if neg && acc != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(acc as u16)
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub fn eql(self, other: Rgb) -> bool {
|
||||
self.r == other.r && self.g == other.g && self.b == other.b
|
||||
}
|
||||
|
||||
/// Calculates the contrast ratio between two colors. The contrast
|
||||
/// ration is a value between 1 and 21 where 1 is the lowest contrast
|
||||
/// and 21 is the highest contrast.
|
||||
///
|
||||
/// https://www.w3.org/TR/WCAG20/#contrast-ratiodef
|
||||
pub fn contrast(self, other: Rgb) -> f64 {
|
||||
// pair[0] = lighter, pair[1] = darker
|
||||
let self_lum = self.luminance();
|
||||
let other_lum = other.luminance();
|
||||
let pair = if self_lum > other_lum {
|
||||
[self_lum, other_lum]
|
||||
} else {
|
||||
[other_lum, self_lum]
|
||||
};
|
||||
|
||||
(pair[0] + 0.05) / (pair[1] + 0.05)
|
||||
}
|
||||
|
||||
/// Calculates luminance based on the W3C formula. This returns a
|
||||
/// normalized value between 0 and 1 where 0 is black and 1 is white.
|
||||
///
|
||||
/// https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
||||
pub fn luminance(self) -> f64 {
|
||||
let r_lum = component_luminance(self.r);
|
||||
let g_lum = component_luminance(self.g);
|
||||
let b_lum = component_luminance(self.b);
|
||||
0.2126 * r_lum + 0.7152 * g_lum + 0.0722 * b_lum
|
||||
}
|
||||
|
||||
/// Calculates "perceived luminance" which is better for determining
|
||||
/// light vs dark.
|
||||
///
|
||||
/// Source: https://www.w3.org/TR/AERT/#color-contrast
|
||||
pub fn perceived_luminance(self) -> f64 {
|
||||
let r = self.r as f64;
|
||||
let g = self.g as f64;
|
||||
let b = self.b as f64;
|
||||
0.299 * (r / 255.0) + 0.587 * (g / 255.0) + 0.114 * (b / 255.0)
|
||||
}
|
||||
|
||||
/// Parse a color from a floating point intensity value.
|
||||
///
|
||||
/// The value should be between 0.0 and 1.0, inclusive.
|
||||
fn from_intensity(value: &[u8]) -> Result<u8, InvalidFormat> {
|
||||
let s = std::str::from_utf8(value).map_err(|_| InvalidFormat)?;
|
||||
let i: f64 = s.parse().map_err(|_| InvalidFormat)?;
|
||||
if !(0.0..=1.0).contains(&i) && !i.is_nan() {
|
||||
return Err(InvalidFormat);
|
||||
}
|
||||
|
||||
Ok((i * 255.0) as u8)
|
||||
}
|
||||
|
||||
/// Parse a color from a string of hexadecimal digits
|
||||
///
|
||||
/// The string can contain 1, 2, 3, or 4 characters and represents the color
|
||||
/// value scaled in 4, 8, 12, or 16 bits, respectively.
|
||||
fn from_hex(value: &[u8]) -> Result<u8, InvalidFormat> {
|
||||
if value.is_empty() || value.len() > 4 {
|
||||
return Err(InvalidFormat);
|
||||
}
|
||||
|
||||
let color = parse_unsigned_hex_u16(value).ok_or(InvalidFormat)?;
|
||||
|
||||
let divisor: usize = match value.len() {
|
||||
1 => 0xf,
|
||||
2 => 0xff,
|
||||
3 => 0xfff,
|
||||
4 => 0xffff,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
Ok((color as usize * 255 / divisor) as u8)
|
||||
}
|
||||
|
||||
/// Parse a color specification.
|
||||
///
|
||||
/// Any of the following forms are accepted:
|
||||
///
|
||||
/// 1. rgb:<red>/<green>/<blue>
|
||||
///
|
||||
/// <red>, <green>, <blue> := h | hh | hhh | hhhh
|
||||
///
|
||||
/// where `h` is a single hexadecimal digit.
|
||||
///
|
||||
/// 2. rgbi:<red>/<green>/<blue>
|
||||
///
|
||||
/// where <red>, <green>, and <blue> are floating point values between
|
||||
/// 0.0 and 1.0 (inclusive).
|
||||
///
|
||||
/// 3. #rgb, #rrggbb, #rrrgggbbb #rrrrggggbbbb
|
||||
///
|
||||
/// where `r`, `g`, and `b` are a single hexadecimal digit.
|
||||
/// These specify a color with 4, 8, 12, and 16 bits of precision
|
||||
/// per color channel.
|
||||
pub fn parse(value: &str) -> Result<Rgb, InvalidFormat> {
|
||||
Self::parse_bytes(value.as_bytes())
|
||||
}
|
||||
|
||||
/// Byte-slice variant of [`Rgb::parse`].
|
||||
pub fn parse_bytes(value: &[u8]) -> Result<Rgb, InvalidFormat> {
|
||||
if value.is_empty() {
|
||||
return Err(InvalidFormat);
|
||||
}
|
||||
|
||||
if value[0] == b'#' {
|
||||
return match value.len() {
|
||||
4 => Ok(Rgb {
|
||||
r: Rgb::from_hex(&value[1..2])?,
|
||||
g: Rgb::from_hex(&value[2..3])?,
|
||||
b: Rgb::from_hex(&value[3..4])?,
|
||||
}),
|
||||
7 => Ok(Rgb {
|
||||
r: Rgb::from_hex(&value[1..3])?,
|
||||
g: Rgb::from_hex(&value[3..5])?,
|
||||
b: Rgb::from_hex(&value[5..7])?,
|
||||
}),
|
||||
10 => Ok(Rgb {
|
||||
r: Rgb::from_hex(&value[1..4])?,
|
||||
g: Rgb::from_hex(&value[4..7])?,
|
||||
b: Rgb::from_hex(&value[7..10])?,
|
||||
}),
|
||||
13 => Ok(Rgb {
|
||||
r: Rgb::from_hex(&value[1..5])?,
|
||||
g: Rgb::from_hex(&value[5..9])?,
|
||||
b: Rgb::from_hex(&value[9..13])?,
|
||||
}),
|
||||
_ => Err(InvalidFormat),
|
||||
};
|
||||
}
|
||||
|
||||
// Check for X11 named colors. We allow whitespace around the edges
|
||||
// of the color because Kitty allows whitespace. This is not part of
|
||||
// any spec I could find.
|
||||
let trimmed = {
|
||||
let mut s = value;
|
||||
while let [b' ', rest @ ..] = s {
|
||||
s = rest;
|
||||
}
|
||||
while let [rest @ .., b' '] = s {
|
||||
s = rest;
|
||||
}
|
||||
s
|
||||
};
|
||||
if let Some(rgb) = x11_color::get_bytes(trimmed) {
|
||||
return Ok(rgb);
|
||||
}
|
||||
|
||||
if value.len() < "rgb:a/a/a".len() || &value[0..3] != b"rgb" {
|
||||
return Err(InvalidFormat);
|
||||
}
|
||||
|
||||
let mut i: usize = 3;
|
||||
|
||||
let use_intensity = if value[i] == b'i' {
|
||||
i += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if value[i] != b':' {
|
||||
return Err(InvalidFormat);
|
||||
}
|
||||
|
||||
i += 1;
|
||||
|
||||
let component = |slice: &[u8]| {
|
||||
if use_intensity {
|
||||
Rgb::from_intensity(slice)
|
||||
} else {
|
||||
Rgb::from_hex(slice)
|
||||
}
|
||||
};
|
||||
|
||||
let r = {
|
||||
let end = value[i..]
|
||||
.iter()
|
||||
.position(|&c| c == b'/')
|
||||
.map(|p| p + i)
|
||||
.ok_or(InvalidFormat)?;
|
||||
let slice = &value[i..end];
|
||||
i += slice.len() + 1;
|
||||
component(slice)?
|
||||
};
|
||||
|
||||
let g = {
|
||||
let end = value[i..]
|
||||
.iter()
|
||||
.position(|&c| c == b'/')
|
||||
.map(|p| p + i)
|
||||
.ok_or(InvalidFormat)?;
|
||||
let slice = &value[i..end];
|
||||
i += slice.len() + 1;
|
||||
component(slice)?
|
||||
};
|
||||
|
||||
let b = component(&value[i..])?;
|
||||
|
||||
Ok(Rgb { r, g, b })
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates single-component luminance based on the W3C formula.
|
||||
///
|
||||
/// Expects sRGB color space which at the time of writing we don't
|
||||
/// generally use but it's a good enough approximation until we fix that.
|
||||
/// https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
||||
fn component_luminance(c: u8) -> f64 {
|
||||
let normalized = c as f64 / 255.0;
|
||||
if normalized <= 0.03928 {
|
||||
return normalized / 12.92;
|
||||
}
|
||||
((normalized + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
|
||||
/// LAB color space
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Lab {
|
||||
l: f32,
|
||||
a: f32,
|
||||
b: f32,
|
||||
}
|
||||
|
||||
#[allow(clippy::excessive_precision)]
|
||||
impl Lab {
|
||||
/// RGB to LAB
|
||||
fn from_rgb(rgb: Rgb) -> Lab {
|
||||
// Step 1: Normalize sRGB channels from [0, 255] to [0.0, 1.0].
|
||||
let mut r = rgb.r as f32 / 255.0;
|
||||
let mut g = rgb.g as f32 / 255.0;
|
||||
let mut b = rgb.b as f32 / 255.0;
|
||||
|
||||
// Step 2: Apply the inverse sRGB companding (gamma correction) to
|
||||
// convert from sRGB to linear RGB.
|
||||
r = if r > 0.04045 {
|
||||
((r + 0.055) / 1.055).powf(2.4)
|
||||
} else {
|
||||
r / 12.92
|
||||
};
|
||||
g = if g > 0.04045 {
|
||||
((g + 0.055) / 1.055).powf(2.4)
|
||||
} else {
|
||||
g / 12.92
|
||||
};
|
||||
b = if b > 0.04045 {
|
||||
((b + 0.055) / 1.055).powf(2.4)
|
||||
} else {
|
||||
b / 12.92
|
||||
};
|
||||
|
||||
// Step 3: Convert linear RGB to CIE XYZ using the sRGB to XYZ
|
||||
// transformation matrix (D65 illuminant). The X and Z values are
|
||||
// normalized by the D65 white point reference values (Xn=0.95047,
|
||||
// Zn=1.08883; Yn=1.0 is implicit).
|
||||
let mut x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047;
|
||||
let mut y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
|
||||
let mut z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883;
|
||||
|
||||
// Step 4: Apply the CIE f(t) nonlinear transform to each XYZ
|
||||
// component.
|
||||
x = if x > 0.008856 {
|
||||
x.cbrt()
|
||||
} else {
|
||||
7.787 * x + 16.0 / 116.0
|
||||
};
|
||||
y = if y > 0.008856 {
|
||||
y.cbrt()
|
||||
} else {
|
||||
7.787 * y + 16.0 / 116.0
|
||||
};
|
||||
z = if z > 0.008856 {
|
||||
z.cbrt()
|
||||
} else {
|
||||
7.787 * z + 16.0 / 116.0
|
||||
};
|
||||
|
||||
// Step 5: Compute the final CIELAB values from the transformed XYZ.
|
||||
Lab {
|
||||
l: 116.0 * y - 16.0,
|
||||
a: 500.0 * (x - y),
|
||||
b: 200.0 * (y - z),
|
||||
}
|
||||
}
|
||||
|
||||
/// LAB to RGB
|
||||
fn to_rgb(self) -> Rgb {
|
||||
// Step 1: Recover the intermediate f(Y), f(X), f(Z) values from
|
||||
// L*a*b* by inverting the CIELAB formulas.
|
||||
let y = (self.l + 16.0) / 116.0;
|
||||
let x = self.a / 500.0 + y;
|
||||
let z = y - self.b / 200.0;
|
||||
|
||||
// Step 2: Apply the inverse CIE f(t) transform to get back to XYZ.
|
||||
let x3 = x * x * x;
|
||||
let y3 = y * y * y;
|
||||
let z3 = z * z * z;
|
||||
let xf = (if x3 > 0.008856 {
|
||||
x3
|
||||
} else {
|
||||
(x - 16.0 / 116.0) / 7.787
|
||||
}) * 0.95047;
|
||||
let yf = if y3 > 0.008856 {
|
||||
y3
|
||||
} else {
|
||||
(y - 16.0 / 116.0) / 7.787
|
||||
};
|
||||
let zf = (if z3 > 0.008856 {
|
||||
z3
|
||||
} else {
|
||||
(z - 16.0 / 116.0) / 7.787
|
||||
}) * 1.08883;
|
||||
|
||||
// Step 3: Convert CIE XYZ back to linear RGB.
|
||||
let mut r = xf * 3.2404542 - yf * 1.5371385 - zf * 0.4985314;
|
||||
let mut g = -xf * 0.9692660 + yf * 1.8760108 + zf * 0.0415560;
|
||||
let mut b = xf * 0.0556434 - yf * 0.2040259 + zf * 1.0572252;
|
||||
|
||||
// Step 4: Apply sRGB companding (gamma correction).
|
||||
r = if r > 0.0031308 {
|
||||
1.055 * r.powf(1.0 / 2.4) - 0.055
|
||||
} else {
|
||||
12.92 * r
|
||||
};
|
||||
g = if g > 0.0031308 {
|
||||
1.055 * g.powf(1.0 / 2.4) - 0.055
|
||||
} else {
|
||||
12.92 * g
|
||||
};
|
||||
b = if b > 0.0031308 {
|
||||
1.055 * b.powf(1.0 / 2.4) - 0.055
|
||||
} else {
|
||||
12.92 * b
|
||||
};
|
||||
|
||||
// Step 5: Clamp to [0.0, 1.0], scale to [0, 255], and round to
|
||||
// the nearest integer to produce the final 8-bit sRGB values.
|
||||
Rgb {
|
||||
r: (r.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
|
||||
g: (g.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
|
||||
b: (b.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Linearly interpolate between two LAB colors component-wise.
|
||||
/// `t` is the interpolation factor in [0, 1]: t=0 returns `a`,
|
||||
/// t=1 returns `b`, and values in between blend proportionally.
|
||||
fn lerp(t: f32, a: Lab, b: Lab) -> Lab {
|
||||
Lab {
|
||||
l: a.l + t * (b.l - a.l),
|
||||
a: a.a + t * (b.a - a.a),
|
||||
b: a.b + t * (b.b - a.b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
|
||||
Rgb { r, g, b }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_osc4() {
|
||||
assert_eq!(256, Special::Bold.osc4());
|
||||
assert_eq!(257, Special::Underline.osc4());
|
||||
assert_eq!(258, Special::Blink.osc4());
|
||||
assert_eq!(259, Special::Reverse.osc4());
|
||||
assert_eq!(260, Special::Italic.osc4());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_next() {
|
||||
use Dynamic::*;
|
||||
assert_eq!(Some(Background), Foreground.next());
|
||||
assert_eq!(Some(Cursor), Background.next());
|
||||
assert_eq!(Some(PointerForeground), Cursor.next());
|
||||
assert_eq!(Some(PointerBackground), PointerForeground.next());
|
||||
assert_eq!(Some(TektronixForeground), PointerBackground.next());
|
||||
assert_eq!(Some(TektronixBackground), TektronixForeground.next());
|
||||
assert_eq!(Some(HighlightBackground), TektronixBackground.next());
|
||||
assert_eq!(Some(TektronixCursor), HighlightBackground.next());
|
||||
assert_eq!(Some(HighlightForeground), TektronixCursor.next());
|
||||
assert_eq!(None, HighlightForeground.next());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_default() {
|
||||
for i in 0..16u8 {
|
||||
assert_eq!(Name(i).default().unwrap(), DEFAULT[i as usize]);
|
||||
}
|
||||
// Spot checks of the cube and ramp.
|
||||
assert_eq!(DEFAULT[16], rgb(0, 0, 0));
|
||||
assert_eq!(DEFAULT[17], rgb(0, 0, 95));
|
||||
assert_eq!(DEFAULT[231], rgb(255, 255, 255));
|
||||
assert_eq!(DEFAULT[232], rgb(8, 8, 8));
|
||||
assert_eq!(DEFAULT[255], rgb(238, 238, 238));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_parse() {
|
||||
assert_eq!(rgb(255, 0, 0), Rgb::parse("rgbi:1.0/0/0").unwrap());
|
||||
assert_eq!(rgb(127, 160, 0), Rgb::parse("rgb:7f/a0a0/0").unwrap());
|
||||
assert_eq!(rgb(255, 255, 255), Rgb::parse("rgb:f/ff/fff").unwrap());
|
||||
assert_eq!(rgb(255, 255, 255), Rgb::parse("#ffffff").unwrap());
|
||||
assert_eq!(rgb(255, 255, 255), Rgb::parse("#fff").unwrap());
|
||||
assert_eq!(rgb(255, 255, 255), Rgb::parse("#fffffffff").unwrap());
|
||||
assert_eq!(rgb(255, 255, 255), Rgb::parse("#ffffffffffff").unwrap());
|
||||
assert_eq!(rgb(255, 0, 16), Rgb::parse("#ff0010").unwrap());
|
||||
|
||||
assert_eq!(rgb(0, 0, 0), Rgb::parse("black").unwrap());
|
||||
assert_eq!(rgb(255, 0, 0), Rgb::parse("red").unwrap());
|
||||
assert_eq!(rgb(0, 255, 0), Rgb::parse("green").unwrap());
|
||||
assert_eq!(rgb(0, 0, 255), Rgb::parse("blue").unwrap());
|
||||
assert_eq!(rgb(255, 255, 255), Rgb::parse("white").unwrap());
|
||||
|
||||
assert_eq!(rgb(124, 252, 0), Rgb::parse("LawnGreen").unwrap());
|
||||
assert_eq!(rgb(0, 250, 154), Rgb::parse("medium spring green").unwrap());
|
||||
assert_eq!(rgb(34, 139, 34), Rgb::parse(" Forest Green ").unwrap());
|
||||
|
||||
// Invalid format
|
||||
for bad in [
|
||||
"rgb;",
|
||||
"rgb:",
|
||||
":a/a/a",
|
||||
"a/a/a",
|
||||
"rgb:a/a/a/",
|
||||
"rgb:00000///",
|
||||
"rgb:000/",
|
||||
"rgbi:a/a/a",
|
||||
"rgb:0.5/0.0/1.0",
|
||||
"rgb:not/hex/zz",
|
||||
"#",
|
||||
"#ff",
|
||||
"#ffff",
|
||||
"#fffff",
|
||||
"#gggggg",
|
||||
] {
|
||||
assert_eq!(Err(InvalidFormat), Rgb::parse(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_palette_init() {
|
||||
let p = DynamicPalette::init(DEFAULT);
|
||||
assert_eq!(DEFAULT, p.current);
|
||||
assert_eq!(DEFAULT, p.original);
|
||||
assert_eq!(0, p.mask.count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_palette_set() {
|
||||
let mut p = DynamicPalette::init(DEFAULT);
|
||||
let new_color = rgb(255, 0, 0);
|
||||
|
||||
p.set(0, new_color);
|
||||
assert_eq!(new_color, p.current[0]);
|
||||
assert!(p.mask.is_set(0));
|
||||
assert_eq!(1, p.mask.count());
|
||||
|
||||
assert_eq!(DEFAULT[0], p.original[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_palette_reset() {
|
||||
let mut p = DynamicPalette::init(DEFAULT);
|
||||
p.set(0, rgb(255, 0, 0));
|
||||
assert!(p.mask.is_set(0));
|
||||
|
||||
p.reset(0);
|
||||
assert_eq!(DEFAULT[0], p.current[0]);
|
||||
assert!(!p.mask.is_set(0));
|
||||
assert_eq!(0, p.mask.count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_palette_reset_all() {
|
||||
let mut p = DynamicPalette::init(DEFAULT);
|
||||
let c = rgb(255, 0, 0);
|
||||
p.set(0, c);
|
||||
p.set(5, c);
|
||||
p.set(10, c);
|
||||
assert_eq!(3, p.mask.count());
|
||||
|
||||
p.reset_all();
|
||||
assert_eq!(DEFAULT, p.current);
|
||||
assert_eq!(DEFAULT, p.original);
|
||||
assert_eq!(0, p.mask.count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_palette_change_default_with_no_changes() {
|
||||
let mut p = DynamicPalette::init(DEFAULT);
|
||||
let mut new_palette = DEFAULT;
|
||||
new_palette[0] = rgb(100, 100, 100);
|
||||
|
||||
p.change_default(new_palette);
|
||||
assert_eq!(new_palette, p.original);
|
||||
assert_eq!(new_palette, p.current);
|
||||
assert_eq!(0, p.mask.count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_palette_change_default_preserves_changes() {
|
||||
let mut p = DynamicPalette::init(DEFAULT);
|
||||
let custom = rgb(255, 0, 0);
|
||||
|
||||
p.set(5, custom);
|
||||
assert!(p.mask.is_set(5));
|
||||
|
||||
let mut new_palette = DEFAULT;
|
||||
new_palette[0] = rgb(100, 100, 100);
|
||||
new_palette[5] = rgb(50, 50, 50);
|
||||
|
||||
p.change_default(new_palette);
|
||||
|
||||
assert_eq!(new_palette, p.original);
|
||||
assert_eq!(new_palette[0], p.current[0]);
|
||||
assert_eq!(custom, p.current[5]);
|
||||
assert!(p.mask.is_set(5));
|
||||
assert_eq!(1, p.mask.count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_palette_change_default_with_multiple_changes() {
|
||||
let mut p = DynamicPalette::init(DEFAULT);
|
||||
let red = rgb(255, 0, 0);
|
||||
let green = rgb(0, 255, 0);
|
||||
let blue = rgb(0, 0, 255);
|
||||
|
||||
p.set(1, red);
|
||||
p.set(2, green);
|
||||
p.set(3, blue);
|
||||
|
||||
let mut new_palette = DEFAULT;
|
||||
new_palette[0] = rgb(50, 50, 50);
|
||||
new_palette[1] = rgb(60, 60, 60);
|
||||
|
||||
p.change_default(new_palette);
|
||||
|
||||
assert_eq!(new_palette[0], p.current[0]);
|
||||
assert_eq!(red, p.current[1]);
|
||||
assert_eq!(green, p.current[2]);
|
||||
assert_eq!(blue, p.current[3]);
|
||||
assert_eq!(3, p.mask.count());
|
||||
}
|
||||
|
||||
fn approx(expected: f32, actual: f32) {
|
||||
assert!((expected - actual).abs() <= 0.5, "{expected} vs {actual}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_from_rgb() {
|
||||
let white = Lab::from_rgb(rgb(255, 255, 255));
|
||||
approx(100.0, white.l);
|
||||
approx(0.0, white.a);
|
||||
approx(0.0, white.b);
|
||||
|
||||
let black = Lab::from_rgb(rgb(0, 0, 0));
|
||||
approx(0.0, black.l);
|
||||
approx(0.0, black.a);
|
||||
approx(0.0, black.b);
|
||||
|
||||
let red = Lab::from_rgb(rgb(255, 0, 0));
|
||||
approx(53.23, red.l);
|
||||
approx(80.11, red.a);
|
||||
approx(67.22, red.b);
|
||||
|
||||
let green = Lab::from_rgb(rgb(0, 128, 0));
|
||||
approx(46.23, green.l);
|
||||
approx(-51.70, green.a);
|
||||
approx(49.90, green.b);
|
||||
|
||||
let blue = Lab::from_rgb(rgb(0, 0, 255));
|
||||
approx(32.30, blue.l);
|
||||
approx(79.20, blue.a);
|
||||
approx(-107.86, blue.b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate256_color_base16_preserved() {
|
||||
let bg = rgb(0, 0, 0);
|
||||
let fg = rgb(255, 255, 255);
|
||||
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
|
||||
for i in 0..16 {
|
||||
assert_eq!(DEFAULT[i], palette[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate256_color_cube_corners_match_base_colors() {
|
||||
let bg = rgb(0, 0, 0);
|
||||
let fg = rgb(255, 255, 255);
|
||||
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
|
||||
assert_eq!(bg, palette[16]);
|
||||
assert_eq!(fg, palette[231]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate256_color_cube_corners_black_white_with_harmonious_false() {
|
||||
let black = rgb(0, 0, 0);
|
||||
let white = rgb(255, 255, 255);
|
||||
|
||||
let dark = generate_256_color(DEFAULT, PaletteMask::init_empty(), black, white, false);
|
||||
assert_eq!(black, dark[16]);
|
||||
assert_eq!(white, dark[231]);
|
||||
|
||||
let light = generate_256_color(DEFAULT, PaletteMask::init_empty(), white, black, false);
|
||||
assert_eq!(black, light[16]);
|
||||
assert_eq!(white, light[231]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate256_color_light_theme_cube_corners_with_harmonious_true() {
|
||||
let white = rgb(255, 255, 255);
|
||||
let black = rgb(0, 0, 0);
|
||||
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), white, black, true);
|
||||
assert_eq!(white, palette[16]);
|
||||
assert_eq!(black, palette[231]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate256_color_grayscale_ramp_monotonic_luminance() {
|
||||
let bg = rgb(0, 0, 0);
|
||||
let fg = rgb(255, 255, 255);
|
||||
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
|
||||
let mut prev_lum = 0.0;
|
||||
for c in &palette[232..256] {
|
||||
let lum = c.luminance();
|
||||
assert!(lum >= prev_lum);
|
||||
prev_lum = lum;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate256_color_skip_mask_preserves_original_colors() {
|
||||
let bg = rgb(0, 0, 0);
|
||||
let fg = rgb(255, 255, 255);
|
||||
|
||||
let mut skip = PaletteMask::init_empty();
|
||||
skip.set(20);
|
||||
skip.set(100);
|
||||
skip.set(240);
|
||||
|
||||
let palette = generate_256_color(DEFAULT, skip, bg, fg, false);
|
||||
assert_eq!(DEFAULT[20], palette[20]);
|
||||
assert_eq!(DEFAULT[100], palette[100]);
|
||||
assert_eq!(DEFAULT[240], palette[240]);
|
||||
|
||||
assert!(!palette[21].eql(DEFAULT[21]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate256_color_dark_theme_harmonious_has_no_effect() {
|
||||
let bg = rgb(0, 0, 0);
|
||||
let fg = rgb(255, 255, 255);
|
||||
let normal = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
|
||||
let harmonious = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, true);
|
||||
for i in 16..256 {
|
||||
assert_eq!(normal[i], harmonious[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate256_color_light_theme_harmonious_skips_inversion() {
|
||||
let bg = rgb(255, 255, 255);
|
||||
let fg = rgb(0, 0, 0);
|
||||
let inverted = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
|
||||
let harmonious = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, true);
|
||||
|
||||
assert_eq!(bg, harmonious[16]);
|
||||
assert!(!inverted[16].eql(bg));
|
||||
|
||||
let differ = (16..232)
|
||||
.filter(|&i| !inverted[i].eql(harmonious[i]))
|
||||
.count();
|
||||
assert!(differ > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate256_color_light_theme_harmonious_grayscale_ramp() {
|
||||
let bg = rgb(255, 255, 255);
|
||||
let fg = rgb(0, 0, 0);
|
||||
|
||||
{
|
||||
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
|
||||
let mut prev_lum = 0.0;
|
||||
for c in &palette[232..256] {
|
||||
let lum = c.luminance();
|
||||
assert!(lum >= prev_lum);
|
||||
prev_lum = lum;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, true);
|
||||
let mut prev_lum = 1.0;
|
||||
for c in &palette[232..256] {
|
||||
let lum = c.luminance();
|
||||
assert!(lum <= prev_lum);
|
||||
prev_lum = lum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_to_rgb() {
|
||||
let cases = [
|
||||
rgb(255, 255, 255),
|
||||
rgb(0, 0, 0),
|
||||
rgb(255, 0, 0),
|
||||
rgb(0, 128, 0),
|
||||
rgb(0, 0, 255),
|
||||
rgb(128, 128, 128),
|
||||
rgb(64, 224, 208),
|
||||
];
|
||||
for expected in cases {
|
||||
let actual = Lab::from_rgb(expected).to_rgb();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! CSI command enums (ZIG/src/terminal/csi.zig).
|
||||
|
||||
/// Modes for the ED CSI command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum EraseDisplay {
|
||||
Below = 0,
|
||||
Above = 1,
|
||||
Complete = 2,
|
||||
Scrollback = 3,
|
||||
/// This is an extension added by Kitty to move the viewport into the
|
||||
/// scrollback and then erase the display.
|
||||
ScrollComplete = 22,
|
||||
}
|
||||
|
||||
impl EraseDisplay {
|
||||
/// Non-exhaustive in spirit: unknown values yield `None`.
|
||||
pub fn from_int(v: u16) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Below,
|
||||
1 => Self::Above,
|
||||
2 => Self::Complete,
|
||||
3 => Self::Scrollback,
|
||||
22 => Self::ScrollComplete,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Modes for the EL CSI command. Non-exhaustive in Zig so that conversion
|
||||
/// never fails since the inputs are user-generated; `Other` carries the raw value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EraseLine {
|
||||
Right,
|
||||
Left,
|
||||
Complete,
|
||||
RightUnlessPendingWrap,
|
||||
Other(u8),
|
||||
}
|
||||
|
||||
impl EraseLine {
|
||||
pub fn from_int(v: u8) -> Self {
|
||||
match v {
|
||||
0 => Self::Right,
|
||||
1 => Self::Left,
|
||||
2 => Self::Complete,
|
||||
4 => Self::RightUnlessPendingWrap,
|
||||
v => Self::Other(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modes for the TBC (tab clear) command. Non-exhaustive like [`EraseLine`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TabClear {
|
||||
Current,
|
||||
All,
|
||||
Other(u8),
|
||||
}
|
||||
|
||||
impl TabClear {
|
||||
pub fn from_int(v: u8) -> Self {
|
||||
match v {
|
||||
0 => Self::Current,
|
||||
3 => Self::All,
|
||||
v => Self::Other(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Style formats for terminal size reports (XTWINOPS).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SizeReportStyle {
|
||||
Csi14T,
|
||||
Csi16T,
|
||||
Csi18T,
|
||||
Csi21T,
|
||||
}
|
||||
|
||||
/// XTWINOPS CSI 22/23
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TitlePushPop {
|
||||
pub op: TitlePushPopOp,
|
||||
pub index: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TitlePushPopOp {
|
||||
Push,
|
||||
Pop,
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! DCS command handler (ZIG/src/terminal/dcs.zig). This should be hooked
|
||||
//! into a stream. The hook/put/unhook functions are meant to be called from
|
||||
//! the stream's dcsHook, dcsPut, and dcsUnhook functions, respectively.
|
||||
|
||||
use crate::parser::Dcs;
|
||||
use crate::tmux::control::{Notification, Parser as TmuxParser};
|
||||
|
||||
/// DCS command handler.
|
||||
#[derive(Debug)]
|
||||
pub struct Handler {
|
||||
pub state: State,
|
||||
|
||||
/// Maximum bytes any DCS command can take. This is to prevent
|
||||
/// malicious input from causing us to allocate too much memory.
|
||||
/// This is arbitrarily set to 1MB today, increase if needed.
|
||||
pub max_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for Handler {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: State::Inactive,
|
||||
max_bytes: 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Hook {
|
||||
state: State,
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn hook(&mut self, dcs: &Dcs) -> Option<Command> {
|
||||
debug_assert!(matches!(self.state, State::Inactive));
|
||||
|
||||
// Initialize our state to ignore in case of error
|
||||
self.state = State::Ignore;
|
||||
|
||||
// Try to parse the hook.
|
||||
let Some(hk) = self.try_hook(dcs) else {
|
||||
log::info!("unknown DCS hook: {dcs:?}");
|
||||
return None;
|
||||
};
|
||||
|
||||
self.state = hk.state;
|
||||
hk.command
|
||||
}
|
||||
|
||||
fn try_hook(&self, dcs: &Dcs) -> Option<Hook> {
|
||||
let params = dcs.params();
|
||||
match dcs.intermediates() {
|
||||
[] => match dcs.final_ {
|
||||
// Tmux control mode
|
||||
b'p' => {
|
||||
// Tmux control mode must start with ESC P 1000 p
|
||||
if params.len() != 1 || params[0] != 1000 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parser = TmuxParser::new();
|
||||
parser.max_bytes = self.max_bytes;
|
||||
Some(Hook {
|
||||
state: State::Tmux(Box::new(parser)),
|
||||
command: Some(Command::Tmux(Notification::Enter)),
|
||||
})
|
||||
}
|
||||
|
||||
_ => None,
|
||||
},
|
||||
|
||||
[b'+'] => match dcs.final_ {
|
||||
// XTGETTCAP
|
||||
// https://github.com/mitchellh/ghostty/issues/517
|
||||
b'q' => Some(Hook {
|
||||
state: State::Xtgettcap(Vec::with_capacity(128)),
|
||||
command: None,
|
||||
}),
|
||||
|
||||
_ => None,
|
||||
},
|
||||
|
||||
[b'$'] => match dcs.final_ {
|
||||
// DECRQSS
|
||||
b'q' => Some(Hook {
|
||||
state: State::Decrqss(DecrqssBuffer::default()),
|
||||
command: None,
|
||||
}),
|
||||
|
||||
_ => None,
|
||||
},
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a byte into the DCS handler. This will return a command
|
||||
/// if a command needs to be executed.
|
||||
pub fn put(&mut self, byte: u8) -> Option<Command> {
|
||||
let Ok(v) = self.try_put(byte) else {
|
||||
// On error we just discard our state and ignore the rest
|
||||
log::info!("error putting byte into DCS handler err=OutOfMemory");
|
||||
self.discard();
|
||||
self.state = State::Ignore;
|
||||
return None;
|
||||
};
|
||||
v
|
||||
}
|
||||
|
||||
fn try_put(&mut self, byte: u8) -> Result<Option<Command>, ()> {
|
||||
match &mut self.state {
|
||||
State::Inactive | State::Ignore => {}
|
||||
|
||||
State::Tmux(tmux) => {
|
||||
return match tmux.put(byte) {
|
||||
Ok(n) => Ok(n.map(Command::Tmux)),
|
||||
Err(_) => Err(()),
|
||||
};
|
||||
}
|
||||
|
||||
State::Xtgettcap(list) => {
|
||||
if list.len() >= self.max_bytes {
|
||||
return Err(());
|
||||
}
|
||||
list.push(byte);
|
||||
}
|
||||
|
||||
State::Decrqss(buffer) => {
|
||||
if buffer.len >= buffer.data.len() {
|
||||
return Err(());
|
||||
}
|
||||
buffer.data[buffer.len] = byte;
|
||||
buffer.len += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn unhook(&mut self) -> Option<Command> {
|
||||
let state = std::mem::replace(&mut self.state, State::Inactive);
|
||||
match state {
|
||||
State::Inactive | State::Ignore => None,
|
||||
|
||||
State::Tmux(_) => Some(Command::Tmux(Notification::Exit)),
|
||||
|
||||
State::Xtgettcap(mut list) => {
|
||||
list.make_ascii_uppercase();
|
||||
Some(Command::Xtgettcap(Xtgettcap { data: list, i: 0 }))
|
||||
}
|
||||
|
||||
State::Decrqss(buffer) => Some(Command::Decrqss(match buffer.len {
|
||||
0 => Decrqss::None,
|
||||
1 => match buffer.data[0] {
|
||||
b'm' => Decrqss::Sgr,
|
||||
b'r' => Decrqss::Decstbm,
|
||||
b's' => Decrqss::Decslrm,
|
||||
_ => Decrqss::None,
|
||||
},
|
||||
2 => match buffer.data[0] {
|
||||
b' ' => match buffer.data[1] {
|
||||
b'q' => Decrqss::Decscusr,
|
||||
_ => Decrqss::None,
|
||||
},
|
||||
_ => Decrqss::None,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn discard(&mut self) {
|
||||
self.state = State::Inactive;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
/// XTGETTCAP
|
||||
Xtgettcap(Xtgettcap),
|
||||
|
||||
/// DECRQSS
|
||||
Decrqss(Decrqss),
|
||||
|
||||
/// Tmux control mode
|
||||
Tmux(Notification),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Xtgettcap {
|
||||
pub data: Vec<u8>,
|
||||
pub i: usize,
|
||||
}
|
||||
|
||||
impl Xtgettcap {
|
||||
/// Returns the next terminfo key being requested and None
|
||||
/// when there are no more keys. The returned value is NOT hex-decoded
|
||||
/// because we expect to use a comptime lookup table.
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn next(&mut self) -> Option<&[u8]> {
|
||||
let items = &self.data;
|
||||
if self.i >= items.len() {
|
||||
return None;
|
||||
}
|
||||
let rem = &items[self.i..];
|
||||
let idx = rem.iter().position(|&b| b == b';').unwrap_or(rem.len());
|
||||
|
||||
// Note that if we're at the end, idx + 1 is len + 1 so we're over
|
||||
// the end but that's okay because our check above is >= so we'll
|
||||
// never read.
|
||||
self.i += idx + 1;
|
||||
|
||||
Some(&rem[..idx])
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported DECRQSS settings
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Decrqss {
|
||||
None,
|
||||
Sgr,
|
||||
Decscusr,
|
||||
Decstbm,
|
||||
Decslrm,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DecrqssBuffer {
|
||||
data: [u8; 2],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub enum State {
|
||||
/// We're not in a DCS state at the moment.
|
||||
#[default]
|
||||
Inactive,
|
||||
|
||||
/// We're hooked, but its an unknown DCS command or one that went
|
||||
/// invalid due to some bad input, so we're ignoring the rest.
|
||||
Ignore,
|
||||
|
||||
/// XTGETTCAP
|
||||
Xtgettcap(Vec<u8>),
|
||||
|
||||
/// DECRQSS
|
||||
Decrqss(DecrqssBuffer),
|
||||
|
||||
/// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode
|
||||
Tmux(Box<TmuxParser>),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn dcs(inter: &[u8], params: &[u16], final_: u8) -> Dcs {
|
||||
Dcs::new(inter, params, final_)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_dcs_command() {
|
||||
let mut h = Handler::new();
|
||||
assert!(h.hook(&dcs(b"", &[], b'A')).is_none());
|
||||
assert!(matches!(h.state, State::Ignore));
|
||||
assert!(h.unhook().is_none());
|
||||
assert!(matches!(h.state, State::Inactive));
|
||||
}
|
||||
|
||||
fn xtgettcap(input: &str) -> Xtgettcap {
|
||||
let mut h = Handler::new();
|
||||
assert!(h.hook(&dcs(b"+", &[], b'q')).is_none());
|
||||
for b in input.bytes() {
|
||||
let _ = h.put(b);
|
||||
}
|
||||
match h.unhook().unwrap() {
|
||||
Command::Xtgettcap(x) => x,
|
||||
_ => panic!("expected xtgettcap"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xtgettcap_command() {
|
||||
let mut cmd = xtgettcap("536D756C78");
|
||||
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
|
||||
assert_eq!(cmd.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xtgettcap_mixed_case() {
|
||||
let mut cmd = xtgettcap("536d756C78");
|
||||
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
|
||||
assert_eq!(cmd.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xtgettcap_command_multiple_keys() {
|
||||
let mut cmd = xtgettcap("536D756C78;536D756C78");
|
||||
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
|
||||
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
|
||||
assert_eq!(cmd.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xtgettcap_command_invalid_data() {
|
||||
let mut cmd = xtgettcap("who;536D756C78");
|
||||
assert_eq!(cmd.next(), Some(&b"WHO"[..]));
|
||||
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
|
||||
assert_eq!(cmd.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xtgettcap_max_bytes() {
|
||||
let mut h = Handler::new();
|
||||
h.max_bytes = 2;
|
||||
assert!(h.hook(&dcs(b"+", &[], b'q')).is_none());
|
||||
for b in b"abc" {
|
||||
let _ = h.put(*b);
|
||||
}
|
||||
assert!(matches!(h.state, State::Ignore));
|
||||
assert!(h.unhook().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrqss_command() {
|
||||
let mut h = Handler::new();
|
||||
assert!(h.hook(&dcs(b"$", &[], b'q')).is_none());
|
||||
let _ = h.put(b'm');
|
||||
assert_eq!(h.unhook(), Some(Command::Decrqss(Decrqss::Sgr)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrqss_invalid_command() {
|
||||
let mut h = Handler::new();
|
||||
assert!(h.hook(&dcs(b"$", &[], b'q')).is_none());
|
||||
let _ = h.put(b'z');
|
||||
assert_eq!(h.unhook(), Some(Command::Decrqss(Decrqss::None)));
|
||||
|
||||
h.discard();
|
||||
|
||||
assert!(h.hook(&dcs(b"$", &[], b'q')).is_none());
|
||||
let _ = h.put(b'"');
|
||||
let _ = h.put(b' ');
|
||||
let _ = h.put(b'q');
|
||||
assert!(h.unhook().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_enter_and_implicit_exit() {
|
||||
let mut h = Handler::new();
|
||||
|
||||
let cmd = h.hook(&dcs(b"", &[1000], b'p')).unwrap();
|
||||
assert_eq!(cmd, Command::Tmux(Notification::Enter));
|
||||
|
||||
let cmd = h.unhook().unwrap();
|
||||
assert_eq!(cmd, Command::Tmux(Notification::Exit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_requires_1000_param() {
|
||||
let mut h = Handler::new();
|
||||
assert!(h.hook(&dcs(b"", &[1], b'p')).is_none());
|
||||
assert!(matches!(h.state, State::Ignore));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Device status requests (ZIG/src/terminal/device_status.zig).
|
||||
|
||||
/// The available device status requests. Each is identified by a numeric
|
||||
/// value plus whether it uses the "?" prefix.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Request {
|
||||
OperatingStatus,
|
||||
CursorPosition,
|
||||
ColorScheme,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// The numeric value of the request.
|
||||
pub fn value(self) -> u16 {
|
||||
ENTRIES
|
||||
.iter()
|
||||
.find(|e| e.request == self)
|
||||
.map_or(0, |e| e.value)
|
||||
}
|
||||
|
||||
/// Whether the request uses the "?" prefix.
|
||||
pub fn question(self) -> bool {
|
||||
ENTRIES
|
||||
.iter()
|
||||
.find(|e| e.request == self)
|
||||
.is_some_and(|e| e.question)
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
request: Request,
|
||||
value: u16,
|
||||
question: bool,
|
||||
}
|
||||
|
||||
/// The full list of device status request entries.
|
||||
const ENTRIES: &[Entry] = &[
|
||||
Entry {
|
||||
request: Request::OperatingStatus,
|
||||
value: 5,
|
||||
question: false,
|
||||
},
|
||||
Entry {
|
||||
request: Request::CursorPosition,
|
||||
value: 6,
|
||||
question: false,
|
||||
},
|
||||
Entry {
|
||||
request: Request::ColorScheme,
|
||||
value: 996,
|
||||
question: true,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn req_from_int(v: u16, question: bool) -> Option<Request> {
|
||||
ENTRIES
|
||||
.iter()
|
||||
.find(|e| e.value == v && e.question == question)
|
||||
.map(|e| e.request)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn req_from_int_lookup() {
|
||||
assert_eq!(req_from_int(5, false), Some(Request::OperatingStatus));
|
||||
assert_eq!(req_from_int(6, false), Some(Request::CursorPosition));
|
||||
assert_eq!(req_from_int(996, true), Some(Request::ColorScheme));
|
||||
assert_eq!(req_from_int(996, false), None);
|
||||
assert_eq!(req_from_int(5, true), None);
|
||||
assert_eq!(Request::ColorScheme.value(), 996);
|
||||
assert!(Request::ColorScheme.question());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Kitty color protocol (ZIG/src/terminal/kitty/color.zig).
|
||||
|
||||
use crate::color::Rgb;
|
||||
use crate::osc::Terminator;
|
||||
use std::fmt;
|
||||
|
||||
/// OSC 21 payload.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct OSC {
|
||||
/// list of requests
|
||||
pub list: Vec<Request>,
|
||||
|
||||
/// We must reply with the same string terminator (ST) as used in the
|
||||
/// request.
|
||||
pub terminator: Terminator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
Query(Kind),
|
||||
Set { key: Kind, color: Rgb },
|
||||
Reset(Kind),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Special {
|
||||
Foreground,
|
||||
Background,
|
||||
SelectionForeground,
|
||||
SelectionBackground,
|
||||
Cursor,
|
||||
CursorText,
|
||||
VisualBell,
|
||||
SecondTransparentBackground,
|
||||
}
|
||||
|
||||
impl Special {
|
||||
/// The tag name (Zig `@tagName`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Special::Foreground => "foreground",
|
||||
Special::Background => "background",
|
||||
Special::SelectionForeground => "selection_foreground",
|
||||
Special::SelectionBackground => "selection_background",
|
||||
Special::Cursor => "cursor",
|
||||
Special::CursorText => "cursor_text",
|
||||
Special::VisualBell => "visual_bell",
|
||||
Special::SecondTransparentBackground => "second_transparent_background",
|
||||
}
|
||||
}
|
||||
|
||||
/// Zig `std.meta.stringToEnum`.
|
||||
pub fn from_name(s: &[u8]) -> Option<Special> {
|
||||
Some(match s {
|
||||
b"foreground" => Special::Foreground,
|
||||
b"background" => Special::Background,
|
||||
b"selection_foreground" => Special::SelectionForeground,
|
||||
b"selection_background" => Special::SelectionBackground,
|
||||
b"cursor" => Special::Cursor,
|
||||
b"cursor_text" => Special::CursorText,
|
||||
b"visual_bell" => Special::VisualBell,
|
||||
b"second_transparent_background" => Special::SecondTransparentBackground,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Kind {
|
||||
Palette(u8),
|
||||
Special(Special),
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// Number of distinct kinds (255 + number of specials).
|
||||
pub const MAX: usize = u8::MAX as usize + 8;
|
||||
|
||||
pub fn parse(key: &[u8]) -> Option<Kind> {
|
||||
if let Some(s) = Special::from_name(key) {
|
||||
return Some(Kind::Special(s));
|
||||
}
|
||||
// Zig parseUnsigned(u8, key, 10): optional '+', digits only.
|
||||
let digits = match key.first()? {
|
||||
b'+' => &key[1..],
|
||||
_ => key,
|
||||
};
|
||||
if digits.is_empty() || !digits.iter().all(u8::is_ascii_digit) {
|
||||
return None;
|
||||
}
|
||||
let s = std::str::from_utf8(digits).ok()?;
|
||||
s.parse::<u8>().ok().map(Kind::Palette)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Kind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Kind::Palette(p) => write!(f, "{p}"),
|
||||
Kind::Special(s) => f.write_str(s.name()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_kitty_color_protocol_kind_string() {
|
||||
assert_eq!("foreground", Kind::Special(Special::Foreground).to_string());
|
||||
assert_eq!("42", Kind::Palette(42).to_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1391 @@
|
||||
//! Kitty graphics protocol command parser
|
||||
//! (ZIG/src/terminal/kitty/graphics_command.zig).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The key-value pairs for the control information for a command. The
|
||||
/// keys are always single characters and the values are either single
|
||||
/// characters or 32-bit unsigned integers.
|
||||
///
|
||||
/// For the value of this: if the value is a single printable ASCII character
|
||||
/// it is the ASCII code. Otherwise, it is parsed as a 32-bit unsigned integer.
|
||||
type Kv = HashMap<u8, u32>;
|
||||
|
||||
/// Errors from the command parser.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid format")]
|
||||
InvalidFormat,
|
||||
#[error("invalid data")]
|
||||
InvalidData,
|
||||
#[error("invalid character")]
|
||||
InvalidCharacter,
|
||||
#[error("overflow")]
|
||||
Overflow,
|
||||
}
|
||||
|
||||
/// Mirrors `std.fmt.parseInt(T, buf, 10)` for 32-bit targets: optional sign,
|
||||
/// at least one digit, range checked.
|
||||
fn parse_int(buf: &[u8], signed: bool) -> Result<u32, Error> {
|
||||
// kv_temp is at most 11 bytes so an i64 always suffices unless there
|
||||
// are overflow digits, which we detect via checked arithmetic.
|
||||
let (neg, digits) = match buf.first() {
|
||||
Some(b'+') if buf.len() > 1 => (false, &buf[1..]),
|
||||
Some(b'-') if buf.len() > 1 => (true, &buf[1..]),
|
||||
_ => (false, buf),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return Err(Error::InvalidCharacter);
|
||||
}
|
||||
let mut v: i64 = 0;
|
||||
let mut overflow = false;
|
||||
for &c in digits {
|
||||
if !c.is_ascii_digit() {
|
||||
return Err(Error::InvalidCharacter);
|
||||
}
|
||||
match v
|
||||
.checked_mul(10)
|
||||
.and_then(|x| x.checked_add(i64::from(c - b'0')))
|
||||
{
|
||||
Some(x) => v = x,
|
||||
None => overflow = true,
|
||||
}
|
||||
}
|
||||
if overflow {
|
||||
return Err(Error::Overflow);
|
||||
}
|
||||
if neg {
|
||||
v = -v;
|
||||
}
|
||||
if signed {
|
||||
i32::try_from(v)
|
||||
.map(|x| x as u32)
|
||||
.map_err(|_| Error::Overflow)
|
||||
} else {
|
||||
u32::try_from(v).map_err(|_| Error::Overflow)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode base64 payload data. The reference implementation uses simdutf,
|
||||
/// which ignores ASCII whitespace and tolerates missing padding.
|
||||
pub fn decode_base64(input: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
use base64::Engine;
|
||||
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
|
||||
const ENGINE: GeneralPurpose = GeneralPurpose::new(
|
||||
&base64::alphabet::STANDARD,
|
||||
GeneralPurposeConfig::new()
|
||||
.with_decode_padding_mode(DecodePaddingMode::Indifferent)
|
||||
.with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
let cleaned: Vec<u8> = input
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0c))
|
||||
.collect();
|
||||
let trimmed_len = cleaned
|
||||
.iter()
|
||||
.rposition(|&b| b != b'=')
|
||||
.map_or(0, |i| i + 1);
|
||||
ENGINE
|
||||
.decode(&cleaned[..trimmed_len])
|
||||
.map_err(|_| Error::InvalidData)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum State {
|
||||
/// Parsing k/v pairs. The "ignore" variants are in that state
|
||||
/// but ignore any data because we know they're invalid.
|
||||
ControlKey,
|
||||
ControlKeyIgnore,
|
||||
ControlValue,
|
||||
ControlValueIgnore,
|
||||
|
||||
/// Collecting the data payload blob.
|
||||
Data,
|
||||
}
|
||||
|
||||
/// Command parser parses the Kitty graphics protocol escape sequence.
|
||||
#[derive(Debug)]
|
||||
pub struct Parser {
|
||||
/// The list of KV pairs that we're building up.
|
||||
kv: Kv,
|
||||
|
||||
/// Buffer to store the key/value of a KV pair. The value of a KV pair is
|
||||
/// at most a 32-bit integer which at most is 10 characters (4294967295),
|
||||
/// plus one character for the sign bit on signed ints.
|
||||
kv_temp: [u8; 11],
|
||||
kv_temp_len: usize,
|
||||
/// Current kv key
|
||||
kv_current: u8,
|
||||
|
||||
/// The bytes of the data payload.
|
||||
data: Vec<u8>,
|
||||
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl Default for Parser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
kv: HashMap::new(),
|
||||
kv_temp: [0; 11],
|
||||
kv_temp_len: 0,
|
||||
kv_current: 0,
|
||||
data: Vec::new(),
|
||||
state: State::ControlKey,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a complete command string.
|
||||
pub fn parse_string(data: &[u8]) -> Result<Command, Error> {
|
||||
let mut parser = Self::new();
|
||||
for &c in data {
|
||||
parser.feed(c)?;
|
||||
}
|
||||
parser.complete()
|
||||
}
|
||||
|
||||
/// Feed a single byte to the parser.
|
||||
///
|
||||
/// The first byte to start parsing should be the byte immediately following
|
||||
/// the "G" in the APC sequence, i.e. "\x1b_G123" the first byte should
|
||||
/// be "1".
|
||||
pub fn feed(&mut self, c: u8) -> Result<(), Error> {
|
||||
match self.state {
|
||||
State::ControlKey => match c {
|
||||
// '=' means the key is complete and we're moving to the value.
|
||||
b'=' => {
|
||||
if self.kv_temp_len == 1 {
|
||||
self.kv_current = self.kv_temp[0];
|
||||
self.kv_temp_len = 0;
|
||||
self.state = State::ControlValue;
|
||||
} else {
|
||||
// All control keys are a single character right now so
|
||||
// if we're not a single character just ignore follow-up
|
||||
// data.
|
||||
self.state = State::ControlValueIgnore;
|
||||
self.kv_temp_len = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// This can be encountered if we have a sequence with no
|
||||
// control data, only payload data (i.e. "\x1b_G;<data>").
|
||||
//
|
||||
// Kitty treats this as valid so we do as well.
|
||||
b';' => self.state = State::Data,
|
||||
|
||||
_ => self.accumulate_value(c, State::ControlKeyIgnore),
|
||||
},
|
||||
|
||||
State::ControlKeyIgnore => {
|
||||
if c == b'=' {
|
||||
self.state = State::ControlValueIgnore;
|
||||
}
|
||||
}
|
||||
|
||||
State::ControlValue => match c {
|
||||
// move to next key
|
||||
b',' => self.finish_value(State::ControlKey)?,
|
||||
// move to data
|
||||
b';' => self.finish_value(State::Data)?,
|
||||
_ => self.accumulate_value(c, State::ControlValueIgnore),
|
||||
},
|
||||
|
||||
State::ControlValueIgnore => match c {
|
||||
b',' => self.state = State::ControlKeyIgnore,
|
||||
b';' => self.state = State::Data,
|
||||
_ => {}
|
||||
},
|
||||
|
||||
State::Data => self.data.push(c),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Complete the parsing. This must be called after all the
|
||||
/// bytes have been fed to the parser. The payload data is moved into the
|
||||
/// resulting command.
|
||||
pub fn complete(&mut self) -> Result<Command, Error> {
|
||||
match self.state {
|
||||
// We can't ever end in the control key state and be valid.
|
||||
// This means the command looked something like "a=1,b"
|
||||
State::ControlKey | State::ControlKeyIgnore => return Err(Error::InvalidFormat),
|
||||
|
||||
// Some commands (i.e. placements) end without extra data so
|
||||
// we end in the value state. i.e. "a=1,b=2"
|
||||
State::ControlValue => self.finish_value(State::Data)?,
|
||||
State::ControlValueIgnore => {}
|
||||
|
||||
// Most commands end in data, i.e. "a=1,b=2;1234"
|
||||
State::Data => {}
|
||||
}
|
||||
|
||||
// Determine our action, which is always a single character.
|
||||
let action: u8 = match self.kv.get(&b'a') {
|
||||
None => b't',
|
||||
Some(&v) => u8::try_from(v).map_err(|_| Error::InvalidFormat)?,
|
||||
};
|
||||
let kv = &self.kv;
|
||||
let control = match action {
|
||||
b'q' => Control::Query(Transmission::parse(kv)?),
|
||||
b't' => Control::Transmit(Transmission::parse(kv)?),
|
||||
b'T' => Control::TransmitAndDisplay {
|
||||
transmission: Transmission::parse(kv)?,
|
||||
display: Display::parse(kv)?,
|
||||
},
|
||||
b'p' => Control::Display(Display::parse(kv)?),
|
||||
b'd' => Control::Delete(Delete::parse(kv)?),
|
||||
b'f' => Control::TransmitAnimationFrame(AnimationFrameLoading::parse(kv)?),
|
||||
b'a' => Control::ControlAnimation(AnimationControl::parse(kv)?),
|
||||
b'c' => Control::ComposeAnimation(AnimationFrameComposition::parse(kv)?),
|
||||
_ => return Err(Error::InvalidFormat),
|
||||
};
|
||||
|
||||
// Determine our quiet value
|
||||
let quiet = match self.kv.get(&b'q') {
|
||||
Some(0) => Quiet::No,
|
||||
Some(1) => Quiet::Ok,
|
||||
Some(2) => Quiet::Failures,
|
||||
Some(_) => return Err(Error::InvalidFormat),
|
||||
None => Quiet::No,
|
||||
};
|
||||
|
||||
Ok(Command {
|
||||
control,
|
||||
quiet,
|
||||
data: self.decode_data()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decodes the payload data from base64 and returns it. This consumes
|
||||
/// the collected payload bytes.
|
||||
fn decode_data(&mut self) -> Result<Vec<u8>, Error> {
|
||||
if self.data.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let data = std::mem::take(&mut self.data);
|
||||
decode_base64(&data).inspect_err(|e| {
|
||||
log::warn!("failed to decode base64 payload data: {e}");
|
||||
})
|
||||
}
|
||||
|
||||
fn accumulate_value(&mut self, c: u8, overflow_state: State) {
|
||||
let idx = self.kv_temp_len;
|
||||
self.kv_temp_len += 1;
|
||||
if self.kv_temp_len > self.kv_temp.len() {
|
||||
self.state = overflow_state;
|
||||
self.kv_temp_len = 0;
|
||||
return;
|
||||
}
|
||||
self.kv_temp[idx] = c;
|
||||
}
|
||||
|
||||
fn finish_value(&mut self, next_state: State) -> Result<(), Error> {
|
||||
// We can move states right away, we don't use it.
|
||||
self.state = next_state;
|
||||
|
||||
// Check for ASCII chars first
|
||||
if self.kv_temp_len == 1 {
|
||||
let c = self.kv_temp[0];
|
||||
if !c.is_ascii_digit() {
|
||||
self.kv.insert(self.kv_current, u32::from(c));
|
||||
self.kv_temp_len = 0;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle integer fields, parsing signed fields accordingly. We still
|
||||
// store the fields as u32 as they can be bitcast back later during
|
||||
// building of the higher-level command tree.
|
||||
let buf = &self.kv_temp[..self.kv_temp_len];
|
||||
let v = match self.kv_current {
|
||||
b'z' | b'H' | b'V' => parse_int(buf, true)?,
|
||||
_ => parse_int(buf, false)?,
|
||||
};
|
||||
self.kv.insert(self.kv_current, v);
|
||||
|
||||
// Clear our temp buffer
|
||||
self.kv_temp_len = 0;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a possible response to a command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Response {
|
||||
pub id: u32,
|
||||
pub image_number: u32,
|
||||
pub placement_id: u32,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Default for Response {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: 0,
|
||||
image_number: 0,
|
||||
placement_id: 0,
|
||||
message: "OK".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn encode(&self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
|
||||
// We only encode a result if we have either an id or an image number.
|
||||
if self.id == 0 && self.image_number == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Used to cheaply keep track if we need to add a comma before
|
||||
// the next key-value pair.
|
||||
let mut prior = false;
|
||||
|
||||
writer.write_all(b"\x1b_G")?;
|
||||
if self.id > 0 {
|
||||
prior = true;
|
||||
write!(writer, "i={}", self.id)?;
|
||||
}
|
||||
if self.image_number > 0 {
|
||||
if prior {
|
||||
writer.write_all(b",")?;
|
||||
} else {
|
||||
prior = true;
|
||||
}
|
||||
write!(writer, "I={}", self.image_number)?;
|
||||
}
|
||||
if self.placement_id > 0 {
|
||||
if prior {
|
||||
writer.write_all(b",")?;
|
||||
}
|
||||
write!(writer, "p={}", self.placement_id)?;
|
||||
}
|
||||
writer.write_all(b";")?;
|
||||
writer.write_all(self.message.as_bytes())?;
|
||||
writer.write_all(b"\x1b\\")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if this response is not an error.
|
||||
pub fn ok(&self) -> bool {
|
||||
self.message == "OK"
|
||||
}
|
||||
|
||||
/// Empty response
|
||||
pub fn empty(&self) -> bool {
|
||||
self.id == 0 && self.image_number == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Command {
|
||||
pub control: Control,
|
||||
pub quiet: Quiet,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// q
|
||||
Query,
|
||||
/// t
|
||||
Transmit,
|
||||
/// T
|
||||
TransmitAndDisplay,
|
||||
/// p
|
||||
Display,
|
||||
/// d
|
||||
Delete,
|
||||
/// f
|
||||
TransmitAnimationFrame,
|
||||
/// a
|
||||
ControlAnimation,
|
||||
/// c
|
||||
ComposeAnimation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Quiet {
|
||||
/// 0
|
||||
#[default]
|
||||
No,
|
||||
/// 1
|
||||
Ok,
|
||||
/// 2
|
||||
Failures,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Control {
|
||||
Query(Transmission),
|
||||
Transmit(Transmission),
|
||||
TransmitAndDisplay {
|
||||
transmission: Transmission,
|
||||
display: Display,
|
||||
},
|
||||
Display(Display),
|
||||
Delete(Delete),
|
||||
TransmitAnimationFrame(AnimationFrameLoading),
|
||||
ControlAnimation(AnimationControl),
|
||||
ComposeAnimation(AnimationFrameComposition),
|
||||
}
|
||||
|
||||
impl Control {
|
||||
pub fn action(&self) -> Action {
|
||||
match self {
|
||||
Control::Query(_) => Action::Query,
|
||||
Control::Transmit(_) => Action::Transmit,
|
||||
Control::TransmitAndDisplay { .. } => Action::TransmitAndDisplay,
|
||||
Control::Display(_) => Action::Display,
|
||||
Control::Delete(_) => Action::Delete,
|
||||
Control::TransmitAnimationFrame(_) => Action::TransmitAnimationFrame,
|
||||
Control::ControlAnimation(_) => Action::ControlAnimation,
|
||||
Control::ComposeAnimation(_) => Action::ComposeAnimation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Take ownership over the data in this command, leaving it empty.
|
||||
pub fn to_owned_data(&mut self) -> Vec<u8> {
|
||||
std::mem::take(&mut self.data)
|
||||
}
|
||||
|
||||
/// Returns the transmission data if it has any.
|
||||
pub fn transmission(&self) -> Option<Transmission> {
|
||||
match &self.control {
|
||||
Control::Query(t) | Control::Transmit(t) => Some(*t),
|
||||
Control::TransmitAndDisplay { transmission, .. } => Some(*transmission),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the display data if it has any.
|
||||
pub fn display(&self) -> Option<Display> {
|
||||
match &self.control {
|
||||
Control::Display(d) => Some(*d),
|
||||
Control::TransmitAndDisplay { display, .. } => Some(*display),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Transmission {
|
||||
/// f
|
||||
pub format: TransmissionFormat,
|
||||
/// t
|
||||
pub medium: TransmissionMedium,
|
||||
/// s
|
||||
pub width: u32,
|
||||
/// v
|
||||
pub height: u32,
|
||||
/// S
|
||||
pub size: u32,
|
||||
/// O
|
||||
pub offset: u32,
|
||||
/// i
|
||||
pub image_id: u32,
|
||||
/// I
|
||||
pub image_number: u32,
|
||||
/// p
|
||||
pub placement_id: u32,
|
||||
/// o
|
||||
pub compression: TransmissionCompression,
|
||||
/// m
|
||||
pub more_chunks: bool,
|
||||
}
|
||||
|
||||
impl Default for Transmission {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format: TransmissionFormat::Rgba,
|
||||
medium: TransmissionMedium::Direct,
|
||||
width: 0,
|
||||
height: 0,
|
||||
size: 0,
|
||||
offset: 0,
|
||||
image_id: 0,
|
||||
image_number: 0,
|
||||
placement_id: 0,
|
||||
compression: TransmissionCompression::None,
|
||||
more_chunks: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransmissionFormat {
|
||||
/// 24
|
||||
Rgb,
|
||||
/// 32
|
||||
Rgba,
|
||||
/// 100
|
||||
Png,
|
||||
|
||||
// The following are not supported directly via the protocol
|
||||
// but they are formats that a png may decode to that we
|
||||
// support.
|
||||
GrayAlpha,
|
||||
Gray,
|
||||
}
|
||||
|
||||
impl TransmissionFormat {
|
||||
/// Bytes per pixel.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics for `Png`, which must be validated before.
|
||||
pub fn bpp(self) -> u8 {
|
||||
match self {
|
||||
Self::Gray => 1,
|
||||
Self::GrayAlpha => 2,
|
||||
Self::Rgb => 3,
|
||||
Self::Rgba => 4,
|
||||
Self::Png => unreachable!("png must be validated before"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransmissionMedium {
|
||||
/// d
|
||||
Direct,
|
||||
/// f
|
||||
File,
|
||||
/// t
|
||||
TemporaryFile,
|
||||
/// s
|
||||
SharedMemory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransmissionCompression {
|
||||
None,
|
||||
/// z
|
||||
ZlibDeflate,
|
||||
}
|
||||
|
||||
fn get(kv: &Kv, k: u8) -> Option<u32> {
|
||||
kv.get(&k).copied()
|
||||
}
|
||||
|
||||
impl Transmission {
|
||||
fn parse(kv: &Kv) -> Result<Transmission, Error> {
|
||||
let mut result = Transmission::default();
|
||||
if let Some(v) = get(kv, b'f') {
|
||||
result.format = match v {
|
||||
24 => TransmissionFormat::Rgb,
|
||||
32 => TransmissionFormat::Rgba,
|
||||
100 => TransmissionFormat::Png,
|
||||
_ => return Err(Error::InvalidFormat),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(v) = get(kv, b't') {
|
||||
let c = u8::try_from(v).map_err(|_| Error::InvalidFormat)?;
|
||||
result.medium = match c {
|
||||
b'd' => TransmissionMedium::Direct,
|
||||
b'f' => TransmissionMedium::File,
|
||||
b't' => TransmissionMedium::TemporaryFile,
|
||||
b's' => TransmissionMedium::SharedMemory,
|
||||
_ => return Err(Error::InvalidFormat),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(v) = get(kv, b's') {
|
||||
result.width = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'v') {
|
||||
result.height = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'S') {
|
||||
result.size = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'O') {
|
||||
result.offset = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'i') {
|
||||
result.image_id = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'I') {
|
||||
result.image_number = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'p') {
|
||||
result.placement_id = v;
|
||||
}
|
||||
|
||||
if let Some(v) = get(kv, b'o') {
|
||||
let c = u8::try_from(v).map_err(|_| Error::InvalidFormat)?;
|
||||
result.compression = match c {
|
||||
b'z' => TransmissionCompression::ZlibDeflate,
|
||||
_ => return Err(Error::InvalidFormat),
|
||||
};
|
||||
}
|
||||
|
||||
// If the transmission medium is a local-only medium, ignore the "m"
|
||||
// key. The Kitty graphics protocol specification does not explicitly
|
||||
// call out this behavior (although the "m" key is only mentioned in
|
||||
// connection with remote clients) but that's how it's implemented in
|
||||
// Kitty and at least one client (mpv) relies on this behavior when
|
||||
// using the shared memory transmission medium.
|
||||
//
|
||||
// https://sw.kovidgoyal.net/kitty/graphics-protocol/#the-transmission-medium
|
||||
// https://github.com/kovidgoyal/kitty/blob/ccc3bee9af794f332b4e9adcd714a649f639c397/kitty/graphics.c#L547-L592
|
||||
if result.medium == TransmissionMedium::Direct
|
||||
&& let Some(v) = get(kv, b'm')
|
||||
{
|
||||
result.more_chunks = v > 0;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Display {
|
||||
/// i
|
||||
pub image_id: u32,
|
||||
/// I
|
||||
pub image_number: u32,
|
||||
/// p
|
||||
pub placement_id: u32,
|
||||
/// x
|
||||
pub x: u32,
|
||||
/// y
|
||||
pub y: u32,
|
||||
/// w
|
||||
pub width: u32,
|
||||
/// h
|
||||
pub height: u32,
|
||||
/// X
|
||||
pub x_offset: u32,
|
||||
/// Y
|
||||
pub y_offset: u32,
|
||||
/// c
|
||||
pub columns: u32,
|
||||
/// r
|
||||
pub rows: u32,
|
||||
/// C
|
||||
pub cursor_movement: CursorMovement,
|
||||
/// U
|
||||
pub virtual_placement: bool,
|
||||
/// P
|
||||
pub parent_id: u32,
|
||||
/// Q
|
||||
pub parent_placement_id: u32,
|
||||
/// H
|
||||
pub horizontal_offset: i32,
|
||||
/// V
|
||||
pub vertical_offset: i32,
|
||||
/// z
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
impl Default for Display {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
image_id: 0,
|
||||
image_number: 0,
|
||||
placement_id: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x_offset: 0,
|
||||
y_offset: 0,
|
||||
columns: 0,
|
||||
rows: 0,
|
||||
cursor_movement: CursorMovement::After,
|
||||
virtual_placement: false,
|
||||
parent_id: 0,
|
||||
parent_placement_id: 0,
|
||||
horizontal_offset: 0,
|
||||
vertical_offset: 0,
|
||||
z: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CursorMovement {
|
||||
/// 0
|
||||
After,
|
||||
/// 1
|
||||
None,
|
||||
}
|
||||
|
||||
impl Display {
|
||||
fn parse(kv: &Kv) -> Result<Display, Error> {
|
||||
let mut r = Display::default();
|
||||
|
||||
if let Some(v) = get(kv, b'i') {
|
||||
r.image_id = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'I') {
|
||||
r.image_number = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'p') {
|
||||
r.placement_id = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'x') {
|
||||
r.x = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'y') {
|
||||
r.y = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'w') {
|
||||
r.width = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'h') {
|
||||
r.height = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'X') {
|
||||
r.x_offset = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'Y') {
|
||||
r.y_offset = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'c') {
|
||||
r.columns = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'r') {
|
||||
r.rows = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'C') {
|
||||
r.cursor_movement = match v {
|
||||
0 => CursorMovement::After,
|
||||
1 => CursorMovement::None,
|
||||
_ => return Err(Error::InvalidFormat),
|
||||
};
|
||||
}
|
||||
if let Some(v) = get(kv, b'U') {
|
||||
r.virtual_placement = match v {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return Err(Error::InvalidFormat),
|
||||
};
|
||||
}
|
||||
if let Some(v) = get(kv, b'z') {
|
||||
// We can bitcast here because of how we parse it earlier.
|
||||
r.z = v as i32;
|
||||
}
|
||||
if let Some(v) = get(kv, b'P') {
|
||||
r.parent_id = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'Q') {
|
||||
r.parent_placement_id = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'H') {
|
||||
r.horizontal_offset = v as i32;
|
||||
}
|
||||
if let Some(v) = get(kv, b'V') {
|
||||
r.vertical_offset = v as i32;
|
||||
}
|
||||
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CompositionMode {
|
||||
/// 0
|
||||
AlphaBlend,
|
||||
/// 1
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
fn composition_mode(v: u32) -> Result<CompositionMode, Error> {
|
||||
match v {
|
||||
0 => Ok(CompositionMode::AlphaBlend),
|
||||
1 => Ok(CompositionMode::Overwrite),
|
||||
_ => Err(Error::InvalidFormat),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct AnimationBackground {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
pub a: u8,
|
||||
}
|
||||
|
||||
impl AnimationBackground {
|
||||
/// Equivalent of the Zig `@bitCast` from a `u32` (r is the low byte).
|
||||
pub fn from_int(v: u32) -> Self {
|
||||
Self {
|
||||
r: v as u8,
|
||||
g: (v >> 8) as u8,
|
||||
b: (v >> 16) as u8,
|
||||
a: (v >> 24) as u8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AnimationFrameLoading {
|
||||
/// x
|
||||
pub x: u32,
|
||||
/// y
|
||||
pub y: u32,
|
||||
/// c
|
||||
pub create_frame: u32,
|
||||
/// r
|
||||
pub edit_frame: u32,
|
||||
/// z
|
||||
pub gap_ms: u32,
|
||||
/// X
|
||||
pub composition_mode: CompositionMode,
|
||||
/// Y
|
||||
pub background: AnimationBackground,
|
||||
}
|
||||
|
||||
impl Default for AnimationFrameLoading {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
x: 0,
|
||||
y: 0,
|
||||
create_frame: 0,
|
||||
edit_frame: 0,
|
||||
gap_ms: 0,
|
||||
composition_mode: CompositionMode::AlphaBlend,
|
||||
background: AnimationBackground::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimationFrameLoading {
|
||||
fn parse(kv: &Kv) -> Result<Self, Error> {
|
||||
let mut r = Self::default();
|
||||
if let Some(v) = get(kv, b'x') {
|
||||
r.x = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'y') {
|
||||
r.y = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'c') {
|
||||
r.create_frame = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'r') {
|
||||
r.edit_frame = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'z') {
|
||||
r.gap_ms = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'X') {
|
||||
r.composition_mode = composition_mode(v)?;
|
||||
}
|
||||
if let Some(v) = get(kv, b'Y') {
|
||||
r.background = AnimationBackground::from_int(v);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AnimationFrameComposition {
|
||||
/// c
|
||||
pub frame: u32,
|
||||
/// r
|
||||
pub edit_frame: u32,
|
||||
/// x
|
||||
pub x: u32,
|
||||
/// y
|
||||
pub y: u32,
|
||||
/// w
|
||||
pub width: u32,
|
||||
/// h
|
||||
pub height: u32,
|
||||
/// X
|
||||
pub left_edge: u32,
|
||||
/// Y
|
||||
pub top_edge: u32,
|
||||
/// C
|
||||
pub composition_mode: CompositionMode,
|
||||
}
|
||||
|
||||
impl Default for AnimationFrameComposition {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frame: 0,
|
||||
edit_frame: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
left_edge: 0,
|
||||
top_edge: 0,
|
||||
composition_mode: CompositionMode::AlphaBlend,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimationFrameComposition {
|
||||
fn parse(kv: &Kv) -> Result<Self, Error> {
|
||||
let mut r = Self::default();
|
||||
if let Some(v) = get(kv, b'c') {
|
||||
r.frame = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'r') {
|
||||
r.edit_frame = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'x') {
|
||||
r.x = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'y') {
|
||||
r.y = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'w') {
|
||||
r.width = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'h') {
|
||||
r.height = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'X') {
|
||||
r.left_edge = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'Y') {
|
||||
r.top_edge = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'C') {
|
||||
r.composition_mode = composition_mode(v)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AnimationAction {
|
||||
/// 0
|
||||
Invalid,
|
||||
/// 1
|
||||
Stop,
|
||||
/// 2
|
||||
RunWait,
|
||||
/// 3
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AnimationControl {
|
||||
/// s
|
||||
pub action: AnimationAction,
|
||||
/// r
|
||||
pub frame: u32,
|
||||
/// z
|
||||
pub gap_ms: u32,
|
||||
/// c
|
||||
pub current_frame: u32,
|
||||
/// v
|
||||
pub loops: u32,
|
||||
}
|
||||
|
||||
impl Default for AnimationControl {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
action: AnimationAction::Invalid,
|
||||
frame: 0,
|
||||
gap_ms: 0,
|
||||
current_frame: 0,
|
||||
loops: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimationControl {
|
||||
fn parse(kv: &Kv) -> Result<Self, Error> {
|
||||
let mut r = Self::default();
|
||||
if let Some(v) = get(kv, b's') {
|
||||
r.action = match v {
|
||||
0 => AnimationAction::Invalid,
|
||||
1 => AnimationAction::Stop,
|
||||
2 => AnimationAction::RunWait,
|
||||
3 => AnimationAction::Run,
|
||||
_ => return Err(Error::InvalidFormat),
|
||||
};
|
||||
}
|
||||
if let Some(v) = get(kv, b'r') {
|
||||
r.frame = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'z') {
|
||||
r.gap_ms = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'c') {
|
||||
r.current_frame = v;
|
||||
}
|
||||
if let Some(v) = get(kv, b'v') {
|
||||
r.loops = v;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Delete {
|
||||
/// a/A
|
||||
All(bool),
|
||||
|
||||
/// i/I
|
||||
Id {
|
||||
/// uppercase
|
||||
delete: bool,
|
||||
/// i
|
||||
image_id: u32,
|
||||
/// p
|
||||
placement_id: u32,
|
||||
},
|
||||
|
||||
/// n/N
|
||||
Newest {
|
||||
delete: bool,
|
||||
/// I
|
||||
image_number: u32,
|
||||
/// p
|
||||
placement_id: u32,
|
||||
},
|
||||
|
||||
/// c/C
|
||||
IntersectCursor(bool),
|
||||
|
||||
/// f/F
|
||||
AnimationFrames(bool),
|
||||
|
||||
/// p/P
|
||||
IntersectCell { delete: bool, x: u32, y: u32 },
|
||||
|
||||
/// q/Q
|
||||
IntersectCellZ {
|
||||
delete: bool,
|
||||
x: u32,
|
||||
y: u32,
|
||||
z: i32,
|
||||
},
|
||||
|
||||
/// r/R
|
||||
Range {
|
||||
delete: bool,
|
||||
/// x
|
||||
first: u32,
|
||||
/// y
|
||||
last: u32,
|
||||
},
|
||||
|
||||
/// x/X
|
||||
Column { delete: bool, x: u32 },
|
||||
|
||||
/// y/Y
|
||||
Row { delete: bool, y: u32 },
|
||||
|
||||
/// z/Z
|
||||
Z { delete: bool, z: i32 },
|
||||
}
|
||||
|
||||
impl Delete {
|
||||
fn parse(kv: &Kv) -> Result<Delete, Error> {
|
||||
let what: u8 = match get(kv, b'd') {
|
||||
None => b'a',
|
||||
Some(v) => u8::try_from(v).map_err(|_| Error::InvalidFormat)?,
|
||||
};
|
||||
let up = what.is_ascii_uppercase();
|
||||
let or0 = |k: u8| get(kv, k).unwrap_or(0);
|
||||
|
||||
Ok(match what {
|
||||
b'a' | b'A' => Delete::All(what == b'A'),
|
||||
b'i' | b'I' => Delete::Id {
|
||||
delete: what == b'I',
|
||||
image_id: or0(b'i'),
|
||||
placement_id: or0(b'p'),
|
||||
},
|
||||
b'n' | b'N' => Delete::Newest {
|
||||
delete: what == b'N',
|
||||
image_number: or0(b'I'),
|
||||
placement_id: or0(b'p'),
|
||||
},
|
||||
b'c' | b'C' => Delete::IntersectCursor(what == b'C'),
|
||||
b'f' | b'F' => Delete::AnimationFrames(what == b'F'),
|
||||
b'p' | b'P' => Delete::IntersectCell {
|
||||
delete: up,
|
||||
x: or0(b'x'),
|
||||
y: or0(b'y'),
|
||||
},
|
||||
b'q' | b'Q' => Delete::IntersectCellZ {
|
||||
delete: up,
|
||||
x: or0(b'x'),
|
||||
y: or0(b'y'),
|
||||
// We can bitcast here because of how we parse it earlier.
|
||||
z: or0(b'z') as i32,
|
||||
},
|
||||
b'r' | b'R' => {
|
||||
let x = get(kv, b'x').ok_or(Error::InvalidFormat)?;
|
||||
let y = get(kv, b'y').ok_or(Error::InvalidFormat)?;
|
||||
if x > y {
|
||||
return Err(Error::InvalidFormat);
|
||||
}
|
||||
Delete::Range {
|
||||
delete: up,
|
||||
first: x,
|
||||
last: y,
|
||||
}
|
||||
}
|
||||
b'x' | b'X' => Delete::Column {
|
||||
delete: up,
|
||||
x: or0(b'x'),
|
||||
},
|
||||
b'y' | b'Y' => Delete::Row {
|
||||
delete: up,
|
||||
y: or0(b'y'),
|
||||
},
|
||||
b'z' | b'Z' => Delete::Z {
|
||||
delete: up,
|
||||
z: or0(b'z') as i32,
|
||||
},
|
||||
_ => return Err(Error::InvalidFormat),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn parse(input: &str) -> Result<Command, Error> {
|
||||
let mut p = Parser::new();
|
||||
for c in input.bytes() {
|
||||
p.feed(c)?;
|
||||
}
|
||||
p.complete()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transmission_command() {
|
||||
let command = parse("f=24,s=10,v=20").unwrap();
|
||||
let Control::Transmit(v) = command.control else {
|
||||
panic!("expected transmit")
|
||||
};
|
||||
assert_eq!(v.format, TransmissionFormat::Rgb);
|
||||
assert_eq!(v.width, 10);
|
||||
assert_eq!(v.height, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transmission_ignores_m_if_medium_is_not_direct() {
|
||||
let command = parse("a=t,t=t,m=1").unwrap();
|
||||
let Control::Transmit(v) = command.control else {
|
||||
panic!("expected transmit")
|
||||
};
|
||||
assert_eq!(v.medium, TransmissionMedium::TemporaryFile);
|
||||
assert!(!v.more_chunks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transmission_respects_m_if_medium_is_direct() {
|
||||
let command = parse("a=t,t=d,m=1").unwrap();
|
||||
let Control::Transmit(v) = command.control else {
|
||||
panic!("expected transmit")
|
||||
};
|
||||
assert_eq!(v.medium, TransmissionMedium::Direct);
|
||||
assert!(v.more_chunks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_command() {
|
||||
let command = parse("i=31,s=1,v=1,a=q,t=d,f=24;QUFBQQ").unwrap();
|
||||
let Control::Query(v) = command.control else {
|
||||
panic!("expected query")
|
||||
};
|
||||
assert_eq!(v.medium, TransmissionMedium::Direct);
|
||||
assert_eq!(v.width, 1);
|
||||
assert_eq!(v.height, 1);
|
||||
assert_eq!(v.image_id, 31);
|
||||
assert_eq!(command.data, b"AAAA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_command() {
|
||||
let command = parse("a=p,U=1,i=31,c=80,r=120").unwrap();
|
||||
let Control::Display(v) = command.control else {
|
||||
panic!("expected display")
|
||||
};
|
||||
assert_eq!(v.columns, 80);
|
||||
assert_eq!(v.rows, 120);
|
||||
assert_eq!(v.image_id, 31);
|
||||
assert!(v.virtual_placement);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_command() {
|
||||
let command = parse("a=d,d=p,x=3,y=4").unwrap();
|
||||
let Control::Delete(Delete::IntersectCell { delete, x, y }) = command.control else {
|
||||
panic!("expected delete intersect_cell")
|
||||
};
|
||||
assert!(!delete);
|
||||
assert_eq!(x, 3);
|
||||
assert_eq!(y, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_control_data() {
|
||||
let command = parse(";QUFBQQ").unwrap();
|
||||
assert!(matches!(command.control, Control::Transmit(_)));
|
||||
assert_eq!(command.data, b"AAAA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_unknown_keys_long() {
|
||||
let command = parse("f=24,s=10,v=20,hello=world").unwrap();
|
||||
let Control::Transmit(v) = command.control else {
|
||||
panic!("expected transmit")
|
||||
};
|
||||
assert_eq!(v.format, TransmissionFormat::Rgb);
|
||||
assert_eq!(v.width, 10);
|
||||
assert_eq!(v.height, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_very_long_values() {
|
||||
let command = parse("f=24,s=10,v=2000000000000000000000000000000000000000").unwrap();
|
||||
let Control::Transmit(v) = command.control else {
|
||||
panic!("expected transmit")
|
||||
};
|
||||
assert_eq!(v.format, TransmissionFormat::Rgb);
|
||||
assert_eq!(v.width, 10);
|
||||
assert_eq!(v.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_very_large_negative_values_dont_get_skipped() {
|
||||
let command = parse("a=p,i=1,z=-2000000000").unwrap();
|
||||
let Control::Display(v) = command.control else {
|
||||
panic!("expected display")
|
||||
};
|
||||
assert_eq!(v.image_id, 1);
|
||||
assert_eq!(v.z, -2_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_proper_overflow_error_for_u32() {
|
||||
assert_eq!(parse("a=p,i=10000000000").unwrap_err(), Error::Overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_proper_overflow_error_for_i32() {
|
||||
assert_eq!(parse("a=p,i=1,z=-9999999999").unwrap_err(), Error::Overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_i32_values() {
|
||||
let c = parse("a=p,i=1,z=-1").unwrap();
|
||||
let Control::Display(v) = c.control else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!((v.image_id, v.z), (1, -1));
|
||||
|
||||
let c = parse("a=p,i=1,H=-1").unwrap();
|
||||
let Control::Display(v) = c.control else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!((v.image_id, v.horizontal_offset), (1, -1));
|
||||
|
||||
let c = parse("a=p,i=1,V=-1").unwrap();
|
||||
let Control::Display(v) = c.control else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!((v.image_id, v.vertical_offset), (1, -1));
|
||||
}
|
||||
|
||||
fn encode(r: &Response) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
r.encode(&mut buf).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_encode_nothing_without_id_or_image_number() {
|
||||
assert_eq!(encode(&Response::default()), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_encode_with_only_image_id() {
|
||||
let r = Response {
|
||||
id: 4,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(encode(&r), b"\x1b_Gi=4;OK\x1b\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_encode_with_only_image_number() {
|
||||
let r = Response {
|
||||
image_number: 4,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(encode(&r), b"\x1b_GI=4;OK\x1b\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_encode_with_image_id_and_number() {
|
||||
let r = Response {
|
||||
id: 12,
|
||||
image_number: 4,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(encode(&r), b"\x1b_Gi=12,I=4;OK\x1b\\");
|
||||
}
|
||||
|
||||
fn range(input: &str) -> Result<(bool, u32, u32), Error> {
|
||||
match parse(input)?.control {
|
||||
Control::Delete(Delete::Range {
|
||||
delete,
|
||||
first,
|
||||
last,
|
||||
}) => Ok((delete, first, last)),
|
||||
_ => panic!("expected range"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_range_command_1() {
|
||||
assert_eq!(range("a=d,d=r,x=3,y=4"), Ok((false, 3, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_range_command_2() {
|
||||
assert_eq!(range("a=d,d=R,x=5,y=11"), Ok((true, 5, 11)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_range_command_3() {
|
||||
assert_eq!(range("a=d,d=R,x=5,y=4"), Err(Error::InvalidFormat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_range_command_4() {
|
||||
assert_eq!(range("a=d,d=R,x=5"), Err(Error::InvalidFormat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_range_command_5() {
|
||||
assert_eq!(range("a=d,d=R,y=5"), Err(Error::InvalidFormat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decode_variants() {
|
||||
assert_eq!(decode_base64(b"aGVsbG8gd29ybGQ=").unwrap(), b"hello world");
|
||||
assert_eq!(decode_base64(b"aGVsbG8gd29ybGQ").unwrap(), b"hello world");
|
||||
assert!(decode_base64(b"!!!!").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_string_works() {
|
||||
let c = Parser::parse_string(b"a=q,i=1;QUFBQQ").unwrap();
|
||||
assert_eq!(c.data, b"AAAA");
|
||||
}
|
||||
}
|
||||
@@ -170,4 +170,55 @@ mod tests {
|
||||
stack.pop(100);
|
||||
assert_eq!(stack, FlagStack::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_stack_push_pop() {
|
||||
let mut stack = FlagStack::default();
|
||||
stack.push(Flags {
|
||||
disambiguate: true,
|
||||
..Flags::DISABLED
|
||||
});
|
||||
assert_eq!(
|
||||
stack.current(),
|
||||
Flags {
|
||||
disambiguate: true,
|
||||
..Flags::DISABLED
|
||||
}
|
||||
);
|
||||
stack.pop(1);
|
||||
assert_eq!(stack.current(), Flags::DISABLED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_stack_pop_big_number() {
|
||||
let mut stack = FlagStack::default();
|
||||
stack.pop(100);
|
||||
assert_eq!(stack.current(), Flags::DISABLED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_stack_set() {
|
||||
let mut stack = FlagStack::default();
|
||||
let dis = Flags {
|
||||
disambiguate: true,
|
||||
..Flags::DISABLED
|
||||
};
|
||||
let ev = Flags {
|
||||
report_events: true,
|
||||
..Flags::DISABLED
|
||||
};
|
||||
stack.set(SetMode::Set, dis);
|
||||
assert_eq!(stack.current(), dis);
|
||||
stack.set(SetMode::Or, ev);
|
||||
assert_eq!(
|
||||
stack.current(),
|
||||
Flags {
|
||||
disambiguate: true,
|
||||
report_events: true,
|
||||
..Flags::DISABLED
|
||||
}
|
||||
);
|
||||
stack.set(SetMode::Not, ev);
|
||||
assert_eq!(stack.current(), dis);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Kitty protocol types (ZIG/src/terminal/kitty.zig).
|
||||
|
||||
pub mod color;
|
||||
pub mod graphics_command;
|
||||
pub mod key;
|
||||
@@ -1,5 +1,20 @@
|
||||
//! ghostty-vt: VT parsing layer of Ghostty (parser, OSC/CSI/DCS/APC, SGR,
|
||||
//! colors, modes, stream). See docs/rewrite/ARCHITECTURE.md.
|
||||
|
||||
pub mod ansi;
|
||||
pub mod apc;
|
||||
pub mod charsets;
|
||||
pub mod color;
|
||||
pub mod csi;
|
||||
pub mod dcs;
|
||||
pub mod device_status;
|
||||
pub mod kitty;
|
||||
pub mod modes;
|
||||
pub mod mouse_shape;
|
||||
pub mod osc;
|
||||
pub mod parse_table;
|
||||
pub mod parser;
|
||||
pub mod sgr;
|
||||
pub mod tmux;
|
||||
pub mod utf8_decoder;
|
||||
pub mod x11_color;
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Terminal modes (ZIG/src/terminal/modes.zig): an enum of supported modes,
|
||||
//! a packed bit storage for mode values, and a state struct that handles
|
||||
//! save/restore.
|
||||
//!
|
||||
//! In Zig the enum, packed struct and lookup are generated at comptime from
|
||||
//! one table. Here the single [`ENTRIES`] table drives everything and the
|
||||
//! `Mode` discriminant doubles as the bit index in [`ModePacked`].
|
||||
|
||||
/// A struct that maintains the state of all the settable modes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct ModeState {
|
||||
/// The values of the current modes.
|
||||
pub values: ModePacked,
|
||||
/// The saved values. We only allow saving each mode once.
|
||||
/// This is in line with other terminals that implement XTSAVE
|
||||
/// and XTRESTORE. We can improve this in the future if it becomes
|
||||
/// a real-world issue but we need to be aware of a DoS vector.
|
||||
pub saved: ModePacked,
|
||||
/// The default values for the modes. This is used to reset
|
||||
/// the modes to their default values during reset.
|
||||
pub default: ModePacked,
|
||||
}
|
||||
|
||||
impl ModeState {
|
||||
/// Reset the modes to their default values. This also clears the
|
||||
/// saved state.
|
||||
pub fn reset(&mut self) {
|
||||
self.values = self.default;
|
||||
self.saved = ModePacked::default();
|
||||
}
|
||||
|
||||
/// Set a mode to a value.
|
||||
pub fn set(&mut self, mode: Mode, value: bool) {
|
||||
self.values.set(mode, value);
|
||||
}
|
||||
|
||||
/// Get the value of a mode.
|
||||
pub fn get(&self, mode: Mode) -> bool {
|
||||
self.values.get(mode)
|
||||
}
|
||||
|
||||
/// Save the state of the given mode. This can then be restored
|
||||
/// with restore. This will only be accurate if the previous
|
||||
/// mode was saved exactly once and not restored. Otherwise this
|
||||
/// will just keep restoring the last stored value in memory.
|
||||
pub fn save(&mut self, mode: Mode) {
|
||||
let v = self.values.get(mode);
|
||||
self.saved.set(mode, v);
|
||||
}
|
||||
|
||||
/// See save. This will return the restored value.
|
||||
pub fn restore(&mut self, mode: Mode) -> bool {
|
||||
let v = self.saved.get(mode);
|
||||
self.values.set(mode, v);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Packed storage of all the settable modes (one bit per mode). This
|
||||
/// shouldn't be used directly but rather through the [`ModeState`] struct.
|
||||
/// Defaults match each entry's default value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ModePacked(u64);
|
||||
|
||||
impl Default for ModePacked {
|
||||
fn default() -> Self {
|
||||
let mut bits = 0u64;
|
||||
for e in ENTRIES {
|
||||
if e.default {
|
||||
bits |= 1 << (e.mode as u64);
|
||||
}
|
||||
}
|
||||
Self(bits)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModePacked {
|
||||
pub fn get(&self, mode: Mode) -> bool {
|
||||
self.0 & (1 << (mode as u64)) != 0
|
||||
}
|
||||
|
||||
pub fn set(&mut self, mode: Mode, value: bool) {
|
||||
if value {
|
||||
self.0 |= 1 << (mode as u64);
|
||||
} else {
|
||||
self.0 &= !(1 << (mode as u64));
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw bits.
|
||||
pub fn bits(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The available modes. See [`ENTRIES`] for numeric values.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum Mode {
|
||||
DisableKeyboard,
|
||||
Insert,
|
||||
SendReceiveMode,
|
||||
Linefeed,
|
||||
CursorKeys,
|
||||
M132Column,
|
||||
SlowScroll,
|
||||
ReverseColors,
|
||||
Origin,
|
||||
Wraparound,
|
||||
Autorepeat,
|
||||
MouseEventX10,
|
||||
CursorBlinking,
|
||||
CursorVisible,
|
||||
EnableMode3,
|
||||
ReverseWrap,
|
||||
AltScreenLegacy,
|
||||
KeypadKeys,
|
||||
EnableLeftAndRightMargin,
|
||||
MouseEventNormal,
|
||||
MouseEventButton,
|
||||
MouseEventAny,
|
||||
FocusEvent,
|
||||
MouseFormatUtf8,
|
||||
MouseFormatSgr,
|
||||
MouseAlternateScroll,
|
||||
MouseFormatUrxvt,
|
||||
MouseFormatSgrPixels,
|
||||
IgnoreKeypadWithNumlock,
|
||||
AltEscPrefix,
|
||||
AltSendsEscape,
|
||||
ReverseWrapExtended,
|
||||
AltScreen,
|
||||
SaveCursor,
|
||||
AltScreenSaveCursorClearEnter,
|
||||
BracketedPaste,
|
||||
SynchronizedOutput,
|
||||
GraphemeCluster,
|
||||
ReportColorScheme,
|
||||
InBandSizeReports,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The entry for this mode.
|
||||
pub fn entry(self) -> &'static Entry {
|
||||
&ENTRIES[self as usize]
|
||||
}
|
||||
|
||||
/// The numeric value of the mode.
|
||||
pub fn value(self) -> u16 {
|
||||
self.entry().value
|
||||
}
|
||||
|
||||
/// True if this is an ANSI mode, false if its a DEC mode (?-prefixed).
|
||||
pub fn ansi(self) -> bool {
|
||||
self.entry().ansi
|
||||
}
|
||||
|
||||
/// The snake_case name of the mode (as in the Zig tag name).
|
||||
pub fn name(self) -> &'static str {
|
||||
self.entry().name
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a mode from its numeric value and ANSI-ness.
|
||||
pub fn mode_from_int(v: u16, ansi: bool) -> Option<Mode> {
|
||||
ENTRIES
|
||||
.iter()
|
||||
.find(|e| !e.disabled && e.value == v && e.ansi == ansi)
|
||||
.map(|e| e.mode)
|
||||
}
|
||||
|
||||
/// A single entry of a possible mode we support.
|
||||
#[derive(Debug)]
|
||||
pub struct Entry {
|
||||
pub mode: Mode,
|
||||
pub name: &'static str,
|
||||
pub value: u16,
|
||||
pub default: bool,
|
||||
/// True if this is an ANSI mode, false if its a DEC mode (?-prefixed).
|
||||
pub ansi: bool,
|
||||
/// If true, this mode is disabled and Ghostty will not allow it to be
|
||||
/// set or queried. The mode enum still has it, allowing Ghostty developers
|
||||
/// to develop a mode without exposing it to real users.
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
const fn Entry(mode: Mode, name: &'static str, value: u16, default: bool, ansi: bool) -> Entry {
|
||||
Entry {
|
||||
mode,
|
||||
name,
|
||||
value,
|
||||
default,
|
||||
ansi,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full list of available entries, in `Mode` declaration order.
|
||||
pub const ENTRIES: &[Entry; 40] = &[
|
||||
Entry(Mode::DisableKeyboard, "disable_keyboard", 2, false, true),
|
||||
Entry(Mode::Insert, "insert", 4, false, true),
|
||||
Entry(Mode::SendReceiveMode, "send_receive_mode", 12, true, true),
|
||||
Entry(Mode::Linefeed, "linefeed", 20, false, true),
|
||||
Entry(Mode::CursorKeys, "cursor_keys", 1, false, false),
|
||||
Entry(Mode::M132Column, "132_column", 3, false, false),
|
||||
Entry(Mode::SlowScroll, "slow_scroll", 4, false, false),
|
||||
Entry(Mode::ReverseColors, "reverse_colors", 5, false, false),
|
||||
Entry(Mode::Origin, "origin", 6, false, false),
|
||||
Entry(Mode::Wraparound, "wraparound", 7, true, false),
|
||||
Entry(Mode::Autorepeat, "autorepeat", 8, false, false),
|
||||
Entry(Mode::MouseEventX10, "mouse_event_x10", 9, false, false),
|
||||
Entry(Mode::CursorBlinking, "cursor_blinking", 12, false, false),
|
||||
Entry(Mode::CursorVisible, "cursor_visible", 25, true, false),
|
||||
Entry(Mode::EnableMode3, "enable_mode_3", 40, false, false),
|
||||
Entry(Mode::ReverseWrap, "reverse_wrap", 45, false, false),
|
||||
Entry(Mode::AltScreenLegacy, "alt_screen_legacy", 47, false, false),
|
||||
Entry(Mode::KeypadKeys, "keypad_keys", 66, false, false),
|
||||
Entry(
|
||||
Mode::EnableLeftAndRightMargin,
|
||||
"enable_left_and_right_margin",
|
||||
69,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(
|
||||
Mode::MouseEventNormal,
|
||||
"mouse_event_normal",
|
||||
1000,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(
|
||||
Mode::MouseEventButton,
|
||||
"mouse_event_button",
|
||||
1002,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(Mode::MouseEventAny, "mouse_event_any", 1003, false, false),
|
||||
Entry(Mode::FocusEvent, "focus_event", 1004, false, false),
|
||||
Entry(
|
||||
Mode::MouseFormatUtf8,
|
||||
"mouse_format_utf8",
|
||||
1005,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(Mode::MouseFormatSgr, "mouse_format_sgr", 1006, false, false),
|
||||
Entry(
|
||||
Mode::MouseAlternateScroll,
|
||||
"mouse_alternate_scroll",
|
||||
1007,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
Entry(
|
||||
Mode::MouseFormatUrxvt,
|
||||
"mouse_format_urxvt",
|
||||
1015,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(
|
||||
Mode::MouseFormatSgrPixels,
|
||||
"mouse_format_sgr_pixels",
|
||||
1016,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(
|
||||
Mode::IgnoreKeypadWithNumlock,
|
||||
"ignore_keypad_with_numlock",
|
||||
1035,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
Entry(Mode::AltEscPrefix, "alt_esc_prefix", 1036, true, false),
|
||||
Entry(Mode::AltSendsEscape, "alt_sends_escape", 1039, false, false),
|
||||
Entry(
|
||||
Mode::ReverseWrapExtended,
|
||||
"reverse_wrap_extended",
|
||||
1045,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(Mode::AltScreen, "alt_screen", 1047, false, false),
|
||||
Entry(Mode::SaveCursor, "save_cursor", 1048, false, false),
|
||||
Entry(
|
||||
Mode::AltScreenSaveCursorClearEnter,
|
||||
"alt_screen_save_cursor_clear_enter",
|
||||
1049,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(Mode::BracketedPaste, "bracketed_paste", 2004, false, false),
|
||||
Entry(
|
||||
Mode::SynchronizedOutput,
|
||||
"synchronized_output",
|
||||
2026,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(
|
||||
Mode::GraphemeCluster,
|
||||
"grapheme_cluster",
|
||||
2027,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(
|
||||
Mode::ReportColorScheme,
|
||||
"report_color_scheme",
|
||||
2031,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Entry(
|
||||
Mode::InBandSizeReports,
|
||||
"in_band_size_reports",
|
||||
2048,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn entries_in_order() {
|
||||
for (i, e) in ENTRIES.iter().enumerate() {
|
||||
assert_eq!(e.mode as usize, i);
|
||||
}
|
||||
assert!(ENTRIES.len() <= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_from_int_lookup() {
|
||||
assert_eq!(mode_from_int(4, true), Some(Mode::Insert));
|
||||
assert_eq!(mode_from_int(9, true), None);
|
||||
assert_eq!(mode_from_int(9, false), Some(Mode::MouseEventX10));
|
||||
assert_eq!(mode_from_int(14, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_state() {
|
||||
let mut state = ModeState::default();
|
||||
// Normal set/get
|
||||
assert!(!state.get(Mode::CursorKeys));
|
||||
state.set(Mode::CursorKeys, true);
|
||||
assert!(state.get(Mode::CursorKeys));
|
||||
|
||||
// Save/restore
|
||||
state.save(Mode::CursorKeys);
|
||||
state.set(Mode::CursorKeys, false);
|
||||
assert!(!state.get(Mode::CursorKeys));
|
||||
assert!(state.restore(Mode::CursorKeys));
|
||||
assert!(state.get(Mode::CursorKeys));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_and_reset() {
|
||||
let mut state = ModeState::default();
|
||||
assert!(state.get(Mode::Wraparound));
|
||||
assert!(state.get(Mode::CursorVisible));
|
||||
assert!(state.get(Mode::SendReceiveMode));
|
||||
assert!(!state.get(Mode::Origin));
|
||||
state.set(Mode::Wraparound, false);
|
||||
state.reset();
|
||||
assert!(state.get(Mode::Wraparound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_tag_order() {
|
||||
// Mirrors Zig ModeTag "order": value in low bits, ansi in the top bit.
|
||||
assert_eq!(Mode::CursorKeys.value(), 1);
|
||||
assert!(!Mode::CursorKeys.ansi());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_size() {
|
||||
// Zig asserts @sizeOf(ModePacked) == 8.
|
||||
assert_eq!(std::mem::size_of::<ModePacked>(), 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Mouse cursor shapes (ZIG/src/terminal/mouse_shape.zig).
|
||||
|
||||
/// The possible cursor shapes. Not all app runtimes support these shapes.
|
||||
/// The shapes are always based on the W3C supported cursor styles so we
|
||||
/// can have a cross platform list.
|
||||
// Must be kept in sync with ghostty_cursor_shape_e
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
#[repr(i32)]
|
||||
pub enum MouseShape {
|
||||
#[default]
|
||||
Default,
|
||||
ContextMenu,
|
||||
Help,
|
||||
Pointer,
|
||||
Progress,
|
||||
Wait,
|
||||
Cell,
|
||||
Crosshair,
|
||||
Text,
|
||||
VerticalText,
|
||||
Alias,
|
||||
Copy,
|
||||
Move,
|
||||
NoDrop,
|
||||
NotAllowed,
|
||||
Grab,
|
||||
Grabbing,
|
||||
AllScroll,
|
||||
ColResize,
|
||||
RowResize,
|
||||
NResize,
|
||||
EResize,
|
||||
SResize,
|
||||
WResize,
|
||||
NeResize,
|
||||
NwResize,
|
||||
SeResize,
|
||||
SwResize,
|
||||
EwResize,
|
||||
NsResize,
|
||||
NeswResize,
|
||||
NwseResize,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
}
|
||||
|
||||
impl MouseShape {
|
||||
/// Build cursor shape from string or None if its unknown.
|
||||
pub fn from_string(v: &str) -> Option<MouseShape> {
|
||||
Some(match v {
|
||||
"default" => MouseShape::Default,
|
||||
"context-menu" => MouseShape::ContextMenu,
|
||||
"help" => MouseShape::Help,
|
||||
"pointer" => MouseShape::Pointer,
|
||||
"progress" => MouseShape::Progress,
|
||||
"wait" => MouseShape::Wait,
|
||||
"cell" => MouseShape::Cell,
|
||||
"crosshair" => MouseShape::Crosshair,
|
||||
"text" => MouseShape::Text,
|
||||
"vertical-text" => MouseShape::VerticalText,
|
||||
"alias" => MouseShape::Alias,
|
||||
"copy" => MouseShape::Copy,
|
||||
"move" => MouseShape::Move,
|
||||
"no-drop" => MouseShape::NoDrop,
|
||||
"not-allowed" => MouseShape::NotAllowed,
|
||||
"grab" => MouseShape::Grab,
|
||||
"grabbing" => MouseShape::Grabbing,
|
||||
"all-scroll" => MouseShape::AllScroll,
|
||||
"col-resize" => MouseShape::ColResize,
|
||||
"row-resize" => MouseShape::RowResize,
|
||||
"n-resize" => MouseShape::NResize,
|
||||
"e-resize" => MouseShape::EResize,
|
||||
"s-resize" => MouseShape::SResize,
|
||||
"w-resize" => MouseShape::WResize,
|
||||
"ne-resize" => MouseShape::NeResize,
|
||||
"nw-resize" => MouseShape::NwResize,
|
||||
"se-resize" => MouseShape::SeResize,
|
||||
"sw-resize" => MouseShape::SwResize,
|
||||
"ew-resize" => MouseShape::EwResize,
|
||||
"ns-resize" => MouseShape::NsResize,
|
||||
"nesw-resize" => MouseShape::NeswResize,
|
||||
"nwse-resize" => MouseShape::NwseResize,
|
||||
"zoom-in" => MouseShape::ZoomIn,
|
||||
"zoom-out" => MouseShape::ZoomOut,
|
||||
"left_ptr" => MouseShape::Default,
|
||||
"question_arrow" => MouseShape::Help,
|
||||
"hand" => MouseShape::Pointer,
|
||||
"left_ptr_watch" => MouseShape::Progress,
|
||||
"watch" => MouseShape::Wait,
|
||||
"cross" => MouseShape::Crosshair,
|
||||
"xterm" => MouseShape::Text,
|
||||
"dnd-link" => MouseShape::Alias,
|
||||
"dnd-copy" => MouseShape::Copy,
|
||||
"dnd-move" => MouseShape::Move,
|
||||
"dnd-no-drop" => MouseShape::NoDrop,
|
||||
"crossed_circle" => MouseShape::NotAllowed,
|
||||
"hand1" => MouseShape::Grab,
|
||||
"right_side" => MouseShape::EResize,
|
||||
"top_side" => MouseShape::NResize,
|
||||
"top_right_corner" => MouseShape::NeResize,
|
||||
"top_left_corner" => MouseShape::NwResize,
|
||||
"bottom_side" => MouseShape::SResize,
|
||||
"bottom_right_corner" => MouseShape::SeResize,
|
||||
"bottom_left_corner" => MouseShape::SwResize,
|
||||
"left_side" => MouseShape::WResize,
|
||||
"fleur" => MouseShape::AllScroll,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cursor_shape_from_string() {
|
||||
assert_eq!(
|
||||
MouseShape::from_string("default"),
|
||||
Some(MouseShape::Default)
|
||||
);
|
||||
assert_eq!(MouseShape::from_string("xterm"), Some(MouseShape::Text));
|
||||
assert_eq!(MouseShape::from_string("nope"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Specialized encodings used in some OSC protocols
|
||||
//! (ZIG/src/terminal/osc/encoding.zig).
|
||||
|
||||
/// Kitty defines "Escape code safe UTF-8" as valid UTF-8 with the
|
||||
/// additional requirement of not containing any C0 escape codes
|
||||
/// (0x00-0x1f), DEL (0x7f) and C1 escape codes (0x80-0x9f).
|
||||
///
|
||||
/// Used by OSC 66 (text sizing) and OSC 99 (Kitty notifications).
|
||||
///
|
||||
/// See: https://sw.kovidgoyal.net/kitty/desktop-notifications/#safe-utf8
|
||||
pub fn is_safe_utf8(s: &[u8]) -> bool {
|
||||
let Ok(s) = std::str::from_utf8(s) else {
|
||||
return false;
|
||||
};
|
||||
!s.chars()
|
||||
.any(|c| matches!(c as u32, 0x00..=0x1f | 0x7f | 0x80..=0x9f))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_safe_utf8_cases() {
|
||||
assert!(is_safe_utf8(b"Hello world!"));
|
||||
assert!(is_safe_utf8("安全的ユニコード☀️".as_bytes()));
|
||||
assert!(!is_safe_utf8(b"No linebreaks\nallowed"));
|
||||
assert!(!is_safe_utf8(b"\x07no bells"));
|
||||
assert!(!is_safe_utf8(b"\x1b]9;no OSCs\x1b\\\x1b[m"));
|
||||
assert!(!is_safe_utf8(b"\x9f8-bit escapes are clever, but no"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
//! OSC (Operating System Command) related functions and types
|
||||
//! (ZIG/src/terminal/osc.zig).
|
||||
//!
|
||||
//! OSC is another set of control sequences for terminal programs that start with
|
||||
//! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings
|
||||
//! and other irregular formatting so a dedicated parser is created to handle it.
|
||||
//!
|
||||
//! Rust port notes (W1):
|
||||
//! - Zig `[:0]const u8` fields become owned `Vec<u8>` (no NUL terminator, not
|
||||
//! necessarily valid UTF-8).
|
||||
//! - Zig's "fixed buffer vs. allocating writer" is a single `Vec<u8>` with an
|
||||
//! optional maximum length (`MAX_BUF`, which always reserves one byte for
|
||||
//! the NUL terminator the Zig code writes). The Zig `alloc: ?Allocator` is
|
||||
//! `alloc: bool` ("an allocator is available").
|
||||
//! - `Parser::end` returns an owned `Command` instead of a pointer into the
|
||||
//! parser.
|
||||
|
||||
pub mod encoding;
|
||||
pub mod parsers;
|
||||
|
||||
pub use parsers::color;
|
||||
pub use parsers::semantic_prompt;
|
||||
|
||||
use crate::kitty::color as kitty_color;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
/// This generally shouldn't ever be set except as an initial zero value.
|
||||
/// Ignore it.
|
||||
Invalid,
|
||||
|
||||
/// Set the window title of the terminal
|
||||
///
|
||||
/// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8
|
||||
/// with each code unit further encoded with two hex digits).
|
||||
///
|
||||
/// If title mode 2 is set or the terminal is setup for unconditional
|
||||
/// utf-8 titles text is interpreted as utf-8. Else text is interpreted
|
||||
/// as latin1.
|
||||
ChangeWindowTitle(Vec<u8>),
|
||||
|
||||
/// Set the icon of the terminal window. The name of the icon is not
|
||||
/// well defined, so this is currently ignored by Ghostty at the time
|
||||
/// of writing this. We just parse it so that we don't get parse errors
|
||||
/// in the log.
|
||||
ChangeWindowIcon(Vec<u8>),
|
||||
|
||||
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
|
||||
SemanticPrompt(SemanticPrompt),
|
||||
|
||||
/// Set or get clipboard contents. If data is null, then the current
|
||||
/// clipboard contents are sent to the pty. If data is set, this
|
||||
/// contents is set on the clipboard.
|
||||
ClipboardContents {
|
||||
kind: u8,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
|
||||
/// OSC 7. Reports the current working directory of the shell. This is
|
||||
/// a moderately flawed escape sequence but one that many major terminals
|
||||
/// support so we also support it. To understand the flaws, read through
|
||||
/// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20
|
||||
ReportPwd {
|
||||
/// The reported pwd value. This is not checked for validity. It should
|
||||
/// be a file URL but it is up to the caller to utilize this value.
|
||||
value: Vec<u8>,
|
||||
},
|
||||
|
||||
/// OSC 22. Set the mouse shape. There doesn't seem to be a standard
|
||||
/// naming scheme for cursors but it looks like terminals such as Foot
|
||||
/// are moving towards using the W3C CSS cursor names. For OSC parsing,
|
||||
/// we just parse whatever string is given.
|
||||
MouseShape {
|
||||
value: Vec<u8>,
|
||||
},
|
||||
|
||||
/// OSC color operations to set, reset, or report color settings. Some OSCs
|
||||
/// allow multiple operations to be specified in a single OSC so we need a
|
||||
/// list-like datastructure to manage them.
|
||||
///
|
||||
/// Currently, these OSCs are handled by `color_operation`:
|
||||
///
|
||||
/// 4, 5, 10-19, 104, 105, 110-119
|
||||
ColorOperation {
|
||||
op: color::Operation,
|
||||
requests: color::List,
|
||||
terminator: Terminator,
|
||||
},
|
||||
|
||||
/// Kitty color protocol, OSC 21
|
||||
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
|
||||
KittyColorProtocol(kitty_color::OSC),
|
||||
|
||||
/// Show a desktop notification (OSC 9 or OSC 777)
|
||||
ShowDesktopNotification {
|
||||
title: Vec<u8>,
|
||||
body: Vec<u8>,
|
||||
},
|
||||
|
||||
/// Start a hyperlink (OSC 8)
|
||||
HyperlinkStart {
|
||||
id: Option<Vec<u8>>,
|
||||
uri: Vec<u8>,
|
||||
},
|
||||
|
||||
/// End a hyperlink (OSC 8)
|
||||
HyperlinkEnd,
|
||||
|
||||
/// ConEmu sleep (OSC 9;1)
|
||||
ConemuSleep {
|
||||
duration_ms: u16,
|
||||
},
|
||||
|
||||
/// ConEmu show GUI message box (OSC 9;2)
|
||||
ConemuShowMessageBox(Vec<u8>),
|
||||
|
||||
/// ConEmu change tab title (OSC 9;3)
|
||||
ConemuChangeTabTitle(ConemuChangeTabTitle),
|
||||
|
||||
/// ConEmu progress report (OSC 9;4)
|
||||
ConemuProgressReport(ProgressReport),
|
||||
|
||||
/// ConEmu wait input (OSC 9;5)
|
||||
ConemuWaitInput,
|
||||
|
||||
/// ConEmu GUI macro (OSC 9;6)
|
||||
ConemuGuimacro(Vec<u8>),
|
||||
|
||||
/// ConEmu run process (OSC 9;7)
|
||||
ConemuRunProcess(Vec<u8>),
|
||||
|
||||
/// ConEmu output environment variable (OSC 9;8)
|
||||
ConemuOutputEnvironmentVariable(Vec<u8>),
|
||||
|
||||
/// ConEmu XTerm keyboard and output emulation (OSC 9;10)
|
||||
/// https://conemu.github.io/en/TerminalModes.html
|
||||
ConemuXtermEmulation {
|
||||
/// None => do not change
|
||||
/// Some(false) => turn off
|
||||
/// Some(true) => turn on
|
||||
keyboard: Option<bool>,
|
||||
/// None => do not change
|
||||
/// Some(false) => turn off
|
||||
/// Some(true) => turn on
|
||||
output: Option<bool>,
|
||||
},
|
||||
|
||||
/// ConEmu comment (OSC 9;11)
|
||||
ConemuComment(Vec<u8>),
|
||||
|
||||
/// Kitty text sizing protocol (OSC 66)
|
||||
KittyTextSizing(parsers::kitty_text_sizing::OSC),
|
||||
|
||||
KittyClipboardProtocol(KittyClipboardProtocol),
|
||||
|
||||
/// OSC 3008. Hierarchical context signalling (UAPI spec).
|
||||
/// https://uapi-group.org/specifications/specs/osc_context/
|
||||
ContextSignal(parsers::context_signal::Command),
|
||||
}
|
||||
|
||||
pub type SemanticPrompt = parsers::semantic_prompt::Command;
|
||||
pub type KittyClipboardProtocol = parsers::kitty_clipboard_protocol::OSC;
|
||||
|
||||
/// Payload of `Command::ConemuChangeTabTitle`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConemuChangeTabTitle {
|
||||
Reset,
|
||||
Value(Vec<u8>),
|
||||
}
|
||||
|
||||
/// The discriminant of a [`Command`] (Zig `Command.Key`). Order matches Zig.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Key {
|
||||
Invalid,
|
||||
ChangeWindowTitle,
|
||||
ChangeWindowIcon,
|
||||
SemanticPrompt,
|
||||
ClipboardContents,
|
||||
ReportPwd,
|
||||
MouseShape,
|
||||
ColorOperation,
|
||||
KittyColorProtocol,
|
||||
ShowDesktopNotification,
|
||||
HyperlinkStart,
|
||||
HyperlinkEnd,
|
||||
ConemuSleep,
|
||||
ConemuShowMessageBox,
|
||||
ConemuChangeTabTitle,
|
||||
ConemuProgressReport,
|
||||
ConemuWaitInput,
|
||||
ConemuGuimacro,
|
||||
ConemuRunProcess,
|
||||
ConemuOutputEnvironmentVariable,
|
||||
ConemuXtermEmulation,
|
||||
ConemuComment,
|
||||
KittyTextSizing,
|
||||
KittyClipboardProtocol,
|
||||
ContextSignal,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn key(&self) -> Key {
|
||||
match self {
|
||||
Command::Invalid => Key::Invalid,
|
||||
Command::ChangeWindowTitle(_) => Key::ChangeWindowTitle,
|
||||
Command::ChangeWindowIcon(_) => Key::ChangeWindowIcon,
|
||||
Command::SemanticPrompt(_) => Key::SemanticPrompt,
|
||||
Command::ClipboardContents { .. } => Key::ClipboardContents,
|
||||
Command::ReportPwd { .. } => Key::ReportPwd,
|
||||
Command::MouseShape { .. } => Key::MouseShape,
|
||||
Command::ColorOperation { .. } => Key::ColorOperation,
|
||||
Command::KittyColorProtocol(_) => Key::KittyColorProtocol,
|
||||
Command::ShowDesktopNotification { .. } => Key::ShowDesktopNotification,
|
||||
Command::HyperlinkStart { .. } => Key::HyperlinkStart,
|
||||
Command::HyperlinkEnd => Key::HyperlinkEnd,
|
||||
Command::ConemuSleep { .. } => Key::ConemuSleep,
|
||||
Command::ConemuShowMessageBox(_) => Key::ConemuShowMessageBox,
|
||||
Command::ConemuChangeTabTitle(_) => Key::ConemuChangeTabTitle,
|
||||
Command::ConemuProgressReport(_) => Key::ConemuProgressReport,
|
||||
Command::ConemuWaitInput => Key::ConemuWaitInput,
|
||||
Command::ConemuGuimacro(_) => Key::ConemuGuimacro,
|
||||
Command::ConemuRunProcess(_) => Key::ConemuRunProcess,
|
||||
Command::ConemuOutputEnvironmentVariable(_) => Key::ConemuOutputEnvironmentVariable,
|
||||
Command::ConemuXtermEmulation { .. } => Key::ConemuXtermEmulation,
|
||||
Command::ConemuComment(_) => Key::ConemuComment,
|
||||
Command::KittyTextSizing(_) => Key::KittyTextSizing,
|
||||
Command::KittyClipboardProtocol(_) => Key::KittyClipboardProtocol,
|
||||
Command::ContextSignal(_) => Key::ContextSignal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ConEmu progress report (OSC 9;4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProgressReport {
|
||||
pub state: ProgressState,
|
||||
pub progress: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProgressState {
|
||||
Remove,
|
||||
Set,
|
||||
Error,
|
||||
Indeterminate,
|
||||
Pause,
|
||||
}
|
||||
|
||||
/// The terminator used to end an OSC command. For OSC commands that demand
|
||||
/// a response, we try to match the terminator used in the request since that
|
||||
/// is most likely to be accepted by the calling program.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Terminator {
|
||||
/// The preferred string terminator is ESC followed by \
|
||||
#[default]
|
||||
St,
|
||||
|
||||
/// Some applications and terminals use BELL (0x07) as the string terminator.
|
||||
Bel,
|
||||
}
|
||||
|
||||
impl Terminator {
|
||||
/// Initialize the terminator based on the last byte seen. If the
|
||||
/// last byte is a BEL then we use BEL, otherwise we just assume ST.
|
||||
pub fn init(ch: Option<u8>) -> Terminator {
|
||||
match ch {
|
||||
Some(0x07) => Terminator::Bel,
|
||||
_ => Terminator::St,
|
||||
}
|
||||
}
|
||||
|
||||
/// The terminator as a string.
|
||||
pub fn string(self) -> &'static [u8] {
|
||||
match self {
|
||||
Terminator::St => b"\x1b\\",
|
||||
Terminator::Bel => b"\x07",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Terminator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// The terminator is always ASCII.
|
||||
f.write_str(std::str::from_utf8(self.string()).unwrap_or(""))
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the OSC parser.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Start,
|
||||
Invalid,
|
||||
|
||||
// OSC command prefixes. Not all of these are valid OSCs, but may be
|
||||
// needed to "bridge" to a valid OSC (e.g. to support OSC 777 we need to
|
||||
// have a state "77" even though there is no OSC 77).
|
||||
S0,
|
||||
S1,
|
||||
S2,
|
||||
S3,
|
||||
S4,
|
||||
S5,
|
||||
S6,
|
||||
S7,
|
||||
S8,
|
||||
S9,
|
||||
S30,
|
||||
S300,
|
||||
S3008,
|
||||
S10,
|
||||
S11,
|
||||
S12,
|
||||
S13,
|
||||
S14,
|
||||
S15,
|
||||
S16,
|
||||
S17,
|
||||
S18,
|
||||
S19,
|
||||
S21,
|
||||
S22,
|
||||
S52,
|
||||
S55,
|
||||
S66,
|
||||
S77,
|
||||
S104,
|
||||
S110,
|
||||
S111,
|
||||
S112,
|
||||
S113,
|
||||
S114,
|
||||
S115,
|
||||
S116,
|
||||
S117,
|
||||
S118,
|
||||
S119,
|
||||
S133,
|
||||
S552,
|
||||
S777,
|
||||
S1337,
|
||||
S5522,
|
||||
}
|
||||
|
||||
/// How OSC payload data is being accumulated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Writer {
|
||||
/// Not accumulating (still parsing the numeric prefix).
|
||||
None,
|
||||
/// Fixed-size buffer of `MAX_BUF` bytes.
|
||||
Fixed,
|
||||
/// Growable buffer.
|
||||
Allocating,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Parser {
|
||||
/// Whether an allocator is available, i.e. whether data longer than
|
||||
/// MAX_BUF may be accepted for some commands (e.g. OSC 52), and whether
|
||||
/// commands that require an allocator are processed. (Zig:
|
||||
/// `alloc: ?Allocator`.)
|
||||
pub alloc: bool,
|
||||
|
||||
/// Current state of the parser.
|
||||
pub state: State,
|
||||
|
||||
/// Buffer for temporary storage of OSC data
|
||||
buffer: Vec<u8>,
|
||||
writer: Writer,
|
||||
}
|
||||
|
||||
impl Default for Parser {
|
||||
fn default() -> Self {
|
||||
Self::init(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
/// Maximum size of a "normal" OSC.
|
||||
pub const MAX_BUF: usize = 2048;
|
||||
|
||||
pub fn init(alloc: bool) -> Parser {
|
||||
Parser {
|
||||
alloc,
|
||||
state: State::Start,
|
||||
buffer: Vec::new(),
|
||||
writer: Writer::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the parser state.
|
||||
pub fn reset(&mut self) {
|
||||
self.state = State::Start;
|
||||
self.buffer.clear();
|
||||
self.writer = Writer::None;
|
||||
}
|
||||
|
||||
/// Make sure that we have an allocator. If we don't, set the state to
|
||||
/// invalid so that any additional OSC data is discarded.
|
||||
fn ensure_allocator(&mut self) -> bool {
|
||||
if self.alloc {
|
||||
return true;
|
||||
}
|
||||
log::warn!(
|
||||
"An allocator is required to process OSC {:?} but none was provided.",
|
||||
self.state
|
||||
);
|
||||
self.state = State::Invalid;
|
||||
false
|
||||
}
|
||||
|
||||
/// Set up a fixed Writer to collect the rest of the OSC data.
|
||||
fn write_to_fixed(&mut self) {
|
||||
self.writer = Writer::Fixed;
|
||||
}
|
||||
|
||||
/// Set up an allocating Writer to collect the rest of the OSC data. If we
|
||||
/// don't have an allocator, fall back to writing to a fixed buffer and
|
||||
/// hope that it's big enough.
|
||||
fn write_to_allocating(&mut self) {
|
||||
if !self.alloc {
|
||||
self.write_to_fixed();
|
||||
return;
|
||||
}
|
||||
self.writer = Writer::Allocating;
|
||||
}
|
||||
|
||||
/// Helper for the per-command parsers. Mirrors the Zig prologue
|
||||
/// `writer orelse invalid; writer.writeByte(0) catch invalid;
|
||||
/// writer.buffered()`: returns a copy of the accumulated data (without
|
||||
/// the NUL terminator) or `None` (and marks the parser invalid) if no
|
||||
/// data was being accumulated or there was no room for the terminator.
|
||||
pub fn data(&mut self) -> Option<Vec<u8>> {
|
||||
if self.writer == Writer::None {
|
||||
self.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
// Reserve room for the NUL terminator.
|
||||
if self.writer == Writer::Fixed && self.buffer.len() >= Self::MAX_BUF {
|
||||
self.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(self.buffer.clone())
|
||||
}
|
||||
|
||||
/// Like [`Parser::data`] but without reserving room for the NUL
|
||||
/// terminator (for parsers that, like Zig's color parsers, read
|
||||
/// `writer.buffered()` directly).
|
||||
pub fn buffered(&mut self) -> Option<Vec<u8>> {
|
||||
if self.writer == Writer::None {
|
||||
self.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(self.buffer.clone())
|
||||
}
|
||||
|
||||
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
|
||||
pub fn has_writer(&self) -> bool {
|
||||
self.writer != Writer::None
|
||||
}
|
||||
|
||||
fn write_byte(&mut self, c: u8) {
|
||||
match self.writer {
|
||||
Writer::None => unreachable!(),
|
||||
Writer::Fixed => {
|
||||
if self.buffer.len() >= Self::MAX_BUF {
|
||||
// Overflow: discard any further input.
|
||||
self.state = State::Invalid;
|
||||
} else {
|
||||
self.buffer.push(c);
|
||||
}
|
||||
}
|
||||
Writer::Allocating => self.buffer.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the next character c and advance the parser state.
|
||||
pub fn next(&mut self, c: u8) {
|
||||
#[allow(clippy::enum_glob_use)]
|
||||
use State::*;
|
||||
|
||||
// If the state becomes invalid for any reason, just discard
|
||||
// any further input.
|
||||
if self.state == Invalid {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a writer has been initialized, we just accumulate the rest of the
|
||||
// OSC sequence in the writer's buffer and skip the state machine.
|
||||
if self.writer != Writer::None {
|
||||
self.write_byte(c);
|
||||
return;
|
||||
}
|
||||
|
||||
match self.state {
|
||||
// handled above, so should never be here
|
||||
Invalid => unreachable!(),
|
||||
|
||||
Start => {
|
||||
self.state = match c {
|
||||
b'0' => S0,
|
||||
b'1' => S1,
|
||||
b'2' => S2,
|
||||
b'3' => S3,
|
||||
b'4' => S4,
|
||||
b'5' => S5,
|
||||
b'6' => S6,
|
||||
b'7' => S7,
|
||||
b'8' => S8,
|
||||
b'9' => S9,
|
||||
_ => Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
S3 => {
|
||||
self.state = match c {
|
||||
b'0' => S30,
|
||||
_ => Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
S30 => {
|
||||
self.state = match c {
|
||||
b'0' => S300,
|
||||
_ => Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
S300 => {
|
||||
self.state = match c {
|
||||
b'8' => S3008,
|
||||
_ => Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
S3008 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S1 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
b'0' => self.state = S10,
|
||||
b'1' => self.state = S11,
|
||||
b'2' => self.state = S12,
|
||||
b'3' => self.state = S13,
|
||||
b'4' => self.state = S14,
|
||||
b'5' => self.state = S15,
|
||||
b'6' => self.state = S16,
|
||||
b'7' => self.state = S17,
|
||||
b'8' => self.state = S18,
|
||||
b'9' => self.state = S19,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S10 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
b'4' => self.state = S104,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S104 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S11 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
b'0' => self.state = S110,
|
||||
b'1' => self.state = S111,
|
||||
b'2' => self.state = S112,
|
||||
b'3' => self.state = S113,
|
||||
b'4' => self.state = S114,
|
||||
b'5' => self.state = S115,
|
||||
b'6' => self.state = S116,
|
||||
b'7' => self.state = S117,
|
||||
b'8' => self.state = S118,
|
||||
b'9' => self.state = S119,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S4 | S12 | S14 | S15 | S16 | S17 | S18 | S19 | S21 | S110 | S111 | S112 | S113
|
||||
| S114 | S115 | S116 | S117 | S118 | S119 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S13 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
b'3' => self.state = S133,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S2 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
b'1' => self.state = S21,
|
||||
b'2' => self.state = S22,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S5 => match c {
|
||||
b';' => {
|
||||
if self.ensure_allocator() {
|
||||
self.write_to_fixed();
|
||||
}
|
||||
}
|
||||
b'2' => self.state = S52,
|
||||
b'5' => self.state = S55,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S6 => match c {
|
||||
b'6' => self.state = S66,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S52 | S66 => match c {
|
||||
b';' => self.write_to_allocating(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S55 => match c {
|
||||
b'2' => self.state = S552,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S7 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
b'7' => self.state = S77,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S77 => match c {
|
||||
b'7' => self.state = S777,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S133 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
b'7' => self.state = S1337,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S552 => match c {
|
||||
b'2' => self.state = S5522,
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S1337 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S5522 => match c {
|
||||
b';' => self.write_to_allocating(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
|
||||
S0 | S22 | S777 | S8 | S9 => match c {
|
||||
b';' => self.write_to_fixed(),
|
||||
_ => self.state = Invalid,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// End the sequence and return the command, if any. If the return value
|
||||
/// is None, then no valid command was found. The optional terminator_ch
|
||||
/// is the final character in the OSC sequence. This is used to determine
|
||||
/// the response terminator.
|
||||
pub fn end(&mut self, terminator_ch: Option<u8>) -> Option<Command> {
|
||||
#[allow(clippy::enum_glob_use)]
|
||||
use State::*;
|
||||
match self.state {
|
||||
Start => None,
|
||||
|
||||
Invalid => None,
|
||||
|
||||
S0 | S2 => parsers::change_window_title::parse(self, terminator_ch),
|
||||
|
||||
S1 => parsers::change_window_icon::parse(self, terminator_ch),
|
||||
|
||||
S4 | S5 | S10 | S11 | S12 | S13 | S14 | S15 | S16 | S17 | S18 | S19 | S104 | S110
|
||||
| S111 | S112 | S113 | S114 | S115 | S116 | S117 | S118 | S119 => {
|
||||
parsers::color::parse(self, terminator_ch)
|
||||
}
|
||||
|
||||
S7 => parsers::report_pwd::parse(self, terminator_ch),
|
||||
|
||||
S8 => parsers::hyperlink::parse(self, terminator_ch),
|
||||
|
||||
S9 => parsers::osc9::parse(self, terminator_ch),
|
||||
|
||||
S21 => parsers::kitty_color::parse(self, terminator_ch),
|
||||
|
||||
S22 => parsers::mouse_shape::parse(self, terminator_ch),
|
||||
|
||||
S52 => parsers::clipboard_operation::parse(self, terminator_ch),
|
||||
|
||||
S55 => None,
|
||||
|
||||
S3 | S30 | S300 => None,
|
||||
|
||||
S3008 => parsers::context_signal::parse(self, terminator_ch),
|
||||
|
||||
S6 => None,
|
||||
|
||||
S66 => parsers::kitty_text_sizing::parse(self, terminator_ch),
|
||||
|
||||
S77 => None,
|
||||
|
||||
S133 => parsers::semantic_prompt::parse(self, terminator_ch),
|
||||
|
||||
S552 => None,
|
||||
|
||||
S777 => parsers::rxvt_extension::parse(self, terminator_ch),
|
||||
|
||||
S1337 => parsers::iterm2::parse(self, terminator_ch),
|
||||
|
||||
S5522 => parsers::kitty_clipboard_protocol::parse(self, terminator_ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! OSC 1 (ZIG/src/terminal/osc/parsers/change_window_icon.zig).
|
||||
use crate::osc::{Command, Parser};
|
||||
|
||||
/// Parse OSC 1
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
Some(Command::ChangeWindowIcon(data))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_1_change_window_icon() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"1;ab" {
|
||||
p.next(*c);
|
||||
}
|
||||
let cmd = p.end(None).unwrap();
|
||||
assert_eq!(cmd, Command::ChangeWindowIcon(b"ab".to_vec()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! OSC 0 and OSC 2 (ZIG/src/terminal/osc/parsers/change_window_title.zig).
|
||||
use crate::osc::{Command, Parser};
|
||||
|
||||
/// Parse OSC 0 and OSC 2
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
Some(Command::ChangeWindowTitle(data))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
fn feed(p: &mut Parser, s: &[u8]) {
|
||||
for c in s {
|
||||
p.next(*c);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_0_change_window_title() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, b"0;ab");
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ChangeWindowTitle(b"ab".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_0_longer_than_buffer() {
|
||||
let mut p = Parser::init(false);
|
||||
let mut input = b"0;".to_vec();
|
||||
input.extend(std::iter::repeat_n(b'a', Parser::MAX_BUF + 2));
|
||||
feed(&mut p, &input);
|
||||
assert!(p.end(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_0_one_shorter_than_buffer_length() {
|
||||
let mut p = Parser::init(false);
|
||||
let title = vec![b'a'; Parser::MAX_BUF - 1];
|
||||
let mut input = b"0;".to_vec();
|
||||
input.extend(&title);
|
||||
feed(&mut p, &input);
|
||||
assert_eq!(p.end(None).unwrap(), Command::ChangeWindowTitle(title));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_0_exactly_at_buffer_length() {
|
||||
let mut p = Parser::init(false);
|
||||
let mut input = b"0;".to_vec();
|
||||
input.extend(vec![b'a'; Parser::MAX_BUF]);
|
||||
feed(&mut p, &input);
|
||||
// This should be None because we always reserve space for a null terminator.
|
||||
assert!(p.end(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_2_change_window_title_with_2() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, b"2;ab");
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ChangeWindowTitle(b"ab".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_2_change_window_title_with_utf8() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, b"2;");
|
||||
// '—' EM DASH U+2014 (E2 80 94)
|
||||
feed(&mut p, &[0xE2, 0x80, 0x94]);
|
||||
p.next(b' ');
|
||||
// '‐' HYPHEN U+2010 (E2 80 90)
|
||||
// Intententionally chosen to conflict with the 0x90 C1 control
|
||||
feed(&mut p, &[0xE2, 0x80, 0x90]);
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ChangeWindowTitle("— ‐".as_bytes().to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_2_change_window_title_empty() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, b"2;");
|
||||
assert_eq!(p.end(None).unwrap(), Command::ChangeWindowTitle(Vec::new()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! OSC 52 (ZIG/src/terminal/osc/parsers/clipboard_operation.zig).
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// Parse OSC 52
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S52);
|
||||
let data = parser.data()?;
|
||||
// Zig's `data` includes the NUL terminator, so `data.len == 1` there
|
||||
// means the payload is empty.
|
||||
if data.is_empty() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
if data[0] == b';' {
|
||||
Some(Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: data[1..].to_vec(),
|
||||
})
|
||||
} else {
|
||||
if data.len() < 2 {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
if data[1] != b';' {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(Command::ClipboardContents {
|
||||
kind: data[0],
|
||||
data: data[2..].to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
fn run(alloc: bool, input: &[u8]) -> Option<Command> {
|
||||
let mut p = Parser::init(alloc);
|
||||
for c in input {
|
||||
p.next(*c);
|
||||
}
|
||||
p.end(None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_52_get_set_clipboard() {
|
||||
assert_eq!(
|
||||
run(false, b"52;s;?").unwrap(),
|
||||
Command::ClipboardContents {
|
||||
kind: b's',
|
||||
data: b"?".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_52_get_set_clipboard_optional_parameter() {
|
||||
assert_eq!(
|
||||
run(false, b"52;;?").unwrap(),
|
||||
Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: b"?".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_52_get_set_clipboard_with_allocator() {
|
||||
assert_eq!(
|
||||
run(true, b"52;s;?").unwrap(),
|
||||
Command::ClipboardContents {
|
||||
kind: b's',
|
||||
data: b"?".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_52_clear_clipboard() {
|
||||
assert_eq!(
|
||||
run(false, b"52;;").unwrap(),
|
||||
Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: Vec::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
//! OSC color operations: 4, 5, 10-19, 104, 105, 110-119
|
||||
//! (ZIG/src/terminal/osc/parsers/color.zig).
|
||||
|
||||
use crate::color::{Dynamic as DynamicColor, Rgb, Special as SpecialColor};
|
||||
use crate::osc::{Command, Parser, State, Terminator};
|
||||
|
||||
/// The possible operations we support for colors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Operation {
|
||||
Osc4,
|
||||
Osc5,
|
||||
Osc10,
|
||||
Osc11,
|
||||
Osc12,
|
||||
Osc13,
|
||||
Osc14,
|
||||
Osc15,
|
||||
Osc16,
|
||||
Osc17,
|
||||
Osc18,
|
||||
Osc19,
|
||||
Osc104,
|
||||
Osc105,
|
||||
Osc110,
|
||||
Osc111,
|
||||
Osc112,
|
||||
Osc113,
|
||||
Osc114,
|
||||
Osc115,
|
||||
Osc116,
|
||||
Osc117,
|
||||
Osc118,
|
||||
Osc119,
|
||||
}
|
||||
|
||||
/// Parse OSCs 4, 5, 10-19, 104, 110-119
|
||||
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
|
||||
if !parser.alloc {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
// If we've collected any extra data parse that, otherwise use an empty
|
||||
// string.
|
||||
let data = if parser.has_writer() {
|
||||
parser.buffered()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Check and make sure that we're parsing the correct OSCs
|
||||
let op = match parser.state {
|
||||
State::S4 => Operation::Osc4,
|
||||
State::S5 => Operation::Osc5,
|
||||
State::S10 => Operation::Osc10,
|
||||
State::S11 => Operation::Osc11,
|
||||
State::S12 => Operation::Osc12,
|
||||
State::S13 => Operation::Osc13,
|
||||
State::S14 => Operation::Osc14,
|
||||
State::S15 => Operation::Osc15,
|
||||
State::S16 => Operation::Osc16,
|
||||
State::S17 => Operation::Osc17,
|
||||
State::S18 => Operation::Osc18,
|
||||
State::S19 => Operation::Osc19,
|
||||
State::S104 => Operation::Osc104,
|
||||
State::S110 => Operation::Osc110,
|
||||
State::S111 => Operation::Osc111,
|
||||
State::S112 => Operation::Osc112,
|
||||
State::S113 => Operation::Osc113,
|
||||
State::S114 => Operation::Osc114,
|
||||
State::S115 => Operation::Osc115,
|
||||
State::S116 => Operation::Osc116,
|
||||
State::S117 => Operation::Osc117,
|
||||
State::S118 => Operation::Osc118,
|
||||
State::S119 => Operation::Osc119,
|
||||
_ => {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Command::ColorOperation {
|
||||
op,
|
||||
requests: parse_color(op, &data),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
})
|
||||
}
|
||||
|
||||
/// Zig `std.mem.tokenizeScalar(u8, buf, ';')`: skips empty tokens.
|
||||
fn tokenize(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
buf.split(|&c| c == b';').filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Zig `std.fmt.parseInt(u9, s, 10)`: optional sign, digits only.
|
||||
fn parse_u9(s: &[u8]) -> Option<u16> {
|
||||
let (neg, digits) = match s.first()? {
|
||||
b'+' => (false, &s[1..]),
|
||||
b'-' => (true, &s[1..]),
|
||||
_ => (false, s),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for &c in digits {
|
||||
if !c.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
acc = acc * 10 + (c - b'0') as u32;
|
||||
if acc > 511 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if neg && acc != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(acc as u16)
|
||||
}
|
||||
|
||||
/// Parse any color operation string. This should NOT include the operation
|
||||
/// itself, but only the body of the operation. e.g. for "4;a;b;c" the body
|
||||
/// should be "a;b;c" and the operation should be set accordingly.
|
||||
///
|
||||
/// Color parsing is fairly complicated so we pull this out to a specialized
|
||||
/// function rather than go through our OSC parsing state machine. This is
|
||||
/// much slower and requires more memory (since we need to buffer the full
|
||||
/// request) but grants us an easier to understand and testable implementation.
|
||||
///
|
||||
/// If color changing ends up being a bottleneck we can optimize this later.
|
||||
pub fn parse_color(op: Operation, buf: &[u8]) -> List {
|
||||
let mut it = tokenize(buf);
|
||||
match op {
|
||||
Operation::Osc4 => parse_get_set_ansi_color(Operation::Osc4, &mut it),
|
||||
Operation::Osc5 => parse_get_set_ansi_color(Operation::Osc5, &mut it),
|
||||
Operation::Osc104 => parse_reset_ansi_color(Operation::Osc104, &mut it),
|
||||
Operation::Osc105 => parse_reset_ansi_color(Operation::Osc105, &mut it),
|
||||
Operation::Osc10 => parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it),
|
||||
Operation::Osc11 => parse_get_set_dynamic_color(DynamicColor::Background, &mut it),
|
||||
Operation::Osc12 => parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it),
|
||||
Operation::Osc13 => parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it),
|
||||
Operation::Osc14 => parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it),
|
||||
Operation::Osc15 => parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it),
|
||||
Operation::Osc16 => parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it),
|
||||
Operation::Osc17 => parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it),
|
||||
Operation::Osc18 => parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it),
|
||||
Operation::Osc19 => parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it),
|
||||
Operation::Osc110 => parse_reset_dynamic_color(DynamicColor::Foreground, &mut it),
|
||||
Operation::Osc111 => parse_reset_dynamic_color(DynamicColor::Background, &mut it),
|
||||
Operation::Osc112 => parse_reset_dynamic_color(DynamicColor::Cursor, &mut it),
|
||||
Operation::Osc113 => parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it),
|
||||
Operation::Osc114 => parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it),
|
||||
Operation::Osc115 => parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it),
|
||||
Operation::Osc116 => parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it),
|
||||
Operation::Osc117 => parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it),
|
||||
Operation::Osc118 => parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it),
|
||||
Operation::Osc119 => parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a parsed numeric color to a target for OSC 4/5/104/105.
|
||||
/// `special_only` is true for OSC 5/105; otherwise 0-255 is the palette and
|
||||
/// 256+ is the special offset.
|
||||
fn ansi_target(color: u16, special_only: bool) -> Option<Target> {
|
||||
if special_only {
|
||||
// std.math.cast(u3, color) then intToEnum
|
||||
let v = u8::try_from(color).ok().filter(|&v| v < 8)?;
|
||||
return SpecialColor::from_u8(v).map(Target::Special);
|
||||
}
|
||||
if let Ok(idx) = u8::try_from(color) {
|
||||
return Some(Target::Palette(idx));
|
||||
}
|
||||
let v = u8::try_from(color - 256).ok().filter(|&v| v < 8)?;
|
||||
SpecialColor::from_u8(v).map(Target::Special)
|
||||
}
|
||||
|
||||
/// OSC 4/5
|
||||
fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
|
||||
// Note: in ANY error scenario below we return the accumulated results.
|
||||
// This matches the xterm behavior (see misc.c ChangeAnsiColorRequest)
|
||||
|
||||
let mut result = List::new();
|
||||
loop {
|
||||
// We expect a `c; spec` pair. If either doesn't exist then
|
||||
// we return the results up to this point.
|
||||
let Some(color_str) = it.next() else {
|
||||
return result;
|
||||
};
|
||||
let Some(spec_str) = it.next() else {
|
||||
return result;
|
||||
};
|
||||
|
||||
// Color must be numeric. u9 because that'll fit our palette + special
|
||||
let Some(color) = parse_u9(color_str) else {
|
||||
return result;
|
||||
};
|
||||
|
||||
// Parse the color.
|
||||
let Some(target) = ansi_target(color, op == Operation::Osc5) else {
|
||||
return result;
|
||||
};
|
||||
|
||||
// "?" always results in a query.
|
||||
if spec_str == b"?" {
|
||||
result.push(Request::Query(target));
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
|
||||
return result;
|
||||
};
|
||||
result.push(Request::Set(ColoredTarget { target, color: rgb }));
|
||||
}
|
||||
}
|
||||
|
||||
/// OSC 104/105: Reset ANSI Colors
|
||||
fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
|
||||
// Note: xterm stops parsing the reset list on any error, but we're
|
||||
// more flexible and try the next value. This matches the behavior of
|
||||
// Kitty and I don't see a downside to being more flexible here. Hopefully
|
||||
// no one depends on the exact behavior of xterm.
|
||||
|
||||
let mut result = List::new();
|
||||
loop {
|
||||
let Some(color_str) = it.next() else {
|
||||
// If no parameters are given, we reset the full table.
|
||||
if result.is_empty() {
|
||||
result.push(match op {
|
||||
Operation::Osc104 => Request::ResetPalette,
|
||||
Operation::Osc105 => Request::ResetSpecial,
|
||||
_ => unreachable!(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Empty color strings are ignored, not treated as an error.
|
||||
if color_str.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Color must be numeric. u9 because that'll fit our palette + special
|
||||
let Some(color) = parse_u9(color_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Parse the color.
|
||||
let Some(target) = ansi_target(color, op == Operation::Osc105) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
result.push(Request::Reset(target));
|
||||
}
|
||||
}
|
||||
|
||||
/// OSC 10-19: Get/Set Dynamic Colors
|
||||
fn parse_get_set_dynamic_color<'a>(
|
||||
start: DynamicColor,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
) -> List {
|
||||
// Note: in ANY error scenario below we return the accumulated results.
|
||||
// This matches the xterm behavior (see misc.c ChangeColorsRequest)
|
||||
|
||||
let mut result = List::new();
|
||||
let mut color = start;
|
||||
loop {
|
||||
let Some(spec_str) = it.next() else {
|
||||
return result;
|
||||
};
|
||||
|
||||
if spec_str == b"?" {
|
||||
result.push(Request::Query(Target::Dynamic(color)));
|
||||
} else {
|
||||
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
|
||||
return result;
|
||||
};
|
||||
result.push(Request::Set(ColoredTarget {
|
||||
target: Target::Dynamic(color),
|
||||
color: rgb,
|
||||
}));
|
||||
}
|
||||
|
||||
// Each successive value uses the next color so long as it exists.
|
||||
match color.next() {
|
||||
Some(n) => color = n,
|
||||
None => return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OSC 110-119: Reset Dynamic Colors
|
||||
fn parse_reset_dynamic_color<'a>(
|
||||
color: DynamicColor,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
) -> List {
|
||||
let mut result = List::new();
|
||||
if it.next().is_some() {
|
||||
return result;
|
||||
}
|
||||
result.push(Request::Reset(Target::Dynamic(color)));
|
||||
result
|
||||
}
|
||||
|
||||
/// The list of requests in a color OSC. (Zig uses a SegmentedList to
|
||||
/// avoid copying; a `Vec` is equivalent.)
|
||||
pub type List = Vec<Request>;
|
||||
|
||||
/// A single operation related to the terminal color palette.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
Set(ColoredTarget),
|
||||
Query(Target),
|
||||
Reset(Target),
|
||||
ResetPalette,
|
||||
ResetSpecial,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
Palette(u8),
|
||||
Special(SpecialColor),
|
||||
Dynamic(DynamicColor),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ColoredTarget {
|
||||
pub target: Target,
|
||||
pub color: Rgb,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
const RED: Rgb = Rgb { r: 255, g: 0, b: 0 };
|
||||
const BLUE: Rgb = Rgb { r: 0, g: 0, b: 255 };
|
||||
|
||||
fn set(target: Target, color: Rgb) -> Request {
|
||||
Request::Set(ColoredTarget { target, color })
|
||||
}
|
||||
|
||||
const SPECIALS: [SpecialColor; 5] = [
|
||||
SpecialColor::Bold,
|
||||
SpecialColor::Underline,
|
||||
SpecialColor::Blink,
|
||||
SpecialColor::Reverse,
|
||||
SpecialColor::Italic,
|
||||
];
|
||||
|
||||
const DYNAMICS: [(DynamicColor, Operation, Operation); 10] = [
|
||||
(
|
||||
DynamicColor::Foreground,
|
||||
Operation::Osc10,
|
||||
Operation::Osc110,
|
||||
),
|
||||
(
|
||||
DynamicColor::Background,
|
||||
Operation::Osc11,
|
||||
Operation::Osc111,
|
||||
),
|
||||
(DynamicColor::Cursor, Operation::Osc12, Operation::Osc112),
|
||||
(
|
||||
DynamicColor::PointerForeground,
|
||||
Operation::Osc13,
|
||||
Operation::Osc113,
|
||||
),
|
||||
(
|
||||
DynamicColor::PointerBackground,
|
||||
Operation::Osc14,
|
||||
Operation::Osc114,
|
||||
),
|
||||
(
|
||||
DynamicColor::TektronixForeground,
|
||||
Operation::Osc15,
|
||||
Operation::Osc115,
|
||||
),
|
||||
(
|
||||
DynamicColor::TektronixBackground,
|
||||
Operation::Osc16,
|
||||
Operation::Osc116,
|
||||
),
|
||||
(
|
||||
DynamicColor::HighlightBackground,
|
||||
Operation::Osc17,
|
||||
Operation::Osc117,
|
||||
),
|
||||
(
|
||||
DynamicColor::TektronixCursor,
|
||||
Operation::Osc18,
|
||||
Operation::Osc118,
|
||||
),
|
||||
(
|
||||
DynamicColor::HighlightForeground,
|
||||
Operation::Osc19,
|
||||
Operation::Osc119,
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn osc_4_empty_param() {
|
||||
let mut p = Parser::init(false);
|
||||
for ch in "4;;".bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
assert!(p.end(Some(0x1b)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_4() {
|
||||
// Test every palette index
|
||||
for idx in 0..u8::MAX {
|
||||
// printf '\e]4;0;red\\'
|
||||
let list = parse_color(Operation::Osc4, format!("{idx};red").as_bytes());
|
||||
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
|
||||
|
||||
// printf '\e]4;0;?\\'
|
||||
let list = parse_color(Operation::Osc4, format!("{idx};?").as_bytes());
|
||||
assert_eq!(vec![Request::Query(Target::Palette(idx))], list);
|
||||
|
||||
// Trailing invalid data produces results up to that point
|
||||
let list = parse_color(Operation::Osc4, format!("{idx};red;").as_bytes());
|
||||
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
|
||||
|
||||
// Whitespace doesn't produce a working value in xterm but we
|
||||
// allow it because Kitty does and it seems harmless.
|
||||
let list = parse_color(Operation::Osc4, format!("{idx};red ").as_bytes());
|
||||
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
|
||||
}
|
||||
|
||||
// Test every special color
|
||||
for (i, special) in SPECIALS.iter().enumerate() {
|
||||
let list = parse_color(Operation::Osc4, format!("{};red", 256 + i).as_bytes());
|
||||
assert_eq!(vec![set(Target::Special(*special), RED)], list);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5() {
|
||||
for (i, special) in SPECIALS.iter().enumerate() {
|
||||
let list = parse_color(Operation::Osc5, format!("{i};red").as_bytes());
|
||||
assert_eq!(vec![set(Target::Special(*special), RED)], list);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_4_multiple_requests() {
|
||||
let list = parse_color(Operation::Osc4, b"0;red;1;blue");
|
||||
assert_eq!(
|
||||
vec![set(Target::Palette(0), RED), set(Target::Palette(1), BLUE)],
|
||||
list
|
||||
);
|
||||
|
||||
// Multiple requests with same index overwrite each other
|
||||
let list = parse_color(Operation::Osc4, b"0;red;0;blue");
|
||||
assert_eq!(
|
||||
vec![set(Target::Palette(0), RED), set(Target::Palette(0), BLUE)],
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_104() {
|
||||
for idx in 0..u8::MAX {
|
||||
let list = parse_color(Operation::Osc104, idx.to_string().as_bytes());
|
||||
assert_eq!(vec![Request::Reset(Target::Palette(idx))], list);
|
||||
}
|
||||
|
||||
for (i, special) in SPECIALS.iter().enumerate() {
|
||||
let list = parse_color(Operation::Osc104, (256 + i).to_string().as_bytes());
|
||||
assert_eq!(vec![Request::Reset(Target::Special(*special))], list);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_104_empty_index() {
|
||||
let list = parse_color(Operation::Osc104, b"0;;1");
|
||||
assert_eq!(
|
||||
vec![
|
||||
Request::Reset(Target::Palette(0)),
|
||||
Request::Reset(Target::Palette(1))
|
||||
],
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_104_invalid_index() {
|
||||
let list = parse_color(Operation::Osc104, b"ffff;1");
|
||||
assert_eq!(vec![Request::Reset(Target::Palette(1))], list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_104_reset_all() {
|
||||
let list = parse_color(Operation::Osc104, b"");
|
||||
assert_eq!(vec![Request::ResetPalette], list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_105_reset_all() {
|
||||
let list = parse_color(Operation::Osc105, b"");
|
||||
assert_eq!(vec![Request::ResetSpecial], list);
|
||||
}
|
||||
|
||||
// OSC 10-19: Get/Set Dynamic Colors
|
||||
#[test]
|
||||
fn osc_10_to_19_dynamic() {
|
||||
for (color, op, _) in DYNAMICS {
|
||||
// printf '\e]10;red\e\\'
|
||||
let list = parse_color(op, b"red");
|
||||
assert_eq!(vec![set(Target::Dynamic(color), RED)], list);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_10_to_19_dynamic_multiple() {
|
||||
// printf '\e]11;red;blue\e\\'
|
||||
let list = parse_color(Operation::Osc11, b"red;blue");
|
||||
assert_eq!(
|
||||
vec![
|
||||
set(Target::Dynamic(DynamicColor::Background), RED),
|
||||
set(Target::Dynamic(DynamicColor::Cursor), BLUE)
|
||||
],
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
// OSC 110-119: Reset Dynamic Colors
|
||||
#[test]
|
||||
fn osc_110_to_119_reset_dynamic() {
|
||||
for (color, _, op) in DYNAMICS {
|
||||
let expected = vec![Request::Reset(Target::Dynamic(color))];
|
||||
|
||||
// printf '\e]110\e\\'
|
||||
assert_eq!(expected, parse_color(op, b""));
|
||||
|
||||
// xterm allows a trailing semicolon.
|
||||
assert_eq!(expected, parse_color(op, b";"));
|
||||
|
||||
// xterm does NOT allow any whitespace
|
||||
assert_eq!(0, parse_color(op, b" ").len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_112_incomplete_sequence() {
|
||||
// https://github.com/darrenstarr/VtNetCore/pull/14
|
||||
let mut p = Parser::init(true);
|
||||
for ch in b"112" {
|
||||
p.next(*ch);
|
||||
}
|
||||
let cmd = p.end(Some(0x07)).unwrap();
|
||||
let Command::ColorOperation {
|
||||
op,
|
||||
requests,
|
||||
terminator,
|
||||
} = cmd
|
||||
else {
|
||||
panic!("wrong command");
|
||||
};
|
||||
assert_eq!(Terminator::Bel, terminator);
|
||||
assert_eq!(Operation::Osc112, op);
|
||||
assert_eq!(
|
||||
vec![Request::Reset(Target::Dynamic(DynamicColor::Cursor))],
|
||||
requests
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
//! OSC 3008: Hierarchical Context Signalling (UAPI spec)
|
||||
//! Specification: https://uapi-group.org/specifications/specs/osc_context/
|
||||
//! (ZIG/src/terminal/osc/parsers/context_signal.zig)
|
||||
//!
|
||||
//! OSC 3008 allows programs to signal context changes to the terminal emulator.
|
||||
//! Each context has an identifier and metadata fields. Contexts are hierarchical
|
||||
//! and form a stack.
|
||||
|
||||
use crate::osc::{Command as OscCommand, Parser, State};
|
||||
|
||||
/// Maximum length of a context identifier (per spec).
|
||||
const MAX_CONTEXT_ID_LEN: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// OSC 3008;start=<id> - initiates, updates, or returns to a context.
|
||||
Start,
|
||||
/// OSC 3008;end=<id> - terminates a context.
|
||||
End,
|
||||
}
|
||||
|
||||
/// A single OSC 3008 context signal command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Command {
|
||||
pub action: Action,
|
||||
/// The context identifier. Must be 1-64 characters in the 32..126 byte range.
|
||||
pub id: Vec<u8>,
|
||||
/// Raw unparsed metadata fields after the context ID.
|
||||
/// Fields are semicolon-separated key=value pairs.
|
||||
/// Parsed lazily via `read_option`.
|
||||
pub metadata: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Value of a decoded metadata field (Zig `Field.Type()`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FieldValue<'a> {
|
||||
Type(ContextType),
|
||||
Exit(ExitStatus),
|
||||
U64(u64),
|
||||
Str(&'a [u8]),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Read a metadata field value from the raw fields string.
|
||||
/// Returns None if the field is not present or malformed.
|
||||
pub fn read_option(&self, option: Field) -> Option<FieldValue<'_>> {
|
||||
option.read(&self.metadata)
|
||||
}
|
||||
|
||||
pub fn context_type(&self) -> Option<ContextType> {
|
||||
match self.read_option(Field::Type)? {
|
||||
FieldValue::Type(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit(&self) -> Option<ExitStatus> {
|
||||
match self.read_option(Field::Exit)? {
|
||||
FieldValue::Exit(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For the numeric fields (`pid`, `pidfdid`, `status`).
|
||||
pub fn read_u64(&self, option: Field) -> Option<u64> {
|
||||
match self.read_option(option)? {
|
||||
FieldValue::U64(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For the string fields.
|
||||
pub fn read_str(&self, option: Field) -> Option<&[u8]> {
|
||||
match self.read_option(option)? {
|
||||
FieldValue::Str(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context types defined by the specification.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContextType {
|
||||
Boot,
|
||||
Container,
|
||||
Vm,
|
||||
Elevate,
|
||||
Chpriv,
|
||||
Subcontext,
|
||||
Remote,
|
||||
Shell,
|
||||
Command,
|
||||
App,
|
||||
Service,
|
||||
Session,
|
||||
}
|
||||
|
||||
impl ContextType {
|
||||
pub fn parse(value: &[u8]) -> Option<ContextType> {
|
||||
Some(match value {
|
||||
b"boot" => ContextType::Boot,
|
||||
b"container" => ContextType::Container,
|
||||
b"vm" => ContextType::Vm,
|
||||
b"elevate" => ContextType::Elevate,
|
||||
b"chpriv" => ContextType::Chpriv,
|
||||
b"subcontext" => ContextType::Subcontext,
|
||||
b"remote" => ContextType::Remote,
|
||||
b"shell" => ContextType::Shell,
|
||||
b"command" => ContextType::Command,
|
||||
b"app" => ContextType::App,
|
||||
b"service" => ContextType::Service,
|
||||
b"session" => ContextType::Session,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit status for the `exit` end-sequence field.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExitStatus {
|
||||
Success,
|
||||
Failure,
|
||||
Crash,
|
||||
Interrupt,
|
||||
}
|
||||
|
||||
impl ExitStatus {
|
||||
pub fn parse(value: &[u8]) -> Option<ExitStatus> {
|
||||
Some(match value {
|
||||
b"success" => ExitStatus::Success,
|
||||
b"failure" => ExitStatus::Failure,
|
||||
b"crash" => ExitStatus::Crash,
|
||||
b"interrupt" => ExitStatus::Interrupt,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata fields that can appear in OSC 3008 sequences.
|
||||
/// Fields are read lazily from the raw string using the `read` method.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Field {
|
||||
// Start sequence fields
|
||||
Type,
|
||||
User,
|
||||
Hostname,
|
||||
Machineid,
|
||||
Bootid,
|
||||
Pid,
|
||||
Pidfdid,
|
||||
Comm,
|
||||
Cwd,
|
||||
Cmdline,
|
||||
Vm,
|
||||
Container,
|
||||
Targetuser,
|
||||
Targethost,
|
||||
Sessionid,
|
||||
|
||||
// End sequence fields
|
||||
Exit,
|
||||
Status,
|
||||
Signal,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Field::Type => "type",
|
||||
Field::User => "user",
|
||||
Field::Hostname => "hostname",
|
||||
Field::Machineid => "machineid",
|
||||
Field::Bootid => "bootid",
|
||||
Field::Pid => "pid",
|
||||
Field::Pidfdid => "pidfdid",
|
||||
Field::Comm => "comm",
|
||||
Field::Cwd => "cwd",
|
||||
Field::Cmdline => "cmdline",
|
||||
Field::Vm => "vm",
|
||||
Field::Container => "container",
|
||||
Field::Targetuser => "targetuser",
|
||||
Field::Targethost => "targethost",
|
||||
Field::Sessionid => "sessionid",
|
||||
Field::Exit => "exit",
|
||||
Field::Status => "status",
|
||||
Field::Signal => "signal",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the field value from the raw fields string.
|
||||
///
|
||||
/// The raw fields string contains semicolon-separated key=value pairs
|
||||
/// e.g. "type=container;user=lennart;hostname=zeta".
|
||||
///
|
||||
/// Unknown or malformed fields are ignored per the specification.
|
||||
pub fn read(self, raw: &[u8]) -> Option<FieldValue<'_>> {
|
||||
let key = self.key().as_bytes();
|
||||
for full in raw.split(|&c| c == b';') {
|
||||
let value = match full.iter().position(|&c| c == b'=') {
|
||||
Some(eql) if &full[..eql] == key => &full[eql + 1..],
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
return match self {
|
||||
Field::Type => ContextType::parse(value).map(FieldValue::Type),
|
||||
Field::Exit => ExitStatus::parse(value).map(FieldValue::Exit),
|
||||
Field::Pid | Field::Pidfdid | Field::Status => {
|
||||
if value.iter().any(|c| !c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(value)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(FieldValue::U64)
|
||||
}
|
||||
// String fields
|
||||
_ => {
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(FieldValue::Str(value))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Not found
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse OSC 3008: hierarchical context signalling.
|
||||
///
|
||||
/// Expected data format (after "3008;" prefix has been consumed by the state machine):
|
||||
/// start=<id>[;<field>=<value>]*
|
||||
/// end=<id>[;<field>=<value>]*
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscCommand> {
|
||||
let data = parser.buffered()?;
|
||||
if data.is_empty() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Determine the action (start= or end=)
|
||||
let (action, prefix_len) = if data.starts_with(b"start=") {
|
||||
(Action::Start, b"start=".len())
|
||||
} else if data.starts_with(b"end=") {
|
||||
(Action::End, b"end=".len())
|
||||
} else {
|
||||
log::warn!(
|
||||
"OSC 3008: expected 'start=' or 'end=' prefix, got: {}",
|
||||
String::from_utf8_lossy(&data[..data.len().min(10)])
|
||||
);
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
|
||||
// Skip past the "start=" or "end=" prefix
|
||||
let rest = &data[prefix_len..];
|
||||
|
||||
if rest.is_empty() {
|
||||
log::warn!("OSC 3008: missing context ID");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract the context ID (up to the first semicolon or end of data)
|
||||
let id_end = rest.iter().position(|&c| c == b';').unwrap_or(rest.len());
|
||||
let id = &rest[..id_end];
|
||||
|
||||
// Validate context ID length (1-64 chars per spec)
|
||||
if id.is_empty() || id.len() > MAX_CONTEXT_ID_LEN {
|
||||
log::warn!(
|
||||
"OSC 3008: context ID length {} out of range (1-{})",
|
||||
id.len(),
|
||||
MAX_CONTEXT_ID_LEN
|
||||
);
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Validate context ID characters (32..126 byte range per spec)
|
||||
for &c in id {
|
||||
if !(0x20..=0x7e).contains(&c) {
|
||||
log::warn!("OSC 3008: invalid character 0x{c:02x} in context ID");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract raw metadata fields (everything after the ID)
|
||||
let metadata = if id_end < rest.len() {
|
||||
rest[id_end + 1..].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Some(OscCommand::ContextSignal(Command {
|
||||
action,
|
||||
id: id.to_vec(),
|
||||
metadata,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> Option<OscCommand> {
|
||||
let mut p = Parser::init(false);
|
||||
for &ch in input.as_bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
p.end(None)
|
||||
}
|
||||
|
||||
fn cs(input: &str) -> Command {
|
||||
match run(input) {
|
||||
Some(OscCommand::ContextSignal(c)) => c,
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_basic_start_command() {
|
||||
let c = cs("3008;start=abc123");
|
||||
assert_eq!(Action::Start, c.action);
|
||||
assert_eq!(b"abc123", &c.id[..]);
|
||||
assert_eq!(b"", &c.metadata[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_basic_end_command() {
|
||||
let c = cs("3008;end=abc123");
|
||||
assert_eq!(Action::End, c.action);
|
||||
assert_eq!(b"abc123", &c.id[..]);
|
||||
assert_eq!(b"", &c.metadata[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_start_with_metadata_fields() {
|
||||
let c = cs(
|
||||
"3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta",
|
||||
);
|
||||
assert_eq!(Action::Start, c.action);
|
||||
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
|
||||
assert_eq!(Some(ContextType::Container), c.context_type());
|
||||
assert_eq!(b"lennart", c.read_str(Field::User).unwrap());
|
||||
assert_eq!(b"zeta", c.read_str(Field::Hostname).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_start_with_all_common_fields() {
|
||||
let c = cs(
|
||||
"3008;start=myctx;type=shell;user=root;hostname=myhost;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=bash",
|
||||
);
|
||||
assert_eq!(Some(ContextType::Shell), c.context_type());
|
||||
assert_eq!(b"root", c.read_str(Field::User).unwrap());
|
||||
assert_eq!(b"myhost", c.read_str(Field::Hostname).unwrap());
|
||||
assert_eq!(
|
||||
b"3deb5353d3ba43d08201c136a47ead7b",
|
||||
c.read_str(Field::Machineid).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
b"d4a3d0fdf2e24fdea6d971ce73f4fbf2",
|
||||
c.read_str(Field::Bootid).unwrap()
|
||||
);
|
||||
assert_eq!(Some(1062862), c.read_u64(Field::Pid));
|
||||
assert_eq!(Some(1063162), c.read_u64(Field::Pidfdid));
|
||||
assert_eq!(b"bash", c.read_str(Field::Comm).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_end_with_exit_metadata() {
|
||||
let c = cs("3008;end=myctx;exit=success;status=0");
|
||||
assert_eq!(Action::End, c.action);
|
||||
assert_eq!(b"myctx", &c.id[..]);
|
||||
assert_eq!(Some(ExitStatus::Success), c.exit());
|
||||
assert_eq!(Some(0), c.read_u64(Field::Status));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_end_with_failure_exit() {
|
||||
let c = cs("3008;end=myctx;exit=failure;status=1;signal=SIGKILL");
|
||||
assert_eq!(Some(ExitStatus::Failure), c.exit());
|
||||
assert_eq!(Some(1), c.read_u64(Field::Status));
|
||||
assert_eq!(b"SIGKILL", c.read_str(Field::Signal).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_unknown_fields_are_ignored() {
|
||||
let c = cs("3008;start=myctx;type=shell;unknownfield=value;user=root");
|
||||
assert_eq!(Some(ContextType::Shell), c.context_type());
|
||||
assert_eq!(b"root", c.read_str(Field::User).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_missing_field_returns_null() {
|
||||
let c = cs("3008;start=myctx;user=lennart");
|
||||
assert!(c.context_type().is_none());
|
||||
assert!(c.read_str(Field::Hostname).is_none());
|
||||
assert!(c.read_u64(Field::Pid).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_invalid_prefix() {
|
||||
assert!(run("3008;bogus=abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_empty_data() {
|
||||
assert!(run("3008;start=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_max_length_context_id() {
|
||||
let id = "a".repeat(64);
|
||||
let c = cs(&format!("3008;start={id}"));
|
||||
assert_eq!(id.as_bytes(), &c.id[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_over_length_context_id() {
|
||||
let id = "a".repeat(65);
|
||||
assert!(run(&format!("3008;start={id}")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_context_type_enum_coverage() {
|
||||
let types = [
|
||||
("boot", ContextType::Boot),
|
||||
("container", ContextType::Container),
|
||||
("vm", ContextType::Vm),
|
||||
("elevate", ContextType::Elevate),
|
||||
("chpriv", ContextType::Chpriv),
|
||||
("subcontext", ContextType::Subcontext),
|
||||
("remote", ContextType::Remote),
|
||||
("shell", ContextType::Shell),
|
||||
("command", ContextType::Command),
|
||||
("app", ContextType::App),
|
||||
("service", ContextType::Service),
|
||||
("session", ContextType::Session),
|
||||
];
|
||||
for (s, e) in types {
|
||||
assert_eq!(Some(e), ContextType::parse(s.as_bytes()));
|
||||
}
|
||||
assert!(ContextType::parse(b"invalid").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_exit_status_enum_coverage() {
|
||||
assert_eq!(Some(ExitStatus::Success), ExitStatus::parse(b"success"));
|
||||
assert_eq!(Some(ExitStatus::Failure), ExitStatus::parse(b"failure"));
|
||||
assert_eq!(Some(ExitStatus::Crash), ExitStatus::parse(b"crash"));
|
||||
assert_eq!(Some(ExitStatus::Interrupt), ExitStatus::parse(b"interrupt"));
|
||||
assert!(ExitStatus::parse(b"invalid").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_spec_example_container_start() {
|
||||
let c = cs(
|
||||
"3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=systemd-nspawn;container=foobar",
|
||||
);
|
||||
assert_eq!(Action::Start, c.action);
|
||||
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
|
||||
assert_eq!(Some(ContextType::Container), c.context_type());
|
||||
assert_eq!(b"lennart", c.read_str(Field::User).unwrap());
|
||||
assert_eq!(b"zeta", c.read_str(Field::Hostname).unwrap());
|
||||
assert_eq!(b"systemd-nspawn", c.read_str(Field::Comm).unwrap());
|
||||
assert_eq!(b"foobar", c.read_str(Field::Container).unwrap());
|
||||
assert_eq!(Some(1062862), c.read_u64(Field::Pid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_spec_example_context_end() {
|
||||
let c = cs("3008;end=bed86fab93af4328bbed0a1224af6d40");
|
||||
assert_eq!(Action::End, c.action);
|
||||
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_cwd_and_cmdline_fields() {
|
||||
let c = cs("3008;start=myctx;type=command;cwd=/home/user;cmdline=ls -la");
|
||||
assert_eq!(b"/home/user", c.read_str(Field::Cwd).unwrap());
|
||||
assert_eq!(b"ls -la", c.read_str(Field::Cmdline).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_3008_start_command_with_no_fields() {
|
||||
let c = cs("3008;start=simpleid");
|
||||
assert_eq!(Action::Start, c.action);
|
||||
assert_eq!(b"simpleid", &c.id[..]);
|
||||
assert!(c.context_type().is_none());
|
||||
assert!(c.read_str(Field::User).is_none());
|
||||
assert!(c.exit().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! OSC 8 hyperlinks (ZIG/src/terminal/osc/parsers/hyperlink.zig).
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// Parse OSC 8 hyperlinks
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
let Some(s) = data.iter().position(|&b| b == b';') else {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
|
||||
let uri = data[s + 1..].to_vec();
|
||||
let mut id: Option<Vec<u8>> = None;
|
||||
|
||||
// The params section is `key=value` pairs separated by ':'. This follows
|
||||
// the Zig implementation byte for byte: ':' and the ';' terminator are
|
||||
// turned into NULs and the segments are walked by searching for the next
|
||||
// NUL starting one past the segment start.
|
||||
let mut kvs = data[..s].to_vec();
|
||||
kvs.push(0);
|
||||
for b in &mut kvs {
|
||||
if *b == b':' {
|
||||
*b = 0;
|
||||
}
|
||||
}
|
||||
let mut kv_start = 0;
|
||||
while kv_start < kvs.len() {
|
||||
let Some(kv_end) = kvs
|
||||
.iter()
|
||||
.skip(kv_start + 1)
|
||||
.position(|&b| b == 0)
|
||||
.map(|i| i + kv_start + 1)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let kv = &kvs[kv_start..kv_end];
|
||||
let Some(v) = kv.iter().position(|&b| b == b'=') else {
|
||||
break;
|
||||
};
|
||||
let key = &kv[..v];
|
||||
let value = &kv[v + 1..];
|
||||
if key == b"id" {
|
||||
if !value.is_empty() {
|
||||
id = Some(value.to_vec());
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"unknown hyperlink option: '{}'",
|
||||
String::from_utf8_lossy(key)
|
||||
);
|
||||
}
|
||||
kv_start = kv_end + 1;
|
||||
}
|
||||
|
||||
if uri.is_empty() {
|
||||
if id.is_some() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
return Some(Command::HyperlinkEnd);
|
||||
}
|
||||
|
||||
Some(Command::HyperlinkStart { id, uri })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> Option<Command> {
|
||||
let mut p = Parser::init(false);
|
||||
for c in input {
|
||||
p.next(*c);
|
||||
}
|
||||
p.end(Some(0x1b))
|
||||
}
|
||||
|
||||
fn start(id: Option<&str>, uri: &str) -> Command {
|
||||
Command::HyperlinkStart {
|
||||
id: id.map(|s| s.as_bytes().to_vec()),
|
||||
uri: uri.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink() {
|
||||
assert_eq!(
|
||||
run(b"8;;http://example.com").unwrap(),
|
||||
start(None, "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_id_set() {
|
||||
assert_eq!(
|
||||
run(b"8;id=foo;http://example.com").unwrap(),
|
||||
start(Some("foo"), "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_empty_id() {
|
||||
assert_eq!(
|
||||
run(b"8;id=;http://example.com").unwrap(),
|
||||
start(None, "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_incomplete_key() {
|
||||
assert_eq!(
|
||||
run(b"8;id;http://example.com").unwrap(),
|
||||
start(None, "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_empty_key() {
|
||||
assert_eq!(
|
||||
run(b"8;=value;http://example.com").unwrap(),
|
||||
start(None, "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_empty_key_and_id() {
|
||||
assert_eq!(
|
||||
run(b"8;=value:id=foo;http://example.com").unwrap(),
|
||||
start(Some("foo"), "http://example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_with_empty_uri() {
|
||||
assert!(run(b"8;id=foo;").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_8_hyperlink_end() {
|
||||
assert_eq!(run(b"8;;").unwrap(), Command::HyperlinkEnd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! OSC 1337 (iTerm2) (ZIG/src/terminal/osc/parsers/iterm2.zig).
|
||||
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// iTerm2 OSC 1337 keys. Lookup is ASCII case-insensitive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Key {
|
||||
AddAnnotation,
|
||||
AddHiddenAnnotation,
|
||||
Block,
|
||||
Button,
|
||||
ClearCapturedOutput,
|
||||
ClearScrollback,
|
||||
Copy,
|
||||
CopyToClipboard,
|
||||
CurrentDir,
|
||||
CursorShape,
|
||||
Custom,
|
||||
Disinter,
|
||||
EndCopy,
|
||||
File,
|
||||
FileEnd,
|
||||
FilePart,
|
||||
HighlightCursorLine,
|
||||
MultipartFile,
|
||||
OpenURL,
|
||||
PopKeyLabels,
|
||||
PushKeyLabels,
|
||||
RemoteHost,
|
||||
ReportCellSize,
|
||||
ReportVariable,
|
||||
RequestAttention,
|
||||
RequestUpload,
|
||||
SetBackgroundImageFile,
|
||||
SetBadgeFormat,
|
||||
SetColors,
|
||||
SetKeyLabel,
|
||||
SetMark,
|
||||
SetProfile,
|
||||
SetUserVar,
|
||||
ShellIntegrationVersion,
|
||||
StealFocus,
|
||||
UnicodeVersion,
|
||||
}
|
||||
|
||||
const KEYS: &[(&str, Key)] = &[
|
||||
("AddAnnotation", Key::AddAnnotation),
|
||||
("AddHiddenAnnotation", Key::AddHiddenAnnotation),
|
||||
("Block", Key::Block),
|
||||
("Button", Key::Button),
|
||||
("ClearCapturedOutput", Key::ClearCapturedOutput),
|
||||
("ClearScrollback", Key::ClearScrollback),
|
||||
("Copy", Key::Copy),
|
||||
("CopyToClipboard", Key::CopyToClipboard),
|
||||
("CurrentDir", Key::CurrentDir),
|
||||
("CursorShape", Key::CursorShape),
|
||||
("Custom", Key::Custom),
|
||||
("Disinter", Key::Disinter),
|
||||
("EndCopy", Key::EndCopy),
|
||||
("File", Key::File),
|
||||
("FileEnd", Key::FileEnd),
|
||||
("FilePart", Key::FilePart),
|
||||
("HighlightCursorLine", Key::HighlightCursorLine),
|
||||
("MultipartFile", Key::MultipartFile),
|
||||
("OpenURL", Key::OpenURL),
|
||||
("PopKeyLabels", Key::PopKeyLabels),
|
||||
("PushKeyLabels", Key::PushKeyLabels),
|
||||
("RemoteHost", Key::RemoteHost),
|
||||
("ReportCellSize", Key::ReportCellSize),
|
||||
("ReportVariable", Key::ReportVariable),
|
||||
("RequestAttention", Key::RequestAttention),
|
||||
("RequestUpload", Key::RequestUpload),
|
||||
("SetBackgroundImageFile", Key::SetBackgroundImageFile),
|
||||
("SetBadgeFormat", Key::SetBadgeFormat),
|
||||
("SetColors", Key::SetColors),
|
||||
("SetKeyLabel", Key::SetKeyLabel),
|
||||
("SetMark", Key::SetMark),
|
||||
("SetProfile", Key::SetProfile),
|
||||
("SetUserVar", Key::SetUserVar),
|
||||
("ShellIntegrationVersion", Key::ShellIntegrationVersion),
|
||||
("StealFocus", Key::StealFocus),
|
||||
("UnicodeVersion", Key::UnicodeVersion),
|
||||
];
|
||||
|
||||
fn lookup(s: &[u8]) -> Option<Key> {
|
||||
KEYS.iter()
|
||||
.find(|(name, _)| name.as_bytes().eq_ignore_ascii_case(s))
|
||||
.map(|(_, k)| *k)
|
||||
}
|
||||
|
||||
/// Parse OSC 1337
|
||||
/// https://iterm2.com/documentation-escape-codes.html
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S1337);
|
||||
|
||||
let data = parser.data()?;
|
||||
|
||||
let (key_str, value): (&[u8], Option<&[u8]>) = match data.iter().position(|&b| b == b'=') {
|
||||
None => (&data[..], None),
|
||||
Some(index) => (&data[..index], Some(&data[index + 1..])),
|
||||
};
|
||||
|
||||
// (Zig sets `parser.command = .invalid` and returns null on all of the
|
||||
// failure paths below.)
|
||||
let key = lookup(key_str)?;
|
||||
|
||||
match key {
|
||||
Key::Copy => {
|
||||
let value = value?;
|
||||
|
||||
// Sending a blank entry to clear the clipboard is an OSC 52-ism,
|
||||
// make sure that is invalid here.
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// base64 value must be prefixed by a colon
|
||||
if value[0] != b':' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value = &value[1..];
|
||||
|
||||
// Sending a blank entry to clear the clipboard is an OSC 52-ism,
|
||||
// make sure that is invalid here.
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Sending a '?' to query the clipboard is an OSC 52-ism, make sure
|
||||
// that is invalid here.
|
||||
if value == b"?" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// It would be better to check for valid base64 data here, but that
|
||||
// would mean parsing the base64 data twice in the "normal" case.
|
||||
|
||||
Some(Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: value.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
Key::CurrentDir => {
|
||||
let value = value?;
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ReportPwd {
|
||||
value: value.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
_ => {
|
||||
log::debug!("unimplemented OSC 1337: {key:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> Option<Command> {
|
||||
let mut p = Parser::init(true);
|
||||
for ch in input.bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
p.end(Some(0x1b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_unimplemented_key_with_no_value() {
|
||||
assert!(run("1337;SetBadgeFormat").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_unimplemented_key_with_empty_value() {
|
||||
assert!(run("1337;SetBadgeFormat=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_unimplemented_key_with_non_empty_value() {
|
||||
assert!(run("1337;SetBadgeFormat=abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_key_with_lower_case_and_with_no_value() {
|
||||
assert!(run("1337;setbadgeformat").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_key_with_lower_case_and_with_empty_value() {
|
||||
assert!(run("1337;setbadgeformat=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_valid_key_with_lower_case_and_with_non_empty_value() {
|
||||
assert!(run("1337;setbadgeformat=abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_invalid_key_with_no_value() {
|
||||
assert!(run("1337;BobrKurwa").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_invalid_key_with_empty_value() {
|
||||
assert!(run("1337;BobrKurwa=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_invalid_key_with_non_empty_value() {
|
||||
assert!(run("1337;BobrKurwa=abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_no_value() {
|
||||
assert!(run("1337;Copy").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_empty_value() {
|
||||
assert!(run("1337;Copy=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_only_prefix_colon() {
|
||||
assert!(run("1337;Copy=:").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_question_mark() {
|
||||
assert!(run("1337;Copy=:?").is_none());
|
||||
}
|
||||
|
||||
// Skipped in Zig too (`return error.SkipZigTest`): for performance reasons
|
||||
// base64 validity is not checked.
|
||||
#[test]
|
||||
#[ignore = "skipped in Zig: base64 is not validated for performance"]
|
||||
fn osc_1337_test_copy_with_non_empty_value_that_is_invalid_base64() {
|
||||
assert!(run("1337;Copy=:abc123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_non_empty_value_that_is_valid_base64_but_not_prefixed_with_a_colon()
|
||||
{
|
||||
assert!(run("1337;Copy=YWJjMTIz").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_copy_with_non_empty_value_that_is_valid_base64() {
|
||||
assert_eq!(
|
||||
run("1337;Copy=:YWJjMTIz"),
|
||||
Some(Command::ClipboardContents {
|
||||
kind: b'c',
|
||||
data: b"YWJjMTIz".to_vec()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_current_dir_with_no_value() {
|
||||
assert!(run("1337;CurrentDir").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_current_dir_with_empty_value() {
|
||||
assert!(run("1337;CurrentDir=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_1337_test_current_dir_with_non_empty_value() {
|
||||
assert_eq!(
|
||||
run("1337;CurrentDir=abc123"),
|
||||
Some(Command::ReportPwd {
|
||||
value: b"abc123".to_vec()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
//! Kitty's clipboard protocol (OSC 5522)
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/
|
||||
//! https://rockorager.dev/misc/bracketed-paste-mime/
|
||||
//! (ZIG/src/terminal/osc/parsers/kitty_clipboard_protocol.zig)
|
||||
|
||||
use crate::osc::{Command, Parser, State, Terminator};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OSC {
|
||||
/// The raw metadata that was received. It can be parsed by using the
|
||||
/// `read_option` method (or the typed helpers).
|
||||
pub metadata: Vec<u8>,
|
||||
/// The raw payload. It may be Base64 encoded, check the `e` option.
|
||||
pub payload: Option<Vec<u8>>,
|
||||
/// The terminator that was used in case we need to send a response.
|
||||
pub terminator: Terminator,
|
||||
}
|
||||
|
||||
/// Value of a decoded option (Zig `Option.Type()`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OptionValue<'a> {
|
||||
Str(&'a [u8]),
|
||||
Loc(Location),
|
||||
Status(Status),
|
||||
Type(Operation),
|
||||
}
|
||||
|
||||
impl OSC {
|
||||
/// Decode an option from the metadata.
|
||||
pub fn read_option(&self, key: Opt) -> Option<OptionValue<'_>> {
|
||||
key.read(&self.metadata)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Id)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn loc(&self) -> Option<Location> {
|
||||
match self.read_option(Opt::Loc)? {
|
||||
OptionValue::Loc(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mime(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Mime)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Name)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn password(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Password)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pw(&self) -> Option<&[u8]> {
|
||||
match self.read_option(Opt::Pw)? {
|
||||
OptionValue::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Option<Status> {
|
||||
match self.read_option(Opt::Status)? {
|
||||
OptionValue::Status(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn type_(&self) -> Option<Operation> {
|
||||
match self.read_option(Opt::Type)? {
|
||||
OptionValue::Type(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Location {
|
||||
Primary,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
pub fn init(s: &[u8]) -> Option<Location> {
|
||||
match s {
|
||||
b"primary" => Some(Location::Primary),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Operation {
|
||||
Read,
|
||||
Walias,
|
||||
Wdata,
|
||||
Write,
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
pub fn init(s: &[u8]) -> Option<Operation> {
|
||||
match s {
|
||||
b"read" => Some(Operation::Read),
|
||||
b"walias" => Some(Operation::Walias),
|
||||
b"wdata" => Some(Operation::Wdata),
|
||||
b"write" => Some(Operation::Write),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum Status {
|
||||
DATA,
|
||||
DONE,
|
||||
EBUSY,
|
||||
EINVAL,
|
||||
EIO,
|
||||
ENOSYS,
|
||||
EPERM,
|
||||
OK,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn init(s: &[u8]) -> Option<Status> {
|
||||
match s {
|
||||
b"DATA" => Some(Status::DATA),
|
||||
b"DONE" => Some(Status::DONE),
|
||||
b"EBUSY" => Some(Status::EBUSY),
|
||||
b"EINVAL" => Some(Status::EINVAL),
|
||||
b"EIO" => Some(Status::EIO),
|
||||
b"ENOSYS" => Some(Status::ENOSYS),
|
||||
b"EPERM" => Some(Status::EPERM),
|
||||
b"OK" => Some(Status::OK),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Option keys (Zig `Option`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Opt {
|
||||
Id,
|
||||
Loc,
|
||||
Mime,
|
||||
Name,
|
||||
Password,
|
||||
Pw,
|
||||
Status,
|
||||
Type,
|
||||
}
|
||||
|
||||
/// Zig `std.ascii.isWhitespace`.
|
||||
fn is_ws(c: u8) -> bool {
|
||||
matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
|
||||
}
|
||||
|
||||
fn trim_ws(mut s: &[u8]) -> &[u8] {
|
||||
while let [first, rest @ ..] = s {
|
||||
if is_ws(*first) {
|
||||
s = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let [rest @ .., last] = s {
|
||||
if is_ws(*last) {
|
||||
s = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn index_of_from(hay: &[u8], from: usize, needle: u8) -> Option<usize> {
|
||||
hay[from..]
|
||||
.iter()
|
||||
.position(|&c| c == needle)
|
||||
.map(|i| i + from)
|
||||
}
|
||||
|
||||
impl Opt {
|
||||
pub fn tag_name(self) -> &'static str {
|
||||
match self {
|
||||
Opt::Id => "id",
|
||||
Opt::Loc => "loc",
|
||||
Opt::Mime => "mime",
|
||||
Opt::Name => "name",
|
||||
Opt::Password => "password",
|
||||
Opt::Pw => "pw",
|
||||
Opt::Status => "status",
|
||||
Opt::Type => "type",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the option value from the raw metadata string.
|
||||
pub fn read(self, metadata: &[u8]) -> Option<OptionValue<'_>> {
|
||||
let tag = self.tag_name().as_bytes();
|
||||
let value: &[u8] = 'value: {
|
||||
let mut pos: usize = 0;
|
||||
while pos < metadata.len() {
|
||||
// skip any whitespace
|
||||
while pos < metadata.len() && is_ws(metadata[pos]) {
|
||||
pos += 1;
|
||||
}
|
||||
// bail if we are out of metadata
|
||||
if pos >= metadata.len() {
|
||||
return None;
|
||||
}
|
||||
if !metadata[pos..].starts_with(tag) {
|
||||
// this isn't the key we are looking for, skip to the next option, or bail if
|
||||
// there is no next option
|
||||
pos = index_of_from(metadata, pos, b':')?;
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
// skip past the key
|
||||
pos += tag.len();
|
||||
// skip any whitespace
|
||||
while pos < metadata.len() && is_ws(metadata[pos]) {
|
||||
pos += 1;
|
||||
}
|
||||
// bail if we are out of metadata
|
||||
if pos >= metadata.len() {
|
||||
return None;
|
||||
}
|
||||
// a valid option has an '='
|
||||
if metadata[pos] != b'=' {
|
||||
return None;
|
||||
}
|
||||
// the end of the value is bounded by a ':' or the end of the metadata
|
||||
let end = index_of_from(metadata, pos, b':').unwrap_or(metadata.len());
|
||||
let start = pos + 1;
|
||||
// strip any leading or trailing whitespace
|
||||
break 'value trim_ws(&metadata[start..end]);
|
||||
}
|
||||
// the key was not found
|
||||
return None;
|
||||
};
|
||||
|
||||
// return the parsed value
|
||||
match self {
|
||||
Opt::Id => parse_identifier(value).map(OptionValue::Str),
|
||||
Opt::Loc => Location::init(value).map(OptionValue::Loc),
|
||||
Opt::Mime | Opt::Name | Opt::Password | Opt::Pw => Some(OptionValue::Str(value)),
|
||||
Opt::Status => Status::init(value).map(OptionValue::Status),
|
||||
Opt::Type => Operation::init(value).map(OptionValue::Type),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters that are valid in identifiers.
|
||||
const VALID_IDENTIFIER_CHARACTERS: &[u8] =
|
||||
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+.";
|
||||
|
||||
fn is_valid_identifier(s: &[u8]) -> bool {
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
s.iter().all(|c| VALID_IDENTIFIER_CHARACTERS.contains(c))
|
||||
}
|
||||
|
||||
fn parse_identifier(s: &[u8]) -> Option<&[u8]> {
|
||||
if is_valid_identifier(s) {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S5522);
|
||||
|
||||
let data = parser.buffered()?;
|
||||
|
||||
let (metadata, payload) = match data.iter().position(|&c| c == b';') {
|
||||
None => (data, None),
|
||||
Some(start) => (data[..start].to_vec(), Some(data[start + 1..].to_vec())),
|
||||
};
|
||||
|
||||
Some(Command::KittyClipboardProtocol(OSC {
|
||||
metadata,
|
||||
payload,
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> OSC {
|
||||
let mut p = Parser::init(true);
|
||||
for &ch in input.as_bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
match p.end(Some(0x1b)) {
|
||||
Some(Command::KittyClipboardProtocol(o)) => o,
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::option_option)]
|
||||
/// Expected values for every option; `None` means "must be null".
|
||||
#[derive(Default)]
|
||||
struct Exp<'a> {
|
||||
metadata: Option<&'a str>,
|
||||
payload: Option<Option<&'a str>>,
|
||||
id: Option<&'a str>,
|
||||
loc: Option<Location>,
|
||||
mime: Option<&'a str>,
|
||||
name: Option<&'a str>,
|
||||
password: Option<&'a str>,
|
||||
pw: Option<&'a str>,
|
||||
status: Option<Status>,
|
||||
type_: Option<Operation>,
|
||||
}
|
||||
|
||||
fn check(c: &OSC, e: &Exp) {
|
||||
if let Some(m) = e.metadata {
|
||||
assert_eq!(m.as_bytes(), &c.metadata[..]);
|
||||
}
|
||||
if let Some(p) = e.payload {
|
||||
assert_eq!(p.map(|s| s.as_bytes().to_vec()), c.payload);
|
||||
}
|
||||
assert_eq!(e.id.map(str::as_bytes), c.id());
|
||||
assert_eq!(e.loc, c.loc());
|
||||
assert_eq!(e.mime.map(str::as_bytes), c.mime());
|
||||
assert_eq!(e.name.map(str::as_bytes), c.name());
|
||||
assert_eq!(e.password.map(str::as_bytes), c.password());
|
||||
assert_eq!(e.pw.map(str::as_bytes), c.pw());
|
||||
assert_eq!(e.status, c.status());
|
||||
assert_eq!(e.type_, c.type_());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_empty_metadata_and_missing_payload() {
|
||||
let c = run("5522;");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
metadata: Some(""),
|
||||
payload: Some(None),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_empty_metadata_and_empty_payload() {
|
||||
let c = run("5522;;");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
metadata: Some(""),
|
||||
payload: Some(Some("")),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_non_empty_metadata_and_payload() {
|
||||
let c = run("5522;type=read;dGV4dC9wbGFpbg==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
metadata: Some("type=read"),
|
||||
payload: Some(Some("dGV4dC9wbGFpbg==")),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_empty_id() {
|
||||
let c = run("5522;id=");
|
||||
assert!(c.id().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_valid_id() {
|
||||
let c = run("5522;id=5c076ad9-d36f-4705-847b-d4dbf356cc0d");
|
||||
assert_eq!(b"5c076ad9-d36f-4705-847b-d4dbf356cc0d", c.id().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_invalid_id() {
|
||||
let c = run("5522;id=*42*");
|
||||
assert!(c.id().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_invalid_status() {
|
||||
let c = run("5522;status=BOBR");
|
||||
assert!(c.status().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_valid_status() {
|
||||
let c = run("5522;status=DONE");
|
||||
assert_eq!(Some(Status::DONE), c.status());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_invalid_location() {
|
||||
let c = run("5522;loc=bobr");
|
||||
assert!(c.loc().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_valid_location() {
|
||||
let c = run("5522;loc=primary");
|
||||
assert_eq!(Some(Location::Primary), c.loc());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_password_1() {
|
||||
let c = run("5522;pw=R2hvc3R0eQ==:name=Qk9CUiBLVVJXQQ==");
|
||||
assert_eq!(b"R2hvc3R0eQ==", c.pw().unwrap());
|
||||
assert_eq!(b"Qk9CUiBLVVJXQQ==", c.name().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_password_2() {
|
||||
let c = run("5522;password=R2hvc3R0eQ==");
|
||||
assert_eq!(b"R2hvc3R0eQ==", c.password().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_1() {
|
||||
let c = run("5522;type=read:status=OK");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_2() {
|
||||
let c = run("5522;type=read:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(Some("R2hvc3R0eQ==")),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_3() {
|
||||
let c = run("5522;type=read:status=OK");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_4() {
|
||||
let c = run("5522;type=write");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
type_: Some(Operation::Write),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_5() {
|
||||
let c = run("5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(Some("R2hvc3R0eQ==")),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
type_: Some(Operation::Wdata),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_6() {
|
||||
let c = run("5522;type=wdata");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
type_: Some(Operation::Wdata),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_7() {
|
||||
let c = run("5522;type=write:status=DONE");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::DONE),
|
||||
type_: Some(Operation::Write),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_8() {
|
||||
let c = run("5522;type=write:status=EPERM");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::EPERM),
|
||||
type_: Some(Operation::Write),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_9() {
|
||||
let c = run("5522;type=walias:mime=dGV4dC9wbGFpbg==;dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(Some("dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=")),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
type_: Some(Operation::Walias),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_10() {
|
||||
let c = run("5522;type=read:status=OK:password=Qk9CUiBLVVJXQQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
password: Some("Qk9CUiBLVVJXQQ=="),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_11() {
|
||||
let c = run("5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
status: Some(Status::DATA),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_12() {
|
||||
let c = run("5522;type=read:mime=dGV4dC9wbGFpbg==:password=Qk9CUiBLVVJXQQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
password: Some("Qk9CUiBLVVJXQQ=="),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_13() {
|
||||
let c = run("5522;type=read:status=OK");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_14() {
|
||||
let c = run("5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;Qk9CUiBLVVJXQQ==");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(Some("Qk9CUiBLVVJXQQ==")),
|
||||
mime: Some("dGV4dC9wbGFpbg=="),
|
||||
status: Some(Status::DATA),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_5522_example_15() {
|
||||
let c = run("5522;type=read:status=OK");
|
||||
check(
|
||||
&c,
|
||||
&Exp {
|
||||
payload: Some(None),
|
||||
status: Some(Status::OK),
|
||||
type_: Some(Operation::Read),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! OSC 21, the Kitty Color Protocol (ZIG/src/terminal/osc/parsers/kitty_color.zig).
|
||||
|
||||
use crate::color::Rgb;
|
||||
use crate::kitty::color::{Kind, OSC, Request};
|
||||
use crate::osc::{Command, Parser, State, Terminator};
|
||||
|
||||
/// Parse OSC 21, the Kitty Color Protocol.
|
||||
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S21);
|
||||
|
||||
if !parser.alloc {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
if !parser.has_writer() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
let data = parser.buffered()?;
|
||||
|
||||
let mut osc = OSC {
|
||||
list: Vec::new(),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
};
|
||||
for kv in data.split(|&c| c == b';') {
|
||||
if osc.list.len() >= Kind::MAX * 2 {
|
||||
log::warn!("exceeded limit for number of keys in kitty color protocol, ignoring");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
let (k, rest) = match kv.iter().position(|&c| c == b'=') {
|
||||
Some(p) => (&kv[..p], &kv[p + 1..]),
|
||||
None => (kv, &kv[kv.len()..]),
|
||||
};
|
||||
if k.is_empty() {
|
||||
log::warn!("zero length key in kitty color protocol");
|
||||
continue;
|
||||
}
|
||||
let Some(key) = Kind::parse(k) else {
|
||||
log::warn!(
|
||||
"unknown key in kitty color protocol: {}",
|
||||
String::from_utf8_lossy(k)
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let value = rest.trim_ascii_matching(b' ');
|
||||
if value.is_empty() {
|
||||
osc.list.push(Request::Reset(key));
|
||||
} else if value == b"?" {
|
||||
osc.list.push(Request::Query(key));
|
||||
} else if let Ok(color) = Rgb::parse_bytes(value) {
|
||||
osc.list.push(Request::Set { key, color });
|
||||
} else {
|
||||
log::warn!(
|
||||
"invalid color format in kitty color protocol: {}",
|
||||
String::from_utf8_lossy(value)
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Command::KittyColorProtocol(osc))
|
||||
}
|
||||
|
||||
trait TrimMatching {
|
||||
fn trim_ascii_matching(&self, c: u8) -> &[u8];
|
||||
}
|
||||
|
||||
impl TrimMatching for [u8] {
|
||||
fn trim_ascii_matching(&self, c: u8) -> &[u8] {
|
||||
let mut s = self;
|
||||
while let [first, rest @ ..] = s {
|
||||
if *first != c {
|
||||
break;
|
||||
}
|
||||
s = rest;
|
||||
}
|
||||
while let [rest @ .., last] = s {
|
||||
if *last != c {
|
||||
break;
|
||||
}
|
||||
s = rest;
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
use crate::kitty::color::Special;
|
||||
|
||||
const INPUT: &str = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0";
|
||||
|
||||
fn feed(p: &mut Parser, s: &str) {
|
||||
for ch in s.bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
|
||||
Rgb { r, g, b }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol() {
|
||||
let mut p = Parser::init(true);
|
||||
feed(&mut p, INPUT);
|
||||
|
||||
let cmd = p.end(Some(0x1b)).unwrap();
|
||||
let Command::KittyColorProtocol(osc) = cmd else {
|
||||
panic!("wrong command");
|
||||
};
|
||||
let list = osc.list;
|
||||
assert_eq!(9, list.len());
|
||||
assert_eq!(Request::Query(Kind::Special(Special::Foreground)), list[0]);
|
||||
assert_eq!(
|
||||
Request::Set {
|
||||
key: Kind::Special(Special::Background),
|
||||
color: rgb(0xf0, 0xf8, 0xff)
|
||||
},
|
||||
list[1]
|
||||
);
|
||||
assert_eq!(
|
||||
Request::Set {
|
||||
key: Kind::Special(Special::Cursor),
|
||||
color: rgb(0xf0, 0xf8, 0xff)
|
||||
},
|
||||
list[2]
|
||||
);
|
||||
assert_eq!(Request::Reset(Kind::Special(Special::CursorText)), list[3]);
|
||||
assert_eq!(Request::Reset(Kind::Special(Special::VisualBell)), list[4]);
|
||||
assert_eq!(
|
||||
Request::Query(Kind::Special(Special::SelectionBackground)),
|
||||
list[5]
|
||||
);
|
||||
assert_eq!(
|
||||
Request::Set {
|
||||
key: Kind::Special(Special::SelectionBackground),
|
||||
color: rgb(0xaa, 0xbb, 0xcc)
|
||||
},
|
||||
list[6]
|
||||
);
|
||||
assert_eq!(Request::Query(Kind::Palette(2)), list[7]);
|
||||
assert_eq!(
|
||||
Request::Set {
|
||||
key: Kind::Palette(3),
|
||||
color: rgb(0xff, 0xff, 0xff)
|
||||
},
|
||||
list[8]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol_without_allocator() {
|
||||
let mut p = Parser::init(false);
|
||||
feed(&mut p, "21;foreground=?");
|
||||
assert!(p.end(Some(0x1b)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol_double_reset() {
|
||||
let mut p = Parser::init(true);
|
||||
feed(&mut p, INPUT);
|
||||
let cmd = p.end(Some(0x1b)).unwrap();
|
||||
assert!(matches!(cmd, Command::KittyColorProtocol(_)));
|
||||
|
||||
p.reset();
|
||||
p.reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol_reset_after_invalid() {
|
||||
let mut p = Parser::init(true);
|
||||
feed(&mut p, INPUT);
|
||||
let cmd = p.end(Some(0x1b)).unwrap();
|
||||
assert!(matches!(cmd, Command::KittyColorProtocol(_)));
|
||||
|
||||
p.reset();
|
||||
|
||||
assert_eq!(State::Start, p.state);
|
||||
p.next(b'X');
|
||||
assert_eq!(State::Invalid, p.state);
|
||||
|
||||
p.reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_21_kitty_color_protocol_no_key() {
|
||||
let mut p = Parser::init(true);
|
||||
feed(&mut p, "21;");
|
||||
let cmd = p.end(Some(0x1b)).unwrap();
|
||||
let Command::KittyColorProtocol(osc) = cmd else {
|
||||
panic!("wrong command");
|
||||
};
|
||||
assert_eq!(0, osc.list.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Kitty's text sizing protocol (OSC 66)
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/text-sizing-protocol/
|
||||
//! (ZIG/src/terminal/osc/parsers/kitty_text_sizing.zig)
|
||||
|
||||
use crate::osc::{Command, Parser, State, encoding};
|
||||
|
||||
pub const MAX_PAYLOAD_LENGTH: usize = 4096;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Bottom,
|
||||
Center,
|
||||
}
|
||||
|
||||
impl VAlign {
|
||||
fn from_int(v: u8) -> Option<VAlign> {
|
||||
match v {
|
||||
0 => Some(VAlign::Top),
|
||||
1 => Some(VAlign::Bottom),
|
||||
2 => Some(VAlign::Center),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum HAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
Center,
|
||||
}
|
||||
|
||||
impl HAlign {
|
||||
fn from_int(v: u8) -> Option<HAlign> {
|
||||
match v {
|
||||
0 => Some(HAlign::Left),
|
||||
1 => Some(HAlign::Right),
|
||||
2 => Some(HAlign::Center),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OSC {
|
||||
/// 1 - 7 (u3)
|
||||
pub scale: u8,
|
||||
/// 0 - 7 (u3), 0 means default
|
||||
pub width: u8,
|
||||
/// u4
|
||||
pub numerator: u8,
|
||||
/// u4
|
||||
pub denominator: u8,
|
||||
pub valign: VAlign,
|
||||
pub halign: HAlign,
|
||||
pub text: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum UpdateError {
|
||||
UnknownKey,
|
||||
InvalidValue,
|
||||
}
|
||||
|
||||
/// Equivalent of Zig `std.fmt.parseInt(u4, value, 10)`.
|
||||
fn parse_u4(value: &[u8]) -> Option<u8> {
|
||||
let (neg, digits) = match value.first() {
|
||||
Some(b'+') => (false, &value[1..]),
|
||||
Some(b'-') => (true, &value[1..]),
|
||||
_ => (false, value),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for &c in digits {
|
||||
if !c.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
acc = acc * 10 + (c - b'0') as u32;
|
||||
if acc > 15 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if neg && acc != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(acc as u8)
|
||||
}
|
||||
|
||||
impl OSC {
|
||||
fn update(&mut self, key: u8, value: &[u8]) -> Result<(), UpdateError> {
|
||||
// All values are numeric, so we can do a small hack here
|
||||
let v = parse_u4(value).ok_or(UpdateError::InvalidValue)?;
|
||||
|
||||
match key {
|
||||
b's' => {
|
||||
if v == 0 || v > 7 {
|
||||
return Err(UpdateError::InvalidValue);
|
||||
}
|
||||
self.scale = v;
|
||||
}
|
||||
b'w' => {
|
||||
if v > 7 {
|
||||
return Err(UpdateError::InvalidValue);
|
||||
}
|
||||
self.width = v;
|
||||
}
|
||||
b'n' => self.numerator = v,
|
||||
b'd' => self.denominator = v,
|
||||
b'v' => self.valign = VAlign::from_int(v).ok_or(UpdateError::InvalidValue)?,
|
||||
b'h' => self.halign = HAlign::from_int(v).ok_or(UpdateError::InvalidValue)?,
|
||||
_ => return Err(UpdateError::UnknownKey),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S66);
|
||||
|
||||
let data = parser.data()?;
|
||||
|
||||
let Some(payload_start) = data.iter().position(|&c| c == b';') else {
|
||||
log::warn!("missing semicolon before payload");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
let payload = &data[payload_start + 1..];
|
||||
|
||||
// Payload has to be a URL-safe UTF-8 string,
|
||||
// and be under the size limit.
|
||||
if payload.len() > MAX_PAYLOAD_LENGTH {
|
||||
log::warn!("payload is too long");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
if !encoding::is_safe_utf8(payload) {
|
||||
log::warn!("payload is not escape code safe UTF-8");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut cmd = OSC {
|
||||
scale: 1,
|
||||
width: 0,
|
||||
numerator: 0,
|
||||
denominator: 0,
|
||||
valign: VAlign::Top,
|
||||
halign: HAlign::Left,
|
||||
text: payload.to_vec(),
|
||||
};
|
||||
|
||||
// Parse any arguments if given
|
||||
if payload_start > 0 {
|
||||
for kv in data[..payload_start].split(|&c| c == b':') {
|
||||
let mut it = kv.split(|&c| c == b'=');
|
||||
let Some(k) = it.next() else {
|
||||
log::warn!("missing key");
|
||||
continue;
|
||||
};
|
||||
if k.len() != 1 {
|
||||
log::warn!("key must be a single character");
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(value) = it.next() else {
|
||||
log::warn!("missing value");
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Err(err) = cmd.update(k[0], value) {
|
||||
match err {
|
||||
UpdateError::UnknownKey => log::warn!("unknown key: '{}'", k[0] as char),
|
||||
UpdateError::InvalidValue => {
|
||||
log::warn!("invalid value for key '{}': {:?}", k[0] as char, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Command::KittyTextSizing(cmd))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> Option<Command> {
|
||||
let mut p = Parser::init(false);
|
||||
for &ch in input {
|
||||
p.next(ch);
|
||||
}
|
||||
p.end(Some(0x1b))
|
||||
}
|
||||
|
||||
fn sizing(input: &[u8]) -> OSC {
|
||||
match run(input) {
|
||||
Some(Command::KittyTextSizing(o)) => o,
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_empty_parameters() {
|
||||
let c = sizing(b"66;;bobr");
|
||||
assert_eq!(1, c.scale);
|
||||
assert_eq!(b"bobr", &c.text[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_single_parameter() {
|
||||
let c = sizing(b"66;s=2;kurwa");
|
||||
assert_eq!(2, c.scale);
|
||||
assert_eq!(b"kurwa", &c.text[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_multiple_parameters() {
|
||||
let c = sizing(b"66;s=2:w=7:n=13:d=15:v=1:h=2;long");
|
||||
assert_eq!(2, c.scale);
|
||||
assert_eq!(7, c.width);
|
||||
assert_eq!(13, c.numerator);
|
||||
assert_eq!(15, c.denominator);
|
||||
assert_eq!(VAlign::Bottom, c.valign);
|
||||
assert_eq!(HAlign::Center, c.halign);
|
||||
assert_eq!(b"long", &c.text[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_scale_is_zero() {
|
||||
let c = sizing(b"66;s=0;nope");
|
||||
assert_eq!(1, c.scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_invalid_parameters() {
|
||||
let c = sizing(b"66;w=8:v=3:n=16;");
|
||||
assert_eq!(0, c.width);
|
||||
assert_eq!(VAlign::Top, c.valign);
|
||||
assert_eq!(0, c.numerator);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_utf8() {
|
||||
let c = sizing("66;;👻魑魅魍魉ゴースッティ".as_bytes());
|
||||
assert_eq!("👻魑魅魍魉ゴースッティ".as_bytes(), &c.text[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_unsafe_utf8() {
|
||||
assert!(run(b"66;;\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_66_overlong_utf8() {
|
||||
let mut input = b"66;;".to_vec();
|
||||
input.extend(b"bobr".repeat(1025));
|
||||
assert!(run(&input).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! OSC parsers (ZIG/src/terminal/osc/parsers.zig).
|
||||
|
||||
pub mod change_window_icon;
|
||||
pub mod change_window_title;
|
||||
pub mod clipboard_operation;
|
||||
pub mod color;
|
||||
pub mod context_signal;
|
||||
pub mod hyperlink;
|
||||
pub mod iterm2;
|
||||
pub mod kitty_clipboard_protocol;
|
||||
pub mod kitty_color;
|
||||
pub mod kitty_text_sizing;
|
||||
pub mod mouse_shape;
|
||||
pub mod osc9;
|
||||
pub mod report_pwd;
|
||||
pub mod rxvt_extension;
|
||||
pub mod semantic_prompt;
|
||||
@@ -0,0 +1,29 @@
|
||||
//! OSC 22 (ZIG/src/terminal/osc/parsers/mouse_shape.zig).
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// Parse OSC 22
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
debug_assert_eq!(parser.state, State::S22);
|
||||
let data = parser.data()?;
|
||||
Some(Command::MouseShape { value: data })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_22_pointer_cursor() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"22;pointer" {
|
||||
p.next(*c);
|
||||
}
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::MouseShape {
|
||||
value: b"pointer".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
//! OSC 9 (iTerm2 notification / ConEmu extensions)
|
||||
//! (ZIG/src/terminal/osc/parsers/osc9.zig).
|
||||
|
||||
use super::semantic_prompt;
|
||||
use crate::osc::{Command, ConemuChangeTabTitle, Parser, ProgressReport, ProgressState};
|
||||
|
||||
/// Parse a base-10 unsigned integer from raw bytes (Zig `parseUnsigned`).
|
||||
fn parse_unsigned<T: std::str::FromStr>(bytes: &[u8]) -> Option<T> {
|
||||
std::str::from_utf8(bytes).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension.
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
// Zig prologue: writer must exist. (The NUL-terminator capacity check is
|
||||
// folded into `data()`.)
|
||||
let data = parser.data()?;
|
||||
|
||||
// Check first to see if this is a ConEmu OSC
|
||||
// https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
|
||||
if let Some(cmd) = conemu(&data) {
|
||||
return Some(cmd);
|
||||
}
|
||||
|
||||
// If it's not a ConEmu OSC, it's an iTerm2 notification
|
||||
Some(Command::ShowDesktopNotification {
|
||||
title: Vec::new(),
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns None where the Zig code does `break :conemu`.
|
||||
fn conemu(data: &[u8]) -> Option<Command> {
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let msg = |skip: usize| data[skip..].to_vec();
|
||||
match data[0] {
|
||||
// Check for OSC 9;1 9;10 9;11 9;12
|
||||
b'1' => {
|
||||
if data.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
match data[1] {
|
||||
// OSC 9;1 sleep
|
||||
b';' => Some(Command::ConemuSleep {
|
||||
duration_ms: match parse_unsigned::<u16>(&data[2..]) {
|
||||
Some(num) => num.min(10_000),
|
||||
None => 100,
|
||||
},
|
||||
}),
|
||||
// OSC 9;10 xterm keyboard and output emulation
|
||||
b'0' => {
|
||||
if data.len() == 2 {
|
||||
return Some(Command::ConemuXtermEmulation {
|
||||
keyboard: Some(true),
|
||||
output: Some(true),
|
||||
});
|
||||
}
|
||||
if data.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
if data[2] != b';' {
|
||||
return None;
|
||||
}
|
||||
let (keyboard, output) = match data[3] {
|
||||
b'0' => (Some(false), Some(false)),
|
||||
b'1' => (Some(true), Some(true)),
|
||||
b'2' => (None, Some(false)),
|
||||
b'3' => (None, Some(true)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Command::ConemuXtermEmulation { keyboard, output })
|
||||
}
|
||||
// OSC 9;11 comment
|
||||
b'1' => {
|
||||
if data.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
if data[2] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuComment(msg(3)))
|
||||
}
|
||||
// OSC 9;12 mark prompt start
|
||||
b'2' => Some(Command::SemanticPrompt(semantic_prompt::Command::init(
|
||||
semantic_prompt::Action::FreshLineNewPrompt,
|
||||
))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// OSC 9;2 show message box
|
||||
b'2' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuShowMessageBox(msg(2)))
|
||||
}
|
||||
// OSC 9;3 change tab title
|
||||
b'3' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
if data.len() == 2 {
|
||||
return Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset));
|
||||
}
|
||||
Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(
|
||||
msg(2),
|
||||
)))
|
||||
}
|
||||
// OSC 9;4 progress report
|
||||
b'4' => {
|
||||
if data.len() < 2 || data[1] != b';' || data.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let mut report = match data[2] {
|
||||
b'0' => ProgressReport {
|
||||
state: ProgressState::Remove,
|
||||
progress: None,
|
||||
},
|
||||
b'1' => ProgressReport {
|
||||
state: ProgressState::Set,
|
||||
progress: Some(0),
|
||||
},
|
||||
b'2' => ProgressReport {
|
||||
state: ProgressState::Error,
|
||||
progress: None,
|
||||
},
|
||||
b'3' => ProgressReport {
|
||||
state: ProgressState::Indeterminate,
|
||||
progress: None,
|
||||
},
|
||||
b'4' => ProgressReport {
|
||||
state: ProgressState::Pause,
|
||||
progress: None,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
match report.state {
|
||||
ProgressState::Remove | ProgressState::Indeterminate => {}
|
||||
ProgressState::Set | ProgressState::Error | ProgressState::Pause => {
|
||||
if data.len() >= 4 && data[3] == b';' {
|
||||
// parse the progress value
|
||||
report.progress =
|
||||
parse_unsigned::<usize>(&data[4..]).map(|v| v.min(100) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::ConemuProgressReport(report))
|
||||
}
|
||||
// OSC 9;5 wait for input
|
||||
b'5' => Some(Command::ConemuWaitInput),
|
||||
// OSC 9;6 guimacro
|
||||
b'6' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuGuimacro(msg(2)))
|
||||
}
|
||||
// OSC 9;7 run process
|
||||
b'7' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuRunProcess(msg(2)))
|
||||
}
|
||||
// OSC 9;8 output environment variable
|
||||
b'8' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ConemuOutputEnvironmentVariable(msg(2)))
|
||||
}
|
||||
// OSC 9;9 current working directory
|
||||
b'9' => {
|
||||
if data.len() < 2 || data[1] != b';' {
|
||||
return None;
|
||||
}
|
||||
Some(Command::ReportPwd { value: msg(2) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> Command {
|
||||
let mut p = Parser::init(false);
|
||||
for ch in input.bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
p.end(Some(0x1b)).unwrap()
|
||||
}
|
||||
|
||||
fn notif(input: &str, body: &str) {
|
||||
match run(input) {
|
||||
Command::ShowDesktopNotification { title, body: b } => {
|
||||
assert_eq!(title, b"");
|
||||
assert_eq!(b, body.as_bytes());
|
||||
}
|
||||
c => panic!("unexpected {c:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn progress(input: &str, state: ProgressState, progress: Option<u8>) {
|
||||
assert_eq!(
|
||||
run(input),
|
||||
Command::ConemuProgressReport(ProgressReport { state, progress })
|
||||
);
|
||||
}
|
||||
|
||||
fn xterm(input: &str, keyboard: Option<bool>, output: Option<bool>) {
|
||||
assert_eq!(
|
||||
run(input),
|
||||
Command::ConemuXtermEmulation { keyboard, output }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_show_desktop_notification() {
|
||||
notif("9;Hello world", "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_show_single_character_desktop_notification() {
|
||||
notif("9;H", "H");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep() {
|
||||
assert_eq!(run("9;1;420"), Command::ConemuSleep { duration_ms: 420 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_with_no_value_default_to_100ms() {
|
||||
assert_eq!(run("9;1;"), Command::ConemuSleep { duration_ms: 100 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_cannot_exceed_10000ms() {
|
||||
assert_eq!(
|
||||
run("9;1;12345"),
|
||||
Command::ConemuSleep { duration_ms: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_invalid_input() {
|
||||
assert_eq!(run("9;1;foo"), Command::ConemuSleep { duration_ms: 100 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_desktop_notification_1() {
|
||||
notif("9;1", "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_1_conemu_sleep_desktop_notification_2() {
|
||||
notif("9;1a", "1a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_conemu_message_box() {
|
||||
assert_eq!(
|
||||
run("9;2;hello world"),
|
||||
Command::ConemuShowMessageBox(b"hello world".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_conemu_message_box_invalid_input() {
|
||||
notif("9;2", "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_conemu_message_box_empty_message() {
|
||||
assert_eq!(run("9;2;"), Command::ConemuShowMessageBox(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_conemu_message_box_spaces_only_message() {
|
||||
assert_eq!(
|
||||
run("9;2; "),
|
||||
Command::ConemuShowMessageBox(b" ".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_message_box_desktop_notification_1() {
|
||||
notif("9;2", "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_2_message_box_desktop_notification_2() {
|
||||
notif("9;2a", "2a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_conemu_change_tab_title() {
|
||||
assert_eq!(
|
||||
run("9;3;foo bar"),
|
||||
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b"foo bar".to_vec()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_conemu_change_tab_title_reset() {
|
||||
assert_eq!(
|
||||
run("9;3;"),
|
||||
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_conemu_change_tab_title_spaces_only() {
|
||||
assert_eq!(
|
||||
run("9;3; "),
|
||||
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b" ".to_vec()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_change_tab_title_desktop_notification_1() {
|
||||
notif("9;3", "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_3_message_box_desktop_notification_2() {
|
||||
notif("9;3a", "3a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set() {
|
||||
progress("9;4;1;100", ProgressState::Set, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set_overflow() {
|
||||
progress("9;4;1;900", ProgressState::Set, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set_single_digit() {
|
||||
progress("9;4;1;9", ProgressState::Set, Some(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set_double_digit() {
|
||||
progress("9;4;1;94", ProgressState::Set, Some(94));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_set_extra_semicolon_ignored() {
|
||||
progress("9;4;1;100", ProgressState::Set, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_remove_with_no_progress() {
|
||||
progress("9;4;0;", ProgressState::Remove, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_remove_with_double_semicolon() {
|
||||
progress("9;4;0;;", ProgressState::Remove, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_remove_ignores_progress() {
|
||||
progress("9;4;0;100", ProgressState::Remove, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_remove_extra_semicolon() {
|
||||
match run("9;4;0;100;") {
|
||||
Command::ConemuProgressReport(r) => assert_eq!(r.state, ProgressState::Remove),
|
||||
c => panic!("unexpected {c:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_error() {
|
||||
progress("9;4;2", ProgressState::Error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_error_with_progress() {
|
||||
progress("9;4;2;100", ProgressState::Error, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_pause() {
|
||||
progress("9;4;4", ProgressState::Pause, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_conemu_progress_pause_with_progress() {
|
||||
progress("9;4;4;100", ProgressState::Pause, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_desktop_notification_1() {
|
||||
notif("9;4", "4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_desktop_notification_2() {
|
||||
notif("9;4;", "4;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_desktop_notification_3() {
|
||||
notif("9;4;5", "4;5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_progress_desktop_notification_4() {
|
||||
notif("9;4;5a", "4;5a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_5_conemu_wait_input() {
|
||||
assert_eq!(run("9;5"), Command::ConemuWaitInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_5_conemu_wait_ignores_trailing_characters() {
|
||||
assert_eq!(run("9;5;foo"), Command::ConemuWaitInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_6_conemu_guimacro_1() {
|
||||
assert_eq!(run("9;6;a"), Command::ConemuGuimacro(b"a".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_6_conemu_guimacro_2() {
|
||||
assert_eq!(run("9;6;ab"), Command::ConemuGuimacro(b"ab".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_6_conemu_guimacro_3_incomplete_desktop_notification() {
|
||||
notif("9;6", "6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_7_conemu_run_process_1() {
|
||||
assert_eq!(run("9;7;ab"), Command::ConemuRunProcess(b"ab".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_7_conemu_run_process_2() {
|
||||
assert_eq!(run("9;7;"), Command::ConemuRunProcess(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_7_conemu_run_process_incomplete_desktop_notification() {
|
||||
notif("9;7", "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_8_conemu_output_environment_variable_1() {
|
||||
assert_eq!(
|
||||
run("9;8;ab"),
|
||||
Command::ConemuOutputEnvironmentVariable(b"ab".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_8_conemu_output_environment_variable_2() {
|
||||
assert_eq!(
|
||||
run("9;8;"),
|
||||
Command::ConemuOutputEnvironmentVariable(Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_8_conemu_output_environment_variable_incomplete_desktop_notification() {
|
||||
notif("9;8", "8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_9_conemu_set_current_working_directory() {
|
||||
assert_eq!(
|
||||
run("9;9;ab"),
|
||||
Command::ReportPwd {
|
||||
value: b"ab".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_9_conemu_set_current_working_directory_incomplete_desktop_notification() {
|
||||
notif("9;9", "9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_1() {
|
||||
xterm("9;10", Some(true), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_2() {
|
||||
xterm("9;10;0", Some(false), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_3() {
|
||||
xterm("9;10;1", Some(true), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_4() {
|
||||
xterm("9;10;2", None, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_5() {
|
||||
xterm("9;10;3", None, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_6() {
|
||||
notif("9;10;4", "10;4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_7() {
|
||||
notif("9;10;", "10;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_8() {
|
||||
notif("9;10;abc", "10;abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_11_conemu_comment() {
|
||||
assert_eq!(run("9;11;ab"), Command::ConemuComment(b"ab".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_11_conemu_comment_incomplete_desktop_notification() {
|
||||
notif("9;11", "11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_12_conemu_mark_prompt_start_1() {
|
||||
assert!(matches!(run("9;12"), Command::SemanticPrompt(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_12_conemu_mark_prompt_start_2() {
|
||||
assert!(matches!(run("9;12;abc"), Command::SemanticPrompt(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! OSC 7 (ZIG/src/terminal/osc/parsers/report_pwd.zig).
|
||||
use crate::osc::{Command, Parser};
|
||||
|
||||
/// Parse OSC 7
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
Some(Command::ReportPwd { value: data })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_7_report_pwd() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"7;file:///tmp/example" {
|
||||
p.next(*c);
|
||||
}
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ReportPwd {
|
||||
value: b"file:///tmp/example".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_7_report_pwd_empty() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"7;" {
|
||||
p.next(*c);
|
||||
}
|
||||
assert_eq!(
|
||||
p.end(None).unwrap(),
|
||||
Command::ReportPwd { value: Vec::new() }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! OSC 777 (ZIG/src/terminal/osc/parsers/rxvt_extension.zig).
|
||||
use crate::osc::{Command, Parser, State};
|
||||
|
||||
/// Parse OSC 777
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
|
||||
let data = parser.data()?;
|
||||
let Some(k) = data.iter().position(|&b| b == b';') else {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
let ext = &data[..k];
|
||||
if ext != b"notify" {
|
||||
log::warn!("unknown rxvt extension: {}", String::from_utf8_lossy(ext));
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
let Some(t) = data[k + 1..]
|
||||
.iter()
|
||||
.position(|&b| b == b';')
|
||||
.map(|i| i + k + 1)
|
||||
else {
|
||||
log::warn!("rxvt notify extension is missing the title");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
};
|
||||
Some(Command::ShowDesktopNotification {
|
||||
title: data[k + 1..t].to_vec(),
|
||||
body: data[t + 1..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_777_show_desktop_notification_with_title() {
|
||||
let mut p = Parser::init(false);
|
||||
for c in b"777;notify;Title;Body" {
|
||||
p.next(*c);
|
||||
}
|
||||
assert_eq!(
|
||||
p.end(Some(0x1b)).unwrap(),
|
||||
Command::ShowDesktopNotification {
|
||||
title: b"Title".to_vec(),
|
||||
body: b"Body".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
//! OSC 133 semantic prompts
|
||||
//! (ZIG/src/terminal/osc/parsers/semantic_prompt.zig).
|
||||
//!
|
||||
//! <https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md>
|
||||
|
||||
use crate::osc::{Command as OscCommand, Parser, State};
|
||||
|
||||
/// Error returned when decoding a command line fails (Zig
|
||||
/// `error.DecodeError`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DecodeError;
|
||||
|
||||
impl std::fmt::Display for DecodeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("decode error")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DecodeError {}
|
||||
|
||||
/// A single semantic prompt command.
|
||||
///
|
||||
/// Technically according to the spec, not all commands have options
|
||||
/// but it is easier to be "liberal in what we accept" here since
|
||||
/// all except one do and the spec does also say to ignore unknown
|
||||
/// options. So, I think this is a fair interpretation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Command {
|
||||
pub action: Action,
|
||||
pub options_unvalidated: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
FreshLine, // 'L'
|
||||
FreshLineNewPrompt, // 'A'
|
||||
NewCommand, // 'N'
|
||||
PromptStart, // 'P'
|
||||
EndPromptStartInput, // 'B'
|
||||
EndPromptStartInputTerminateEol, // 'I'
|
||||
EndInputStartOutput, // 'C'
|
||||
EndCommand, // 'D'
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn init(action: Action) -> Command {
|
||||
Command {
|
||||
action,
|
||||
options_unvalidated: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an option for this command. Returns None if unset or invalid.
|
||||
pub fn read_option(&self, option: OptionKey) -> Option<OptionValue<'_>> {
|
||||
option.read(&self.options_unvalidated)
|
||||
}
|
||||
|
||||
/// Typed convenience: `aid` option.
|
||||
pub fn aid(&self) -> Option<&[u8]> {
|
||||
match self.read_option(OptionKey::Aid)? {
|
||||
OptionValue::Bytes(b) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `cl` option.
|
||||
pub fn cl(&self) -> Option<Click> {
|
||||
match self.read_option(OptionKey::Cl)? {
|
||||
OptionValue::Click(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `k` option.
|
||||
pub fn prompt_kind(&self) -> Option<PromptKind> {
|
||||
match self.read_option(OptionKey::PromptKind)? {
|
||||
OptionValue::PromptKind(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `err` option.
|
||||
pub fn err(&self) -> Option<&[u8]> {
|
||||
match self.read_option(OptionKey::Err)? {
|
||||
OptionValue::Bytes(b) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `redraw` option.
|
||||
pub fn redraw(&self) -> Option<Redraw> {
|
||||
match self.read_option(OptionKey::Redraw)? {
|
||||
OptionValue::Redraw(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `special_key` option.
|
||||
pub fn special_key(&self) -> Option<bool> {
|
||||
match self.read_option(OptionKey::SpecialKey)? {
|
||||
OptionValue::Bool(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `click_events` option.
|
||||
pub fn click_events(&self) -> Option<bool> {
|
||||
match self.read_option(OptionKey::ClickEvents)? {
|
||||
OptionValue::Bool(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed convenience: `exit_code` (first positional value of 'D').
|
||||
pub fn exit_code(&self) -> Option<i32> {
|
||||
match self.read_option(OptionKey::ExitCode)? {
|
||||
OptionValue::Int(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the decoded command line (if any) to the writer. If an error
|
||||
/// occurs garbage may have been written to the writer.
|
||||
///
|
||||
/// TODO(integration): uses local copies of `printfQDecode` /
|
||||
/// `urlPercentDecode` (ZIG/src/os/string_encoding.zig); switch to
|
||||
/// `ghostty-os` once available (that crate would need to be a dependency
|
||||
/// of ghostty-vt, so these remain local for now).
|
||||
pub fn write_command_line(&self, writer: &mut Vec<u8>) -> Result<(), DecodeError> {
|
||||
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::Cmdline) {
|
||||
return printf_q_decode(writer, command_line);
|
||||
}
|
||||
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::CmdlineUrl) {
|
||||
return url_percent_decode(writer, command_line);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OptionKey {
|
||||
Aid,
|
||||
Cl,
|
||||
PromptKind,
|
||||
Err,
|
||||
Cmdline,
|
||||
CmdlineUrl,
|
||||
|
||||
// https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
|
||||
// Kitty supports a "redraw" option for prompt_start. This is extended
|
||||
// by Ghostty with the "last" option. See Redraw the type for more details.
|
||||
Redraw,
|
||||
|
||||
// Use a special key instead of arrow keys to move the cursor on
|
||||
// mouse click. Useful if arrow keys have side-effets like triggering
|
||||
// auto-complete. The shell integration script should bind the special
|
||||
// key as needed.
|
||||
// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
|
||||
SpecialKey,
|
||||
|
||||
// If true, the shell is capable of handling mouse click events.
|
||||
// Ghostty will then send a click event to the shell when the user
|
||||
// clicks somewhere in the prompt. The shell can then move the cursor
|
||||
// to that position or perform some other appropriate action. If false,
|
||||
// Ghostty may generate a number of fake key events to move the cursor
|
||||
// which is not very robust.
|
||||
// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
|
||||
ClickEvents,
|
||||
|
||||
// Not technically an option that can be set with k=v and only
|
||||
// present currently with command 'D' but its easier to just
|
||||
// parse it into our options.
|
||||
ExitCode,
|
||||
}
|
||||
|
||||
/// The value of an option; the variant is determined by the `Option` (Zig
|
||||
/// `Option.Type()`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OptionValue<'a> {
|
||||
Bytes(&'a [u8]),
|
||||
Click(Click),
|
||||
PromptKind(PromptKind),
|
||||
Redraw(Redraw),
|
||||
Bool(bool),
|
||||
Int(i32),
|
||||
}
|
||||
|
||||
impl OptionKey {
|
||||
fn key(self) -> &'static [u8] {
|
||||
match self {
|
||||
OptionKey::Aid => b"aid",
|
||||
OptionKey::Cl => b"cl",
|
||||
OptionKey::PromptKind => b"k",
|
||||
OptionKey::Err => b"err",
|
||||
OptionKey::Redraw => b"redraw",
|
||||
OptionKey::SpecialKey => b"special_key",
|
||||
OptionKey::ClickEvents => b"click_events",
|
||||
OptionKey::Cmdline => b"cmdline",
|
||||
OptionKey::CmdlineUrl => b"cmdline_url",
|
||||
|
||||
// special case, handled before ever calling key
|
||||
OptionKey::ExitCode => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the option value from the raw options string.
|
||||
///
|
||||
/// The raw options string is the raw unparsed data after the
|
||||
/// OSC 133 command. e.g. for `133;A;aid=14;cl=line`, the
|
||||
/// raw options string would be `aid=14;cl=line`.
|
||||
///
|
||||
/// Any errors in the raw string will return None since the OSC133
|
||||
/// specification says to ignore unknown or malformed options.
|
||||
pub fn read(self, raw: &[u8]) -> Option<OptionValue<'_>> {
|
||||
let mut remaining = raw;
|
||||
'outer: while !remaining.is_empty() {
|
||||
// Length of the next value is up to the `;` or the
|
||||
// end of the string.
|
||||
let len = remaining
|
||||
.iter()
|
||||
.position(|&b| b == b';')
|
||||
.unwrap_or(remaining.len());
|
||||
|
||||
// Grab our full value and move our cursor past the `;`
|
||||
let full = &remaining[..len];
|
||||
|
||||
// If we're looking for exit_code we special case it.
|
||||
// as the first value.
|
||||
if self == OptionKey::ExitCode {
|
||||
return std::str::from_utf8(full)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.map(OptionValue::Int);
|
||||
}
|
||||
|
||||
// Parse our key=value and verify our key matches our
|
||||
// expectation.
|
||||
let value = 'value: {
|
||||
if let Some(eql_idx) = full
|
||||
.iter()
|
||||
.position(|&b| b == b'=')
|
||||
.filter(|&i| full[..i] == *self.key())
|
||||
{
|
||||
break 'value &full[eql_idx + 1..];
|
||||
}
|
||||
|
||||
// No match!
|
||||
if len < remaining.len() {
|
||||
remaining = &remaining[len + 1..];
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
break 'outer;
|
||||
};
|
||||
|
||||
return match self {
|
||||
OptionKey::Aid | OptionKey::Err | OptionKey::Cmdline | OptionKey::CmdlineUrl => {
|
||||
Some(OptionValue::Bytes(value))
|
||||
}
|
||||
OptionKey::Cl => Click::init(value).map(OptionValue::Click),
|
||||
OptionKey::PromptKind => {
|
||||
if value.len() == 1 {
|
||||
PromptKind::init(value[0]).map(OptionValue::PromptKind)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
OptionKey::Redraw => match value {
|
||||
b"0" => Some(OptionValue::Redraw(Redraw::False)),
|
||||
b"1" => Some(OptionValue::Redraw(Redraw::True)),
|
||||
b"last" => Some(OptionValue::Redraw(Redraw::Last)),
|
||||
_ => None,
|
||||
},
|
||||
OptionKey::SpecialKey | OptionKey::ClickEvents => match value {
|
||||
b"0" => Some(OptionValue::Bool(false)),
|
||||
b"1" => Some(OptionValue::Bool(true)),
|
||||
_ => None,
|
||||
},
|
||||
// Handled above
|
||||
OptionKey::ExitCode => unreachable!(),
|
||||
};
|
||||
}
|
||||
|
||||
// Not found
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The `cl` option specifies what kind of cursor key sequences are handled
|
||||
/// by the application for click-to-move-cursor functionality.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Click {
|
||||
/// Value: "line". Allows motion within a single input line using standard
|
||||
/// left/right arrow escape sequences. Only a single left/right sequence
|
||||
/// should be emitted for double-width characters.
|
||||
Line,
|
||||
|
||||
/// Value: "m". Allows movement between different lines in the same group,
|
||||
/// but only using left/right arrow escape sequences.
|
||||
Multiple,
|
||||
|
||||
/// Value: "v". Like `multiple` but cursor up/down should be used. The
|
||||
/// terminal should be conservative when moving between lines: move the
|
||||
/// cursor left to the start of line, emit the needed up/down sequences,
|
||||
/// then move the cursor right to the clicked destination.
|
||||
ConservativeVertical,
|
||||
|
||||
/// Value: "w". Like `conservative_vertical` but specifies that there are
|
||||
/// no spurious spaces at the end of the line, and the application editor
|
||||
/// handles "smart vertical movement" (moving 2 lines up from position 20,
|
||||
/// where the intermediate line is 15 chars wide and the destination is
|
||||
/// 18 chars wide, ends at position 18).
|
||||
SmartVertical,
|
||||
}
|
||||
|
||||
impl Click {
|
||||
pub fn init(value: &[u8]) -> Option<Click> {
|
||||
if value.len() == 1 {
|
||||
match value[0] {
|
||||
b'm' => Some(Click::Multiple),
|
||||
b'v' => Some(Click::ConservativeVertical),
|
||||
b'w' => Some(Click::SmartVertical),
|
||||
_ => None,
|
||||
}
|
||||
} else if value == b"line" {
|
||||
Some(Click::Line)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PromptKind {
|
||||
Initial,
|
||||
Right,
|
||||
Continuation,
|
||||
Secondary,
|
||||
}
|
||||
|
||||
impl PromptKind {
|
||||
pub fn init(c: u8) -> Option<PromptKind> {
|
||||
match c {
|
||||
b'i' => Some(PromptKind::Initial),
|
||||
b'r' => Some(PromptKind::Right),
|
||||
b'c' => Some(PromptKind::Continuation),
|
||||
b's' => Some(PromptKind::Secondary),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The values for the `redraw` extension to OSC133. This was
|
||||
/// started by Kitty[1] and extended by Ghostty (the "last" option).
|
||||
///
|
||||
/// [1]: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Redraw {
|
||||
/// The shell supports redrawing the full prompt and all continuations.
|
||||
/// This is the default value, it does not need to be explicitly set
|
||||
/// unless it is to reset a prior other value.
|
||||
True,
|
||||
|
||||
/// The shell does NOT support redrawing. In this case, Ghostty will NOT
|
||||
/// clear any prompt lines on resize.
|
||||
False,
|
||||
|
||||
/// The shell supports redrawing only the LAST line of the prompt.
|
||||
/// Ghostty will only clear the last line of the prompt on resize.
|
||||
///
|
||||
/// This is specifically introduced because Bash only redraws the last
|
||||
/// line. It is literally the only shell that does this and it does this
|
||||
/// because its bad and they should feel bad. Don't be like Bash.
|
||||
Last,
|
||||
}
|
||||
|
||||
/// Parse OSC 133, semantic prompts
|
||||
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscCommand> {
|
||||
let data = parser.buffered()?;
|
||||
if data.is_empty() {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
|
||||
// All valid cases terminate within this block. Any fallthroughs
|
||||
// are invalid.
|
||||
let action = match data[0] {
|
||||
b'A' => Action::FreshLineNewPrompt,
|
||||
b'B' => Action::EndPromptStartInput,
|
||||
b'I' => Action::EndPromptStartInputTerminateEol,
|
||||
b'C' => Action::EndInputStartOutput,
|
||||
b'D' => Action::EndCommand,
|
||||
b'N' => Action::NewCommand,
|
||||
b'P' => Action::PromptStart,
|
||||
b'L' => {
|
||||
if data.len() > 1 {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
return Some(OscCommand::SemanticPrompt(Command::init(Action::FreshLine)));
|
||||
}
|
||||
_ => {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut cmd = Command::init(action);
|
||||
if data.len() > 1 {
|
||||
if data[1] != b';' {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
cmd.options_unvalidated = data[2..].to_vec();
|
||||
}
|
||||
Some(OscCommand::SemanticPrompt(cmd))
|
||||
}
|
||||
|
||||
// TODO(integration): local copies of ZIG/src/os/string_encoding.zig.
|
||||
|
||||
/// Decode data that has been encoded in the same way that `bash`'s
|
||||
/// `printf %q` encodes a string and write it to the writer. If an error is
|
||||
/// returned garbage may have been written to the buffer.
|
||||
pub fn printf_q_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
|
||||
let data: &[u8] = if buf.starts_with(b"$'") {
|
||||
// Strip off `$''` quoting.
|
||||
if buf.len() < 3 || !buf.ends_with(b"'") {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
&buf[2..buf.len() - 1]
|
||||
} else if buf.starts_with(b"'") {
|
||||
// Strip off `''` quoting.
|
||||
if buf.len() < 2 || !buf.ends_with(b"'") {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
&buf[1..buf.len() - 1]
|
||||
} else {
|
||||
buf
|
||||
};
|
||||
|
||||
let mut src = 0;
|
||||
while src < data.len() {
|
||||
match data[src] {
|
||||
b'\\' => {
|
||||
if src + 1 >= data.len() {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
match data[src + 1] {
|
||||
c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => writer.push(c),
|
||||
b'e' => writer.push(0x1b),
|
||||
b'n' => writer.push(b'\n'),
|
||||
b'r' => writer.push(b'\r'),
|
||||
b't' => writer.push(b'\t'),
|
||||
b'v' => writer.push(0x0b),
|
||||
_ => return Err(DecodeError),
|
||||
}
|
||||
src += 2;
|
||||
}
|
||||
c => {
|
||||
writer.push(c);
|
||||
src += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode data that has been URL percent encoded and write it to the given
|
||||
/// buffer. If an error is returned garbage may have been written.
|
||||
pub fn url_percent_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
|
||||
fn hex(c: u8) -> Option<u8> {
|
||||
match c {
|
||||
b'0'..=b'9' => Some(c - b'0'),
|
||||
b'a'..=b'f' => Some(c - b'a' + 10),
|
||||
b'A'..=b'F' => Some(c - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
let mut src = 0;
|
||||
while src < buf.len() {
|
||||
match buf[src] {
|
||||
b'%' => {
|
||||
if src + 2 >= buf.len() {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
match (hex(buf[src + 1]), hex(buf[src + 2])) {
|
||||
(Some(a), Some(b)) => {
|
||||
writer.push((a << 4) | b);
|
||||
src += 3;
|
||||
}
|
||||
_ => return Err(DecodeError),
|
||||
}
|
||||
}
|
||||
c => {
|
||||
writer.push(c);
|
||||
src += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &str) -> Option<Command> {
|
||||
let mut p = Parser::init(false);
|
||||
for ch in input.bytes() {
|
||||
p.next(ch);
|
||||
}
|
||||
match p.end(None) {
|
||||
Some(OscCommand::SemanticPrompt(c)) => Some(c),
|
||||
Some(c) => panic!("unexpected {c:?}"),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cmdline(input: &str) -> (Command, Result<Vec<u8>, DecodeError>) {
|
||||
let cmd = run(input).unwrap();
|
||||
assert_eq!(cmd.action, Action::EndInputStartOutput);
|
||||
let mut w = Vec::new();
|
||||
let r = cmd.write_command_line(&mut w).map(|()| w);
|
||||
(cmd, r)
|
||||
}
|
||||
|
||||
fn cmdline_ok(input: &str, expected: &str) {
|
||||
assert_eq!(cmdline(input).1.unwrap(), expected.as_bytes());
|
||||
}
|
||||
|
||||
fn cmdline_err(input: &str) {
|
||||
assert_eq!(cmdline(input).1, Err(DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output() {
|
||||
let cmd = run("133;C").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndInputStartOutput);
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_extra_contents() {
|
||||
assert!(run("133;Cextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_options() {
|
||||
let cmd = run("133;C;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndInputStartOutput);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline() {
|
||||
cmdline_ok("133;C;cmdline=echo bobr kurwa", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_3() {
|
||||
cmdline_ok("133;C;cmdline=echo bobr\\nkurwa", "echo bobr\nkurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_4() {
|
||||
cmdline_ok("133;C;cmdline=$'echo bobr kurwa'", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_5() {
|
||||
cmdline_ok("133;C;cmdline='echo bobr kurwa'", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_6() {
|
||||
cmdline_err("133;C;cmdline='echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_7() {
|
||||
cmdline_err("133;C;cmdline=$'echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_8() {
|
||||
cmdline_err("133;C;cmdline=$'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_9() {
|
||||
cmdline_ok("133;C;cmdline=", "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_1() {
|
||||
cmdline_ok("133;C;cmdline_url=echo bobr kurwa", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_2() {
|
||||
cmdline_ok("133;C;cmdline_url=echo bobr%20kurwa", "echo bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_3() {
|
||||
cmdline_ok("133;C;cmdline_url=echo bobr%3bkurwa", "echo bobr;kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_4() {
|
||||
cmdline_err("133;C;cmdline_url=echo bobr%3kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_5() {
|
||||
cmdline_err("133;C;cmdline_url=echo bobr%kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_6() {
|
||||
cmdline_ok("133;C;cmdline_url=echo bobr kurwa%20", "echo bobr kurwa ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_7() {
|
||||
cmdline_err("133;C;cmdline_url=echo bobr kurwa%2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_input_start_output_with_cmdline_url_8() {
|
||||
cmdline_err("133;C;cmdline_url=echo bobr kurwa%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line() {
|
||||
assert_eq!(run("133;L").unwrap().action, Action::FreshLine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_extra_contents() {
|
||||
// Random
|
||||
assert!(run("133;Lol").is_none());
|
||||
// Options
|
||||
assert!(run("133;L;aid=foo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt() {
|
||||
let cmd = run("133;A").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_aid() {
|
||||
let cmd = run("133;A;aid=14").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.aid().unwrap(), b"14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_equals_in_aid() {
|
||||
let cmd = run("133;A;aid=a=b").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.aid().unwrap(), b"a=b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_cl_line() {
|
||||
let cmd = run("133;A;cl=line").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.cl(), Some(Click::Line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_cl_m() {
|
||||
let cmd = run("133;A;cl=m").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.cl(), Some(Click::Multiple));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_invalid_cl() {
|
||||
let cmd = run("133;A;cl=invalid").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_trailing_semicolon() {
|
||||
let cmd = run("133;A;").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_bare_key() {
|
||||
let cmd = run("133;A;barekey").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_multiple_options() {
|
||||
let cmd = run("133;A;aid=foo;cl=line").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
assert_eq!(cmd.cl(), Some(Click::Line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_default_redraw() {
|
||||
let cmd = run("133;A").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.redraw().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_redraw_0() {
|
||||
let cmd = run("133;A;redraw=0").unwrap();
|
||||
assert_eq!(cmd.redraw(), Some(Redraw::False));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_redraw_1() {
|
||||
let cmd = run("133;A;redraw=1").unwrap();
|
||||
assert_eq!(cmd.redraw(), Some(Redraw::True));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_fresh_line_new_prompt_with_invalid_redraw() {
|
||||
let cmd = run("133;A;redraw=x").unwrap();
|
||||
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
|
||||
assert!(cmd.redraw().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start() {
|
||||
let cmd = run("133;P").unwrap();
|
||||
assert_eq!(cmd.action, Action::PromptStart);
|
||||
assert!(cmd.prompt_kind().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_k_i() {
|
||||
let cmd = run("133;P;k=i").unwrap();
|
||||
assert_eq!(cmd.action, Action::PromptStart);
|
||||
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Initial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_k_r() {
|
||||
let cmd = run("133;P;k=r").unwrap();
|
||||
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_k_c() {
|
||||
let cmd = run("133;P;k=c").unwrap();
|
||||
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Continuation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_k_s() {
|
||||
let cmd = run("133;P;k=s").unwrap();
|
||||
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Secondary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_with_invalid_k() {
|
||||
let cmd = run("133;P;k=x").unwrap();
|
||||
assert_eq!(cmd.action, Action::PromptStart);
|
||||
assert!(cmd.prompt_kind().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_prompt_start_extra_contents() {
|
||||
assert!(run("133;Pextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command() {
|
||||
let cmd = run("133;N").unwrap();
|
||||
assert_eq!(cmd.action, Action::NewCommand);
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.cl().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command_with_aid() {
|
||||
let cmd = run("133;N;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::NewCommand);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command_with_cl_line() {
|
||||
let cmd = run("133;N;cl=line").unwrap();
|
||||
assert_eq!(cmd.action, Action::NewCommand);
|
||||
assert_eq!(cmd.cl(), Some(Click::Line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command_with_multiple_options() {
|
||||
let cmd = run("133;N;aid=foo;cl=line").unwrap();
|
||||
assert_eq!(cmd.action, Action::NewCommand);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
assert_eq!(cmd.cl(), Some(Click::Line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_new_command_extra_contents() {
|
||||
assert!(run("133;Nextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input() {
|
||||
let cmd = run("133;B").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndPromptStartInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_extra_contents() {
|
||||
assert!(run("133;Bextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_with_options() {
|
||||
let cmd = run("133;B;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndPromptStartInput);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_terminate_eol() {
|
||||
let cmd = run("133;I").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndPromptStartInputTerminateEol);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_terminate_eol_extra_contents() {
|
||||
assert!(run("133;Iextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_prompt_start_input_terminate_eol_with_options() {
|
||||
let cmd = run("133;I;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndPromptStartInputTerminateEol);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_command() {
|
||||
let cmd = run("133;D").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndCommand);
|
||||
assert!(cmd.exit_code().is_none());
|
||||
assert!(cmd.aid().is_none());
|
||||
assert!(cmd.err().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_command_extra_contents() {
|
||||
assert!(run("133;Dextra").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_command_with_exit_code_0() {
|
||||
let cmd = run("133;D;0").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndCommand);
|
||||
assert_eq!(cmd.exit_code(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_end_command_with_exit_code_and_aid() {
|
||||
let cmd = run("133;D;12;aid=foo").unwrap();
|
||||
assert_eq!(cmd.action, Action::EndCommand);
|
||||
assert_eq!(cmd.aid().unwrap(), b"foo");
|
||||
assert_eq!(cmd.exit_code(), Some(12));
|
||||
}
|
||||
|
||||
fn bytes(v: Option<OptionValue<'_>>) -> Option<&[u8]> {
|
||||
match v {
|
||||
Some(OptionValue::Bytes(b)) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_aid() {
|
||||
let r = |s: &'static str| bytes(OptionKey::Aid.read(s.as_bytes()));
|
||||
assert_eq!(r("aid=test123").unwrap(), b"test123");
|
||||
assert_eq!(r("cl=line;aid=myaid;k=i").unwrap(), b"myaid");
|
||||
assert!(r("cl=line;k=i").is_none());
|
||||
assert_eq!(r("aid=").unwrap(), b"");
|
||||
assert_eq!(r("k=i;aid=last").unwrap(), b"last");
|
||||
assert_eq!(r("aid=first;k=i").unwrap(), b"first");
|
||||
assert!(r("").is_none());
|
||||
assert!(r("aid").is_none());
|
||||
assert_eq!(r(";;aid=value;;").unwrap(), b"value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_cl() {
|
||||
let r = |s: &'static str| OptionKey::Cl.read(s.as_bytes());
|
||||
assert_eq!(r("cl=line"), Some(OptionValue::Click(Click::Line)));
|
||||
assert_eq!(r("cl=m"), Some(OptionValue::Click(Click::Multiple)));
|
||||
assert_eq!(
|
||||
r("cl=v"),
|
||||
Some(OptionValue::Click(Click::ConservativeVertical))
|
||||
);
|
||||
assert_eq!(r("cl=w"), Some(OptionValue::Click(Click::SmartVertical)));
|
||||
assert!(r("cl=invalid").is_none());
|
||||
assert!(r("aid=foo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_prompt_kind() {
|
||||
let r = |s: &'static str| OptionKey::PromptKind.read(s.as_bytes());
|
||||
assert_eq!(r("k=i"), Some(OptionValue::PromptKind(PromptKind::Initial)));
|
||||
assert_eq!(r("k=r"), Some(OptionValue::PromptKind(PromptKind::Right)));
|
||||
assert_eq!(
|
||||
r("k=c"),
|
||||
Some(OptionValue::PromptKind(PromptKind::Continuation))
|
||||
);
|
||||
assert_eq!(
|
||||
r("k=s"),
|
||||
Some(OptionValue::PromptKind(PromptKind::Secondary))
|
||||
);
|
||||
assert!(r("k=x").is_none());
|
||||
assert!(r("k=ii").is_none());
|
||||
assert!(r("k=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_err() {
|
||||
assert_eq!(
|
||||
bytes(OptionKey::Err.read(b"err=some_error")).unwrap(),
|
||||
b"some_error"
|
||||
);
|
||||
assert!(OptionKey::Err.read(b"aid=foo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_redraw() {
|
||||
let r = |s: &'static str| OptionKey::Redraw.read(s.as_bytes());
|
||||
assert_eq!(r("redraw=1"), Some(OptionValue::Redraw(Redraw::True)));
|
||||
assert_eq!(r("redraw=0"), Some(OptionValue::Redraw(Redraw::False)));
|
||||
assert_eq!(r("redraw=last"), Some(OptionValue::Redraw(Redraw::Last)));
|
||||
assert!(r("redraw=2").is_none());
|
||||
assert!(r("redraw=10").is_none());
|
||||
assert!(r("redraw=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_special_key() {
|
||||
let r = |s: &'static str| OptionKey::SpecialKey.read(s.as_bytes());
|
||||
assert_eq!(r("special_key=1"), Some(OptionValue::Bool(true)));
|
||||
assert_eq!(r("special_key=0"), Some(OptionValue::Bool(false)));
|
||||
assert!(r("special_key=x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_click_events() {
|
||||
let r = |s: &'static str| OptionKey::ClickEvents.read(s.as_bytes());
|
||||
assert_eq!(r("click_events=1"), Some(OptionValue::Bool(true)));
|
||||
assert_eq!(r("click_events=0"), Some(OptionValue::Bool(false)));
|
||||
assert!(r("click_events=yes").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_read_exit_code() {
|
||||
let r = |s: &'static str| OptionKey::ExitCode.read(s.as_bytes());
|
||||
assert_eq!(r("42"), Some(OptionValue::Int(42)));
|
||||
assert_eq!(r("0"), Some(OptionValue::Int(0)));
|
||||
assert_eq!(r("-1"), Some(OptionValue::Int(-1)));
|
||||
assert!(r("abc").is_none());
|
||||
assert_eq!(r("127;aid=foo"), Some(OptionValue::Int(127)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_encoding_extra_printf_q_and_percent() {
|
||||
let mut w = Vec::new();
|
||||
printf_q_decode(&mut w, b"bobr\\ kurwa").unwrap();
|
||||
assert_eq!(w, b"bobr kurwa");
|
||||
assert_eq!(
|
||||
printf_q_decode(&mut Vec::new(), b"bobr\\dkurwa"),
|
||||
Err(DecodeError)
|
||||
);
|
||||
assert_eq!(
|
||||
printf_q_decode(&mut Vec::new(), b"bobr kurwa\\"),
|
||||
Err(DecodeError)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
//! The primary export of this file is `TABLE`, which contains a
|
||||
//! compile-time-generated state transition table for VT emulation
|
||||
//! (ZIG/src/terminal/parse_table.zig).
|
||||
//!
|
||||
//! This is based on the vt100.net state machine:
|
||||
//! https://vt100.net/emu/dec_ansi_parser
|
||||
//! But has some modifications:
|
||||
//!
|
||||
//! * csi_param accepts the colon character (':') since the SGR command
|
||||
//! accepts colon as a valid parameter value.
|
||||
|
||||
use crate::parser::{State, TransitionAction as A};
|
||||
|
||||
const NUM_STATES: usize = 14;
|
||||
|
||||
const STATES: [State; NUM_STATES] = [
|
||||
State::Ground,
|
||||
State::Escape,
|
||||
State::EscapeIntermediate,
|
||||
State::CsiEntry,
|
||||
State::CsiIntermediate,
|
||||
State::CsiParam,
|
||||
State::CsiIgnore,
|
||||
State::DcsEntry,
|
||||
State::DcsParam,
|
||||
State::DcsIntermediate,
|
||||
State::DcsPassthrough,
|
||||
State::DcsIgnore,
|
||||
State::OscString,
|
||||
State::SosPmApcString,
|
||||
];
|
||||
|
||||
/// Transition is the transition to take within the table
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Transition {
|
||||
pub state: State,
|
||||
pub action: A,
|
||||
}
|
||||
|
||||
/// The type of the state transition table: `[byte][state]`.
|
||||
pub type Table = [[Transition; NUM_STATES]; 256];
|
||||
|
||||
type OptionalTable = [[Option<Transition>; NUM_STATES]; 256];
|
||||
|
||||
/// The state transition table, indexed `TABLE[byte][state as usize]`.
|
||||
pub const TABLE: Table = gen_table();
|
||||
|
||||
/// Function to generate the full state transition table for VT emulation.
|
||||
const fn gen_table() -> Table {
|
||||
// We accumulate using an "optional" table so we can detect unset entries.
|
||||
let mut result: OptionalTable = [[None; NUM_STATES]; 256];
|
||||
|
||||
// anywhere transitions
|
||||
let mut s = 0;
|
||||
while s < NUM_STATES {
|
||||
let source = STATES[s];
|
||||
|
||||
// anywhere => ground
|
||||
single(&mut result, 0x18, source, State::Ground, A::Execute);
|
||||
single(&mut result, 0x1A, source, State::Ground, A::Execute);
|
||||
range(&mut result, 0x80, 0x8F, source, State::Ground, A::Execute);
|
||||
range(&mut result, 0x91, 0x97, source, State::Ground, A::Execute);
|
||||
single(&mut result, 0x99, source, State::Ground, A::Execute);
|
||||
single(&mut result, 0x9A, source, State::Ground, A::Execute);
|
||||
single(&mut result, 0x9C, source, State::Ground, A::None);
|
||||
|
||||
// anywhere => escape
|
||||
single(&mut result, 0x1B, source, State::Escape, A::None);
|
||||
|
||||
// anywhere => sos_pm_apc_string
|
||||
single(&mut result, 0x98, source, State::SosPmApcString, A::None);
|
||||
single(&mut result, 0x9E, source, State::SosPmApcString, A::None);
|
||||
single(&mut result, 0x9F, source, State::SosPmApcString, A::None);
|
||||
|
||||
// anywhere => csi_entry
|
||||
single(&mut result, 0x9B, source, State::CsiEntry, A::None);
|
||||
|
||||
// anywhere => dcs_entry
|
||||
single(&mut result, 0x90, source, State::DcsEntry, A::None);
|
||||
|
||||
// anywhere => osc_string
|
||||
single(&mut result, 0x9D, source, State::OscString, A::None);
|
||||
s += 1;
|
||||
}
|
||||
// ground
|
||||
{
|
||||
// events
|
||||
single(&mut result, 0x19, State::Ground, State::Ground, A::Execute);
|
||||
range(
|
||||
&mut result,
|
||||
0,
|
||||
0x17,
|
||||
State::Ground,
|
||||
State::Ground,
|
||||
A::Execute,
|
||||
);
|
||||
range(
|
||||
&mut result,
|
||||
0x1C,
|
||||
0x1F,
|
||||
State::Ground,
|
||||
State::Ground,
|
||||
A::Execute,
|
||||
);
|
||||
range(
|
||||
&mut result,
|
||||
0x20,
|
||||
0x7F,
|
||||
State::Ground,
|
||||
State::Ground,
|
||||
A::Print,
|
||||
);
|
||||
}
|
||||
|
||||
// escape_intermediate
|
||||
{
|
||||
let source = State::EscapeIntermediate;
|
||||
|
||||
single(&mut result, 0x19, source, source, A::Execute);
|
||||
range(&mut result, 0, 0x17, source, source, A::Execute);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
|
||||
range(&mut result, 0x20, 0x2F, source, source, A::Collect);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
|
||||
// => ground
|
||||
range(
|
||||
&mut result,
|
||||
0x30,
|
||||
0x7E,
|
||||
source,
|
||||
State::Ground,
|
||||
A::EscDispatch,
|
||||
);
|
||||
}
|
||||
|
||||
// sos_pm_apc_string
|
||||
{
|
||||
let source = State::SosPmApcString;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::ApcPut);
|
||||
range(&mut result, 0, 0x17, source, source, A::ApcPut);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::ApcPut);
|
||||
range(&mut result, 0x20, 0x7F, source, source, A::ApcPut);
|
||||
}
|
||||
|
||||
// escape
|
||||
{
|
||||
let source = State::Escape;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Execute);
|
||||
range(&mut result, 0, 0x17, source, source, A::Execute);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
|
||||
// => ground
|
||||
range(
|
||||
&mut result,
|
||||
0x30,
|
||||
0x4F,
|
||||
source,
|
||||
State::Ground,
|
||||
A::EscDispatch,
|
||||
);
|
||||
range(
|
||||
&mut result,
|
||||
0x51,
|
||||
0x57,
|
||||
source,
|
||||
State::Ground,
|
||||
A::EscDispatch,
|
||||
);
|
||||
range(
|
||||
&mut result,
|
||||
0x60,
|
||||
0x7E,
|
||||
source,
|
||||
State::Ground,
|
||||
A::EscDispatch,
|
||||
);
|
||||
single(&mut result, 0x59, source, State::Ground, A::EscDispatch);
|
||||
single(&mut result, 0x5A, source, State::Ground, A::EscDispatch);
|
||||
single(&mut result, 0x5C, source, State::Ground, A::EscDispatch);
|
||||
|
||||
// => escape_intermediate
|
||||
range(
|
||||
&mut result,
|
||||
0x20,
|
||||
0x2F,
|
||||
source,
|
||||
State::EscapeIntermediate,
|
||||
A::Collect,
|
||||
);
|
||||
|
||||
// => sos_pm_apc_string
|
||||
single(&mut result, 0x58, source, State::SosPmApcString, A::None);
|
||||
single(&mut result, 0x5E, source, State::SosPmApcString, A::None);
|
||||
single(&mut result, 0x5F, source, State::SosPmApcString, A::None);
|
||||
|
||||
// => dcs_entry
|
||||
single(&mut result, 0x50, source, State::DcsEntry, A::None);
|
||||
|
||||
// => csi_entry
|
||||
single(&mut result, 0x5B, source, State::CsiEntry, A::None);
|
||||
|
||||
// => osc_string
|
||||
single(&mut result, 0x5D, source, State::OscString, A::None);
|
||||
}
|
||||
|
||||
// dcs_entry
|
||||
{
|
||||
let source = State::DcsEntry;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Ignore);
|
||||
range(&mut result, 0, 0x17, source, source, A::Ignore);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
|
||||
// => dcs_intermediate
|
||||
range(
|
||||
&mut result,
|
||||
0x20,
|
||||
0x2F,
|
||||
source,
|
||||
State::DcsIntermediate,
|
||||
A::Collect,
|
||||
);
|
||||
|
||||
// => dcs_ignore
|
||||
single(&mut result, 0x3A, source, State::DcsIgnore, A::None);
|
||||
|
||||
// => dcs_param
|
||||
range(&mut result, 0x30, 0x39, source, State::DcsParam, A::Param);
|
||||
single(&mut result, 0x3B, source, State::DcsParam, A::Param);
|
||||
range(&mut result, 0x3C, 0x3F, source, State::DcsParam, A::Collect);
|
||||
|
||||
// => dcs_passthrough
|
||||
range(
|
||||
&mut result,
|
||||
0x40,
|
||||
0x7E,
|
||||
source,
|
||||
State::DcsPassthrough,
|
||||
A::None,
|
||||
);
|
||||
}
|
||||
|
||||
// dcs_intermediate
|
||||
{
|
||||
let source = State::DcsIntermediate;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Ignore);
|
||||
range(&mut result, 0, 0x17, source, source, A::Ignore);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
|
||||
range(&mut result, 0x20, 0x2F, source, source, A::Collect);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
|
||||
// => dcs_ignore
|
||||
range(&mut result, 0x30, 0x3F, source, State::DcsIgnore, A::None);
|
||||
|
||||
// => dcs_passthrough
|
||||
range(
|
||||
&mut result,
|
||||
0x40,
|
||||
0x7E,
|
||||
source,
|
||||
State::DcsPassthrough,
|
||||
A::None,
|
||||
);
|
||||
}
|
||||
|
||||
// dcs_ignore
|
||||
{
|
||||
let source = State::DcsIgnore;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Ignore);
|
||||
range(&mut result, 0, 0x17, source, source, A::Ignore);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
|
||||
}
|
||||
|
||||
// dcs_param
|
||||
{
|
||||
let source = State::DcsParam;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Ignore);
|
||||
range(&mut result, 0, 0x17, source, source, A::Ignore);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
|
||||
range(&mut result, 0x30, 0x39, source, source, A::Param);
|
||||
single(&mut result, 0x3B, source, source, A::Param);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
|
||||
// => dcs_ignore
|
||||
single(&mut result, 0x3A, source, State::DcsIgnore, A::None);
|
||||
range(&mut result, 0x3C, 0x3F, source, State::DcsIgnore, A::None);
|
||||
|
||||
// => dcs_intermediate
|
||||
range(
|
||||
&mut result,
|
||||
0x20,
|
||||
0x2F,
|
||||
source,
|
||||
State::DcsIntermediate,
|
||||
A::Collect,
|
||||
);
|
||||
|
||||
// => dcs_passthrough
|
||||
range(
|
||||
&mut result,
|
||||
0x40,
|
||||
0x7E,
|
||||
source,
|
||||
State::DcsPassthrough,
|
||||
A::None,
|
||||
);
|
||||
}
|
||||
|
||||
// dcs_passthrough
|
||||
{
|
||||
let source = State::DcsPassthrough;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Put);
|
||||
range(&mut result, 0, 0x17, source, source, A::Put);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Put);
|
||||
range(&mut result, 0x20, 0x7E, source, source, A::Put);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
}
|
||||
|
||||
// csi_param
|
||||
{
|
||||
let source = State::CsiParam;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Execute);
|
||||
range(&mut result, 0, 0x17, source, source, A::Execute);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
|
||||
range(&mut result, 0x30, 0x39, source, source, A::Param);
|
||||
single(&mut result, 0x3A, source, source, A::Param);
|
||||
single(&mut result, 0x3B, source, source, A::Param);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
|
||||
// => ground
|
||||
range(
|
||||
&mut result,
|
||||
0x40,
|
||||
0x7E,
|
||||
source,
|
||||
State::Ground,
|
||||
A::CsiDispatch,
|
||||
);
|
||||
|
||||
// => csi_ignore
|
||||
range(&mut result, 0x3C, 0x3F, source, State::CsiIgnore, A::None);
|
||||
|
||||
// => csi_intermediate
|
||||
range(
|
||||
&mut result,
|
||||
0x20,
|
||||
0x2F,
|
||||
source,
|
||||
State::CsiIntermediate,
|
||||
A::Collect,
|
||||
);
|
||||
}
|
||||
|
||||
// csi_ignore
|
||||
{
|
||||
let source = State::CsiIgnore;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Execute);
|
||||
range(&mut result, 0, 0x17, source, source, A::Execute);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
|
||||
range(&mut result, 0x20, 0x3F, source, source, A::Ignore);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
|
||||
// => ground
|
||||
range(&mut result, 0x40, 0x7E, source, State::Ground, A::None);
|
||||
}
|
||||
|
||||
// csi_intermediate
|
||||
{
|
||||
let source = State::CsiIntermediate;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Execute);
|
||||
range(&mut result, 0, 0x17, source, source, A::Execute);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
|
||||
range(&mut result, 0x20, 0x2F, source, source, A::Collect);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
|
||||
// => ground
|
||||
range(
|
||||
&mut result,
|
||||
0x40,
|
||||
0x7E,
|
||||
source,
|
||||
State::Ground,
|
||||
A::CsiDispatch,
|
||||
);
|
||||
|
||||
// => csi_ignore
|
||||
range(&mut result, 0x30, 0x3F, source, State::CsiIgnore, A::None);
|
||||
}
|
||||
|
||||
// csi_entry
|
||||
{
|
||||
let source = State::CsiEntry;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Execute);
|
||||
range(&mut result, 0, 0x17, source, source, A::Execute);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
|
||||
single(&mut result, 0x7F, source, source, A::Ignore);
|
||||
|
||||
// => ground
|
||||
range(
|
||||
&mut result,
|
||||
0x40,
|
||||
0x7E,
|
||||
source,
|
||||
State::Ground,
|
||||
A::CsiDispatch,
|
||||
);
|
||||
|
||||
// => csi_ignore
|
||||
single(&mut result, 0x3A, source, State::CsiIgnore, A::None);
|
||||
|
||||
// => csi_intermediate
|
||||
range(
|
||||
&mut result,
|
||||
0x20,
|
||||
0x2F,
|
||||
source,
|
||||
State::CsiIntermediate,
|
||||
A::Collect,
|
||||
);
|
||||
|
||||
// => csi_param
|
||||
range(&mut result, 0x30, 0x39, source, State::CsiParam, A::Param);
|
||||
single(&mut result, 0x3B, source, State::CsiParam, A::Param);
|
||||
range(&mut result, 0x3C, 0x3F, source, State::CsiParam, A::Collect);
|
||||
}
|
||||
|
||||
// osc_string
|
||||
{
|
||||
let source = State::OscString;
|
||||
|
||||
// events
|
||||
single(&mut result, 0x19, source, source, A::Ignore);
|
||||
range(&mut result, 0, 0x06, source, source, A::Ignore);
|
||||
range(&mut result, 0x08, 0x17, source, source, A::Ignore);
|
||||
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
|
||||
range(&mut result, 0x20, 0xFF, source, source, A::OscPut);
|
||||
|
||||
// XTerm accepts either BEL or ST for terminating OSC
|
||||
// sequences, and when returning information, uses the same
|
||||
// terminator used in a query.
|
||||
single(&mut result, 0x07, source, State::Ground, A::None);
|
||||
}
|
||||
|
||||
// Create our immutable version
|
||||
let mut fin: Table = [[Transition {
|
||||
state: State::Ground,
|
||||
action: A::None,
|
||||
}; NUM_STATES]; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let mut j = 0;
|
||||
while j < NUM_STATES {
|
||||
fin[i][j] = match result[i][j] {
|
||||
Some(t) => t,
|
||||
None => transition(STATES[j], A::None),
|
||||
};
|
||||
j += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
fin
|
||||
}
|
||||
|
||||
const fn single(t: &mut OptionalTable, c: u8, s0: State, s1: State, a: A) {
|
||||
t[c as usize][s0 as usize] = Some(transition(s1, a));
|
||||
}
|
||||
|
||||
const fn range(t: &mut OptionalTable, from: u8, to: u8, s0: State, s1: State, a: A) {
|
||||
let mut i = from;
|
||||
loop {
|
||||
single(t, i, s0, s1, a);
|
||||
// If 'to' is 0xFF, our next pass would overflow.
|
||||
if i == to {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const fn transition(state: State, action: A) -> Transition {
|
||||
Transition { state, action }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_builds() {
|
||||
// This forces evaluation of the table, so we're just testing
|
||||
// that it succeeds in creation.
|
||||
let _ = &TABLE;
|
||||
assert_eq!(
|
||||
TABLE[b'a' as usize][State::Ground as usize],
|
||||
transition(State::Ground, A::Print)
|
||||
);
|
||||
assert_eq!(
|
||||
TABLE[0x1B][State::CsiParam as usize],
|
||||
transition(State::Escape, A::None)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
//! VT-series parser for escape and control sequences
|
||||
//! (ZIG/src/terminal/Parser.zig).
|
||||
//!
|
||||
//! NOTE(W1): this file is owned by work package W1. `MAX_PARAMS` and
|
||||
//! `SepList` were pre-created by the orchestrator because `sgr` (W2)
|
||||
//! depends on them; keep their API stable.
|
||||
//! This is implemented directly as the state machine described on
|
||||
//! vt100.net: https://vt100.net/emu/dec_ansi_parser
|
||||
//!
|
||||
//! `MAX_PARAMS` and `SepList` were pre-created by the orchestrator because
|
||||
//! `sgr` depends on them; keep their API stable.
|
||||
//!
|
||||
//! Rust port note: Zig's `Action` borrows slices out of the parser
|
||||
//! (`csi_dispatch.params` etc., "only valid until the next call to next").
|
||||
//! Here the CSI/ESC/DCS payloads are small fixed-size inline copies exposed
|
||||
//! through `params()` / `intermediates()` accessors, so an `Action` owns its
|
||||
//! data and is independent of the parser's lifetime.
|
||||
|
||||
use crate::osc;
|
||||
use crate::parse_table::TABLE;
|
||||
|
||||
/// Maximum number of CSI parameters. This is a const in Ghostty so we
|
||||
/// match it.
|
||||
@@ -50,3 +61,924 @@ impl SepList {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of intermediate characters during parsing. This is
|
||||
/// 4 because we also use the intermediates array for UTF8 decoding which
|
||||
/// can be at most 4 bytes.
|
||||
pub const MAX_INTERMEDIATE: usize = 4;
|
||||
|
||||
/// States for the state machine
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum State {
|
||||
Ground,
|
||||
Escape,
|
||||
EscapeIntermediate,
|
||||
CsiEntry,
|
||||
CsiIntermediate,
|
||||
CsiParam,
|
||||
CsiIgnore,
|
||||
DcsEntry,
|
||||
DcsParam,
|
||||
DcsIntermediate,
|
||||
DcsPassthrough,
|
||||
DcsIgnore,
|
||||
OscString,
|
||||
SosPmApcString,
|
||||
}
|
||||
|
||||
/// Transition action is an action that can be taken during a state
|
||||
/// transition. This is more of an internal action, not one used by
|
||||
/// end users, typically.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TransitionAction {
|
||||
None,
|
||||
Ignore,
|
||||
Print,
|
||||
Execute,
|
||||
Collect,
|
||||
Param,
|
||||
EscDispatch,
|
||||
CsiDispatch,
|
||||
Put,
|
||||
OscPut,
|
||||
ApcPut,
|
||||
}
|
||||
|
||||
/// The separator used for CSI params.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Sep {
|
||||
Semicolon = 0,
|
||||
Colon = 1,
|
||||
}
|
||||
|
||||
/// CSI dispatch payload (Zig `Action.CSI`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Csi {
|
||||
intermediates_buf: [u8; MAX_INTERMEDIATE],
|
||||
intermediates_len: u8,
|
||||
params_buf: [u16; MAX_PARAMS],
|
||||
params_len: u8,
|
||||
/// The list of separators used for CSI params. The value of the
|
||||
/// bit can be mapped to Sep. The index of this bit set specifies
|
||||
/// the separator AFTER that param. For example: 0;4:3 would have
|
||||
/// index 1 set.
|
||||
pub params_sep: SepList,
|
||||
pub final_: u8,
|
||||
}
|
||||
|
||||
impl Csi {
|
||||
pub fn intermediates(&self) -> &[u8] {
|
||||
&self.intermediates_buf[..self.intermediates_len as usize]
|
||||
}
|
||||
|
||||
pub fn params(&self) -> &[u16] {
|
||||
&self.params_buf[..self.params_len as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Csi {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"ESC [ {} {:?} {}",
|
||||
String::from_utf8_lossy(self.intermediates()),
|
||||
self.params(),
|
||||
self.final_ as char
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// ESC dispatch payload (Zig `Action.ESC`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Esc {
|
||||
intermediates_buf: [u8; MAX_INTERMEDIATE],
|
||||
intermediates_len: u8,
|
||||
pub final_: u8,
|
||||
}
|
||||
|
||||
impl Esc {
|
||||
pub fn intermediates(&self) -> &[u8] {
|
||||
&self.intermediates_buf[..self.intermediates_len as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Esc {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"ESC {} {}",
|
||||
String::from_utf8_lossy(self.intermediates()),
|
||||
self.final_ as char
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// DCS hook payload (Zig `Action.DCS`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Dcs {
|
||||
intermediates_buf: [u8; MAX_INTERMEDIATE],
|
||||
intermediates_len: u8,
|
||||
params_buf: [u16; MAX_PARAMS],
|
||||
params_len: u8,
|
||||
pub final_: u8,
|
||||
}
|
||||
|
||||
impl Dcs {
|
||||
/// Build a DCS from slices (extra elements beyond the maximums are
|
||||
/// dropped). Useful for tests and for consumers constructing hooks.
|
||||
pub fn new(intermediates: &[u8], params: &[u16], final_: u8) -> Self {
|
||||
let mut d = Dcs {
|
||||
intermediates_buf: [0; MAX_INTERMEDIATE],
|
||||
intermediates_len: 0,
|
||||
params_buf: [0; MAX_PARAMS],
|
||||
params_len: 0,
|
||||
final_,
|
||||
};
|
||||
let n = intermediates.len().min(MAX_INTERMEDIATE);
|
||||
d.intermediates_buf[..n].copy_from_slice(&intermediates[..n]);
|
||||
d.intermediates_len = n as u8;
|
||||
let n = params.len().min(MAX_PARAMS);
|
||||
d.params_buf[..n].copy_from_slice(¶ms[..n]);
|
||||
d.params_len = n as u8;
|
||||
d
|
||||
}
|
||||
|
||||
pub fn intermediates(&self) -> &[u8] {
|
||||
&self.intermediates_buf[..self.intermediates_len as usize]
|
||||
}
|
||||
|
||||
pub fn params(&self) -> &[u16] {
|
||||
&self.params_buf[..self.params_len as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Action is the action that a caller of the parser is expected to
|
||||
/// take as a result of some input character.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
/// Draw character to the screen. This is a unicode codepoint.
|
||||
Print(u32),
|
||||
|
||||
/// Execute the C0 or C1 function.
|
||||
Execute(u8),
|
||||
|
||||
/// Execute the CSI command.
|
||||
CsiDispatch(Csi),
|
||||
|
||||
/// Execute the ESC command.
|
||||
EscDispatch(Esc),
|
||||
|
||||
/// Execute the OSC command.
|
||||
OscDispatch(osc::Command),
|
||||
|
||||
/// DCS-related events.
|
||||
DcsHook(Dcs),
|
||||
DcsPut(u8),
|
||||
DcsUnhook,
|
||||
|
||||
/// APC data
|
||||
ApcStart,
|
||||
ApcPut(u8),
|
||||
ApcEnd,
|
||||
}
|
||||
|
||||
/// The VT parser state machine.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Parser {
|
||||
/// Current state of the state machine
|
||||
pub state: State,
|
||||
|
||||
/// Intermediate tracking.
|
||||
intermediates: [u8; MAX_INTERMEDIATE],
|
||||
intermediates_idx: u8,
|
||||
|
||||
/// Param tracking, building
|
||||
params: [u16; MAX_PARAMS],
|
||||
params_sep: SepList,
|
||||
params_idx: u8,
|
||||
param_acc: u16,
|
||||
param_acc_idx: u8,
|
||||
|
||||
/// Parser for OSC sequences
|
||||
pub osc_parser: osc::Parser,
|
||||
}
|
||||
|
||||
impl Default for Parser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
pub fn new() -> Parser {
|
||||
Parser {
|
||||
state: State::Ground,
|
||||
intermediates: [0; MAX_INTERMEDIATE],
|
||||
intermediates_idx: 0,
|
||||
params: [0; MAX_PARAMS],
|
||||
params_sep: SepList::init_empty(),
|
||||
params_idx: 0,
|
||||
param_acc: 0,
|
||||
param_acc_idx: 0,
|
||||
osc_parser: osc::Parser::init(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Next consumes the next character c and returns the actions to execute.
|
||||
/// Up to 3 actions may need to be executed -- in order -- representing
|
||||
/// the state exit, transition, and entry actions.
|
||||
pub fn next(&mut self, c: u8) -> [Option<Action>; 3] {
|
||||
let effect = TABLE[c as usize][self.state as usize];
|
||||
|
||||
let next_state = effect.state;
|
||||
let action = effect.action;
|
||||
|
||||
// When going from one state to another, the actions take place in this order:
|
||||
//
|
||||
// 1. exit action from old state
|
||||
// 2. transition action
|
||||
// 3. entry action to new state
|
||||
|
||||
// Exit depends on current state
|
||||
let exit = if self.state == next_state {
|
||||
None
|
||||
} else {
|
||||
match self.state {
|
||||
State::OscString => self.osc_parser.end(Some(c)).map(Action::OscDispatch),
|
||||
State::DcsPassthrough => Some(Action::DcsUnhook),
|
||||
State::SosPmApcString => Some(Action::ApcEnd),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let transition = self.do_action(action, c);
|
||||
|
||||
// Entry depends on new state
|
||||
let entry = if self.state == next_state {
|
||||
None
|
||||
} else {
|
||||
match next_state {
|
||||
State::Escape | State::DcsEntry | State::CsiEntry => {
|
||||
self.clear();
|
||||
None
|
||||
}
|
||||
State::OscString => {
|
||||
self.osc_parser.reset();
|
||||
None
|
||||
}
|
||||
State::DcsPassthrough => 'dcs_hook: {
|
||||
// Ignore too many parameters
|
||||
if self.params_idx as usize >= MAX_PARAMS {
|
||||
break 'dcs_hook None;
|
||||
}
|
||||
// Finalize parameters
|
||||
if self.param_acc_idx > 0 {
|
||||
self.params[self.params_idx as usize] = self.param_acc;
|
||||
self.params_idx += 1;
|
||||
}
|
||||
Some(Action::DcsHook(Dcs::new(
|
||||
&self.intermediates[..self.intermediates_idx as usize],
|
||||
&self.params[..self.params_idx as usize],
|
||||
c,
|
||||
)))
|
||||
}
|
||||
State::SosPmApcString => Some(Action::ApcStart),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
// After generating the actions, we set our next state.
|
||||
self.state = next_state;
|
||||
|
||||
[exit, transition, entry]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn collect(&mut self, c: u8) {
|
||||
if self.intermediates_idx as usize >= MAX_INTERMEDIATE {
|
||||
log::warn!("invalid intermediates count");
|
||||
return;
|
||||
}
|
||||
|
||||
self.intermediates[self.intermediates_idx as usize] = c;
|
||||
self.intermediates_idx += 1;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn do_action(&mut self, action: TransitionAction, c: u8) -> Option<Action> {
|
||||
match action {
|
||||
TransitionAction::None | TransitionAction::Ignore => None,
|
||||
TransitionAction::Print => Some(Action::Print(c as u32)),
|
||||
TransitionAction::Execute => Some(Action::Execute(c)),
|
||||
TransitionAction::Collect => {
|
||||
self.collect(c);
|
||||
None
|
||||
}
|
||||
TransitionAction::Param => {
|
||||
// Semicolon separates parameters. If we encounter a semicolon
|
||||
// we need to store and move on to the next parameter.
|
||||
if c == b';' || c == b':' {
|
||||
// Ignore too many parameters
|
||||
if self.params_idx as usize >= MAX_PARAMS {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Set param final value
|
||||
self.params[self.params_idx as usize] = self.param_acc;
|
||||
if c == b':' {
|
||||
self.params_sep.set(self.params_idx as usize);
|
||||
}
|
||||
self.params_idx += 1;
|
||||
|
||||
// Reset current param value to 0
|
||||
self.param_acc = 0;
|
||||
self.param_acc_idx = 0;
|
||||
return None;
|
||||
}
|
||||
|
||||
// A numeric value. Add it to our accumulator.
|
||||
self.param_acc = self.param_acc.saturating_mul(10);
|
||||
self.param_acc = self.param_acc.saturating_add((c - b'0') as u16);
|
||||
|
||||
// Increment our accumulator index. If we overflow then
|
||||
// we're out of bounds and we exit immediately.
|
||||
self.param_acc_idx = self.param_acc_idx.wrapping_add(1);
|
||||
|
||||
// The client is expected to perform no action.
|
||||
None
|
||||
}
|
||||
TransitionAction::OscPut => {
|
||||
self.osc_parser.next(c);
|
||||
None
|
||||
}
|
||||
TransitionAction::CsiDispatch => {
|
||||
// Ignore too many parameters
|
||||
if self.params_idx as usize >= MAX_PARAMS {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Finalize parameters if we have one
|
||||
if self.param_acc_idx > 0 {
|
||||
self.params[self.params_idx as usize] = self.param_acc;
|
||||
self.params_idx += 1;
|
||||
}
|
||||
|
||||
let mut csi = Csi {
|
||||
intermediates_buf: self.intermediates,
|
||||
intermediates_len: self.intermediates_idx,
|
||||
params_buf: [0; MAX_PARAMS],
|
||||
params_len: self.params_idx,
|
||||
params_sep: self.params_sep,
|
||||
final_: c,
|
||||
};
|
||||
csi.params_buf[..self.params_idx as usize]
|
||||
.copy_from_slice(&self.params[..self.params_idx as usize]);
|
||||
|
||||
// We only allow colon or mixed separators for the 'm' command.
|
||||
if c != b'm' && self.params_sep.count() > 0 {
|
||||
log::warn!(
|
||||
"CSI colon or mixed separators only allowed for 'm' command, got: {csi}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Action::CsiDispatch(csi))
|
||||
}
|
||||
TransitionAction::EscDispatch => Some(Action::EscDispatch(Esc {
|
||||
intermediates_buf: self.intermediates,
|
||||
intermediates_len: self.intermediates_idx,
|
||||
final_: c,
|
||||
})),
|
||||
TransitionAction::Put => Some(Action::DcsPut(c)),
|
||||
TransitionAction::ApcPut => Some(Action::ApcPut(c)),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn clear(&mut self) {
|
||||
self.intermediates_idx = 0;
|
||||
self.params_idx = 0;
|
||||
self.params_sep = SepList::init_empty();
|
||||
self.param_acc = 0;
|
||||
self.param_acc_idx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
use crate::osc::Command;
|
||||
|
||||
fn feed_quiet(p: &mut Parser, s: &[u8]) {
|
||||
for &c in s {
|
||||
let a = p.next(c);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[1].is_none());
|
||||
assert!(a[2].is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn csi(a: &[Option<Action>; 3]) -> Csi {
|
||||
match a[1].as_ref().expect("csi_dispatch") {
|
||||
Action::CsiDispatch(c) => *c,
|
||||
other => panic!("expected csi_dispatch, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_basic() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x9E);
|
||||
assert_eq!(p.state, State::SosPmApcString);
|
||||
p.next(0x9C);
|
||||
assert_eq!(p.state, State::Ground);
|
||||
|
||||
{
|
||||
let a = p.next(b'a');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(matches!(a[1], Some(Action::Print(_))));
|
||||
assert!(a[2].is_none());
|
||||
}
|
||||
|
||||
{
|
||||
let a = p.next(0x19);
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(matches!(a[1], Some(Action::Execute(_))));
|
||||
assert!(a[2].is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_esc_paren_b() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
p.next(b'(');
|
||||
|
||||
let a = p.next(b'B');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let Some(Action::EscDispatch(d)) = &a[1] else {
|
||||
panic!("expected esc_dispatch");
|
||||
};
|
||||
assert_eq!(d.final_, b'B');
|
||||
assert_eq!(d.intermediates(), b"(");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_esc_bracket_h() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
p.next(0x5B);
|
||||
|
||||
let a = p.next(0x48);
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, 0x48);
|
||||
assert_eq!(d.params().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_esc_bracket_1_semicolon_4_h() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
p.next(0x5B);
|
||||
p.next(0x31); // 1
|
||||
p.next(0x3B); // ;
|
||||
p.next(0x34); // 4
|
||||
|
||||
let a = p.next(0x48); // H
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, b'H');
|
||||
assert_eq!(d.params(), &[1, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_sgr_esc_bracket_38_colon_2_m() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
p.next(b'[');
|
||||
p.next(b'3');
|
||||
p.next(b'8');
|
||||
p.next(b':');
|
||||
p.next(b'2');
|
||||
|
||||
let a = p.next(b'm');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, b'm');
|
||||
assert_eq!(d.params(), &[38, 2]);
|
||||
assert!(d.params_sep.is_set(0));
|
||||
assert!(!d.params_sep.is_set(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_sgr_colon_followed_by_semicolon() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"[48:2");
|
||||
|
||||
{
|
||||
let a = p.next(b'm');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(matches!(a[1], Some(Action::CsiDispatch(_))));
|
||||
assert!(a[2].is_none());
|
||||
}
|
||||
|
||||
p.next(0x1B);
|
||||
p.next(b'[');
|
||||
{
|
||||
let a = p.next(b'H');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(matches!(a[1], Some(Action::CsiDispatch(_))));
|
||||
assert!(a[2].is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_sgr_mixed_colon_and_semicolon() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"[38:5:1;48:5:0");
|
||||
|
||||
let a = p.next(b'm');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(matches!(a[1], Some(Action::CsiDispatch(_))));
|
||||
assert!(a[2].is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_sgr_esc_bracket_48_colon_2_m() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"[48:2:240:143:104");
|
||||
|
||||
let a = p.next(b'm');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, b'm');
|
||||
assert_eq!(d.params(), &[48, 2, 240, 143, 104]);
|
||||
for (i, expect) in [true, true, true, true, false].iter().enumerate() {
|
||||
assert_eq!(d.params_sep.is_set(i), *expect, "sep {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_sgr_esc_bracket_4_colon_3_m_colon() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
p.next(b'[');
|
||||
p.next(b'4');
|
||||
p.next(b':');
|
||||
p.next(b'3');
|
||||
|
||||
let a = p.next(b'm');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, b'm');
|
||||
assert_eq!(d.params(), &[4, 3]);
|
||||
assert!(d.params_sep.is_set(0));
|
||||
assert!(!d.params_sep.is_set(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_sgr_with_many_blank_and_colon() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"[58:2::240:143:104");
|
||||
|
||||
let a = p.next(b'm');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, b'm');
|
||||
assert_eq!(d.params(), &[58, 2, 0, 240, 143, 104]);
|
||||
for (i, expect) in [true, true, true, true, true, false].iter().enumerate() {
|
||||
assert_eq!(d.params_sep.is_set(i), *expect, "sep {i}");
|
||||
}
|
||||
}
|
||||
|
||||
// This is from a Kakoune actual SGR sequence.
|
||||
#[test]
|
||||
fn csi_sgr_mixed_colon_and_semicolon_with_blank() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"[;4:3;38;2;175;175;215;58:2::190:80:70");
|
||||
|
||||
let a = p.next(b'm');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, b'm');
|
||||
assert_eq!(
|
||||
d.params(),
|
||||
&[0, 4, 3, 38, 2, 175, 175, 215, 58, 2, 0, 190, 80, 70]
|
||||
);
|
||||
let set = [1usize, 8, 9, 10, 11, 12];
|
||||
for i in 0..14 {
|
||||
assert_eq!(d.params_sep.is_set(i), set.contains(&i), "sep {i}");
|
||||
}
|
||||
}
|
||||
|
||||
// This is from a Kakoune actual SGR sequence also.
|
||||
#[test]
|
||||
fn csi_sgr_mixed_colon_and_semicolon_setting_underline_bg_fg() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(
|
||||
&mut p,
|
||||
b"[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136",
|
||||
);
|
||||
|
||||
let a = p.next(b'm');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, b'm');
|
||||
assert_eq!(
|
||||
d.params(),
|
||||
&[
|
||||
4, 3, 38, 2, 51, 51, 51, 48, 2, 170, 170, 170, 58, 2, 255, 97, 136
|
||||
]
|
||||
);
|
||||
for i in 0..17 {
|
||||
assert_eq!(d.params_sep.is_set(i), i == 0, "sep {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_colon_for_non_m_final() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"[38:2h");
|
||||
assert_eq!(p.state, State::Ground);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_request_mode_decrqm() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"[?2026$");
|
||||
|
||||
let a = p.next(b'p');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, b'p');
|
||||
assert_eq!(d.intermediates(), b"?$");
|
||||
assert_eq!(d.params(), &[2026]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_change_cursor() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"[3 ");
|
||||
|
||||
let a = p.next(b'q');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let d = csi(&a);
|
||||
assert_eq!(d.final_, b'q');
|
||||
assert_eq!(d.intermediates(), b" ");
|
||||
assert_eq!(d.params(), &[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_change_window_title() {
|
||||
let mut p = Parser::new();
|
||||
for c in [0x1B, b']', b'0', b';', b'a', b'b', b'c'] {
|
||||
p.next(c);
|
||||
}
|
||||
|
||||
let a = p.next(0x07); // BEL
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[1].is_none());
|
||||
assert!(a[2].is_none());
|
||||
assert_eq!(
|
||||
a[0],
|
||||
Some(Action::OscDispatch(Command::ChangeWindowTitle(
|
||||
b"abc".to_vec()
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_change_window_title_end_in_esc() {
|
||||
let mut p = Parser::new();
|
||||
for c in [0x1B, b']', b'0', b';', b'a', b'b', b'c'] {
|
||||
p.next(c);
|
||||
}
|
||||
|
||||
let a = p.next(0x1B);
|
||||
p.next(b'\\');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[1].is_none());
|
||||
assert!(a[2].is_none());
|
||||
assert_eq!(
|
||||
a[0],
|
||||
Some(Action::OscDispatch(Command::ChangeWindowTitle(
|
||||
b"abc".to_vec()
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
// https://github.com/darrenstarr/VtNetCore/pull/14
|
||||
// Saw this on HN, decided to add a test case because why not.
|
||||
#[test]
|
||||
fn osc_112_incomplete_sequence() {
|
||||
let mut p = Parser::new();
|
||||
p.osc_parser.alloc = true;
|
||||
|
||||
for c in [0x1B, b']', b'1', b'1', b'2'] {
|
||||
p.next(c);
|
||||
}
|
||||
|
||||
let a = p.next(0x07);
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[1].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let Some(Action::OscDispatch(Command::ColorOperation {
|
||||
op,
|
||||
requests,
|
||||
terminator,
|
||||
})) = a[0].clone()
|
||||
else {
|
||||
panic!("expected color_operation");
|
||||
};
|
||||
assert_eq!(terminator, osc::Terminator::Bel);
|
||||
assert_eq!(op, osc::color::Operation::Osc112);
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0],
|
||||
osc::color::Request::Reset(osc::color::Target::Dynamic(crate::color::Dynamic::Cursor))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_104_empty() {
|
||||
let mut p = Parser::new();
|
||||
p.osc_parser.alloc = true;
|
||||
|
||||
for c in [0x1B, b']', b'1', b'0', b'4'] {
|
||||
p.next(c);
|
||||
}
|
||||
|
||||
let a = p.next(0x07);
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[1].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let Some(Action::OscDispatch(Command::ColorOperation {
|
||||
op,
|
||||
requests,
|
||||
terminator,
|
||||
})) = a[0].clone()
|
||||
else {
|
||||
panic!("expected color_operation");
|
||||
};
|
||||
assert_eq!(terminator, osc::Terminator::Bel);
|
||||
assert_eq!(op, osc::color::Operation::Osc104);
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0], osc::color::Request::ResetPalette);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_too_many_params() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
p.next(b'[');
|
||||
for _ in 0..100 {
|
||||
p.next(b'1');
|
||||
p.next(b';');
|
||||
}
|
||||
p.next(b'1');
|
||||
|
||||
let a = p.next(b'C');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[1].is_none());
|
||||
assert!(a[2].is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_sgr_with_up_to_our_max_parameters() {
|
||||
for max in 1..=MAX_PARAMS {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
p.next(b'[');
|
||||
|
||||
for _ in 0..max - 1 {
|
||||
p.next(b'1');
|
||||
p.next(b';');
|
||||
}
|
||||
p.next(b'2');
|
||||
|
||||
let a = p.next(b'H');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[2].is_none());
|
||||
let c = csi(&a);
|
||||
assert_eq!(c.params().len(), max);
|
||||
assert_eq!(c.params()[max - 1], 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_sgr_beyond_our_max_drops_it() {
|
||||
// Has to be +2 for the loops below
|
||||
let max = MAX_PARAMS + 2;
|
||||
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
p.next(b'[');
|
||||
|
||||
for _ in 0..max - 1 {
|
||||
p.next(b'1');
|
||||
p.next(b';');
|
||||
}
|
||||
p.next(b'2');
|
||||
|
||||
let a = p.next(b'H');
|
||||
assert_eq!(p.state, State::Ground);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[1].is_none());
|
||||
assert!(a[2].is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcs_xtgettcap() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"P+");
|
||||
|
||||
let a = p.next(b'q');
|
||||
assert_eq!(p.state, State::DcsPassthrough);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[1].is_none());
|
||||
let Some(Action::DcsHook(hook)) = &a[2] else {
|
||||
panic!("expected dcs_hook");
|
||||
};
|
||||
assert_eq!(hook.intermediates(), b"+");
|
||||
assert_eq!(hook.params(), &[] as &[u16]);
|
||||
assert_eq!(hook.final_, b'q');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcs_params() {
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B);
|
||||
feed_quiet(&mut p, b"P1000");
|
||||
|
||||
let a = p.next(b'p');
|
||||
assert_eq!(p.state, State::DcsPassthrough);
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[1].is_none());
|
||||
let Some(Action::DcsHook(hook)) = &a[2] else {
|
||||
panic!("expected dcs_hook");
|
||||
};
|
||||
assert_eq!(hook.params(), &[1000]);
|
||||
assert_eq!(hook.final_, b'p');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcs_too_many_params() {
|
||||
// Regression test for a crash found by fuzzing (afl). When a DCS
|
||||
// sequence has more than MAX_PARAMS parameters and param_acc_idx > 0,
|
||||
// entering dcs_passthrough wrote to params[params_idx] without a
|
||||
// bounds check, causing an out-of-bounds access.
|
||||
let mut p = Parser::new();
|
||||
p.next(0x1B); // ESC
|
||||
p.next(b'P'); // DCS entry
|
||||
|
||||
// Feed a digit then MAX_PARAMS semicolons to fill all param slots.
|
||||
p.next(b'6');
|
||||
for _ in 0..MAX_PARAMS {
|
||||
p.next(b';');
|
||||
}
|
||||
// Feed another digit so param_acc_idx > 0 while params_idx == MAX_PARAMS.
|
||||
p.next(b'7');
|
||||
|
||||
// A final byte triggers entry to dcs_passthrough. The DCS should
|
||||
// be dropped entirely, consistent with how CSI handles overflow.
|
||||
let a = p.next(b'p');
|
||||
assert!(a[0].is_none());
|
||||
assert!(a[1].is_none());
|
||||
assert!(a[2].is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
255 250 250 snow
|
||||
248 248 255 ghost white
|
||||
248 248 255 GhostWhite
|
||||
245 245 245 white smoke
|
||||
245 245 245 WhiteSmoke
|
||||
220 220 220 gainsboro
|
||||
255 250 240 floral white
|
||||
255 250 240 FloralWhite
|
||||
253 245 230 old lace
|
||||
253 245 230 OldLace
|
||||
250 240 230 linen
|
||||
250 235 215 antique white
|
||||
250 235 215 AntiqueWhite
|
||||
255 239 213 papaya whip
|
||||
255 239 213 PapayaWhip
|
||||
255 235 205 blanched almond
|
||||
255 235 205 BlanchedAlmond
|
||||
255 228 196 bisque
|
||||
255 218 185 peach puff
|
||||
255 218 185 PeachPuff
|
||||
255 222 173 navajo white
|
||||
255 222 173 NavajoWhite
|
||||
255 228 181 moccasin
|
||||
255 248 220 cornsilk
|
||||
255 255 240 ivory
|
||||
255 250 205 lemon chiffon
|
||||
255 250 205 LemonChiffon
|
||||
255 245 238 seashell
|
||||
240 255 240 honeydew
|
||||
245 255 250 mint cream
|
||||
245 255 250 MintCream
|
||||
240 255 255 azure
|
||||
240 248 255 alice blue
|
||||
240 248 255 AliceBlue
|
||||
230 230 250 lavender
|
||||
255 240 245 lavender blush
|
||||
255 240 245 LavenderBlush
|
||||
255 228 225 misty rose
|
||||
255 228 225 MistyRose
|
||||
255 255 255 white
|
||||
0 0 0 black
|
||||
47 79 79 dark slate gray
|
||||
47 79 79 DarkSlateGray
|
||||
47 79 79 dark slate grey
|
||||
47 79 79 DarkSlateGrey
|
||||
105 105 105 dim gray
|
||||
105 105 105 DimGray
|
||||
105 105 105 dim grey
|
||||
105 105 105 DimGrey
|
||||
112 128 144 slate gray
|
||||
112 128 144 SlateGray
|
||||
112 128 144 slate grey
|
||||
112 128 144 SlateGrey
|
||||
119 136 153 light slate gray
|
||||
119 136 153 LightSlateGray
|
||||
119 136 153 light slate grey
|
||||
119 136 153 LightSlateGrey
|
||||
190 190 190 gray
|
||||
190 190 190 grey
|
||||
190 190 190 x11 gray
|
||||
190 190 190 X11Gray
|
||||
190 190 190 x11 grey
|
||||
190 190 190 X11Grey
|
||||
128 128 128 web gray
|
||||
128 128 128 WebGray
|
||||
128 128 128 web grey
|
||||
128 128 128 WebGrey
|
||||
211 211 211 light grey
|
||||
211 211 211 LightGrey
|
||||
211 211 211 light gray
|
||||
211 211 211 LightGray
|
||||
25 25 112 midnight blue
|
||||
25 25 112 MidnightBlue
|
||||
0 0 128 navy
|
||||
0 0 128 navy blue
|
||||
0 0 128 NavyBlue
|
||||
100 149 237 cornflower blue
|
||||
100 149 237 CornflowerBlue
|
||||
72 61 139 dark slate blue
|
||||
72 61 139 DarkSlateBlue
|
||||
106 90 205 slate blue
|
||||
106 90 205 SlateBlue
|
||||
123 104 238 medium slate blue
|
||||
123 104 238 MediumSlateBlue
|
||||
132 112 255 light slate blue
|
||||
132 112 255 LightSlateBlue
|
||||
0 0 205 medium blue
|
||||
0 0 205 MediumBlue
|
||||
65 105 225 royal blue
|
||||
65 105 225 RoyalBlue
|
||||
0 0 255 blue
|
||||
30 144 255 dodger blue
|
||||
30 144 255 DodgerBlue
|
||||
0 191 255 deep sky blue
|
||||
0 191 255 DeepSkyBlue
|
||||
135 206 235 sky blue
|
||||
135 206 235 SkyBlue
|
||||
135 206 250 light sky blue
|
||||
135 206 250 LightSkyBlue
|
||||
70 130 180 steel blue
|
||||
70 130 180 SteelBlue
|
||||
176 196 222 light steel blue
|
||||
176 196 222 LightSteelBlue
|
||||
173 216 230 light blue
|
||||
173 216 230 LightBlue
|
||||
176 224 230 powder blue
|
||||
176 224 230 PowderBlue
|
||||
175 238 238 pale turquoise
|
||||
175 238 238 PaleTurquoise
|
||||
0 206 209 dark turquoise
|
||||
0 206 209 DarkTurquoise
|
||||
72 209 204 medium turquoise
|
||||
72 209 204 MediumTurquoise
|
||||
64 224 208 turquoise
|
||||
0 255 255 cyan
|
||||
0 255 255 aqua
|
||||
224 255 255 light cyan
|
||||
224 255 255 LightCyan
|
||||
95 158 160 cadet blue
|
||||
95 158 160 CadetBlue
|
||||
102 205 170 medium aquamarine
|
||||
102 205 170 MediumAquamarine
|
||||
127 255 212 aquamarine
|
||||
0 100 0 dark green
|
||||
0 100 0 DarkGreen
|
||||
85 107 47 dark olive green
|
||||
85 107 47 DarkOliveGreen
|
||||
143 188 143 dark sea green
|
||||
143 188 143 DarkSeaGreen
|
||||
46 139 87 sea green
|
||||
46 139 87 SeaGreen
|
||||
60 179 113 medium sea green
|
||||
60 179 113 MediumSeaGreen
|
||||
32 178 170 light sea green
|
||||
32 178 170 LightSeaGreen
|
||||
152 251 152 pale green
|
||||
152 251 152 PaleGreen
|
||||
0 255 127 spring green
|
||||
0 255 127 SpringGreen
|
||||
124 252 0 lawn green
|
||||
124 252 0 LawnGreen
|
||||
0 255 0 green
|
||||
0 255 0 lime
|
||||
0 255 0 x11 green
|
||||
0 255 0 X11Green
|
||||
0 128 0 web green
|
||||
0 128 0 WebGreen
|
||||
127 255 0 chartreuse
|
||||
0 250 154 medium spring green
|
||||
0 250 154 MediumSpringGreen
|
||||
173 255 47 green yellow
|
||||
173 255 47 GreenYellow
|
||||
50 205 50 lime green
|
||||
50 205 50 LimeGreen
|
||||
154 205 50 yellow green
|
||||
154 205 50 YellowGreen
|
||||
34 139 34 forest green
|
||||
34 139 34 ForestGreen
|
||||
107 142 35 olive drab
|
||||
107 142 35 OliveDrab
|
||||
189 183 107 dark khaki
|
||||
189 183 107 DarkKhaki
|
||||
240 230 140 khaki
|
||||
238 232 170 pale goldenrod
|
||||
238 232 170 PaleGoldenrod
|
||||
250 250 210 light goldenrod yellow
|
||||
250 250 210 LightGoldenrodYellow
|
||||
255 255 224 light yellow
|
||||
255 255 224 LightYellow
|
||||
255 255 0 yellow
|
||||
255 215 0 gold
|
||||
238 221 130 light goldenrod
|
||||
238 221 130 LightGoldenrod
|
||||
218 165 32 goldenrod
|
||||
184 134 11 dark goldenrod
|
||||
184 134 11 DarkGoldenrod
|
||||
188 143 143 rosy brown
|
||||
188 143 143 RosyBrown
|
||||
205 92 92 indian red
|
||||
205 92 92 IndianRed
|
||||
139 69 19 saddle brown
|
||||
139 69 19 SaddleBrown
|
||||
160 82 45 sienna
|
||||
205 133 63 peru
|
||||
222 184 135 burlywood
|
||||
245 245 220 beige
|
||||
245 222 179 wheat
|
||||
244 164 96 sandy brown
|
||||
244 164 96 SandyBrown
|
||||
210 180 140 tan
|
||||
210 105 30 chocolate
|
||||
178 34 34 firebrick
|
||||
165 42 42 brown
|
||||
233 150 122 dark salmon
|
||||
233 150 122 DarkSalmon
|
||||
250 128 114 salmon
|
||||
255 160 122 light salmon
|
||||
255 160 122 LightSalmon
|
||||
255 165 0 orange
|
||||
255 140 0 dark orange
|
||||
255 140 0 DarkOrange
|
||||
255 127 80 coral
|
||||
240 128 128 light coral
|
||||
240 128 128 LightCoral
|
||||
255 99 71 tomato
|
||||
255 69 0 orange red
|
||||
255 69 0 OrangeRed
|
||||
255 0 0 red
|
||||
255 105 180 hot pink
|
||||
255 105 180 HotPink
|
||||
255 20 147 deep pink
|
||||
255 20 147 DeepPink
|
||||
255 192 203 pink
|
||||
255 182 193 light pink
|
||||
255 182 193 LightPink
|
||||
219 112 147 pale violet red
|
||||
219 112 147 PaleVioletRed
|
||||
176 48 96 maroon
|
||||
176 48 96 x11 maroon
|
||||
176 48 96 X11Maroon
|
||||
128 0 0 web maroon
|
||||
128 0 0 WebMaroon
|
||||
199 21 133 medium violet red
|
||||
199 21 133 MediumVioletRed
|
||||
208 32 144 violet red
|
||||
208 32 144 VioletRed
|
||||
255 0 255 magenta
|
||||
255 0 255 fuchsia
|
||||
238 130 238 violet
|
||||
221 160 221 plum
|
||||
218 112 214 orchid
|
||||
186 85 211 medium orchid
|
||||
186 85 211 MediumOrchid
|
||||
153 50 204 dark orchid
|
||||
153 50 204 DarkOrchid
|
||||
148 0 211 dark violet
|
||||
148 0 211 DarkViolet
|
||||
138 43 226 blue violet
|
||||
138 43 226 BlueViolet
|
||||
160 32 240 purple
|
||||
160 32 240 x11 purple
|
||||
160 32 240 X11Purple
|
||||
128 0 128 web purple
|
||||
128 0 128 WebPurple
|
||||
147 112 219 medium purple
|
||||
147 112 219 MediumPurple
|
||||
216 191 216 thistle
|
||||
255 250 250 snow1
|
||||
238 233 233 snow2
|
||||
205 201 201 snow3
|
||||
139 137 137 snow4
|
||||
255 245 238 seashell1
|
||||
238 229 222 seashell2
|
||||
205 197 191 seashell3
|
||||
139 134 130 seashell4
|
||||
255 239 219 AntiqueWhite1
|
||||
238 223 204 AntiqueWhite2
|
||||
205 192 176 AntiqueWhite3
|
||||
139 131 120 AntiqueWhite4
|
||||
255 228 196 bisque1
|
||||
238 213 183 bisque2
|
||||
205 183 158 bisque3
|
||||
139 125 107 bisque4
|
||||
255 218 185 PeachPuff1
|
||||
238 203 173 PeachPuff2
|
||||
205 175 149 PeachPuff3
|
||||
139 119 101 PeachPuff4
|
||||
255 222 173 NavajoWhite1
|
||||
238 207 161 NavajoWhite2
|
||||
205 179 139 NavajoWhite3
|
||||
139 121 94 NavajoWhite4
|
||||
255 250 205 LemonChiffon1
|
||||
238 233 191 LemonChiffon2
|
||||
205 201 165 LemonChiffon3
|
||||
139 137 112 LemonChiffon4
|
||||
255 248 220 cornsilk1
|
||||
238 232 205 cornsilk2
|
||||
205 200 177 cornsilk3
|
||||
139 136 120 cornsilk4
|
||||
255 255 240 ivory1
|
||||
238 238 224 ivory2
|
||||
205 205 193 ivory3
|
||||
139 139 131 ivory4
|
||||
240 255 240 honeydew1
|
||||
224 238 224 honeydew2
|
||||
193 205 193 honeydew3
|
||||
131 139 131 honeydew4
|
||||
255 240 245 LavenderBlush1
|
||||
238 224 229 LavenderBlush2
|
||||
205 193 197 LavenderBlush3
|
||||
139 131 134 LavenderBlush4
|
||||
255 228 225 MistyRose1
|
||||
238 213 210 MistyRose2
|
||||
205 183 181 MistyRose3
|
||||
139 125 123 MistyRose4
|
||||
240 255 255 azure1
|
||||
224 238 238 azure2
|
||||
193 205 205 azure3
|
||||
131 139 139 azure4
|
||||
131 111 255 SlateBlue1
|
||||
122 103 238 SlateBlue2
|
||||
105 89 205 SlateBlue3
|
||||
71 60 139 SlateBlue4
|
||||
72 118 255 RoyalBlue1
|
||||
67 110 238 RoyalBlue2
|
||||
58 95 205 RoyalBlue3
|
||||
39 64 139 RoyalBlue4
|
||||
0 0 255 blue1
|
||||
0 0 238 blue2
|
||||
0 0 205 blue3
|
||||
0 0 139 blue4
|
||||
30 144 255 DodgerBlue1
|
||||
28 134 238 DodgerBlue2
|
||||
24 116 205 DodgerBlue3
|
||||
16 78 139 DodgerBlue4
|
||||
99 184 255 SteelBlue1
|
||||
92 172 238 SteelBlue2
|
||||
79 148 205 SteelBlue3
|
||||
54 100 139 SteelBlue4
|
||||
0 191 255 DeepSkyBlue1
|
||||
0 178 238 DeepSkyBlue2
|
||||
0 154 205 DeepSkyBlue3
|
||||
0 104 139 DeepSkyBlue4
|
||||
135 206 255 SkyBlue1
|
||||
126 192 238 SkyBlue2
|
||||
108 166 205 SkyBlue3
|
||||
74 112 139 SkyBlue4
|
||||
176 226 255 LightSkyBlue1
|
||||
164 211 238 LightSkyBlue2
|
||||
141 182 205 LightSkyBlue3
|
||||
96 123 139 LightSkyBlue4
|
||||
198 226 255 SlateGray1
|
||||
185 211 238 SlateGray2
|
||||
159 182 205 SlateGray3
|
||||
108 123 139 SlateGray4
|
||||
202 225 255 LightSteelBlue1
|
||||
188 210 238 LightSteelBlue2
|
||||
162 181 205 LightSteelBlue3
|
||||
110 123 139 LightSteelBlue4
|
||||
191 239 255 LightBlue1
|
||||
178 223 238 LightBlue2
|
||||
154 192 205 LightBlue3
|
||||
104 131 139 LightBlue4
|
||||
224 255 255 LightCyan1
|
||||
209 238 238 LightCyan2
|
||||
180 205 205 LightCyan3
|
||||
122 139 139 LightCyan4
|
||||
187 255 255 PaleTurquoise1
|
||||
174 238 238 PaleTurquoise2
|
||||
150 205 205 PaleTurquoise3
|
||||
102 139 139 PaleTurquoise4
|
||||
152 245 255 CadetBlue1
|
||||
142 229 238 CadetBlue2
|
||||
122 197 205 CadetBlue3
|
||||
83 134 139 CadetBlue4
|
||||
0 245 255 turquoise1
|
||||
0 229 238 turquoise2
|
||||
0 197 205 turquoise3
|
||||
0 134 139 turquoise4
|
||||
0 255 255 cyan1
|
||||
0 238 238 cyan2
|
||||
0 205 205 cyan3
|
||||
0 139 139 cyan4
|
||||
151 255 255 DarkSlateGray1
|
||||
141 238 238 DarkSlateGray2
|
||||
121 205 205 DarkSlateGray3
|
||||
82 139 139 DarkSlateGray4
|
||||
127 255 212 aquamarine1
|
||||
118 238 198 aquamarine2
|
||||
102 205 170 aquamarine3
|
||||
69 139 116 aquamarine4
|
||||
193 255 193 DarkSeaGreen1
|
||||
180 238 180 DarkSeaGreen2
|
||||
155 205 155 DarkSeaGreen3
|
||||
105 139 105 DarkSeaGreen4
|
||||
84 255 159 SeaGreen1
|
||||
78 238 148 SeaGreen2
|
||||
67 205 128 SeaGreen3
|
||||
46 139 87 SeaGreen4
|
||||
154 255 154 PaleGreen1
|
||||
144 238 144 PaleGreen2
|
||||
124 205 124 PaleGreen3
|
||||
84 139 84 PaleGreen4
|
||||
0 255 127 SpringGreen1
|
||||
0 238 118 SpringGreen2
|
||||
0 205 102 SpringGreen3
|
||||
0 139 69 SpringGreen4
|
||||
0 255 0 green1
|
||||
0 238 0 green2
|
||||
0 205 0 green3
|
||||
0 139 0 green4
|
||||
127 255 0 chartreuse1
|
||||
118 238 0 chartreuse2
|
||||
102 205 0 chartreuse3
|
||||
69 139 0 chartreuse4
|
||||
192 255 62 OliveDrab1
|
||||
179 238 58 OliveDrab2
|
||||
154 205 50 OliveDrab3
|
||||
105 139 34 OliveDrab4
|
||||
202 255 112 DarkOliveGreen1
|
||||
188 238 104 DarkOliveGreen2
|
||||
162 205 90 DarkOliveGreen3
|
||||
110 139 61 DarkOliveGreen4
|
||||
255 246 143 khaki1
|
||||
238 230 133 khaki2
|
||||
205 198 115 khaki3
|
||||
139 134 78 khaki4
|
||||
255 236 139 LightGoldenrod1
|
||||
238 220 130 LightGoldenrod2
|
||||
205 190 112 LightGoldenrod3
|
||||
139 129 76 LightGoldenrod4
|
||||
255 255 224 LightYellow1
|
||||
238 238 209 LightYellow2
|
||||
205 205 180 LightYellow3
|
||||
139 139 122 LightYellow4
|
||||
255 255 0 yellow1
|
||||
238 238 0 yellow2
|
||||
205 205 0 yellow3
|
||||
139 139 0 yellow4
|
||||
255 215 0 gold1
|
||||
238 201 0 gold2
|
||||
205 173 0 gold3
|
||||
139 117 0 gold4
|
||||
255 193 37 goldenrod1
|
||||
238 180 34 goldenrod2
|
||||
205 155 29 goldenrod3
|
||||
139 105 20 goldenrod4
|
||||
255 185 15 DarkGoldenrod1
|
||||
238 173 14 DarkGoldenrod2
|
||||
205 149 12 DarkGoldenrod3
|
||||
139 101 8 DarkGoldenrod4
|
||||
255 193 193 RosyBrown1
|
||||
238 180 180 RosyBrown2
|
||||
205 155 155 RosyBrown3
|
||||
139 105 105 RosyBrown4
|
||||
255 106 106 IndianRed1
|
||||
238 99 99 IndianRed2
|
||||
205 85 85 IndianRed3
|
||||
139 58 58 IndianRed4
|
||||
255 130 71 sienna1
|
||||
238 121 66 sienna2
|
||||
205 104 57 sienna3
|
||||
139 71 38 sienna4
|
||||
255 211 155 burlywood1
|
||||
238 197 145 burlywood2
|
||||
205 170 125 burlywood3
|
||||
139 115 85 burlywood4
|
||||
255 231 186 wheat1
|
||||
238 216 174 wheat2
|
||||
205 186 150 wheat3
|
||||
139 126 102 wheat4
|
||||
255 165 79 tan1
|
||||
238 154 73 tan2
|
||||
205 133 63 tan3
|
||||
139 90 43 tan4
|
||||
255 127 36 chocolate1
|
||||
238 118 33 chocolate2
|
||||
205 102 29 chocolate3
|
||||
139 69 19 chocolate4
|
||||
255 48 48 firebrick1
|
||||
238 44 44 firebrick2
|
||||
205 38 38 firebrick3
|
||||
139 26 26 firebrick4
|
||||
255 64 64 brown1
|
||||
238 59 59 brown2
|
||||
205 51 51 brown3
|
||||
139 35 35 brown4
|
||||
255 140 105 salmon1
|
||||
238 130 98 salmon2
|
||||
205 112 84 salmon3
|
||||
139 76 57 salmon4
|
||||
255 160 122 LightSalmon1
|
||||
238 149 114 LightSalmon2
|
||||
205 129 98 LightSalmon3
|
||||
139 87 66 LightSalmon4
|
||||
255 165 0 orange1
|
||||
238 154 0 orange2
|
||||
205 133 0 orange3
|
||||
139 90 0 orange4
|
||||
255 127 0 DarkOrange1
|
||||
238 118 0 DarkOrange2
|
||||
205 102 0 DarkOrange3
|
||||
139 69 0 DarkOrange4
|
||||
255 114 86 coral1
|
||||
238 106 80 coral2
|
||||
205 91 69 coral3
|
||||
139 62 47 coral4
|
||||
255 99 71 tomato1
|
||||
238 92 66 tomato2
|
||||
205 79 57 tomato3
|
||||
139 54 38 tomato4
|
||||
255 69 0 OrangeRed1
|
||||
238 64 0 OrangeRed2
|
||||
205 55 0 OrangeRed3
|
||||
139 37 0 OrangeRed4
|
||||
255 0 0 red1
|
||||
238 0 0 red2
|
||||
205 0 0 red3
|
||||
139 0 0 red4
|
||||
255 20 147 DeepPink1
|
||||
238 18 137 DeepPink2
|
||||
205 16 118 DeepPink3
|
||||
139 10 80 DeepPink4
|
||||
255 110 180 HotPink1
|
||||
238 106 167 HotPink2
|
||||
205 96 144 HotPink3
|
||||
139 58 98 HotPink4
|
||||
255 181 197 pink1
|
||||
238 169 184 pink2
|
||||
205 145 158 pink3
|
||||
139 99 108 pink4
|
||||
255 174 185 LightPink1
|
||||
238 162 173 LightPink2
|
||||
205 140 149 LightPink3
|
||||
139 95 101 LightPink4
|
||||
255 130 171 PaleVioletRed1
|
||||
238 121 159 PaleVioletRed2
|
||||
205 104 137 PaleVioletRed3
|
||||
139 71 93 PaleVioletRed4
|
||||
255 52 179 maroon1
|
||||
238 48 167 maroon2
|
||||
205 41 144 maroon3
|
||||
139 28 98 maroon4
|
||||
255 62 150 VioletRed1
|
||||
238 58 140 VioletRed2
|
||||
205 50 120 VioletRed3
|
||||
139 34 82 VioletRed4
|
||||
255 0 255 magenta1
|
||||
238 0 238 magenta2
|
||||
205 0 205 magenta3
|
||||
139 0 139 magenta4
|
||||
255 131 250 orchid1
|
||||
238 122 233 orchid2
|
||||
205 105 201 orchid3
|
||||
139 71 137 orchid4
|
||||
255 187 255 plum1
|
||||
238 174 238 plum2
|
||||
205 150 205 plum3
|
||||
139 102 139 plum4
|
||||
224 102 255 MediumOrchid1
|
||||
209 95 238 MediumOrchid2
|
||||
180 82 205 MediumOrchid3
|
||||
122 55 139 MediumOrchid4
|
||||
191 62 255 DarkOrchid1
|
||||
178 58 238 DarkOrchid2
|
||||
154 50 205 DarkOrchid3
|
||||
104 34 139 DarkOrchid4
|
||||
155 48 255 purple1
|
||||
145 44 238 purple2
|
||||
125 38 205 purple3
|
||||
85 26 139 purple4
|
||||
171 130 255 MediumPurple1
|
||||
159 121 238 MediumPurple2
|
||||
137 104 205 MediumPurple3
|
||||
93 71 139 MediumPurple4
|
||||
255 225 255 thistle1
|
||||
238 210 238 thistle2
|
||||
205 181 205 thistle3
|
||||
139 123 139 thistle4
|
||||
0 0 0 gray0
|
||||
0 0 0 grey0
|
||||
3 3 3 gray1
|
||||
3 3 3 grey1
|
||||
5 5 5 gray2
|
||||
5 5 5 grey2
|
||||
8 8 8 gray3
|
||||
8 8 8 grey3
|
||||
10 10 10 gray4
|
||||
10 10 10 grey4
|
||||
13 13 13 gray5
|
||||
13 13 13 grey5
|
||||
15 15 15 gray6
|
||||
15 15 15 grey6
|
||||
18 18 18 gray7
|
||||
18 18 18 grey7
|
||||
20 20 20 gray8
|
||||
20 20 20 grey8
|
||||
23 23 23 gray9
|
||||
23 23 23 grey9
|
||||
26 26 26 gray10
|
||||
26 26 26 grey10
|
||||
28 28 28 gray11
|
||||
28 28 28 grey11
|
||||
31 31 31 gray12
|
||||
31 31 31 grey12
|
||||
33 33 33 gray13
|
||||
33 33 33 grey13
|
||||
36 36 36 gray14
|
||||
36 36 36 grey14
|
||||
38 38 38 gray15
|
||||
38 38 38 grey15
|
||||
41 41 41 gray16
|
||||
41 41 41 grey16
|
||||
43 43 43 gray17
|
||||
43 43 43 grey17
|
||||
46 46 46 gray18
|
||||
46 46 46 grey18
|
||||
48 48 48 gray19
|
||||
48 48 48 grey19
|
||||
51 51 51 gray20
|
||||
51 51 51 grey20
|
||||
54 54 54 gray21
|
||||
54 54 54 grey21
|
||||
56 56 56 gray22
|
||||
56 56 56 grey22
|
||||
59 59 59 gray23
|
||||
59 59 59 grey23
|
||||
61 61 61 gray24
|
||||
61 61 61 grey24
|
||||
64 64 64 gray25
|
||||
64 64 64 grey25
|
||||
66 66 66 gray26
|
||||
66 66 66 grey26
|
||||
69 69 69 gray27
|
||||
69 69 69 grey27
|
||||
71 71 71 gray28
|
||||
71 71 71 grey28
|
||||
74 74 74 gray29
|
||||
74 74 74 grey29
|
||||
77 77 77 gray30
|
||||
77 77 77 grey30
|
||||
79 79 79 gray31
|
||||
79 79 79 grey31
|
||||
82 82 82 gray32
|
||||
82 82 82 grey32
|
||||
84 84 84 gray33
|
||||
84 84 84 grey33
|
||||
87 87 87 gray34
|
||||
87 87 87 grey34
|
||||
89 89 89 gray35
|
||||
89 89 89 grey35
|
||||
92 92 92 gray36
|
||||
92 92 92 grey36
|
||||
94 94 94 gray37
|
||||
94 94 94 grey37
|
||||
97 97 97 gray38
|
||||
97 97 97 grey38
|
||||
99 99 99 gray39
|
||||
99 99 99 grey39
|
||||
102 102 102 gray40
|
||||
102 102 102 grey40
|
||||
105 105 105 gray41
|
||||
105 105 105 grey41
|
||||
107 107 107 gray42
|
||||
107 107 107 grey42
|
||||
110 110 110 gray43
|
||||
110 110 110 grey43
|
||||
112 112 112 gray44
|
||||
112 112 112 grey44
|
||||
115 115 115 gray45
|
||||
115 115 115 grey45
|
||||
117 117 117 gray46
|
||||
117 117 117 grey46
|
||||
120 120 120 gray47
|
||||
120 120 120 grey47
|
||||
122 122 122 gray48
|
||||
122 122 122 grey48
|
||||
125 125 125 gray49
|
||||
125 125 125 grey49
|
||||
127 127 127 gray50
|
||||
127 127 127 grey50
|
||||
130 130 130 gray51
|
||||
130 130 130 grey51
|
||||
133 133 133 gray52
|
||||
133 133 133 grey52
|
||||
135 135 135 gray53
|
||||
135 135 135 grey53
|
||||
138 138 138 gray54
|
||||
138 138 138 grey54
|
||||
140 140 140 gray55
|
||||
140 140 140 grey55
|
||||
143 143 143 gray56
|
||||
143 143 143 grey56
|
||||
145 145 145 gray57
|
||||
145 145 145 grey57
|
||||
148 148 148 gray58
|
||||
148 148 148 grey58
|
||||
150 150 150 gray59
|
||||
150 150 150 grey59
|
||||
153 153 153 gray60
|
||||
153 153 153 grey60
|
||||
156 156 156 gray61
|
||||
156 156 156 grey61
|
||||
158 158 158 gray62
|
||||
158 158 158 grey62
|
||||
161 161 161 gray63
|
||||
161 161 161 grey63
|
||||
163 163 163 gray64
|
||||
163 163 163 grey64
|
||||
166 166 166 gray65
|
||||
166 166 166 grey65
|
||||
168 168 168 gray66
|
||||
168 168 168 grey66
|
||||
171 171 171 gray67
|
||||
171 171 171 grey67
|
||||
173 173 173 gray68
|
||||
173 173 173 grey68
|
||||
176 176 176 gray69
|
||||
176 176 176 grey69
|
||||
179 179 179 gray70
|
||||
179 179 179 grey70
|
||||
181 181 181 gray71
|
||||
181 181 181 grey71
|
||||
184 184 184 gray72
|
||||
184 184 184 grey72
|
||||
186 186 186 gray73
|
||||
186 186 186 grey73
|
||||
189 189 189 gray74
|
||||
189 189 189 grey74
|
||||
191 191 191 gray75
|
||||
191 191 191 grey75
|
||||
194 194 194 gray76
|
||||
194 194 194 grey76
|
||||
196 196 196 gray77
|
||||
196 196 196 grey77
|
||||
199 199 199 gray78
|
||||
199 199 199 grey78
|
||||
201 201 201 gray79
|
||||
201 201 201 grey79
|
||||
204 204 204 gray80
|
||||
204 204 204 grey80
|
||||
207 207 207 gray81
|
||||
207 207 207 grey81
|
||||
209 209 209 gray82
|
||||
209 209 209 grey82
|
||||
212 212 212 gray83
|
||||
212 212 212 grey83
|
||||
214 214 214 gray84
|
||||
214 214 214 grey84
|
||||
217 217 217 gray85
|
||||
217 217 217 grey85
|
||||
219 219 219 gray86
|
||||
219 219 219 grey86
|
||||
222 222 222 gray87
|
||||
222 222 222 grey87
|
||||
224 224 224 gray88
|
||||
224 224 224 grey88
|
||||
227 227 227 gray89
|
||||
227 227 227 grey89
|
||||
229 229 229 gray90
|
||||
229 229 229 grey90
|
||||
232 232 232 gray91
|
||||
232 232 232 grey91
|
||||
235 235 235 gray92
|
||||
235 235 235 grey92
|
||||
237 237 237 gray93
|
||||
237 237 237 grey93
|
||||
240 240 240 gray94
|
||||
240 240 240 grey94
|
||||
242 242 242 gray95
|
||||
242 242 242 grey95
|
||||
245 245 245 gray96
|
||||
245 245 245 grey96
|
||||
247 247 247 gray97
|
||||
247 247 247 grey97
|
||||
250 250 250 gray98
|
||||
250 250 250 grey98
|
||||
252 252 252 gray99
|
||||
252 252 252 grey99
|
||||
255 255 255 gray100
|
||||
255 255 255 grey100
|
||||
169 169 169 dark grey
|
||||
169 169 169 DarkGrey
|
||||
169 169 169 dark gray
|
||||
169 169 169 DarkGray
|
||||
0 0 139 dark blue
|
||||
0 0 139 DarkBlue
|
||||
0 139 139 dark cyan
|
||||
0 139 139 DarkCyan
|
||||
139 0 139 dark magenta
|
||||
139 0 139 DarkMagenta
|
||||
139 0 0 dark red
|
||||
139 0 0 DarkRed
|
||||
144 238 144 light green
|
||||
144 238 144 LightGreen
|
||||
220 20 60 crimson
|
||||
75 0 130 indigo
|
||||
128 128 0 olive
|
||||
102 51 153 rebecca purple
|
||||
102 51 153 RebeccaPurple
|
||||
192 192 192 silver
|
||||
0 128 128 teal
|
||||
@@ -0,0 +1,740 @@
|
||||
//! SGR (Select Graphic Rendition) attribute parsing and types
|
||||
//! (ZIG/src/terminal/sgr.zig).
|
||||
//!
|
||||
//! C-ABI helpers of the Zig version (`Attribute.C`, `cval`, `lib.Enum`
|
||||
//! `Tag`) are intentionally not ported.
|
||||
|
||||
use crate::color::{Name, Rgb};
|
||||
use crate::parser::{MAX_PARAMS, SepList};
|
||||
|
||||
/// Attribute type for SGR
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Attribute<'a> {
|
||||
/// Unset all attributes
|
||||
Unset,
|
||||
|
||||
/// Unknown attribute, the raw CSI command parameters are here.
|
||||
Unknown(Unknown<'a>),
|
||||
|
||||
/// Bold the text.
|
||||
Bold,
|
||||
ResetBold,
|
||||
|
||||
/// Italic text.
|
||||
Italic,
|
||||
ResetItalic,
|
||||
|
||||
/// Faint/dim text.
|
||||
/// Note: reset faint is the same SGR code as reset bold
|
||||
Faint,
|
||||
|
||||
/// Underline the text
|
||||
Underline(Underline),
|
||||
UnderlineColor(Rgb),
|
||||
/// `256_underline_color`
|
||||
UnderlineColor256(u8),
|
||||
ResetUnderlineColor,
|
||||
|
||||
// Overline the text
|
||||
Overline,
|
||||
ResetOverline,
|
||||
|
||||
/// Blink the text
|
||||
Blink,
|
||||
ResetBlink,
|
||||
|
||||
/// Invert fg/bg colors.
|
||||
Inverse,
|
||||
ResetInverse,
|
||||
|
||||
/// Invisible
|
||||
Invisible,
|
||||
ResetInvisible,
|
||||
|
||||
/// Strikethrough the text.
|
||||
Strikethrough,
|
||||
ResetStrikethrough,
|
||||
|
||||
/// Set foreground color as RGB values.
|
||||
DirectColorFg(Rgb),
|
||||
|
||||
/// Set background color as RGB values.
|
||||
DirectColorBg(Rgb),
|
||||
|
||||
/// Set the background/foreground as a named color attribute (`8_bg`).
|
||||
Bg8(Name),
|
||||
/// `8_fg`
|
||||
Fg8(Name),
|
||||
|
||||
/// Reset the fg/bg to their default values.
|
||||
ResetFg,
|
||||
ResetBg,
|
||||
|
||||
/// Set the background/foreground as a named bright color attribute
|
||||
/// (`8_bright_bg`).
|
||||
Bg8Bright(Name),
|
||||
/// `8_bright_fg`
|
||||
Fg8Bright(Name),
|
||||
|
||||
/// Set background color as 256-color palette (`256_bg`).
|
||||
Bg256(u8),
|
||||
|
||||
/// Set foreground color as 256-color palette (`256_fg`).
|
||||
Fg256(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Unknown<'a> {
|
||||
/// Full is the full SGR input.
|
||||
pub full: &'a [u16],
|
||||
|
||||
/// Partial is the remaining, where we got hung up.
|
||||
pub partial: &'a [u16],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Underline {
|
||||
None = 0,
|
||||
Single = 1,
|
||||
Double = 2,
|
||||
Curly = 3,
|
||||
Dotted = 4,
|
||||
Dashed = 5,
|
||||
}
|
||||
|
||||
/// Parser parses the attributes from a list of SGR parameters. Iterating
|
||||
/// yields attributes until the parameters are exhausted.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Parser<'a> {
|
||||
pub params: &'a [u16],
|
||||
pub params_sep: SepList,
|
||||
pub idx: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
/// Empty state parser.
|
||||
pub const EMPTY: Parser<'static> = Parser {
|
||||
params: &[],
|
||||
params_sep: SepList::init_empty(),
|
||||
idx: 0,
|
||||
};
|
||||
|
||||
pub fn new(params: &'a [u16], params_sep: SepList) -> Self {
|
||||
Self {
|
||||
params,
|
||||
params_sep,
|
||||
idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `SepList::is_set` but tolerant of indices past the end.
|
||||
fn sep_is_set(&self, idx: usize) -> bool {
|
||||
idx < MAX_PARAMS && self.params_sep.is_set(idx)
|
||||
}
|
||||
|
||||
/// Next returns the next attribute or None if there are no more attributes.
|
||||
fn next_attr(&mut self) -> Option<Attribute<'a>> {
|
||||
if self.idx >= self.params.len() {
|
||||
// Add one to ensure we don't loop on unset
|
||||
let was_zero = self.idx == 0;
|
||||
self.idx += 1;
|
||||
|
||||
// If we're at index zero it means we must have an empty list
|
||||
// and an empty list implicitly means unset, otherwise we're
|
||||
// done and return None.
|
||||
return if was_zero {
|
||||
Some(Attribute::Unset)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
let params = self.params;
|
||||
let slice = ¶ms[self.idx..];
|
||||
let colon = self.sep_is_set(self.idx);
|
||||
self.idx += 1;
|
||||
|
||||
// Our last one will have an idx be the last value.
|
||||
if slice.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// If we have a colon separator then we need to ensure we're
|
||||
// parsing a value that allows it.
|
||||
if colon {
|
||||
match slice[0] {
|
||||
4 | 38 | 48 | 58 => {}
|
||||
|
||||
_ => {
|
||||
// Consume all the colon separated
|
||||
// values and return them as unknown.
|
||||
let start = self.idx;
|
||||
while self.sep_is_set(self.idx) {
|
||||
self.idx += 1;
|
||||
}
|
||||
self.idx += 1;
|
||||
return Some(Attribute::Unknown(Unknown {
|
||||
full: params,
|
||||
partial: &slice[..(self.idx - start + 1).min(slice.len())],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match slice[0] {
|
||||
0 => return Some(Attribute::Unset),
|
||||
1 => return Some(Attribute::Bold),
|
||||
2 => return Some(Attribute::Faint),
|
||||
3 => return Some(Attribute::Italic),
|
||||
|
||||
4 => {
|
||||
if colon {
|
||||
// A trailing colon with no following sub-param
|
||||
// (e.g. "ESC[58:4:m") leaves the colon separator
|
||||
// bit set on the last param without adding another
|
||||
// entry, so we can see param 4 with a colon but
|
||||
// nothing after it.
|
||||
if slice.len() < 2 {
|
||||
// fall through to unknown
|
||||
} else if self.is_colon() {
|
||||
self.consume_unknown_colon();
|
||||
} else {
|
||||
self.idx += 1;
|
||||
return Some(Attribute::Underline(match slice[1] {
|
||||
0 => Underline::None,
|
||||
1 => Underline::Single,
|
||||
2 => Underline::Double,
|
||||
3 => Underline::Curly,
|
||||
4 => Underline::Dotted,
|
||||
5 => Underline::Dashed,
|
||||
// For unknown underline styles,
|
||||
// just render a single underline.
|
||||
_ => Underline::Single,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
return Some(Attribute::Underline(Underline::Single));
|
||||
}
|
||||
}
|
||||
|
||||
5 | 6 => return Some(Attribute::Blink),
|
||||
7 => return Some(Attribute::Inverse),
|
||||
8 => return Some(Attribute::Invisible),
|
||||
9 => return Some(Attribute::Strikethrough),
|
||||
21 => return Some(Attribute::Underline(Underline::Double)),
|
||||
22 => return Some(Attribute::ResetBold),
|
||||
23 => return Some(Attribute::ResetItalic),
|
||||
24 => return Some(Attribute::Underline(Underline::None)),
|
||||
25 => return Some(Attribute::ResetBlink),
|
||||
27 => return Some(Attribute::ResetInverse),
|
||||
28 => return Some(Attribute::ResetInvisible),
|
||||
29 => return Some(Attribute::ResetStrikethrough),
|
||||
|
||||
30..=37 => return Some(Attribute::Fg8(Name((slice[0] - 30) as u8))),
|
||||
|
||||
38 => {
|
||||
if slice.len() >= 2 {
|
||||
match slice[1] {
|
||||
// `2` indicates direct-color (r, g, b).
|
||||
// We need at least 3 more params for this to make sense.
|
||||
2 => {
|
||||
if let Some(v) =
|
||||
self.parse_direct_color(Attribute::DirectColorFg, slice, colon)
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
// `5` indicates indexed color.
|
||||
5 if slice.len() >= 3 => {
|
||||
self.idx += 2;
|
||||
return Some(Attribute::Fg256(slice[2] as u8));
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39 => return Some(Attribute::ResetFg),
|
||||
|
||||
40..=47 => return Some(Attribute::Bg8(Name((slice[0] - 40) as u8))),
|
||||
|
||||
48 => {
|
||||
if slice.len() >= 2 {
|
||||
match slice[1] {
|
||||
2 => {
|
||||
if let Some(v) =
|
||||
self.parse_direct_color(Attribute::DirectColorBg, slice, colon)
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
5 if slice.len() >= 3 => {
|
||||
self.idx += 2;
|
||||
return Some(Attribute::Bg256(slice[2] as u8));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49 => return Some(Attribute::ResetBg),
|
||||
|
||||
53 => return Some(Attribute::Overline),
|
||||
55 => return Some(Attribute::ResetOverline),
|
||||
|
||||
58 => {
|
||||
if slice.len() >= 2 {
|
||||
match slice[1] {
|
||||
2 => {
|
||||
if let Some(v) =
|
||||
self.parse_direct_color(Attribute::UnderlineColor, slice, colon)
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
5 if slice.len() >= 3 => {
|
||||
self.idx += 2;
|
||||
return Some(Attribute::UnderlineColor256(slice[2] as u8));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59 => return Some(Attribute::ResetUnderlineColor),
|
||||
|
||||
// 82 instead of 90 to offset to "bright" colors
|
||||
90..=97 => return Some(Attribute::Fg8Bright(Name((slice[0] - 82) as u8))),
|
||||
|
||||
100..=107 => return Some(Attribute::Bg8Bright(Name((slice[0] - 92) as u8))),
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(Attribute::Unknown(Unknown {
|
||||
full: params,
|
||||
partial: slice,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_direct_color(
|
||||
&mut self,
|
||||
make: fn(Rgb) -> Attribute<'a>,
|
||||
slice: &[u16],
|
||||
colon: bool,
|
||||
) -> Option<Attribute<'a>> {
|
||||
// Any direct color style must have at least 5 values.
|
||||
if slice.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Only used for direct color sets (38, 48, 58) and subparam 2.
|
||||
debug_assert_eq!(slice[1], 2);
|
||||
|
||||
// Note: We truncate because the value should be 0 to 255. If
|
||||
// it isn't, the behavior is undefined so we just... truncate it.
|
||||
|
||||
// If we don't have a colon, then we expect exactly 3 semicolon
|
||||
// separated values.
|
||||
if !colon {
|
||||
self.idx += 4;
|
||||
return Some(make(Rgb {
|
||||
r: slice[2] as u8,
|
||||
g: slice[3] as u8,
|
||||
b: slice[4] as u8,
|
||||
}));
|
||||
}
|
||||
|
||||
// We have a colon, we might have either 5 or 6 values depending
|
||||
// on if the colorspace is present.
|
||||
match self.count_colon() {
|
||||
3 => {
|
||||
self.idx += 4;
|
||||
Some(make(Rgb {
|
||||
r: slice[2] as u8,
|
||||
g: slice[3] as u8,
|
||||
b: slice[4] as u8,
|
||||
}))
|
||||
}
|
||||
|
||||
4 => {
|
||||
self.idx += 5;
|
||||
Some(make(Rgb {
|
||||
r: slice[3] as u8,
|
||||
g: slice[4] as u8,
|
||||
b: slice[5] as u8,
|
||||
}))
|
||||
}
|
||||
|
||||
_ => {
|
||||
self.consume_unknown_colon();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the present position has a colon separator.
|
||||
/// This always returns false for the last value since it has no
|
||||
/// separator.
|
||||
fn is_colon(&self) -> bool {
|
||||
self.sep_is_set(self.idx)
|
||||
}
|
||||
|
||||
fn count_colon(&self) -> usize {
|
||||
let mut count = 0;
|
||||
let mut idx = self.idx;
|
||||
while idx < self.params.len().saturating_sub(1) && self.sep_is_set(idx) {
|
||||
count += 1;
|
||||
idx += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Consumes all the remaining parameters separated by a colon and
|
||||
/// returns an unknown attribute.
|
||||
fn consume_unknown_colon(&mut self) {
|
||||
let count = self.count_colon();
|
||||
self.idx += count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Parser<'a> {
|
||||
type Item = Attribute<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Attribute<'a>> {
|
||||
self.next_attr()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
use Attribute as A;
|
||||
|
||||
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
|
||||
Rgb { r, g, b }
|
||||
}
|
||||
|
||||
fn test_parse(params: &[u16]) -> Attribute<'_> {
|
||||
let mut p = Parser::new(params, SepList::init_empty());
|
||||
p.next().unwrap()
|
||||
}
|
||||
|
||||
fn test_parse_colon(params: &[u16]) -> Attribute<'_> {
|
||||
let mut p = Parser::new(params, SepList::init_empty());
|
||||
// Mark all parameters except the last as having a colon after.
|
||||
for i in 0..params.len() - 1 {
|
||||
p.params_sep.set(i);
|
||||
}
|
||||
p.next().unwrap()
|
||||
}
|
||||
|
||||
fn sep(idxs: &[usize]) -> SepList {
|
||||
let mut l = SepList::init_empty();
|
||||
for &i in idxs {
|
||||
l.set(i);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn is_unknown(a: Option<Attribute<'_>>) -> bool {
|
||||
matches!(a, Some(A::Unknown(_)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_parser() {
|
||||
assert_eq!(test_parse(&[]), A::Unset);
|
||||
assert_eq!(test_parse(&[0]), A::Unset);
|
||||
|
||||
assert_eq!(
|
||||
test_parse(&[38, 2, 40, 44, 52]),
|
||||
A::DirectColorFg(rgb(40, 44, 52))
|
||||
);
|
||||
assert!(is_unknown(Some(test_parse(&[38, 2, 44, 52]))));
|
||||
|
||||
assert_eq!(
|
||||
test_parse(&[48, 2, 40, 44, 52]),
|
||||
A::DirectColorBg(rgb(40, 44, 52))
|
||||
);
|
||||
assert!(is_unknown(Some(test_parse(&[48, 2, 44, 52]))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_parser_multiple() {
|
||||
let mut p = Parser::new(&[0, 38, 2, 40, 44, 52], SepList::init_empty());
|
||||
assert_eq!(p.next(), Some(A::Unset));
|
||||
assert!(matches!(p.next(), Some(A::DirectColorFg(_))));
|
||||
assert_eq!(p.next(), None);
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_unsupported_with_colon() {
|
||||
let mut p = Parser::new(&[0, 4, 1], sep(&[0]));
|
||||
assert!(is_unknown(p.next()));
|
||||
assert_eq!(p.next(), Some(A::Bold));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_unsupported_with_multiple_colon() {
|
||||
let mut p = Parser::new(&[0, 4, 2, 1], sep(&[0, 1]));
|
||||
assert!(is_unknown(p.next()));
|
||||
assert_eq!(p.next(), Some(A::Bold));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_bold() {
|
||||
assert_eq!(test_parse(&[1]), A::Bold);
|
||||
assert_eq!(test_parse(&[22]), A::ResetBold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_italic() {
|
||||
assert_eq!(test_parse(&[3]), A::Italic);
|
||||
assert_eq!(test_parse(&[23]), A::ResetItalic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_underline() {
|
||||
assert_eq!(test_parse(&[4]), A::Underline(Underline::Single));
|
||||
assert_eq!(test_parse(&[24]), A::Underline(Underline::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_underline_styles() {
|
||||
assert_eq!(test_parse_colon(&[4, 2]), A::Underline(Underline::Double));
|
||||
assert_eq!(test_parse_colon(&[4, 0]), A::Underline(Underline::None));
|
||||
assert_eq!(test_parse_colon(&[4, 1]), A::Underline(Underline::Single));
|
||||
assert_eq!(test_parse_colon(&[4, 3]), A::Underline(Underline::Curly));
|
||||
assert_eq!(test_parse_colon(&[4, 4]), A::Underline(Underline::Dotted));
|
||||
assert_eq!(test_parse_colon(&[4, 5]), A::Underline(Underline::Dashed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_underline_style_with_more() {
|
||||
let mut p = Parser::new(&[4, 2, 1], sep(&[0]));
|
||||
assert!(matches!(p.next(), Some(A::Underline(_))));
|
||||
assert_eq!(p.next(), Some(A::Bold));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_underline_style_with_too_many_colons() {
|
||||
let mut p = Parser::new(&[4, 2, 3, 1], sep(&[0, 1]));
|
||||
assert!(is_unknown(p.next()));
|
||||
assert_eq!(p.next(), Some(A::Bold));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_blink() {
|
||||
assert_eq!(test_parse(&[5]), A::Blink);
|
||||
assert_eq!(test_parse(&[6]), A::Blink);
|
||||
assert_eq!(test_parse(&[25]), A::ResetBlink);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_inverse() {
|
||||
assert_eq!(test_parse(&[7]), A::Inverse);
|
||||
assert_eq!(test_parse(&[27]), A::ResetInverse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_strikethrough() {
|
||||
assert_eq!(test_parse(&[9]), A::Strikethrough);
|
||||
assert_eq!(test_parse(&[29]), A::ResetStrikethrough);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_8_color() {
|
||||
let mut p = Parser::new(&[31, 43, 90, 103], SepList::init_empty());
|
||||
assert_eq!(p.next(), Some(A::Fg8(Name::RED)));
|
||||
assert_eq!(p.next(), Some(A::Bg8(Name::YELLOW)));
|
||||
assert_eq!(p.next(), Some(A::Fg8Bright(Name::BRIGHT_BLACK)));
|
||||
assert_eq!(p.next(), Some(A::Bg8Bright(Name::BRIGHT_YELLOW)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_256_color() {
|
||||
let mut p = Parser::new(&[38, 5, 161, 48, 5, 236], SepList::init_empty());
|
||||
assert_eq!(p.next(), Some(A::Fg256(161)));
|
||||
assert_eq!(p.next(), Some(A::Bg256(236)));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_256_color_underline() {
|
||||
let mut p = Parser::new(&[58, 5, 9], SepList::init_empty());
|
||||
assert_eq!(p.next(), Some(A::UnderlineColor256(9)));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_24_bit_bg_color() {
|
||||
assert_eq!(
|
||||
test_parse_colon(&[48, 2, 1, 2, 3]),
|
||||
A::DirectColorBg(rgb(1, 2, 3))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_underline_color() {
|
||||
assert_eq!(
|
||||
test_parse_colon(&[58, 2, 1, 2, 3]),
|
||||
A::UnderlineColor(rgb(1, 2, 3))
|
||||
);
|
||||
assert_eq!(
|
||||
test_parse_colon(&[58, 2, 0, 1, 2, 3]),
|
||||
A::UnderlineColor(rgb(1, 2, 3))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_reset_underline_color() {
|
||||
let mut p = Parser::new(&[59], SepList::init_empty());
|
||||
assert_eq!(p.next(), Some(A::ResetUnderlineColor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_invisible() {
|
||||
let mut p = Parser::new(&[8, 28], SepList::init_empty());
|
||||
assert_eq!(p.next(), Some(A::Invisible));
|
||||
assert_eq!(p.next(), Some(A::ResetInvisible));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_underline_bg_and_fg() {
|
||||
let mut p = Parser::new(
|
||||
&[4, 38, 2, 255, 247, 219, 48, 2, 242, 93, 147, 4],
|
||||
SepList::init_empty(),
|
||||
);
|
||||
assert_eq!(p.next(), Some(A::Underline(Underline::Single)));
|
||||
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(255, 247, 219))));
|
||||
assert_eq!(p.next(), Some(A::DirectColorBg(rgb(242, 93, 147))));
|
||||
assert_eq!(p.next(), Some(A::Underline(Underline::Single)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_direct_color_fg_missing_color() {
|
||||
// This used to crash
|
||||
let p = Parser::new(&[38, 5], SepList::init_empty());
|
||||
for _ in p {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_direct_color_bg_missing_color() {
|
||||
// This used to crash
|
||||
let p = Parser::new(&[48, 5], SepList::init_empty());
|
||||
for _ in p {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_direct_fg_bg_underline_ignore_optional_color_space() {
|
||||
// These behaviors have been verified against xterm.
|
||||
|
||||
// Colon version should skip the optional color space identifier
|
||||
assert_eq!(
|
||||
test_parse_colon(&[38, 2, 0, 1, 2, 3]),
|
||||
A::DirectColorFg(rgb(1, 2, 3))
|
||||
);
|
||||
assert_eq!(
|
||||
test_parse_colon(&[48, 2, 0, 1, 2, 3]),
|
||||
A::DirectColorBg(rgb(1, 2, 3))
|
||||
);
|
||||
assert_eq!(
|
||||
test_parse_colon(&[58, 2, 0, 1, 2, 3]),
|
||||
A::UnderlineColor(rgb(1, 2, 3))
|
||||
);
|
||||
|
||||
// Semicolon version should not parse optional color space identifier
|
||||
assert_eq!(
|
||||
test_parse(&[38, 2, 0, 1, 2, 3]),
|
||||
A::DirectColorFg(rgb(0, 1, 2))
|
||||
);
|
||||
assert_eq!(
|
||||
test_parse(&[48, 2, 0, 1, 2, 3]),
|
||||
A::DirectColorBg(rgb(0, 1, 2))
|
||||
);
|
||||
assert_eq!(
|
||||
test_parse(&[58, 2, 0, 1, 2, 3]),
|
||||
A::UnderlineColor(rgb(0, 1, 2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_direct_fg_colon_with_too_many_colons() {
|
||||
let mut p = Parser::new(&[38, 2, 0, 1, 2, 3, 4, 1], sep(&[0, 1, 2, 3, 4, 5]));
|
||||
assert!(is_unknown(p.next()));
|
||||
assert_eq!(p.next(), Some(A::Bold));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_direct_fg_colon_with_colorspace_and_extra_param() {
|
||||
let mut p = Parser::new(&[38, 2, 0, 1, 2, 3, 1], sep(&[0, 1, 2, 3, 4]));
|
||||
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(1, 2, 3))));
|
||||
assert_eq!(p.next(), Some(A::Bold));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_direct_fg_colon_no_colorspace_and_extra_param() {
|
||||
let mut p = Parser::new(&[38, 2, 1, 2, 3, 1], sep(&[0, 1, 2, 3]));
|
||||
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(1, 2, 3))));
|
||||
assert_eq!(p.next(), Some(A::Bold));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
// Kakoune sent this complex SGR sequence that caused invalid behavior.
|
||||
#[test]
|
||||
fn sgr_kakoune_input() {
|
||||
// This used to crash
|
||||
let mut p = Parser::new(
|
||||
&[0, 4, 3, 38, 2, 175, 175, 215, 58, 2, 0, 190, 80, 70],
|
||||
sep(&[1, 8, 9, 10, 11, 12]),
|
||||
);
|
||||
assert_eq!(p.next(), Some(A::Unset));
|
||||
assert_eq!(p.next(), Some(A::Underline(Underline::Curly)));
|
||||
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(175, 175, 215))));
|
||||
assert_eq!(p.next(), Some(A::UnderlineColor(rgb(190, 80, 70))));
|
||||
}
|
||||
|
||||
// Discussion #5930, another input sent by kakoune
|
||||
#[test]
|
||||
fn sgr_kakoune_input_issue_underline_fg_and_bg() {
|
||||
// echo -e "\033[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136mset everything in one sequence, broken\033[m"
|
||||
let mut p = Parser::new(
|
||||
&[
|
||||
4, 3, 38, 2, 51, 51, 51, 48, 2, 170, 170, 170, 58, 2, 255, 97, 136,
|
||||
],
|
||||
sep(&[0]),
|
||||
);
|
||||
assert_eq!(p.next(), Some(A::Underline(Underline::Curly)));
|
||||
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(51, 51, 51))));
|
||||
assert_eq!(p.next(), Some(A::DirectColorBg(rgb(170, 170, 170))));
|
||||
assert_eq!(p.next(), Some(A::UnderlineColor(rgb(255, 97, 136))));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
|
||||
// Fuzz crash: afl-out/stream/default/crashes/id:000021
|
||||
// Input "ESC [ 5 8 : 4 : m" produces params [58, 4] with colon
|
||||
// separator bits set at indices 0 and 1.
|
||||
#[test]
|
||||
fn sgr_underline_colon_with_trailing_separator_and_short_slice() {
|
||||
let mut p = Parser::new(&[58, 4], sep(&[0, 1]));
|
||||
// 58:4 is not a valid underline color (sub-param 4 is not 2 or 5),
|
||||
// so it falls through as unknown.
|
||||
assert!(is_unknown(p.next()));
|
||||
// Param 4 with a trailing colon but no sub-param is malformed,
|
||||
// so it also falls through as unknown rather than panicking.
|
||||
assert!(is_unknown(p.next()));
|
||||
assert_eq!(p.next(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
//! This file contains the implementation for tmux control mode
|
||||
//! (ZIG/src/terminal/tmux/control.zig). See tmux(1) for more information on
|
||||
//! control mode. Some basics are documented here but this is not meant to be
|
||||
//! a comprehensive source of protocol documentation.
|
||||
//!
|
||||
//! Deviations from the Zig version:
|
||||
//! - Notifications own their data (`Vec<u8>`) instead of borrowing from the
|
||||
//! parser's buffer, which avoids self-referential lifetimes.
|
||||
//! - Oniguruma regexes are replaced by hand-written matchers that implement
|
||||
//! the same (greedy, backtracking) semantics for the specific patterns.
|
||||
//! - A numeric ID that overflows `usize` is treated as a failed match (Zig
|
||||
//! would hit `unreachable`).
|
||||
|
||||
/// Errors returned by [`Parser::put`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("out of memory")]
|
||||
OutOfMemory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum State {
|
||||
/// Outside of any active notifications. This should drop any output
|
||||
/// unless it is '%' on the first byte of a line. The buffer will be
|
||||
/// cleared when it sees '%', this is so that the previous notification
|
||||
/// data is valid until we receive/process new data.
|
||||
Idle,
|
||||
|
||||
/// We experienced unexpected input and are in a broken state
|
||||
/// so we cannot continue processing. When this state is set,
|
||||
/// the buffer has been freed and must not be accessed.
|
||||
Broken,
|
||||
|
||||
/// Inside an active notification (started with '%').
|
||||
Notification,
|
||||
|
||||
/// Inside a begin/end block.
|
||||
Block,
|
||||
}
|
||||
|
||||
/// A tmux control mode parser. This takes in output from tmux control
|
||||
/// mode and parses it into a structured notifications.
|
||||
///
|
||||
/// It is up to the caller to establish the connection to the tmux
|
||||
/// control mode session in some way (e.g. via exec, a network socket,
|
||||
/// whatever). This is fully agnostic to how the data is received and sent.
|
||||
#[derive(Debug)]
|
||||
pub struct Parser {
|
||||
/// Current state of the client.
|
||||
state: State,
|
||||
|
||||
/// The buffer used to store in-progress notifications, output, etc.
|
||||
buffer: Vec<u8>,
|
||||
|
||||
/// The maximum size in bytes of the buffer. This is used to limit
|
||||
/// memory usage. If the buffer exceeds this size, the client will
|
||||
/// enter a broken state (the control mode session will be forcibly
|
||||
/// exited and future data dropped).
|
||||
pub max_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for Parser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: State::Idle,
|
||||
buffer: Vec::new(),
|
||||
max_bytes: 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a byte of input.
|
||||
///
|
||||
/// If we reach our byte limit this will return `OutOfMemory`. It only
|
||||
/// does this on the first time we exceed the limit; subsequent calls
|
||||
/// will return None as we drop all input in a broken state.
|
||||
pub fn put(&mut self, byte: u8) -> Result<Option<Notification>, Error> {
|
||||
// If we're in a broken state, just do nothing.
|
||||
if self.state == State::Broken {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if self.buffer.len() >= self.max_bytes {
|
||||
self.broken();
|
||||
return Err(Error::OutOfMemory);
|
||||
}
|
||||
|
||||
match self.state {
|
||||
State::Broken => return Ok(None),
|
||||
|
||||
// Waiting for a notification so if the byte is not '%' then
|
||||
// we're in a broken state. Control mode output should always
|
||||
// be wrapped in '%begin/%end' orelse we expect a notification.
|
||||
// Return an exit notification.
|
||||
State::Idle => {
|
||||
if byte == b'%' {
|
||||
self.buffer.clear();
|
||||
self.state = State::Notification;
|
||||
} else {
|
||||
self.broken();
|
||||
return Ok(Some(Notification::Exit));
|
||||
}
|
||||
}
|
||||
|
||||
// If we're in a notification and its not a newline then
|
||||
// we accumulate. If it is a newline then we have a
|
||||
// complete notification we need to parse.
|
||||
State::Notification => {
|
||||
if byte == b'\n' {
|
||||
// If parsing failed we do not mark the state as broken
|
||||
// because we may be able to continue parsing other types
|
||||
// of notifications.
|
||||
return Ok(self.parse_notification());
|
||||
}
|
||||
}
|
||||
|
||||
// If we're in a block then we accumulate until we see a newline
|
||||
// and then we check to see if that line ended the block.
|
||||
State::Block => {
|
||||
if byte == b'\n' {
|
||||
let idx = self
|
||||
.buffer
|
||||
.iter()
|
||||
.rposition(|&b| b == b'\n')
|
||||
.map_or(0, |v| v + 1);
|
||||
let line = &self.buffer[idx..];
|
||||
|
||||
if line.starts_with(b"%end") || line.starts_with(b"%error") {
|
||||
let err = line.starts_with(b"%error");
|
||||
let output = trim_right_crlf(&self.buffer[..idx]).to_vec();
|
||||
|
||||
// If it is an error then log it.
|
||||
if err {
|
||||
log::warn!(
|
||||
"tmux control mode error={}",
|
||||
String::from_utf8_lossy(&output)
|
||||
);
|
||||
}
|
||||
|
||||
self.state = State::Idle;
|
||||
return Ok(Some(if err {
|
||||
Notification::BlockErr(output)
|
||||
} else {
|
||||
Notification::BlockEnd(output)
|
||||
}));
|
||||
}
|
||||
|
||||
// Didn't end the block, continue accumulating.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.buffer.push(byte);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_notification(&mut self) -> Option<Notification> {
|
||||
debug_assert_eq!(self.state, State::Notification);
|
||||
|
||||
let mut line: &[u8] = &self.buffer;
|
||||
if line.last() == Some(&b'\r') {
|
||||
line = &line[..line.len() - 1];
|
||||
}
|
||||
let cmd_end = line.iter().position(|&b| b == b' ').unwrap_or(line.len());
|
||||
let cmd = &line[..cmd_end];
|
||||
|
||||
// The notification MUST exist because we guard entering the notification
|
||||
// state on seeing at least a '%'.
|
||||
let result: Option<Notification> = match cmd {
|
||||
b"%begin" => {
|
||||
// We don't use the rest of the tokens for now because tmux
|
||||
// claims to guarantee that begin/end are always in order and
|
||||
// never intermixed.
|
||||
|
||||
// Move to block state because we expect a corresponding end/error
|
||||
// and want to accumulate the data.
|
||||
self.state = State::Block;
|
||||
self.buffer.clear();
|
||||
return None;
|
||||
}
|
||||
b"%output" => match_output(line),
|
||||
b"%session-changed" => match_session_changed(line),
|
||||
b"%sessions-changed" => {
|
||||
if line == b"%sessions-changed" {
|
||||
Some(Notification::SessionsChanged)
|
||||
} else {
|
||||
log::warn!(
|
||||
"failed to match notification cmd=%sessions-changed line=\"{}\"",
|
||||
String::from_utf8_lossy(line)
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
b"%layout-change" => match_layout_change(line),
|
||||
b"%window-add" => match_window_add(line),
|
||||
b"%window-renamed" => match_window_renamed(line),
|
||||
b"%window-pane-changed" => match_window_pane_changed(line),
|
||||
b"%client-detached" => match_client_detached(line),
|
||||
b"%client-session-changed" => match_client_session_changed(line),
|
||||
_ => {
|
||||
// Unknown notification, log it and return to idle state.
|
||||
log::warn!(
|
||||
"unknown tmux control mode notification={}",
|
||||
String::from_utf8_lossy(cmd)
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if result.is_none() && cmd_is_known(cmd) && cmd != b"%sessions-changed" {
|
||||
log::warn!(
|
||||
"failed to match notification cmd={} line=\"{}\"",
|
||||
String::from_utf8_lossy(cmd),
|
||||
String::from_utf8_lossy(line)
|
||||
);
|
||||
}
|
||||
|
||||
// Whether matched or not, clear the buffer and return to idle. (The
|
||||
// Zig code avoids clearing when slices point into the buffer, but we
|
||||
// own our data so clearing is always safe.)
|
||||
self.buffer.clear();
|
||||
self.state = State::Idle;
|
||||
result
|
||||
}
|
||||
|
||||
/// Mark the tmux state as broken.
|
||||
fn broken(&mut self) {
|
||||
self.state = State::Broken;
|
||||
self.buffer = Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_is_known(cmd: &[u8]) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
b"%output"
|
||||
| b"%session-changed"
|
||||
| b"%sessions-changed"
|
||||
| b"%layout-change"
|
||||
| b"%window-add"
|
||||
| b"%window-renamed"
|
||||
| b"%window-pane-changed"
|
||||
| b"%client-detached"
|
||||
| b"%client-session-changed"
|
||||
)
|
||||
}
|
||||
|
||||
fn trim_right_crlf(mut s: &[u8]) -> &[u8] {
|
||||
while let Some((&last, rest)) = s.split_last() {
|
||||
if last == b'\r' || last == b'\n' {
|
||||
s = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Hand-written matchers equivalent to the Zig regexes. `.` does not match
|
||||
// '\n' in Oniguruma, but lines never contain '\n' here.
|
||||
|
||||
/// Parse `[0-9]+` at the start of `s`; returns (value, rest).
|
||||
fn digits(s: &[u8]) -> Option<(usize, &[u8])> {
|
||||
let n = s.iter().take_while(|b| b.is_ascii_digit()).count();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let v = std::str::from_utf8(&s[..n]).ok()?.parse::<usize>().ok()?;
|
||||
Some((v, &s[n..]))
|
||||
}
|
||||
|
||||
/// Strip an exact literal prefix.
|
||||
fn lit<'a>(s: &'a [u8], p: &[u8]) -> Option<&'a [u8]> {
|
||||
s.strip_prefix(p)
|
||||
}
|
||||
|
||||
/// `(.+)` followed by end: non-empty remainder.
|
||||
fn nonempty(s: &[u8]) -> Option<&[u8]> {
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
}
|
||||
|
||||
fn match_output(line: &[u8]) -> Option<Notification> {
|
||||
// ^%output %([0-9]+) (.+)$
|
||||
let s = lit(line, b"%output %")?;
|
||||
let (id, s) = digits(s)?;
|
||||
let s = lit(s, b" ")?;
|
||||
let data = nonempty(s)?;
|
||||
Some(Notification::Output {
|
||||
pane_id: id,
|
||||
data: data.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match_session_changed(line: &[u8]) -> Option<Notification> {
|
||||
// ^%session-changed \$([0-9]+) (.+)$
|
||||
let s = lit(line, b"%session-changed $")?;
|
||||
let (id, s) = digits(s)?;
|
||||
let s = lit(s, b" ")?;
|
||||
let name = nonempty(s)?;
|
||||
Some(Notification::SessionChanged {
|
||||
id,
|
||||
name: name.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match_layout_change(line: &[u8]) -> Option<Notification> {
|
||||
// ^%layout-change @([0-9]+) (.+) (.+) (.*)$
|
||||
let s = lit(line, b"%layout-change @")?;
|
||||
let (id, s) = digits(s)?;
|
||||
let rest = lit(s, b" ")?;
|
||||
// Greedy group 2, then group 3 (non-empty), then group 4 (any).
|
||||
// The last space starts group 4; group 2 ends at the last space at or
|
||||
// before `last - 2` (so that group 3 is non-empty) and at index >= 1.
|
||||
let last = rest.iter().rposition(|&b| b == b' ')?;
|
||||
if last < 3 {
|
||||
return None;
|
||||
}
|
||||
let p = rest[1..=last - 2].iter().rposition(|&b| b == b' ')? + 1;
|
||||
let layout = &rest[..p];
|
||||
let visible = &rest[p + 1..last];
|
||||
let flags = &rest[last + 1..];
|
||||
Some(Notification::LayoutChange {
|
||||
window_id: id,
|
||||
layout: layout.to_vec(),
|
||||
visible_layout: visible.to_vec(),
|
||||
raw_flags: flags.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match_window_add(line: &[u8]) -> Option<Notification> {
|
||||
// ^%window-add @([0-9]+)$
|
||||
let s = lit(line, b"%window-add @")?;
|
||||
let (id, s) = digits(s)?;
|
||||
if !s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Notification::WindowAdd { id })
|
||||
}
|
||||
|
||||
fn match_window_renamed(line: &[u8]) -> Option<Notification> {
|
||||
// ^%window-renamed @([0-9]+) (.+)$
|
||||
let s = lit(line, b"%window-renamed @")?;
|
||||
let (id, s) = digits(s)?;
|
||||
let s = lit(s, b" ")?;
|
||||
let name = nonempty(s)?;
|
||||
Some(Notification::WindowRenamed {
|
||||
id,
|
||||
name: name.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match_window_pane_changed(line: &[u8]) -> Option<Notification> {
|
||||
// ^%window-pane-changed @([0-9]+) %([0-9]+)$
|
||||
let s = lit(line, b"%window-pane-changed @")?;
|
||||
let (window_id, s) = digits(s)?;
|
||||
let s = lit(s, b" %")?;
|
||||
let (pane_id, s) = digits(s)?;
|
||||
if !s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Notification::WindowPaneChanged { window_id, pane_id })
|
||||
}
|
||||
|
||||
fn match_client_detached(line: &[u8]) -> Option<Notification> {
|
||||
// ^%client-detached (.+)$
|
||||
let s = lit(line, b"%client-detached ")?;
|
||||
let client = nonempty(s)?;
|
||||
Some(Notification::ClientDetached {
|
||||
client: client.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match_client_session_changed(line: &[u8]) -> Option<Notification> {
|
||||
// ^%client-session-changed (.+) \$([0-9]+) (.+)$
|
||||
let s = lit(line, b"%client-session-changed ")?;
|
||||
// Greedy client: try the latest split first.
|
||||
for p in (1..s.len()).rev() {
|
||||
if let Some(r) = s[p..].strip_prefix(b" $")
|
||||
&& let Some((session_id, r)) = digits(r)
|
||||
&& let Some(r) = lit(r, b" ")
|
||||
&& let Some(name) = nonempty(r)
|
||||
{
|
||||
return Some(Notification::ClientSessionChanged {
|
||||
client: s[..p].to_vec(),
|
||||
session_id,
|
||||
name: name.to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Possible notification types from tmux control mode. These are documented
|
||||
/// in tmux(1). A lot of the simple documentation was copied from that man
|
||||
/// page here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Notification {
|
||||
/// Entering tmux control mode. This isn't an actual event sent by
|
||||
/// tmux but is one sent by us to indicate that we have detected that
|
||||
/// tmux control mode is starting.
|
||||
Enter,
|
||||
|
||||
/// Exit.
|
||||
///
|
||||
/// NOTE: The tmux protocol contains a "reason" string (human friendly)
|
||||
/// associated with this. We currently drop it because we don't need it
|
||||
/// but this may be something we want to add later.
|
||||
Exit,
|
||||
|
||||
/// Dispatched at the end of a begin/end block with the raw data.
|
||||
/// The control mode parser can't parse the data because it is unaware
|
||||
/// of the command that was sent to trigger this output.
|
||||
BlockEnd(Vec<u8>),
|
||||
BlockErr(Vec<u8>),
|
||||
|
||||
/// Raw output from a pane.
|
||||
Output {
|
||||
pane_id: usize,
|
||||
/// unescaped
|
||||
data: Vec<u8>,
|
||||
},
|
||||
|
||||
/// The client is now attached to the session with ID session-id, which is
|
||||
/// named name.
|
||||
SessionChanged {
|
||||
id: usize,
|
||||
name: Vec<u8>,
|
||||
},
|
||||
|
||||
/// A session was created or destroyed.
|
||||
SessionsChanged,
|
||||
|
||||
/// The layout of the window with ID window-id changed.
|
||||
LayoutChange {
|
||||
window_id: usize,
|
||||
layout: Vec<u8>,
|
||||
visible_layout: Vec<u8>,
|
||||
raw_flags: Vec<u8>,
|
||||
},
|
||||
|
||||
/// The window with ID window-id was linked to the current session.
|
||||
WindowAdd {
|
||||
id: usize,
|
||||
},
|
||||
|
||||
/// The window with ID window-id was renamed to name.
|
||||
WindowRenamed {
|
||||
id: usize,
|
||||
name: Vec<u8>,
|
||||
},
|
||||
|
||||
/// The active pane in the window with ID window-id changed to the pane
|
||||
/// with ID pane-id.
|
||||
WindowPaneChanged {
|
||||
window_id: usize,
|
||||
pane_id: usize,
|
||||
},
|
||||
|
||||
/// The client has detached.
|
||||
ClientDetached {
|
||||
client: Vec<u8>,
|
||||
},
|
||||
|
||||
/// The client is now attached to the session with ID session-id, which is
|
||||
/// named name.
|
||||
ClientSessionChanged {
|
||||
client: Vec<u8>,
|
||||
session_id: usize,
|
||||
name: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn feed(c: &mut Parser, s: &str) {
|
||||
for b in s.bytes() {
|
||||
assert_eq!(c.put(b).unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_begin_end_empty() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%begin 1578922740 269 1\n");
|
||||
feed(&mut c, "%end 1578922740 269 1");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(n, Notification::BlockEnd(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_begin_error_empty() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%begin 1578922740 269 1\n");
|
||||
feed(&mut c, "%error 1578922740 269 1");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(n, Notification::BlockErr(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_begin_end_data() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%begin 1578922740 269 1\n");
|
||||
feed(&mut c, "hello\nworld\n");
|
||||
feed(&mut c, "%end 1578922740 269 1");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(n, Notification::BlockEnd(b"hello\nworld".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_output() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%output %42 foo bar baz");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(
|
||||
n,
|
||||
Notification::Output {
|
||||
pane_id: 42,
|
||||
data: b"foo bar baz".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_session_changed() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%session-changed $42 foo");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(
|
||||
n,
|
||||
Notification::SessionChanged {
|
||||
id: 42,
|
||||
name: b"foo".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_sessions_changed() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%sessions-changed");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(n, Notification::SessionsChanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_sessions_changed_carriage_return() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%sessions-changed\r");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(n, Notification::SessionsChanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_layout_change() {
|
||||
let mut c = Parser::new();
|
||||
feed(
|
||||
&mut c,
|
||||
"%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-",
|
||||
);
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(
|
||||
n,
|
||||
Notification::LayoutChange {
|
||||
window_id: 2,
|
||||
layout: b"1234x791,0,0{617x791,0,0,0,617x791,618,0,1}".to_vec(),
|
||||
visible_layout: b"1234x791,0,0{617x791,0,0,0,617x791,618,0,1}".to_vec(),
|
||||
raw_flags: b"*-".to_vec(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_layout_change_empty_flags() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%layout-change @2 a b ");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(
|
||||
n,
|
||||
Notification::LayoutChange {
|
||||
window_id: 2,
|
||||
layout: b"a".to_vec(),
|
||||
visible_layout: b"b".to_vec(),
|
||||
raw_flags: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_window_add() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%window-add @14");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(n, Notification::WindowAdd { id: 14 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_window_renamed() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%window-renamed @42 bar");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(
|
||||
n,
|
||||
Notification::WindowRenamed {
|
||||
id: 42,
|
||||
name: b"bar".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_window_pane_changed() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%window-pane-changed @42 %2");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(
|
||||
n,
|
||||
Notification::WindowPaneChanged {
|
||||
window_id: 42,
|
||||
pane_id: 2
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_client_detached() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%client-detached /dev/pts/1");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(
|
||||
n,
|
||||
Notification::ClientDetached {
|
||||
client: b"/dev/pts/1".to_vec()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_client_session_changed() {
|
||||
let mut c = Parser::new();
|
||||
feed(&mut c, "%client-session-changed /dev/pts/1 $2 mysession");
|
||||
let n = c.put(b'\n').unwrap().unwrap();
|
||||
assert_eq!(
|
||||
n,
|
||||
Notification::ClientSessionChanged {
|
||||
client: b"/dev/pts/1".to_vec(),
|
||||
session_id: 2,
|
||||
name: b"mysession".to_vec(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_idle_garbage_is_exit_then_broken() {
|
||||
let mut c = Parser::new();
|
||||
assert_eq!(c.put(b'x').unwrap(), Some(Notification::Exit));
|
||||
assert_eq!(c.put(b'%').unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_max_bytes() {
|
||||
let mut c = Parser::new();
|
||||
c.max_bytes = 4;
|
||||
for b in b"%out" {
|
||||
assert_eq!(c.put(*b).unwrap(), None);
|
||||
}
|
||||
assert_eq!(c.put(b'p'), Err(Error::OutOfMemory));
|
||||
assert_eq!(c.put(b'p').unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Types and functions related to tmux protocols (ZIG/src/terminal/tmux.zig).
|
||||
//!
|
||||
//! Only the pure control-mode parser lives in this crate; layout, output and
|
||||
//! viewer belong to `ghostty-terminal`.
|
||||
|
||||
pub mod control;
|
||||
|
||||
pub use control::{Notification as ControlNotification, Parser as ControlParser};
|
||||
@@ -0,0 +1,156 @@
|
||||
//! DFA-based non-allocating error-replacing UTF-8 decoder
|
||||
//! (ZIG/src/terminal/UTF8Decoder.zig).
|
||||
//!
|
||||
//! This implementation is based largely on the excellent work of
|
||||
//! Bjoern Hoehrmann, with slight modifications to support error-
|
||||
//! replacement.
|
||||
//!
|
||||
//! For details on Bjoern's DFA-based UTF-8 decoder, see
|
||||
//! http://bjoern.hoehrmann.de/utf-8/decoder/dfa (MIT licensed)
|
||||
|
||||
#[rustfmt::skip]
|
||||
const CHAR_CLASSES: [u8; 256] = [
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,
|
||||
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
|
||||
8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||
10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8,
|
||||
];
|
||||
|
||||
#[rustfmt::skip]
|
||||
const TRANSITIONS: [u8; 108] = [
|
||||
0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12,
|
||||
12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12,
|
||||
12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12,
|
||||
12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12,
|
||||
12,36,12,12,12,12,12,12,12,12,12,12,
|
||||
];
|
||||
|
||||
// DFA states
|
||||
const ACCEPT_STATE: u8 = 0;
|
||||
const REJECT_STATE: u8 = 12;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Utf8Decoder {
|
||||
// This is where we accumulate our current codepoint.
|
||||
accumulator: u32,
|
||||
// The internal state of the DFA.
|
||||
state: u8,
|
||||
}
|
||||
|
||||
impl Utf8Decoder {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
accumulator: 0,
|
||||
state: ACCEPT_STATE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the next byte in the utf-8 sequence and emits a tuple of
|
||||
/// - The codepoint that was generated, if there is one.
|
||||
/// - A boolean that indicates whether the provided byte was consumed.
|
||||
///
|
||||
/// The only case where the byte is not consumed is if an ill-formed
|
||||
/// sequence is reached, in which case a replacement character will be
|
||||
/// emitted and the byte will not be consumed.
|
||||
///
|
||||
/// If the byte is not consumed, the caller is responsible for calling
|
||||
/// again with the same byte before continuing.
|
||||
#[inline]
|
||||
pub fn next(&mut self, byte: u8) -> (Option<u32>, bool) {
|
||||
let char_class = CHAR_CLASSES[byte as usize];
|
||||
|
||||
let initial_state = self.state;
|
||||
|
||||
if self.state == ACCEPT_STATE {
|
||||
self.accumulator = (0xFFu32 >> char_class) & (byte as u32);
|
||||
} else {
|
||||
self.accumulator <<= 6;
|
||||
self.accumulator |= (byte & 0x3F) as u32;
|
||||
}
|
||||
|
||||
self.state = TRANSITIONS[(self.state + char_class) as usize];
|
||||
|
||||
if self.state == ACCEPT_STATE {
|
||||
let cp = self.accumulator;
|
||||
self.accumulator = 0;
|
||||
|
||||
// Emit the fully decoded codepoint.
|
||||
(Some(cp), true)
|
||||
} else if self.state == REJECT_STATE {
|
||||
self.accumulator = 0;
|
||||
self.state = ACCEPT_STATE;
|
||||
// Emit a replacement character. If we rejected the first byte
|
||||
// in a sequence, then it was consumed, otherwise it was not.
|
||||
(Some(0xFFFD), initial_state == ACCEPT_STATE)
|
||||
} else {
|
||||
// Emit nothing, we're in the middle of a sequence.
|
||||
(None, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ascii() {
|
||||
let mut d = Utf8Decoder::new();
|
||||
let mut out = Vec::new();
|
||||
for &byte in b"Hello, World!" {
|
||||
let res = d.next(byte);
|
||||
assert!(res.1);
|
||||
if let Some(cp) = res.0 {
|
||||
out.push(cp as u8);
|
||||
}
|
||||
}
|
||||
assert_eq!(out, b"Hello, World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_utf8() {
|
||||
let mut d = Utf8Decoder::new();
|
||||
let mut out = Vec::new();
|
||||
// 4 bytes, 3 bytes, 2 bytes, 1 byte
|
||||
for &byte in "😄✤ÁA".as_bytes() {
|
||||
let mut consumed = false;
|
||||
while !consumed {
|
||||
let res = d.next(byte);
|
||||
consumed = res.1;
|
||||
// There are no errors in this sequence, so
|
||||
// every byte should be consumed first try.
|
||||
assert!(consumed);
|
||||
if let Some(cp) = res.0 {
|
||||
out.push(cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(out, vec![0x1F604, 0x2724, 0xC1, 0x41]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partially_invalid_utf8() {
|
||||
let mut d = Utf8Decoder::new();
|
||||
let mut out = Vec::new();
|
||||
// Illegally terminated sequence, valid sequence, illegal surrogate pair.
|
||||
let mut input = vec![0xF0, 0x9F];
|
||||
input.extend("😄".as_bytes());
|
||||
input.extend([0xED, 0xA0, 0x80]);
|
||||
for byte in input {
|
||||
let mut consumed = false;
|
||||
while !consumed {
|
||||
let res = d.next(byte);
|
||||
consumed = res.1;
|
||||
if let Some(cp) = res.0 {
|
||||
out.push(cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(out, vec![0xFFFD, 0x1F604, 0xFFFD, 0xFFFD, 0xFFFD]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! X11 color names (ZIG/src/terminal/x11_color.zig).
|
||||
|
||||
use crate::color::Rgb;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// This is the rgb.txt file from the X11 project. This was last sourced
|
||||
/// from this location: https://gitlab.freedesktop.org/xorg/app/rgb
|
||||
/// This data is licensed under the MIT/X11 license while this file is
|
||||
/// licensed under the same license as Ghostty.
|
||||
const DATA: &str = include_str!("res/rgb.txt");
|
||||
|
||||
/// The map of all available X11 colors. Keys are lowercase ASCII; lookups
|
||||
/// are ASCII case-insensitive (see [`get`]).
|
||||
fn map() -> &'static HashMap<String, Rgb> {
|
||||
static MAP: OnceLock<HashMap<String, Rgb>> = OnceLock::new();
|
||||
MAP.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
// Parse the line. This is not very robust parsing, because we expect
|
||||
// a very exact format for rgb.txt.
|
||||
for line in DATA.split('\n') {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let r: u8 = line[0..3].trim_matches(' ').parse().expect("bad rgb.txt");
|
||||
let g: u8 = line[4..7].trim_matches(' ').parse().expect("bad rgb.txt");
|
||||
let b: u8 = line[8..11].trim_matches(' ').parse().expect("bad rgb.txt");
|
||||
let name = line[12..].trim_matches(|c| c == ' ' || c == '\t' || c == '\n');
|
||||
m.entry(name.to_ascii_lowercase())
|
||||
.or_insert(Rgb { r, g, b });
|
||||
}
|
||||
m
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up an X11 color by name (ASCII case-insensitive).
|
||||
pub fn get(name: &str) -> Option<Rgb> {
|
||||
map().get(&name.to_ascii_lowercase()).copied()
|
||||
}
|
||||
|
||||
/// Byte-slice variant of [`get`].
|
||||
pub fn get_bytes(name: &[u8]) -> Option<Rgb> {
|
||||
get(std::str::from_utf8(name).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn x11_color_map() {
|
||||
assert_eq!(None, get("nosuchcolor"));
|
||||
assert_eq!(
|
||||
Rgb {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255
|
||||
},
|
||||
get("white").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
Rgb {
|
||||
r: 0,
|
||||
g: 250,
|
||||
b: 154
|
||||
},
|
||||
get("medium spring green").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
Rgb {
|
||||
r: 34,
|
||||
g: 139,
|
||||
b: 34
|
||||
},
|
||||
get("ForestGreen").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
Rgb {
|
||||
r: 34,
|
||||
g: 139,
|
||||
b: 34
|
||||
},
|
||||
get("FoReStGReen").unwrap()
|
||||
);
|
||||
assert_eq!(Rgb { r: 0, g: 0, b: 0 }, get("black").unwrap());
|
||||
assert_eq!(Rgb { r: 255, g: 0, b: 0 }, get("red").unwrap());
|
||||
assert_eq!(Rgb { r: 0, g: 255, b: 0 }, get("green").unwrap());
|
||||
assert_eq!(Rgb { r: 0, g: 0, b: 255 }, get("blue").unwrap());
|
||||
assert_eq!(
|
||||
Rgb {
|
||||
r: 124,
|
||||
g: 252,
|
||||
b: 0
|
||||
},
|
||||
get("lawngreen").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
Rgb {
|
||||
r: 0,
|
||||
g: 250,
|
||||
b: 154
|
||||
},
|
||||
get("mediumspringgreen").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
Rgb {
|
||||
r: 34,
|
||||
g: 139,
|
||||
b: 34
|
||||
},
|
||||
get("forestgreen").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user