beer-protocols: add the kitty text-sizing (OSC 66) parser

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I420f4232071d54228ed7b88faa8d97596a6a6964
This commit is contained in:
raf 2026-06-26 21:48:04 +03:00
commit f818019ce1
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
2 changed files with 251 additions and 0 deletions

View file

@ -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,

View file

@ -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);
}
}