From d39199987fce0a84979620f1ab2b6bf7cae40647 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Wed, 8 Jul 2026 13:27:59 +0800 Subject: [PATCH] fix(terminal): draw powerline separators natively, anchor bundled Hack in font fallbacks (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- src/terminal/element.rs | 191 +++++++++++++++++++++++++++++++++++++++- src/terminal/view.rs | 56 +++++++++++- 2 files changed, 241 insertions(+), 6 deletions(-) diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 93fa6b2a..5efe6c3f 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -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 { + 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, shape: PowerlineShape) -> gpui::Path { + 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, b: Point, c: Point| { + 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. diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 38f5a308..059ce4ee 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -366,6 +366,22 @@ fn clipboard_paste_text(item: &ClipboardItem) -> Option { 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 { + 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, @@ -419,7 +435,7 @@ impl TerminalView { // Font Mono + Apple Color Emoji at 13px. let config = cx.global::(); 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]