From c21508df6fc8f69bf011c767512301c3ea722c37 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 08:04:27 +0800 Subject: [PATCH 1/2] chore(settings): finish off the dim-inactive-panes setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #214 added the switch itself; this is the wiring around it that a new setting in this codebase is expected to carry. - Index the row in `settings_search_entries`, which the settings search box matches against. Without an entry, searching "dim", "fade" or "unfocused" — the words someone actually looks for — finds nothing, and the switch is only reachable by scrolling to it. Pinned in `index_titles_match_rendered_row_labels` so the title cannot drift. - Pin the default and the round trip, as every other `default_true` flag here does (see `confirm_window_close_defaults_on_and_round_trips`): a config written before the switch existed must still dim, and a `false` must survive save/load or the effect comes back next launch. - Hand the flag to `Pane::render` instead of reading the `Config` global from inside it. `pane.rs` had no global state before, deliberately — the leaf type is generic so the tree logic can be tested with plain values. The caller already computes the split test the dimming was gated on, so it can compute this too: one lookup per frame rather than one per leaf, and the tree stays renderable without a Config global. While there, `show_focus` is now named for what it does — nothing drew a focus ring; it only ever gated the fade. - Move the row below "Follow theme". That button clears the opacity and blur overrides only, and a third row directly above it read as something it would also reset. - Changelog entry. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++++++++++++ src/core/config.rs | 19 +++++++++++++++++++ src/ui/app.rs | 11 +++++++---- src/ui/pane.rs | 42 +++++++++++++++++++++--------------------- src/ui/settings.rs | 30 ++++++++++++++++++++---------- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478bb9c1..487e9cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Inactive panes only fade if you want them to** — a split tab dims every pane + but the focused one so the active terminal reads as foreground. That is the + right default, but it is not free: at 55% opacity a dim theme's comment color + or a long-running build's output in the pane you are *watching* rather than + typing into gets harder to read, and some people track panes by cursor alone + and never needed the cue. Settings → Appearance → Transparency now carries a + "Dim inactive panes" switch. On by default, so nothing changes for anyone who + was happy; off renders every pane at full opacity. (#214) + ## [26.7.5] - 2026-07-27 ### Added diff --git a/src/core/config.rs b/src/core/config.rs index 700f3b94..98e53117 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1095,6 +1095,25 @@ mod tests { assert!(!newer.confirm_window_close); } + /// Also opt-*out*: every config written before the switch existed predates + /// the choice, and those users have been looking at dimmed panes all along — + /// defaulting to `false` would silently change how every split tab looks on + /// upgrade. And once someone does turn it off, the `false` has to survive a + /// save/load cycle, or the effect they opted out of returns on next launch. + #[test] + fn dim_inactive_panes_defaults_on_and_round_trips() { + assert!(Config::default().dim_inactive_panes); + + let old: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(old.dim_inactive_panes); + + let off: Config = serde_json::from_str(r#"{"dim_inactive_panes": false}"#).unwrap(); + assert!(!off.dim_inactive_panes); + let json = serde_json::to_string(&off).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(!back.dim_inactive_panes); + } + #[test] fn theme_follow_system_defaults_and_round_trips() { // Old configs (no follow-system keys) must land on off + the built-in diff --git a/src/ui/app.rs b/src/ui/app.rs index b2181f7d..b73cb4ad 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2506,8 +2506,8 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.check_for_updates = on); } - /// Toggle inactive-pane dimming. Applies on the next render — the pane tree - /// reads the flag from the `Config` global each frame. + /// Toggle inactive-pane dimming. Applies on the next render — `update_config` + /// notifies, and this view's render is what hands the flag to the pane tree. pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.dim_inactive_panes = on); } @@ -5235,8 +5235,11 @@ impl Render for Tty7App { .child(leaf.clone()) .into_any_element(), None => { - let show_focus = active_tab.pane.leaves().len() > 1; - active_tab.pane.render(show_focus, window, cx) + // Fading the unfocused panes only says anything once the + // tab is actually split, and the user can turn it off. + let dim_inactive = active_tab.pane.leaves().len() > 1 + && cx.global::().dim_inactive_panes; + active_tab.pane.render(dim_inactive, window, cx) } } } diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 5f658295..cade6881 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -524,33 +524,33 @@ impl Pane> { self.close_leaf_where(&|v| v.entity_id() == target.entity_id()) } - /// Render the subtree. `show_focus` draws a focus ring on the active leaf - /// (suppressed when the tab has a single pane). - pub fn render(&self, show_focus: bool, window: &mut Window, cx: &mut App) -> gpui::AnyElement { + /// Render the subtree. `dim_inactive` fades every leaf but the focused one; + /// the caller decides it — it is off for an unsplit tab (nothing to + /// distinguish) and off when the user turned `dim_inactive_panes` off. Kept + /// a parameter rather than a `Config` global read here so the tree stays + /// renderable without one, as the rest of this module is. + pub fn render( + &self, + dim_inactive: bool, + window: &mut Window, + cx: &mut App, + ) -> gpui::AnyElement { match self { Pane::Empty => div().into_any_element(), Pane::Leaf(v) => { - let focused = show_focus && v.read(cx).focus_handle.contains_focused(window, cx); + let focused = v.read(cx).focus_handle.contains_focused(window, cx); // No full border (it reads as a hard rectangle). div() .size_full() .relative() .overflow_hidden() - // Inactive panes (only when the tab is actually split) fade back - // so the focused terminal reads as foreground without a hard - // border. Element opacity multiplies through the whole subtree - // (terminal glyphs + cell fills), unlike a background-tinted - // scrim which is near-invisible on a light theme (white on - // white). Applied to the container, so a click still lands on - // the terminal and focuses it. `dim_inactive_panes` opts out. - .when( - show_focus - && !focused - && cx - .global::() - .dim_inactive_panes, - |d| d.opacity(0.55), - ) + // Inactive panes fade back so the focused terminal reads as + // foreground without a hard border. Element opacity multiplies + // through the whole subtree (terminal glyphs + cell fills), + // unlike a background-tinted scrim which is near-invisible on a + // light theme (white on white). Applied to the container, so a + // click still lands on the terminal and focuses it. + .when(dim_inactive && !focused, |d| d.opacity(0.55)) .child(v.clone()) .into_any_element() } @@ -681,7 +681,7 @@ impl Pane> { .flex_basis(px(0.)) .min_w_0() .min_h_0() - .child(a.render(show_focus, window, cx)), + .child(a.render(dim_inactive, window, cx)), ) .child(divider) .child( @@ -691,7 +691,7 @@ impl Pane> { .flex_basis(px(0.)) .min_w_0() .min_h_0() - .child(b.render(show_focus, window, cx)), + .child(b.render(dim_inactive, window, cx)), ) .into_any_element() } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 4eeb63ff..edd32d10 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -144,6 +144,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Blur", keywords: "transparency translucent frosted vibrancy window background", }, + SearchEntry { + section: Appearance, + title: "Dim inactive panes", + keywords: "fade unfocused inactive split pane focus opacity highlight active dimming", + }, SearchEntry { section: Appearance, title: "Font size", @@ -1518,10 +1523,12 @@ impl Tty7App { .into_any_element() } - /// Window section (Appearance): global opacity slider + blur switch that - /// apply to every theme. Both are config *overrides* — until touched they - /// follow the active theme's own `opacity`/`blur`, and "Follow theme" - /// clears them back to that state. + /// Window section (Appearance): the global opacity slider and blur switch + /// that apply to every theme, then the inactive-pane dimming switch. The + /// first two are config *overrides* — until touched they follow the active + /// theme's own `opacity`/`blur`, and "Follow theme" clears them back to that + /// state; the dimming switch is a plain flag no theme carries a value for, + /// so it sits below that button and "Follow theme" leaves it alone. fn render_window_section(&self, cx: &mut Context) -> AnyElement { let Some(slider) = self .active_settings() @@ -1578,12 +1585,6 @@ impl Tty7App { blur_switch, cx, )) - .child(self.settings_row( - "Dim inactive panes", - "Fade unfocused panes in a split so the active one stands out.", - dim_switch, - cx, - )) // Only offered while an override is active; otherwise the values // already follow the theme and the button would be a no-op. .when(overridden, |this| { @@ -1598,6 +1599,14 @@ impl Tty7App { ), ) }) + // Below "Follow theme", which resets the two rows above it and not + // this one — a plain setting with no theme value behind it. + .child(self.settings_row( + "Dim inactive panes", + "Fade unfocused panes in a split so the active one stands out.", + dim_switch, + cx, + )) .into_any_element() } @@ -4623,6 +4632,7 @@ mod tests { "Sidebar grouping", "Tab completion", "History search", + "Dim inactive panes", ] { assert!( settings_search_entries().iter().any(|e| e.title == title), From 4d09df3b714c0895a6e6bf524490d0a15c86aebd Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 10:36:28 +0800 Subject: [PATCH 2/2] chore: drop stale "focus rings" comment the show_focus rename missed The render call site still said "show focus rings only when split" -- the same misdescription the parameter rename in this PR removes: nothing ever drew a ring, the flag only gated the fade, and the fade condition is now spelled out two lines below. Co-Authored-By: Claude Fable 5 --- src/ui/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index b73cb4ad..26c1925f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5214,7 +5214,7 @@ impl Render for Tty7App { .get(self.active) .and_then(|t| t.pane.focused_or_first(window, cx)) .and_then(|leaf| self.render_ssh_status_strip(&leaf, cx)); - // Render the active tab's pane tree; show focus rings only when split. + // Render the active tab's pane tree. let body = match self.tabs.get(self.active) { // Zero tabs: the window's own face — the home page (see `ui::home`). None => self.render_home(cx).into_any_element(),