diff --git a/CHANGELOG.md b/CHANGELOG.md index 478bb9c1..535479db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Panes are told which terminal they're running in** — every pane now carries + `TERM_PROGRAM=tty7` and `TERM_PROGRAM_VERSION`, the de-facto standard pair + Apple Terminal introduced and iTerm2, WezTerm, Ghostty, VS Code and tmux all + set. `TERM` names terminfo capabilities and can't answer "which program is + this", so without the pair, capability probes (`supports-color`, + `supports-hyperlinks`, and the CLI ecosystem built on them), editors applying + terminal-specific workarounds, and shell prompts all fell back to their most + conservative behaviour. tty7's own `TTY7` marker doesn't help them — it exists + so globally-installed agent hooks stay silent in other terminals, and nothing + third-party knows to look for it. Unlike `TERM` and `COLORTERM`, both new + variables can be overridden from `env` in `config.json`: they name an + identity, not a capability, and posing as another terminal is a legitimate way + to get a tool that only recognises a fixed list to light up. Local panes only + — ssh forwards environment variables solely by agreement between client and + server, so a remote host still sees whatever it sets for itself. (#212) + +- **Inactive panes only fade if you want them to** — a split tab dims every pane + but the focused one so the active terminal reads as foreground. That is the + right default, but it is not free: at 55% opacity a dim theme's comment color + or a long-running build's output in the pane you are *watching* rather than + typing into gets harder to read, and some people track panes by cursor alone + and never needed the cue. Settings → Appearance → Transparency now carries a + "Dim inactive panes" switch. On by default, so nothing changes for anyone who + was happy; off renders every pane at full opacity. (#214) + +### Fixed + +- **Italic CJK rendered as unrelated CJK on Windows** — every character came out + as a different character, one for one, consistently, so it read as a broken + locale or a mangled encoding. It was neither. Hack, the bundled default, has no + CJK, so those cells are shaped by the font-fallback chain; gpui's Windows + backend then threw away the face DirectWrite shaped with and looked a fresh one + up by family, weight and style. That round trip mapped DirectWrite's *italic* + to *oblique* — the two are numbered the other way around in the API — and a + family with no oblique face resolved to its upright one. The glyph indices were + right; the outlines they were pointing into belonged to a different face. Fixed + in our gpui fork by rasterizing the face DirectWrite actually chose, which also + closes a latent use-after-free in the same cache: it keyed fonts by a raw + pointer to a face nothing held a reference to. + ## [26.7.5] - 2026-07-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index f63f5adf..605e3ebf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1369,7 +1369,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "gpui_util", "indexmap", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "proc-macro2", "quote", @@ -3096,7 +3096,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "anyhow", @@ -3287,7 +3287,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "accesskit_unix", @@ -3338,7 +3338,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "accesskit_macos", @@ -3385,7 +3385,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "console_error_panic_hook", "gpui", @@ -3409,7 +3409,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "schemars", "serde", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "log", @@ -3428,7 +3428,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "bytemuck", @@ -3481,7 +3481,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "accesskit_windows", @@ -3800,7 +3800,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "async-compression", @@ -3825,7 +3825,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "rustls", "rustls-platform-verifier", @@ -4946,7 +4946,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "bindgen", @@ -6044,7 +6044,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "collections", "serde", @@ -7021,7 +7021,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "derive_refineable", ] @@ -7064,7 +7064,7 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "bytes", @@ -7589,7 +7589,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "async-task", "backtrace", @@ -8396,7 +8396,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "heapless", "log", @@ -9775,7 +9775,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "async-fs", @@ -9814,7 +9814,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "perf", "quote", @@ -11620,7 +11620,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "chrono", @@ -11665,7 +11665,7 @@ dependencies = [ [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "tracing", "tracing-subscriber", @@ -11676,7 +11676,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 9d076654..b1b0a15f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -319,12 +319,21 @@ lto = "thin" codegen-units = 1 # ---- gpui fork ------------------------------------------------------------ -# Our `tty7` branch (cut from the pinned upstream rev, one commit on top) carries -# a single patch: `prefers_ime_for_printable_keys` takes the keystroke, so an -# input handler can answer per key instead of per view. tty7 needs it for -# Option-as-Meta — macOS routes ⌥-chords to the IME whenever a CJK input source -# is active, and without the keystroke there is no way to decline just those -# chords (see `terminal::input::prefers_ime_for_printable_keys`, issue #177). +# Our `tty7` branch (cut from the pinned upstream rev, two commits on top) carries: +# +# 1. `prefers_ime_for_printable_keys` takes the keystroke, so an input handler can +# answer per key instead of per view. tty7 needs it for Option-as-Meta — macOS +# routes ⌥-chords to the IME whenever a CJK input source is active, and without +# the keystroke there is no way to decline just those chords (see +# `terminal::input::prefers_ime_for_printable_keys`, issue #177). +# +# 2. gpui's Windows backend rasterizes a font-fallback run with the face +# DirectWrite actually shaped it with, instead of re-deriving one from the +# face's family/weight/style. The round trip mapped DirectWrite's italic to +# oblique, so italic CJK — which every pane reaches through the fallback chain, +# Hack having no CJK — drew a *different* face's outlines at the shaped glyph +# indices. Every character rendered as an unrelated character, one for one, +# which reads as mojibake rather than as a font bug. # # Patching by source rather than editing the `gpui`/`gpui_platform` pins above is # deliberate: `gpui-component` declares its own `gpui` from the upstream URL, and diff --git a/src/core/config.rs b/src/core/config.rs index 700f3b94..98e53117 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1095,6 +1095,25 @@ mod tests { assert!(!newer.confirm_window_close); } + /// Also opt-*out*: every config written before the switch existed predates + /// the choice, and those users have been looking at dimmed panes all along — + /// defaulting to `false` would silently change how every split tab looks on + /// upgrade. And once someone does turn it off, the `false` has to survive a + /// save/load cycle, or the effect they opted out of returns on next launch. + #[test] + fn dim_inactive_panes_defaults_on_and_round_trips() { + assert!(Config::default().dim_inactive_panes); + + let old: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(old.dim_inactive_panes); + + let off: Config = serde_json::from_str(r#"{"dim_inactive_panes": false}"#).unwrap(); + assert!(!off.dim_inactive_panes); + let json = serde_json::to_string(&off).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(!back.dim_inactive_panes); + } + #[test] fn theme_follow_system_defaults_and_round_trips() { // Old configs (no follow-system keys) must land on off + the built-in diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index a7a167a2..e4e17711 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -422,29 +422,84 @@ fn system_locale_identifier() -> Option { } } +/// What tty7 answers to in `TERM_PROGRAM`. Terminals name themselves in the +/// form they brand themselves in — `Apple_Terminal`, `iTerm.app`, `WezTerm`, +/// `ghostty`, `vscode` — so ours is the lowercase product name. +const TERM_PROGRAM_NAME: &str = "tty7"; + +/// Env keys that describe our emulator's real capabilities. A user's `env` map +/// must not override these: the answer isn't a preference, it's a fact about +/// what the pane on the other end can decode. +const CAPABILITY_ENV: [&str; 2] = ["TERM", "COLORTERM"]; + +/// Whether a configured `env` key names one of [`CAPABILITY_ENV`]. Windows +/// environment blocks are case-insensitive — `portable-pty` keeps one slot per +/// lowercased key, so a configured `Term` there would replace `TERM` just as +/// surely as the exact spelling — so the filter must use the platform's own +/// notion of "the same variable". On Unix a differently-cased key is a genuinely +/// distinct variable and stays the user's to set. +fn names_capability_env(key: &str) -> bool { + CAPABILITY_ENV.iter().any(|cap| { + if cfg!(windows) { + key.eq_ignore_ascii_case(cap) + } else { + key == *cap + } + }) +} + +/// The environment every pane starts with, in application order — tty7's own +/// advertisements first, then the user's `env` map, which overrides all but +/// [`CAPABILITY_ENV`]. Returned as a list rather than applied in place so the +/// precedence is testable without a `CommandBuilder` or a real `config.json`. +fn pane_environment( + extra_env: &std::collections::HashMap, +) -> Vec<(String, String)> { + let version = env!("CARGO_PKG_VERSION"); + let mut env = vec![ + // A widely-available terminfo + truecolor. + ("TERM".to_string(), "xterm-256color".to_string()), + ("COLORTERM".to_string(), "truecolor".to_string()), + // Mark the session as tty7's, for tooling that adapts to its host + // terminal — most importantly the `tty7 agent-hook` emitter, which + // stays silent without it so globally-installed agent hooks can't leak + // escape sequences into other terminals (see `core::agent_hooks`). + ( + crate::core::agent_hooks::TTY7_ENV_MARKER.to_string(), + version.to_string(), + ), + // The de-facto standard pair for "which terminal is this": Apple + // Terminal introduced it, and iTerm2, WezTerm, Ghostty, VS Code and + // tmux all set it. `TERM` describes terminfo capabilities and can't + // answer this — but capability probes (`supports-color`, + // `supports-hyperlinks` and the JS CLI ecosystem built on them), + // editors applying terminal-specific workarounds, and shell prompts all + // branch on the program name, falling back to their most conservative + // behaviour when it's missing. `TTY7` doesn't help them: it's ours, and + // nothing third-party knows to look for it. + // + // Deliberately overridable below, unlike the capability keys: this + // names an identity, and posing as another terminal is a legitimate way + // to get a tool that only recognises a fixed list to light up. + ("TERM_PROGRAM".to_string(), TERM_PROGRAM_NAME.to_string()), + ("TERM_PROGRAM_VERSION".to_string(), version.to_string()), + ]; + env.extend( + extra_env + .iter() + .filter(|(k, _)| !names_capability_env(k)) + .map(|(k, v)| (k.clone(), v.clone())), + ); + env +} + fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option) { if let Some(dir) = initial_cwd { cmd.cwd(dir); } - // Advertise a widely-available terminfo + truecolor. - cmd.env("TERM", "xterm-256color"); - cmd.env("COLORTERM", "truecolor"); - // Mark the session as tty7's, for tooling that adapts to its host terminal - // — most importantly the `tty7 agent-hook` emitter, which stays silent - // without it so globally-installed agent hooks can't leak escape sequences - // into other terminals (see `core::agent_hooks`). - cmd.env( - crate::core::agent_hooks::TTY7_ENV_MARKER, - env!("CARGO_PKG_VERSION"), - ); - - // User-configured environment variables override inherited values (but not - // TERM/COLORTERM above, which reflect our emulator's real capabilities). let extra_env = crate::core::config::extra_env(); - for (k, v) in &extra_env { - if k != "TERM" && k != "COLORTERM" { - cmd.env(k, v); - } + for (k, v) in pane_environment(&extra_env) { + cmd.env(k, v); } // LaunchServices commonly starts a macOS app with no locale variables at @@ -4042,6 +4097,107 @@ mod tests { assert!(dead_rx.try_recv().is_err(), "on_dead must fire only once"); } + /// Every pane is told which terminal it is running in, under the names the + /// rest of the world reads (`TERM_PROGRAM`/`TERM_PROGRAM_VERSION`) as well + /// as our own `TTY7` marker. Nothing third-party looks for the marker, so + /// dropping the standard pair would leave capability probes guessing. + #[test] + fn pane_environment_advertises_the_terminal_under_the_standard_names() { + let env: std::collections::HashMap<_, _> = + pane_environment(&std::collections::HashMap::new()) + .into_iter() + .collect(); + let version = env!("CARGO_PKG_VERSION"); + + assert_eq!(env.get("TERM_PROGRAM").map(String::as_str), Some("tty7")); + assert_eq!( + env.get("TERM_PROGRAM_VERSION").map(String::as_str), + Some(version) + ); + assert_eq!( + env.get(crate::core::agent_hooks::TTY7_ENV_MARKER) + .map(String::as_str), + Some(version) + ); + assert_eq!( + env.get("TERM").map(String::as_str), + Some("xterm-256color"), + "terminfo name is what the pane's decoder actually implements" + ); + } + + /// The user's `env` map may rename the terminal — posing as another program + /// is how you get a tool that only recognises a fixed list to light up — + /// but it may not contradict what our emulator can decode. Later entries + /// win, so the ordering is the precedence. + #[test] + fn pane_environment_lets_configured_env_override_identity_but_not_capability() { + let configured = [ + ("TERM_PROGRAM", "iTerm.app"), + ("TERM_PROGRAM_VERSION", "3.5.0"), + ("TERM", "dumb"), + ("COLORTERM", ""), + ("EDITOR", "hx"), + ] + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + + let applied: std::collections::HashMap<_, _> = + pane_environment(&configured).into_iter().collect(); + + assert_eq!( + applied.get("TERM_PROGRAM").map(String::as_str), + Some("iTerm.app") + ); + assert_eq!( + applied.get("TERM_PROGRAM_VERSION").map(String::as_str), + Some("3.5.0") + ); + assert_eq!(applied.get("EDITOR").map(String::as_str), Some("hx")); + assert_eq!( + applied.get("TERM").map(String::as_str), + Some("xterm-256color") + ); + assert_eq!( + applied.get("COLORTERM").map(String::as_str), + Some("truecolor") + ); + } + + /// Windows environment blocks are case-insensitive — `portable-pty` keeps + /// one slot per lowercased key — so a configured `Term` would replace + /// `TERM` just as surely as the exact spelling. The capability filter must + /// therefore drop any casing of a capability key, not just the canonical + /// one. (On Unix a differently-cased key is a distinct variable and passes + /// through untouched.) + #[cfg(windows)] + #[test] + fn pane_environment_capability_keys_cannot_be_overridden_by_recasing() { + let configured = [("Term", "dumb"), ("ColorTerm", ""), ("term_program", "x")] + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + + let applied = pane_environment(&configured); + + assert!( + !applied.iter().any(|(k, _)| k == "Term" || k == "ColorTerm"), + "a recased capability key must be filtered out, or it would land \ + in the same case-folded slot and win by coming later" + ); + let get = |key: &str| { + applied + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(get("TERM"), Some("xterm-256color")); + assert_eq!(get("COLORTERM"), Some("truecolor")); + // Identity keys stay overridable in any casing the user spells. + assert_eq!(get("term_program"), Some("x")); + } + /// The macOS UTF-8 fallback applies only when the inherited environment has /// no locale and the user has not taken control through the generic `env` /// map. Key presence is authoritative there, including an empty value. diff --git a/src/terminal/element.rs b/src/terminal/element.rs index b4044521..512c8d43 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -490,18 +490,51 @@ enum RowSeg { /// drawing, accented Latin, …) that may route to a fallback face whose /// advance isn't the cell width. Solo { col: usize }, - /// A base plus the combining marks stacked on it, shaped as one string so - /// the marks reach the shaper. Never batched with neighbours: the marks add + /// A base with everything that has to shape alongside it — the combining + /// marks stacked on it, and a following SARA AM — as one string, so the + /// shaper sees the whole cluster. Never batched with neighbours: marks add /// characters without adding columns, which is exactly the correspondence /// `force_width` relies on in a [`RowSeg::Run`] or [`RowSeg::Wide`]. + /// + /// An absorbed SARA AM takes the base's style rather than its own. Unlike + /// a [`RowSeg::Run`], the cluster can't break on a style change: split off, + /// SARA AM has no base to reorder its nikhahit onto and renders as a dotted + /// circle. A recoloured vowel beats a broken one. Cluster { col: usize, - /// Columns the base occupies — 2 once the grid marked it wide. + /// Columns the whole cluster occupies — 2 for a wide base, or for a + /// narrow base that absorbed a following SARA AM. cells: usize, text: String, + /// Whether `cells == 2` because the *base* is wide, rather than because + /// a spacing character joined it. The two need opposite pinning: a wide + /// base is one glyph across two columns, an absorbed SARA AM is two + /// glyphs of one column each. + wide_base: bool, }, } +/// Append a cell's character followed by any combining marks riding on it. +fn push_cell(text: &mut String, cell: &RenderCell) { + text.push(cell.c); + text.extend(cell.marks.iter().flat_map(|marks| marks.iter())); +} + +/// SARA AM (Thai U+0E33, Lao U+0EB3) is `Lo` and owns a column, but it is not +/// atomic to the shaper: the Thai shaper decomposes it into NIKHAHIT + SARA AA +/// and moves the nikhahit backwards over any above-base marks onto the base +/// consonant. Shaped in a run of its own it has no base to reorder onto, and +/// comes out as a dotted circle. +fn is_sara_am(c: char) -> bool { + matches!(c, '\u{0E33}' | '\u{0EB3}') +} + +/// Does `col` hold a SARA AM that should join the preceding cell's cluster? +fn sara_am_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> { + row.get(col) + .filter(|cell| !cell.spacer && is_sara_am(cell.c)) +} + /// Split one grid row into paintable segments. /// /// ASCII-graphic cells batch into [`RowSeg::Run`]s: they always come from the @@ -530,15 +563,26 @@ fn segment_row(row: &[RenderCell]) -> Vec { // Combining marks come first: they can sit on an ASCII base too, and // either way the whole cluster has to reach the shaper in one string. if let Some(marks) = &cell.marks { - let cells = if col + 1 < row.len() && row[col + 1].spacer { - 2 - } else { - 1 - }; + let wide_base = col + 1 < row.len() && row[col + 1].spacer; + let mut cells = if wide_base { 2 } else { 1 }; let mut text = String::with_capacity(1 + marks.len()); - text.push(cell.c); - text.extend(marks.iter()); - segs.push(RowSeg::Cluster { col, cells, text }); + push_cell(&mut text, cell); + // A wide base already owns both columns, so only a narrow one has a + // column spare for SARA AM to join it in. A SARA AM is not itself a + // base to absorb onto — two in a row stay separate. + if !wide_base + && !is_sara_am(cell.c) + && let Some(am) = sara_am_at(row, col + 1) + { + push_cell(&mut text, am); + cells = 2; + } + segs.push(RowSeg::Cluster { + col, + cells, + text, + wide_base, + }); col += cells; continue; } @@ -569,6 +613,23 @@ fn segment_row(row: &[RenderCell]) -> Vec { cells: col - start, text, }); + } else if !is_sara_am(cell.c) + && let Some(am) = sara_am_at(row, col + 1) + { + // An unmarked base still has to shape with its SARA AM. A + // baseless SARA AM is not a base for the next one: absorbing + // there would pin the second one's glyphs outside the cluster's + // clip, so two in a row stay separate and both stay visible. + let mut text = String::with_capacity(2); + push_cell(&mut text, cell); + push_cell(&mut text, am); + segs.push(RowSeg::Cluster { + col, + cells: 2, + text, + wide_base: false, + }); + col += 2; } else { segs.push(RowSeg::Solo { col }); col += 1; @@ -904,11 +965,22 @@ fn paint_glyphs( // Same pinning as the batched runs, just for one base: two // columns get `force_width` so a fallback emoji face can't // drift, one column paints at the origin like `Solo`. - RowSeg::Cluster { col, cells, text } => ( + // Two columns pin per *base glyph*, and which that is depends + // on why the cluster is two cells wide: a wide base is one + // glyph spanning both, an absorbed SARA AM is two glyphs of one + // column each. `force_width` classifies by advance, so the + // marks ride their base under either. One column paints at the + // origin like `Solo`. + RowSeg::Cluster { + col, + cells, + text, + wide_base, + } => ( col, cells, SharedString::from(text), - (cells == 2).then(|| geom.cell_width * 2.), + (cells == 2).then(|| geom.cell_width * if wide_base { 2. } else { 1. }), cells == 1, ), }; @@ -2228,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, } } @@ -2246,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). @@ -2258,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] @@ -2277,7 +2411,7 @@ mod tests { segment_row(&row), [ wide(0, 2, "你"), - cluster(2, 2, "好\u{FE0F}"), + wide_cluster(2, 2, "好\u{FE0F}"), wide(4, 2, "世"), ] ); diff --git a/src/ui/app.rs b/src/ui/app.rs index b2181f7d..26c1925f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2506,8 +2506,8 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.check_for_updates = on); } - /// Toggle inactive-pane dimming. Applies on the next render — the pane tree - /// reads the flag from the `Config` global each frame. + /// Toggle inactive-pane dimming. Applies on the next render — `update_config` + /// notifies, and this view's render is what hands the flag to the pane tree. pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.dim_inactive_panes = on); } @@ -5214,7 +5214,7 @@ impl Render for Tty7App { .get(self.active) .and_then(|t| t.pane.focused_or_first(window, cx)) .and_then(|leaf| self.render_ssh_status_strip(&leaf, cx)); - // Render the active tab's pane tree; show focus rings only when split. + // Render the active tab's pane tree. let body = match self.tabs.get(self.active) { // Zero tabs: the window's own face — the home page (see `ui::home`). None => self.render_home(cx).into_any_element(), @@ -5235,8 +5235,11 @@ impl Render for Tty7App { .child(leaf.clone()) .into_any_element(), None => { - let show_focus = active_tab.pane.leaves().len() > 1; - active_tab.pane.render(show_focus, window, cx) + // Fading the unfocused panes only says anything once the + // tab is actually split, and the user can turn it off. + let dim_inactive = active_tab.pane.leaves().len() > 1 + && cx.global::().dim_inactive_panes; + active_tab.pane.render(dim_inactive, window, cx) } } } diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 5f658295..cade6881 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -524,33 +524,33 @@ impl Pane> { self.close_leaf_where(&|v| v.entity_id() == target.entity_id()) } - /// Render the subtree. `show_focus` draws a focus ring on the active leaf - /// (suppressed when the tab has a single pane). - pub fn render(&self, show_focus: bool, window: &mut Window, cx: &mut App) -> gpui::AnyElement { + /// Render the subtree. `dim_inactive` fades every leaf but the focused one; + /// the caller decides it — it is off for an unsplit tab (nothing to + /// distinguish) and off when the user turned `dim_inactive_panes` off. Kept + /// a parameter rather than a `Config` global read here so the tree stays + /// renderable without one, as the rest of this module is. + pub fn render( + &self, + dim_inactive: bool, + window: &mut Window, + cx: &mut App, + ) -> gpui::AnyElement { match self { Pane::Empty => div().into_any_element(), Pane::Leaf(v) => { - let focused = show_focus && v.read(cx).focus_handle.contains_focused(window, cx); + let focused = v.read(cx).focus_handle.contains_focused(window, cx); // No full border (it reads as a hard rectangle). div() .size_full() .relative() .overflow_hidden() - // Inactive panes (only when the tab is actually split) fade back - // so the focused terminal reads as foreground without a hard - // border. Element opacity multiplies through the whole subtree - // (terminal glyphs + cell fills), unlike a background-tinted - // scrim which is near-invisible on a light theme (white on - // white). Applied to the container, so a click still lands on - // the terminal and focuses it. `dim_inactive_panes` opts out. - .when( - show_focus - && !focused - && cx - .global::() - .dim_inactive_panes, - |d| d.opacity(0.55), - ) + // Inactive panes fade back so the focused terminal reads as + // foreground without a hard border. Element opacity multiplies + // through the whole subtree (terminal glyphs + cell fills), + // unlike a background-tinted scrim which is near-invisible on a + // light theme (white on white). Applied to the container, so a + // click still lands on the terminal and focuses it. + .when(dim_inactive && !focused, |d| d.opacity(0.55)) .child(v.clone()) .into_any_element() } @@ -681,7 +681,7 @@ impl Pane> { .flex_basis(px(0.)) .min_w_0() .min_h_0() - .child(a.render(show_focus, window, cx)), + .child(a.render(dim_inactive, window, cx)), ) .child(divider) .child( @@ -691,7 +691,7 @@ impl Pane> { .flex_basis(px(0.)) .min_w_0() .min_h_0() - .child(b.render(show_focus, window, cx)), + .child(b.render(dim_inactive, window, cx)), ) .into_any_element() } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 4eeb63ff..edd32d10 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -144,6 +144,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Blur", keywords: "transparency translucent frosted vibrancy window background", }, + SearchEntry { + section: Appearance, + title: "Dim inactive panes", + keywords: "fade unfocused inactive split pane focus opacity highlight active dimming", + }, SearchEntry { section: Appearance, title: "Font size", @@ -1518,10 +1523,12 @@ impl Tty7App { .into_any_element() } - /// Window section (Appearance): global opacity slider + blur switch that - /// apply to every theme. Both are config *overrides* — until touched they - /// follow the active theme's own `opacity`/`blur`, and "Follow theme" - /// clears them back to that state. + /// Window section (Appearance): the global opacity slider and blur switch + /// that apply to every theme, then the inactive-pane dimming switch. The + /// first two are config *overrides* — until touched they follow the active + /// theme's own `opacity`/`blur`, and "Follow theme" clears them back to that + /// state; the dimming switch is a plain flag no theme carries a value for, + /// so it sits below that button and "Follow theme" leaves it alone. fn render_window_section(&self, cx: &mut Context) -> AnyElement { let Some(slider) = self .active_settings() @@ -1578,12 +1585,6 @@ impl Tty7App { blur_switch, cx, )) - .child(self.settings_row( - "Dim inactive panes", - "Fade unfocused panes in a split so the active one stands out.", - dim_switch, - cx, - )) // Only offered while an override is active; otherwise the values // already follow the theme and the button would be a no-op. .when(overridden, |this| { @@ -1598,6 +1599,14 @@ impl Tty7App { ), ) }) + // Below "Follow theme", which resets the two rows above it and not + // this one — a plain setting with no theme value behind it. + .child(self.settings_row( + "Dim inactive panes", + "Fade unfocused panes in a split so the active one stands out.", + dim_switch, + cx, + )) .into_any_element() } @@ -4623,6 +4632,7 @@ mod tests { "Sidebar grouping", "Tab completion", "History search", + "Dim inactive panes", ] { assert!( settings_search_entries().iter().any(|e| e.title == title),