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:
ddidderr committed 2026-10-11 21:52:41 +02:00
1 parent 864ef82acb
commit b2b248e003
44 files changed
+15040 -6

No files matched your search

+114
View File
@@ -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");
}
}
+51
View File
@@ -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);
}
}
+2
View File
@@ -1,3 +1,5 @@
//! Kitty protocol types (ZIG/src/terminal/kitty.zig).
pub mod color;
pub mod graphics_command;
pub mod key;