Merge remote-tracking branch 'origin/main' into dependabot/cargo/resvg-0.47.0

This commit is contained in:
thomas
2026-07-28 11:31:12 +08:00
17 changed files with 2260 additions and 156 deletions
+44
View File
@@ -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
Generated
+33 -33
View File
@@ -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"
+15 -6
View File
@@ -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
+1 -1
View File
@@ -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 · <kbd>⌃ R</kbd> fuzzy history |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · eight themes · IME |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> 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 |
+1 -1
View File
@@ -19,7 +19,7 @@
- **Command palette** <kbd>⌘ P</kbd> · scrollback search <kbd>⌘ F</kbd>
- **⌘/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**
+1 -1
View File
@@ -19,7 +19,7 @@
- **命令面板** <kbd>⌘ P</kbd> · 回滚搜索 <kbd>⌘ F</kbd>
- **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 终端 → 剪贴板)
- **智能双击选中** —— 双击直接选中整条 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 / 输入法输入**
+25
View File
@@ -60,6 +60,11 @@ pub struct Config {
pub window_opacity: Option<f32>,
/// Global window-blur override. `None` follows the active theme's `blur`.
pub window_blur: Option<bool>,
/// 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
+174 -18
View File
@@ -422,29 +422,84 @@ fn system_locale_identifier() -> Option<String> {
}
}
/// 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<String, String>,
) -> 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<PathBuf>) {
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.
+969
View File
@@ -0,0 +1,969 @@
//! Native box-drawing: the U+2500U+257F box characters and U+2580U+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<Pixels>),
/// A rectangle at a fraction of the foreground's alpha — the ░▒▓ shades,
/// which fake their dither by translucency exactly as WezTerm does.
Shade(Bounds<Pixels>, f32),
/// A filled path — rounded corners and diagonals, the two shapes a
/// rectangle can't express.
Path(gpui::Path<Pixels>),
}
/// 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<Pixels>, scale: f32) -> Option<Vec<Ink>> {
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<Pixels>, 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<Pixels> {
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<Ink> {
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+2550U+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<Vec<Ink>> {
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<Vec<Ink>> {
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* (LoopBlinn 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<gpui::Path<Pixels>> = 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<Vec<Ink>> {
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<Vec<Ink>> {
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+2580U+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<Vec<Ink>> {
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+2500U+254B, and the half/mixed lines of U+2574U+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<Pixels> {
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+2500U+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<f32> = (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<f32> = (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());
}
}
}
}
+81 -8
View File
@@ -24,6 +24,11 @@ pub struct CmdEditor {
/// shuttle states between the two.
undo: Vec<(Vec<char>, usize)>,
redo: Vec<(Vec<char>, 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);
+257 -22
View File
@@ -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<RowSeg> {
// 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<RowSeg> {
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<Pixels>, shape: PowerlineShape) -> gpui::Path<P
}
}
/// What a natively-drawn cell — a Powerline separator or a box-drawing/block
/// character, both painted as geometry rather than as a font glyph — still has
/// to send through the text pipeline after its ink is on screen.
///
/// `None` for the common case: the geometry *is* the whole cell, so the shaping
/// and painting below can be skipped entirely.
///
/// `Some(' ')` when the style draws on blanks, i.e. it carries an underline (or
/// is part of a hovered link). Underlines are not painted per-cell — they ride
/// on the [`TextRun`] that `paint_glyphs` builds, so a cell that returns early
/// silently loses its underline, leaving a one-column hole in an `ESC[4m` span
/// or a hovered URL. Shaping a *space* in the cell's own style closes the hole:
/// a space puts no glyph ink over the geometry already painted, and gpui draws
/// the line from the same [`gpui::UnderlineStyle`] (curly and double included)
/// it uses for every other cell, so weight, offset and colour match exactly.
/// The space comes from the primary monospace face, whose advance *is* the cell
/// width, so it needs no `force_width` to cover its column.
fn native_cell_residue(style: &GlyphStyle) -> Option<char> {
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, ""),
]
);
+1
View File
@@ -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;
+565 -42
View File
@@ -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<LastWordWalk>,
/// 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<Self>) {
// 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();
}
+12 -3
View File
@@ -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>) {
self.update_config(cx, |cfg| cfg.dim_inactive_panes = on);
}
pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context<Self>) {
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::<Config>().dim_inactive_panes;
active_tab.pane.render(dim_inactive, window, cx)
}
}
}
+21 -14
View File
@@ -524,26 +524,33 @@ impl Pane<Entity<TerminalView>> {
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<Entity<TerminalView>> {
.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<Entity<TerminalView>> {
.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()
}
+35 -3
View File
@@ -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
+25 -4
View File
@@ -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<Self>) -> AnyElement {
let Some(slider) = self
.active_settings()
@@ -1531,6 +1538,7 @@ impl Tty7App {
};
let config = cx.global::<Config>();
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::<Config>().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),