diff --git a/CHANGELOG.md b/CHANGELOG.md index 478bb9c1..535479db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ 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) + +- **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) + +### 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 eb45a0c9..f06b43c9 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", @@ -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", @@ -4607,9 +4607,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" @@ -4964,7 +4964,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", @@ -6062,7 +6062,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", @@ -7048,7 +7048,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", ] @@ -7091,7 +7091,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", @@ -7639,7 +7639,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", @@ -8446,7 +8446,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", @@ -8959,9 +8959,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", @@ -9221,9 +9221,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", @@ -9883,7 +9883,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", @@ -9922,7 +9922,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", @@ -11728,7 +11728,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", @@ -11773,7 +11773,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", @@ -11784,7 +11784,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 bccc6575..9374e8fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -320,12 +320,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 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/core/config.rs b/src/core/config.rs index 32e2fe79..98e53117 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(), @@ -1089,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/daemon/pane.rs b/src/daemon/pane.rs index a7a167a2..e4e17711 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -422,29 +422,84 @@ 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"]; + +/// 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 +/// 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, _)| !names_capability_env(k)) + .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 +4097,107 @@ 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") + ); + } + + /// 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. diff --git a/src/terminal/boxdraw.rs b/src/terminal/boxdraw.rs new file mode 100644 index 00000000..61d1cd92 --- /dev/null +++ b/src/terminal/boxdraw.rs @@ -0,0 +1,969 @@ +//! 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). +/// +/// `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, scale); + 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 `t` (see +/// [`light_thickness`] for how that one is chosen). +struct Cell { + x0: f32, + y0: f32, + x1: f32, + y1: f32, + cx: f32, + cy: f32, + t: f32, + 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, + x1, + y1, + cx: (x0 + x1) / 2., + cy: (y0 + y1) / 2., + t: light_thickness(x1 - x0, scale), + scale, + } + } + + /// 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 { + 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 { + 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. + /// + /// 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.vstroke(self.cx, wu, self.y0, self.cy + m)); + } + if wd > 0. { + ink.push(self.vstroke(self.cx, wd, self.cy - m, self.y1)); + } + if wl > 0. { + ink.push(self.hstroke(self.cy, wl, self.x0, self.cx + m)); + } + if wr > 0. { + ink.push(self.hstroke(self.cy, wr, self.cx - m, self.x1)); + } + 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.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)], + '╒' => 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). 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.vstroke(cx, self.t, cy + r - lap, self.y1)); + } else { + ink.push(self.vstroke(cx, self.t, self.y0, cy - r + lap)); + } + if sx > 0. { + ink.push(self.hstroke(cy, self.t, cx + r - lap, self.x1)); + } else { + 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. + // + // 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) = ( + ax - sx * radius * theta.cos(), + ay - sy * radius * theta.sin(), + ); + point(px(x), px(y)) + }; + 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 + 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) + } + + /// 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.vstroke(self.cx, w, s, s + len) + } else { + self.hstroke(self.cy, w, s, s + len) + } + }) + .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(), 1.).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. + /// + /// 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(); + 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, 1.).unwrap()); + assert!( + 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}" + ); + } + } + + /// 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, 1.).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, 1.).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, 1.).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, 1.).unwrap()); + assert_eq!( + (nx, xx, ny, xy), + (x0, x0 + w, y0, y0 + h), + "█ isn't the full cell" + ); + // 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, 1.).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(), 1.).unwrap()[0] else { + panic!() + }; + r.size.width.as_f32() + }; + let heavy = { + let Ink::Rect(r) = &glyph('┃', cell(), 1.).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, 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"); + } + + /// 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/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/element.rs b/src/terminal/element.rs index 5e18f837..512c8d43 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; @@ -738,6 +799,27 @@ 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` @@ -834,25 +916,71 @@ fn paint_glyphs( // for a single glyph — it paints at the run origin regardless. RowSeg::Solo { col } => { let cell = &buf[row_base + col]; - if let Some(shape) = PowerlineShape::of(cell.c) { - let cell_bounds = Bounds::new( - point(geom.origin.x + geom.cell_width * (col as f32), y), - size(geom.cell_width, geom.line_height), - ); + let cell_bounds = Bounds::new( + point(geom.origin.x + geom.cell_width * (col as f32), y), + size(geom.cell_width, geom.line_height), + ); + // 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; + true + } else 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 { + 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), + } + } + 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 // 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, ), }; @@ -2070,6 +2198,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`; @@ -2127,6 +2300,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 +2328,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 +2340,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 +2411,7 @@ mod tests { segment_row(&row), [ wide(0, 2, "你"), - cluster(2, 2, "好\u{FE0F}"), + wide_cluster(2, 2, "好\u{FE0F}"), wide(4, 2, "世"), ] ); 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; diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 7fa9d42c..ed5d8f2c 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,23 @@ 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 — the next press swaps that span + /// for the word from an older entry. + at: 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. fn unix_now() -> u64 { std::time::SystemTime::now() @@ -1172,6 +1195,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 +1679,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 +1796,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 +1897,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 +1954,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 +2105,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 +2115,29 @@ 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. 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 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; + } } } cx.notify(); @@ -2055,14 +2145,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 +2197,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 +2231,9 @@ impl TerminalView { self.cmd.delete(); } } - _ => {} + _ => return false, } + true } /// Horizontal caret motion in the editor with selection semantics: Shift @@ -2272,6 +2371,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 +2393,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 +3608,72 @@ 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) { + // 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 &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 { + if let Some(w) = self.history[entry].split_whitespace().next_back() { + break w.to_string(); + } + let Some(older) = entry.checked_sub(1) else { + self.last_word_nav = resumed; + 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) = resumed { + self.cmd.clear_selection(); + self.cmd.set_cursor(walk.at); + self.cmd.extend_to(walk.at + walk.word.chars().count()); + self.cmd.delete_selection(); + } + self.cmd.insert_str(&word); + // `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(); } @@ -4228,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(); @@ -4239,11 +4411,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 +8094,365 @@ 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(); + } + + /// 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. + #[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(); } diff --git a/src/ui/app.rs b/src/ui/app.rs index d5b06207..26c1925f 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 — `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); + } + 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; @@ -5208,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(), @@ -5229,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 9151b5a5..cade6881 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -524,26 +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. - .when(show_focus && !focused, |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() } @@ -674,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( @@ -684,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/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 diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 5aa52d0d..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() @@ -1531,6 +1538,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 +1562,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 @@ -1587,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() } @@ -4612,6 +4632,7 @@ mod tests { "Sidebar grouping", "Tab completion", "History search", + "Dim inactive panes", ] { assert!( settings_search_entries().iter().any(|e| e.title == title),