brewery
notashelf /
3775e25cdffdf83766a39887521084fa6f7c2844

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git
crates/beer-protocols/src/text_size.rsRust249 lines8.1 KB
1
//! The kitty text-sizing protocol (`OSC 66`): rendering a run of text in a
2
//! block of cells larger than one, at a multiplied or fractional font size.
3
//!
4
//! The escape code is `OSC 66 ; metadata ; text ST`, where `metadata` is a
5
//! colon-separated list of `key=value` pairs. This module parses that metadata
6
//! into a [`TextSize`]; the dispatcher supplies the text and the grid lays it
7
//! out, because splitting a run into cells needs character-width tables and the
8
//! screen width that live with the terminal, not here.
9
//!
10
//! The keys, per the protocol:
11
//!
12
//! - `s` (1-7, default 1) - the overall scale. The run occupies a block `s * w`
13
//!   cells wide and `s` cells high, and the font size is multiplied by `s`.
14
//! - `w` (0-7, default 0) - the width in scaled cells. `0` means "split the run
15
//!   into cells as normal", each cell then being an `s` by `s` block.
16
//! - `n`, `d` (0-15, default 0) - numerator and denominator of a fractional
17
//!   scale applied on top of `s`. It changes the rendered font size but not the
18
//!   number of cells occupied. Alignment applies only when `n < d`.
19
//! - `v` - vertical alignment of the fractional render area in its block.
20
//! - `h` - horizontal alignment of the fractional render area in its block.
21
22
/// Vertical placement of a fractionally-scaled render area within its cell
23
/// block. Only meaningful when the fraction `n/d` is below 1; `Top` is the
24
/// default (a superscript), `Bottom` a subscript, `Middle` centred.
25
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
26
pub enum VAlign {
27
    #[default]
28
    Top,
29
    Bottom,
30
    Middle,
31
}
32
33
/// Horizontal placement of a fractionally-scaled render area within its cell
34
/// block. `Left` is the default.
35
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
36
pub enum HAlign {
37
    #[default]
38
    Left,
39
    Right,
40
    Center,
41
}
42
43
/// Parsed `OSC 66` metadata: the size and alignment of one multicell text run.
44
///
45
/// A value equal to [`TextSize::default`] (`s=1, w=0, n=0, d=0`) describes
46
/// ordinary text and needs no special handling.
47
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
48
pub struct TextSize {
49
    /// `s`: overall integer scale, clamped to 1-7.
50
    pub scale: u8,
51
    /// `w`: width in scaled cells, clamped to 0-7. `0` means split as normal.
52
    pub width: u8,
53
    /// `n`: fractional-scale numerator, clamped to 0-15.
54
    pub numerator: u8,
55
    /// `d`: fractional-scale denominator, clamped to 0-15.
56
    pub denominator: u8,
57
    /// `v`: vertical alignment of the fractional render area.
58
    pub valign: VAlign,
59
    /// `h`: horizontal alignment of the fractional render area.
60
    pub halign: HAlign,
61
}
62
63
impl Default for TextSize {
64
    fn default() -> Self {
65
        Self {
66
            scale: 1,
67
            width: 0,
68
            numerator: 0,
69
            denominator: 0,
70
            valign: VAlign::default(),
71
            halign: HAlign::default(),
72
        }
73
    }
74
}
75
76
impl TextSize {
77
    /// Whether this run is ordinary text needing no scaled layout: scale 1, no
78
    /// explicit width, and no fractional scale.
79
    pub fn is_plain(&self) -> bool {
80
        self.scale == 1 && self.width == 0 && !self.has_fraction()
81
    }
82
83
    /// Whether a fractional scale `n/d` (with `n < d`) is in effect. Only then
84
    /// does the font shrink within the block and does alignment matter.
85
    pub fn has_fraction(&self) -> bool {
86
        self.numerator > 0 && self.denominator > 0 && self.numerator < self.denominator
87
    }
88
89
    /// The factor to multiply the base font size by when rendering this run:
90
    /// the integer scale `s`, reduced by the fraction `n/d` when one is set.
91
    pub fn font_scale(&self) -> f32 {
92
        if self.has_fraction() {
93
            self.scale as f32 * self.numerator as f32 / self.denominator as f32
94
        } else {
95
            self.scale as f32
96
        }
97
    }
98
99
    /// The height of the cell block, in cells: `s`.
100
    pub fn cell_height(&self) -> usize {
101
        self.scale as usize
102
    }
103
104
    /// Parse from a `&str` of metadata; convenience over [`parse`] when the
105
    /// caller already holds a string rather than bytes.
106
    pub fn parse_str(meta: &str) -> Self {
107
        parse(meta.as_bytes())
108
    }
109
}
110
111
/// Parse the colon-separated `OSC 66` metadata field into a [`TextSize`].
112
///
113
/// Unknown keys and malformed pairs are ignored; out-of-range integers are
114
/// clamped to the protocol's documented bounds. An empty field yields the
115
/// default (ordinary text).
116
pub fn parse(meta: &[u8]) -> TextSize {
117
    let mut ts = TextSize::default();
118
    if meta.is_empty() {
119
        return ts;
120
    }
121
    for pair in meta.split(|&b| b == b':') {
122
        let Some(eq) = pair.iter().position(|&b| b == b'=') else {
123
            continue;
124
        };
125
        let key = pair[0];
126
        let Some(value) = parse_u32(&pair[eq + 1..]) else {
127
            continue;
128
        };
129
        match key {
130
            b's' => ts.scale = value.clamp(1, 7) as u8,
131
            b'w' => ts.width = value.min(7) as u8,
132
            b'n' => ts.numerator = value.min(15) as u8,
133
            b'd' => ts.denominator = value.min(15) as u8,
134
            b'v' => {
135
                ts.valign = match value {
136
                    1 => VAlign::Bottom,
137
                    2 => VAlign::Middle,
138
                    _ => VAlign::Top,
139
                }
140
            }
141
            b'h' => {
142
                ts.halign = match value {
143
                    1 => HAlign::Right,
144
                    2 => HAlign::Center,
145
                    _ => HAlign::Left,
146
                }
147
            }
148
            _ => {}
149
        }
150
    }
151
    ts
152
}
153
154
/// Parse a run of ASCII digits into a `u32`, saturating on overflow. Returns
155
/// `None` if the slice is empty or holds a non-digit.
156
fn parse_u32(bytes: &[u8]) -> Option<u32> {
157
    if bytes.is_empty() {
158
        return None;
159
    }
160
    let mut acc: u32 = 0;
161
    for &b in bytes {
162
        let d = b.checked_sub(b'0').filter(|&d| d < 10)?;
163
        acc = acc.saturating_mul(10).saturating_add(d as u32);
164
    }
165
    Some(acc)
166
}
167
168
#[cfg(test)]
169
mod tests {
170
    use super::*;
171
172
    #[test]
173
    fn empty_is_plain_default() {
174
        let ts = parse(b"");
175
        assert_eq!(ts, TextSize::default());
176
        assert!(ts.is_plain());
177
        assert_eq!(ts.font_scale(), 1.0);
178
    }
179
180
    #[test]
181
    fn scale_doubles_font_and_height() {
182
        let ts = parse(b"s=2");
183
        assert_eq!(ts.scale, 2);
184
        assert_eq!(ts.font_scale(), 2.0);
185
        assert_eq!(ts.cell_height(), 2);
186
        assert!(!ts.is_plain());
187
    }
188
189
    #[test]
190
    fn width_and_scale_together() {
191
        let ts = parse(b"s=2:w=3");
192
        assert_eq!((ts.scale, ts.width), (2, 3));
193
    }
194
195
    #[test]
196
    fn superscript_is_half_size_top_aligned() {
197
        // `n=1:d=2` with default scale: half-size font, top of the cell.
198
        let ts = parse(b"n=1:d=2");
199
        assert!(ts.has_fraction());
200
        assert_eq!(ts.font_scale(), 0.5);
201
        assert_eq!(ts.valign, VAlign::Top);
202
    }
203
204
    #[test]
205
    fn subscript_is_bottom_aligned() {
206
        let ts = parse(b"n=1:d=2:v=1");
207
        assert_eq!(ts.valign, VAlign::Bottom);
208
        assert_eq!(ts.font_scale(), 0.5);
209
    }
210
211
    #[test]
212
    fn centred_normal_size_in_double_block() {
213
        // `s=2:n=1:d=2:v=2`: full-size font (2 * 1/2) centred in a 2-high block.
214
        let ts = parse(b"s=2:n=1:d=2:v=2");
215
        assert_eq!(ts.font_scale(), 1.0);
216
        assert_eq!(ts.valign, VAlign::Middle);
217
        assert_eq!(ts.cell_height(), 2);
218
    }
219
220
    #[test]
221
    fn fraction_at_or_above_one_does_not_shrink() {
222
        // n >= d means no reduction and no alignment.
223
        let ts = parse(b"s=3:n=2:d=2");
224
        assert!(!ts.has_fraction());
225
        assert_eq!(ts.font_scale(), 3.0);
226
    }
227
228
    #[test]
229
    fn out_of_range_values_clamp() {
230
        assert_eq!(parse(b"s=99").scale, 7);
231
        assert_eq!(parse(b"s=0").scale, 1);
232
        assert_eq!(parse(b"w=42").width, 7);
233
        assert_eq!(parse(b"n=200:d=200").numerator, 15);
234
    }
235
236
    #[test]
237
    fn unknown_and_malformed_pairs_are_ignored() {
238
        let ts = parse(b"s=2:zzz=9:bad:=5:w=");
239
        assert_eq!(ts.scale, 2);
240
        assert_eq!(ts.width, 0);
241
    }
242
243
    #[test]
244
    fn horizontal_alignment() {
245
        assert_eq!(parse(b"h=1").halign, HAlign::Right);
246
        assert_eq!(parse(b"h=2").halign, HAlign::Center);
247
        assert_eq!(parse(b"h=0").halign, HAlign::Left);
248
    }
249
}