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..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),