fix(render): draw box-drawing and block characters natively

Box characters rendered as font glyphs only cover the font's own line
height, but the cell is font_size × line_height (1.4 by default) — so
every vertical run of │/╭/╰ broke into dashes with a gap at each row
boundary: a two-line prompt's corners never connected, a TUI frame was
perforated down both sides.

New `terminal::boxdraw` module draws U+2500–U+257F and U+2580–U+259F as
geometry pinned to the cell's real edges, the same special case every
terminal with a line-height setting ships (kitty, alacritty, WezTerm)
and the same approach the existing Powerline separators use:

* light/heavy lines, corners, tees, crosses: per-arm rectangles with a
  centre overshoot so any weight combination joins solid
* the double-line set: explicit per-character stroke lists, keeping the
  open junctions (╬ is four corners around a hole) intact
* rounded corners ╭╮╯╰: straight stubs plus a quarter-circle band of
  overlapping convex quads — a single band outline is concave, which
  gpui's fan fill renders as a solid blob, and butted segments seam at
  75% opacity where two antialiased edges meet
* dashed lines, diagonals ╱╲╳, block eighths/quadrants, and the ░▒▓
  shades as foreground-alpha washes

Anything outside the range still renders through the font.
This commit is contained in:
yetone
2026-07-27 17:54:47 +08:00
parent 0030b98faa
commit ee921c4879
3 changed files with 744 additions and 4 deletions
+719
View File
@@ -0,0 +1,719 @@
//! Native box-drawing: the U+2500U+257F box characters and U+2580U+259F
//! block elements, drawn as geometry sized to the actual cell instead of as
//! font glyphs.
//!
//! Why the font can't do this job: a glyph fills (at most) the font's own line
//! height, but the cell it paints into is `font_size × Config::line_height` —
//! 1.4 by default. At any line height above 1.0 a `│` covers only the middle of
//! its cell, so every vertical run of box characters breaks into dashes with a
//! gap at each row boundary: a two-line shell prompt's `╭`/`╰` no longer
//! connect, a TUI frame is perforated down both sides. Horizontal continuity
//! has the same problem in miniature whenever a fallback face's advance
//! disagrees with the cell width.
//!
//! Drawing the range natively pins every stroke to the cell's real edges, so
//! adjacent cells join seamlessly at any line height, any font, any fallback
//! chain. This is the same special case every terminal with a line-height
//! setting ships (kitty, alacritty, WezTerm, iTerm2), and the same approach the
//! Powerline separators in `element.rs` already use — they skip fonts entirely.
//!
//! [`glyph`] returns the character's ink as rectangles and filled paths in cell
//! coordinates; `paint_glyphs` fills them with the cell's foreground. A char
//! outside the range returns `None` and falls back to the font.
use gpui::{Bounds, Pixels, point, px, size};
/// One paintable piece of a box-drawing glyph.
pub(crate) enum Ink {
/// A solid rectangle in the cell's foreground color.
Rect(Bounds<Pixels>),
/// A rectangle at a fraction of the foreground's alpha — the ░▒▓ shades,
/// which fake their dither by translucency exactly as WezTerm does.
Shade(Bounds<Pixels>, f32),
/// A filled path — rounded corners and diagonals, the two shapes a
/// rectangle can't express.
Path(gpui::Path<Pixels>),
}
/// The ink for `c` sized to `bounds`, or `None` for anything that isn't a
/// box-drawing/block character (which then renders through the font).
pub(crate) fn glyph(c: char, bounds: Bounds<Pixels>) -> Option<Vec<Ink>> {
if !('\u{2500}'..='\u{259f}').contains(&c) {
return None;
}
let g = Cell::new(&bounds);
if let Some((u, d, l, r)) = arms_of(c) {
return Some(g.arms(u, d, l, r));
}
g.doubles(c)
.or_else(|| g.rounded(c))
.or_else(|| g.dashed(c))
.or_else(|| g.diagonal(c))
.or_else(|| g.blocks(c))
}
/// The weight of one arm (centre → edge) of a box character.
#[derive(Clone, Copy, PartialEq)]
enum Arm {
None,
Light,
Heavy,
}
/// Cell geometry in f32, plus the light stroke thickness.
///
/// Thickness derives from the cell *width* — a pure font-size proxy — never the
/// height: the height carries the line-height stretch, and a `─` that fattens
/// when the user opens up their line spacing would look broken.
struct Cell {
x0: f32,
y0: f32,
x1: f32,
y1: f32,
cx: f32,
cy: f32,
t: f32,
}
impl Cell {
fn new(b: &Bounds<Pixels>) -> Self {
let x0 = b.origin.x.as_f32();
let y0 = b.origin.y.as_f32();
let x1 = x0 + b.size.width.as_f32();
let y1 = y0 + b.size.height.as_f32();
Cell {
x0,
y0,
x1,
y1,
cx: (x0 + x1) / 2.,
cy: (y0 + y1) / 2.,
t: ((x1 - x0) * 0.15).round().max(1.),
}
}
fn rectb(&self, x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
Bounds::new(point(px(x), px(y)), size(px(w), px(h)))
}
fn rect(&self, x: f32, y: f32, w: f32, h: f32) -> Ink {
Ink::Rect(self.rectb(x, y, w, h))
}
/// The light/heavy arm combinations: one rectangle per arm, each running
/// from its cell edge to just past the centre.
///
/// The overshoot (`m`, half the thickest arm) is what makes a corner: two
/// perpendicular strokes that merely *meet* at the centre point leave a
/// notch at the outside of the turn. Same-color opaque overlap costs
/// nothing, so every arm overshoots by the same amount and any combination
/// of weights joins solid.
fn arms(&self, u: Arm, d: Arm, l: Arm, r: Arm) -> Vec<Ink> {
let w = |a: Arm| match a {
Arm::None => 0.,
Arm::Light => self.t,
Arm::Heavy => self.t * 2.,
};
let (wu, wd, wl, wr) = (w(u), w(d), w(l), w(r));
let m = wu.max(wd).max(wl).max(wr) / 2.;
let mut ink = Vec::new();
if wu > 0. {
ink.push(self.rect(self.cx - wu / 2., self.y0, wu, self.cy + m - self.y0));
}
if wd > 0. {
ink.push(self.rect(self.cx - wd / 2., self.cy - m, wd, self.y1 - (self.cy - m)));
}
if wl > 0. {
ink.push(self.rect(self.x0, self.cy - wl / 2., self.cx + m - self.x0, wl));
}
if wr > 0. {
ink.push(self.rect(self.cx - m, self.cy - wr / 2., self.x1 - (self.cx - m), wr));
}
ink
}
/// The double-line set (U+2550U+256C), spelled out stroke by stroke.
///
/// Doubles can't reuse the [`arms`](Self::arms) overshoot trick: their
/// junctions are *open* — ╬ is four corner pieces around a hole, ╠'s inner
/// stroke breaks where the branch leaves — so each character lists exactly
/// the segments the Unicode chart draws, with endpoints snapped half a
/// stroke past the line they join so corners close without crossing the
/// gap.
fn doubles(&self, c: char) -> Option<Vec<Ink>> {
let t = self.t;
let h = t / 2.;
// The parallel strokes sit at centre ± d. At the 1px thickness of
// ordinary font sizes this leaves a 3px gap — wide enough to survive
// subpixel placement without the two strokes bleeding into one.
let d = (t * 1.5).max(2.0);
let (x0, x1, y0, y1, cx, cy) = (self.x0, self.x1, self.y0, self.y1, self.cx, self.cy);
let (va, vb) = (cx - d, cx + d);
let (ha, hb) = (cy - d, cy + d);
let v = |x: f32, ya: f32, yb: f32| self.rect(x - h, ya, t, yb - ya);
let hz = |y: f32, xa: f32, xb: f32| self.rect(xa, y - h, xb - xa, t);
Some(match c {
'═' => vec![hz(ha, x0, x1), hz(hb, x0, x1)],
'║' => vec![v(va, y0, y1), v(vb, y0, y1)],
'╒' => vec![hz(ha, cx - h, x1), hz(hb, cx - h, x1), v(cx, ha - h, y1)],
'╓' => vec![hz(cy, va - h, x1), v(va, cy - h, y1), v(vb, cy - h, y1)],
'╔' => vec![
v(va, ha - h, y1),
hz(ha, va - h, x1),
v(vb, hb - h, y1),
hz(hb, vb - h, x1),
],
'╕' => vec![hz(ha, x0, cx + h), hz(hb, x0, cx + h), v(cx, ha - h, y1)],
'╖' => vec![hz(cy, x0, vb + h), v(va, cy - h, y1), v(vb, cy - h, y1)],
'╗' => vec![
v(vb, ha - h, y1),
hz(ha, x0, vb + h),
v(va, hb - h, y1),
hz(hb, x0, va + h),
],
'╘' => vec![v(cx, y0, hb + h), hz(ha, cx - h, x1), hz(hb, cx - h, x1)],
'╙' => vec![v(va, y0, cy + h), v(vb, y0, cy + h), hz(cy, va - h, x1)],
'╚' => vec![
v(va, y0, hb + h),
hz(hb, va - h, x1),
v(vb, y0, ha + h),
hz(ha, vb - h, x1),
],
'╛' => vec![v(cx, y0, hb + h), hz(ha, x0, cx + h), hz(hb, x0, cx + h)],
'╜' => vec![v(va, y0, cy + h), v(vb, y0, cy + h), hz(cy, x0, vb + h)],
'╝' => vec![
v(vb, y0, hb + h),
hz(hb, x0, vb + h),
v(va, y0, ha + h),
hz(ha, x0, va + h),
],
'╞' => vec![v(cx, y0, y1), hz(ha, cx - h, x1), hz(hb, cx - h, x1)],
'╟' => vec![v(va, y0, y1), v(vb, y0, y1), hz(cy, vb - h, x1)],
'╠' => vec![
v(va, y0, y1),
v(vb, y0, ha + h),
v(vb, hb - h, y1),
hz(ha, vb - h, x1),
hz(hb, vb - h, x1),
],
'╡' => vec![v(cx, y0, y1), hz(ha, x0, cx + h), hz(hb, x0, cx + h)],
'╢' => vec![v(va, y0, y1), v(vb, y0, y1), hz(cy, x0, va + h)],
'╣' => vec![
v(vb, y0, y1),
v(va, y0, ha + h),
v(va, hb - h, y1),
hz(ha, x0, va + h),
hz(hb, x0, va + h),
],
'╤' => vec![hz(ha, x0, x1), hz(hb, x0, x1), v(cx, hb - h, y1)],
'╥' => vec![hz(cy, x0, x1), v(va, cy - h, y1), v(vb, cy - h, y1)],
'╦' => vec![
hz(ha, x0, x1),
hz(hb, x0, va + h),
hz(hb, vb - h, x1),
v(va, hb - h, y1),
v(vb, hb - h, y1),
],
'╧' => vec![hz(ha, x0, x1), hz(hb, x0, x1), v(cx, y0, ha + h)],
'╨' => vec![hz(cy, x0, x1), v(va, y0, cy + h), v(vb, y0, cy + h)],
'╩' => vec![
hz(hb, x0, x1),
hz(ha, x0, va + h),
hz(ha, vb - h, x1),
v(va, y0, ha + h),
v(vb, y0, ha + h),
],
'╪' => vec![v(cx, y0, y1), hz(ha, x0, x1), hz(hb, x0, x1)],
'╫' => vec![v(va, y0, y1), v(vb, y0, y1), hz(cy, x0, x1)],
'╬' => vec![
v(va, y0, ha + h),
v(vb, y0, ha + h),
v(va, hb - h, y1),
v(vb, hb - h, y1),
hz(ha, x0, va + h),
hz(ha, vb - h, x1),
hz(hb, x0, va + h),
hz(hb, vb - h, x1),
],
_ => return None,
})
}
/// The rounded corners ╭ ╮ ╯ ╰ — two straight stubs to the cell edges plus
/// a quarter-circle band between them. `sx`/`sy` name the quadrant the arms
/// leave through: ╭ runs down (+1) and right (+1).
///
/// The band is a fan of small convex quads, one per arc step, NOT a single
/// outer-arc/inner-arc outline. That outline is concave, and gpui fills a
/// path as a triangle fan from its first vertex — a concave contour gets
/// its whole hollow covered, which rendered every corner as a solid
/// quarter-disc blob the first time around. Each quad is convex, so each
/// fills exactly itself, and at stroke widths of a few pixels twelve steps
/// are indistinguishable from a true arc.
fn rounded(&self, c: char) -> Option<Vec<Ink>> {
let (sx, sy): (f32, f32) = match c {
'╭' => (1., 1.),
'╮' => (-1., 1.),
'╯' => (-1., -1.),
'╰' => (1., -1.),
_ => return None,
};
let h = self.t / 2.;
// The largest radius that keeps the arc inside the cell on its short
// axis; the straight stubs cover whatever the long axis has left over.
let r = ((self.x1 - self.x0).min(self.y1 - self.y0) / 2.).max(h * 2.);
let (cx, cy) = (self.cx, self.cy);
let mut ink = Vec::new();
// Straight stubs from the arc's ends to the cell edges (zero-length
// when the radius already spans the half-axis).
if sy > 0. {
ink.push(self.rect(cx - h, cy + r, self.t, self.y1 - (cy + r)));
} else {
ink.push(self.rect(cx - h, self.y0, self.t, (cy - r) - self.y0));
}
if sx > 0. {
ink.push(self.rect(cx + r, cy - h, self.x1 - (cx + r), self.t));
} else {
ink.push(self.rect(self.x0, cy - h, (cx - r) - self.x0, self.t));
}
// The arc band, stepped from the vertical stub (θ=0) to the horizontal
// one (θ=π/2) around the arc centre one radius into the quadrant.
let (ax, ay) = (cx + sx * r, cy + sy * r);
let at = |radius: f32, theta: f32| {
let (x, y) = (
ax - sx * radius * theta.cos(),
ay - sy * radius * theta.sin(),
);
point(px(x), px(y))
};
// Adjacent segments OVERLAP by half a step. Butted edges would each be
// antialiased on their own, and two 50%-coverage edges composite to
// 75% opacity — a lighter hairline seam at every joint, which is what
// made the first cut of this arc read as lumpy next to kitty's. With
// the overlap every internal edge lands inside the neighbour's solid
// fill (opaque-over-opaque, invisible), leaving only the outer
// silhouette to antialias.
const STEPS: usize = 16;
let step = std::f32::consts::FRAC_PI_2 / STEPS as f32;
for i in 0..STEPS {
let t0 = step * i as f32;
let t1 = (step * (i as f32 + 1.5)).min(std::f32::consts::FRAC_PI_2);
let mut quad = gpui::Path::new(at(r + h, t0));
quad.line_to(at(r + h, t1));
quad.line_to(at(r - h, t1));
quad.line_to(at(r - h, t0));
ink.push(Ink::Path(quad));
}
Some(ink)
}
/// The dashed lines: n dashes, each 70% of its slot, centred. Deliberately
/// *not* edge-to-edge — a dashed line is supposed to read as broken, and
/// this matches how the font glyphs space them.
fn dashed(&self, c: char) -> Option<Vec<Ink>> {
let (n, heavy, vertical) = match c {
'╌' => (2, false, false),
'╍' => (2, true, false),
'╎' => (2, false, true),
'╏' => (2, true, true),
'┄' => (3, false, false),
'┅' => (3, true, false),
'┆' => (3, false, true),
'┇' => (3, true, true),
'┈' => (4, false, false),
'┉' => (4, true, false),
'┊' => (4, false, true),
'┋' => (4, true, true),
_ => return None,
};
let w = if heavy { self.t * 2. } else { self.t };
let (a0, a1) = if vertical {
(self.y0, self.y1)
} else {
(self.x0, self.x1)
};
let seg = (a1 - a0) / n as f32;
let ink = (0..n)
.map(|i| {
let s = a0 + seg * (i as f32 + 0.15);
let len = seg * 0.7;
if vertical {
self.rect(self.cx - w / 2., s, w, len)
} else {
self.rect(s, self.cy - w / 2., len, w)
}
})
.collect();
Some(ink)
}
/// The diagonals as corner-to-corner parallelograms. The offset is
/// vertical (not perpendicular) so every vertex stays inside the cell; its
/// length is scaled so the *perpendicular* stroke width still comes out at
/// the light thickness.
fn diagonal(&self, c: char) -> Option<Vec<Ink>> {
let (w, hgt) = (self.x1 - self.x0, self.y1 - self.y0);
let v = self.t * (w * w + hgt * hgt).sqrt() / w;
let p = |x: f32, y: f32| point(px(x), px(y));
let quad = |top_x: f32, bot_x: f32| {
let mut path = gpui::Path::new(p(top_x, self.y0));
path.line_to(p(top_x, self.y0 + v));
path.line_to(p(bot_x, self.y1));
path.line_to(p(bot_x, self.y1 - v));
Ink::Path(path)
};
Some(match c {
'' => vec![quad(self.x1, self.x0)],
'╲' => vec![quad(self.x0, self.x1)],
'' => vec![quad(self.x1, self.x0), quad(self.x0, self.x1)],
_ => return None,
})
}
/// The block elements U+2580U+259F: eighths, halves, quadrants, and the
/// ░▒▓ shades (a full-cell wash at a quarter / half / three quarters of the
/// foreground's alpha).
fn blocks(&self, c: char) -> Option<Vec<Ink>> {
let (x0, x1, y0, y1, cx, cy) = (self.x0, self.x1, self.y0, self.y1, self.cx, self.cy);
let (w, hgt) = (x1 - x0, y1 - y0);
let r = |x: f32, y: f32, ww: f32, hh: f32| self.rect(x, y, ww, hh);
let ul = || r(x0, y0, cx - x0, cy - y0);
let ur = || r(cx, y0, x1 - cx, cy - y0);
let ll = || r(x0, cy, cx - x0, y1 - cy);
let lr = || r(cx, cy, x1 - cx, y1 - cy);
Some(match c {
'▀' => vec![r(x0, y0, w, hgt / 2.)],
// ▁ (1/8) through █ (the full block): lower k eighths.
'▁'..='█' => {
let k = (c as u32 - 0x2580) as f32;
let hh = hgt * k / 8.;
vec![r(x0, y1 - hh, w, hh)]
}
// ▉ (7/8) through ▏ (1/8): left k eighths.
'▉'..='▏' => {
let k = (0x2590 - c as u32) as f32;
vec![r(x0, y0, w * k / 8., hgt)]
}
'▐' => vec![r(cx, y0, x1 - cx, hgt)],
'░' => vec![Ink::Shade(self.rectb(x0, y0, w, hgt), 0.25)],
'▒' => vec![Ink::Shade(self.rectb(x0, y0, w, hgt), 0.5)],
'▓' => vec![Ink::Shade(self.rectb(x0, y0, w, hgt), 0.75)],
'▔' => vec![r(x0, y0, w, hgt / 8.)],
'▕' => vec![r(x1 - w / 8., y0, w / 8., hgt)],
'▖' => vec![ll()],
'▗' => vec![lr()],
'▘' => vec![ul()],
'▙' => vec![ul(), ll(), lr()],
'▚' => vec![ul(), lr()],
'▛' => vec![ul(), ur(), ll()],
'▜' => vec![ul(), ur(), lr()],
'▝' => vec![ur()],
'▞' => vec![ur(), ll()],
'▟' => vec![ur(), ll(), lr()],
_ => return None,
})
}
}
/// Decode the light/heavy arm combinations: the solid lines, corners, tees and
/// crosses of U+2500U+254B, and the half/mixed lines of U+2574U+257F. Order
/// is (up, down, left, right).
fn arms_of(c: char) -> Option<(Arm, Arm, Arm, Arm)> {
use Arm::{Heavy as H, Light as L, None as N};
Some(match c {
'─' => (N, N, L, L),
'━' => (N, N, H, H),
'│' => (L, L, N, N),
'┃' => (H, H, N, N),
'┌' => (N, L, N, L),
'┍' => (N, L, N, H),
'┎' => (N, H, N, L),
'┏' => (N, H, N, H),
'┐' => (N, L, L, N),
'┑' => (N, L, H, N),
'┒' => (N, H, L, N),
'┓' => (N, H, H, N),
'└' => (L, N, N, L),
'┕' => (L, N, N, H),
'┖' => (H, N, N, L),
'┗' => (H, N, N, H),
'┘' => (L, N, L, N),
'┙' => (L, N, H, N),
'┚' => (H, N, L, N),
'┛' => (H, N, H, N),
'├' => (L, L, N, L),
'┝' => (L, L, N, H),
'┞' => (H, L, N, L),
'┟' => (L, H, N, L),
'┠' => (H, H, N, L),
'┡' => (H, L, N, H),
'┢' => (L, H, N, H),
'┣' => (H, H, N, H),
'┤' => (L, L, L, N),
'┥' => (L, L, H, N),
'┦' => (H, L, L, N),
'┧' => (L, H, L, N),
'┨' => (H, H, L, N),
'┩' => (H, L, H, N),
'┪' => (L, H, H, N),
'┫' => (H, H, H, N),
'┬' => (N, L, L, L),
'┭' => (N, L, H, L),
'┮' => (N, L, L, H),
'┯' => (N, L, H, H),
'┰' => (N, H, L, L),
'┱' => (N, H, H, L),
'┲' => (N, H, L, H),
'┳' => (N, H, H, H),
'┴' => (L, N, L, L),
'┵' => (L, N, H, L),
'┶' => (L, N, L, H),
'┷' => (L, N, H, H),
'┸' => (H, N, L, L),
'┹' => (H, N, H, L),
'┺' => (H, N, L, H),
'┻' => (H, N, H, H),
'┼' => (L, L, L, L),
'┽' => (L, L, H, L),
'┾' => (L, L, L, H),
'┿' => (L, L, H, H),
'╀' => (H, L, L, L),
'╁' => (L, H, L, L),
'╂' => (H, H, L, L),
'╃' => (H, L, H, L),
'╄' => (H, L, L, H),
'╅' => (L, H, H, L),
'╆' => (L, H, L, H),
'╇' => (H, L, H, H),
'╈' => (L, H, H, H),
'╉' => (H, H, H, L),
'╊' => (H, H, L, H),
'╋' => (H, H, H, H),
'╴' => (N, N, L, N),
'╵' => (L, N, N, N),
'╶' => (N, N, N, L),
'╷' => (N, L, N, N),
'╸' => (N, N, H, N),
'╹' => (H, N, N, N),
'╺' => (N, N, N, H),
'╻' => (N, H, N, N),
'╼' => (N, N, L, H),
'╽' => (L, H, N, N),
'╾' => (N, N, H, L),
'╿' => (H, L, N, N),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// A cell with the proportions the bug shipped in: a 15px font's ~9px
/// advance stretched to a 21px line by `line_height: 1.4`.
fn cell() -> Bounds<Pixels> {
Bounds::new(point(px(10.), px(20.)), size(px(9.), px(21.)))
}
/// min_x / max_x / min_y / max_y over every rect corner and path vertex.
fn extents(ink: &[Ink]) -> (f32, f32, f32, f32) {
let (mut nx, mut xx, mut ny, mut xy) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN);
let mut visit = |x: f32, y: f32| {
nx = nx.min(x);
xx = xx.max(x);
ny = ny.min(y);
xy = xy.max(y);
};
for i in ink {
match i {
Ink::Rect(b) | Ink::Shade(b, _) => {
let (x, y) = (b.origin.x.as_f32(), b.origin.y.as_f32());
visit(x, y);
visit(x + b.size.width.as_f32(), y + b.size.height.as_f32());
}
Ink::Path(p) => {
for v in &p.vertices {
visit(v.xy_position.x.as_f32(), v.xy_position.y.as_f32());
}
}
}
}
(nx, xx, ny, xy)
}
/// Every character in U+2500U+259F must decode to native ink — one that
/// silently falls through to the font reintroduces the row-boundary gap
/// for exactly that character, which is worse than uniform behavior in
/// either direction.
#[test]
fn the_whole_range_is_covered() {
for cp in 0x2500u32..=0x259f {
let c = char::from_u32(cp).unwrap();
assert!(
glyph(c, cell()).is_some(),
"U+{cp:04X} {c} fell through to the font"
);
}
}
/// Nothing may paint outside its own cell: box characters tile, and one
/// cell's overshoot is its neighbor's artifact.
#[test]
fn ink_stays_inside_the_cell() {
let b = cell();
let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32());
let (x1, y1) = (x0 + b.size.width.as_f32(), y0 + b.size.height.as_f32());
for cp in 0x2500u32..=0x259f {
let c = char::from_u32(cp).unwrap();
let (nx, xx, ny, xy) = extents(&glyph(c, b).unwrap());
assert!(
nx >= x0 - 0.01 && xx <= x1 + 0.01 && ny >= y0 - 0.01 && xy <= y1 + 0.01,
"U+{cp:04X} {c} paints outside the cell: \
x {nx}..{xx} vs {x0}..{x1}, y {ny}..{xy} vs {y0}..{y1}"
);
}
}
/// The regression this module exists for: every arm must reach its cell
/// edge *exactly*, so vertical runs connect across the line-height gap and
/// horizontal runs connect across cells. Checked for the whole arms table
/// — including the mixed and half lines — not just `│`.
#[test]
fn arms_reach_their_edges() {
let b = cell();
let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32());
let (x1, y1) = (x0 + b.size.width.as_f32(), y0 + b.size.height.as_f32());
for cp in 0x2500u32..=0x259f {
let c = char::from_u32(cp).unwrap();
let Some((u, d, l, r)) = arms_of(c) else {
continue;
};
let (nx, xx, ny, xy) = extents(&glyph(c, b).unwrap());
if u != Arm::None {
assert_eq!(ny, y0, "{c}: up arm misses the top edge");
}
if d != Arm::None {
assert_eq!(xy, y1, "{c}: down arm misses the bottom edge");
}
if l != Arm::None {
assert_eq!(nx, x0, "{c}: left arm misses the left edge");
}
if r != Arm::None {
assert_eq!(xx, x1, "{c}: right arm misses the right edge");
}
}
}
/// Same edge guarantee for the shapes that aren't plain arms: the doubles,
/// the rounded corners, and the diagonals all tile too.
#[test]
fn doubles_rounded_and_diagonals_reach_their_edges() {
let b = cell();
let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32());
let (x1, y1) = (x0 + b.size.width.as_f32(), y0 + b.size.height.as_f32());
// (char, up, down, left, right)
let expect = [
('═', false, false, true, true),
('║', true, true, false, false),
('╔', false, true, false, true),
('╬', true, true, true, true),
('╠', true, true, false, true),
('╦', false, true, true, true),
('╭', false, true, false, true),
('╮', false, true, true, false),
('╯', true, false, true, false),
('╰', true, false, false, true),
('', true, true, true, true),
('╲', true, true, true, true),
];
for (c, u, d, l, r) in expect {
let (nx, xx, ny, xy) = extents(&glyph(c, b).unwrap());
if u {
assert_eq!(ny, y0, "{c}: misses the top edge");
}
if d {
assert_eq!(xy, y1, "{c}: misses the bottom edge");
}
if l {
assert_eq!(nx, x0, "{c}: misses the left edge");
}
if r {
assert_eq!(xx, x1, "{c}: misses the right edge");
}
}
}
/// ╬ is four corner pieces around an open centre — the one double junction
/// where "just extend everything through the middle" would visibly lie.
#[test]
fn double_cross_keeps_its_open_centre() {
let b = cell();
let cx = b.origin.x.as_f32() + b.size.width.as_f32() / 2.;
let cy = b.origin.y.as_f32() + b.size.height.as_f32() / 2.;
for i in glyph('╬', b).unwrap() {
let Ink::Rect(r) = i else {
panic!("╬ should be rects only");
};
let (x, y) = (r.origin.x.as_f32(), r.origin.y.as_f32());
let inside = cx > x
&& cx < x + r.size.width.as_f32()
&& cy > y
&& cy < y + r.size.height.as_f32();
assert!(!inside, "╬'s centre is covered");
}
}
/// Blocks: the full block is the full cell, the halves are exact halves,
/// and the shades wash the whole cell at their nominal alpha.
#[test]
fn blocks_cover_their_nominal_area() {
let b = cell();
let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32());
let (w, h) = (b.size.width.as_f32(), b.size.height.as_f32());
let (nx, xx, ny, xy) = extents(&glyph('█', b).unwrap());
assert_eq!(
(nx, xx, ny, xy),
(x0, x0 + w, y0, y0 + h),
"█ isn't the full cell"
);
let (_, _, ny, xy) = extents(&glyph('▀', b).unwrap());
assert_eq!((ny, xy), (y0, y0 + h / 2.), "▀ isn't the top half");
let (_, _, ny, xy) = extents(&glyph('▄', b).unwrap());
assert_eq!((ny, xy), (y0 + h / 2., y0 + h), "▄ isn't the bottom half");
for (c, alpha) in [('░', 0.25), ('▒', 0.5), ('▓', 0.75)] {
let ink = glyph(c, b).unwrap();
assert_eq!(ink.len(), 1);
let Ink::Shade(r, a) = &ink[0] else {
panic!("{c} should be a shade");
};
assert_eq!(*a, alpha);
assert_eq!(r.size.width.as_f32(), w, "{c} doesn't wash the full cell");
}
}
/// Heavy strokes must actually be heavier than light ones, and a light
/// stroke never vanishes (≥ 1px) however small the cell.
#[test]
fn stroke_weights_are_ordered_and_visible() {
let light = {
let Ink::Rect(r) = &glyph('│', cell()).unwrap()[0] else {
panic!()
};
r.size.width.as_f32()
};
let heavy = {
let Ink::Rect(r) = &glyph('┃', cell()).unwrap()[0] else {
panic!()
};
r.size.width.as_f32()
};
assert!(light >= 1., "light stroke thinner than a pixel");
assert!(heavy > light, "heavy stroke isn't heavier");
// A pathologically narrow cell still yields visible ink.
let tiny = Bounds::new(point(px(0.), px(0.)), size(px(2.), px(4.)));
let Ink::Rect(r) = &glyph('│', tiny).unwrap()[0] else {
panic!()
};
assert!(r.size.width.as_f32() >= 1.);
}
}
+24 -4
View File
@@ -834,15 +834,35 @@ fn paint_glyphs(
// for a single glyph — it paints at the run origin regardless.
RowSeg::Solo { col } => {
let cell = &buf[row_base + col];
let cell_bounds = Bounds::new(
point(geom.origin.x + geom.cell_width * (col as f32), y),
size(geom.cell_width, geom.line_height),
);
if let Some(shape) = PowerlineShape::of(cell.c) {
let cell_bounds = Bounds::new(
point(geom.origin.x + geom.cell_width * (col as f32), y),
size(geom.cell_width, geom.line_height),
);
let path = powerline_path(cell_bounds, shape);
window.paint_path(path, GlyphStyle::of(cell).fg);
continue;
}
// Box-drawing / block characters paint as native geometry
// sized to the actual (line-height-stretched) cell. A font
// glyph only covers the font's own line height, which is
// what broke every vertical run of `│`/`╭`/`╰` into dashes
// at line_height > 1.0 — see `boxdraw`.
if let Some(ink) = super::boxdraw::glyph(cell.c, cell_bounds) {
let fg = GlyphStyle::of(cell).fg;
for piece in ink {
match piece {
super::boxdraw::Ink::Rect(r) => window.paint_quad(fill(r, fg)),
super::boxdraw::Ink::Shade(r, alpha) => {
let mut c = fg;
c.a *= alpha;
window.paint_quad(fill(r, c));
}
super::boxdraw::Ink::Path(p) => window.paint_path(p, fg),
}
}
continue;
}
(col, 1, char_string(cell.c), None, true)
}
// Same pinning as the batched runs, just for one base: two
+1
View File
@@ -15,6 +15,7 @@
//! `TermSize` / `RemoteTerminal` are re-exported here so the rest of the crate
//! can refer to `terminal::RemoteTerminal` without reaching into submodules.
mod boxdraw;
mod cmd_editor;
mod completion;
pub mod element;