| 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 | } |