refactor: type escaped OS strings and fix the final audit findings
Escaped config strings (ghostty_os::osbytes) kept reaching OS boundaries undecoded, and decoding was occasionally applied to plain text. The root cause was that both were plain `String`s. Now: - `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only via to_os/to_path/to_bytes/to_wide; plain text enters via from_plain_text. OS helpers (env, home, xdg, passwd, resources dir, path expansion), config::Path, WorkingDirectory, theme/file_load/edit paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the compiler forces decoding at every boundary. os::open takes &OsStr. Config output is unchanged. Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md): screen-file copy; regex/OSC 8 URLs no longer decoded (regression); lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows resources dir/TERMINFO; color OSC request lists and X11 color lookup no longer allocate globally (C zero-allocation test extended); macOS Mach handler resumes all threads before KERN_FAILURE (hang regression) and SIGABRT minidumps go through the exception thread; plus open-config, background images, crash dir, ssh cache, exe dir/PATH, fontconfig filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths. The typing also surfaced and fixed gtk-custom-css, bell audio, bash HISTFILE, OSC 52 writes and the C config getters. Test Plan: - cargo test --workspace (3376 passed) - just check-macos (both), just check-windows, just check-wasm - diff <(ghostty-rs +show-config --default --docs) \ <(ghostty +show-config --default --docs)
This commit is contained in:
1 parent
0f898d3e41
commit
8f3e206d9f
92 files changed
+2967
-992
No files matched your search
@@ -1,21 +1,88 @@
|
||||
//! Kitty color protocol (ZIG/src/terminal/kitty/color.zig).
|
||||
|
||||
use std::fmt;
|
||||
use std::{borrow::Cow, fmt};
|
||||
|
||||
use crate::{color::Rgb, osc::Terminator};
|
||||
|
||||
/// OSC 21 payload.
|
||||
///
|
||||
/// The request list is stored encoded (fixed size records) in the OSC
|
||||
/// parser's own storage, so parsing it never touches the global allocator
|
||||
/// when the parser was given a caller-provided allocator (C API).
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct OSC {
|
||||
pub struct OSC<'a> {
|
||||
/// list of requests
|
||||
pub list: Vec<Request>,
|
||||
pub list: List<'a>,
|
||||
|
||||
/// We must reply with the same string terminator (ST) as used in the
|
||||
/// request.
|
||||
pub terminator: Terminator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
impl OSC<'_> {
|
||||
/// Detach from the parser storage.
|
||||
#[must_use]
|
||||
pub fn into_owned(self) -> OSC<'static> {
|
||||
OSC {
|
||||
list: self.list.into_owned(),
|
||||
terminator: self.terminator,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of one encoded [`Request`] record.
|
||||
pub const RECORD_LEN: usize = 8;
|
||||
|
||||
/// A list of kitty color [`Request`]s: borrowed (or owned) encoded records.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct List<'a>(Cow<'a, [u8]>);
|
||||
|
||||
impl<'a> List<'a> {
|
||||
/// View records encoded with [`Request::encode`] (`bytes.len()` must be a
|
||||
/// multiple of [`RECORD_LEN`]).
|
||||
#[must_use]
|
||||
pub fn from_encoded(bytes: &'a [u8]) -> List<'a> {
|
||||
debug_assert_eq!(bytes.len() % RECORD_LEN, 0);
|
||||
List(Cow::Borrowed(bytes))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len() / RECORD_LEN
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, i: usize) -> Option<Request> {
|
||||
Request::decode(self.0.get(i * RECORD_LEN..(i + 1) * RECORD_LEN)?)
|
||||
}
|
||||
|
||||
/// Iterate the requests (by value).
|
||||
pub fn iter(&self) -> impl Iterator<Item = Request> + '_ {
|
||||
self.0
|
||||
.as_chunks::<RECORD_LEN>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|rec| Request::decode(rec))
|
||||
}
|
||||
|
||||
/// Copy into a `Vec` (allocates; for tests and diagnostics).
|
||||
#[must_use]
|
||||
pub fn to_vec(&self) -> Vec<Request> {
|
||||
self.iter().collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_owned(self) -> List<'static> {
|
||||
List(Cow::Owned(self.0.into_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
Query(Kind),
|
||||
Set { key: Kind, color: Rgb },
|
||||
@@ -35,6 +102,26 @@ pub enum Special {
|
||||
}
|
||||
|
||||
impl Special {
|
||||
/// All specials in discriminant order.
|
||||
const ALL: [Special; 8] = [
|
||||
Special::Foreground,
|
||||
Special::Background,
|
||||
Special::SelectionForeground,
|
||||
Special::SelectionBackground,
|
||||
Special::Cursor,
|
||||
Special::CursorText,
|
||||
Special::VisualBell,
|
||||
Special::SecondTransparentBackground,
|
||||
];
|
||||
|
||||
fn to_u8(self) -> u8 {
|
||||
Special::ALL.iter().position(|s| *s == self).unwrap_or(0) as u8
|
||||
}
|
||||
|
||||
fn from_u8(v: u8) -> Option<Special> {
|
||||
Special::ALL.get(usize::from(v)).copied()
|
||||
}
|
||||
|
||||
/// The tag name (Zig `@tagName`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
@@ -92,6 +179,46 @@ impl Kind {
|
||||
}
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Encode as a fixed size record: `[tag, kind class, kind value, r, g, b,
|
||||
/// 0, 0]`.
|
||||
#[must_use]
|
||||
pub fn encode(&self) -> [u8; RECORD_LEN] {
|
||||
let (tag, key, color) = match *self {
|
||||
Request::Query(k) => (0, k, Rgb::default()),
|
||||
Request::Set { key, color } => (1, key, color),
|
||||
Request::Reset(k) => (2, k, Rgb::default()),
|
||||
};
|
||||
let (class, value) = match key {
|
||||
Kind::Palette(p) => (0, p),
|
||||
Kind::Special(s) => (1, s.to_u8()),
|
||||
};
|
||||
[tag, class, value, color.r, color.g, color.b, 0, 0]
|
||||
}
|
||||
|
||||
/// Inverse of [`Request::encode`].
|
||||
#[must_use]
|
||||
pub fn decode(rec: &[u8]) -> Option<Request> {
|
||||
let [tag, class, value, r, g, b, ..] = *rec else {
|
||||
return None;
|
||||
};
|
||||
let key = match class {
|
||||
0 => Kind::Palette(value),
|
||||
1 => Kind::Special(Special::from_u8(value)?),
|
||||
_ => return None,
|
||||
};
|
||||
Some(match tag {
|
||||
0 => Request::Query(key),
|
||||
1 => Request::Set {
|
||||
key,
|
||||
color: Rgb { r, g, b },
|
||||
},
|
||||
2 => Request::Reset(key),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Kind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -106,6 +233,33 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_records_round_trip() {
|
||||
let mut reqs = vec![];
|
||||
for s in Special::ALL {
|
||||
let k = Kind::Special(s);
|
||||
reqs.push(Request::Query(k));
|
||||
reqs.push(Request::Reset(k));
|
||||
reqs.push(Request::Set {
|
||||
key: k,
|
||||
color: Rgb { r: 1, g: 2, b: 3 },
|
||||
});
|
||||
}
|
||||
reqs.push(Request::Set {
|
||||
key: Kind::Palette(255),
|
||||
color: Rgb { r: 255, g: 0, b: 9 },
|
||||
});
|
||||
reqs.push(Request::Query(Kind::Palette(0)));
|
||||
let bytes: Vec<u8> = reqs.iter().flat_map(Request::encode).collect();
|
||||
let list = List::from_encoded(&bytes);
|
||||
assert_eq!(list.len(), reqs.len());
|
||||
assert_eq!(list.to_vec(), reqs);
|
||||
assert_eq!(list.get(reqs.len()), None);
|
||||
assert_eq!(list.into_owned().to_vec(), reqs);
|
||||
assert_eq!(Request::decode(&[9, 0, 0, 0, 0, 0, 0, 0]), None);
|
||||
assert_eq!(Request::decode(&[0, 1, 200, 0, 0, 0, 0, 0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_kitty_color_protocol_kind_string() {
|
||||
assert_eq!("foreground", Kind::Special(Special::Foreground).to_string());
|
||||
|
||||
@@ -82,13 +82,13 @@ pub enum Command<'a> {
|
||||
/// 4, 5, 10-19, 104, 105, 110-119
|
||||
ColorOperation {
|
||||
op: color::Operation,
|
||||
requests: color::List,
|
||||
requests: color::List<'a>,
|
||||
terminator: Terminator,
|
||||
},
|
||||
|
||||
/// Kitty color protocol, OSC 21
|
||||
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
|
||||
KittyColorProtocol(kitty_color::OSC),
|
||||
KittyColorProtocol(kitty_color::OSC<'a>),
|
||||
|
||||
/// Show a desktop notification (OSC 9 or OSC 777)
|
||||
ShowDesktopNotification {
|
||||
@@ -228,10 +228,10 @@ impl Command<'_> {
|
||||
terminator,
|
||||
} => Command::ColorOperation {
|
||||
op,
|
||||
requests,
|
||||
requests: requests.into_owned(),
|
||||
terminator,
|
||||
},
|
||||
Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v),
|
||||
Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v.into_owned()),
|
||||
Command::ShowDesktopNotification { title, body } => Command::ShowDesktopNotification {
|
||||
title: own(title),
|
||||
body: own(body),
|
||||
@@ -486,6 +486,10 @@ pub struct Parser<S: ByteStorage = Vec<u8>> {
|
||||
|
||||
/// Buffer for temporary storage of OSC data
|
||||
buffer: S,
|
||||
/// Auxiliary storage for the encoded request lists of the color OSCs
|
||||
/// (Zig: a `SegmentedList` on the parser's allocator). Same allocator as
|
||||
/// `buffer`.
|
||||
aux: S,
|
||||
writer: Writer,
|
||||
}
|
||||
|
||||
@@ -501,6 +505,7 @@ impl Parser {
|
||||
alloc,
|
||||
state: State::Start,
|
||||
buffer: Vec::new(),
|
||||
aux: Vec::new(),
|
||||
writer: Writer::None,
|
||||
}
|
||||
}
|
||||
@@ -512,11 +517,16 @@ impl<S: ByteStorage> Parser<S> {
|
||||
|
||||
/// Like [`Parser::init`] with an allocator-backed payload buffer
|
||||
/// (`alloc` is true: an allocator is available).
|
||||
pub fn with_storage(storage: S) -> Parser<S> {
|
||||
///
|
||||
/// `aux` holds the request lists of the color OSCs; give it the same
|
||||
/// allocator as `storage` (a capacity of a few [`color::RECORD_LEN`]
|
||||
/// records avoids allocating for common color commands).
|
||||
pub fn with_storage(storage: S, aux: S) -> Parser<S> {
|
||||
Parser {
|
||||
alloc: true,
|
||||
state: State::Start,
|
||||
buffer: storage,
|
||||
aux,
|
||||
writer: Writer::None,
|
||||
}
|
||||
}
|
||||
@@ -525,6 +535,7 @@ impl<S: ByteStorage> Parser<S> {
|
||||
pub fn reset(&mut self) {
|
||||
self.state = State::Start;
|
||||
self.buffer.reset();
|
||||
self.aux.reset();
|
||||
self.writer = Writer::None;
|
||||
}
|
||||
|
||||
@@ -608,6 +619,18 @@ impl<S: ByteStorage> Parser<S> {
|
||||
self.buffer.bytes()
|
||||
}
|
||||
|
||||
/// The payload and the auxiliary request storage at once (disjoint
|
||||
/// borrows, so a color parser can read the former while filling the
|
||||
/// latter).
|
||||
pub(crate) fn payload_and_aux(&mut self) -> (&[u8], &mut S) {
|
||||
(self.buffer.bytes(), &mut self.aux)
|
||||
}
|
||||
|
||||
/// The encoded records written to the auxiliary storage.
|
||||
pub(crate) fn aux_bytes(&self) -> &[u8] {
|
||||
self.aux.bytes()
|
||||
}
|
||||
|
||||
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
|
||||
pub fn has_writer(&self) -> bool {
|
||||
self.writer != Writer::None
|
||||
@@ -977,10 +1000,16 @@ mod next_slice_tests {
|
||||
|
||||
#[test]
|
||||
fn storage_failure_invalidates_osc() {
|
||||
let mut p = Parser::with_storage(Limited {
|
||||
data: Vec::new(),
|
||||
cap: 8,
|
||||
});
|
||||
let mut p = Parser::with_storage(
|
||||
Limited {
|
||||
data: Vec::new(),
|
||||
cap: 8,
|
||||
},
|
||||
Limited {
|
||||
data: Vec::new(),
|
||||
cap: 64,
|
||||
},
|
||||
);
|
||||
for b in b"0;abcdefghijkl" {
|
||||
p.next(*b);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! OSC color operations: 4, 5, 10-19, 104, 105, 110-119
|
||||
//! (ZIG/src/terminal/osc/parsers/color.zig).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{
|
||||
color::{Dynamic as DynamicColor, Rgb, Special as SpecialColor},
|
||||
osc::{ByteStorage, Command, Parser, State, Terminator},
|
||||
@@ -49,11 +51,7 @@ pub fn parse<S: ByteStorage>(
|
||||
if parser.has_writer() {
|
||||
parser.buffered()?;
|
||||
}
|
||||
let data: &[u8] = if parser.has_writer() {
|
||||
parser.payload()
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let has_writer = parser.has_writer();
|
||||
// Check and make sure that we're parsing the correct OSCs
|
||||
let op = match parser.state {
|
||||
State::S4 => Operation::Osc4,
|
||||
@@ -84,13 +82,60 @@ pub fn parse<S: ByteStorage>(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The request list is built in the parser's own (allocator-backed)
|
||||
// auxiliary storage, not on the global allocator.
|
||||
let (payload, aux) = parser.payload_and_aux();
|
||||
let data: &[u8] = if has_writer { payload } else { &[] };
|
||||
aux.reset();
|
||||
let mut sink = StorageSink { aux, failed: false };
|
||||
parse_color_into(op, data, &mut sink);
|
||||
if sink.failed {
|
||||
// Allocation failure: the OSC cannot be represented.
|
||||
log::warn!("unable to allocate the color request list");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(Command::ColorOperation {
|
||||
op,
|
||||
requests: parse_color(op, data),
|
||||
requests: List::from_encoded(parser.aux_bytes()),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where parsed requests go: a `Vec` (tests, [`parse_color`]) or the
|
||||
/// parser's storage ([`StorageSink`]).
|
||||
pub trait Sink {
|
||||
fn push(&mut self, request: Request);
|
||||
fn is_empty(&self) -> bool;
|
||||
}
|
||||
|
||||
impl Sink for Vec<Request> {
|
||||
fn push(&mut self, request: Request) {
|
||||
Vec::push(self, request);
|
||||
}
|
||||
fn is_empty(&self) -> bool {
|
||||
Vec::is_empty(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends encoded request records to a [`ByteStorage`]. An allocation
|
||||
/// failure is remembered in `failed` (later pushes are dropped).
|
||||
pub struct StorageSink<'s, S: ByteStorage> {
|
||||
aux: &'s mut S,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<S: ByteStorage> Sink for StorageSink<'_, S> {
|
||||
fn push(&mut self, request: Request) {
|
||||
if !self.failed && self.aux.try_extend(&request.encode()).is_err() {
|
||||
self.failed = true;
|
||||
}
|
||||
}
|
||||
fn is_empty(&self) -> bool {
|
||||
self.aux.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
@@ -132,33 +177,88 @@ fn parse_u9(s: &[u8]) -> Option<u16> {
|
||||
/// 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 {
|
||||
pub fn parse_color(op: Operation, buf: &[u8]) -> Vec<Request> {
|
||||
let mut out = Vec::new();
|
||||
parse_color_into(op, buf, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// [`parse_color`] into any [`Sink`].
|
||||
pub fn parse_color_into(op: Operation, buf: &[u8], out: &mut impl Sink) {
|
||||
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),
|
||||
Operation::Osc4 => {
|
||||
parse_get_set_ansi_color(Operation::Osc4, &mut it, out);
|
||||
}
|
||||
Operation::Osc5 => {
|
||||
parse_get_set_ansi_color(Operation::Osc5, &mut it, out);
|
||||
}
|
||||
Operation::Osc104 => {
|
||||
parse_reset_ansi_color(Operation::Osc104, &mut it, out);
|
||||
}
|
||||
Operation::Osc105 => {
|
||||
parse_reset_ansi_color(Operation::Osc105, &mut it, out);
|
||||
}
|
||||
Operation::Osc10 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it, out);
|
||||
}
|
||||
Operation::Osc11 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::Background, &mut it, out);
|
||||
}
|
||||
Operation::Osc12 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it, out);
|
||||
}
|
||||
Operation::Osc13 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc14 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc15 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc16 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc17 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc18 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it, out);
|
||||
}
|
||||
Operation::Osc19 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc110 => {
|
||||
parse_reset_dynamic_color(DynamicColor::Foreground, &mut it, out);
|
||||
}
|
||||
Operation::Osc111 => {
|
||||
parse_reset_dynamic_color(DynamicColor::Background, &mut it, out);
|
||||
}
|
||||
Operation::Osc112 => {
|
||||
parse_reset_dynamic_color(DynamicColor::Cursor, &mut it, out);
|
||||
}
|
||||
Operation::Osc113 => {
|
||||
parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc114 => {
|
||||
parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc115 => {
|
||||
parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc116 => {
|
||||
parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc117 => {
|
||||
parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc118 => {
|
||||
parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it, out);
|
||||
}
|
||||
Operation::Osc119 => {
|
||||
parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,29 +279,32 @@ fn ansi_target(color: u16, special_only: bool) -> Option<Target> {
|
||||
}
|
||||
|
||||
/// OSC 4/5
|
||||
fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
|
||||
fn parse_get_set_ansi_color<'a>(
|
||||
op: Operation,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
result: &mut impl Sink,
|
||||
) {
|
||||
// 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;
|
||||
return;
|
||||
};
|
||||
let Some(spec_str) = it.next() else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
|
||||
// Color must be numeric. u9 because that'll fit our palette + special
|
||||
let Some(color) = parse_u9(color_str) else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
|
||||
// Parse the color.
|
||||
let Some(target) = ansi_target(color, op == Operation::Osc5) else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
|
||||
// "?" always results in a query.
|
||||
@@ -211,20 +314,23 @@ fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a
|
||||
}
|
||||
|
||||
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
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 {
|
||||
fn parse_reset_ansi_color<'a>(
|
||||
op: Operation,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
result: &mut impl Sink,
|
||||
) {
|
||||
// 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.
|
||||
@@ -235,7 +341,7 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [
|
||||
_ => unreachable!(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
|
||||
// Empty color strings are ignored, not treated as an error.
|
||||
@@ -261,22 +367,22 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [
|
||||
fn parse_get_set_dynamic_color<'a>(
|
||||
start: DynamicColor,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
) -> List {
|
||||
result: &mut impl Sink,
|
||||
) {
|
||||
// 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;
|
||||
return;
|
||||
};
|
||||
|
||||
if spec_str == b"?" {
|
||||
result.push(Request::Query(Target::Dynamic(color)));
|
||||
} else {
|
||||
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
result.push(Request::Set(ColoredTarget {
|
||||
target: Target::Dynamic(color),
|
||||
@@ -287,7 +393,7 @@ fn parse_get_set_dynamic_color<'a>(
|
||||
// Each successive value uses the next color so long as it exists.
|
||||
match color.next() {
|
||||
Some(n) => color = n,
|
||||
None => return result,
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,18 +402,117 @@ fn parse_get_set_dynamic_color<'a>(
|
||||
fn parse_reset_dynamic_color<'a>(
|
||||
color: DynamicColor,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
) -> List {
|
||||
let mut result = List::new();
|
||||
result: &mut impl Sink,
|
||||
) {
|
||||
if it.next().is_some() {
|
||||
return result;
|
||||
return;
|
||||
}
|
||||
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>;
|
||||
/// Size of one encoded [`Request`] record.
|
||||
pub const RECORD_LEN: usize = 8;
|
||||
|
||||
/// The list of requests in a color OSC. (Zig uses a SegmentedList on the
|
||||
/// parser's allocator.) The requests are stored as fixed size encoded
|
||||
/// records ([`Request::encode`]) in the parser's own storage, so with a
|
||||
/// caller-provided allocator (C API) the list is allocated by that allocator
|
||||
/// and borrowed by the command, like Zig's.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct List<'a>(Cow<'a, [u8]>);
|
||||
|
||||
impl<'a> List<'a> {
|
||||
/// View records encoded with [`Request::encode`].
|
||||
#[must_use]
|
||||
pub fn from_encoded(bytes: &'a [u8]) -> List<'a> {
|
||||
debug_assert_eq!(bytes.len() % RECORD_LEN, 0);
|
||||
List(Cow::Borrowed(bytes))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len() / RECORD_LEN
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, i: usize) -> Option<Request> {
|
||||
Request::decode(self.0.get(i * RECORD_LEN..(i + 1) * RECORD_LEN)?)
|
||||
}
|
||||
|
||||
/// Iterate the requests (by value).
|
||||
pub fn iter(&self) -> impl Iterator<Item = Request> + '_ {
|
||||
self.0
|
||||
.as_chunks::<RECORD_LEN>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|rec| Request::decode(rec))
|
||||
}
|
||||
|
||||
/// Copy into a `Vec` (allocates; for tests and diagnostics).
|
||||
#[must_use]
|
||||
pub fn to_vec(&self) -> Vec<Request> {
|
||||
self.iter().collect()
|
||||
}
|
||||
|
||||
/// Detach from the parser storage.
|
||||
#[must_use]
|
||||
pub fn into_owned(self) -> List<'static> {
|
||||
List(Cow::Owned(self.0.into_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Encode as a fixed size record: `[tag, target class, target value, r,
|
||||
/// g, b, 0, 0]`.
|
||||
#[must_use]
|
||||
pub fn encode(&self) -> [u8; RECORD_LEN] {
|
||||
fn target(t: Target) -> (u8, u8) {
|
||||
match t {
|
||||
Target::Palette(p) => (0, p),
|
||||
Target::Special(s) => (1, s as u8),
|
||||
Target::Dynamic(d) => (2, d as u8),
|
||||
}
|
||||
}
|
||||
let (tag, (class, value), c) = match *self {
|
||||
Request::Set(ColoredTarget { target: t, color }) => (0, target(t), color),
|
||||
Request::Query(t) => (1, target(t), Rgb::default()),
|
||||
Request::Reset(t) => (2, target(t), Rgb::default()),
|
||||
Request::ResetPalette => (3, (0, 0), Rgb::default()),
|
||||
Request::ResetSpecial => (4, (0, 0), Rgb::default()),
|
||||
};
|
||||
[tag, class, value, c.r, c.g, c.b, 0, 0]
|
||||
}
|
||||
|
||||
/// Inverse of [`Request::encode`].
|
||||
#[must_use]
|
||||
pub fn decode(rec: &[u8]) -> Option<Request> {
|
||||
let [tag, class, value, r, g, b, ..] = *rec else {
|
||||
return None;
|
||||
};
|
||||
let target = match class {
|
||||
0 => Target::Palette(value),
|
||||
1 => Target::Special(SpecialColor::from_u8(value)?),
|
||||
2 => Target::Dynamic(DynamicColor::from_u8(value)?),
|
||||
_ => return None,
|
||||
};
|
||||
Some(match tag {
|
||||
0 => Request::Set(ColoredTarget {
|
||||
target,
|
||||
color: Rgb { r, g, b },
|
||||
}),
|
||||
1 => Request::Query(target),
|
||||
2 => Request::Reset(target),
|
||||
3 => Request::ResetPalette,
|
||||
4 => Request::ResetSpecial,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A single operation related to the terminal color palette.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -447,6 +652,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_records_round_trip() {
|
||||
let mut reqs = vec![Request::ResetPalette, Request::ResetSpecial];
|
||||
for t in [
|
||||
Target::Palette(0),
|
||||
Target::Palette(255),
|
||||
Target::Special(SpecialColor::Italic),
|
||||
Target::Dynamic(DynamicColor::HighlightForeground),
|
||||
] {
|
||||
reqs.push(Request::Query(t));
|
||||
reqs.push(Request::Reset(t));
|
||||
reqs.push(Request::Set(ColoredTarget {
|
||||
target: t,
|
||||
color: Rgb { r: 9, g: 8, b: 7 },
|
||||
}));
|
||||
}
|
||||
let bytes: Vec<u8> = reqs.iter().flat_map(Request::encode).collect();
|
||||
let list = List::from_encoded(&bytes);
|
||||
assert_eq!(list.len(), reqs.len());
|
||||
assert_eq!(list.to_vec(), reqs);
|
||||
assert_eq!(list.iter().count(), reqs.len());
|
||||
assert_eq!(list.into_owned().to_vec(), reqs);
|
||||
assert_eq!(Request::decode(&[7, 0, 0, 0, 0, 0, 0, 0]), None);
|
||||
assert_eq!(Request::decode(&[1, 2, 99, 0, 0, 0, 0, 0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_4_multiple_requests() {
|
||||
let list = parse_color(Operation::Osc4, b"0;red;1;blue");
|
||||
@@ -566,7 +797,7 @@ mod tests {
|
||||
assert_eq!(Operation::Osc112, op);
|
||||
assert_eq!(
|
||||
vec![Request::Reset(Target::Dynamic(DynamicColor::Cursor))],
|
||||
requests
|
||||
requests.to_vec()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::{
|
||||
color::Rgb,
|
||||
kitty::color::{Kind, OSC, Request},
|
||||
kitty::color::{Kind, List, OSC, Request},
|
||||
osc::{ByteStorage, Command, Parser, State, Terminator},
|
||||
};
|
||||
|
||||
@@ -23,16 +23,18 @@ pub fn parse<S: ByteStorage>(
|
||||
}
|
||||
parser.buffered()?;
|
||||
|
||||
// The parse only reads the payload; failure marks the parser invalid
|
||||
// after the borrow ends.
|
||||
let data = parser.payload();
|
||||
// The parse reads the payload and appends encoded records to the
|
||||
// parser's auxiliary storage (the caller's allocator in the C API); the
|
||||
// command then borrows them.
|
||||
let (data, aux) = parser.payload_and_aux();
|
||||
aux.reset();
|
||||
let mut exceeded = false;
|
||||
let mut osc = OSC {
|
||||
list: Vec::new(),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
let mut out = Pending {
|
||||
failed: false,
|
||||
count: 0,
|
||||
};
|
||||
for kv in data.split(|&c| c == b';') {
|
||||
if osc.list.len() >= Kind::MAX * 2 {
|
||||
if out.count >= Kind::MAX * 2 {
|
||||
log::warn!("exceeded limit for number of keys in kitty color protocol, ignoring");
|
||||
exceeded = true;
|
||||
break;
|
||||
@@ -54,11 +56,11 @@ pub fn parse<S: ByteStorage>(
|
||||
};
|
||||
let value = rest.trim_ascii_matching(b' ');
|
||||
if value.is_empty() {
|
||||
osc.list.push(Request::Reset(key));
|
||||
out.push(aux, Request::Reset(key));
|
||||
} else if value == b"?" {
|
||||
osc.list.push(Request::Query(key));
|
||||
out.push(aux, Request::Query(key));
|
||||
} else if let Ok(color) = Rgb::parse_bytes(value) {
|
||||
osc.list.push(Request::Set { key, color });
|
||||
out.push(aux, Request::Set { key, color });
|
||||
} else {
|
||||
log::warn!(
|
||||
"invalid color format in kitty color protocol: {}",
|
||||
@@ -66,11 +68,31 @@ pub fn parse<S: ByteStorage>(
|
||||
);
|
||||
}
|
||||
}
|
||||
if exceeded {
|
||||
if exceeded || out.failed {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(Command::KittyColorProtocol(osc))
|
||||
Some(Command::KittyColorProtocol(OSC {
|
||||
list: List::from_encoded(parser.aux_bytes()),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Bookkeeping while appending records to the parser's storage.
|
||||
struct Pending {
|
||||
/// An allocation failed (the OSC cannot be represented).
|
||||
failed: bool,
|
||||
/// Number of requests so far (for the limit).
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
fn push<S: ByteStorage>(&mut self, aux: &mut S, req: Request) {
|
||||
if !self.failed && aux.try_extend(&req.encode()).is_err() {
|
||||
self.failed = true;
|
||||
}
|
||||
self.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
trait TrimMatching {
|
||||
@@ -123,7 +145,7 @@ mod tests {
|
||||
let Command::KittyColorProtocol(osc) = cmd else {
|
||||
panic!("wrong command");
|
||||
};
|
||||
let list = osc.list;
|
||||
let list = osc.list.to_vec();
|
||||
assert_eq!(9, list.len());
|
||||
assert_eq!(Request::Query(Kind::Special(Special::Foreground)), list[0]);
|
||||
assert_eq!(
|
||||
|
||||
@@ -892,8 +892,10 @@ mod tests {
|
||||
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))
|
||||
requests.get(0),
|
||||
Some(osc::color::Request::Reset(osc::color::Target::Dynamic(
|
||||
crate::color::Dynamic::Cursor
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -921,7 +923,7 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(requests.get(0), Some(osc::color::Request::ResetPalette));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -151,7 +151,7 @@ define_actions! {
|
||||
MouseShape(MouseShape),
|
||||
ConfigureCharset(ConfigureCharset),
|
||||
SetAttribute(sgr::Attribute<'a>),
|
||||
KittyColorReport(&'a kitty::color::OSC),
|
||||
KittyColorReport(&'a kitty::color::OSC<'a>),
|
||||
ColorOperation(ColorOperation<'a>),
|
||||
SemanticPrompt(&'a osc::SemanticPrompt<'a>),
|
||||
}
|
||||
@@ -248,7 +248,7 @@ pub struct ConfigureCharset {
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ColorOperation<'a> {
|
||||
pub op: osc::color::Operation,
|
||||
pub requests: &'a osc::color::List,
|
||||
pub requests: &'a osc::color::List<'a>,
|
||||
pub terminator: osc::Terminator,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! X11 color names (ZIG/src/terminal/x11_color.zig).
|
||||
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::color::Rgb;
|
||||
|
||||
@@ -23,17 +23,15 @@ fn parse_entries() -> impl Iterator<Item = (&'static str, Rgb)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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();
|
||||
for (name, rgb) in parse_entries() {
|
||||
m.entry(name.to_ascii_lowercase()).or_insert(rgb);
|
||||
}
|
||||
m
|
||||
})
|
||||
/// `(lowercase name, r, g, b)` sorted by name (generated by `build.rs` from
|
||||
/// `res/rgb.txt`; the first spelling of a name wins).
|
||||
static TABLE: &[(&str, u8, u8, u8)] = include!(concat!(env!("OUT_DIR"), "/x11_table.rs"));
|
||||
|
||||
/// ASCII case-insensitive comparison of `query` against an already lowercase
|
||||
/// `key`, without allocating.
|
||||
fn cmp_lowercase(query: &[u8], key: &[u8]) -> std::cmp::Ordering {
|
||||
let q = query.iter().map(u8::to_ascii_lowercase);
|
||||
q.cmp(key.iter().copied())
|
||||
}
|
||||
|
||||
/// All X11 color names with their original spelling (e.g. `AliceBlue`,
|
||||
@@ -49,14 +47,18 @@ pub fn names() -> &'static [(&'static str, Rgb)] {
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up an X11 color by name (ASCII case-insensitive).
|
||||
/// Look up an X11 color by name (ASCII case-insensitive). Never allocates.
|
||||
pub fn get(name: &str) -> Option<Rgb> {
|
||||
map().get(&name.to_ascii_lowercase()).copied()
|
||||
get_bytes(name.as_bytes())
|
||||
}
|
||||
|
||||
/// Byte-slice variant of [`get`].
|
||||
pub fn get_bytes(name: &[u8]) -> Option<Rgb> {
|
||||
get(std::str::from_utf8(name).ok()?)
|
||||
let i = TABLE
|
||||
.binary_search_by(|(key, ..)| cmp_lowercase(name, key.as_bytes()).reverse())
|
||||
.ok()?;
|
||||
let (_, r, g, b) = TABLE[i];
|
||||
Some(Rgb { r, g, b })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in new issue
Block a user