fix(terminal): draw powerline separators natively, anchor bundled Hack in font fallbacks (#19)

Prompt themes (powerlevel10k, oh-my-posh, oh-my-zsh) render their
segment separators and status glyphs through whatever face the font
fallback chain resolves, and two failure modes followed (#17):

- Powerline separators (U+E0B0-E0BE) resolved from a fallback face
  render at *that face's* advance, not the cell width — narrow wedges
  that no longer meet their segment backgrounds, or tofu with no Nerd
  Font installed at all.
- A glyph nothing in the configured chain covers falls through to the
  OS cascade, which can serve a proportional glyph wider than the
  cell; paint_glyphs' per-cell clip then truncates it — the severed
  U+279C arrow in the report (pixel forensics: the cut is a zero-
  antialiasing vertical edge exactly one cell after the glyph origin).

Fix, the approach Warp (stretchable SVGs) and kitty (programmatic
glyphs) settled on:

- The eight solid powerline separators — sharp triangles E0B0/E0B2,
  round caps E0B4/E0B6, slants E0B8/BA/BC/BE — are now drawn as gpui
  fill paths sized to the exact cell: pixel-perfect for every
  font/size/line-height combination, no font dependency at all. The
  thin/outline variants stay on the font path (hairline strokes can't
  be a fill, and the bundled Hack covers the common ones).
- fallback_chain() appends the bundled "Hack" to every font fallback
  list, so prompt symbols (U+279C, U+276F, box drawing, the sharp
  wedges) resolve against a monospace-fitting face before ever
  reaching the OS cascade lottery.

Claude-Session: https://claude.ai/code/session_01ABey161AUxhgmJC3PRoYtF

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-07-08 13:27:59 +08:00
committed by GitHub
co-authored by l0ng-ai
parent bf9410d903
commit d39199987f
2 changed files with 241 additions and 6 deletions
+189 -2
View File
@@ -595,6 +595,98 @@ fn char_string(c: char) -> SharedString {
})
}
/// The Nerd Font powerline separators tty7 draws natively — as gpui paths
/// sized to the exact cell — instead of rasterizing a font glyph.
///
/// These glyphs are pure geometry that only reads right when it fills the cell
/// edge-to-edge: prompt themes (powerlevel10k, oh-my-posh, starship) butt them
/// against colored segment backgrounds, so any gap or overshoot shows as a
/// seam. Font rasterization can't guarantee that fit — the primary font rarely
/// covers these codepoints, and a fallback face renders them at *its own*
/// advance, narrower or wider than our cell (issue #17: separators at
/// two-thirds width from a mismatched fallback). Building the shape from the
/// cell rect makes it exact for every font/size combination — the approach
/// Warp (bundled stretchable SVGs) and kitty (programmatic glyphs) settled on.
/// The thin/outline variants (U+E0B1, U+E0B3, …) stay on the font path: they
/// are hairline strokes, not fills, and the bundled Hack covers the common
/// ones.
#[derive(Clone, Copy, PartialEq, Debug)]
enum PowerlineShape {
/// U+E0B0 — solid right-pointing triangle (the classic left separator).
TriangleRight,
/// U+E0B2 — solid left-pointing triangle (right-prompt separator).
TriangleLeft,
/// U+E0B4 — solid half-circle bulging right (rounded cap/separator).
HalfCircleRight,
/// U+E0B6 — solid half-circle bulging left.
HalfCircleLeft,
/// U+E0B8 — solid slant triangle filling the lower-left half.
SlantLowerLeft,
/// U+E0BA — solid slant triangle filling the lower-right half.
SlantLowerRight,
/// U+E0BC — solid slant triangle filling the upper-left half.
SlantUpperLeft,
/// U+E0BE — solid slant triangle filling the upper-right half.
SlantUpperRight,
}
impl PowerlineShape {
fn of(c: char) -> Option<Self> {
Some(match c {
'\u{e0b0}' => Self::TriangleRight,
'\u{e0b2}' => Self::TriangleLeft,
'\u{e0b4}' => Self::HalfCircleRight,
'\u{e0b6}' => Self::HalfCircleLeft,
'\u{e0b8}' => Self::SlantLowerLeft,
'\u{e0ba}' => Self::SlantLowerRight,
'\u{e0bc}' => Self::SlantUpperLeft,
'\u{e0be}' => Self::SlantUpperRight,
_ => return None,
})
}
}
/// Build the fill path for one powerline shape spanning exactly `bounds`
/// (one cell). Pure geometry, split from the painting so tests can check it.
///
/// The half-circles approximate each quarter-ellipse with a single quadratic
/// Bézier through the corner control point — within ~7% of a true ellipse,
/// indistinguishable at cell sizes and the same trade kitty makes.
fn powerline_path(bounds: Bounds<Pixels>, shape: PowerlineShape) -> gpui::Path<Pixels> {
let (x0, y0) = (bounds.origin.x, bounds.origin.y);
let (x1, y1) = (x0 + bounds.size.width, y0 + bounds.size.height);
let ymid = y0 + bounds.size.height / 2.;
let tri = |a: Point<Pixels>, b: Point<Pixels>, c: Point<Pixels>| {
let mut p = gpui::Path::new(a);
p.line_to(b);
p.line_to(c);
p
};
match shape {
PowerlineShape::TriangleRight => tri(point(x0, y0), point(x1, ymid), point(x0, y1)),
PowerlineShape::TriangleLeft => tri(point(x1, y0), point(x0, ymid), point(x1, y1)),
PowerlineShape::SlantLowerLeft => tri(point(x0, y0), point(x1, y1), point(x0, y1)),
PowerlineShape::SlantLowerRight => tri(point(x1, y0), point(x1, y1), point(x0, y1)),
PowerlineShape::SlantUpperLeft => tri(point(x0, y0), point(x1, y0), point(x0, y1)),
PowerlineShape::SlantUpperRight => tri(point(x0, y0), point(x1, y0), point(x1, y1)),
PowerlineShape::HalfCircleRight => {
// Flat edge on the left; the fan fill from the start point closes
// it implicitly (start → last point is that straight edge).
let mut p = gpui::Path::new(point(x0, y0));
p.curve_to(point(x1, ymid), point(x1, y0));
p.curve_to(point(x0, y1), point(x1, y1));
p
}
PowerlineShape::HalfCircleLeft => {
let mut p = gpui::Path::new(point(x1, y0));
p.curve_to(point(x0, ymid), point(x0, y0));
p.curve_to(point(x1, y1), point(x0, y1));
p
}
}
}
/// Paint glyphs as per-row batched runs where safe, single cells otherwise.
///
/// Merging cells into multi-char `shape_line` runs causes drift whenever a
@@ -606,7 +698,8 @@ fn char_string(c: char) -> SharedString {
/// Single-width glyphs that may come from a fallback face (box drawing, …)
/// still paint cell-by-cell: their advances are unpredictable and mixing
/// widths inside one batch would break `force_width`'s uniform-column
/// assumption.
/// assumption. Powerline separators skip fonts entirely — see
/// [`PowerlineShape`].
fn paint_glyphs(
window: &mut Window,
cx: &mut App,
@@ -660,7 +753,19 @@ fn paint_glyphs(
// Always exactly one column now — anything with a trailing
// spacer became a Wide run in `segment_row`. No `force_width`
// for a single glyph — it paints at the run origin regardless.
RowSeg::Solo { col } => (col, 1, char_string(buf[row_base + col].c), None),
RowSeg::Solo { col } => {
let cell = &buf[row_base + col];
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;
}
(col, 1, char_string(cell.c), None)
}
};
let style = GlyphStyle::of(&buf[row_base + start]);
@@ -1682,6 +1787,88 @@ mod tests {
);
}
#[test]
fn powerline_shape_maps_only_the_solid_separators() {
// The eight solid separators are drawn natively.
for (c, shape) in [
('\u{e0b0}', PowerlineShape::TriangleRight),
('\u{e0b2}', PowerlineShape::TriangleLeft),
('\u{e0b4}', PowerlineShape::HalfCircleRight),
('\u{e0b6}', PowerlineShape::HalfCircleLeft),
('\u{e0b8}', PowerlineShape::SlantLowerLeft),
('\u{e0ba}', PowerlineShape::SlantLowerRight),
('\u{e0bc}', PowerlineShape::SlantUpperLeft),
('\u{e0be}', PowerlineShape::SlantUpperRight),
] {
assert_eq!(PowerlineShape::of(c), Some(shape), "U+{:04X}", c as u32);
}
// The thin/outline variants are hairline strokes — a filled gpui path
// can't draw those, so they stay on the font path — as do neighboring
// codepoints and ordinary prompt symbols.
for c in [
'\u{e0b1}', '\u{e0b3}', '\u{e0b5}', '\u{e0b7}', '\u{e0b9}', '\u{e0bb}', '\u{e0bd}',
'\u{e0bf}', '\u{e0a0}', '\u{e0c0}', '\u{2500}', '', '➜',
] {
assert_eq!(PowerlineShape::of(c), None, "U+{:04X}", c as u32);
}
}
#[test]
fn powerline_path_fills_exactly_one_cell() {
// Native drawing exists to guarantee edge-to-edge fit: every vertex of
// every shape must stay inside the cell it was given (no overshoot into
// a neighbor), and the fill must actually reach both horizontal edges
// (no two-thirds-width separators — the issue #17 symptom).
let (x0, y0, w, h) = (px(10.), px(20.), px(9.), px(21.));
let bounds = Bounds::new(point(x0, y0), size(w, h));
for shape in [
PowerlineShape::TriangleRight,
PowerlineShape::TriangleLeft,
PowerlineShape::HalfCircleRight,
PowerlineShape::HalfCircleLeft,
PowerlineShape::SlantLowerLeft,
PowerlineShape::SlantLowerRight,
PowerlineShape::SlantUpperLeft,
PowerlineShape::SlantUpperRight,
] {
let path = powerline_path(bounds, shape);
assert!(!path.vertices.is_empty(), "{shape:?} produced no geometry");
let (mut min_x, mut max_x) = (px(f32::MAX), px(f32::MIN));
for v in &path.vertices {
let p = v.xy_position;
assert!(
p.x >= x0 && p.x <= x0 + w && p.y >= y0 && p.y <= y0 + h,
"{shape:?} vertex at {p:?} escapes the cell"
);
min_x = min_x.min(p.x);
max_x = max_x.max(p.x);
}
assert_eq!(min_x, x0, "{shape:?} does not reach the left cell edge");
assert_eq!(
max_x,
x0 + w,
"{shape:?} does not reach the right cell edge"
);
}
}
#[test]
fn segment_row_keeps_powerline_separators_solo() {
// The native-draw intercept lives in the Solo arm of `paint_glyphs`;
// if separators ever started batching into Run/Wide segments they'd
// silently bypass it and fall back to font rasterization.
let row = vec![cell('a'), cell('\u{e0b0}'), cell('\u{e0b4}'), cell('b')];
assert_eq!(
segment_row(&row),
[
run(0, 1, "a"),
RowSeg::Solo { col: 1 },
RowSeg::Solo { col: 2 },
run(3, 1, "b")
]
);
}
#[test]
fn segment_row_batches_consecutive_wide_glyphs() {
// A CJK phrase batches into one Wide run covering glyphs + spacers.
+52 -4
View File
@@ -366,6 +366,22 @@ fn clipboard_paste_text(item: &ClipboardItem) -> Option<String> {
item.text()
}
/// The font fallback chain: the user's configured list with the bundled "Hack"
/// pinned to the end. Hack ships inside the binary (`register_bundled_fonts`)
/// and covers the symbols prompt themes lean on — ``, `➜`, box drawing, the
/// sharp powerline wedges — with ink that fits a monospace advance. Without
/// this anchor, a custom `font_family` that lacks one of those codepoints
/// falls through the whole configured list into the OS cascade, which happily
/// serves a proportional glyph wider than the cell that `paint_glyphs`'
/// per-cell clip then truncates (issue #17's severed `➜`).
fn fallback_chain(family: &str, configured: &[String]) -> Vec<String> {
let mut chain = configured.to_vec();
if family != "Hack" && !chain.iter().any(|f| f == "Hack") {
chain.push("Hack".to_string());
}
chain
}
impl TerminalView {
pub fn new(
working_directory: Option<std::path::PathBuf>,
@@ -419,7 +435,7 @@ impl TerminalView {
// Font Mono + Apple Color Emoji at 13px.
let config = cx.global::<Config>();
let font_family = config.font_family.clone();
let fallbacks = config.font_fallbacks.clone();
let fallbacks = fallback_chain(&font_family, &config.font_fallbacks);
let font_size = px(config.font_size);
let line_height_mul = config.line_height;
let mut font = gpui::font(font_family);
@@ -3424,15 +3440,47 @@ fn smooth_scroll_step(offset: usize, frac: f32, delta: f32, max: usize) -> (i32,
#[cfg(test)]
mod tests {
use super::{
WheelRoute, clipboard_paste_text, display_width, encode_mouse, fig_icon_emoji,
fig_icon_glyph, focus_report_bytes, menu_layout, paste_bytes, shell_escape_path,
smooth_scroll_step, trim_trailing_spaces, wheel_route, wrapped_click_index,
WheelRoute, clipboard_paste_text, display_width, encode_mouse, fallback_chain,
fig_icon_emoji, fig_icon_glyph, focus_report_bytes, menu_layout, paste_bytes,
shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route,
wrapped_click_index,
};
use alacritty_terminal::term::TermMode;
use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers};
use gpui_component::IconName;
use std::path::PathBuf;
/// The bundled Hack always anchors the fallback chain so prompt symbols
/// (`➜`, ``, powerline wedges) never fall through to the OS cascade —
/// unless the user already covers it as primary or in their own list.
#[test]
fn fallback_chain_pins_bundled_hack_last() {
let configured = vec!["Menlo".to_string(), "Apple Color Emoji".to_string()];
// A custom primary that may lack the prompt symbols → Hack appended.
assert_eq!(
fallback_chain("JetBrains Mono", &configured),
["Menlo", "Apple Color Emoji", "Hack"]
);
// Hack as the primary face already covers everything it could add.
assert_eq!(
fallback_chain("Hack", &configured),
["Menlo", "Apple Color Emoji"]
);
// A user who lists Hack explicitly keeps their chosen position.
let with_hack = vec!["Hack".to_string(), "Menlo".to_string()];
assert_eq!(fallback_chain("SF Mono", &with_hack), ["Hack", "Menlo"]);
// "Hack Nerd Font" is a different family — the bundled face still lands.
assert_eq!(
fallback_chain("Hack Nerd Font", &[]),
["Hack"],
"a Hack-prefixed family name must not suppress the bundled anchor"
);
}
/// The wheel reaches the app only through the modes it negotiated: mouse
/// reporting first, alternate scroll second, local scrollback otherwise.
#[test]