diff --git a/crates/beer/src/font.rs b/crates/beer/src/font.rs index d232707..0f66047 100644 --- a/crates/beer/src/font.rs +++ b/crates/beer/src/font.rs @@ -221,6 +221,21 @@ impl Fonts { Ok(self.gcache.get(&key).expect("glyph was just inserted")) } + /// Rasterize `c` in `style` at `scale` times the base size, uncached. + /// + /// Scaled glyphs come from the text-sizing protocol (`OSC 66`); they are + /// rare and transient, so they bypass the glyph cache. The scale is applied + /// as an outline transform, which leaves the face's configured pixel size - + /// and therefore the cell metrics every other glyph depends on - untouched. + /// Embedded-bitmap (colour) glyphs ignore the transform; the caller scales + /// those at blit time instead. + pub fn glyph_scaled(&mut self, c: char, style: Style, scale: f32) -> Result { + let idx = self.face_for(c, style)?; + let face = &self.faces[idx].face; + let (synth_bold, synth_italic) = synth_flags(face, style); + rasterize_scaled(face, c, scale.max(0.01), synth_bold, synth_italic) + } + /// Shape `base` plus its combining `marks` into positioned glyphs using /// HarfBuzz, so marks land where the font's GPOS table wants them rather /// than stacked at the origin. Returns `None` when shaping is unavailable or @@ -473,6 +488,58 @@ fn rasterize_index( }) } +/// Rasterize `c` with the outline scaled by `scale` (and sheared if italic is +/// synthesized). The transform is reset before returning so the face is left as +/// it was found. +fn rasterize_scaled( + face: &Face, + c: char, + scale: f32, + synth_bold: bool, + synth_italic: bool, +) -> Result { + // A scale matrix on the diagonal, in 16.16 fixed point; the off-diagonal + // `xy` term shears for a synthetic italic (~0.2 of the glyph height). `as _` + // takes the field's `FT_Fixed` type, as the identity/shear matrices do. + let mut matrix = Matrix { + xx: (scale * 65536.0).round() as _, + xy: if synth_italic { + (scale * 0.2 * 65536.0).round() as _ + } else { + 0 + }, + yx: 0, + yy: (scale * 65536.0).round() as _, + }; + face.set_transform(&mut matrix, &mut Vector { x: 0, y: 0 }); + let result = face.load_char(c as usize, LoadFlag::RENDER | LoadFlag::COLOR); + face.set_transform(&mut identity_matrix(), &mut Vector { x: 0, y: 0 }); + result?; + + let slot = face.glyph(); + let bitmap = slot.bitmap(); + let width = bitmap.width().max(0) as usize; + let height = bitmap.rows().max(0) as usize; + let pitch = bitmap.pitch(); + let src = bitmap.buffer(); + let mut data = match bitmap.pixel_mode()? { + PixelMode::Gray => GlyphData::Mask(pack_rows(src, width, pitch, height)), + PixelMode::Bgra => GlyphData::Color(pack_rows(src, width * 4, pitch, height)), + PixelMode::Mono => GlyphData::Mask(expand_mono(src, width, pitch, height)), + _ => GlyphData::Mask(vec![0; width * height]), + }; + if synth_bold && let GlyphData::Mask(mask) = &mut data { + embolden(mask, width, height); + } + Ok(Glyph { + left: slot.bitmap_left(), + top: slot.bitmap_top(), + width: width as u32, + height: height as u32, + data, + }) +} + fn rasterize_with( face: &Face, synth_bold: bool, diff --git a/crates/beer/src/grid/mod.rs b/crates/beer/src/grid/mod.rs index ef06572..b1cf307 100644 --- a/crates/beer/src/grid/mod.rs +++ b/crates/beer/src/grid/mod.rs @@ -19,6 +19,7 @@ const SCROLLBACK_CAP: usize = 10_000; /// The protocol vocabulary an SGR/DECSET stream selects lives in /// `beer-protocols` and is re-exported here so the grid and renderer keep /// referring to it as `grid::Color`, `grid::Underline`, and so on. +pub use beer_protocols::text_size::TextSize; pub use beer_protocols::{Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline}; /// Per-cell style flags, packed into a `u16`. @@ -36,6 +37,9 @@ impl Flags { /// Trailing column of a double-width glyph; holds no character of its own. pub const WIDE_CONT: Self = Self(1 << 8); pub const OVERLINE: Self = Self(1 << 9); + /// A cell of a text-sizing (`OSC 66`) block that is not the block's leading + /// cell; it holds no character of its own and is drawn by the leading cell. + pub const SIZED_CONT: Self = Self(1 << 10); pub const fn empty() -> Self { Self(0) @@ -58,6 +62,31 @@ impl Flags { } } +/// The text-sizing (`OSC 66`) descriptor a scaled cell carries. A run is drawn +/// as a block `cols` cells wide and `rows` high; this cell sits at `(dx, dy)` +/// within it. The leading cell (`dx == 0, dy == 0`) carries the run text and is +/// what the renderer draws; the rest are flagged [`Flags::SIZED_CONT`]. +/// +/// Boxed on the cell so the common, unscaled path keeps `Cell` lean: only the +/// rare scaled cell pays an allocation. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Sized { + /// Scale, fractional scale, and alignment parsed from the metadata. + pub size: TextSize, + /// Block width in cells: `s * w`, or `s * grapheme_width` when `w == 0`. + pub cols: u8, + /// Block height in cells: `s`. + pub rows: u8, + /// This cell's column within the block. + pub dx: u8, + /// This cell's row within the block. + pub dy: u8, + /// The run text, on the leading cell only. For the per-grapheme (`w == 0`) + /// form this is `None` and the grapheme lives in the cell's `c`/`combining`; + /// for the packed (`w > 0`) form it holds the whole run. + pub run: Option>, +} + /// One grid cell: a character plus its rendering style. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Cell { @@ -74,6 +103,8 @@ pub struct Cell { pub combining: Option>, /// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`. pub link: Option, + /// Text-sizing block membership (`OSC 66`), or `None` for ordinary cells. + pub sized: Option>, } impl Default for Cell { @@ -87,6 +118,7 @@ impl Default for Cell { underline_color: Color::Default, combining: None, link: None, + sized: None, } } } @@ -210,6 +242,24 @@ fn is_word(c: char, delims: &str) -> bool { !c.is_whitespace() && !delims.contains(c) } +/// Split text into grapheme-ish units for `OSC 66 w=0` layout: each base +/// character with its trailing zero-width combining marks and display width. +/// Leading combining marks with no base are dropped, as in normal printing. +fn graphemes(text: &str) -> Vec<(char, String, usize)> { + let mut out: Vec<(char, String, usize)> = Vec::new(); + for c in text.chars() { + match c.width().unwrap_or(0) { + 0 => { + if let Some(last) = out.last_mut() { + last.1.push(c); + } + } + w => out.push((c, String::new(), w)), + } + } + out +} + impl Grid { pub fn new(cols: usize, rows: usize) -> Self { let cols = cols.max(1); @@ -314,6 +364,7 @@ impl Grid { pub fn resize(&mut self, cols: usize, rows: usize) { let cols = cols.max(1); let rows = rows.max(1); + self.clear_sized_runs(); if self.alt_saved.is_some() { self.clip_resize(cols, rows); } else { @@ -560,9 +611,16 @@ impl Grid { } let (x, y) = (self.cursor.x, self.cursor.y); + // Overwriting any cell of a text-sizing block dissolves the whole block, + // so no orphaned continuation cells are left for the renderer to draw. + self.clear_sized_at(x, y); + if width == 2 && x + 1 < self.cols { + self.clear_sized_at(x + 1, y); + } let mut cell = self.pen.clone(); cell.c = c; cell.combining = None; + cell.sized = None; cell.flags.remove(Flags::WIDE_CONT); self.lines[y].cells[x] = cell; self.last_base = Some((x, y)); @@ -570,6 +628,7 @@ impl Grid { let mut cont = self.pen.clone(); cont.c = ' '; cont.combining = None; + cont.sized = None; cont.flags.insert(Flags::WIDE_CONT); self.lines[y].cells[x + 1] = cont; } @@ -604,6 +663,137 @@ impl Grid { cell.combining = Some(s.into_boxed_str()); } + /// Lay out a text-sizing run (`OSC 66`) as scaled multicell blocks at the + /// cursor, advancing it on the same row by the total block width, as the + /// protocol requires. With `width == 0` each grapheme gets its own `s` by `s` + /// block (the font scaled by `s`); with `width > 0` the whole run is packed + /// into one block `s * width` cells wide and `s` high. A plain descriptor + /// (scale 1, no width, no fraction) falls back to ordinary printing. + pub fn print_sized(&mut self, text: &str, size: TextSize) { + if size.is_plain() { + for c in text.chars() { + self.print(c); + } + return; + } + let rows = size.cell_height().clamp(1, self.rows); + if size.width == 0 { + for (base, marks, w) in graphemes(text) { + let cols = (rows * w).clamp(1, self.cols); + self.place_block(base, marks, None, size, cols, rows); + } + } else { + let cols = (rows * size.width as usize).clamp(1, self.cols); + self.place_block(' ', String::new(), Some(text.into()), size, cols, rows); + } + } + + /// Write one scaled block of `cols` by `rows` cells at the cursor and + /// advance past it. The leading cell carries the text; the rest are flagged + /// [`Flags::SIZED_CONT`]. A block that will not fit wraps to the next line. + fn place_block( + &mut self, + lead: char, + marks: String, + run: Option>, + size: TextSize, + cols: usize, + rows: usize, + ) { + if self.wrap_pending || (self.autowrap && self.cursor.x + cols > self.cols) { + self.cursor.x = 0; + self.lines[self.cursor.y].wrapped = true; + self.line_feed(); + self.wrap_pending = false; + } + let (x0, y0) = (self.cursor.x, self.cursor.y); + let marks = (!marks.is_empty()).then(|| marks.into_boxed_str()); + for dy in 0..rows { + let cy = y0 + dy; + if cy >= self.rows { + break; + } + for dx in 0..cols { + let cx = x0 + dx; + if cx >= self.cols { + break; + } + // Dissolve any block already occupying this cell before reusing it. + self.clear_sized_at(cx, cy); + let lead_cell = dx == 0 && dy == 0; + let mut cell = self.pen.clone(); + cell.c = if lead_cell { lead } else { ' ' }; + cell.combining = if lead_cell { marks.clone() } else { None }; + cell.flags.remove(Flags::WIDE_CONT); + if !lead_cell { + cell.flags.insert(Flags::SIZED_CONT); + } + cell.sized = Some(Box::new(Sized { + size, + cols: cols as u8, + rows: rows as u8, + dx: dx as u8, + dy: dy as u8, + run: if lead_cell { run.clone() } else { None }, + })); + self.lines[cy].cells[cx] = cell; + } + } + self.last_base = None; + if self.cursor.x + cols >= self.cols { + self.cursor.x = self.cols - 1; + self.wrap_pending = self.autowrap; + } else { + self.cursor.x += cols; + } + } + + /// If `(x, y)` belongs to a text-sizing block, blank every cell of that + /// block so a write into it cannot orphan continuation cells. + fn clear_sized_at(&mut self, x: usize, y: usize) { + let Some(s) = self + .lines + .get(y) + .and_then(|l| l.cells.get(x)) + .and_then(|c| c.sized.as_deref()) + else { + return; + }; + let (dx, dy, cols, rows) = ( + s.dx as usize, + s.dy as usize, + s.cols as usize, + s.rows as usize, + ); + let x0 = x.saturating_sub(dx); + let y0 = y.saturating_sub(dy); + let blank = self.pen_blank(); + let x_end = (x0 + cols).min(self.cols); + for by in y0..(y0 + rows).min(self.rows) { + for cell in &mut self.lines[by].cells[x0..x_end] { + *cell = blank.clone(); + } + } + } + + /// Drop all text-sizing blocks: their cells become plain characters. Used + /// before a resize, since a scaled block must not be split across a rewrap; + /// applications using `OSC 66` repaint on resize regardless. + fn clear_sized_runs(&mut self) { + let strip = |line: &mut Line| { + for cell in &mut line.cells { + if cell.sized.take().is_some() { + cell.flags.remove(Flags::SIZED_CONT); + } + } + }; + self.lines.iter_mut().for_each(strip); + self.scrollback.iter_mut().for_each(strip); + if let Some(alt) = self.alt_saved.as_mut() { + alt.iter_mut().for_each(strip); + } + } + fn shift_right(&mut self, n: usize) { let (x, y) = (self.cursor.x, self.cursor.y); let end = self.cols; @@ -982,6 +1172,29 @@ impl Grid { } } + /// For a viewport cell `(y, x)` that is the left edge of a text-sizing block + /// (its `dx == 0`), return the block's leading cell - which holds the text + /// and the full descriptor - together with this row's `dy` within the block. + /// `None` if `(y, x)` is not a left-edge sized cell, or the leading row is + /// scrolled above the viewport top (the block is then clipped, not drawn). + pub fn sized_lead(&self, y: usize, x: usize) -> Option<(&Cell, usize)> { + let dy = { + let s = self.view_row(y).get(x)?.sized.as_ref()?; + if s.dx != 0 { + return None; + } + s.dy as usize + }; + if dy > y { + return None; + } + let lead = self.view_row(y - dy).get(x)?; + lead.sized + .as_ref() + .filter(|ls| ls.dx == 0 && ls.dy == 0) + .map(|_| (lead, dy)) + } + // --- selection --- /// The absolute row currently shown at viewport row `y`. @@ -1130,7 +1343,7 @@ impl Grid { self.lines[y] .cells .iter() - .filter(|c| !c.flags.contains(Flags::WIDE_CONT)) + .filter(|c| !c.flags.contains(Flags::WIDE_CONT) && !c.flags.contains(Flags::SIZED_CONT)) .map(|c| c.c) .collect::() .trim_end() @@ -1299,6 +1512,61 @@ mod tests { assert_eq!(g.view_row(0)[0].c, '3'); } + #[test] + fn sized_scale_lays_out_a_block_and_advances() { + // `s=2` with width 0: 'X' fills a 2x2 block, cursor advances 2 cells. + let mut g = Grid::new(8, 4); + g.print_sized("X", TextSize::parse_str("s=2")); + let lead = g.cell(0, 0); + assert_eq!(lead.c, 'X'); + assert!(!lead.flags.contains(Flags::SIZED_CONT)); + let s = lead.sized.as_ref().expect("leading cell is sized"); + assert_eq!((s.cols, s.rows, s.dx, s.dy), (2, 2, 0, 0)); + // The other three cells of the block are continuations. + for (x, y) in [(1, 0), (0, 1), (1, 1)] { + assert!(g.cell(x, y).flags.contains(Flags::SIZED_CONT)); + } + assert_eq!(g.cursor(), (2, 0)); + } + + #[test] + fn sized_packed_run_round_trips_as_text() { + // `w=1` packs the whole run into one cell; selection yields it whole. + let mut g = Grid::new(8, 2); + g.print_sized("ab", TextSize::parse_str("n=1:d=2:w=1")); + assert!(g.cell(0, 0).sized.as_ref().unwrap().run.is_some()); + g.start_selection(0, 0); + g.extend_selection(0, 0); + assert_eq!(g.selection_text().as_deref(), Some("ab")); + assert_eq!(g.cursor(), (1, 0)); + } + + #[test] + fn overwriting_a_sized_block_dissolves_it() { + let mut g = Grid::new(8, 4); + g.print_sized("X", TextSize::parse_str("s=2")); + // Print a normal char into a continuation cell of the block. + g.move_to(1, 1); + g.print('z'); + // The whole block is gone: no cell still claims to be sized. + for y in 0..2 { + for x in 0..2 { + assert!(g.cell(x, y).sized.is_none(), "({x},{y}) still sized"); + assert!(!g.cell(x, y).flags.contains(Flags::SIZED_CONT)); + } + } + assert_eq!(g.cell(1, 1).c, 'z'); + } + + #[test] + fn resize_dissolves_sized_runs() { + let mut g = Grid::new(8, 4); + g.print_sized("X", TextSize::parse_str("s=2")); + g.resize(6, 4); + assert!(g.cell(0, 0).sized.is_none()); + assert!(!g.cell(1, 0).flags.contains(Flags::SIZED_CONT)); + } + #[test] fn wide_char_occupies_two_columns() { let mut g = Grid::new(6, 1); diff --git a/crates/beer/src/grid/selection.rs b/crates/beer/src/grid/selection.rs index 74d1fe3..a3bcbd8 100644 --- a/crates/beer/src/grid/selection.rs +++ b/crates/beer/src/grid/selection.rs @@ -169,8 +169,9 @@ impl Grid { Some(out) } - /// The characters of an absolute row in `[from, to)`, skipping wide - /// continuation cells. + /// The characters of an absolute row in `[from, to)`, skipping wide and + /// text-sizing continuation cells. A leading text-sizing cell that holds a + /// packed run (`OSC 66 w>0`) contributes that whole run. pub(super) fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String { let mut out = String::new(); for cell in self @@ -178,8 +179,12 @@ impl Grid { .get(from..to.min(self.abs_row(row).len())) .unwrap_or(&[]) .iter() - .filter(|c| !c.flags.contains(Flags::WIDE_CONT)) + .filter(|c| !c.flags.contains(Flags::WIDE_CONT) && !c.flags.contains(Flags::SIZED_CONT)) { + if let Some(run) = cell.sized.as_ref().and_then(|s| s.run.as_ref()) { + out.push_str(run); + continue; + } out.push(cell.c); if let Some(marks) = &cell.combining { out.push_str(marks); diff --git a/crates/beer/src/render.rs b/crates/beer/src/render.rs index d7206f0..b64bdd7 100644 --- a/crates/beer/src/render.rs +++ b/crates/beer/src/render.rs @@ -7,6 +7,8 @@ use std::num::NonZeroU16; +use beer_protocols::text_size::{HAlign, VAlign}; + use crate::font::{CellMetrics, Fonts, Glyph, GlyphData, Style}; use crate::grid::{Cell, CursorShape, Flags, Grid, Underline}; use crate::theme::{Plane, Rgb, Theme}; @@ -200,6 +202,18 @@ impl Renderer { if cell.flags.contains(Flags::WIDE_CONT) { continue; } + // Text-sizing (OSC 66) blocks are drawn from their per-row left edge, + // clipped to this row's band; every other block cell is skipped. + if let Some(sized) = &cell.sized { + if sized.dx == 0 + && let Some((lead, dy)) = grid.sized_lead(y, x) + { + let origin_x = pad_x + x as i32 * m.width as i32; + let (fg, _) = cell_colors(lead, theme); + self.draw_sized(&mut canvas, lead, dy, (origin_x, row_top), m, fg); + } + continue; + } if cell.flags.contains(Flags::BLINK) && !blink_on { continue; } @@ -444,6 +458,125 @@ impl Renderer { }; blit_glyph(canvas, glyph, m, origin_x, cell_top, 0, fg); } + + /// Draw the slice of a text-sizing (`OSC 66`) block that falls in one row. + /// + /// `lead` is the block's leading cell (holding the run text); `dy` is this + /// row's offset within the block; `pos` is this row's left edge and top in + /// pixels. The full block is `cols * width` by `rows * height` pixels, + /// starting `dy` rows above the row top; glyphs are rasterized at the block's + /// font scale and clipped to this row's band, so the block paints correctly + /// across its several per-row repaints. + fn draw_sized( + &mut self, + canvas: &mut Canvas, + lead: &Cell, + dy: usize, + pos: (i32, i32), + m: CellMetrics, + fg: Rgb, + ) { + let (block_left_x, row_top) = pos; + let Some(s) = lead.sized.as_deref() else { + return; + }; + let scale = s.size.font_scale(); + let (cols, rows) = (s.cols as i32, s.rows as i32); + let block_top = row_top - dy as i32 * m.height as i32; + let (block_w, block_h) = (cols * m.width as i32, rows * m.height as i32); + let clip = (row_top, row_top + m.height as i32); + + let advance = (m.width as f32 * scale).round().max(1.0) as i32; + let render_h = (m.height as f32 * scale).round().max(1.0) as i32; + + // The run: a packed string (w>0) or the leading grapheme (w==0). + let run: Vec = match s.run.as_deref() { + Some(text) => text.chars().collect(), + None => vec![lead.c], + }; + let render_w = advance * run.len() as i32; + + // A fractional scale renders into an area smaller than the block, placed + // by the v/h alignment; a whole scale fills the block (offsets zero). + let (ox, oy) = if s.size.has_fraction() { + let ox = match s.size.halign { + HAlign::Left => 0, + HAlign::Right => block_w - render_w, + HAlign::Center => (block_w - render_w) / 2, + }; + let oy = match s.size.valign { + VAlign::Top => 0, + VAlign::Bottom => block_h - render_h, + VAlign::Middle => (block_h - render_h) / 2, + }; + (ox.max(0), oy.max(0)) + } else { + (0, 0) + }; + + let baseline = block_top + oy + (m.ascent as f32 * scale).round() as i32; + let style = cell_style(lead); + let mut pen_x = block_left_x + ox; + for c in run { + if c != ' ' + && let Ok(glyph) = self.fonts.glyph_scaled(c, style, scale) + { + blit_glyph_clipped(canvas, &glyph, pen_x, baseline, clip, fg, render_h); + } + pen_x += advance; + } + } +} + +/// Composite a rasterized glyph, clipping to the vertical band `clip = (y0, y1)` +/// so a tall text-sizing glyph paints only the part belonging to the current +/// row. Mask glyphs are tinted with `fg`; colour glyphs are scaled to +/// `target_h` (the outline transform does not scale embedded bitmaps). +fn blit_glyph_clipped( + canvas: &mut Canvas, + glyph: &Glyph, + pen_x: i32, + baseline: i32, + clip: (i32, i32), + fg: Rgb, + target_h: i32, +) { + let (gw, gh) = (glyph.width as i32, glyph.height as i32); + match &glyph.data { + GlyphData::Mask(mask) => { + for gy in 0..gh { + let py = baseline - glyph.top + gy; + if py < clip.0 || py >= clip.1 { + continue; + } + for gx in 0..gw { + let a = mask[(gy * gw + gx) as usize]; + if a != 0 { + canvas.blend(pen_x + glyph.left + gx, py, fg, a); + } + } + } + } + GlyphData::Color(bgra) if gh > 0 => { + let sc = target_h as f32 / gh as f32; + let tw = (gw as f32 * sc).round() as i32; + // Place the scaled bitmap so most of it sits above the baseline. + let top = baseline - target_h * 4 / 5; + for ty in 0..target_h { + let py = top + ty; + if py < clip.0 || py >= clip.1 { + continue; + } + let sy = ((ty as f32 / sc) as i32).min(gh - 1); + for tx in 0..tw { + let sx = ((tx as f32 / sc) as i32).min(gw - 1); + let i = ((sy * gw + sx) * 4) as usize; + canvas.over(pen_x + tx, py, &bgra[i..i + 4]); + } + } + } + GlyphData::Color(_) => {} + } } /// Composite a rasterized glyph into the canvas. `origin_x`/`cell_top` are the diff --git a/crates/beer/src/vt/mod.rs b/crates/beer/src/vt/mod.rs index 4021cc5..4039665 100644 --- a/crates/beer/src/vt/mod.rs +++ b/crates/beer/src/vt/mod.rs @@ -500,6 +500,19 @@ mod tests { assert_eq!(t.grid().row_text(1), "two"); } + #[test] + fn text_sizing_osc66_lays_out_a_scaled_block() { + // `OSC 66 ; s=2 ; X BEL`: a 2x2 scaled block, cursor advances two cells. + let mut t = Term::new(20, 4); + feed(&mut t, b"\x1b]66;s=2;X\x07"); + let g = t.grid(); + assert_eq!(g.cell(0, 0).c, 'X'); + let s = g.cell(0, 0).sized.as_ref().expect("leading cell is scaled"); + assert_eq!((s.cols, s.rows), (2, 2)); + assert!(g.cell(1, 1).flags.contains(crate::grid::Flags::SIZED_CONT)); + assert_eq!(g.cursor(), (2, 0)); + } + #[test] fn device_attributes_levels() { let mut t = Term::new(20, 4); diff --git a/crates/beer/src/vt/perform.rs b/crates/beer/src/vt/perform.rs index 202f29f..dd681da 100644 --- a/crates/beer/src/vt/perform.rs +++ b/crates/beer/src/vt/perform.rs @@ -225,6 +225,19 @@ impl Perform for Term { self.clipboard_ops.push(ClipboardOp::Set { primary, text }); } } + // OSC 66: kitty text-sizing protocol. `OSC 66 ; metadata ; text`, + // where metadata is a colon-separated key=value list and the text + // (which may itself contain ';') is rejoined and laid out scaled. + Some(&n) if n == b"66" => { + let size = beer_protocols::text_size::parse(params.get(1).copied().unwrap_or(b"")); + let text = params + .get(2..) + .map(|parts| parts.join(&b';')) + .unwrap_or_default(); + if let Ok(text) = std::str::from_utf8(&text) { + self.grid.print_sized(text, size); + } + } _ => {} } }