From c69db5fa83b407d3a35aaff98244cdbc76a309f9 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 27 Jul 2026 17:16:53 +0800 Subject: [PATCH 1/5] feat(theme): add One Dark Pro built-in theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add One Dark Pro as a ninth built-in, slotted alphabetically among the dark themes: background #282c34, foreground #abb2bf, the classic One Dark syntax palette for the normal ANSI slots and One Dark Pro's bright variants for the bright ones. Two seeds deliberately diverge from the VS Code theme's terminal set: * The accent is the editor cursor/focus blue #528bff, not the syntax blue #61afef — the accent doubles as the switch's checked track, and #61afef sits at the same luminance as the #abb2bf knob (1.11:1). * The normal red is the classic #e06c75, not the Pro terminal #e05561 — conditioned for AA the latter lands within 37 channel-distance of the orange-yellow #d18f52, under the 40 separability floor danger/warning must clear. Also bumps the theme count in README and docs (eight → nine). --- README.md | 2 +- docs/features.md | 2 +- docs/features.zh-CN.md | 2 +- src/ui/presets.rs | 38 +++++++++++++++++++++++++++++++++++--- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 15278946..160b493e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Native builds for each platform on [**Releases**](https://github.com/l0ng-ai/tty | | | |---|---| | **Input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | -| **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · eight themes · IME | +| **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · nine themes · IME | | **Coding agents** | per-pane agent detection (~17 CLIs): status dot, notifications, branch + diff, resume after reboot, tray icon that signals "needs your input" | | **SSH** | native russh stack: profiles with keychain secrets, SFTP panel, port forwarding, jump hosts | diff --git a/docs/features.md b/docs/features.md index 4f778752..8eccfa0b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -19,7 +19,7 @@ - **Command palette** ⌘ P · scrollback search ⌘ F - **⌘/Ctrl-click links** (⌘ on macOS, Ctrl on Windows/Linux) · desktop notifications · copy on select (opt-in, Settings → Terminal → Clipboard) - **Smart double-click selection** — double-click grabs the whole URL, file path, bracket/quote pair, or dictionary-segmented CJK word under the cursor; Shift-click extends a selection (toggle in Settings → Terminal → Mouse; word separators via `word_separators` in `config.json`) -- **Eight themes, plus your own** — YAML seed themes with solid, gradient, or image backgrounds; iTerm2 `.itermcolors` import; in-app color editor with a background-image picker +- **Nine themes, plus your own** — YAML seed themes with solid, gradient, or image backgrounds; iTerm2 `.itermcolors` import; in-app color editor with a background-image picker - **Sync with system** — Settings → Appearance; pick separate light and dark themes and tty7 follows the OS appearance live (`theme_follow_system`, `theme_preset_light` / `theme_preset_dark` in `config.json`) - **Window opacity & blur** — Settings → Appearance → Window; applies to every theme, *Follow theme* returns to the theme's own `opacity` / `blur` - **CJK / IME input** diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 9c59192f..83b79b09 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -19,7 +19,7 @@ - **命令面板** ⌘ P · 回滚搜索 ⌘ F - **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 终端 → 剪贴板) - **智能双击选中** —— 双击直接选中整条 URL、文件路径、括号/引号对,中文按词典分词出词;Shift 点击扩展选区(设置 → 终端 → 鼠标可开关;分隔符用 `config.json` 的 `word_separators` 配置) -- **8 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择 +- **9 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择 - **跟随系统外观** — 设置 → Appearance;分别选好浅色和深色主题,tty7 随系统深浅模式实时切换(`config.json` 中的 `theme_follow_system`、`theme_preset_light` / `theme_preset_dark`) - **窗口透明与模糊** — 设置 → Appearance → Window;对所有主题生效,*Follow theme* 恢复主题自带的 `opacity` / `blur` - **CJK / 输入法输入** diff --git a/src/ui/presets.rs b/src/ui/presets.rs index 80e94963..aac14015 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -1109,7 +1109,7 @@ struct BuiltinSpec { } /// A hand-picked set of familiar terminal palettes. -static BUILTINS: [BuiltinSpec; 8] = [ +static BUILTINS: [BuiltinSpec; 9] = [ BuiltinSpec { id: "light", name: "Light", @@ -1295,6 +1295,35 @@ static BUILTINS: [BuiltinSpec; 8] = [ (0xff, 0xff, 0xff), ], }, + BuiltinSpec { + id: "one_dark_pro", + name: "One Dark Pro", + background: 0x282c34, + foreground: 0xabb2bf, + // The editor cursor / focus blue, not the syntax blue `#61afef`: the + // accent doubles as the switch's checked track, and `#61afef` sits at + // the same luminance as the `#abb2bf` knob (1.11:1 — invisible). + accent: 0x528bff, + caret: None, + ansi16: [ + (0x3f, 0x44, 0x51), + (0xe0, 0x6c, 0x75), + (0x98, 0xc3, 0x79), + (0xe5, 0xc0, 0x7b), + (0x61, 0xaf, 0xef), + (0xc6, 0x78, 0xdd), + (0x56, 0xb6, 0xc2), + (0xab, 0xb2, 0xbf), + (0x5c, 0x63, 0x70), + (0xff, 0x61, 0x6e), + (0xa5, 0xe0, 0x75), + (0xf0, 0xa4, 0x5d), + (0x4d, 0xc4, 0xff), + (0xde, 0x73, 0xff), + (0x4c, 0xd1, 0xe0), + (0xe6, 0xe6, 0xe6), + ], + }, BuiltinSpec { id: "rose_pine", name: "Rosé Pine", @@ -1341,7 +1370,7 @@ mod tests { } /// Brightness is inferred correctly: the four light built-ins classify light, - /// the four dark ones dark. + /// the five dark ones dark. #[test] fn dark_is_inferred_from_background() { let dark: Vec<_> = builtins() @@ -1349,7 +1378,10 @@ mod tests { .filter(|t| t.dark) .map(|t| t.id) .collect(); - assert_eq!(dark, ["dark", "dracula", "harbor", "rose_pine"]); + assert_eq!( + dark, + ["dark", "dracula", "harbor", "one_dark_pro", "rose_pine"] + ); } /// The selection surface must stay a *tint* — decisively on the background's From ee921c4879a0ee17138ddd65d27ef2b9363303db Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 27 Jul 2026 17:54:47 +0800 Subject: [PATCH 2/5] fix(render): draw box-drawing and block characters natively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/terminal/boxdraw.rs | 719 ++++++++++++++++++++++++++++++++++++++++ src/terminal/element.rs | 28 +- src/terminal/mod.rs | 1 + 3 files changed, 744 insertions(+), 4 deletions(-) create mode 100644 src/terminal/boxdraw.rs diff --git a/src/terminal/boxdraw.rs b/src/terminal/boxdraw.rs new file mode 100644 index 00000000..e251388c --- /dev/null +++ b/src/terminal/boxdraw.rs @@ -0,0 +1,719 @@ +//! Native box-drawing: the U+2500–U+257F box characters and U+2580–U+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), + /// A rectangle at a fraction of the foreground's alpha — the ░▒▓ shades, + /// which fake their dither by translucency exactly as WezTerm does. + Shade(Bounds, f32), + /// A filled path — rounded corners and diagonals, the two shapes a + /// rectangle can't express. + Path(gpui::Path), +} + +/// 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) -> Option> { + 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) -> 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 { + 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 { + 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+2550–U+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> { + 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> { + 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> { + 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> { + 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+2580–U+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> { + 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+2500–U+254B, and the half/mixed lines of U+2574–U+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 { + 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+2500–U+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.); + } +} diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 5e18f837..16294bc9 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -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 diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 94bda0fe..d7791529 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -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; From fac3e4492e3f7ae97e2b1c5633057adb75593da8 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 27 Jul 2026 18:03:35 +0800 Subject: [PATCH 3/5] fix(render): snap box-drawing strokes to the device pixel grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Straight strokes with edges at fractional device pixels rasterize an antialiasing ramp at each end, and two abutting 50%-coverage ramps composite to 75% opacity — so a multi-row │ was perforated by a lighter band at every cell boundary and read as broken next to kitty's solid lines (kitty's cell-aligned box bitmaps never sit off-grid). Snap every rectangle edge to whole device pixels via the window scale factor. Edges are snapped individually (not origin + size), so the two cells sharing a boundary snap the same coordinate to the same pixel line: zero gap, zero overlap, whatever the window position. Arcs and diagonals keep their antialiasing on purpose; the rounded corners' straight stubs now reach one device pixel into the arc band to cover the snapped-meets-unsnapped handoff. --- src/terminal/boxdraw.rs | 113 ++++++++++++++++++++++++++++++++-------- src/terminal/element.rs | 4 +- 2 files changed, 93 insertions(+), 24 deletions(-) diff --git a/src/terminal/boxdraw.rs b/src/terminal/boxdraw.rs index e251388c..6c812f76 100644 --- a/src/terminal/boxdraw.rs +++ b/src/terminal/boxdraw.rs @@ -37,11 +37,20 @@ pub(crate) enum Ink { /// 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) -> Option> { +/// +/// `scale` is the window's device scale factor. Every straight stroke is +/// snapped to the *device pixel* grid it implies — not for crispness alone, +/// but for continuity: a cell boundary at a fractional device pixel gets an +/// antialiasing ramp on both sides, and two abutting 50%-coverage edges +/// composite to 75% opacity, which perforated every multi-row `│` with a +/// lighter band at each row boundary. Snapped edges rasterize with no ramp at +/// all, so adjacent cells butt into one continuous solid — the same reason +/// kitty's cell-aligned box bitmaps tile seamlessly. +pub(crate) fn glyph(c: char, bounds: Bounds, scale: f32) -> Option> { if !('\u{2500}'..='\u{259f}').contains(&c) { return None; } - let g = Cell::new(&bounds); + let g = Cell::new(&bounds, scale); if let Some((u, d, l, r)) = arms_of(c) { return Some(g.arms(u, d, l, r)); } @@ -73,10 +82,11 @@ struct Cell { cx: f32, cy: f32, t: f32, + scale: f32, } impl Cell { - fn new(b: &Bounds) -> Self { + fn new(b: &Bounds, scale: f32) -> Self { let x0 = b.origin.x.as_f32(); let y0 = b.origin.y.as_f32(); let x1 = x0 + b.size.width.as_f32(); @@ -89,11 +99,24 @@ impl Cell { cx: (x0 + x1) / 2., cy: (y0 + y1) / 2., t: ((x1 - x0) * 0.15).round().max(1.), + scale: scale.max(0.1), } } + /// Snap a logical coordinate onto the device pixel grid. + fn snap(&self, v: f32) -> f32 { + (v * self.scale).round() / self.scale + } + + /// A rectangle with every edge snapped to device pixels (see [`glyph`]). + /// Snapping the two edges — not origin + size — is what keeps a shared + /// cell boundary shared: both cells snap the same coordinate to the same + /// pixel line, so consecutive `│` cells tile with zero gap and zero + /// overlap whatever the window position. fn rectb(&self, x: f32, y: f32, w: f32, h: f32) -> Bounds { - Bounds::new(point(px(x), px(y)), size(px(w), px(h))) + let (sx0, sy0) = (self.snap(x), self.snap(y)); + let (sx1, sy1) = (self.snap(x + w), self.snap(y + h)); + Bounds::new(point(px(sx0), px(sy0)), size(px(sx1 - sx0), px(sy1 - sy0))) } fn rect(&self, x: f32, y: f32, w: f32, h: f32) -> Ink { @@ -265,16 +288,22 @@ impl Cell { 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). + // when the radius already spans the half-axis). Each stub reaches one + // device pixel *into* the arc band: the stub is pixel-snapped, the arc + // isn't, and without the overlap that mismatch reopens a hairline + // seam exactly where they hand off. + let lap = 1. / self.scale; if sy > 0. { - ink.push(self.rect(cx - h, cy + r, self.t, self.y1 - (cy + r))); + let top = cy + r - lap; + ink.push(self.rect(cx - h, top, self.t, self.y1 - top)); } else { - ink.push(self.rect(cx - h, self.y0, self.t, (cy - r) - self.y0)); + ink.push(self.rect(cx - h, self.y0, self.t, (cy - r + lap) - self.y0)); } if sx > 0. { - ink.push(self.rect(cx + r, cy - h, self.x1 - (cx + r), self.t)); + let left = cx + r - lap; + ink.push(self.rect(left, cy - h, self.x1 - left, self.t)); } else { - ink.push(self.rect(self.x0, cy - h, (cx - r) - self.x0, self.t)); + ink.push(self.rect(self.x0, cy - h, (cx - r + lap) - 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. @@ -550,7 +579,7 @@ mod tests { for cp in 0x2500u32..=0x259f { let c = char::from_u32(cp).unwrap(); assert!( - glyph(c, cell()).is_some(), + glyph(c, cell(), 1.).is_some(), "U+{cp:04X} {c} fell through to the font" ); } @@ -565,7 +594,7 @@ mod tests { 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()); + let (nx, xx, ny, xy) = extents(&glyph(c, b, 1.).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: \ @@ -588,7 +617,7 @@ mod tests { let Some((u, d, l, r)) = arms_of(c) else { continue; }; - let (nx, xx, ny, xy) = extents(&glyph(c, b).unwrap()); + let (nx, xx, ny, xy) = extents(&glyph(c, b, 1.).unwrap()); if u != Arm::None { assert_eq!(ny, y0, "{c}: up arm misses the top edge"); } @@ -627,7 +656,7 @@ mod tests { ('╲', true, true, true, true), ]; for (c, u, d, l, r) in expect { - let (nx, xx, ny, xy) = extents(&glyph(c, b).unwrap()); + let (nx, xx, ny, xy) = extents(&glyph(c, b, 1.).unwrap()); if u { assert_eq!(ny, y0, "{c}: misses the top edge"); } @@ -650,7 +679,7 @@ mod tests { 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() { + for i in glyph('╬', b, 1.).unwrap() { let Ink::Rect(r) = i else { panic!("╬ should be rects only"); }; @@ -670,18 +699,22 @@ mod tests { 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()); + let (nx, xx, ny, xy) = extents(&glyph('█', b, 1.).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"); + // Interior edges (the half-cell split) may sit up to half a device + // pixel from nominal after snapping; the outer edges stay exact. + let (_, _, ny, xy) = extents(&glyph('▀', b, 1.).unwrap()); + assert_eq!(ny, y0, "▀ doesn't reach the top"); + assert!((xy - (y0 + h / 2.)).abs() <= 0.5, "▀ isn't the top half"); + let (_, _, ny, xy) = extents(&glyph('▄', b, 1.).unwrap()); + assert_eq!(xy, y0 + h, "▄ doesn't reach the bottom"); + assert!((ny - (y0 + h / 2.)).abs() <= 0.5, "▄ isn't the bottom half"); for (c, alpha) in [('░', 0.25), ('▒', 0.5), ('▓', 0.75)] { - let ink = glyph(c, b).unwrap(); + let ink = glyph(c, b, 1.).unwrap(); assert_eq!(ink.len(), 1); let Ink::Shade(r, a) = &ink[0] else { panic!("{c} should be a shade"); @@ -696,13 +729,13 @@ mod tests { #[test] fn stroke_weights_are_ordered_and_visible() { let light = { - let Ink::Rect(r) = &glyph('│', cell()).unwrap()[0] else { + let Ink::Rect(r) = &glyph('│', cell(), 1.).unwrap()[0] else { panic!() }; r.size.width.as_f32() }; let heavy = { - let Ink::Rect(r) = &glyph('┃', cell()).unwrap()[0] else { + let Ink::Rect(r) = &glyph('┃', cell(), 1.).unwrap()[0] else { panic!() }; r.size.width.as_f32() @@ -711,9 +744,43 @@ mod tests { 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 { + let Ink::Rect(r) = &glyph('│', tiny, 1.).unwrap()[0] else { panic!() }; assert!(r.size.width.as_f32() >= 1.); } + + /// The seam regression: with the window at a fractional device-pixel + /// offset, every straight stroke must still land on whole device pixels. + /// An unsnapped edge rasterizes an antialiasing ramp, and two abutting + /// ramps composite to 75% opacity — the perforated `│` runs this module + /// was reported for a second time over. + #[test] + fn straight_strokes_snap_to_device_pixels() { + let scale = 2.0; + // Deliberately misaligned: fractional origin and cell width. + let b = Bounds::new(point(px(10.37), px(20.11)), size(px(9.03), px(21.))); + let on_grid = |v: f32| ((v * scale).round() - v * scale).abs() < 1e-3; + for cp in 0x2500u32..=0x259f { + let c = char::from_u32(cp).unwrap(); + for i in glyph(c, b, scale).unwrap() { + let (Ink::Rect(r) | Ink::Shade(r, _)) = i else { + continue; // arcs and diagonals antialias on purpose + }; + let (x, y) = (r.origin.x.as_f32(), r.origin.y.as_f32()); + let (x2, y2) = (x + r.size.width.as_f32(), y + r.size.height.as_f32()); + assert!( + on_grid(x) && on_grid(y) && on_grid(x2) && on_grid(y2), + "U+{cp:04X} {c}: stroke edge off the device grid \ + ({x}, {y})..({x2}, {y2}) at scale {scale}" + ); + } + } + // And two vertically adjacent `│` cells must share their boundary + // exactly — same coordinate in, same snapped pixel line out. + let below = Bounds::new(point(px(10.37), px(41.11)), size(px(9.03), px(21.))); + let bottom = extents(&glyph('│', b, scale).unwrap()).3; + let top = extents(&glyph('│', below, scale).unwrap()).2; + assert_eq!(bottom, top, "adjacent │ cells no longer tile"); + } } diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 16294bc9..0e64be90 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -848,7 +848,9 @@ fn paint_glyphs( // 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) { + if let Some(ink) = + super::boxdraw::glyph(cell.c, cell_bounds, window.scale_factor()) + { let fg = GlyphStyle::of(cell).fg; for piece in ink { match piece { From 0cb5679842b69c05a4dd790b7e41d7d74976c4eb Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 27 Jul 2026 18:15:58 +0800 Subject: [PATCH 4/5] fix(render): analytically antialiased rounded corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polyline arc band couldn't match kitty's corners on two counts, both dictated by how gpui rasterizes paths: * Separately painted segments composite as premultiplied sprites, so two abutting antialiased edges meet at 75% opacity — a lighter seam at every joint. All contours now ride in ONE Path (via move_to), where the 4x-MSAA samples partition cleanly across shared edges. * Straight path edges only get 4-level MSAA antialiasing, while `curve_to` quadratics are antialiased analytically (Loop–Blinn signed distance) — a continuous ramp, the same quality kitty gets from supersampling. The outer arc edge is now a real quadratic per 30° slice (control point at the tangents' intersection). The inner edge stays a fine polyline: gpui accumulates coverage with no winding cancellation, so a concave-side curve bulge can only over-cover (the origin of the original quarter-disc blob). Its chord error at 7.5° steps is under 0.1px and hides inside the MSAA. Each 30° contour is star-shaped from its start vertex, which is what the fan fill actually requires. --- src/terminal/boxdraw.rs | 75 ++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/src/terminal/boxdraw.rs b/src/terminal/boxdraw.rs index 6c812f76..2ddc6648 100644 --- a/src/terminal/boxdraw.rs +++ b/src/terminal/boxdraw.rs @@ -305,8 +305,31 @@ impl Cell { } else { ink.push(self.rect(self.x0, cy - h, (cx - r + lap) - 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. + // The arc band, from the vertical stub (θ=0) to the horizontal one + // (θ=π/2) around the arc centre one radius into the quadrant. + // + // How this renders decides whether the corner looks like kitty's or + // not, and gpui's pipeline dictates the shape (learned the hard way, + // twice): + // + // * A path contour is filled as a triangle FAN from its start vertex, + // and coverage in the intermediate texture only accumulates — there + // is no winding cancellation. A whole-band outline is concave, so + // its fan covered the hollow and every corner rendered as a solid + // quarter-disc blob. Each contour must therefore be *star-shaped + // from its start vertex*: 30° slices of a thin band are, a 90° band + // is not. + // * All contours ride in ONE Path. Paths composite as premultiplied + // sprites, so two separately painted segments overlap their + // antialiased edges at 75% opacity — the seam at every joint of the + // first polyline attempt. Within a single path the 4x-MSAA samples + // partition cleanly across shared edges instead. + // * The outer edge is a real quadratic (`curve_to`), which the shader + // antialiases *analytically* (Loop–Blinn signed distance) — the + // smooth continuous ramp kitty gets from supersampling. The inner + // edge can't be a curve: with no winding, a concave-side bulge can + // only over-cover. It is a fine polyline instead, whose chord error + // at 7.5° steps (< 0.1px at cell sizes) hides inside the MSAA. let (ax, ay) = (cx + sx * r, cy + sy * r); let at = |radius: f32, theta: f32| { let (x, y) = ( @@ -315,23 +338,32 @@ impl Cell { ); 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 { + const SEGS: usize = 3; + const INNER_PTS: usize = 4; + let step = std::f32::consts::FRAC_PI_2 / SEGS as f32; + let mut path: Option> = None; + for i in 0..SEGS { 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)); + let t1 = step * (i + 1) as f32; + let start = at(r + h, t0); + let p = match path.as_mut() { + Some(p) => { + p.move_to(start); + p + } + None => path.insert(gpui::Path::new(start)), + }; + // Control point at the tangents' intersection: the exact + // quadratic through both endpoints for this arc slice. + let ctrl = at((r + h) / (step / 2.).cos(), (t0 + t1) / 2.); + p.curve_to(at(r + h, t1), ctrl); + p.line_to(at(r - h, t1)); + for k in (0..INNER_PTS).rev() { + p.line_to(at(r - h, t0 + (t1 - t0) * k as f32 / INNER_PTS as f32)); + } + } + if let Some(p) = path { + ink.push(Ink::Path(p)); } Some(ink) } @@ -587,6 +619,11 @@ mod tests { /// Nothing may paint outside its own cell: box characters tile, and one /// cell's overshoot is its neighbor's artifact. + /// + /// The tolerance is half a pixel, not exact: a quadratic's *control point* + /// sits slightly outside the ink it bounds (tangent-intersection, ~3.5% + /// past the arc radius), and `extents` reads raw vertices. The curve + /// itself never leaves the cell. #[test] fn ink_stays_inside_the_cell() { let b = cell(); @@ -596,7 +633,7 @@ mod tests { let c = char::from_u32(cp).unwrap(); let (nx, xx, ny, xy) = extents(&glyph(c, b, 1.).unwrap()); assert!( - nx >= x0 - 0.01 && xx <= x1 + 0.01 && ny >= y0 - 0.01 && xy <= y1 + 0.01, + nx >= x0 - 0.5 && xx <= x1 + 0.5 && ny >= y0 - 0.5 && xy <= y1 + 0.5, "U+{cp:04X} {c} paints outside the cell: \ x {nx}..{xx} vs {x0}..{x1}, y {ny}..{xy} vs {y0}..{y1}" ); From 91532f7a0b00c0f4d38d862d7c367a4c8c76f7d5 Mon Sep 17 00:00:00 2001 From: Qiu Daomao Date: Mon, 27 Jul 2026 11:13:52 +0800 Subject: [PATCH 5/5] feat: make inactive pane fade optional --- src/core/config.rs | 6 ++++++ src/ui/app.rs | 6 ++++++ src/ui/pane.rs | 11 +++++++++-- src/ui/settings.rs | 11 +++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index 32e2fe79..700f3b94 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -60,6 +60,11 @@ pub struct Config { pub window_opacity: Option, /// Global window-blur override. `None` follows the active theme's `blur`. pub window_blur: Option, + /// Fade unfocused panes in a split tab so the focused terminal reads as + /// foreground. On by default; when off every pane renders at full opacity + /// and only focus (cursor, etc.) distinguishes the active one. + #[serde(default = "default_true")] + pub dim_inactive_panes: bool, /// Optional keybinding overrides: action name (e.g. "NewTab") → keystroke /// (e.g. "secondary-t", which is ⌘ on macOS and Ctrl elsewhere). Unknown /// actions and unparseable keystrokes are ignored (with a warning) so a bad @@ -596,6 +601,7 @@ impl Default for Config { theme_preset_dark: "dark".to_string(), window_opacity: None, window_blur: None, + dim_inactive_panes: true, keybindings: HashMap::new(), keybinding_preset: default_preset(), prefix: default_prefix(), diff --git a/src/ui/app.rs b/src/ui/app.rs index d5b06207..b2181f7d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2506,6 +2506,12 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.check_for_updates = on); } + /// Toggle inactive-pane dimming. Applies on the next render — the pane tree + /// reads the flag from the `Config` global each frame. + pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.dim_inactive_panes = on); + } + pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.cursor_blink = on); // Turning blink off mid-cycle could leave the cursor in its hidden phase; diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 9151b5a5..5f658295 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -542,8 +542,15 @@ impl Pane> { // (terminal glyphs + cell fills), unlike a background-tinted // scrim which is near-invisible on a light theme (white on // white). Applied to the container, so a click still lands on - // the terminal and focuses it. - .when(show_focus && !focused, |d| d.opacity(0.55)) + // the terminal and focuses it. `dim_inactive_panes` opts out. + .when( + show_focus + && !focused + && cx + .global::() + .dim_inactive_panes, + |d| d.opacity(0.55), + ) .child(v.clone()) .into_any_element() } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 5aa52d0d..4eeb63ff 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1531,6 +1531,7 @@ impl Tty7App { }; let config = cx.global::(); let overridden = config.window_opacity.is_some() || config.window_blur.is_some(); + let dim_inactive_panes = config.dim_inactive_panes; let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx)); let opacity = Tty7App::effective_window_opacity(cx); let blur = cx.global::().window_blur.unwrap_or(theme.blur); @@ -1554,6 +1555,10 @@ impl Tty7App { cx.listener(|this, on: &bool, window, cx| this.set_window_blur(*on, window, cx)), ) .into_any_element(); + let dim_switch = crate::ui::theme::switch("dim-inactive-panes", cx) + .checked(dim_inactive_panes) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_dim_inactive_panes(*on, cx))) + .into_any_element(); v_flex() // Not "Window": Settings → Window & Tabs owns that word for the @@ -1573,6 +1578,12 @@ impl Tty7App { blur_switch, cx, )) + .child(self.settings_row( + "Dim inactive panes", + "Fade unfocused panes in a split so the active one stands out.", + dim_switch, + cx, + )) // Only offered while an override is active; otherwise the values // already follow the theme and the button would be a no-op. .when(overridden, |this| {