brewery
notashelf /
f818019ce1054a6e1b0c840e12d8378f1ebcb834

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit f818019ce105

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 18:56 UTC

unverified2 files changed+251-0
beer-protocols: add the kitty text-sizing (OSC 66) parser

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I420f4232071d54228ed7b88faa8d97596a6a6964
diff --git a/crates/beer-protocols/src/lib.rs b/crates/beer-protocols/src/lib.rsindex 7b0ea4f..d5428a0 100644--- a/crates/beer-protocols/src/lib.rs+++ b/crates/beer-protocols/src/lib.rs@@ -17,6 +17,7 @@ //! - [`sgr`] - the multi-parameter SGR colour and underline forms. //! - [`key`] - legacy xterm/VT and kitty keyboard-protocol key encoding. //! - [`mouse`] - X10/UTF-8/SGR mouse-report encoding.+//! - [`text_size`] - the kitty text-sizing protocol (`OSC 66`) metadata. //! - [`style`] - the enums an SGR/DECSET stream selects (colour, underline, //!   cursor shape, mouse protocol/encoding, shell-integration prompt marks). //!@@ -30,6 +31,7 @@ pub mod key; pub mod mouse; pub mod sgr; pub mod style;+pub mod text_size;  pub use style::{     Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline, prompt_kind,diff --git a/crates/beer-protocols/src/text_size.rs b/crates/beer-protocols/src/text_size.rsnew file mode 100644index 0000000..74c4f23--- /dev/null+++ b/crates/beer-protocols/src/text_size.rs@@ -0,0 +1,249 @@+//! The kitty text-sizing protocol (`OSC 66`): rendering a run of text in a+//! block of cells larger than one, at a multiplied or fractional font size.+//!+//! The escape code is `OSC 66 ; metadata ; text ST`, where `metadata` is a+//! colon-separated list of `key=value` pairs. This module parses that metadata+//! into a [`TextSize`]; the dispatcher supplies the text and the grid lays it+//! out, because splitting a run into cells needs character-width tables and the+//! screen width that live with the terminal, not here.+//!+//! The keys, per the protocol:+//!+//! - `s` (1-7, default 1) - the overall scale. The run occupies a block `s * w`+//!   cells wide and `s` cells high, and the font size is multiplied by `s`.+//! - `w` (0-7, default 0) - the width in scaled cells. `0` means "split the run+//!   into cells as normal", each cell then being an `s` by `s` block.+//! - `n`, `d` (0-15, default 0) - numerator and denominator of a fractional+//!   scale applied on top of `s`. It changes the rendered font size but not the+//!   number of cells occupied. Alignment applies only when `n < d`.+//! - `v` - vertical alignment of the fractional render area in its block.+//! - `h` - horizontal alignment of the fractional render area in its block.++/// Vertical placement of a fractionally-scaled render area within its cell+/// block. Only meaningful when the fraction `n/d` is below 1; `Top` is the+/// default (a superscript), `Bottom` a subscript, `Middle` centred.+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum VAlign {+    #[default]+    Top,+    Bottom,+    Middle,+}++/// Horizontal placement of a fractionally-scaled render area within its cell+/// block. `Left` is the default.+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum HAlign {+    #[default]+    Left,+    Right,+    Center,+}++/// Parsed `OSC 66` metadata: the size and alignment of one multicell text run.+///+/// A value equal to [`TextSize::default`] (`s=1, w=0, n=0, d=0`) describes+/// ordinary text and needs no special handling.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub struct TextSize {+    /// `s`: overall integer scale, clamped to 1-7.+    pub scale: u8,+    /// `w`: width in scaled cells, clamped to 0-7. `0` means split as normal.+    pub width: u8,+    /// `n`: fractional-scale numerator, clamped to 0-15.+    pub numerator: u8,+    /// `d`: fractional-scale denominator, clamped to 0-15.+    pub denominator: u8,+    /// `v`: vertical alignment of the fractional render area.+    pub valign: VAlign,+    /// `h`: horizontal alignment of the fractional render area.+    pub halign: HAlign,+}++impl Default for TextSize {+    fn default() -> Self {+        Self {+            scale: 1,+            width: 0,+            numerator: 0,+            denominator: 0,+            valign: VAlign::default(),+            halign: HAlign::default(),+        }+    }+}++impl TextSize {+    /// Whether this run is ordinary text needing no scaled layout: scale 1, no+    /// explicit width, and no fractional scale.+    pub fn is_plain(&self) -> bool {+        self.scale == 1 && self.width == 0 && !self.has_fraction()+    }++    /// Whether a fractional scale `n/d` (with `n < d`) is in effect. Only then+    /// does the font shrink within the block and does alignment matter.+    pub fn has_fraction(&self) -> bool {+        self.numerator > 0 && self.denominator > 0 && self.numerator < self.denominator+    }++    /// The factor to multiply the base font size by when rendering this run:+    /// the integer scale `s`, reduced by the fraction `n/d` when one is set.+    pub fn font_scale(&self) -> f32 {+        if self.has_fraction() {+            self.scale as f32 * self.numerator as f32 / self.denominator as f32+        } else {+            self.scale as f32+        }+    }++    /// The height of the cell block, in cells: `s`.+    pub fn cell_height(&self) -> usize {+        self.scale as usize+    }++    /// Parse from a `&str` of metadata; convenience over [`parse`] when the+    /// caller already holds a string rather than bytes.+    pub fn parse_str(meta: &str) -> Self {+        parse(meta.as_bytes())+    }+}++/// Parse the colon-separated `OSC 66` metadata field into a [`TextSize`].+///+/// Unknown keys and malformed pairs are ignored; out-of-range integers are+/// clamped to the protocol's documented bounds. An empty field yields the+/// default (ordinary text).+pub fn parse(meta: &[u8]) -> TextSize {+    let mut ts = TextSize::default();+    if meta.is_empty() {+        return ts;+    }+    for pair in meta.split(|&b| b == b':') {+        let Some(eq) = pair.iter().position(|&b| b == b'=') else {+            continue;+        };+        let key = pair[0];+        let Some(value) = parse_u32(&pair[eq + 1..]) else {+            continue;+        };+        match key {+            b's' => ts.scale = value.clamp(1, 7) as u8,+            b'w' => ts.width = value.min(7) as u8,+            b'n' => ts.numerator = value.min(15) as u8,+            b'd' => ts.denominator = value.min(15) as u8,+            b'v' => {+                ts.valign = match value {+                    1 => VAlign::Bottom,+                    2 => VAlign::Middle,+                    _ => VAlign::Top,+                }+            }+            b'h' => {+                ts.halign = match value {+                    1 => HAlign::Right,+                    2 => HAlign::Center,+                    _ => HAlign::Left,+                }+            }+            _ => {}+        }+    }+    ts+}++/// Parse a run of ASCII digits into a `u32`, saturating on overflow. Returns+/// `None` if the slice is empty or holds a non-digit.+fn parse_u32(bytes: &[u8]) -> Option<u32> {+    if bytes.is_empty() {+        return None;+    }+    let mut acc: u32 = 0;+    for &b in bytes {+        let d = b.checked_sub(b'0').filter(|&d| d < 10)?;+        acc = acc.saturating_mul(10).saturating_add(d as u32);+    }+    Some(acc)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn empty_is_plain_default() {+        let ts = parse(b"");+        assert_eq!(ts, TextSize::default());+        assert!(ts.is_plain());+        assert_eq!(ts.font_scale(), 1.0);+    }++    #[test]+    fn scale_doubles_font_and_height() {+        let ts = parse(b"s=2");+        assert_eq!(ts.scale, 2);+        assert_eq!(ts.font_scale(), 2.0);+        assert_eq!(ts.cell_height(), 2);+        assert!(!ts.is_plain());+    }++    #[test]+    fn width_and_scale_together() {+        let ts = parse(b"s=2:w=3");+        assert_eq!((ts.scale, ts.width), (2, 3));+    }++    #[test]+    fn superscript_is_half_size_top_aligned() {+        // `n=1:d=2` with default scale: half-size font, top of the cell.+        let ts = parse(b"n=1:d=2");+        assert!(ts.has_fraction());+        assert_eq!(ts.font_scale(), 0.5);+        assert_eq!(ts.valign, VAlign::Top);+    }++    #[test]+    fn subscript_is_bottom_aligned() {+        let ts = parse(b"n=1:d=2:v=1");+        assert_eq!(ts.valign, VAlign::Bottom);+        assert_eq!(ts.font_scale(), 0.5);+    }++    #[test]+    fn centred_normal_size_in_double_block() {+        // `s=2:n=1:d=2:v=2`: full-size font (2 * 1/2) centred in a 2-high block.+        let ts = parse(b"s=2:n=1:d=2:v=2");+        assert_eq!(ts.font_scale(), 1.0);+        assert_eq!(ts.valign, VAlign::Middle);+        assert_eq!(ts.cell_height(), 2);+    }++    #[test]+    fn fraction_at_or_above_one_does_not_shrink() {+        // n >= d means no reduction and no alignment.+        let ts = parse(b"s=3:n=2:d=2");+        assert!(!ts.has_fraction());+        assert_eq!(ts.font_scale(), 3.0);+    }++    #[test]+    fn out_of_range_values_clamp() {+        assert_eq!(parse(b"s=99").scale, 7);+        assert_eq!(parse(b"s=0").scale, 1);+        assert_eq!(parse(b"w=42").width, 7);+        assert_eq!(parse(b"n=200:d=200").numerator, 15);+    }++    #[test]+    fn unknown_and_malformed_pairs_are_ignored() {+        let ts = parse(b"s=2:zzz=9:bad:=5:w=");+        assert_eq!(ts.scale, 2);+        assert_eq!(ts.width, 0);+    }++    #[test]+    fn horizontal_alignment() {+        assert_eq!(parse(b"h=1").halign, HAlign::Right);+        assert_eq!(parse(b"h=2").halign, HAlign::Center);+        assert_eq!(parse(b"h=0").halign, HAlign::Left);+    }+}