diff --git a/AGENTS.md b/AGENTS.md index 3f0787f..32eb87e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. - `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`. - `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance. -- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into shapes, `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. +- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. - `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title. - `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks. - `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them). @@ -43,5 +43,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: on Unix the pty is ELYTerminal's own: alacritty 0.26 ignores `chdir`'s result, so a session could start in the wrong folder. Its traits feed alacritty's event loop, keyed 0 and 1 as alacritty's private constants are; alacritty is pinned to =0.26.0 for that. - 2026-10-14: bash integration loads through `--rcfile`, not Ghostty's `--posix` and `ENV`: macOS's bash 3.2 ignores `ENV` there. bash then runs as a non-login shell; on macOS the script reads the login files itself, so `logout` and `~/.bash_logout` do not apply. - 2026-10-14: icons fall back by hand: a private-use character the font lacks paints in JetBrains Mono NF Mono. gpui's macOS fallbacks go through CoreText's cascade list, which never sees fonts added in memory, and gpui loads no font without an `m`, so the symbols-only Nerd Font cannot load. -- 2026-10-14: box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) follow Ghostty's sprite rules (MIT, `LICENSE-GHOSTTY`): whole device pixels, line thickness the underline's, each cell clipped to itself, so lines join across cells whatever the font. +- 2026-10-14: box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) follow Ghostty's sprite rules (MIT, `LICENSE-GHOSTTY`): whole device pixels, line thickness the underline's, each cell clipped to itself, so lines join across cells whatever the font. A character's pieces never overlap: gpui blends every quad and every path triangle on its own, so a dim line painted twice brightens where it crosses. A drawn base keeps its geometry under combining marks, which paint over a no-break space. - 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten. diff --git a/src/term/boxes.rs b/src/term/boxes.rs index d7f6b02..373a156 100644 --- a/src/term/boxes.rs +++ b/src/term/boxes.rs @@ -1,13 +1,9 @@ -/// One step of a path, in device pixels from the cell's top-left. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Step { - Move(f32, f32), - Line(f32, f32), - /// A cubic curve: two control points, then the end. - Curve([f32; 6]), -} +mod outline; -/// A piece of a drawn character, in device pixels from the cell's top-left. +/// A point in device pixels from the cell's top-left. +pub type Point = (f32, f32); + +/// A piece of a drawn character in device pixels from the cell's top-left; pieces never overlap. #[derive(Clone, Debug, PartialEq)] pub enum Shape { /// A rectangle filled at `alpha` of the text color. @@ -18,10 +14,8 @@ pub enum Shape { h: u32, alpha: f32, }, - /// A closed outline, filled. - Fill(Vec), - /// An open path, stroked this wide with butt ends. - Stroke(Vec, f32), + /// A simple polygon, filled. + Fill(Vec), } /// Whether the terminal draws `ch` as shapes instead of through the font. @@ -60,28 +54,65 @@ const QUADRANTS: [u8; 10] = [ 0b0010, 0b0001, 0b1000, 0b1011, 0b1001, 0b1110, 0b1101, 0b0100, 0b0110, 0b0111, ]; +/// A box by its corners: left, top, right, bottom. +type Corners = [u32; 4]; + /// The shapes of a `drawn` char in a `w` by `h` cell whose light lines are `t` thick. pub fn shapes(ch: char, w: u32, h: u32, t: u32) -> Vec { let code = ch as u32; match code { - 0x2504..=0x250B | 0x254C..=0x254F => dashes(code, w, h, t), - 0x256D..=0x2570 => arc(code, w, h, t), - 0x2571..=0x2573 => diagonals(code, w, h, t), - 0x2500..=0x257F => lines(LINES[(code - 0x2500) as usize], w, h, t), - 0x2580..=0x259F => blocks(code, w, h), - 0xE0B0..=0xE0B7 => powerline(code, w, h, t), + 0x2504..=0x250B | 0x254C..=0x254F => rects(dashes(code, w, h, t), 1.), + 0x256D..=0x2570 => outline::arc(code, w, h, t), + 0x2571..=0x2573 => outline::diagonals(code, w, h, t), + 0x2500..=0x257F => rects(lines(LINES[(code - 0x2500) as usize], w, h, t), 1.), + 0x2591..=0x2593 => rects(vec![[0, 0, w, h]], (code - 0x2590) as f32 / 4.), + 0x2580..=0x259F => rects(blocks(code, w, h), 1.), + 0xE0B0..=0xE0B7 => outline::powerline(code, w, h, t), _ => panic!("U+{code:04X} is not a drawn character"), } } -fn rect(x0: u32, y0: u32, x1: u32, y1: u32) -> Shape { - Shape::Rect { - x: x0, - y: y0, - w: x1.saturating_sub(x0), - h: y1.saturating_sub(y0), - alpha: 1., +/// Rects from boxes with their overlaps cut away, so each pixel takes the color once. +fn rects(boxes: Vec, alpha: f32) -> Vec { + let mut kept: Vec = Vec::new(); + for corners in boxes { + let mut pieces = vec![corners]; + for seen in &kept { + pieces = pieces + .into_iter() + .flat_map(|piece| outside(piece, *seen)) + .collect(); + } + kept.extend(pieces); } + kept.into_iter() + .filter(|[x0, y0, x1, y1]| x1 > x0 && y1 > y0) + .map(|[x0, y0, x1, y1]| Shape::Rect { + x: x0, + y: y0, + w: x1 - x0, + h: y1 - y0, + alpha, + }) + .collect() +} + +/// The parts of `piece` outside `cut`. +fn outside(piece: Corners, cut: Corners) -> Vec { + let ([x0, y0, x1, y1], [cx0, cy0, cx1, cy1]) = (piece, cut); + if cx0 >= x1 || cx1 <= x0 || cy0 >= y1 || cy1 <= y0 { + return vec![piece]; + } + let (top, bottom) = (cy0.max(y0), cy1.min(y1)); + [ + [x0, y0, x1, top], + [x0, bottom, x1, y1], + [x0, top, cx0.max(x0), bottom], + [cx1.min(x1), top, x1, bottom], + ] + .into_iter() + .filter(|[a, b, c, d]| c > a && d > b) + .collect() } /// Where an arm ends at the center, by the arms crossing it: `[heavy, double, light, short]`. @@ -102,7 +133,7 @@ fn stop((a, b): (u8, u8), straight: bool, [heavy, double, light, short]: [u32; 4 } /// Arms from the center to the edges, joined as Ghostty joins them. -fn lines([up, right, down, left]: [u8; 4], w: u32, h: u32, t: u32) -> Vec { +fn lines([up, right, down, left]: [u8; 4], w: u32, h: u32, t: u32) -> Vec { let heavy = 2 * t; let h_light_top = h.saturating_sub(t) / 2; let h_light_bottom = h_light_top + t; @@ -139,39 +170,34 @@ fn lines([up, right, down, left]: [u8; 4], w: u32, h: u32, t: u32) -> Vec let (top_double, bottom_double) = (up == DOUBLE, down == DOUBLE); let mut out = Vec::new(); match up { - LIGHT => out.push(rect(v_light_left, 0, v_light_right, up_bottom)), - HEAVY => out.push(rect(v_heavy_left, 0, v_heavy_right, up_bottom)), + LIGHT => out.push([v_light_left, 0, v_light_right, up_bottom]), + HEAVY => out.push([v_heavy_left, 0, v_heavy_right, up_bottom]), DOUBLE => { - let stop = |side| { + let end = |side| { if side == DOUBLE { h_light_top } else { up_bottom } }; - out.push(rect(v_double_left, 0, v_light_left, stop(left))); - out.push(rect(v_light_right, 0, v_double_right, stop(right))); + out.push([v_double_left, 0, v_light_left, end(left)]); + out.push([v_light_right, 0, v_double_right, end(right)]); } _ => {} } match right { - LIGHT => out.push(rect(right_left, h_light_top, w, h_light_bottom)), - HEAVY => out.push(rect(right_left, h_heavy_top, w, h_heavy_bottom)), + LIGHT => out.push([right_left, h_light_top, w, h_light_bottom]), + HEAVY => out.push([right_left, h_heavy_top, w, h_heavy_bottom]), DOUBLE => { let start = |double| if double { v_light_right } else { right_left }; - out.push(rect(start(top_double), h_double_top, w, h_light_top)); - out.push(rect( - start(bottom_double), - h_light_bottom, - w, - h_double_bottom, - )); + out.push([start(top_double), h_double_top, w, h_light_top]); + out.push([start(bottom_double), h_light_bottom, w, h_double_bottom]); } _ => {} } match down { - LIGHT => out.push(rect(v_light_left, down_top, v_light_right, h)), - HEAVY => out.push(rect(v_heavy_left, down_top, v_heavy_right, h)), + LIGHT => out.push([v_light_left, down_top, v_light_right, h]), + HEAVY => out.push([v_heavy_left, down_top, v_heavy_right, h]), DOUBLE => { let start = |side| { if side == DOUBLE { @@ -180,18 +206,18 @@ fn lines([up, right, down, left]: [u8; 4], w: u32, h: u32, t: u32) -> Vec down_top } }; - out.push(rect(v_double_left, start(left), v_light_left, h)); - out.push(rect(v_light_right, start(right), v_double_right, h)); + out.push([v_double_left, start(left), v_light_left, h]); + out.push([v_light_right, start(right), v_double_right, h]); } _ => {} } match left { - LIGHT => out.push(rect(0, h_light_top, left_right, h_light_bottom)), - HEAVY => out.push(rect(0, h_heavy_top, left_right, h_heavy_bottom)), + LIGHT => out.push([0, h_light_top, left_right, h_light_bottom]), + HEAVY => out.push([0, h_heavy_top, left_right, h_heavy_bottom]), DOUBLE => { let end = |double| if double { v_light_left } else { left_right }; - out.push(rect(0, h_double_top, end(top_double), h_light_top)); - out.push(rect(0, h_light_bottom, end(bottom_double), h_double_bottom)); + out.push([0, h_double_top, end(top_double), h_light_top]); + out.push([0, h_light_bottom, end(bottom_double), h_double_bottom]); } _ => {} } @@ -199,7 +225,7 @@ fn lines([up, right, down, left]: [u8; 4], w: u32, h: u32, t: u32) -> Vec } /// Dashes that tile across cells; a cell too small for them takes a solid light line. -fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec { +fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec { let wide = t.max(4); let (count, thick, gap, vertical) = match code { 0x2504 => (3, t, wide, false), @@ -218,9 +244,9 @@ fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec { let (along, across) = if vertical { (h, w) } else { (w, h) }; if along < 2 * count { let solid = if vertical { - [LIGHT, 0, LIGHT, 0] + [LIGHT, NONE, LIGHT, NONE] } else { - [0, LIGHT, 0, LIGHT] + [NONE, LIGHT, NONE, LIGHT] }; return lines(solid, w, h, t); } @@ -234,9 +260,9 @@ fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec { let end = at + dash + u32::from(extra > 0); extra = extra.saturating_sub(1); let piece = if vertical { - rect(side, at, side + thick, end) + [side, at, side + thick, end] } else { - rect(at, side, end, side + thick) + [at, side, end, side + thick] }; at = end + gap; piece @@ -244,130 +270,36 @@ fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec { .collect() } -/// A rounded corner from the light lines' centers to two edges. -fn arc(code: u32, w: u32, h: u32, t: u32) -> Vec { - let (wf, hf, tf) = (w as f32, h as f32, t as f32); - let cx = (w.saturating_sub(t) / 2) as f32 + tf / 2.; - let cy = (h.saturating_sub(t) / 2) as f32 + tf / 2.; - let r = wf.min(hf) / 2.; - let s = 0.25; - let [dy, dx]: [f32; 2] = [[1., 1.], [1., -1.], [-1., -1.], [-1., 1.]][(code - 0x256D) as usize]; - let (edge_x, edge_y) = (if dx > 0. { wf } else { 0. }, if dy > 0. { hf } else { 0. }); - let path = vec![ - Step::Move(cx, edge_y), - Step::Line(cx, cy + dy * r), - Step::Curve([cx, cy + dy * s * r, cx + dx * s * r, cy, cx + dx * r, cy]), - Step::Line(edge_x, cy), - ]; - vec![Shape::Stroke(path, tf)] -} - -/// Corner-to-corner lines, run half a pixel past so neighbors join once clipped. -fn diagonals(code: u32, w: u32, h: u32, t: u32) -> Vec { - let (wf, hf) = (w as f32, h as f32); - let (sx, sy) = ((wf / hf).min(1.) / 2., (hf / wf).min(1.) / 2.); - let line = |from: (f32, f32), to: (f32, f32)| { - Shape::Stroke( - vec![Step::Move(from.0, from.1), Step::Line(to.0, to.1)], - t as f32, - ) - }; - let rising = line((wf + sx, -sy), (-sx, hf + sy)); - let falling = line((-sx, -sy), (wf + sx, hf + sy)); - match code { - 0x2571 => vec![rising], - 0x2572 => vec![falling], - _ => vec![rising, falling], - } -} - -/// Block elements: eighths, halves, shades and quadrants. -fn blocks(code: u32, w: u32, h: u32) -> Vec { +/// Block elements but the shades: eighths, halves and quadrants. +fn blocks(code: u32, w: u32, h: u32) -> Vec { let part = |share: f32, side: u32| (side as f32 * share).round() as u32; let eighths = |n: u32| n as f32 / 8.; - let shade = |alpha| Shape::Rect { - x: 0, - y: 0, - w, - h, - alpha, - }; match code { - 0x2580 => vec![rect(0, 0, w, part(0.5, h))], - 0x2581..=0x2587 => vec![rect(0, h - part(eighths(code - 0x2580), h), w, h)], - 0x2588 => vec![rect(0, 0, w, h)], - 0x2589..=0x258F => vec![rect(0, 0, part(eighths(0x2590 - code), w), h)], - 0x2590 => vec![rect(w - part(0.5, w), 0, w, h)], - 0x2591..=0x2593 => vec![shade((code - 0x2590) as f32 / 4.)], - 0x2594 => vec![rect(0, 0, w, part(0.125, h))], - 0x2595 => vec![rect(w - part(0.125, w), 0, w, h)], + 0x2580 => vec![[0, 0, w, part(0.5, h)]], + 0x2581..=0x2587 => vec![[0, h - part(eighths(code - 0x2580), h), w, h]], + 0x2588 => vec![[0, 0, w, h]], + 0x2589..=0x258F => vec![[0, 0, part(eighths(0x2590 - code), w), h]], + 0x2590 => vec![[w - part(0.5, w), 0, w, h]], + 0x2594 => vec![[0, 0, w, part(0.125, h)]], + 0x2595 => vec![[w - part(0.125, w), 0, w, h]], _ => { let bits = QUADRANTS[(code - 0x2596) as usize]; let (mx, my) = (part(0.5, w), part(0.5, h)); let (nx, ny) = (w - mx, h - my); [ - rect(0, 0, mx, my), - rect(nx, 0, w, my), - rect(0, ny, mx, h), - rect(nx, ny, w, h), + [0, 0, mx, my], + [nx, 0, w, my], + [0, ny, mx, h], + [nx, ny, w, h], ] .into_iter() .enumerate() .filter(|(index, _)| bits & (0b1000 >> index) != 0) - .map(|(_, piece)| piece) + .map(|(_, corners)| corners) .collect() } } } -/// Powerline's arrows and half circles, filling the cell's height. -fn powerline(code: u32, w: u32, h: u32, t: u32) -> Vec { - let (wf, hf, tf) = (w as f32, h as f32, t as f32); - let steps = match code { - 0xE0B0..=0xE0B3 => vec![ - Step::Move(0., 0.), - Step::Line(wf, hf / 2.), - Step::Line(0., hf), - ], - 0xE0B4 | 0xE0B6 => half_circle(wf, hf, 0.), - _ => half_circle(wf, hf, tf / 2.), - }; - let steps = if matches!(code, 0xE0B2 | 0xE0B3 | 0xE0B6 | 0xE0B7) { - mirror(steps, wf) - } else { - steps - }; - if matches!(code, 0xE0B0 | 0xE0B2 | 0xE0B4 | 0xE0B6) { - vec![Shape::Fill(steps)] - } else { - vec![Shape::Stroke(steps, tf)] - } -} - -/// The right half of a circle against the left edge, inset `by` so a stroke stays inside. -fn half_circle(w: f32, h: f32, by: f32) -> Vec { - let c = (2f32.sqrt() - 1.) * 4. / 3.; - let r = w.min(h / 2.) - by; - let (top, bottom) = (by, h - by); - vec![ - Step::Move(0., top), - Step::Curve([r * c, top, r, top + r - r * c, r, top + r]), - Step::Line(r, bottom - r), - Step::Curve([r, bottom - r + r * c, r * c, bottom, 0., bottom]), - ] -} - -/// The steps flipped left to right within a cell `w` wide. -fn mirror(steps: Vec, w: f32) -> Vec { - steps - .into_iter() - .map(|step| match step { - Step::Move(x, y) => Step::Move(w - x, y), - Step::Line(x, y) => Step::Line(w - x, y), - Step::Curve([ax, ay, bx, by, x, y]) => Step::Curve([w - ax, ay, w - bx, by, w - x, y]), - }) - .collect() -} - #[cfg(test)] mod tests; diff --git a/src/term/boxes/outline.rs b/src/term/boxes/outline.rs new file mode 100644 index 0000000..945f5b4 --- /dev/null +++ b/src/term/boxes/outline.rs @@ -0,0 +1,170 @@ +use super::{Point, Shape}; + +/// Straight pieces a cubic curve is cut into. +const SEGMENTS: usize = 16; + +/// A rounded corner from the light lines' centers to two edges. +pub(super) fn arc(code: u32, w: u32, h: u32, t: u32) -> Vec { + let (wf, hf, tf) = (w as f32, h as f32, t as f32); + let cx = (w.saturating_sub(t) / 2) as f32 + tf / 2.; + let cy = (h.saturating_sub(t) / 2) as f32 + tf / 2.; + let r = wf.min(hf) / 2.; + let s = 0.25; + let [dy, dx]: [f32; 2] = [[1., 1.], [1., -1.], [-1., -1.], [-1., 1.]][(code - 0x256D) as usize]; + let (bend, end) = ((cx, cy + dy * r), (cx + dx * r, cy)); + let (lead_in, lead_out) = ( + (cx, if dy > 0. { hf } else { 0. }), + (if dx > 0. { wf } else { 0. }, cy), + ); + let mut path = Vec::new(); + if dy * (lead_in.1 - bend.1) > 0. { + path.push(lead_in); + } + path.push(bend); + path.extend(cubic( + bend, + [(cx, cy + dy * s * r), (cx + dx * s * r, cy), end], + )); + if dx * (lead_out.0 - end.0) > 0. { + path.push(lead_out); + } + vec![Shape::Fill(stroke(&path, tf / 2.))] +} + +/// Corner-to-corner bands, run half a pixel past so neighbors join once clipped. +pub(super) fn diagonals(code: u32, w: u32, h: u32, t: u32) -> Vec { + let (wf, hf, half) = (w as f32, h as f32, t as f32 / 2.); + let (sx, sy) = ((wf / hf).min(1.) / 2., (hf / wf).min(1.) / 2.); + let rising = [(wf + sx, -sy), (-sx, hf + sy)]; + let falling = [(-sx, -sy), (wf + sx, hf + sy)]; + match code { + 0x2571 => vec![Shape::Fill(stroke(&rising, half))], + 0x2572 => vec![Shape::Fill(stroke(&falling, half))], + _ => { + let band = stroke(&falling, half); + let n = normal(rising[0], rising[1]); + let at = n.0 * rising[0].0 + n.1 * rising[0].1; + let before = clip(&band, (-n.0, -n.1), half - at); + let after = clip(&band, n, at + half); + [stroke(&rising, half), before, after] + .into_iter() + .filter(|polygon| polygon.len() >= 3) + .map(Shape::Fill) + .collect() + } + } +} + +/// Powerline's arrows and half circles, filling the cell's height. +pub(super) fn powerline(code: u32, w: u32, h: u32, t: u32) -> Vec { + let (wf, hf, tf) = (w as f32, h as f32, t as f32); + let arrow = vec![(0., 0.), (wf, hf / 2.), (0., hf)]; + let polygon = match code { + 0xE0B0 | 0xE0B2 => arrow, + 0xE0B1 | 0xE0B3 => stroke(&arrow, tf / 2.), + 0xE0B4 | 0xE0B6 => half_circle(wf, hf, 0.), + _ => stroke(&half_circle(wf, hf, tf / 2.), tf / 2.), + }; + let polygon = if matches!(code, 0xE0B2 | 0xE0B3 | 0xE0B6 | 0xE0B7) { + polygon.into_iter().map(|(x, y)| (wf - x, y)).collect() + } else { + polygon + }; + vec![Shape::Fill(polygon)] +} + +/// The right half of a circle against the left edge, inset `by` so a stroke stays inside; its ends run straight a pixel, as Ghostty's do. +fn half_circle(w: f32, h: f32, by: f32) -> Vec { + let c = (2f32.sqrt() - 1.) * 4. / 3.; + let r = w.min(h / 2.) - by; + let (top, bottom) = (by, h - by); + let mut path = vec![(0., top), (1., top)]; + path.extend(cubic( + (1., top), + [(r * c, top), (r, top + r - r * c), (r, top + r)], + )); + path.push((r, bottom - r)); + path.extend(cubic( + (r, bottom - r), + [(r, bottom - r + r * c), (r * c, bottom), (1., bottom)], + )); + path.push((0., bottom)); + distinct(&path) +} + +/// Points along a cubic after `from`, its end the last. +fn cubic(from: Point, [a, b, to]: [Point; 3]) -> impl Iterator { + (1..=SEGMENTS).map(move |step| { + let t = step as f32 / SEGMENTS as f32; + let u = 1. - t; + let mix = |p: f32, q: f32, r: f32, s: f32| { + u * u * u * p + 3. * u * u * t * q + 3. * u * t * t * r + t * t * t * s + }; + (mix(from.0, a.0, b.0, to.0), mix(from.1, a.1, b.1, to.1)) + }) +} + +/// The path without points that repeat the one before. +fn distinct(path: &[Point]) -> Vec { + let mut points: Vec = Vec::with_capacity(path.len()); + for &point in path { + let repeat = points + .last() + .is_some_and(|last| (point.0 - last.0).hypot(point.1 - last.1) < 1e-3); + if !repeat { + points.push(point); + } + } + points +} + +/// The outline of `path` stroked `half` each side, with butt ends and mitered corners. +fn stroke(path: &[Point], half: f32) -> Vec { + let points = distinct(path); + assert!(points.len() >= 2, "a stroke needs two distinct points"); + let last = points.len() - 1; + let offsets: Vec = (0..=last) + .map(|index| { + let before = normal(points[index.saturating_sub(1)], points[index.max(1)]); + let after = normal(points[index.min(last - 1)], points[(index + 1).min(last)]); + let mean = ((before.0 + after.0) / 2., (before.1 + after.1) / 2.); + let scale = half / (mean.0 * mean.0 + mean.1 * mean.1); + (mean.0 * scale, mean.1 * scale) + }) + .collect(); + let ahead = points + .iter() + .zip(&offsets) + .map(|(p, o)| (p.0 + o.0, p.1 + o.1)); + let back = points + .iter() + .zip(&offsets) + .rev() + .map(|(p, o)| (p.0 - o.0, p.1 - o.1)); + ahead.chain(back).collect() +} + +/// The unit normal of the segment from `from` to `to`. +pub(super) fn normal(from: Point, to: Point) -> Point { + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let length = dx.hypot(dy); + (-dy / length, dx / length) +} + +/// The part of convex `polygon` where `n · p >= at`. +fn clip(polygon: &[Point], n: Point, at: f32) -> Vec { + let side = |p: Point| n.0 * p.0 + n.1 * p.1 - at; + let mut kept = Vec::new(); + for (index, &p) in polygon.iter().enumerate() { + let q = polygon[(index + 1) % polygon.len()]; + let (sp, sq) = (side(p), side(q)); + if sp >= 0. { + kept.push(p); + } + if (sp >= 0.) != (sq >= 0.) { + let k = sp / (sp - sq); + kept.push((p.0 + (q.0 - p.0) * k, p.1 + (q.1 - p.1) * k)); + } + } + kept +} diff --git a/src/term/boxes/tests.rs b/src/term/boxes/tests.rs index 7022d49..3d2eaea 100644 --- a/src/term/boxes/tests.rs +++ b/src/term/boxes/tests.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use super::*; /// Cells as fonts give them: even and odd sides, thin and thick lines. @@ -11,35 +13,49 @@ fn every_drawn() -> impl Iterator { ('\u{2500}'..='\u{259F}').chain('\u{E0B0}'..='\u{E0B7}') } -/// The spans across an edge of the rects touching it; sides go up, right, down, left. +fn corners(shape: &Shape) -> Option { + match *shape { + Shape::Rect { x, y, w, h, .. } => Some([x, y, x + w, y + h]), + Shape::Fill(_) => None, + } +} + +fn pixels(boxes: &[Corners]) -> BTreeSet<(u32, u32)> { + boxes + .iter() + .flat_map(|&[x0, y0, x1, y1]| (y0..y1).flat_map(move |y| (x0..x1).map(move |x| (x, y)))) + .collect() +} + +/// The merged spans across an edge of the rects touching it; sides go up, right, down, left. fn edge(shapes: &[Shape], side: usize, cell_w: u32, cell_h: u32) -> Vec<(u32, u32)> { let mut spans: Vec<(u32, u32)> = shapes .iter() - .filter_map(|shape| match *shape { - Shape::Rect { x, y, w, h, .. } => { - let touches = [y == 0, x + w == cell_w, y + h == cell_h, x == 0][side]; - let span = if side.is_multiple_of(2) { - (x, x + w) - } else { - (y, y + h) - }; - touches.then_some(span) + .filter_map(corners) + .filter(|&[x0, y0, x1, y1]| [y0 == 0, x1 == cell_w, y1 == cell_h, x0 == 0][side]) + .map(|[x0, y0, x1, y1]| { + if side.is_multiple_of(2) { + (x0, x1) + } else { + (y0, y1) } - _ => None, }) .collect(); spans.sort(); - spans + let mut merged: Vec<(u32, u32)> = Vec::new(); + for span in spans { + match merged.last_mut() { + Some(last) if span.0 <= last.1 => last.1 = last.1.max(span.1), + _ => merged.push(span), + } + } + merged } -fn points(steps: &[Step]) -> Vec<(f32, f32)> { - steps - .iter() - .flat_map(|step| match *step { - Step::Move(x, y) | Step::Line(x, y) => vec![(x, y)], - Step::Curve([ax, ay, bx, by, x, y]) => vec![(ax, ay), (bx, by), (x, y)], - }) - .collect() +/// Whether segments `a`-`b` and `c`-`d` cross inside both. +fn cross(a: Point, b: Point, c: Point, d: Point) -> bool { + let turn = |p: Point, q: Point, r: Point| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0); + turn(c, d, a) * turn(c, d, b) < -1e-4 && turn(a, b, c) * turn(a, b, d) < -1e-4 } #[test] @@ -67,9 +83,10 @@ fn every_drawn_char_draws_inside_its_cell() { assert!(*rw > 0 && *rh > 0, "{ch} has an empty rect at {w}x{h}"); assert!(x + rw <= w && y + rh <= h, "{ch} leaves {w}x{h}"); } - Shape::Fill(steps) | Shape::Stroke(steps, _) => { - let reach = t as f32; - for (px, py) in points(steps) { + Shape::Fill(polygon) => { + assert!(polygon.len() >= 3, "{ch} has a degenerate polygon"); + let reach = 2. * t as f32; + for &(px, py) in polygon { let inside = (-reach..=w as f32 + reach).contains(&px) && (-reach..=h as f32 + reach).contains(&py); assert!(inside, "{ch} reaches ({px}, {py}) in {w}x{h}"); @@ -81,6 +98,90 @@ fn every_drawn_char_draws_inside_its_cell() { } } +#[test] +fn no_pixel_is_painted_twice() { + for (w, h, t) in CELLS { + for ch in every_drawn() { + let shapes = shapes(ch, w, h, t); + let rects: Vec = shapes.iter().filter_map(corners).collect(); + for (index, a) in rects.iter().enumerate() { + for b in &rects[index + 1..] { + let apart = a[2] <= b[0] || b[2] <= a[0] || a[3] <= b[1] || b[3] <= a[1]; + assert!(apart, "{ch} overlaps {a:?} and {b:?} at {w}x{h}"); + } + } + for shape in &shapes { + let Shape::Fill(polygon) = shape else { + continue; + }; + let n = polygon.len(); + for i in 0..n { + for j in i + 2..n { + if i == 0 && j == n - 1 { + continue; + } + let (a, b) = (polygon[i], polygon[i + 1]); + let (c, d) = (polygon[j], polygon[(j + 1) % n]); + assert!( + !cross(a, b, c, d), + "{ch}'s outline crosses itself at {w}x{h}" + ); + } + } + } + } + } +} + +#[test] +fn cutting_overlaps_keeps_every_pixel() { + for (w, h, t) in CELLS { + for code in 0x2500..=0x259F_u32 { + let boxes = match code { + 0x2504..=0x250B | 0x254C..=0x254F => dashes(code, w, h, t), + 0x256D..=0x2573 | 0x2591..=0x2593 => continue, + 0x2500..=0x257F => lines(LINES[(code - 0x2500) as usize], w, h, t), + _ => blocks(code, w, h), + }; + let kept: Vec = rects(boxes.clone(), 1.) + .iter() + .filter_map(corners) + .collect(); + assert_eq!(pixels(&kept), pixels(&boxes), "U+{code:04X} at {w}x{h}"); + } + } +} + +#[test] +fn the_cross_cuts_its_second_band_around_the_first() { + for (w, h, t) in CELLS { + let shapes = shapes('╳', w, h, t); + let [Shape::Fill(first), pieces @ ..] = shapes.as_slice() else { + panic!("╳ is polygons"); + }; + assert_eq!( + pieces.len(), + 2, + "two pieces around the first band at {w}x{h}" + ); + let n = outline::normal(first[0], first[1]); + let line = |p: Point| n.0 * p.0 + n.1 * p.1; + let centre = (line(first[0]) + line(first[3])) / 2.; + for piece in pieces { + let Shape::Fill(piece) = piece else { + panic!("pieces are polygons"); + }; + for &p in piece { + let off = (line(p) - centre).abs(); + assert!( + off >= t as f32 / 2. - 1e-3, + "{p:?} is inside the first band at {w}x{h}" + ); + } + } + } +} + #[test] fn straight_lines_sit_centered_at_their_weight() { let (w, h, t) = (16, 32, 2); @@ -136,11 +237,8 @@ fn dashes_tile_with_one_gap_between_cells() { } let mut runs: Vec<(u32, u32)> = drawn .iter() - .map(|shape| match *shape { - Shape::Rect { y, h: rh, .. } if vertical => (y, y + rh), - Shape::Rect { x, w: rw, .. } => (x, x + rw), - _ => panic!("{ch} is drawn with rects"), - }) + .filter_map(corners) + .map(|[x0, y0, x1, y1]| if vertical { (y0, y1) } else { (x0, x1) }) .collect(); runs.sort(); assert_eq!(runs.len() as u32, count, "{ch} at {w}x{h}"); @@ -164,18 +262,13 @@ fn dashes_tile_with_one_gap_between_cells() { #[test] fn halves_cover_the_cell_and_shades_wash_it() { for (w, h, t) in CELLS { - let span = |ch: char| match shapes(ch, w, h, t)[0] { - Shape::Rect { - x, y, w: rw, h: rh, .. - } => (x, y, x + rw, y + rh), - _ => panic!("{ch} is a rect"), - }; + let span = |ch: char| corners(&shapes(ch, w, h, t)[0]).expect("a rect"); let (upper, lower) = (span('▀'), span('▄')); - assert_eq!((upper.1, lower.3), (0, h)); - assert!(upper.3 >= lower.1, "a gap between ▀ and ▄ at {w}x{h}"); + assert_eq!((upper[1], lower[3]), (0, h)); + assert!(upper[3] >= lower[1], "a gap between ▀ and ▄ at {w}x{h}"); let (left, right) = (span('▌'), span('▐')); - assert_eq!((left.0, right.2), (0, w)); - assert!(left.2 >= right.0, "a gap between ▌ and ▐ at {w}x{h}"); + assert_eq!((left[0], right[2]), (0, w)); + assert!(left[2] >= right[0], "a gap between ▌ and ▐ at {w}x{h}"); for (ch, alpha) in [('░', 0.25), ('▒', 0.5), ('▓', 0.75)] { let want = Shape::Rect { x: 0, @@ -186,44 +279,45 @@ fn halves_cover_the_cell_and_shades_wash_it() { }; assert_eq!(shapes(ch, w, h, t), [want]); } - assert_eq!(shapes('▚', w, h, t).len(), 2); - assert_eq!(shapes('▟', w, h, t).len(), 3); } } #[test] fn powerline_fills_the_cell_and_mirrors() { - let (w, h) = (16., 32.); - let right = vec![Step::Move(0., 0.), Step::Line(w, h / 2.), Step::Line(0., h)]; - let left = vec![Step::Move(w, 0.), Step::Line(0., h / 2.), Step::Line(w, h)]; - assert_eq!(shapes('\u{E0B0}', 16, 32, 2), [Shape::Fill(right.clone())]); - assert_eq!(shapes('\u{E0B2}', 16, 32, 2), [Shape::Fill(left)]); - assert_eq!(shapes('\u{E0B1}', 16, 32, 2), [Shape::Stroke(right, 2.)]); - let Shape::Fill(round) = &shapes('\u{E0B6}', 16, 32, 2)[0] else { - panic!("a filled half circle"); + let fill = |ch: char| match shapes(ch, 16, 32, 2).as_slice() { + [Shape::Fill(polygon)] => polygon.clone(), + other => panic!("{ch} is one polygon, not {other:?}"), }; - assert_eq!(round.first(), Some(&Step::Move(16., 0.))); - let Shape::Stroke(outline, 2.) = &shapes('\u{E0B5}', 16, 32, 2)[0] else { - panic!("a stroked half circle"); - }; - assert_eq!( - outline.first(), - Some(&Step::Move(0., 1.)), - "inset by half a line" + assert_eq!(fill('\u{E0B0}'), [(0., 0.), (16., 16.), (0., 32.)]); + assert_eq!(fill('\u{E0B2}'), [(16., 0.), (0., 16.), (16., 32.)]); + assert_eq!(fill('\u{E0B1}').len(), 6, "a mitered chevron"); + assert_eq!(fill('\u{E0B6}')[0], (16., 0.)); + let outline = fill('\u{E0B5}'); + let low = outline.iter().map(|p| p.1).fold(f32::MAX, f32::min); + let high = outline.iter().map(|p| p.1).fold(f32::MIN, f32::max); + let right = outline.iter().map(|p| p.0).fold(f32::MIN, f32::max); + assert!( + low.abs() < 1e-3 && (high - 32.).abs() < 1e-3, + "{low}..{high}" + ); + assert!( + right <= 16.01, + "the outline stays in the half circle: {right}" ); } #[test] -fn arcs_end_on_the_light_lines_centers() { +fn arcs_end_on_the_light_lines() { let (w, h, t) = (16, 32, 2); - let light = (w - t) / 2; - let Shape::Stroke(path, width) = &shapes('╭', w, h, t)[0] else { - panic!("╭ is a stroke"); + let drawn = shapes('╭', w, h, t); + let [Shape::Fill(outline)] = drawn.as_slice() else { + panic!("╭ is one polygon"); }; - assert_eq!(*width, t as f32); - assert_eq!(path.first(), Some(&Step::Move(light as f32 + 1., h as f32))); - assert_eq!( - path.last(), - Some(&Step::Line(w as f32, ((h - t) / 2) as f32 + 1.)) - ); + let near = |x: f32, y: f32| { + outline + .iter() + .any(|p| (p.0 - x).abs() < 0.05 && (p.1 - y).abs() < 0.05) + }; + assert!(near(7., 32.) && near(9., 32.), "the bottom end spans │"); + assert!(near(16., 15.) && near(16., 17.), "the right end spans ─"); } diff --git a/src/term/frame.rs b/src/term/frame.rs index 2299b53..2ae8d25 100644 --- a/src/term/frame.rs +++ b/src/term/frame.rs @@ -40,10 +40,8 @@ pub struct Style { pub struct Row { pub text: String, pub runs: Vec<(usize, Style)>, - /// Cells with combining marks, painted alone at their column. - pub clusters: Vec<(usize, String, Style)>, - /// Cells the terminal draws as shapes: column, char, style. - pub boxes: Vec<(usize, char, Style)>, + /// Cells painted on their own at their column: combining clusters and drawn characters. + pub alone: Vec<(usize, String, Style)>, } /// The cursor where it shows: its cell, shape, and what the cell holds. @@ -72,11 +70,8 @@ impl Frame { .iter() .map(|row| { let mut cells: Vec = row.text.chars().map(String::from).collect(); - for (column, cluster, _) in &row.clusters { - cells[*column] = cluster.clone(); - } - for (column, ch, _) in &row.boxes { - cells[*column] = ch.to_string(); + for (column, text, _) in &row.alone { + cells[*column] = text.clone(); } cells.concat().trim_end().to_string() }) @@ -100,17 +95,13 @@ pub fn frame(term: &Term, ink: &Ink) -> Frame { .flags .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER); let hidden = cell.flags.contains(Flags::HIDDEN); - let cluster = cell - .zerowidth() - .filter(|_| !hidden) - .filter(|marks| !marks.is_empty()) - .map(|marks| { - std::iter::once(cell.c) - .chain(marks.iter().copied()) - .collect::() - }); - let drawn = !hidden && cluster.is_none() && boxes::drawn(cell.c); - let shown = if spacer || hidden || drawn || cluster.is_some() { + let marks = cell.zerowidth().filter(|marks| !marks.is_empty()); + let alone = (!hidden && (marks.is_some() || boxes::drawn(cell.c))).then(|| { + std::iter::once(cell.c) + .chain(marks.into_iter().flatten().copied()) + .collect::() + }); + let shown = if spacer || hidden || alone.is_some() { ' ' } else { cell.c @@ -122,17 +113,12 @@ pub fn frame(term: &Term, ink: &Ink) -> Frame { column: point.column.0, shape: content.cursor.shape, wide: cell.flags.contains(Flags::WIDE_CHAR), - text: cluster - .clone() - .unwrap_or_else(|| if drawn { cell.c } else { shown }.to_string()), + text: alone.clone().unwrap_or_else(|| shown.to_string()), style: look, }); } - if let Some(cluster) = cluster { - rows[row].clusters.push((point.column.0, cluster, look)); - } - if drawn { - rows[row].boxes.push((point.column.0, cell.c, look)); + if let Some(alone) = alone { + rows[row].alone.push((point.column.0, alone, look)); } } Frame { rows, cursor } @@ -231,7 +217,7 @@ mod tests { let (frame, _) = shown("e\u{301}x".as_bytes()); assert_eq!(frame.text(), "e\u{301}x"); assert_eq!(&frame.rows[0].text[..2], " x"); - assert_eq!(frame.rows[0].clusters[0].1, "e\u{301}"); + assert_eq!(frame.rows[0].alone[0].1, "e\u{301}"); } #[test] @@ -271,27 +257,30 @@ mod tests { } #[test] - fn drawn_characters_leave_the_font() { - let (frame, ink) = shown("a\u{2500}\u{E0B0}\x1b[1D".as_bytes()); - assert_eq!(&frame.rows[0].text[..3], "a "); - let drawn: Vec<_> = frame.rows[0] - .boxes + fn drawn_characters_leave_the_font_with_their_marks() { + let (frame, ink) = shown("a\u{2500}\u{E0B0}\u{2500}\u{301}\x1b[2D".as_bytes()); + assert_eq!(&frame.rows[0].text[..4], "a "); + let alone: Vec<_> = frame.rows[0] + .alone .iter() - .map(|(at, ch, _)| (*at, *ch)) + .map(|(at, text, _)| (*at, text.as_str())) .collect(); - assert_eq!(drawn, [(1, '\u{2500}'), (2, '\u{E0B0}')]); - assert_eq!(frame.rows[0].boxes[0].2.fg, ink.fg); - assert_eq!(frame.text(), "a\u{2500}\u{E0B0}"); + assert_eq!( + alone, + [(1, "\u{2500}"), (2, "\u{E0B0}"), (3, "\u{2500}\u{301}")] + ); + assert_eq!(frame.rows[0].alone[0].2.fg, ink.fg); + assert_eq!(frame.text(), "a\u{2500}\u{E0B0}\u{2500}\u{301}"); assert_eq!(frame.cursor.expect("a cursor").text, "\u{E0B0}"); let (hidden, _) = shown(b"\x1b[8m\xe2\x94\x80"); - assert!(hidden.rows[0].boxes.is_empty(), "hidden lines draw nothing"); + assert!(hidden.rows[0].alone.is_empty(), "hidden lines draw nothing"); } #[test] fn hidden_cells_show_nothing_but_their_ground() { let (frame, _) = shown("\x1b[8;4;9;41mSe\u{301}\x1b[0m\x1b[2D".as_bytes()); assert_eq!(frame.rows[0].text.trim_end(), ""); - assert!(frame.rows[0].clusters.is_empty()); + assert!(frame.rows[0].alone.is_empty()); let (_, style) = frame.rows[0].runs[0]; assert_eq!((style.underline, style.strike), (Underline::None, false)); assert!(style.bg.is_some(), "the ground stays"); diff --git a/src/term/paint.rs b/src/term/paint.rs index 2246989..6fc9e1e 100644 --- a/src/term/paint.rs +++ b/src/term/paint.rs @@ -8,7 +8,7 @@ use gpui::{ }; use super::{ - boxes::{self, Shape, Step}, + boxes::{self, Shape}, frame::{Cursor, Frame, Style, Underline}, view::TerminalView, }; @@ -122,11 +122,8 @@ fn paint_row(row: &super::frame::Row, top: Point, look: &Look, window: & }) .collect(); draw(&row.text, &runs, top, Some(cell.width), look, window); - for (column, cluster, style) in &row.clusters { - text(cluster, style.fg, style, at(*column), look, window); - } - for (column, ch, style) in &row.boxes { - paint_drawn(*ch, style.fg, at(*column), look, window); + for (column, content, style) in &row.alone { + paint_alone(content, style.fg, style, at(*column), look, window); } for (column, columns, style) in spans { let width = cell.width * columns as f32; @@ -231,6 +228,29 @@ fn upward(y: Pixels) -> Pixels { y } +/// Paints a cell's text alone at `corner`: a drawn base as shapes, its marks over a no-break space. +fn paint_alone( + content: &str, + color: Hsla, + style: &Style, + corner: Point, + look: &Look, + window: &mut Window, +) { + let mut chars = content.chars(); + match chars.next() { + Some(base) if boxes::drawn(base) => { + paint_drawn(base, color, corner, look, window); + let marks = chars.as_str(); + if !marks.is_empty() { + let shown = format!("\u{A0}{marks}"); + text(&shown, color, style, corner, look, window); + } + } + _ => text(content, color, style, corner, look, window), + } +} + /// Paints `ch` as shapes in `color`, clipped to the cell at `corner`, a device pixel corner. fn paint_drawn(ch: char, color: Hsla, corner: Point, look: &Look, window: &mut Window) { let scale = window.scale_factor(); @@ -240,44 +260,34 @@ fn paint_drawn(ch: char, color: Hsla, corner: Point, look: &Look, window device(look.cell.height), device(look.rule).max(1), ); - let at = |x: f32, y: f32| corner + point(px(x / scale), px(y / scale)); + let at = |(x, y): (f32, f32)| corner + point(px(x / scale), px(y / scale)); let mask = ContentMask { bounds: Bounds::new(corner, look.cell), }; window.with_content_mask(Some(mask), |window| { for piece in boxes::shapes(ch, w, h, t) { - let (builder, steps, close) = match piece { + match piece { Shape::Rect { x, y, w, h, alpha } => { let length = |device: u32| px(device as f32 / scale); - let bounds = Bounds::new(at(x as f32, y as f32), size(length(w), length(h))); + let bounds = Bounds::new(at((x as f32, y as f32)), size(length(w), length(h))); window.paint_quad(fill(bounds, color.opacity(alpha))); - continue; } - Shape::Fill(steps) => (PathBuilder::fill(), steps, true), - Shape::Stroke(steps, width) => { - (PathBuilder::stroke(px(width / scale)), steps, false) - } - }; - let mut builder = builder; - for step in steps { - match step { - Step::Move(x, y) => builder.move_to(at(x, y)), - Step::Line(x, y) => builder.line_to(at(x, y)), - Step::Curve([ax, ay, bx, by, x, y]) => { - builder.cubic_bezier_to(at(x, y), at(ax, ay), at(bx, by)) + Shape::Fill(points) => { + let mut builder = PathBuilder::fill(); + builder.move_to(at(points[0])); + for &corner in &points[1..] { + builder.line_to(at(corner)); + } + builder.close(); + match builder.build() { + Ok(path) => window.paint_path(path, color), + Err(error) => log::error!( + "terminal: U+{:04X} failed to tessellate: {error:?}", + ch as u32 + ), } } } - if close { - builder.close(); - } - match builder.build() { - Ok(path) => window.paint_path(path, color), - Err(error) => log::error!( - "terminal: U+{:04X} failed to tessellate: {error:?}", - ch as u32 - ), - } } }); } @@ -355,20 +365,14 @@ fn paint_cursor( match shape { CursorShape::Block => { window.paint_quad(fill(whole, look.cursor)); - let mut chars = cursor.text.chars(); - match (chars.next(), chars.next()) { - (Some(ch), None) if boxes::drawn(ch) => { - paint_drawn(ch, look.cursor_text, corner, look, window) - } - _ => text( - &cursor.text, - look.cursor_text, - &cursor.style, - corner, - look, - window, - ), - } + paint_alone( + &cursor.text, + look.cursor_text, + &cursor.style, + corner, + look, + window, + ); } CursorShape::HollowBlock => { window.paint_quad(outline(whole, look.cursor, gpui::BorderStyle::Solid)); @@ -412,8 +416,7 @@ mod tests { let row = Row { text: "中 ab".into(), runs: vec![(4, style), (2, bold)], - clusters: Vec::new(), - boxes: Vec::new(), + alone: Vec::new(), }; let spans: Vec<_> = spans(&row).into_iter().map(|(at, n, _)| (at, n)).collect(); assert_eq!(spans, [(0, 2), (2, 2)]);