From 4aedb1faf7091ee8643c6318667bb2e467f1d235 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:11:58 +0800 Subject: [PATCH 01/17] feat(daemon): tell every pane which terminal it is running in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TERM` names terminfo capabilities; it cannot answer "which program is this". The de-facto standard pair that does — `TERM_PROGRAM` and `TERM_PROGRAM_VERSION`, introduced by Apple Terminal and set by iTerm2, WezTerm, Ghostty, VS Code and tmux — went unset, so anything asking was told nothing. Plenty asks. Capability probes (`supports-color`, `supports-hyperlinks`, and the CLI ecosystem built on them) read the program name to decide on truecolor and OSC 8; editors branch on it for terminal-specific workarounds; shell prompts adapt their glyphs to it. Absent, they all fall back to their most conservative behaviour. The `TTY7` marker we do export is no substitute: it exists so globally-installed agent hooks stay silent in other terminals, and nothing third-party looks for it. Both new variables stay overridable through `env` in `config.json`, unlike `TERM` and `COLORTERM`. Those two state what the pane's decoder implements, which isn't the user's to contradict; the program name is an identity, and posing as another terminal is a legitimate way to get a tool that only recognises a fixed list to light up. Building the pane's environment is now one function returning the pairs in application order, so that precedence is testable without a `CommandBuilder` or a real `config.json`. Local panes only. ssh forwards environment variables solely by agreement between client and server (`SendEnv`/`AcceptEnv`, `LANG` and `LC_*` by default), so a native-SSH pane still sees whatever the remote host sets for itself — as is already true of `COLORTERM` and `TTY7`. Closes #212 --- CHANGELOG.md | 20 +++++++ src/daemon/pane.rs | 143 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 145 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478bb9c1..9fd27312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Panes are told which terminal they're running in** — every pane now carries + `TERM_PROGRAM=tty7` and `TERM_PROGRAM_VERSION`, the de-facto standard pair + Apple Terminal introduced and iTerm2, WezTerm, Ghostty, VS Code and tmux all + set. `TERM` names terminfo capabilities and can't answer "which program is + this", so without the pair, capability probes (`supports-color`, + `supports-hyperlinks`, and the CLI ecosystem built on them), editors applying + terminal-specific workarounds, and shell prompts all fell back to their most + conservative behaviour. tty7's own `TTY7` marker doesn't help them — it exists + so globally-installed agent hooks stay silent in other terminals, and nothing + third-party knows to look for it. Unlike `TERM` and `COLORTERM`, both new + variables can be overridden from `env` in `config.json`: they name an + identity, not a capability, and posing as another terminal is a legitimate way + to get a tool that only recognises a fixed list to light up. Local panes only + — ssh forwards environment variables solely by agreement between client and + server, so a remote host still sees whatever it sets for itself. (#212) + ## [26.7.5] - 2026-07-27 ### Added diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index a7a167a2..3f178736 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -422,29 +422,68 @@ fn system_locale_identifier() -> Option { } } +/// What tty7 answers to in `TERM_PROGRAM`. Terminals name themselves in the +/// form they brand themselves in — `Apple_Terminal`, `iTerm.app`, `WezTerm`, +/// `ghostty`, `vscode` — so ours is the lowercase product name. +const TERM_PROGRAM_NAME: &str = "tty7"; + +/// Env keys that describe our emulator's real capabilities. A user's `env` map +/// must not override these: the answer isn't a preference, it's a fact about +/// what the pane on the other end can decode. +const CAPABILITY_ENV: [&str; 2] = ["TERM", "COLORTERM"]; + +/// The environment every pane starts with, in application order — tty7's own +/// advertisements first, then the user's `env` map, which overrides all but +/// [`CAPABILITY_ENV`]. Returned as a list rather than applied in place so the +/// precedence is testable without a `CommandBuilder` or a real `config.json`. +fn pane_environment( + extra_env: &std::collections::HashMap, +) -> Vec<(String, String)> { + let version = env!("CARGO_PKG_VERSION"); + let mut env = vec![ + // A widely-available terminfo + truecolor. + ("TERM".to_string(), "xterm-256color".to_string()), + ("COLORTERM".to_string(), "truecolor".to_string()), + // Mark the session as tty7's, for tooling that adapts to its host + // terminal — most importantly the `tty7 agent-hook` emitter, which + // stays silent without it so globally-installed agent hooks can't leak + // escape sequences into other terminals (see `core::agent_hooks`). + ( + crate::core::agent_hooks::TTY7_ENV_MARKER.to_string(), + version.to_string(), + ), + // The de-facto standard pair for "which terminal is this": Apple + // Terminal introduced it, and iTerm2, WezTerm, Ghostty, VS Code and + // tmux all set it. `TERM` describes terminfo capabilities and can't + // answer this — but capability probes (`supports-color`, + // `supports-hyperlinks` and the JS CLI ecosystem built on them), + // editors applying terminal-specific workarounds, and shell prompts all + // branch on the program name, falling back to their most conservative + // behaviour when it's missing. `TTY7` doesn't help them: it's ours, and + // nothing third-party knows to look for it. + // + // Deliberately overridable below, unlike the capability keys: this + // names an identity, and posing as another terminal is a legitimate way + // to get a tool that only recognises a fixed list to light up. + ("TERM_PROGRAM".to_string(), TERM_PROGRAM_NAME.to_string()), + ("TERM_PROGRAM_VERSION".to_string(), version.to_string()), + ]; + env.extend( + extra_env + .iter() + .filter(|(k, _)| !CAPABILITY_ENV.contains(&k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())), + ); + env +} + fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option) { if let Some(dir) = initial_cwd { cmd.cwd(dir); } - // Advertise a widely-available terminfo + truecolor. - cmd.env("TERM", "xterm-256color"); - cmd.env("COLORTERM", "truecolor"); - // Mark the session as tty7's, for tooling that adapts to its host terminal - // — most importantly the `tty7 agent-hook` emitter, which stays silent - // without it so globally-installed agent hooks can't leak escape sequences - // into other terminals (see `core::agent_hooks`). - cmd.env( - crate::core::agent_hooks::TTY7_ENV_MARKER, - env!("CARGO_PKG_VERSION"), - ); - - // User-configured environment variables override inherited values (but not - // TERM/COLORTERM above, which reflect our emulator's real capabilities). let extra_env = crate::core::config::extra_env(); - for (k, v) in &extra_env { - if k != "TERM" && k != "COLORTERM" { - cmd.env(k, v); - } + for (k, v) in pane_environment(&extra_env) { + cmd.env(k, v); } // LaunchServices commonly starts a macOS app with no locale variables at @@ -4042,6 +4081,74 @@ mod tests { assert!(dead_rx.try_recv().is_err(), "on_dead must fire only once"); } + /// Every pane is told which terminal it is running in, under the names the + /// rest of the world reads (`TERM_PROGRAM`/`TERM_PROGRAM_VERSION`) as well + /// as our own `TTY7` marker. Nothing third-party looks for the marker, so + /// dropping the standard pair would leave capability probes guessing. + #[test] + fn pane_environment_advertises_the_terminal_under_the_standard_names() { + let env: std::collections::HashMap<_, _> = + pane_environment(&std::collections::HashMap::new()) + .into_iter() + .collect(); + let version = env!("CARGO_PKG_VERSION"); + + assert_eq!(env.get("TERM_PROGRAM").map(String::as_str), Some("tty7")); + assert_eq!( + env.get("TERM_PROGRAM_VERSION").map(String::as_str), + Some(version) + ); + assert_eq!( + env.get(crate::core::agent_hooks::TTY7_ENV_MARKER) + .map(String::as_str), + Some(version) + ); + assert_eq!( + env.get("TERM").map(String::as_str), + Some("xterm-256color"), + "terminfo name is what the pane's decoder actually implements" + ); + } + + /// The user's `env` map may rename the terminal — posing as another program + /// is how you get a tool that only recognises a fixed list to light up — + /// but it may not contradict what our emulator can decode. Later entries + /// win, so the ordering is the precedence. + #[test] + fn pane_environment_lets_configured_env_override_identity_but_not_capability() { + let configured = [ + ("TERM_PROGRAM", "iTerm.app"), + ("TERM_PROGRAM_VERSION", "3.5.0"), + ("TERM", "dumb"), + ("COLORTERM", ""), + ("EDITOR", "hx"), + ] + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + + let applied: std::collections::HashMap<_, _> = + pane_environment(&configured).into_iter().collect(); + + assert_eq!( + applied.get("TERM_PROGRAM").map(String::as_str), + Some("iTerm.app") + ); + assert_eq!( + applied.get("TERM_PROGRAM_VERSION").map(String::as_str), + Some("3.5.0") + ); + assert_eq!(applied.get("EDITOR").map(String::as_str), Some("hx")); + assert_eq!( + applied.get("TERM").map(String::as_str), + Some("xterm-256color") + ); + assert_eq!( + applied.get("COLORTERM").map(String::as_str), + Some("truecolor") + ); + } + /// The macOS UTF-8 fallback applies only when the inherited environment has /// no locale and the user has not taken control through the generic `env` /// map. Key presence is authoritative there, including an empty value. From e68fcdefb6af31e7bbc4163112558d3c183c8daa Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:59:46 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat(editor):=20readline=20parity=20at=20?= =?UTF-8?q?the=20prompt=20=E2=80=94=20ctrl-p/n,=20ctrl-y,=20meta-.=20and?= =?UTF-8?q?=20shell=20fallthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local command editor swallowed every Ctrl chord at the prompt, so engaging shell integration silently removed keys that worked before it: Ctrl-P/N history motions, Ctrl-T transpose, and any zle widget the user had bound. Kills (Ctrl-W/U/K) had no yank to put them back. - Ctrl-P / Ctrl-N are rewritten into the arrow keys up front, so the two spellings can't drift apart across history recall, multi-line steps, the completion picker and reverse search. - Ctrl-Y yanks the last kill back; the kill chords now stash what they cut in a one-slot ring. Answered locally — zle's kill ring is a different buffer and would yank text this editor never cut. - Alt-. is readline's yank-last-arg, walking back through history on repeat and swapping the word the previous press inserted. - Unrecognized Ctrl and Meta chords hand the line to the shell instead of dying at the prompt, the same escape hatch Ctrl-R already used. - Keys handled locally now snap the viewport back to the live prompt (#208): the editor returns early, so it never reached the raw key path's housekeeping and could edit a line off screen. --- src/terminal/cmd_editor.rs | 89 +++++++- src/terminal/view.rs | 451 +++++++++++++++++++++++++++++++++---- 2 files changed, 490 insertions(+), 50 deletions(-) diff --git a/src/terminal/cmd_editor.rs b/src/terminal/cmd_editor.rs index 971bf46f..24c02ea2 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -24,6 +24,11 @@ pub struct CmdEditor { /// shuttle states between the two. undo: Vec<(Vec, usize)>, redo: Vec<(Vec, usize)>, + /// What the last *kill* removed, for [`Self::yank`] to put back — readline's + /// kill ring, one slot deep. Only the word/line kills (⌃W, ⌃U, ⌃K, ⌥D and + /// the arrow-key spellings of them) write here; a plain character delete is + /// not a kill and leaves it untouched. + kill: String, } /// Cap on undo history, so a long editing session can't grow it without bound. @@ -409,6 +414,15 @@ impl CmdEditor { } } + /// Remove `s..e` and stash it as the kill ring's contents. The four chords + /// below are readline *kills*, not deletes: what they take is meant to come + /// back out under [`Self::yank`]. + fn kill_range(&mut self, s: usize, e: usize) { + self.kill = self.chars[s..e].iter().collect(); + self.chars.drain(s..e); + self.shift_anchor_for_removal(s, e); + } + /// Delete the word after the cursor (Alt+Delete): skip following whitespace, /// then the word. pub fn delete_word_right(&mut self) { @@ -421,8 +435,7 @@ impl CmdEditor { while e < n && !self.chars[e].is_whitespace() { e += 1; } - self.chars.drain(self.cursor..e); - self.shift_anchor_for_removal(self.cursor, e); + self.kill_range(self.cursor, e); } /// Delete the word before the cursor (Ctrl+W / Alt+Backspace). @@ -430,25 +443,33 @@ impl CmdEditor { self.checkpoint(); let end = self.cursor; self.move_word_left(); - self.chars.drain(self.cursor..end); - self.shift_anchor_for_removal(self.cursor, end); + self.kill_range(self.cursor, end); } /// Delete from the cursor to the start of the line (Ctrl+U / Cmd+Backspace). pub fn delete_to_start(&mut self) { self.checkpoint(); - self.chars.drain(0..self.cursor); let end = self.cursor; self.cursor = 0; - self.shift_anchor_for_removal(0, end); + self.kill_range(0, end); } /// Delete from the cursor to the end of the line (Ctrl+K). pub fn delete_to_end(&mut self) { self.checkpoint(); let end = self.chars.len(); - self.chars.drain(self.cursor..); - self.shift_anchor_for_removal(self.cursor, end); + self.kill_range(self.cursor, end); + } + + /// Reinsert the most recent kill at the cursor (Ctrl+Y). A no-op — undo + /// checkpoint included — when nothing has been killed yet. + pub fn yank(&mut self) { + if self.kill.is_empty() { + return; + } + let kill = std::mem::take(&mut self.kill); + self.insert_str(&kill); + self.kill = kill; } /// Clear the line and reset the cursor and undo history (after submit). @@ -572,6 +593,58 @@ mod tests { assert_eq!(d.cursor(), 9); } + /// The four readline *kill* chords stash what they removed so ⌃Y can put it + /// back; the ring holds the most recent kill only. + #[test] + fn kills_fill_the_kill_buffer_and_yank_puts_it_back() { + let mut e = ed("git push origin", 15); + e.delete_word_left(); + assert_eq!(e.text(), "git push "); + e.yank(); + assert_eq!((e.text().as_str(), e.cursor()), ("git push origin", 15)); + + let mut k = ed("hello world", 5); + k.delete_to_end(); + assert_eq!(k.text(), "hello"); + k.yank(); + assert_eq!(k.text(), "hello world"); + + let mut u = ed("hello world", 6); + u.delete_to_start(); + assert_eq!(u.text(), "world"); + u.move_end(); + u.yank(); + assert_eq!(u.text(), "worldhello "); + + let mut d = ed("git push origin", 4); + d.delete_word_right(); + assert_eq!(d.text(), "git origin"); + d.yank(); + assert_eq!(d.text(), "git push origin"); + } + + /// A plain character delete is not a kill — readline keeps the two apart, + /// so backspacing must not clobber the word ⌃W stashed a moment ago. + #[test] + fn character_deletes_leave_the_kill_buffer_alone() { + let mut e = ed("git push origin", 15); + e.delete_word_left(); + e.backspace(); + e.delete(); + assert_eq!(e.text(), "git push"); + e.yank(); + assert_eq!(e.text(), "git pushorigin"); + } + + /// Nothing killed yet: ⌃Y leaves the line and the caret exactly as they + /// were rather than inserting an empty string. + #[test] + fn yank_without_a_kill_does_nothing() { + let mut e = ed("hello", 3); + e.yank(); + assert_eq!((e.text().as_str(), e.cursor()), ("hello", 3)); + } + #[test] fn delete_to_start_and_end() { let mut s = ed("hello world", 6); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 7fa9d42c..6fc84269 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -340,6 +340,12 @@ pub struct TerminalView { /// The in-progress line saved when history navigation starts, so pressing ↓ /// past the newest entry restores what the user was typing. history_stash: String, + /// Position of a run of ⌥. presses (readline's `yank-last-arg`): which + /// `history` entry the last press took its word from, and the char span it + /// left in the line — the next press replaces that span with the word from + /// the entry before it. Any other key clears this, so the following ⌥. + /// starts a fresh walk at the newest entry. + last_word_nav: Option, /// A submitted command whose history-file record is deferred until the /// shell reports back at its prompt, so the record can carry the command's /// exit code (see [`PendingHistory`]). @@ -448,6 +454,17 @@ struct PendingHistory { seq: u64, } +/// Where a run of ⌥. presses has walked to (see +/// [`TerminalView::last_word_nav`]). +struct LastWordWalk { + /// Index into `history` the last press took its word from. + entry: usize, + /// Char offset of the word it inserted, and how many chars it spans — the + /// next press swaps that range for the word from an older entry. + at: usize, + len: usize, +} + /// Seconds since the unix epoch — the timestamp history records carry. fn unix_now() -> u64 { std::time::SystemTime::now() @@ -1172,6 +1189,7 @@ impl TerminalView { ranked_cwd: None, history_nav: None, history_stash: String::new(), + last_word_nav: None, pending_history: None, completion: None, completion_generation: 0, @@ -1655,11 +1673,7 @@ impl TerminalView { // Keep the cursor solid while typing (resets the blink phase). self.cursor_visible = true; // Typing clears the selection and jumps to the prompt. - let mut term = self.terminal.term.lock(); - term.selection = None; - term.scroll_display(Scroll::Bottom); - self.scroll_frac = 0.; - drop(term); + self.jump_to_prompt(); cx.notify(); // Consume so the key isn't also re-sent through the IME path. cx.stop_propagation(); @@ -1776,11 +1790,43 @@ impl TerminalView { let m = &ks.modifiers; let key = ks.key.as_str(); self.cursor_visible = true; + // The raw key path does this per keystroke; the editor owns the keyboard + // at the prompt and every arm below returns early, so it has to happen + // once here instead. Without it a key pressed while scrolled up edits a + // line the viewport isn't showing (#208). + self.jump_to_prompt(); + + // ⌃P / ⌃N are readline's spelling of ↑ / ↓ (0x10 / 0x0e on the wire, and + // what the shell's own keymap answers when the editor isn't holding the + // line). Rewrite them into the arrow keys here rather than giving them + // arms of their own, so the two spellings can't drift apart — history + // recall, multi-line steps, the completion picker and the reverse-search + // menu all treat them identically from this point down. + let aliased; + let ks = if m.control && !m.platform && !m.alt && matches!(key, "p" | "n") { + aliased = gpui::Keystroke { + modifiers: gpui::Modifiers::default(), + key: if key == "p" { "up" } else { "down" }.to_string(), + key_char: None, + }; + &aliased + } else { + ks + }; + let m = &ks.modifiers; + let key = ks.key.as_str(); + // Any key other than a vertical step drops the sticky goal column, so the // next ↑/↓ takes its column from wherever the caret ends up. if key != "up" && key != "down" { self.editor_goal_col = None; } + // Likewise, only a repeat of ⌥. continues an insert-last-word walk — + // anything else and the next press starts fresh at the newest entry + // rather than swallowing whatever now sits left of the caret. + if !(m.alt && key == ".") { + self.last_word_nav = None; + } // A reverse search, when active, owns the keyboard. if self.reverse_search.is_some() { @@ -1845,8 +1891,9 @@ impl TerminalView { self.close_completion(); // Readline-style control combinations, delegated so this dispatcher stays - // scannable. Every Ctrl chord is swallowed at the prompt (recognized or - // not), so this always notifies and returns. + // scannable. A chord the editor answers is consumed here; one it doesn't + // goes on to the shell rather than dying at the prompt. Either way this + // branch returns. if m.control && !m.platform && !m.alt { // Off macOS, word navigation and deletion live on Ctrl (the Windows / // Linux convention): Ctrl+←/→ move by word (Shift extends the @@ -1901,19 +1948,33 @@ impl TerminalView { self.handoff_line_to_shell(&[0x12], cx); return; } - self.apply_readline_ctrl(key); - cx.notify(); + if self.apply_readline_ctrl(key) { + cx.notify(); + } else if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.kitty_flags()) { + // No local widget answers this chord. Swallowing it is the one + // thing we mustn't do — the key worked before shell integration + // engaged, and zle's keymap (⌃T transpose, a `bindkey` widget, + // an fzf binding…) still knows what to do with it. + self.handoff_line_to_shell(&bytes, cx); + } else { + cx.notify(); + } return; } - // Readline-style Meta word chords on the edited line: M-b / M-f motions - // and M-d delete-word, mirroring the Alt+←/→/Delete handling below. On - // macOS these are reachable only with `macos_option_as_alt` on — with it - // off the chord composes a character upstream and arrives here altless, - // through the printable-text arm. Other Alt+letter chords stay swallowed - // no-ops as before (the local editor can't mirror every zle widget). + // Readline-style Meta chords on the edited line: M-b / M-f motions, + // M-d delete-word (mirroring the Alt+←/→/Delete handling below) and + // M-. insert-last-word. On macOS these are reachable only with + // `macos_option_as_alt` on — with it off the chord composes a character + // upstream and arrives here altless, through the printable-text arm. + // Meta chords with no arm here reach the shell instead of dying (see + // the fallthrough at the bottom of the dispatcher). if m.alt && !m.platform && !m.control { match key { + "." => { + self.insert_last_word(cx); + return; + } "b" => { self.editor_move_h(false, m.shift, true); cx.notify(); @@ -2038,7 +2099,7 @@ impl TerminalView { // events carrying `key_char`; feed them through the same commit path // the IME would use so the local editor sees the text. Skip control / // Cmd chords and any non-printable char (function keys have no - // `key_char`; Alt combos stay editor no-ops as before). + // `key_char`). _ => { if !m.control && !m.platform && !m.alt { if let Some(ch) = ks.key_char.as_deref() { @@ -2048,6 +2109,17 @@ impl TerminalView { } } } + // A Meta chord with nothing local behind it (M-t transpose-word, + // M-u/M-l/M-c case widgets, whatever the user bound) goes to the + // shell rather than dying here — same reasoning as the Ctrl side + // above. Built from the key name, not `key_char`: the platforms + // that deliver Alt chords at all don't reliably carry one. + if m.alt && !m.control && !m.platform && key.chars().count() == 1 { + let mut bytes = vec![0x1b]; + bytes.extend_from_slice(key.as_bytes()); + self.handoff_line_to_shell(&bytes, cx); + return; + } } } cx.notify(); @@ -2055,14 +2127,18 @@ impl TerminalView { /// Apply a readline-style Ctrl chord to the command editor: Ctrl-A/E/B/F /// motions (Ctrl-F also accepts the autosuggestion), Ctrl-W/U/K/H deletions - /// (each removing the selection first if there is one), Ctrl-L clear-screen, - /// Ctrl-R reverse search, Ctrl-C interrupt, and Ctrl-D EOF/forward-delete. - /// Unrecognized chords are no-ops (the caller swallows every Ctrl combo at - /// the prompt regardless). + /// (each removing the selection first if there is one), Ctrl-Y yanking the + /// last kill back, Ctrl-L clear-screen, Ctrl-R reverse search, Ctrl-C + /// interrupt, and Ctrl-D EOF/forward-delete. /// - /// The caller resolves Ctrl-J / Ctrl-M (accept-line) and, when the history - /// menu is switched off, Ctrl-R before this point — neither reaches here. - fn apply_readline_ctrl(&mut self, key: &str) { + /// Returns whether the chord was recognized: the caller hands the ones that + /// weren't to the shell, so a widget tty7 has no answer for still reaches + /// the keymap that does. + /// + /// The caller resolves Ctrl-J / Ctrl-M (accept-line), Ctrl-P / Ctrl-N (the + /// arrow keys by another name) and, when the history menu is switched off, + /// Ctrl-R before this point — none of them reach here. + fn apply_readline_ctrl(&mut self, key: &str) -> bool { match key { "r" => self.start_reverse_search(), "a" => { @@ -2103,6 +2179,10 @@ impl TerminalView { } } "h" => self.cmd.backspace(), + // Yank: the other half of ⌃W / ⌃U / ⌃K. Answered locally rather + // than handed to the shell — zle keeps its own kill ring, and + // yanking from it would paste text this editor never cut. + "y" => self.cmd.yank(), "l" => { // Clear screen belongs to the shell/readline layer: send the // same form-feed byte the raw terminal path emits for Ctrl+L. @@ -2133,8 +2213,9 @@ impl TerminalView { self.cmd.delete(); } } - _ => {} + _ => return false, } + true } /// Horizontal caret motion in the editor with selection semantics: Shift @@ -2272,6 +2353,19 @@ impl TerminalView { super::input::tab_bytes(shift, self.kitty_flags()) } + /// The housekeeping every input path shares: drop the selection the key + /// invalidated and bring the viewport back to the live prompt, whole lines + /// (`display_offset`) and sub-line remainder (`scroll_frac`) alike. Acting + /// on a line the user can't see is the thing to avoid — so this runs for + /// keys handled locally too, not only for bytes that reach the PTY. + fn jump_to_prompt(&mut self) { + let mut term = self.terminal.term.lock(); + term.selection = None; + term.scroll_display(Scroll::Bottom); + drop(term); + self.scroll_frac = 0.; + } + /// Write a fixed byte sequence to the PTY (for keystrokes delivered as /// actions rather than through `on_key_down`, e.g. Tab / Shift-Tab), applying /// the same cursor / selection / scroll housekeeping as normal typing. @@ -2281,11 +2375,7 @@ impl TerminalView { } self.terminal.write(bytes.to_vec()); self.cursor_visible = true; - let mut term = self.terminal.term.lock(); - term.selection = None; - term.scroll_display(Scroll::Bottom); - self.scroll_frac = 0.; - drop(term); + self.jump_to_prompt(); cx.notify(); } @@ -3500,11 +3590,54 @@ impl TerminalView { self.terminal.write(submit_bytes(&line, bracketed)); self.cmd.clear(); self.cursor_visible = true; - let mut term = self.terminal.term.lock(); - term.selection = None; - term.scroll_display(Scroll::Bottom); - self.scroll_frac = 0.; - drop(term); + self.jump_to_prompt(); + cx.notify(); + } + + /// Readline's `yank-last-arg` (⌥.): drop the last word of the previous + /// command at the caret. Repeating the chord walks further back through the + /// history, each press swapping out the word the one before it inserted, so + /// a run of presses leaves exactly one word behind. Entries with no words + /// are stepped over rather than inserting nothing. + fn insert_last_word(&mut self, cx: &mut Context) { + // A repeat resumes one entry older than the last press; a fresh walk + // starts at the newest entry. + let start = match &self.last_word_nav { + Some(walk) => walk.entry.checked_sub(1), + None => self.history.len().checked_sub(1), + }; + let Some(mut entry) = start else { + // Nothing older to reach (or no history at all) — leave the line as + // it stands, the word the previous press inserted included. + return; + }; + let word = loop { + if let Some(w) = self.history[entry].split_whitespace().next_back() { + break w.to_string(); + } + let Some(older) = entry.checked_sub(1) else { + return; + }; + entry = older; + }; + + // Take back what the previous press left, so the walk swaps words in + // place rather than piling them up. + if let Some(walk) = self.last_word_nav.take() { + self.cmd.clear_selection(); + self.cmd.set_cursor(walk.at); + self.cmd.extend_to(walk.at + walk.len); + self.cmd.delete_selection(); + } + let at = self.cmd.cursor(); + self.cmd.insert_str(&word); + self.last_word_nav = Some(LastWordWalk { + entry, + at, + len: word.chars().count(), + }); + // The line is now the user's own edit, not a recalled entry. + self.history_nav = None; cx.notify(); } @@ -4239,11 +4372,7 @@ impl TerminalView { self.write_gap_text(text, text.as_bytes().to_vec(), cx); // Keep the cursor solid while committing input (resets the blink phase). self.cursor_visible = true; - let mut term = self.terminal.term.lock(); - term.selection = None; - term.scroll_display(Scroll::Bottom); - self.scroll_frac = 0.; - drop(term); + self.jump_to_prompt(); cx.notify(); } @@ -7926,10 +8055,248 @@ mod gpui_tests { assert_eq!(view.cmd.cursor(), 0); view.handle_editor_key(&meta("f"), cx); assert_eq!(view.cmd.cursor(), 4); - // Other Meta letters stay swallowed no-ops (line untouched). + // Other Meta letters have no local widget, so they hand the line + // to the shell rather than dying here — see + // `an_unknown_meta_chord_goes_to_the_shell_with_the_line`. view.handle_editor_key(&meta("z"), cx); - assert_eq!(view.cmd.text(), "echo "); - assert_eq!(view.cmd.cursor(), 4); + assert_eq!(view.cmd.text(), ""); + }) + .unwrap(); + } + + /// Fill the scrollback and park the viewport `offset` lines up inside it, + /// so a test can watch a keystroke snap it back to the live prompt. + fn scroll_into_history(view: &TerminalView, offset: usize) { + let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default(); + let mut term = view.terminal.term.lock(); + parser.advance(&mut *term, &b"line\r\n".repeat(60)); + term.scroll_display(Scroll::Delta(offset as i32)); + assert_eq!( + term.grid().display_offset(), + offset, + "the viewport starts parked in the scrollback" + ); + } + + fn display_offset(view: &TerminalView) -> usize { + view.terminal.term.lock().grid().display_offset() + } + + /// Scrolled up into the scrollback, recalling history with ↑ must bring the + /// viewport back to the live prompt (#208). The local editor owns ↑ and + /// returns early, so it never reached the "typing jumps to the prompt" + /// housekeeping on the raw key path — leaving the user editing a line they + /// cannot see. + #[gpui::test] + fn history_recall_snaps_the_viewport_back_to_the_prompt(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + view.history = vec!["echo hello".to_string()]; + scroll_into_history(view, 10); + view.scroll_frac = 0.5; + + view.handle_editor_key(&key("up"), cx); + + assert_eq!(view.cmd.text(), "echo hello", "↑ recalled the entry"); + assert_eq!(display_offset(view), 0, "and the viewport followed it down"); + assert_eq!(view.scroll_frac, 0., "the sub-line remainder reset too"); + }) + .unwrap(); + } + + /// ⌃P / ⌃N are readline's history motions, and a raw terminal passes them + /// to the shell as 0x10 / 0x0e. The local editor swallows every Ctrl chord + /// at the prompt, so without arms of their own they went from "works" to + /// "does nothing" the moment shell integration engaged. + #[gpui::test] + fn ctrl_p_and_ctrl_n_walk_the_history(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + view.history = ["git status", "cargo build", "echo hello"] + .into_iter() + .map(String::from) + .collect(); + + // ⌃P walks back from the newest entry. + view.handle_editor_key(&key("ctrl-p"), cx); + assert_eq!(view.cmd.text(), "echo hello"); + view.handle_editor_key(&key("ctrl-p"), cx); + assert_eq!(view.cmd.text(), "cargo build"); + // ⌃N walks forward again. + view.handle_editor_key(&key("ctrl-n"), cx); + assert_eq!(view.cmd.text(), "echo hello"); + // Past the newest entry the in-progress line comes back. + view.handle_editor_key(&key("ctrl-n"), cx); + assert_eq!(view.cmd.text(), ""); + }) + .unwrap(); + } + + /// A Ctrl chord the local editor has no widget for used to be swallowed, so + /// engaging shell integration *removed* ⌃T, ⌥T, ⌥U and every `bindkey` + /// widget the user had bound. Hand the line to zle instead and let its + /// keymap answer — the same escape hatch ⌃R already uses. + #[gpui::test] + fn an_unknown_ctrl_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + view.cmd.set("echo hi"); + // ⌃T is readline's transpose-chars; tty7 has no widget for it. + view.handle_editor_key(&key("ctrl-t"), cx); + assert_eq!( + view.cmd.text(), + "", + "the line left for the shell, so the local buffer is empty" + ); + assert!( + view.editor_handoff.is_some(), + "the local editor stands down for the rest of the line" + ); + }) + .unwrap(); + assert_eq!(next_input(&mut daemon), b"echo hi".to_vec()); + assert_eq!(next_input(&mut daemon), vec![0x14], "⌃T reached the shell"); + } + + /// The Meta half of the same gap: ⌥U (upcase-word) and friends were dead at + /// the prompt. Unrecognized Meta chords ship the line and the ESC-prefixed + /// key, the way a raw terminal would have. + #[gpui::test] + fn an_unknown_meta_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + view.cmd.set("echo hi"); + view.handle_editor_key( + &gpui::Keystroke { + modifiers: gpui::Modifiers { + alt: true, + ..Default::default() + }, + key: "u".to_string(), + key_char: None, + }, + cx, + ); + assert_eq!(view.cmd.text(), ""); + }) + .unwrap(); + assert_eq!(next_input(&mut daemon), b"echo hi".to_vec()); + assert_eq!(next_input(&mut daemon), b"\x1bu".to_vec()); + } + + /// ⌃W / ⌃U / ⌃K are *kills*, and ⌃Y is what puts a kill back — without it + /// the pair was half-implemented: the editor cut text with nowhere to paste + /// it from. ⌃Y has to stay local rather than reaching the shell, because + /// zle's kill ring is a different buffer and would yank unrelated text. + #[gpui::test] + fn ctrl_y_yanks_back_what_the_kill_chords_cut(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + view.cmd.set("echo hello world"); + view.handle_editor_key(&key("ctrl-w"), cx); + assert_eq!(view.cmd.text(), "echo hello "); + view.handle_editor_key(&key("ctrl-y"), cx); + assert_eq!(view.cmd.text(), "echo hello world"); + assert!( + view.editor_handoff.is_none(), + "the line never left for the shell" + ); + }) + .unwrap(); + } + + /// ⌥. is readline's `yank-last-arg`: it pulls the last word of the previous + /// command into the line, and repeating it walks further back through the + /// history, replacing what the last press inserted. Frequent enough that + /// paying the handoff cost (ghost text and completion gone for the rest of + /// the line) on every press would be the wrong trade — tty7 holds the same + /// history, so it answers locally. + #[gpui::test] + fn meta_dot_walks_back_through_the_last_words(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + let meta_dot = gpui::Keystroke { + modifiers: gpui::Modifiers { + alt: true, + ..Default::default() + }, + key: ".".to_string(), + key_char: None, + }; + view.history = ["git status", "cargo build --release", "echo hello world"] + .into_iter() + .map(String::from) + .collect(); + view.cmd.set("ls "); + + view.handle_editor_key(&meta_dot, cx); + assert_eq!(view.cmd.text(), "ls world", "newest entry's last word"); + view.handle_editor_key(&meta_dot, cx); + assert_eq!(view.cmd.text(), "ls --release", "repeat steps one back"); + view.handle_editor_key(&meta_dot, cx); + assert_eq!(view.cmd.text(), "ls status"); + // Nothing older to reach: the line holds what it had. + view.handle_editor_key(&meta_dot, cx); + assert_eq!(view.cmd.text(), "ls status"); + // The caret sits after the inserted word, ready to keep typing. + assert_eq!(view.cmd.cursor(), "ls status".chars().count()); + }) + .unwrap(); + } + + /// The walk is only a walk while ⌥. repeats. Once another key edits the + /// line, the next ⌥. starts over from the newest entry instead of eating + /// whatever happens to sit left of the caret. + #[gpui::test] + fn an_intervening_key_restarts_the_last_word_walk(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + let meta_dot = gpui::Keystroke { + modifiers: gpui::Modifiers { + alt: true, + ..Default::default() + }, + key: ".".to_string(), + key_char: None, + }; + view.history = ["cargo build --release", "echo hello world"] + .into_iter() + .map(String::from) + .collect(); + + view.handle_editor_key(&meta_dot, cx); + assert_eq!(view.cmd.text(), "world"); + view.handle_editor_key(&key("left"), cx); + view.handle_editor_key(&key("end"), cx); + view.handle_editor_key(&meta_dot, cx); + assert_eq!( + view.cmd.text(), + "worldworld", + "a fresh walk appends rather than replacing the earlier word" + ); + }) + .unwrap(); + } + + /// Chords the editor *does* answer stay local — handing off would forfeit + /// ghost text and completion for the rest of the line, and ⌃A/⌃E/⌃W are + /// exactly the keys pressed most often mid-edit. + #[gpui::test] + fn a_known_ctrl_chord_stays_in_the_local_editor(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + view.cmd.set("echo hi"); + view.handle_editor_key(&key("ctrl-w"), cx); + assert_eq!(view.cmd.text(), "echo ", "⌃W cut the word locally"); + assert!(view.editor_handoff.is_none()); }) .unwrap(); } From c69db5fa83b407d3a35aaff98244cdbc76a309f9 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 27 Jul 2026 17:16:53 +0800 Subject: [PATCH 03/17] 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 679ce82eea25222a1157cc40be72abd6e66d50e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:33:15 +0000 Subject: [PATCH 04/17] deps: bump the cargo-minor-patch group with 4 updates Bumps the cargo-minor-patch group with 4 updates: [ignore](https://github.com/BurntSushi/ripgrep), [tokio](https://github.com/tokio-rs/tokio), [tray-icon](https://github.com/tauri-apps/tray-icon) and [libc](https://github.com/rust-lang/libc). Updates `ignore` from 0.4.26 to 0.4.31 - [Release notes](https://github.com/BurntSushi/ripgrep/releases) - [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md) - [Commits](https://github.com/BurntSushi/ripgrep/compare/ignore-0.4.26...ignore-0.4.31) Updates `tokio` from 1.53.0 to 1.53.1 - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.53.0...tokio-1.53.1) Updates `tray-icon` from 0.24.1 to 0.24.2 - [Release notes](https://github.com/tauri-apps/tray-icon/releases) - [Changelog](https://github.com/tauri-apps/tray-icon/blob/dev/CHANGELOG.md) - [Commits](https://github.com/tauri-apps/tray-icon/compare/tray-icon-v0.24.1...tray-icon-v0.24.2) Updates `libc` from 0.2.186 to 0.2.189 - [Release notes](https://github.com/rust-lang/libc/releases) - [Changelog](https://github.com/rust-lang/libc/blob/0.2.189/CHANGELOG.md) - [Commits](https://github.com/rust-lang/libc/compare/0.2.186...0.2.189) --- updated-dependencies: - dependency-name: ignore dependency-version: 0.4.31 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch - dependency-name: tokio dependency-version: 1.53.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch - dependency-name: tray-icon dependency-version: 0.24.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch - dependency-name: libc dependency-version: 0.2.189 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f63f5adf..7513e929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2022,7 +2022,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -4035,9 +4035,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.26" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" dependencies = [ "crossbeam-deque", "globset", @@ -4589,9 +4589,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libflate" @@ -8873,9 +8873,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -9135,9 +9135,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs", From a7835a0b8c221d8f24aeb872182c4d345ac3969b Mon Sep 17 00:00:00 2001 From: mingrath Date: Mon, 27 Jul 2026 16:42:29 +0700 Subject: [PATCH 05/17] fix(terminal): shape Thai SARA AM with the base it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SARA AM (ำ U+0E33) is `Lo` and width 1, so the grid gives it its own column — but it is not atomic to the shaper. The Thai shaper decomposes it into NIKHAHIT + SARA AA and moves the nikhahit backwards over any above-base marks onto the base consonant. Shaped in a run of its own it has no base to reorder onto, so `น้ำ` came out as `น้` plus a dotted circle, losing the vowel entirely. Absorb a following SARA AM into the preceding cell's cluster, so base, tone mark and SARA AM reach `shape_line` in one string. Lao SARA AM (U+0EB3) takes the same shaper path and is handled with it. That makes `cells == 2` ambiguous, so `Cluster` now records why: a wide base is one glyph spanning two columns and pins at `2 × cell_width`, while an absorbed SARA AM is two base glyphs of one column each and pins like a `Run`. `apply_force_width_to_layout` classifies glyphs by advance rather than by count, so the marks ride their base under either pinning. Two deliberate limits, both pinned by tests: A SARA AM is not a base for another one. Absorbing there would pin the second one's glyphs past the cluster's two-cell clip and swallow it, so `ำำ` stays two `Solo`s and both remain visible. A SARA AM with nothing before it likewise paints alone — a dotted circle is the shaper's honest answer for an orphaned mark, and inventing a base would be worse. An absorbed SARA AM takes its base's style rather than its own, so a colour change mid-syllable (`grep --color` landing between a consonant and its vowel) recolours the vowel. Unlike `Run` and `Wide`, the cluster cannot break on a style change: split off, the vowel renders as a dotted circle. A recoloured vowel beats a broken one. Co-Authored-By: Claude Opus 5 (1M context) --- src/terminal/element.rs | 164 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 149 insertions(+), 15 deletions(-) diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 5e18f837..4d8f3352 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -490,18 +490,51 @@ enum RowSeg { /// drawing, accented Latin, …) that may route to a fallback face whose /// advance isn't the cell width. Solo { col: usize }, - /// A base plus the combining marks stacked on it, shaped as one string so - /// the marks reach the shaper. Never batched with neighbours: the marks add + /// A base with everything that has to shape alongside it — the combining + /// marks stacked on it, and a following SARA AM — as one string, so the + /// shaper sees the whole cluster. Never batched with neighbours: marks add /// characters without adding columns, which is exactly the correspondence /// `force_width` relies on in a [`RowSeg::Run`] or [`RowSeg::Wide`]. + /// + /// An absorbed SARA AM takes the base's style rather than its own. Unlike + /// a [`RowSeg::Run`], the cluster can't break on a style change: split off, + /// SARA AM has no base to reorder its nikhahit onto and renders as a dotted + /// circle. A recoloured vowel beats a broken one. Cluster { col: usize, - /// Columns the base occupies — 2 once the grid marked it wide. + /// Columns the whole cluster occupies — 2 for a wide base, or for a + /// narrow base that absorbed a following SARA AM. cells: usize, text: String, + /// Whether `cells == 2` because the *base* is wide, rather than because + /// a spacing character joined it. The two need opposite pinning: a wide + /// base is one glyph across two columns, an absorbed SARA AM is two + /// glyphs of one column each. + wide_base: bool, }, } +/// Append a cell's character followed by any combining marks riding on it. +fn push_cell(text: &mut String, cell: &RenderCell) { + text.push(cell.c); + text.extend(cell.marks.iter().flat_map(|marks| marks.iter())); +} + +/// SARA AM (Thai U+0E33, Lao U+0EB3) is `Lo` and owns a column, but it is not +/// atomic to the shaper: the Thai shaper decomposes it into NIKHAHIT + SARA AA +/// and moves the nikhahit backwards over any above-base marks onto the base +/// consonant. Shaped in a run of its own it has no base to reorder onto, and +/// comes out as a dotted circle. +fn is_sara_am(c: char) -> bool { + matches!(c, '\u{0E33}' | '\u{0EB3}') +} + +/// Does `col` hold a SARA AM that should join the preceding cell's cluster? +fn sara_am_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> { + row.get(col) + .filter(|cell| !cell.spacer && is_sara_am(cell.c)) +} + /// Split one grid row into paintable segments. /// /// ASCII-graphic cells batch into [`RowSeg::Run`]s: they always come from the @@ -530,15 +563,26 @@ fn segment_row(row: &[RenderCell]) -> Vec { // Combining marks come first: they can sit on an ASCII base too, and // either way the whole cluster has to reach the shaper in one string. if let Some(marks) = &cell.marks { - let cells = if col + 1 < row.len() && row[col + 1].spacer { - 2 - } else { - 1 - }; + let wide_base = col + 1 < row.len() && row[col + 1].spacer; + let mut cells = if wide_base { 2 } else { 1 }; let mut text = String::with_capacity(1 + marks.len()); - text.push(cell.c); - text.extend(marks.iter()); - segs.push(RowSeg::Cluster { col, cells, text }); + push_cell(&mut text, cell); + // A wide base already owns both columns, so only a narrow one has a + // column spare for SARA AM to join it in. A SARA AM is not itself a + // base to absorb onto — two in a row stay separate. + if !wide_base + && !is_sara_am(cell.c) + && let Some(am) = sara_am_at(row, col + 1) + { + push_cell(&mut text, am); + cells = 2; + } + segs.push(RowSeg::Cluster { + col, + cells, + text, + wide_base, + }); col += cells; continue; } @@ -569,6 +613,23 @@ fn segment_row(row: &[RenderCell]) -> Vec { cells: col - start, text, }); + } else if !is_sara_am(cell.c) + && let Some(am) = sara_am_at(row, col + 1) + { + // An unmarked base still has to shape with its SARA AM. A + // baseless SARA AM is not a base for the next one: absorbing + // there would pin the second one's glyphs outside the cluster's + // clip, so two in a row stay separate and both stay visible. + let mut text = String::with_capacity(2); + push_cell(&mut text, cell); + push_cell(&mut text, am); + segs.push(RowSeg::Cluster { + col, + cells: 2, + text, + wide_base: false, + }); + col += 2; } else { segs.push(RowSeg::Solo { col }); col += 1; @@ -848,11 +909,22 @@ fn paint_glyphs( // Same pinning as the batched runs, just for one base: two // columns get `force_width` so a fallback emoji face can't // drift, one column paints at the origin like `Solo`. - RowSeg::Cluster { col, cells, text } => ( + // Two columns pin per *base glyph*, and which that is depends + // on why the cluster is two cells wide: a wide base is one + // glyph spanning both, an absorbed SARA AM is two glyphs of one + // column each. `force_width` classifies by advance, so the + // marks ride their base under either. One column paints at the + // origin like `Solo`. + RowSeg::Cluster { + col, + cells, + text, + wide_base, + } => ( col, cells, SharedString::from(text), - (cells == 2).then(|| geom.cell_width * 2.), + (cells == 2).then(|| geom.cell_width * if wide_base { 2. } else { 1. }), cells == 1, ), }; @@ -2127,6 +2199,16 @@ mod tests { col, cells, text: text.to_string(), + wide_base: false, + } + } + + fn wide_cluster(col: usize, cells: usize, text: &str) -> RowSeg { + RowSeg::Cluster { + col, + cells, + text: text.to_string(), + wide_base: true, } } @@ -2145,7 +2227,7 @@ mod tests { // spacer too (❤ + U+FE0F). let mut row = wide_cells("\u{2764}"); row[0].marks = Some(Box::from(['\u{FE0F}'])); - assert_eq!(segment_row(&row), [cluster(0, 2, "\u{2764}\u{FE0F}")]); + assert_eq!(segment_row(&row), [wide_cluster(0, 2, "\u{2764}\u{FE0F}")]); // Several marks on one base: an above-base vowel and a tone mark both // sit on the consonant (ที่ = ท U+0E17 + ◌ี U+0E35 + ◌่ U+0E48). @@ -2157,6 +2239,58 @@ mod tests { ); } + /// SARA AM (U+0E33) is the awkward Thai vowel: `Lo`, width 1, so the grid + /// gives it its own column — but the shaper decomposes it into NIKHAHIT + + /// SARA AA and reorders the nikhahit backwards onto the base consonant. + /// Shaped in its own run it has no base to reorder onto and comes out as a + /// dotted circle, so it has to join the preceding cell's cluster. + #[test] + fn segment_row_absorbs_sara_am_into_its_base() { + // น + ้ (tone) + ำ — the base already carries a mark. + let mut row = vec![cell('\u{0E19}'), cell('\u{0E33}'), cell('a')]; + row[0].marks = Some(Box::from(['\u{0E49}'])); + assert_eq!( + segment_row(&row), + [cluster(0, 2, "\u{0E19}\u{0E49}\u{0E33}"), run(2, 1, "a")] + ); + + // ก + ำ — an unmarked base still has to shape with it. + let row = vec![cell('\u{0E01}'), cell('\u{0E33}')]; + assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]); + + // Lao SARA AM (U+0EB3) takes the same shaper path. + let row = vec![cell('\u{0E81}'), cell('\u{0EB3}')]; + assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E81}\u{0EB3}")]); + + // A style change does not break the cluster, unlike a `Run` or `Wide` + // batch: split off, the vowel has no base and paints a dotted circle, + // so it takes the base's style instead. + let mut row = vec![cell('\u{0E01}'), cell('\u{0E33}')]; + row[1].fg = gpui::red(); + assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]); + } + + /// With nothing to attach to, SARA AM paints alone — a dotted circle is the + /// shaper's honest answer for an orphaned mark, and inventing a base would + /// be worse. + #[test] + fn segment_row_leaves_a_baseless_sara_am_alone() { + let row = vec![cell('\u{0E33}'), cell('a')]; + assert_eq!(segment_row(&row), [RowSeg::Solo { col: 0 }, run(1, 1, "a")]); + + // A blank before it is not a base either. + let row = vec![cell(' '), cell('\u{0E33}')]; + assert_eq!(segment_row(&row), [RowSeg::Solo { col: 1 }]); + + // Nor is another SARA AM: absorbing would pin the second one's glyphs + // past the cluster's two-cell clip and swallow it entirely. + let row = vec![cell('\u{0E33}'), cell('\u{0E33}')]; + assert_eq!( + segment_row(&row), + [RowSeg::Solo { col: 0 }, RowSeg::Solo { col: 1 }] + ); + } + /// A marked cell never joins a batch: marks add characters without adding /// columns, which would desync `force_width`'s glyph-per-column pinning. #[test] @@ -2176,7 +2310,7 @@ mod tests { segment_row(&row), [ wide(0, 2, "你"), - cluster(2, 2, "好\u{FE0F}"), + wide_cluster(2, 2, "好\u{FE0F}"), wide(4, 2, "世"), ] ); From ee921c4879a0ee17138ddd65d27ef2b9363303db Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 27 Jul 2026 17:54:47 +0800 Subject: [PATCH 06/17] 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 07/17] 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 08/17] 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 09/17] 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| { From c21508df6fc8f69bf011c767512301c3ea722c37 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 08:04:27 +0800 Subject: [PATCH 10/17] chore(settings): finish off the dim-inactive-panes setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #214 added the switch itself; this is the wiring around it that a new setting in this codebase is expected to carry. - Index the row in `settings_search_entries`, which the settings search box matches against. Without an entry, searching "dim", "fade" or "unfocused" — the words someone actually looks for — finds nothing, and the switch is only reachable by scrolling to it. Pinned in `index_titles_match_rendered_row_labels` so the title cannot drift. - Pin the default and the round trip, as every other `default_true` flag here does (see `confirm_window_close_defaults_on_and_round_trips`): a config written before the switch existed must still dim, and a `false` must survive save/load or the effect comes back next launch. - Hand the flag to `Pane::render` instead of reading the `Config` global from inside it. `pane.rs` had no global state before, deliberately — the leaf type is generic so the tree logic can be tested with plain values. The caller already computes the split test the dimming was gated on, so it can compute this too: one lookup per frame rather than one per leaf, and the tree stays renderable without a Config global. While there, `show_focus` is now named for what it does — nothing drew a focus ring; it only ever gated the fade. - Move the row below "Follow theme". That button clears the opacity and blur overrides only, and a third row directly above it read as something it would also reset. - Changelog entry. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++++++++++++ src/core/config.rs | 19 +++++++++++++++++++ src/ui/app.rs | 11 +++++++---- src/ui/pane.rs | 42 +++++++++++++++++++++--------------------- src/ui/settings.rs | 30 ++++++++++++++++++++---------- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478bb9c1..487e9cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Inactive panes only fade if you want them to** — a split tab dims every pane + but the focused one so the active terminal reads as foreground. That is the + right default, but it is not free: at 55% opacity a dim theme's comment color + or a long-running build's output in the pane you are *watching* rather than + typing into gets harder to read, and some people track panes by cursor alone + and never needed the cue. Settings → Appearance → Transparency now carries a + "Dim inactive panes" switch. On by default, so nothing changes for anyone who + was happy; off renders every pane at full opacity. (#214) + ## [26.7.5] - 2026-07-27 ### Added diff --git a/src/core/config.rs b/src/core/config.rs index 700f3b94..98e53117 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1095,6 +1095,25 @@ mod tests { assert!(!newer.confirm_window_close); } + /// Also opt-*out*: every config written before the switch existed predates + /// the choice, and those users have been looking at dimmed panes all along — + /// defaulting to `false` would silently change how every split tab looks on + /// upgrade. And once someone does turn it off, the `false` has to survive a + /// save/load cycle, or the effect they opted out of returns on next launch. + #[test] + fn dim_inactive_panes_defaults_on_and_round_trips() { + assert!(Config::default().dim_inactive_panes); + + let old: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(old.dim_inactive_panes); + + let off: Config = serde_json::from_str(r#"{"dim_inactive_panes": false}"#).unwrap(); + assert!(!off.dim_inactive_panes); + let json = serde_json::to_string(&off).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(!back.dim_inactive_panes); + } + #[test] fn theme_follow_system_defaults_and_round_trips() { // Old configs (no follow-system keys) must land on off + the built-in diff --git a/src/ui/app.rs b/src/ui/app.rs index b2181f7d..b73cb4ad 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2506,8 +2506,8 @@ 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. + /// Toggle inactive-pane dimming. Applies on the next render — `update_config` + /// notifies, and this view's render is what hands the flag to the pane tree. pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.dim_inactive_panes = on); } @@ -5235,8 +5235,11 @@ impl Render for Tty7App { .child(leaf.clone()) .into_any_element(), None => { - let show_focus = active_tab.pane.leaves().len() > 1; - active_tab.pane.render(show_focus, window, cx) + // Fading the unfocused panes only says anything once the + // tab is actually split, and the user can turn it off. + let dim_inactive = active_tab.pane.leaves().len() > 1 + && cx.global::().dim_inactive_panes; + active_tab.pane.render(dim_inactive, window, cx) } } } diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 5f658295..cade6881 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -524,33 +524,33 @@ impl Pane> { self.close_leaf_where(&|v| v.entity_id() == target.entity_id()) } - /// Render the subtree. `show_focus` draws a focus ring on the active leaf - /// (suppressed when the tab has a single pane). - pub fn render(&self, show_focus: bool, window: &mut Window, cx: &mut App) -> gpui::AnyElement { + /// Render the subtree. `dim_inactive` fades every leaf but the focused one; + /// the caller decides it — it is off for an unsplit tab (nothing to + /// distinguish) and off when the user turned `dim_inactive_panes` off. Kept + /// a parameter rather than a `Config` global read here so the tree stays + /// renderable without one, as the rest of this module is. + pub fn render( + &self, + dim_inactive: bool, + window: &mut Window, + cx: &mut App, + ) -> gpui::AnyElement { match self { Pane::Empty => div().into_any_element(), Pane::Leaf(v) => { - let focused = show_focus && v.read(cx).focus_handle.contains_focused(window, cx); + let focused = v.read(cx).focus_handle.contains_focused(window, cx); // No full border (it reads as a hard rectangle). div() .size_full() .relative() .overflow_hidden() - // Inactive panes (only when the tab is actually split) fade back - // so the focused terminal reads as foreground without a hard - // border. Element opacity multiplies through the whole subtree - // (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. `dim_inactive_panes` opts out. - .when( - show_focus - && !focused - && cx - .global::() - .dim_inactive_panes, - |d| d.opacity(0.55), - ) + // Inactive panes fade back so the focused terminal reads as + // foreground without a hard border. Element opacity multiplies + // through the whole subtree (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(dim_inactive && !focused, |d| d.opacity(0.55)) .child(v.clone()) .into_any_element() } @@ -681,7 +681,7 @@ impl Pane> { .flex_basis(px(0.)) .min_w_0() .min_h_0() - .child(a.render(show_focus, window, cx)), + .child(a.render(dim_inactive, window, cx)), ) .child(divider) .child( @@ -691,7 +691,7 @@ impl Pane> { .flex_basis(px(0.)) .min_w_0() .min_h_0() - .child(b.render(show_focus, window, cx)), + .child(b.render(dim_inactive, window, cx)), ) .into_any_element() } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 4eeb63ff..edd32d10 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -144,6 +144,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Blur", keywords: "transparency translucent frosted vibrancy window background", }, + SearchEntry { + section: Appearance, + title: "Dim inactive panes", + keywords: "fade unfocused inactive split pane focus opacity highlight active dimming", + }, SearchEntry { section: Appearance, title: "Font size", @@ -1518,10 +1523,12 @@ impl Tty7App { .into_any_element() } - /// Window section (Appearance): global opacity slider + blur switch that - /// apply to every theme. Both are config *overrides* — until touched they - /// follow the active theme's own `opacity`/`blur`, and "Follow theme" - /// clears them back to that state. + /// Window section (Appearance): the global opacity slider and blur switch + /// that apply to every theme, then the inactive-pane dimming switch. The + /// first two are config *overrides* — until touched they follow the active + /// theme's own `opacity`/`blur`, and "Follow theme" clears them back to that + /// state; the dimming switch is a plain flag no theme carries a value for, + /// so it sits below that button and "Follow theme" leaves it alone. fn render_window_section(&self, cx: &mut Context) -> AnyElement { let Some(slider) = self .active_settings() @@ -1578,12 +1585,6 @@ 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| { @@ -1598,6 +1599,14 @@ impl Tty7App { ), ) }) + // Below "Follow theme", which resets the two rows above it and not + // this one — a plain setting with no theme value behind it. + .child(self.settings_row( + "Dim inactive panes", + "Fade unfocused panes in a split so the active one stands out.", + dim_switch, + cx, + )) .into_any_element() } @@ -4623,6 +4632,7 @@ mod tests { "Sidebar grouping", "Tab completion", "History search", + "Dim inactive panes", ] { assert!( settings_search_entries().iter().any(|e| e.title == title), From 0be3b6764024f8eaa8d34835be3aa87a95a2b5e1 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 08:42:08 +0800 Subject: [PATCH 11/17] fix(render): stop italic CJK rendering as unrelated CJK on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every character came out as a different character, one for one, consistently — it read as a broken locale or a mangled encoding, and it was neither. Hack, the bundled default, has no CJK, so those cells are shaped through the font-fallback chain. gpui's Windows backend then threw away the face DirectWrite shaped the run with and looked a fresh one up by family, weight and style. That round trip mapped DirectWrite's italic to oblique — the enum is numbered OBLIQUE = 1, ITALIC = 2, and the mapping had them the other way around — so an italic fallback face resolved to a request for an oblique one, and a family with no oblique face (Maple Mono NF CN, first in our Windows chain) came back as its upright face instead. The glyph indices were right; the outlines they indexed belonged to a different face, at a fixed glyph-id skew. Fixed upstream in our gpui fork by registering the face DirectWrite actually chose rather than re-deriving one, which also closes a latent use-after-free in the same cache: it keyed fonts by a raw pointer to a face nothing held a reference to, so a released face could be aliased by any later allocation. Bumps the fork pin; no tty7 code changes. Covered there by two tests in `gpui_windows::direct_write` — one asserting a shaped run's glyphs round-trip through the font id the run reports, one asserting every font-face cache key is owned by the font it maps to. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 +++++++++++++++++ Cargo.lock | 50 +++++++++++++++++++++++++------------------------- Cargo.toml | 21 +++++++++++++++------ 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478bb9c1..ea70d003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Italic CJK rendered as unrelated CJK on Windows** — every character came out + as a different character, one for one, consistently, so it read as a broken + locale or a mangled encoding. It was neither. Hack, the bundled default, has no + CJK, so those cells are shaped by the font-fallback chain; gpui's Windows + backend then threw away the face DirectWrite shaped with and looked a fresh one + up by family, weight and style. That round trip mapped DirectWrite's *italic* + to *oblique* — the two are numbered the other way around in the API — and a + family with no oblique face resolved to its upright one. The glyph indices were + right; the outlines they were pointing into belonged to a different face. Fixed + in our gpui fork by rasterizing the face DirectWrite actually chose, which also + closes a latent use-after-free in the same cache: it keyed fonts by a raw + pointer to a face nothing held a reference to. + ## [26.7.5] - 2026-07-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index f63f5adf..605e3ebf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1369,7 +1369,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "gpui_util", "indexmap", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "proc-macro2", "quote", @@ -3096,7 +3096,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "anyhow", @@ -3287,7 +3287,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "accesskit_unix", @@ -3338,7 +3338,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "accesskit_macos", @@ -3385,7 +3385,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "console_error_panic_hook", "gpui", @@ -3409,7 +3409,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "schemars", "serde", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "log", @@ -3428,7 +3428,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "bytemuck", @@ -3481,7 +3481,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "accesskit_windows", @@ -3800,7 +3800,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "async-compression", @@ -3825,7 +3825,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "rustls", "rustls-platform-verifier", @@ -4946,7 +4946,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "bindgen", @@ -6044,7 +6044,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "collections", "serde", @@ -7021,7 +7021,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "derive_refineable", ] @@ -7064,7 +7064,7 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "bytes", @@ -7589,7 +7589,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "async-task", "backtrace", @@ -8396,7 +8396,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "heapless", "log", @@ -9775,7 +9775,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "async-fs", @@ -9814,7 +9814,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "perf", "quote", @@ -11620,7 +11620,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "chrono", @@ -11665,7 +11665,7 @@ dependencies = [ [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "tracing", "tracing-subscriber", @@ -11676,7 +11676,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 9d076654..b1b0a15f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -319,12 +319,21 @@ lto = "thin" codegen-units = 1 # ---- gpui fork ------------------------------------------------------------ -# Our `tty7` branch (cut from the pinned upstream rev, one commit on top) carries -# a single patch: `prefers_ime_for_printable_keys` takes the keystroke, so an -# input handler can answer per key instead of per view. tty7 needs it for -# Option-as-Meta — macOS routes ⌥-chords to the IME whenever a CJK input source -# is active, and without the keystroke there is no way to decline just those -# chords (see `terminal::input::prefers_ime_for_printable_keys`, issue #177). +# Our `tty7` branch (cut from the pinned upstream rev, two commits on top) carries: +# +# 1. `prefers_ime_for_printable_keys` takes the keystroke, so an input handler can +# answer per key instead of per view. tty7 needs it for Option-as-Meta — macOS +# routes ⌥-chords to the IME whenever a CJK input source is active, and without +# the keystroke there is no way to decline just those chords (see +# `terminal::input::prefers_ime_for_printable_keys`, issue #177). +# +# 2. gpui's Windows backend rasterizes a font-fallback run with the face +# DirectWrite actually shaped it with, instead of re-deriving one from the +# face's family/weight/style. The round trip mapped DirectWrite's italic to +# oblique, so italic CJK — which every pane reaches through the fallback chain, +# Hack having no CJK — drew a *different* face's outlines at the shaped glyph +# indices. Every character rendered as an unrelated character, one for one, +# which reads as mojibake rather than as a font bug. # # Patching by source rather than editing the `gpui`/`gpui_platform` pins above is # deliberate: `gpui-component` declares its own `gpui` from the upstream URL, and From 063e4a5064adb1a1534caa5ffacdbbd6467c87dd Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 09:14:14 +0800 Subject: [PATCH 12/17] fix(render): keep underlines on natively-drawn cells, and stroke weight uniform at fractional DPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to #229. Underlines ride on the `TextRun` that `paint_glyphs` builds, and the Solo arm returned early for every natively-drawn cell — so an `ESC[4m` span or a hovered URL showed a one-column hole wherever it crossed a box-drawing character. The mechanism predates #229 (the Powerline branch has always had it), but #229 widened it from a dozen private-use separators to all 256 characters of U+2500–U+259F. Such a cell now shapes a space in its own style instead of returning, so gpui draws the line from the same `UnderlineStyle` — curly and double included — that every other cell uses. Stroke weight varied between cells at fractional device scale. `rectb` snaps a rect's two edges independently, which is what makes neighbouring cells tile, but two edges `w` apart land `w × scale` device pixels apart: when that is not a whole number the two roundings straddle it, so a 1-logical-pixel rule came out 1 device pixel wide in one column and 2 in the next. At Windows' default 125%/150% scaling that alternated thin/thick across every column of a TUI table, and down every row for horizontal rules. Integer scales are blind to it by construction, which is why 1x and 2x looked right. `light_thickness` now quantises to whole device pixels, and `vstroke` / `hstroke` lay that width off from the snapped near edge rather than inferring it from a second snap — float ties at `.5` made the quantisation alone insufficient. Stroke ends still snap, so #229's tiling guarantee is untouched. Block elements stay on `rectb`: they are area fills, not strokes. Co-Authored-By: Claude Opus 5 --- src/terminal/boxdraw.rs | 188 +++++++++++++++++++++++++++++++++++----- src/terminal/element.rs | 101 ++++++++++++++++++--- 2 files changed, 257 insertions(+), 32 deletions(-) diff --git a/src/terminal/boxdraw.rs b/src/terminal/boxdraw.rs index 2ddc6648..61d1cd92 100644 --- a/src/terminal/boxdraw.rs +++ b/src/terminal/boxdraw.rs @@ -69,11 +69,8 @@ enum Arm { 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. +/// Cell geometry in f32, plus the light stroke thickness `t` (see +/// [`light_thickness`] for how that one is chosen). struct Cell { x0: f32, y0: f32, @@ -85,12 +82,36 @@ struct Cell { scale: f32, } +/// The light stroke thickness for a cell `cell_width` wide, in logical pixels. +/// +/// Two rules, in order: +/// +/// 1. Derive 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. +/// 2. Then quantise so the result covers a whole number of device pixels. +/// +/// Rule 2 keeps the nominal weight and the painted weight in agreement: +/// [`Cell::vstroke`] lays a stroke off in whole device pixels, and everything +/// positioned relative to `t` (the arm overshoot, the double-line separation, +/// `heavy = 2 × light`) should be reasoning about the same value the rasteriser +/// will actually produce. +/// +/// Rounding the logical value *first* is what keeps 1x and 2x byte-identical to +/// what this module shipped with — those are the scales it was tuned and +/// visually verified at, so the fractional-scale fix must not disturb them. +fn light_thickness(cell_width: f32, scale: f32) -> f32 { + let logical = (cell_width * 0.15).round().max(1.); + (logical * scale).round().max(1.) / scale +} + impl Cell { 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(); let y1 = y0 + b.size.height.as_f32(); + let scale = scale.max(0.1); Cell { x0, y0, @@ -98,8 +119,8 @@ impl Cell { y1, cx: (x0 + x1) / 2., cy: (y0 + y1) / 2., - t: ((x1 - x0) * 0.15).round().max(1.), - scale: scale.max(0.1), + t: light_thickness(x1 - x0, scale), + scale, } } @@ -123,6 +144,49 @@ impl Cell { Ink::Rect(self.rectb(x, y, w, h)) } + /// A logical thickness as a whole number of device pixels, back in logical + /// units. Never zero: a stroke that rounds away is worse than one that is + /// a touch too thick. + fn stroke_px(&self, w: f32) -> f32 { + (w * self.scale).round().max(1.) / self.scale + } + + /// A vertical stroke of logical width `w`, centred on `x`, spanning + /// `ya..yb`. + /// + /// The two *ends* snap like any other edge, so a stroke that runs to a cell + /// boundary still shares that boundary exactly with the cell beyond it — + /// the tiling property [`rectb`](Self::rectb) exists for. + /// + /// The *width* is deliberately not a second pair of independent snaps. Two + /// edges `w` apart land `w × scale` device pixels apart, and unless that is + /// exactly a whole number the two `round`s straddle it — rounding apart in + /// some cells and together in others, which made vertical rules alternate + /// thin/thick across the columns of a TUI table at Windows' default 125% / + /// 150% scaling. [`light_thickness`] picks `w` so the product is integral, + /// but `f32` cannot always represent it exactly (a `1.5×` scale gives + /// `2/1.5 × 1.5 = 2.0000001`), and a coordinate landing on a `.5` tie then + /// rounds whichever way the error points. Laying the width off from the + /// snapped near edge sidesteps the tie entirely: same weight everywhere, + /// by construction rather than by luck. + fn vstroke(&self, x: f32, w: f32, ya: f32, yb: f32) -> Ink { + let (x0, y0, y1) = (self.snap(x - w / 2.), self.snap(ya), self.snap(yb)); + Ink::Rect(Bounds::new( + point(px(x0), px(y0)), + size(px(self.stroke_px(w)), px(y1 - y0)), + )) + } + + /// A horizontal stroke of logical width `w`, centred on `y`, spanning + /// `xa..xb`. See [`vstroke`](Self::vstroke). + fn hstroke(&self, y: f32, w: f32, xa: f32, xb: f32) -> Ink { + let (y0, x0, x1) = (self.snap(y - w / 2.), self.snap(xa), self.snap(xb)); + Ink::Rect(Bounds::new( + point(px(x0), px(y0)), + size(px(x1 - x0), px(self.stroke_px(w))), + )) + } + /// The light/heavy arm combinations: one rectangle per arm, each running /// from its cell edge to just past the centre. /// @@ -141,16 +205,16 @@ impl Cell { 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)); + ink.push(self.vstroke(self.cx, wu, self.y0, self.cy + m)); } if wd > 0. { - ink.push(self.rect(self.cx - wd / 2., self.cy - m, wd, self.y1 - (self.cy - m))); + ink.push(self.vstroke(self.cx, wd, self.cy - m, self.y1)); } if wl > 0. { - ink.push(self.rect(self.x0, self.cy - wl / 2., self.cx + m - self.x0, wl)); + ink.push(self.hstroke(self.cy, wl, self.x0, self.cx + m)); } if wr > 0. { - ink.push(self.rect(self.cx - m, self.cy - wr / 2., self.x1 - (self.cx - m), wr)); + ink.push(self.hstroke(self.cy, wr, self.cx - m, self.x1)); } ink } @@ -173,8 +237,8 @@ impl Cell { 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); + let v = |x: f32, ya: f32, yb: f32| self.vstroke(x, t, ya, yb); + let hz = |y: f32, xa: f32, xb: f32| self.hstroke(y, t, xa, xb); Some(match c { '═' => vec![hz(ha, x0, x1), hz(hb, x0, x1)], '║' => vec![v(va, y0, y1), v(vb, y0, y1)], @@ -294,16 +358,14 @@ impl Cell { // seam exactly where they hand off. let lap = 1. / self.scale; if sy > 0. { - let top = cy + r - lap; - ink.push(self.rect(cx - h, top, self.t, self.y1 - top)); + ink.push(self.vstroke(cx, self.t, cy + r - lap, self.y1)); } else { - ink.push(self.rect(cx - h, self.y0, self.t, (cy - r + lap) - self.y0)); + ink.push(self.vstroke(cx, self.t, self.y0, cy - r + lap)); } if sx > 0. { - let left = cx + r - lap; - ink.push(self.rect(left, cy - h, self.x1 - left, self.t)); + ink.push(self.hstroke(cy, self.t, cx + r - lap, self.x1)); } else { - ink.push(self.rect(self.x0, cy - h, (cx - r + lap) - self.x0, self.t)); + ink.push(self.hstroke(cy, self.t, self.x0, cx - r + lap)); } // The arc band, from the vertical stub (θ=0) to the horizontal one // (θ=π/2) around the arc centre one radius into the quadrant. @@ -399,9 +461,9 @@ impl Cell { 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) + self.vstroke(self.cx, w, s, s + len) } else { - self.rect(s, self.cy - w / 2., len, w) + self.hstroke(self.cy, w, s, s + len) } }) .collect(); @@ -820,4 +882,88 @@ mod tests { let top = extents(&glyph('│', below, scale).unwrap()).2; assert_eq!(bottom, top, "adjacent │ cells no longer tile"); } + + /// Every column must draw `│` at the *same* weight, and every row must draw + /// `─` at the same weight, at any scale factor — not just the integer ones. + /// + /// Note what the test above does *not* catch: it asserts each edge lands on + /// the device grid, which a 1-device-pixel stroke and a 2-device-pixel + /// stroke both satisfy. Windows' default 125%/150% display scaling put a + /// 1-logical-pixel stroke a non-integer number of device pixels wide, and + /// the two independent edge snaps then rounded apart in some columns and + /// together in others: vertical rules alternated thin/thick across a TUI + /// table, horizontal rules alternated down it. Both 1x and 2x are blind to + /// it by construction, so the earlier fixtures could never have failed. + #[test] + fn stroke_weight_is_uniform_across_cells_at_any_scale() { + // Realistic cell metrics: a 13/15/16px font's advance, line_height 1.4. + for (cw, lh) in [(7.8f32, 18.0f32), (9.03, 21.0), (9.6, 22.0), (10.8, 25.0)] { + for scale in [1.0f32, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] { + let widths: Vec = (0..24) + .map(|i| { + let b = Bounds::new(point(px(cw * i as f32), px(0.)), size(px(cw), px(lh))); + let Ink::Rect(r) = &glyph('│', b, scale).unwrap()[0] else { + panic!("│ should be a rect") + }; + (r.size.width.as_f32() * scale).round() + }) + .collect(); + let (lo, hi) = ( + widths.iter().cloned().fold(f32::MAX, f32::min), + widths.iter().cloned().fold(f32::MIN, f32::max), + ); + assert_eq!( + lo, hi, + "│ weight varies {lo}..{hi} device px across columns \ + (cell_width {cw}, scale {scale}): {widths:?}" + ); + assert!(lo >= 1., "│ thinner than a device pixel at scale {scale}"); + + let heights: Vec = (0..24) + .map(|r| { + let b = Bounds::new(point(px(0.), px(lh * r as f32)), size(px(cw), px(lh))); + let Ink::Rect(rect) = &glyph('─', b, scale).unwrap()[0] else { + panic!("─ should be a rect") + }; + (rect.size.height.as_f32() * scale).round() + }) + .collect(); + let (lo, hi) = ( + heights.iter().cloned().fold(f32::MAX, f32::min), + heights.iter().cloned().fold(f32::MIN, f32::max), + ); + assert_eq!( + lo, hi, + "─ weight varies {lo}..{hi} device px across rows \ + (cell_width {cw}, scale {scale}): {heights:?}" + ); + } + } + } + + /// Quantising the thickness in device space must not change what 1x and 2x + /// already rendered — those are the two scales the module was tuned and + /// visually verified at. + #[test] + fn integer_scales_keep_their_previous_thickness() { + for (cw, lh) in [(7.8f32, 18.0f32), (9.03, 21.0), (9.6, 22.0), (10.8, 25.0)] { + for scale in [1.0f32, 2.0, 3.0] { + let previous = (cw * 0.15).round().max(1.); + assert_eq!( + light_thickness(cw, scale), + previous, + "cell_width {cw} at scale {scale} changed weight" + ); + // And heavy stays exactly twice light, as `arms` assumes. + let b = Bounds::new(point(px(0.), px(0.)), size(px(cw), px(lh))); + let Ink::Rect(l) = &glyph('│', b, scale).unwrap()[0] else { + panic!() + }; + let Ink::Rect(h) = &glyph('┃', b, scale).unwrap()[0] else { + panic!() + }; + assert!(h.size.width.as_f32() > l.size.width.as_f32()); + } + } + } } diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 0e64be90..da6fc897 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -756,6 +756,27 @@ fn powerline_path(bounds: Bounds, shape: PowerlineShape) -> gpui::Path

Option { + style.draws_on_blanks().then_some(' ') +} + fn seg_clip_width(solo: bool, cells: usize, cell_width: Pixels) -> Pixels { if solo { cell_width * 2. @@ -838,17 +859,19 @@ fn paint_glyphs( 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) { + // Two families paint as native geometry rather than as a + // font glyph: Powerline separators, and the box-drawing / + // block characters (`boxdraw`) — a font glyph only covers + // the font's own line height, which broke every vertical + // run of `│`/`╭`/`╰` into dashes at line_height > 1.0. + // Either way the cell may still owe an underline, so this + // records whether the ink is already down rather than + // returning outright. + let native = if let Some(shape) = PowerlineShape::of(cell.c) { 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) = + true + } else if let Some(ink) = super::boxdraw::glyph(cell.c, cell_bounds, window.scale_factor()) { let fg = GlyphStyle::of(cell).fg; @@ -863,9 +886,20 @@ fn paint_glyphs( super::boxdraw::Ink::Path(p) => window.paint_path(p, fg), } } - continue; + true + } else { + false + }; + if !native { + (col, 1, char_string(cell.c), None, true) + } else { + match native_cell_residue(&GlyphStyle::of(cell)) { + None => continue, + // `solo: false` clips the space to its own single + // column so the underline can't spill sideways. + Some(c) => (col, 1, char_string(c), None, false), + } } - (col, 1, char_string(cell.c), None, true) } // Same pinning as the batched runs, just for one base: two // columns get `force_width` so a fallback emoji face can't @@ -2092,6 +2126,51 @@ mod tests { ); } + /// A natively-drawn cell keeps its underline. + /// + /// Underlines ride on the `TextRun`, so the Solo arm's early return for + /// Powerline separators and box-drawing characters used to drop them: an + /// `ESC[4m` span or a hovered URL containing `─`, `│` or `` showed a + /// one-column hole where the line should have run through. The residue is + /// what closes it — a space shaped in the cell's own style, carrying the + /// underline and no glyph ink. + #[test] + fn natively_drawn_cells_still_carry_their_underline() { + let plain = GlyphStyle::of(&cell('│')); + assert_eq!( + native_cell_residue(&plain), + None, + "an unstyled box character has nothing left to shape" + ); + + for kind in [ + UnderlineKind::Single, + UnderlineKind::Double, + UnderlineKind::Curly, + ] { + let mut c = cell('│'); + c.underline = kind; + assert_eq!( + native_cell_residue(&GlyphStyle::of(&c)), + Some(' '), + "{kind:?} underline dropped on a box-drawing cell" + ); + } + + // A hovered link underlines even without an emulator underline, and + // the characters it spans may well be box drawing or a separator. + for ch in ['│', '─', '╭', '█', '\u{e0b0}'] { + let mut c = cell(ch); + c.link_hover = true; + assert_eq!( + native_cell_residue(&GlyphStyle::of(&c)), + Some(' '), + "hovered-link underline dropped on U+{:04X}", + ch as u32 + ); + } + } + #[test] fn segment_row_keeps_powerline_separators_solo() { // The native-draw intercept lives in the Solo arm of `paint_glyphs`; From 4d09df3b714c0895a6e6bf524490d0a15c86aebd Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 10:36:28 +0800 Subject: [PATCH 13/17] chore: drop stale "focus rings" comment the show_focus rename missed The render call site still said "show focus rings only when split" -- the same misdescription the parameter rename in this PR removes: nothing ever drew a ring, the flag only gated the fade, and the fade condition is now spelled out two lines below. Co-Authored-By: Claude Fable 5 --- src/ui/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index b73cb4ad..26c1925f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5214,7 +5214,7 @@ impl Render for Tty7App { .get(self.active) .and_then(|t| t.pane.focused_or_first(window, cx)) .and_then(|leaf| self.render_ssh_status_strip(&leaf, cx)); - // Render the active tab's pane tree; show focus rings only when split. + // Render the active tab's pane tree. let body = match self.tabs.get(self.active) { // Zero tabs: the window's own face — the home page (see `ui::home`). None => self.render_home(cx).into_any_element(), From 7ffd6afe65ac05d80658c77f2f89c8b6f1044288 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 10:36:46 +0800 Subject: [PATCH 14/17] docs(render): reattach seg_clip_width's doc comment native_cell_residue was inserted between seg_clip_width's doc block and the function itself, so rustdoc attached the clip-width prose to the residue helper and left seg_clip_width undocumented. Move the helper (with its own doc) above the block instead. No code change. Co-Authored-By: Claude Fable 5 --- src/terminal/element.rs | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/terminal/element.rs b/src/terminal/element.rs index da6fc897..b4044521 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -738,24 +738,6 @@ fn powerline_path(bounds: Bounds, shape: PowerlineShape) -> gpui::Path

Option { style.draws_on_blanks().then_some(' ') } +/// The width `paint_glyphs` clips a segment's paint to. +/// +/// A batched `Run`/`Wide` segment clips to its exact column span (`cells` +/// columns): its glyphs come from faces whose advance matches the cell, so +/// nothing should spill past that span. A lone `solo` glyph is different — it +/// can be a symbol whose face paints ink well past the single cell the grid +/// reserved for it: a non-Mono Nerd Font sets a *one-cell advance* on its icons +/// yet draws up to ~1.9 cells of ink (measured across Hasklug / Meslo / +/// JetBrainsMono NF), and the OS cascade serves a proportional `➜`/`❯` the same +/// way. Clipping that to one cell severs the glyph mid-ink — the incomplete +/// icons and the cut-off arrow in issue #17. +/// +/// Advance is no signal there (it reads one cell for exactly those overflowing +/// icons), so a solo glyph gets a two-cell window instead. A glyph that already +/// fits is untouched — it has no ink to spill — while a symbol that overflows +/// renders whole, bleeding into a trailing blank the way iTerm2 and Terminal.app +/// do with non-Mono faces. The two-cell bound keeps a pathological face from +/// smearing a lone glyph across the row. fn seg_clip_width(solo: bool, cells: usize, cell_width: Pixels) -> Pixels { if solo { cell_width * 2. From 042bb784edfae735ef82df291decf08072ad067a Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 10:40:58 +0800 Subject: [PATCH 15/17] fix(daemon): match capability env keys case-insensitively on Windows Windows environment blocks are case-insensitive: portable-pty's CommandBuilder keeps one slot per lowercased key, so a configured `Term`/`ColorTerm` in `env` would land in the same slot as `TERM`/`COLORTERM` and, coming later, replace it -- sidestepping the rule that user env may rename the terminal but not contradict what the pane's decoder implements. Filter capability keys with the platform's own notion of "the same variable": case-insensitive on Windows, exact elsewhere (where a differently-cased key is a genuinely distinct variable and stays the user's to set). Pinned by a Windows-only test. Co-Authored-By: Claude Fable 5 --- src/daemon/pane.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 3f178736..e4e17711 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -432,6 +432,22 @@ const TERM_PROGRAM_NAME: &str = "tty7"; /// what the pane on the other end can decode. const CAPABILITY_ENV: [&str; 2] = ["TERM", "COLORTERM"]; +/// Whether a configured `env` key names one of [`CAPABILITY_ENV`]. Windows +/// environment blocks are case-insensitive — `portable-pty` keeps one slot per +/// lowercased key, so a configured `Term` there would replace `TERM` just as +/// surely as the exact spelling — so the filter must use the platform's own +/// notion of "the same variable". On Unix a differently-cased key is a genuinely +/// distinct variable and stays the user's to set. +fn names_capability_env(key: &str) -> bool { + CAPABILITY_ENV.iter().any(|cap| { + if cfg!(windows) { + key.eq_ignore_ascii_case(cap) + } else { + key == *cap + } + }) +} + /// The environment every pane starts with, in application order — tty7's own /// advertisements first, then the user's `env` map, which overrides all but /// [`CAPABILITY_ENV`]. Returned as a list rather than applied in place so the @@ -471,7 +487,7 @@ fn pane_environment( env.extend( extra_env .iter() - .filter(|(k, _)| !CAPABILITY_ENV.contains(&k.as_str())) + .filter(|(k, _)| !names_capability_env(k)) .map(|(k, v)| (k.clone(), v.clone())), ); env @@ -4149,6 +4165,39 @@ mod tests { ); } + /// Windows environment blocks are case-insensitive — `portable-pty` keeps + /// one slot per lowercased key — so a configured `Term` would replace + /// `TERM` just as surely as the exact spelling. The capability filter must + /// therefore drop any casing of a capability key, not just the canonical + /// one. (On Unix a differently-cased key is a distinct variable and passes + /// through untouched.) + #[cfg(windows)] + #[test] + fn pane_environment_capability_keys_cannot_be_overridden_by_recasing() { + let configured = [("Term", "dumb"), ("ColorTerm", ""), ("term_program", "x")] + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + + let applied = pane_environment(&configured); + + assert!( + !applied.iter().any(|(k, _)| k == "Term" || k == "ColorTerm"), + "a recased capability key must be filtered out, or it would land \ + in the same case-folded slot and win by coming later" + ); + let get = |key: &str| { + applied + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(get("TERM"), Some("xterm-256color")); + assert_eq!(get("COLORTERM"), Some("truecolor")); + // Identity keys stay overridable in any casing the user spells. + assert_eq!(get("term_program"), Some("x")); + } + /// The macOS UTF-8 fallback applies only when the inherited environment has /// no locale and the user has not taken control through the generic `env` /// map. Key presence is authoritative there, including an empty value. From f6d0b30312d5a675de14757273a0141a862ae486 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 10:42:26 +0800 Subject: [PATCH 16/17] fix(editor): make the alt-. walk survive bypass edits, selections, and shifted meta chords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three holes in the readline-parity work, found in review: - The walk's reset lived only in `handle_editor_key`, but edits arrive by other doors too — IME-committed text (all typing on macOS and Windows), paste, a completion pick, cmd-Z. A repeat alt-. after any of those deleted the recorded span even though it no longer held the walk's word. The walk now stores the word itself and resumes only while the line still shows it at `at` with the caret at its end; `commit_text` also clears the walk outright, mirroring the key path's reset. - A fresh alt-. over an active selection recorded `at` from the pre-insert caret, but `insert_str` collapses the selection to its start first — so the span pointed past the inserted word and the next press ate the wrong text. `at` is now derived from where the caret actually landed. - The unknown-Meta handoff built `ESC` + the key name, which gpui reports unshifted — alt-shift-U shipped `ESC u` instead of `ESC U`. The shared encoder now goes first (it knows the shifted character and the Kitty form when `key_char` is present), with the hand-built fallback uppercasing under Shift. Co-Authored-By: Claude Fable 5 --- src/terminal/view.rs | 188 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 172 insertions(+), 16 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 6fc84269..ed5d8f2c 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -459,10 +459,16 @@ struct PendingHistory { struct LastWordWalk { /// Index into `history` the last press took its word from. entry: usize, - /// Char offset of the word it inserted, and how many chars it spans — the - /// next press swaps that range for the word from an older entry. + /// Char offset of the word it inserted — the next press swaps that span + /// for the word from an older entry. at: usize, - len: usize, + /// The word itself: both the span's length and a fingerprint. Edits that + /// bypass `handle_editor_key` (IME-committed text, a paste, a completion + /// pick, ⌘Z) can't clear `last_word_nav`, so before resuming, the walk + /// checks the line still holds this word at `at` with the caret at its + /// end — anything else means an edit intervened and the walk starts over + /// rather than eating it. + word: String, } /// Seconds since the unix epoch — the timestamp history records carry. @@ -2112,11 +2118,23 @@ impl TerminalView { // A Meta chord with nothing local behind it (M-t transpose-word, // M-u/M-l/M-c case widgets, whatever the user bound) goes to the // shell rather than dying here — same reasoning as the Ctrl side - // above. Built from the key name, not `key_char`: the platforms - // that deliver Alt chords at all don't reliably carry one. + // above. The shared encoder goes first (it knows the shifted + // character and the Kitty form when `key_char` is there to + // consult), but the platforms that deliver Alt chords at all + // don't reliably carry one — then fall back to ESC + the key + // name, uppercased under Shift, as a raw terminal would send. if m.alt && !m.control && !m.platform && key.chars().count() == 1 { - let mut bytes = vec![0x1b]; - bytes.extend_from_slice(key.as_bytes()); + let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags()) + .unwrap_or_else(|| { + let name = if m.shift { + key.to_uppercase() + } else { + key.to_string() + }; + let mut b = vec![0x1b]; + b.extend_from_slice(name.as_bytes()); + b + }); self.handoff_line_to_shell(&bytes, cx); return; } @@ -3600,15 +3618,33 @@ impl TerminalView { /// a run of presses leaves exactly one word behind. Entries with no words /// are stepped over rather than inserting nothing. fn insert_last_word(&mut self, cx: &mut Context) { + // Only trust the recorded walk while the line still shows it: its word + // sitting at `at`, caret at the word's end, nothing selected. The keys + // this dispatcher sees reset `last_word_nav` themselves, but edits that + // bypass it (IME-committed text, a paste, a completion pick, ⌘Z) don't + // — resuming over those would delete text the walk never inserted. + let resumed = self.last_word_nav.take().filter(|walk| { + let len = walk.word.chars().count(); + self.cmd.cursor() == walk.at + len + && self.cmd.selection().is_none() + && self + .cmd + .text() + .chars() + .skip(walk.at) + .take(len) + .eq(walk.word.chars()) + }); // A repeat resumes one entry older than the last press; a fresh walk // starts at the newest entry. - let start = match &self.last_word_nav { + let start = match &resumed { Some(walk) => walk.entry.checked_sub(1), None => self.history.len().checked_sub(1), }; let Some(mut entry) = start else { // Nothing older to reach (or no history at all) — leave the line as // it stands, the word the previous press inserted included. + self.last_word_nav = resumed; return; }; let word = loop { @@ -3616,6 +3652,7 @@ impl TerminalView { break w.to_string(); } let Some(older) = entry.checked_sub(1) else { + self.last_word_nav = resumed; return; }; entry = older; @@ -3623,19 +3660,18 @@ impl TerminalView { // Take back what the previous press left, so the walk swaps words in // place rather than piling them up. - if let Some(walk) = self.last_word_nav.take() { + if let Some(walk) = resumed { self.cmd.clear_selection(); self.cmd.set_cursor(walk.at); - self.cmd.extend_to(walk.at + walk.len); + self.cmd.extend_to(walk.at + walk.word.chars().count()); self.cmd.delete_selection(); } - let at = self.cmd.cursor(); self.cmd.insert_str(&word); - self.last_word_nav = Some(LastWordWalk { - entry, - at, - len: word.chars().count(), - }); + // `insert_str` replaces a live selection first, which moves the caret + // to the selection's start — so the word's position is wherever the + // caret landed minus the word, not the pre-insert cursor. + let at = self.cmd.cursor() - word.chars().count(); + self.last_word_nav = Some(LastWordWalk { entry, at, word }); // The line is now the user's own edit, not a recalled entry. self.history_nav = None; cx.notify(); @@ -4361,6 +4397,9 @@ impl TerminalView { self.cmd.insert_str(text); self.history_nav = None; self.editor_goal_col = None; + // Typed text ends an ⌥. run: IME-committed text bypasses + // `handle_editor_key`'s reset, so it has to happen here too. + self.last_word_nav = None; self.completion_refilter(); self.cursor_visible = true; cx.notify(); @@ -8285,6 +8324,123 @@ mod gpui_tests { .unwrap(); } + /// Edits that bypass `handle_editor_key` — IME-committed text is the + /// everyday one (it's how all typing arrives on macOS and Windows) — must + /// end the walk too. Without that, the next ⌥. deletes the span the walk + /// recorded even though the user's typing now sits inside it. + #[gpui::test] + fn an_intervening_ime_commit_restarts_the_last_word_walk(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + // `commit_text` edits the local line only while the editor is engaged + // at a shell prompt; anywhere else it writes gap text to the PTY. + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + window + .update(cx, |view, _, cx| { + let meta_dot = gpui::Keystroke { + modifiers: gpui::Modifiers { + alt: true, + ..Default::default() + }, + key: ".".to_string(), + key_char: None, + }; + view.history = ["cargo build --release", "echo hello world"] + .into_iter() + .map(String::from) + .collect(); + + view.handle_editor_key(&meta_dot, cx); + assert_eq!(view.cmd.text(), "world"); + view.commit_text("x", cx); + view.handle_editor_key(&meta_dot, cx); + assert_eq!( + view.cmd.text(), + "worldxworld", + "the typed char survives; the walk starts over after it" + ); + }) + .unwrap(); + } + + /// ⌥. with a selection active: the word replaces the selection (insertion + /// replaces selections everywhere in this editor), and the walk records + /// where the word actually landed — the caret the selection collapsed to, + /// not where the caret stood before the insert — so a repeat swaps the + /// word cleanly. + #[gpui::test] + fn meta_dot_over_a_selection_records_where_the_word_landed(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + let meta_dot = gpui::Keystroke { + modifiers: gpui::Modifiers { + alt: true, + ..Default::default() + }, + key: ".".to_string(), + key_char: None, + }; + view.history = ["cargo build --release", "echo hello world"] + .into_iter() + .map(String::from) + .collect(); + view.cmd.set("ls foo"); + // Select "foo" with the caret at the selection's far end. + view.cmd.set_cursor(3); + view.cmd.extend_to(6); + + view.handle_editor_key(&meta_dot, cx); + assert_eq!( + view.cmd.text(), + "ls world", + "the word replaced the selection" + ); + view.handle_editor_key(&meta_dot, cx); + assert_eq!( + view.cmd.text(), + "ls --release", + "the repeat swapped the word, not some other span" + ); + }) + .unwrap(); + } + + /// A shifted Meta chord must ship the shifted character: ⌥⇧U is `ESC U` + /// on the wire (upcase-region in zsh's keymap), not the `ESC u` of plain + /// ⌥U — gpui reports the key name unshifted, so the handoff has to apply + /// Shift itself when no `key_char` is there to consult. + #[gpui::test] + fn a_shifted_meta_chord_hands_off_the_shifted_character(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + view.cmd.set("echo hi"); + view.handle_editor_key( + &gpui::Keystroke { + modifiers: gpui::Modifiers { + alt: true, + shift: true, + ..Default::default() + }, + key: "u".to_string(), + key_char: None, + }, + cx, + ); + assert_eq!(view.cmd.text(), ""); + }) + .unwrap(); + assert_eq!(next_input(&mut daemon), b"echo hi".to_vec()); + assert_eq!(next_input(&mut daemon), b"\x1bU".to_vec()); + } + /// Chords the editor *does* answer stay local — handing off would forfeit /// ghost text and completion for the rest of the line, and ⌃A/⌃E/⌃W are /// exactly the keys pressed most often mid-edit. From bd97d1878ad8b8fa130b94abc592b6cd38a62497 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:07:19 +0000 Subject: [PATCH 17/17] deps: bump the cargo-minor-patch group across 1 directory with 4 updates Bumps the cargo-minor-patch group with 4 updates in the / directory: [ignore](https://github.com/BurntSushi/ripgrep), [tokio](https://github.com/tokio-rs/tokio), [tray-icon](https://github.com/tauri-apps/tray-icon) and [libc](https://github.com/rust-lang/libc). Updates `ignore` from 0.4.26 to 0.4.31 - [Release notes](https://github.com/BurntSushi/ripgrep/releases) - [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md) - [Commits](https://github.com/BurntSushi/ripgrep/compare/ignore-0.4.26...ignore-0.4.31) Updates `tokio` from 1.53.0 to 1.53.1 - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.53.0...tokio-1.53.1) Updates `tray-icon` from 0.24.1 to 0.24.2 - [Release notes](https://github.com/tauri-apps/tray-icon/releases) - [Changelog](https://github.com/tauri-apps/tray-icon/blob/dev/CHANGELOG.md) - [Commits](https://github.com/tauri-apps/tray-icon/compare/tray-icon-v0.24.1...tray-icon-v0.24.2) Updates `libc` from 0.2.186 to 0.2.189 - [Release notes](https://github.com/rust-lang/libc/releases) - [Changelog](https://github.com/rust-lang/libc/blob/0.2.189/CHANGELOG.md) - [Commits](https://github.com/rust-lang/libc/compare/0.2.186...0.2.189) --- updated-dependencies: - dependency-name: ignore dependency-version: 0.4.31 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch - dependency-name: libc dependency-version: 0.2.189 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch - dependency-name: tokio dependency-version: 1.53.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch - dependency-name: tray-icon dependency-version: 0.24.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 605e3ebf..36f6a526 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2022,7 +2022,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -4035,9 +4035,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.26" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" dependencies = [ "crossbeam-deque", "globset", @@ -4589,9 +4589,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libflate" @@ -8873,9 +8873,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -9135,9 +9135,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs",