Merge remote-tracking branch 'origin/main' into pr-234-review

This commit is contained in:
thomas
2026-07-28 11:03:39 +08:00
9 changed files with 475 additions and 100 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
+25 -25
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",
@@ -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"
+15 -6
View File
@@ -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
+19
View File
@@ -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
+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.
+149 -15
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;
@@ -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, ""),
]
);
+8 -5
View File
@@ -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>) {
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::<Config>().dim_inactive_panes;
active_tab.pane.render(dim_inactive, window, cx)
}
}
}
+21 -21
View File
@@ -524,33 +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. `dim_inactive_panes` opts out.
.when(
show_focus
&& !focused
&& cx
.global::<crate::core::config::Config>()
.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<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(
@@ -691,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()
}
+20 -10
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()
@@ -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),