T07: one coverage per pixel (overlaps cut, strokes as polygons, the cross cut around its first band); a drawn base keeps its geometry under marks
CI / platforms (windows-latest) (push) Failing after 7s
CI / macos (push) Failing after 8m53s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 13:57:47 -04:00
1 parent 387bfa1c8b
commit 2804aa6117
6 files changed
+488 -300

No files matched your search

+2 -2
View File
@@ -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/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/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/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/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/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). - `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: 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: 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: 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. - 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
+92 -160
View File
@@ -1,13 +1,9 @@
/// One step of a path, in device pixels from the cell's top-left. mod outline;
#[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]),
}
/// 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)] #[derive(Clone, Debug, PartialEq)]
pub enum Shape { pub enum Shape {
/// A rectangle filled at `alpha` of the text color. /// A rectangle filled at `alpha` of the text color.
@@ -18,10 +14,8 @@ pub enum Shape {
h: u32, h: u32,
alpha: f32, alpha: f32,
}, },
/// A closed outline, filled. /// A simple polygon, filled.
Fill(Vec<Step>), Fill(Vec<Point>),
/// An open path, stroked this wide with butt ends.
Stroke(Vec<Step>, f32),
} }
/// Whether the terminal draws `ch` as shapes instead of through the font. /// 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, 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. /// 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<Shape> { pub fn shapes(ch: char, w: u32, h: u32, t: u32) -> Vec<Shape> {
let code = ch as u32; let code = ch as u32;
match code { match code {
0x2504..=0x250B | 0x254C..=0x254F => dashes(code, w, h, t), 0x2504..=0x250B | 0x254C..=0x254F => rects(dashes(code, w, h, t), 1.),
0x256D..=0x2570 => arc(code, w, h, t), 0x256D..=0x2570 => outline::arc(code, w, h, t),
0x2571..=0x2573 => diagonals(code, w, h, t), 0x2571..=0x2573 => outline::diagonals(code, w, h, t),
0x2500..=0x257F => lines(LINES[(code - 0x2500) as usize], w, h, t), 0x2500..=0x257F => rects(lines(LINES[(code - 0x2500) as usize], w, h, t), 1.),
0x2580..=0x259F => blocks(code, w, h), 0x2591..=0x2593 => rects(vec![[0, 0, w, h]], (code - 0x2590) as f32 / 4.),
0xE0B0..=0xE0B7 => powerline(code, w, h, t), 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"), _ => panic!("U+{code:04X} is not a drawn character"),
} }
} }
fn rect(x0: u32, y0: u32, x1: u32, y1: u32) -> Shape { /// Rects from boxes with their overlaps cut away, so each pixel takes the color once.
Shape::Rect { fn rects(boxes: Vec<Corners>, alpha: f32) -> Vec<Shape> {
let mut kept: Vec<Corners> = 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, x: x0,
y: y0, y: y0,
w: x1.saturating_sub(x0), w: x1 - x0,
h: y1.saturating_sub(y0), h: y1 - y0,
alpha: 1., alpha,
})
.collect()
}
/// The parts of `piece` outside `cut`.
fn outside(piece: Corners, cut: Corners) -> Vec<Corners> {
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]`. /// 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. /// 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<Shape> { fn lines([up, right, down, left]: [u8; 4], w: u32, h: u32, t: u32) -> Vec<Corners> {
let heavy = 2 * t; let heavy = 2 * t;
let h_light_top = h.saturating_sub(t) / 2; let h_light_top = h.saturating_sub(t) / 2;
let h_light_bottom = h_light_top + t; 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<Shape>
let (top_double, bottom_double) = (up == DOUBLE, down == DOUBLE); let (top_double, bottom_double) = (up == DOUBLE, down == DOUBLE);
let mut out = Vec::new(); let mut out = Vec::new();
match up { match up {
LIGHT => out.push(rect(v_light_left, 0, v_light_right, up_bottom)), LIGHT => out.push([v_light_left, 0, v_light_right, up_bottom]),
HEAVY => out.push(rect(v_heavy_left, 0, v_heavy_right, up_bottom)), HEAVY => out.push([v_heavy_left, 0, v_heavy_right, up_bottom]),
DOUBLE => { DOUBLE => {
let stop = |side| { let end = |side| {
if side == DOUBLE { if side == DOUBLE {
h_light_top h_light_top
} else { } else {
up_bottom up_bottom
} }
}; };
out.push(rect(v_double_left, 0, v_light_left, stop(left))); out.push([v_double_left, 0, v_light_left, end(left)]);
out.push(rect(v_light_right, 0, v_double_right, stop(right))); out.push([v_light_right, 0, v_double_right, end(right)]);
} }
_ => {} _ => {}
} }
match right { match right {
LIGHT => out.push(rect(right_left, h_light_top, w, h_light_bottom)), LIGHT => out.push([right_left, h_light_top, w, h_light_bottom]),
HEAVY => out.push(rect(right_left, h_heavy_top, w, h_heavy_bottom)), HEAVY => out.push([right_left, h_heavy_top, w, h_heavy_bottom]),
DOUBLE => { DOUBLE => {
let start = |double| if double { v_light_right } else { right_left }; 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([start(top_double), h_double_top, w, h_light_top]);
out.push(rect( out.push([start(bottom_double), h_light_bottom, w, h_double_bottom]);
start(bottom_double),
h_light_bottom,
w,
h_double_bottom,
));
} }
_ => {} _ => {}
} }
match down { match down {
LIGHT => out.push(rect(v_light_left, down_top, v_light_right, h)), LIGHT => out.push([v_light_left, down_top, v_light_right, h]),
HEAVY => out.push(rect(v_heavy_left, down_top, v_heavy_right, h)), HEAVY => out.push([v_heavy_left, down_top, v_heavy_right, h]),
DOUBLE => { DOUBLE => {
let start = |side| { let start = |side| {
if side == DOUBLE { if side == DOUBLE {
@@ -180,18 +206,18 @@ fn lines([up, right, down, left]: [u8; 4], w: u32, h: u32, t: u32) -> Vec<Shape>
down_top down_top
} }
}; };
out.push(rect(v_double_left, start(left), v_light_left, h)); out.push([v_double_left, start(left), v_light_left, h]);
out.push(rect(v_light_right, start(right), v_double_right, h)); out.push([v_light_right, start(right), v_double_right, h]);
} }
_ => {} _ => {}
} }
match left { match left {
LIGHT => out.push(rect(0, h_light_top, left_right, h_light_bottom)), LIGHT => out.push([0, h_light_top, left_right, h_light_bottom]),
HEAVY => out.push(rect(0, h_heavy_top, left_right, h_heavy_bottom)), HEAVY => out.push([0, h_heavy_top, left_right, h_heavy_bottom]),
DOUBLE => { DOUBLE => {
let end = |double| if double { v_light_left } else { left_right }; 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([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_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<Shape>
} }
/// Dashes that tile across cells; a cell too small for them takes a solid light line. /// 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<Shape> { fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec<Corners> {
let wide = t.max(4); let wide = t.max(4);
let (count, thick, gap, vertical) = match code { let (count, thick, gap, vertical) = match code {
0x2504 => (3, t, wide, false), 0x2504 => (3, t, wide, false),
@@ -218,9 +244,9 @@ fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec<Shape> {
let (along, across) = if vertical { (h, w) } else { (w, h) }; let (along, across) = if vertical { (h, w) } else { (w, h) };
if along < 2 * count { if along < 2 * count {
let solid = if vertical { let solid = if vertical {
[LIGHT, 0, LIGHT, 0] [LIGHT, NONE, LIGHT, NONE]
} else { } else {
[0, LIGHT, 0, LIGHT] [NONE, LIGHT, NONE, LIGHT]
}; };
return lines(solid, w, h, t); return lines(solid, w, h, t);
} }
@@ -234,9 +260,9 @@ fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec<Shape> {
let end = at + dash + u32::from(extra > 0); let end = at + dash + u32::from(extra > 0);
extra = extra.saturating_sub(1); extra = extra.saturating_sub(1);
let piece = if vertical { let piece = if vertical {
rect(side, at, side + thick, end) [side, at, side + thick, end]
} else { } else {
rect(at, side, end, side + thick) [at, side, end, side + thick]
}; };
at = end + gap; at = end + gap;
piece piece
@@ -244,130 +270,36 @@ fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec<Shape> {
.collect() .collect()
} }
/// A rounded corner from the light lines' centers to two edges. /// Block elements but the shades: eighths, halves and quadrants.
fn arc(code: u32, w: u32, h: u32, t: u32) -> Vec<Shape> { fn blocks(code: u32, w: u32, h: u32) -> Vec<Corners> {
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<Shape> {
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<Shape> {
let part = |share: f32, side: u32| (side as f32 * share).round() as u32; let part = |share: f32, side: u32| (side as f32 * share).round() as u32;
let eighths = |n: u32| n as f32 / 8.; let eighths = |n: u32| n as f32 / 8.;
let shade = |alpha| Shape::Rect {
x: 0,
y: 0,
w,
h,
alpha,
};
match code { match code {
0x2580 => vec![rect(0, 0, w, part(0.5, h))], 0x2580 => vec![[0, 0, w, part(0.5, h)]],
0x2581..=0x2587 => vec![rect(0, h - part(eighths(code - 0x2580), h), w, h)], 0x2581..=0x2587 => vec![[0, h - part(eighths(code - 0x2580), h), w, h]],
0x2588 => vec![rect(0, 0, w, h)], 0x2588 => vec![[0, 0, w, h]],
0x2589..=0x258F => vec![rect(0, 0, part(eighths(0x2590 - code), w), h)], 0x2589..=0x258F => vec![[0, 0, part(eighths(0x2590 - code), w), h]],
0x2590 => vec![rect(w - part(0.5, w), 0, w, h)], 0x2590 => vec![[w - part(0.5, w), 0, w, h]],
0x2591..=0x2593 => vec![shade((code - 0x2590) as f32 / 4.)], 0x2594 => vec![[0, 0, w, part(0.125, h)]],
0x2594 => vec![rect(0, 0, w, part(0.125, h))], 0x2595 => vec![[w - part(0.125, w), 0, w, h]],
0x2595 => vec![rect(w - part(0.125, w), 0, w, h)],
_ => { _ => {
let bits = QUADRANTS[(code - 0x2596) as usize]; let bits = QUADRANTS[(code - 0x2596) as usize];
let (mx, my) = (part(0.5, w), part(0.5, h)); let (mx, my) = (part(0.5, w), part(0.5, h));
let (nx, ny) = (w - mx, h - my); let (nx, ny) = (w - mx, h - my);
[ [
rect(0, 0, mx, my), [0, 0, mx, my],
rect(nx, 0, w, my), [nx, 0, w, my],
rect(0, ny, mx, h), [0, ny, mx, h],
rect(nx, ny, w, h), [nx, ny, w, h],
] ]
.into_iter() .into_iter()
.enumerate() .enumerate()
.filter(|(index, _)| bits & (0b1000 >> index) != 0) .filter(|(index, _)| bits & (0b1000 >> index) != 0)
.map(|(_, piece)| piece) .map(|(_, corners)| corners)
.collect() .collect()
} }
} }
} }
/// Powerline's arrows and half circles, filling the cell's height.
fn powerline(code: u32, w: u32, h: u32, t: u32) -> Vec<Shape> {
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<Step> {
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<Step>, w: f32) -> Vec<Step> {
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)] #[cfg(test)]
mod tests; mod tests;
+170
View File
@@ -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<Shape> {
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<Shape> {
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<Shape> {
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<Point> {
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<Item = Point> {
(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<Point> {
let mut points: Vec<Point> = 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<Point> {
let points = distinct(path);
assert!(points.len() >= 2, "a stroke needs two distinct points");
let last = points.len() - 1;
let offsets: Vec<Point> = (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<Point> {
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
}
+159 -65
View File
@@ -1,3 +1,5 @@
use std::collections::BTreeSet;
use super::*; use super::*;
/// Cells as fonts give them: even and odd sides, thin and thick lines. /// Cells as fonts give them: even and odd sides, thin and thick lines.
@@ -11,35 +13,49 @@ fn every_drawn() -> impl Iterator<Item = char> {
('\u{2500}'..='\u{259F}').chain('\u{E0B0}'..='\u{E0B7}') ('\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<Corners> {
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)> { fn edge(shapes: &[Shape], side: usize, cell_w: u32, cell_h: u32) -> Vec<(u32, u32)> {
let mut spans: Vec<(u32, u32)> = shapes let mut spans: Vec<(u32, u32)> = shapes
.iter() .iter()
.filter_map(|shape| match *shape { .filter_map(corners)
Shape::Rect { x, y, w, h, .. } => { .filter(|&[x0, y0, x1, y1]| [y0 == 0, x1 == cell_w, y1 == cell_h, x0 == 0][side])
let touches = [y == 0, x + w == cell_w, y + h == cell_h, x == 0][side]; .map(|[x0, y0, x1, y1]| {
let span = if side.is_multiple_of(2) { if side.is_multiple_of(2) {
(x, x + w) (x0, x1)
} else { } else {
(y, y + h) (y0, y1)
};
touches.then_some(span)
} }
_ => None,
}) })
.collect(); .collect();
spans.sort(); 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)> { /// Whether segments `a`-`b` and `c`-`d` cross inside both.
steps fn cross(a: Point, b: Point, c: Point, d: Point) -> bool {
.iter() let turn = |p: Point, q: Point, r: Point| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);
.flat_map(|step| match *step { turn(c, d, a) * turn(c, d, b) < -1e-4 && turn(a, b, c) * turn(a, b, d) < -1e-4
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()
} }
#[test] #[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!(*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}"); assert!(x + rw <= w && y + rh <= h, "{ch} leaves {w}x{h}");
} }
Shape::Fill(steps) | Shape::Stroke(steps, _) => { Shape::Fill(polygon) => {
let reach = t as f32; assert!(polygon.len() >= 3, "{ch} has a degenerate polygon");
for (px, py) in points(steps) { let reach = 2. * t as f32;
for &(px, py) in polygon {
let inside = (-reach..=w as f32 + reach).contains(&px) let inside = (-reach..=w as f32 + reach).contains(&px)
&& (-reach..=h as f32 + reach).contains(&py); && (-reach..=h as f32 + reach).contains(&py);
assert!(inside, "{ch} reaches ({px}, {py}) in {w}x{h}"); 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<Corners> = 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<Corners> = 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] #[test]
fn straight_lines_sit_centered_at_their_weight() { fn straight_lines_sit_centered_at_their_weight() {
let (w, h, t) = (16, 32, 2); 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 let mut runs: Vec<(u32, u32)> = drawn
.iter() .iter()
.map(|shape| match *shape { .filter_map(corners)
Shape::Rect { y, h: rh, .. } if vertical => (y, y + rh), .map(|[x0, y0, x1, y1]| if vertical { (y0, y1) } else { (x0, x1) })
Shape::Rect { x, w: rw, .. } => (x, x + rw),
_ => panic!("{ch} is drawn with rects"),
})
.collect(); .collect();
runs.sort(); runs.sort();
assert_eq!(runs.len() as u32, count, "{ch} at {w}x{h}"); 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] #[test]
fn halves_cover_the_cell_and_shades_wash_it() { fn halves_cover_the_cell_and_shades_wash_it() {
for (w, h, t) in CELLS { for (w, h, t) in CELLS {
let span = |ch: char| match shapes(ch, w, h, t)[0] { let span = |ch: char| corners(&shapes(ch, w, h, t)[0]).expect("a rect");
Shape::Rect {
x, y, w: rw, h: rh, ..
} => (x, y, x + rw, y + rh),
_ => panic!("{ch} is a rect"),
};
let (upper, lower) = (span('▀'), span('▄')); let (upper, lower) = (span('▀'), span('▄'));
assert_eq!((upper.1, lower.3), (0, h)); assert_eq!((upper[1], lower[3]), (0, h));
assert!(upper.3 >= lower.1, "a gap between ▀ and ▄ at {w}x{h}"); assert!(upper[3] >= lower[1], "a gap between ▀ and ▄ at {w}x{h}");
let (left, right) = (span('▌'), span('▐')); let (left, right) = (span('▌'), span('▐'));
assert_eq!((left.0, right.2), (0, w)); assert_eq!((left[0], right[2]), (0, w));
assert!(left.2 >= right.0, "a gap between ▌ and ▐ at {w}x{h}"); assert!(left[2] >= right[0], "a gap between ▌ and ▐ at {w}x{h}");
for (ch, alpha) in [('░', 0.25), ('▒', 0.5), ('▓', 0.75)] { for (ch, alpha) in [('░', 0.25), ('▒', 0.5), ('▓', 0.75)] {
let want = Shape::Rect { let want = Shape::Rect {
x: 0, 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(ch, w, h, t), [want]);
} }
assert_eq!(shapes('▚', w, h, t).len(), 2);
assert_eq!(shapes('▟', w, h, t).len(), 3);
} }
} }
#[test] #[test]
fn powerline_fills_the_cell_and_mirrors() { fn powerline_fills_the_cell_and_mirrors() {
let (w, h) = (16., 32.); let fill = |ch: char| match shapes(ch, 16, 32, 2).as_slice() {
let right = vec![Step::Move(0., 0.), Step::Line(w, h / 2.), Step::Line(0., h)]; [Shape::Fill(polygon)] => polygon.clone(),
let left = vec![Step::Move(w, 0.), Step::Line(0., h / 2.), Step::Line(w, h)]; other => panic!("{ch} is one polygon, not {other:?}"),
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");
}; };
assert_eq!(round.first(), Some(&Step::Move(16., 0.))); assert_eq!(fill('\u{E0B0}'), [(0., 0.), (16., 16.), (0., 32.)]);
let Shape::Stroke(outline, 2.) = &shapes('\u{E0B5}', 16, 32, 2)[0] else { assert_eq!(fill('\u{E0B2}'), [(16., 0.), (0., 16.), (16., 32.)]);
panic!("a stroked half circle"); assert_eq!(fill('\u{E0B1}').len(), 6, "a mitered chevron");
}; assert_eq!(fill('\u{E0B6}')[0], (16., 0.));
assert_eq!( let outline = fill('\u{E0B5}');
outline.first(), let low = outline.iter().map(|p| p.1).fold(f32::MAX, f32::min);
Some(&Step::Move(0., 1.)), let high = outline.iter().map(|p| p.1).fold(f32::MIN, f32::max);
"inset by half a line" 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] #[test]
fn arcs_end_on_the_light_lines_centers() { fn arcs_end_on_the_light_lines() {
let (w, h, t) = (16, 32, 2); let (w, h, t) = (16, 32, 2);
let light = (w - t) / 2; let drawn = shapes('╭', w, h, t);
let Shape::Stroke(path, width) = &shapes('╭', w, h, t)[0] else { let [Shape::Fill(outline)] = drawn.as_slice() else {
panic!("╭ is a stroke"); panic!("╭ is one polygon");
}; };
assert_eq!(*width, t as f32); let near = |x: f32, y: f32| {
assert_eq!(path.first(), Some(&Step::Move(light as f32 + 1., h as f32))); outline
assert_eq!( .iter()
path.last(), .any(|p| (p.0 - x).abs() < 0.05 && (p.1 - y).abs() < 0.05)
Some(&Step::Line(w as f32, ((h - t) / 2) as f32 + 1.)) };
); assert!(near(7., 32.) && near(9., 32.), "the bottom end spans │");
assert!(near(16., 15.) && near(16., 17.), "the right end spans ─");
} }
+26 -37
View File
@@ -40,10 +40,8 @@ pub struct Style {
pub struct Row { pub struct Row {
pub text: String, pub text: String,
pub runs: Vec<(usize, Style)>, pub runs: Vec<(usize, Style)>,
/// Cells with combining marks, painted alone at their column. /// Cells painted on their own at their column: combining clusters and drawn characters.
pub clusters: Vec<(usize, String, Style)>, pub alone: Vec<(usize, String, Style)>,
/// Cells the terminal draws as shapes: column, char, style.
pub boxes: Vec<(usize, char, Style)>,
} }
/// The cursor where it shows: its cell, shape, and what the cell holds. /// The cursor where it shows: its cell, shape, and what the cell holds.
@@ -72,11 +70,8 @@ impl Frame {
.iter() .iter()
.map(|row| { .map(|row| {
let mut cells: Vec<String> = row.text.chars().map(String::from).collect(); let mut cells: Vec<String> = row.text.chars().map(String::from).collect();
for (column, cluster, _) in &row.clusters { for (column, text, _) in &row.alone {
cells[*column] = cluster.clone(); cells[*column] = text.clone();
}
for (column, ch, _) in &row.boxes {
cells[*column] = ch.to_string();
} }
cells.concat().trim_end().to_string() cells.concat().trim_end().to_string()
}) })
@@ -100,17 +95,13 @@ pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink) -> Frame {
.flags .flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER); .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER);
let hidden = cell.flags.contains(Flags::HIDDEN); let hidden = cell.flags.contains(Flags::HIDDEN);
let cluster = cell let marks = cell.zerowidth().filter(|marks| !marks.is_empty());
.zerowidth() let alone = (!hidden && (marks.is_some() || boxes::drawn(cell.c))).then(|| {
.filter(|_| !hidden)
.filter(|marks| !marks.is_empty())
.map(|marks| {
std::iter::once(cell.c) std::iter::once(cell.c)
.chain(marks.iter().copied()) .chain(marks.into_iter().flatten().copied())
.collect::<String>() .collect::<String>()
}); });
let drawn = !hidden && cluster.is_none() && boxes::drawn(cell.c); let shown = if spacer || hidden || alone.is_some() {
let shown = if spacer || hidden || drawn || cluster.is_some() {
' ' ' '
} else { } else {
cell.c cell.c
@@ -122,17 +113,12 @@ pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink) -> Frame {
column: point.column.0, column: point.column.0,
shape: content.cursor.shape, shape: content.cursor.shape,
wide: cell.flags.contains(Flags::WIDE_CHAR), wide: cell.flags.contains(Flags::WIDE_CHAR),
text: cluster text: alone.clone().unwrap_or_else(|| shown.to_string()),
.clone()
.unwrap_or_else(|| if drawn { cell.c } else { shown }.to_string()),
style: look, style: look,
}); });
} }
if let Some(cluster) = cluster { if let Some(alone) = alone {
rows[row].clusters.push((point.column.0, cluster, look)); rows[row].alone.push((point.column.0, alone, look));
}
if drawn {
rows[row].boxes.push((point.column.0, cell.c, look));
} }
} }
Frame { rows, cursor } Frame { rows, cursor }
@@ -231,7 +217,7 @@ mod tests {
let (frame, _) = shown("e\u{301}x".as_bytes()); let (frame, _) = shown("e\u{301}x".as_bytes());
assert_eq!(frame.text(), "e\u{301}x"); assert_eq!(frame.text(), "e\u{301}x");
assert_eq!(&frame.rows[0].text[..2], " 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] #[test]
@@ -271,27 +257,30 @@ mod tests {
} }
#[test] #[test]
fn drawn_characters_leave_the_font() { fn drawn_characters_leave_the_font_with_their_marks() {
let (frame, ink) = shown("a\u{2500}\u{E0B0}\x1b[1D".as_bytes()); let (frame, ink) = shown("a\u{2500}\u{E0B0}\u{2500}\u{301}\x1b[2D".as_bytes());
assert_eq!(&frame.rows[0].text[..3], "a "); assert_eq!(&frame.rows[0].text[..4], "a ");
let drawn: Vec<_> = frame.rows[0] let alone: Vec<_> = frame.rows[0]
.boxes .alone
.iter() .iter()
.map(|(at, ch, _)| (*at, *ch)) .map(|(at, text, _)| (*at, text.as_str()))
.collect(); .collect();
assert_eq!(drawn, [(1, '\u{2500}'), (2, '\u{E0B0}')]); assert_eq!(
assert_eq!(frame.rows[0].boxes[0].2.fg, ink.fg); alone,
assert_eq!(frame.text(), "a\u{2500}\u{E0B0}"); [(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}"); assert_eq!(frame.cursor.expect("a cursor").text, "\u{E0B0}");
let (hidden, _) = shown(b"\x1b[8m\xe2\x94\x80"); 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] #[test]
fn hidden_cells_show_nothing_but_their_ground() { fn hidden_cells_show_nothing_but_their_ground() {
let (frame, _) = shown("\x1b[8;4;9;41mSe\u{301}\x1b[0m\x1b[2D".as_bytes()); 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_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]; let (_, style) = frame.rows[0].runs[0];
assert_eq!((style.underline, style.strike), (Underline::None, false)); assert_eq!((style.underline, style.strike), (Underline::None, false));
assert!(style.bg.is_some(), "the ground stays"); assert!(style.bg.is_some(), "the ground stays");
+39 -36
View File
@@ -8,7 +8,7 @@ use gpui::{
}; };
use super::{ use super::{
boxes::{self, Shape, Step}, boxes::{self, Shape},
frame::{Cursor, Frame, Style, Underline}, frame::{Cursor, Frame, Style, Underline},
view::TerminalView, view::TerminalView,
}; };
@@ -122,11 +122,8 @@ fn paint_row(row: &super::frame::Row, top: Point<Pixels>, look: &Look, window: &
}) })
.collect(); .collect();
draw(&row.text, &runs, top, Some(cell.width), look, window); draw(&row.text, &runs, top, Some(cell.width), look, window);
for (column, cluster, style) in &row.clusters { for (column, content, style) in &row.alone {
text(cluster, style.fg, style, at(*column), look, window); paint_alone(content, 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, columns, style) in spans { for (column, columns, style) in spans {
let width = cell.width * columns as f32; let width = cell.width * columns as f32;
@@ -231,6 +228,29 @@ fn upward(y: Pixels) -> Pixels {
y 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<Pixels>,
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. /// 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<Pixels>, look: &Look, window: &mut Window) { fn paint_drawn(ch: char, color: Hsla, corner: Point<Pixels>, look: &Look, window: &mut Window) {
let scale = window.scale_factor(); let scale = window.scale_factor();
@@ -240,37 +260,25 @@ fn paint_drawn(ch: char, color: Hsla, corner: Point<Pixels>, look: &Look, window
device(look.cell.height), device(look.cell.height),
device(look.rule).max(1), 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 { let mask = ContentMask {
bounds: Bounds::new(corner, look.cell), bounds: Bounds::new(corner, look.cell),
}; };
window.with_content_mask(Some(mask), |window| { window.with_content_mask(Some(mask), |window| {
for piece in boxes::shapes(ch, w, h, t) { for piece in boxes::shapes(ch, w, h, t) {
let (builder, steps, close) = match piece { match piece {
Shape::Rect { x, y, w, h, alpha } => { Shape::Rect { x, y, w, h, alpha } => {
let length = |device: u32| px(device as f32 / scale); 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))); window.paint_quad(fill(bounds, color.opacity(alpha)));
continue;
} }
Shape::Fill(steps) => (PathBuilder::fill(), steps, true), Shape::Fill(points) => {
Shape::Stroke(steps, width) => { let mut builder = PathBuilder::fill();
(PathBuilder::stroke(px(width / scale)), steps, false) builder.move_to(at(points[0]));
for &corner in &points[1..] {
builder.line_to(at(corner));
} }
};
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))
}
}
}
if close {
builder.close(); builder.close();
}
match builder.build() { match builder.build() {
Ok(path) => window.paint_path(path, color), Ok(path) => window.paint_path(path, color),
Err(error) => log::error!( Err(error) => log::error!(
@@ -279,6 +287,8 @@ fn paint_drawn(ch: char, color: Hsla, corner: Point<Pixels>, look: &Look, window
), ),
} }
} }
}
}
}); });
} }
@@ -355,20 +365,14 @@ fn paint_cursor(
match shape { match shape {
CursorShape::Block => { CursorShape::Block => {
window.paint_quad(fill(whole, look.cursor)); window.paint_quad(fill(whole, look.cursor));
let mut chars = cursor.text.chars(); paint_alone(
match (chars.next(), chars.next()) {
(Some(ch), None) if boxes::drawn(ch) => {
paint_drawn(ch, look.cursor_text, corner, look, window)
}
_ => text(
&cursor.text, &cursor.text,
look.cursor_text, look.cursor_text,
&cursor.style, &cursor.style,
corner, corner,
look, look,
window, window,
), );
}
} }
CursorShape::HollowBlock => { CursorShape::HollowBlock => {
window.paint_quad(outline(whole, look.cursor, gpui::BorderStyle::Solid)); window.paint_quad(outline(whole, look.cursor, gpui::BorderStyle::Solid));
@@ -412,8 +416,7 @@ mod tests {
let row = Row { let row = Row {
text: "中 ab".into(), text: "中 ab".into(),
runs: vec![(4, style), (2, bold)], runs: vec![(4, style), (2, bold)],
clusters: Vec::new(), alone: Vec::new(),
boxes: Vec::new(),
}; };
let spans: Vec<_> = spans(&row).into_iter().map(|(at, n, _)| (at, n)).collect(); let spans: Vec<_> = spans(&row).into_iter().map(|(at, n, _)| (at, n)).collect();
assert_eq!(spans, [(0, 2), (2, 2)]); assert_eq!(spans, [(0, 2), (2, 2)]);