From 305535cde35299c3861328f23900ac0af2887aaa Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:41:49 +0800 Subject: [PATCH 1/3] feat(sidebar): give the rail a text hierarchy and put colour on state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every line in the sidebar sat at the same 4.5:1 grey — tab title, branch line, group header, search placeholder — so the only things that stood out were twelve identical brand-orange agent discs and twelve copies of the same `+94 −26`, neither of which says which tab matters. - Titles rise to a 7:1 floor (AAA) with a cap that keeps the selected label its `TEXT_STEP`; captions stay at `TEXT_RESTING`. A new test pins the step between them. - The sidebar gets its own selection ladder (`SIDEBAR_SELECTED` 1.50); the window's signed-off 1.30 rung is untouched and its Dracula pin now checks the constant rather than the rail's fill. - Agent avatars rest as a 16% brand tint with the mark in brand ink and light up solid only on the active tab or an agent that is waiting. - Diff counts render in `resting_ink`: same hue, blended toward the caption, walked back to the 4.5 floor where needed. - A group whose rows all share one branch and diff says so once on its header; rows with no status yet do not vote, so ⌘T no longer flips the group twice while the poll comes back. - Group headers get 12px above, the count shows only when folded, and the two sub-floor `.opacity()` captions are gone. The workspace switcher name reads at the headers' weight instead of inheriting the title ink. Claude-Session: https://claude.ai/code/session_01Ak6Lnzf3EGErJZmG51Rxbo --- src/ui/presets.rs | 138 +++++++++++++++++++++++++++--- src/ui/switcher.rs | 1 + src/ui/tab_sidebar.rs | 193 ++++++++++++++++++++++++++++++++++++------ src/ui/tab_strip.rs | 37 ++++++-- 4 files changed, 325 insertions(+), 44 deletions(-) diff --git a/src/ui/presets.rs b/src/ui/presets.rs index 436c594d..de5162c2 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -86,6 +86,16 @@ pub mod state { pub const CURSOR: f32 = 1.70; pub const TEXT_RESTING: f32 = 4.6; pub const TEXT_STEP: f32 = 1.4; + + /// The sidebar's selection ladder sits one rung above the window's. The + /// window paints a selected row inside a list the user is already looking + /// at; the sidebar paints the one tab out of twenty that owns the pane + /// area, and at 1.30:1 that tint measured as the faintest mark in the + /// column — fainter than a group header's count. `PRESSED` and `CURSOR` + /// climb with it so the ladder keeps its spacing. + pub const SIDEBAR_SELECTED: f32 = 1.50; + pub const SIDEBAR_PRESSED: f32 = 1.75; + pub const SIDEBAR_CURSOR: f32 = 1.92; } #[derive(Debug, Clone, Copy)] @@ -192,9 +202,26 @@ impl Theme { sidebar, // Blended, not bisected, so a palette's own softness carries into // the sidebar — but floored on the fill it is actually painted on - // (`sidebar`, not `background`), because four of the builtins - // land this under 4.5:1 and it is the tab title, not a caption. - sidebar_fg: at_least(mix(fg, bg, 0.28), fg, sidebar, TEXT_FLOOR), + // (`sidebar`, not `background`). This is the tab title, the top + // rung of a three-rung column (title / branch / group header), and + // it is floored at `TITLE_FLOOR` rather than `TEXT_FLOOR` because + // the rung under it, `muted_foreground`, already sits at + // `TEXT_RESTING`: a title at 4.5:1 next to a caption at 4.6:1 is + // the same grey twice, and the column reads as one flat wash with + // nothing to look at first. + sidebar_fg: { + let title = at_least(mix(fg, bg, 0.10), fg, sidebar, TITLE_FLOOR); + // …and capped so the selected label keeps its `TEXT_STEP` + // above it: on a white-on-black palette a 10% blend lands so + // close to `fg` that there is nothing brighter left to step + // to. The floor wins over the cap on a soft palette, where + // the step is taken past `fg` instead (see `stepped_ink`). + let headroom = (contrast(fg, sidebar) / state::TEXT_STEP).max(TITLE_FLOOR); + match headroom > TITLE_FLOOR && contrast(title, sidebar) > headroom { + true => dim(title, sidebar, headroom), + false => title, + } + }, accent: legible_accent(bg, self.accent), } } @@ -298,14 +325,14 @@ impl Theme { pub fn surfaces(&self) -> Surfaces { let m = self.neutrals(); + let fg = legible_foreground(self.background_color(), self.foreground); let mut sidebar = self.surface(m.sidebar); + sidebar.selected = raise(sidebar.base, fg, state::SIDEBAR_SELECTED); + sidebar.pressed = raise(sidebar.base, fg, state::SIDEBAR_PRESSED); + sidebar.cursor = raise(sidebar.base, fg, state::SIDEBAR_CURSOR); sidebar.text_resting = m.sidebar_fg; - sidebar.text_selected = stepped_ink( - sidebar.selected, - sidebar.base, - legible_foreground(self.background_color(), self.foreground), - sidebar.text_resting, - ); + sidebar.text_selected = + stepped_ink(sidebar.selected, sidebar.base, fg, sidebar.text_resting); Surfaces { window: self.surface(m.background), sidebar, @@ -573,6 +600,41 @@ pub(crate) fn wash(surface: u32, tint: u32, target: f32) -> u32 { const TEXT_FLOOR: f32 = 4.5; +/// The floor for the top rung of a text column whose second rung rests at +/// `TEXT_RESTING`. WCAG AAA's 7:1, which also happens to be the smallest +/// ratio that clears `TEXT_STEP` over a 4.6:1 caption with room to spare on +/// the fills a sidebar is actually painted on. +const TITLE_FLOOR: f32 = 7.0; + +/// A semantic ink stepped down to sit beside body text instead of over it. +/// +/// `success` and `danger` are cleared to `TEXT_FLOOR` at full chroma, which is +/// right for the one line that says a push failed and wrong for a `+94 −26` +/// repeated on every row of a list: twelve saturated numerals become the +/// loudest thing in the column while carrying the least. Blending toward the +/// caption ink they sit next to keeps the hue (green still means added) and +/// takes the shout out, then the blend is walked back toward the full ink +/// only when the surface it lands on cannot carry it at `TEXT_FLOOR`. +pub(crate) fn resting_ink(ink: Hsla, beside: Hsla, surface: Hsla) -> Hsla { + let (ink, beside, surface) = (pack(ink), pack(beside), pack(surface)); + let blend = mix(ink, beside, 0.45); + gpui::rgb(at_least(blend, ink, surface, TEXT_FLOOR)).into() +} + +/// A brand colour as ink on a theme surface: kept as authored when it already +/// reads there, walked toward black or white only when it does not. What an +/// agent glyph is drawn in once the disc behind it is no longer a solid brand +/// fill — Claude's orange is a fill colour, not a text colour, on a light +/// window, and pure-black Codex vanishes into a dark one. +pub(crate) fn legible_on(surface: Hsla, seed: u32) -> Hsla { + gpui::rgb(legible_ink(pack(surface), seed, ACCENT_FLOOR)).into() +} + +fn pack(c: Hsla) -> u32 { + let rgb = crate::terminal::palette::hsla_to_rgb(c); + (rgb.r as u32) << 16 | (rgb.g as u32) << 8 | rgb.b as u32 +} + /// Hairlines are separators, not control outlines — the surfaces they divide /// carry their own fills, so WCAG 1.4.11's 3:1 does not apply and painting them /// that hard would read as a wireframe. This floor only rescues the palettes @@ -1632,10 +1694,17 @@ mod tests { let dracula = builtins().into_iter().find(|t| t.id == "dracula").unwrap(); let bg = dracula.background_color(); let s = dracula.surfaces(); + // The resting rung is checked as `state::SELECTED` on the sidebar + // fill — the surface it was signed off on — rather than as the rail's + // own fill: the rail was lifted off this value on purpose + // (`state::SIDEBAR_SELECTED`) once the tab that owns the pane area + // measured as the faintest mark in its own column, and this pin is + // here to catch the constant drifting, not that decision. + let fg = legible_foreground(bg, dracula.foreground); for (what, now, legacy) in [ ( "resting", - s.sidebar.selected, + raise(s.sidebar.base, fg, state::SELECTED), mix(bg, dracula.foreground, 0.12), ), ("cursor", s.window.cursor, mix(bg, dracula.foreground, 0.17)), @@ -1936,13 +2005,60 @@ mod tests { ); } + #[test] + fn sidebar_title_is_stepped_off_its_caption() { + // The three rungs of a sidebar row — title, branch line, group + // header — all sat at the same 4.5:1 grey once; this is the guard + // against that column flattening again. The caption is dimmed on the + // window and painted on the sidebar, so measure it where it lands. + for t in builtins() { + let m = t.neutrals(); + let step = contrast(m.sidebar_fg, m.muted_foreground); + assert!( + step >= state::TEXT_STEP - 0.01, + "{}: title {:#08x} is only {step:.2}:1 off the caption {:#08x}", + t.id, + m.sidebar_fg, + m.muted_foreground + ); + } + } + + #[test] + fn resting_semantic_ink_keeps_the_text_floor() { + for t in builtins() { + let m = t.neutrals(); + let sem = t.semantics(); + let beside: Hsla = gpui::rgb(m.muted_foreground).into(); + for (name, ink) in [("success", sem.success.ink), ("danger", sem.danger.ink)] { + for (surface_name, surface) in [ + ("window", m.background), + ("sidebar", m.sidebar), + ("popover", m.popover), + ] { + let resting = pack(resting_ink( + gpui::rgb(ink).into(), + beside, + gpui::rgb(surface).into(), + )); + let ratio = contrast(resting, surface); + assert!( + ratio >= TEXT_FLOOR - 0.02, + "{}/{surface_name}: resting {name} {resting:#08x} is only {ratio:.2}:1", + t.id + ); + } + } + } + } + #[test] fn sidebar_text_reads_on_the_fill_it_is_painted_on() { for t in builtins() { let m = t.neutrals(); let ratio = contrast(m.sidebar_fg, m.sidebar); assert!( - ratio >= TEXT_FLOOR - 0.01, + ratio >= TITLE_FLOOR - 0.01, "{}: sidebar text {:#08x} is only {ratio:.2}:1 on the sidebar fill {:#08x}", t.id, m.sidebar_fg, diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index d57f1b09..fcb6f9e4 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -2916,6 +2916,7 @@ impl Tty7App { tab.status, tab.unread, tab.ssh, + picked, ROW_AVATAR, cx, )) diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index ca2841b8..3a01bd19 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use crate::core::config::{Config, SidebarGrouping}; use crate::core::group_key::{GroupKey, collapse_key}; use crate::terminal::git_status::GitStatusCache; -use crate::ui::app::{TITLE_BAR_HEIGHT, Tty7App}; +use crate::ui::app::{TITLE_BAR_HEIGHT, Tab, Tty7App}; use crate::ui::hints::tab_badge_label; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::reorder::{self, Reorder, Surface}; @@ -61,6 +61,16 @@ mod row_metrics { } } +/// The branch a whole group shares, lifted off its rows and onto its header. +struct SharedGit { + status: crate::terminal::git_status::GitStatus, + /// Where a click on the counts opens the diff overlay, if the setting + /// allows one. + click: Option<(crate::ui::host_ops::HostId, PathBuf)>, + /// Every row the group counts, drawn or folded away. + rows: Vec, +} + /// What a sidebar row rendered, next to what it had to leave out, so the /// hover card can be built by comparison instead of deriving the same strings /// a second time — the two derivations have to agree, and the shortest way to @@ -248,6 +258,18 @@ impl Tty7App { ..font.clone() }; let rem = window.rem_size().as_f32(); + // The diff counts in their resting weight: green still means added, + // but twelve of them down a column no longer outshout the titles. + let added_ink = crate::ui::presets::resting_ink( + cx.theme().success, + cx.theme().muted_foreground, + cx.theme().sidebar, + ); + let removed_ink = crate::ui::presets::resting_ink( + cx.theme().danger, + cx.theme().muted_foreground, + cx.theme().sidebar, + ); let rendered = |ix: &usize| !visible_by_section[*ix].is_empty(); // Every section that owns a key — a repo root or a custom name — draws // a header, and a header is what there is to grab, so these are the @@ -328,6 +350,36 @@ impl Tty7App { visible.len(), pointer, ); + // A group whose rows all sit on the same branch with the same + // diff says so once, on its header, instead of once per row. + // Four copies of `pr-818 +94 −26` under one heading describe the + // repo, not the tabs, and being the only coloured text in the + // column they were also the loudest thing in it. Read off every + // row the group counts rather than the ones it draws, so a folded + // group still names its branch. + let shared_git: Option = section.name.as_ref().and_then(|_| { + let rows = &visible_by_section[group_ix]; + if rows.len() < 2 { + return None; + } + // Only rows that *have* a status get a vote. A tab that was + // just opened has none until its shell reports a directory + // and the poll comes back; counting it as a disagreement + // pulled the branch off the header and grew a branch line + // under every sibling for the half second it took, then + // folded them all back — the column jumped twice for every + // ⌘T. Unknown is not different; it is not yet known. + let mut known = rows + .iter() + .filter_map(|&i| Some((i, self.tabs[i].git_status(Some(window), cx)?))); + let (first, status) = known.next()?; + let same = known.all(|(_, other)| other == status); + same.then(|| SharedGit { + status, + click: git_click(&self.tabs[first], window, cx), + rows: rows.clone(), + }) + }); for (slot, i) in visible.into_iter().enumerate() { let badge_pos = badge_pos[i]; let tab = &self.tabs[i]; @@ -336,14 +388,7 @@ impl Tty7App { let agent = tab.agent(cx); let agent_status = tab.agent_status(cx); let agent_unread = tab.agent_unread_count(cx); - let git_cwd = diff_click_cwd( - cx.global::(), - tab.pane.focused_or_first(window, cx).and_then(|leaf| { - let view = leaf.read(cx); - let cwd = view.git_status_cwd()?.to_path_buf(); - Some((view.host_id(), cwd)) - }), - ); + let git_cwd = git_click(tab, window, cx); let badge_extra = if show_badges && badge_pos < 9 { row_metrics::BADGE + row_metrics::GAP } else { @@ -426,7 +471,11 @@ impl Tty7App { }; let mut branch_shown: Option<(SharedString, SharedString, u32, u32)> = None; let mut cwd_shown: Option<(SharedString, SharedString)> = None; - let git_line = tab.git_status(Some(window), cx).map(|g| { + let git_line = match shared_git.is_some() { + true => None, + false => tab.git_status(Some(window), cx), + } + .map(|g| { let mut line = h_flex() .id(("sidebar-git", i)) .w_full() @@ -521,16 +570,13 @@ impl Tty7App { ) }); if g.added > 0 { - counts = counts.child( - div() - .text_color(cx.theme().success) - .child(format!("+{}", g.added)), - ); + counts = counts + .child(div().text_color(added_ink).child(format!("+{}", g.added))); } if g.removed > 0 { counts = counts.child( div() - .text_color(cx.theme().danger) + .text_color(removed_ink) .child(format!("−{}", g.removed)), ); } @@ -541,7 +587,7 @@ impl Tty7App { // Outside a repo there is no branch line; the second line then // carries the compressed cwd with its root marker, so a tab // whose title is just a shell name still says where it lives. - if git_line.is_none() { + if git_line.is_none() && shared_git.is_none() { cwd_shown = tab .pane .focused_or_first(window, cx) @@ -582,8 +628,8 @@ impl Tty7App { // Colors are captured by value so the tooltip builder (which // borrows no app state) can style the card on its own. let muted = cx.theme().muted_foreground; - let success = cx.theme().success; - let danger = cx.theme().danger; + let success = added_ink; + let danger = removed_ink; let label_region = match rename_input { Some(input) => div() @@ -707,7 +753,7 @@ impl Tty7App { .items_center() .gap_1p5() .text_xs() - .text_color(cx.theme().muted_foreground.opacity(0.8)) + .text_color(cx.theme().muted_foreground) .child(div().flex_1().min_w_0().truncate().child(cwd)), ) }) @@ -795,6 +841,7 @@ impl Tty7App { agent_status, agent_unread, ssh_dot, + is_active, 22., cx, )) @@ -951,7 +998,10 @@ impl Tty7App { .gap_1p5() .pl_2() .pr_1p5() - .pt_1p5() + // More above a heading than below it: the 12px is the + // generous interval in a column whose rows sit 2px apart, + // and it is what makes a group a group without a box. + .pt(px(12.)) .pb_0p5() .text_size(px(11.)) .text_color(cx.theme().muted_foreground) @@ -1015,12 +1065,84 @@ impl Tty7App { .child(label) .into_any_element(), }) - .child( - div() - .flex_shrink_0() - .text_color(cx.theme().muted_foreground.opacity(0.7)) - .child(row_count.to_string()), - ); + .when_some(shared_git, |bar, shared| { + let SharedGit { + status, + click, + rows, + } = shared; + let mut line = h_flex() + .id(("sidebar-group-git", group_ix)) + .flex_shrink(2.) + .min_w_0() + .items_center() + .gap_1p5() + .child( + gpui::svg() + .path("icons/git-branch.svg") + .flex_shrink_0() + .size(px(row_metrics::BRANCH_ICON)) + .text_color(cx.theme().muted_foreground), + ) + .child(div().min_w_0().truncate().child(status.branch.clone())); + if status.added > 0 || status.removed > 0 { + let mut counts = h_flex() + .id(("sidebar-group-diff", group_ix)) + .flex_shrink_0() + .items_center() + .gap_1p5() + .when_some(click, |counts, (host, cwd)| { + counts + .cursor_pointer() + .hover(|s| s.underline()) + .on_mouse_down( + MouseButton::Left, + cx.listener( + move |this, _: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + // The overlay opens over the + // active tab; make sure that + // is one of this group's, + // the same way a row's counts + // activate their row first. + if !rows.contains(&this.active) + && let Some(&first) = rows.first() + { + this.activate(first, window, cx); + } + this.toggle_diff_overlay( + host, + cwd.clone(), + window, + cx, + ); + }, + ), + ) + }); + if status.added > 0 { + counts = counts.child( + div() + .text_color(added_ink) + .child(format!("+{}", status.added)), + ); + } + if status.removed > 0 { + counts = counts.child( + div() + .text_color(removed_ink) + .child(format!("−{}", status.removed)), + ); + } + line = line.child(counts); + } + bar.child(div().flex_1()).child(line) + }) + // The count is redundant while the rows are on screen; it + // is what a shut group has instead of them. + .when(folded, |bar| { + bar.child(div().flex_shrink_0().child(row_count.to_string())) + }); // Renaming is offered on a menu rather than a double click: // the first click of a double would fold the group, so the // name would be edited on a box that just shut. A repo group @@ -1908,6 +2030,23 @@ fn group_names(roots: &[&PathBuf]) -> Vec { /// tree, and "Open diff preview from sidebar counts" turning one of them into /// plain text while the other stayed clickable would be a setting that half /// works. +/// Where a click on a tab's diff counts opens the overlay: the focused pane's +/// repo, when the setting allows a preview at all. +fn git_click( + tab: &Tab, + window: &Window, + cx: &gpui::App, +) -> Option<(crate::ui::host_ops::HostId, PathBuf)> { + diff_click_cwd( + cx.global::(), + tab.pane.focused_or_first(window, cx).and_then(|leaf| { + let view = leaf.read(cx); + let cwd = view.git_status_cwd()?.to_path_buf(); + Some((view.host_id(), cwd)) + }), + ) +} + pub(crate) fn diff_click_cwd(cfg: &Config, target: Option) -> Option { cfg.sidebar_diff_preview.then_some(target).flatten() } diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index f03598f5..a241c4e4 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -1006,15 +1006,22 @@ impl Tty7App { .bg(cx.theme().secondary) .text_size(px(10.)) .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) .child(monogram), ) .child( + // Chrome, not a row: the tile inherits the + // rail's title ink, which now belongs to the + // tabs. The workspace name reads at the group + // headers' weight so the one dark line in + // the column stays the tab in front. div() .flex_shrink(1.) .min_w_0() .truncate() .text_size(px(12.5)) .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) .child(SharedString::from(current.clone())), ) .child( @@ -1271,6 +1278,7 @@ impl Tty7App { status: Option, unread: usize, ssh: Option, + lit: bool, size: f32, cx: &App, ) -> gpui::AnyElement { @@ -1293,16 +1301,29 @@ impl Tty7App { Some(state) => format!("{} — {state}", agent.display_name()), None => agent.display_name().to_string(), }; + // The brand colour is identity, not state, and identity is + // not what a column of twenty tabs needs shouted: a solid + // orange disc on every Claude row made the brand the loudest + // mark in the sidebar while a seven-pixel dot carried the one + // thing that changes. So the disc rests as a tint of its + // brand with the mark drawn in the brand's own ink, and only + // lights up solid where the eye is meant to land — the tab in + // front, and an agent that has stopped to ask something. + let accent = agent.accent_rgb(); + let lit = lit || hollow; + let surface = cx.theme().background; base.relative() .rounded_full() - .bg(gpui::rgb(agent.accent_rgb())) + .when(lit, |d| d.bg(gpui::rgb(accent))) + .when(!lit, |d| { + d.bg(gpui::Hsla::from(gpui::rgb(accent)).opacity(0.16)) + }) // Codex and Grok are both pure black, which is the window // fill on a dark theme — the disc dissolves and leaves the // glyph floating. A hairline keeps it a disc in any theme. - .when( - crate::ui::presets::needs_edge(agent.accent_rgb(), cx.theme().background), - |d| d.border_1().border_color(cx.theme().border), - ) + .when(crate::ui::presets::needs_edge(accent, surface), |d| { + d.border_1().border_color(cx.theme().border) + }) .child( gpui::svg() .path(agent.icon_path()) @@ -1311,7 +1332,10 @@ impl Tty7App { // the mark's colour comes from the agent rather // than from the file. The tray icon reads the same // answer. - .text_color(gpui::rgb(agent.icon_rgb())), + .text_color(match lit { + true => gpui::Hsla::from(gpui::rgb(agent.icon_rgb())), + false => crate::ui::presets::legible_on(surface, accent), + }), ) .when_some(dot, |b, dot| b.child(dot)) .tooltip(move |window, cx| { @@ -1935,6 +1959,7 @@ impl Tty7App { agent_status, agent_unread, None, + is_active, 18., cx, )) From c57913fad43cadde362287455262e296dd3a0834 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:47:03 +0800 Subject: [PATCH 2/3] feat(switcher): same hierarchy for the workspace switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switcher card had the sidebar's problem in a popover: the loudest marks were the filled `this window` / `open` / `active` pills — states dressed as buttons — and the full-ink diff counts, while the keyboard cursor sat on the `pressed` rung one step under `cursor`, close enough to a hovered row that the focused column was anybody's guess. - Badges become words in the caption ink; only "taken over" keeps its warning colour. - The picked row takes the `cursor` rung the ladder set aside for a palette's keyboard row. - The three `muted_foreground.opacity(0.7)` inks (≈2.6:1) are gone; the second line is subordinate by size, not by a grey under the floor. - A workspace row's second line keeps its timestamp: the path is what truncates, so a narrow row no longer ends in a dangling `·`. - Diff counts use `resting_ink` on the popover fill, as the sidebar does. Claude-Session: https://claude.ai/code/session_01Ak6Lnzf3EGErJZmG51Rxbo --- src/ui/switcher.rs | 110 +++++++++++++++++++++------------------------ 1 file changed, 50 insertions(+), 60 deletions(-) diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index fcb6f9e4..4f9b8bba 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -1840,11 +1840,7 @@ impl Tty7App { fn render_footer(&self, cx: &mut Context) -> impl IntoElement + use<> { let theme = cx.theme(); - let (muted, dim, border) = ( - theme.muted_foreground, - theme.muted_foreground.opacity(0.7), - theme.border, - ); + let (muted, border) = (theme.muted_foreground, theme.border); let hover = hover_fill(cx); let holding = self.switcher.as_ref().is_some_and(|sw| sw.hold.is_some()); // With a query in the box, ← and → belong to the caret and Tab becomes @@ -1874,7 +1870,7 @@ impl Tty7App { .text_color(muted) .child(glyph_col( GUTTER, - Icon::new(IconName::Plus).size(px(ICON)).text_color(dim), + Icon::new(IconName::Plus).size(px(ICON)).text_color(muted), )) .child(t(L10nKey::AppMenuNewWorkspace)) .on_click(cx.listener(|this, _, window, cx| { @@ -1887,7 +1883,7 @@ impl Tty7App { .gap(px(6.)) .pr(px(ROW_PAD)) .text_xs() - .text_color(dim) + .text_color(muted) .when(!holding && filtering, |hint| { hint.child(t(L10nKey::SwitcherTabToCrossColumns)) }) @@ -2301,12 +2297,7 @@ impl Tty7App { } let theme = cx.theme(); - let (fg, muted, dim, warn) = ( - theme.foreground, - theme.muted_foreground, - theme.muted_foreground.opacity(0.7), - theme.warning, - ); + let (fg, muted, warn) = (theme.foreground, theme.muted_foreground, theme.warning); let sf = rungs(cx); let hover = gpui::rgb(sf.hover); let rref = RowRef::of(group, row); @@ -2341,10 +2332,6 @@ impl Tty7App { // the trailing pieces straight out over the divider. The second line // leads with the machine the workspace lives on — the flat list's only // grouping — with its link state as the dot's color. - let when_path = match row.path.is_empty() { - true => row.when.clone(), - false => format!("{} · {}", row.path, row.when), - }; let host_dot: Option = match group.link { Link::Local => None, Link::Connected if group.preempted => Some(warn), @@ -2369,7 +2356,7 @@ impl Tty7App { .rounded(px(6.)) .overflow_hidden() .cursor_pointer() - .when(picked, |r| r.bg(gpui::rgb(sf.pressed))) + .when(picked, |r| r.bg(gpui::rgb(sf.cursor))) .anchor_scroll(self.switcher_anchor(Column::Left, picked)) .hover(move |r| r.bg(hover)) .child(crate::ui::tab_strip::workspace_avatar( @@ -2397,7 +2384,7 @@ impl Tty7App { .gap(px(5.)) .min_w_0() .text_xs() - .text_color(dim) + .text_color(muted) .child(match host_dot { Some(color) => div() .flex_shrink_0() @@ -2409,7 +2396,7 @@ impl Tty7App { .path("icons/machine-local.svg") .flex_shrink_0() .size(px(10.)) - .text_color(dim) + .text_color(muted) .into_any_element(), }) .child( @@ -2420,24 +2407,30 @@ impl Tty7App { .text_color(muted) .child(host_label), ) - .when(!when_path.is_empty(), |line| { + // The path gives way first and the timestamp + // never does: with both in one truncating string + // the row ended in `~/repo/025/tty7 · …` every + // time, a dangling dot where the time had been. + .when(!row.path.is_empty(), |line| { line.child(div().flex_shrink_0().child("·")) - .child(div().min_w_0().truncate().child(when_path)) + .child(div().min_w_0().truncate().child(row.path.clone())) + }) + .when(!row.when.is_empty(), |line| { + line.child(div().flex_shrink_0().child("·")) + .child(div().flex_shrink_0().child(row.when.clone())) }), ), ) - .children(badge.map(|(label, here)| { + // A word, not a chip: a filled pill reads as a button, and these + // are states. Only "taken over" keeps a colour — it is the one + // that warns. + .children(badge.map(|(label, _here)| { div() .flex_shrink_0() - .px(px(6.)) - .py(px(1.)) - .rounded(px(4.)) .text_xs() - .bg(gpui::rgb(sf.selected)) - .text_color(match (row.preempted, here) { - (true, _) => warn, - (_, true) => fg.opacity(0.85), - _ => muted, + .text_color(match row.preempted { + true => warn, + false => muted, }) .child(label) })) @@ -2505,13 +2498,9 @@ impl Tty7App { ) -> AnyElement { let theme = cx.theme(); let (border, card_bg) = (theme.border, theme.popover); - let (fg, muted, dim) = ( - theme.foreground, - theme.muted_foreground, - theme.muted_foreground.opacity(0.7), - ); + let (fg, muted) = (theme.foreground, theme.muted_foreground); let sf = rungs(cx); - let (hover, picked_bg) = (gpui::rgb(sf.hover), gpui::rgb(sf.pressed)); + let (hover, picked_bg) = (gpui::rgb(sf.hover), gpui::rgb(sf.cursor)); let viewport = window.viewport_size(); let card_w = FORM_W .min(viewport.width.as_f32() - 2. * CARD_MARGIN) @@ -2624,7 +2613,7 @@ impl Tty7App { .child( Icon::new(IconName::ChevronDown) .size(px(ICON)) - .text_color(dim), + .text_color(muted), ) .on_click(cx.listener(|this, _, window, cx| { this.switcher_form_open_hosts(window, cx); @@ -2695,7 +2684,7 @@ impl Tty7App { .min_w_0() .truncate() .text_xs() - .text_color(dim) + .text_color(muted) .child(host.detail.clone()), ) .into_any_element() @@ -2704,7 +2693,7 @@ impl Tty7App { .border_t_1() .border_color(border) .rounded_none() - .child(Icon::new(IconName::Plus).size(px(14.)).text_color(dim)) + .child(Icon::new(IconName::Plus).size(px(14.)).text_color(muted)) .child( div() .text_sm() @@ -2744,7 +2733,7 @@ impl Tty7App { .border_t_1() .border_color(border) .text_xs() - .text_color(dim) + .text_color(muted) .child(match form.open { true => t(L10nKey::SwitcherFormPickHint), false => t(L10nKey::SwitcherFormCreateHint), @@ -2780,11 +2769,11 @@ impl Tty7App { cx: &mut Context, ) -> AnyElement { let theme = cx.theme(); - let (fg, muted, dim) = ( - theme.foreground, - theme.muted_foreground, - theme.muted_foreground.opacity(0.7), - ); + let (fg, muted) = (theme.foreground, theme.muted_foreground); + let added_ink = + crate::ui::presets::resting_ink(theme.success, theme.muted_foreground, theme.popover); + let removed_ink = + crate::ui::presets::resting_ink(theme.danger, theme.muted_foreground, theme.popover); let note = |text: String| { div() .px(px(ROW_PAD)) @@ -2819,7 +2808,7 @@ impl Tty7App { } let sf = rungs(cx); - let (hover, picked_bg) = (gpui::rgb(sf.hover), gpui::rgb(sf.pressed)); + let (hover, picked_bg) = (gpui::rgb(sf.hover), gpui::rgb(sf.cursor)); let right_sel = self.switcher.as_ref().map(|sw| sw.right_sel).unwrap_or(0); let holding = self.switcher.as_ref().is_some_and(|sw| sw.hold.is_some()); let ws = row.id; @@ -2840,10 +2829,15 @@ impl Tty7App { .text_color(muted) .child(row.name.clone()), ) - .child(div().text_xs().text_color(dim).child(match row.tabs.len() { - 1 => t(L10nKey::SwitcherTabCountOne).to_string(), - n => t_fmt(L10nKey::SwitcherTabCount, &[("n", &n.to_string())]), - })), + .child( + div() + .text_xs() + .text_color(muted) + .child(match row.tabs.len() { + 1 => t(L10nKey::SwitcherTabCountOne).to_string(), + n => t_fmt(L10nKey::SwitcherTabCount, &[("n", &n.to_string())]), + }), + ), ); for (nth, i) in hits.iter().enumerate() { @@ -2857,20 +2851,20 @@ impl Tty7App { .items_center() .gap(px(5.)) .text_xs() - .text_color(dim) + .text_color(muted) .child( gpui::svg() .path("icons/git-branch.svg") .flex_shrink_0() .size(px(11.)) - .text_color(dim), + .text_color(muted), ) .child(div().min_w_0().truncate().child(g.branch.clone())) .when(g.added > 0, |c| { c.child( div() .flex_shrink_0() - .text_color(theme.success) + .text_color(added_ink) .child(format!("+{}", g.added)), ) }) @@ -2878,7 +2872,7 @@ impl Tty7App { c.child( div() .flex_shrink_0() - .text_color(theme.danger) + .text_color(removed_ink) .child(format!("−{}", g.removed)), ) }) @@ -2889,7 +2883,7 @@ impl Tty7App { div() .text_xs() .truncate() - .text_color(dim) + .text_color(muted) .child(tab.path.clone()) .into_any_element(), ), @@ -2939,11 +2933,7 @@ impl Tty7App { r.child( div() .flex_shrink_0() - .px(px(6.)) - .py(px(1.)) - .rounded(px(4.)) .text_xs() - .bg(gpui::rgb(sf.selected)) .text_color(muted) .child(t(L10nKey::SwitcherActiveTab)), ) From 615acfc15dba113907e56a5a0f6250971d297001 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:56:07 +0800 Subject: [PATCH 3/3] docs(sidebar): keep diff_click_cwd's doc comment on diff_click_cwd git_click was inserted between the function and its doc block, so the block explaining why the setting is one setting ended up on the wrong function. Move the helper above it. Claude-Session: https://claude.ai/code/session_01Ak6Lnzf3EGErJZmG51Rxbo --- src/ui/tab_sidebar.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 3a01bd19..3873db87 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -2023,13 +2023,6 @@ fn group_names(roots: &[&PathBuf]) -> Vec { } } -/// Whether a `+N −M` is a button, and what it opens if it is. -/// -/// One function because the setting is one setting: the sidebar's counts and -/// the Info panel's `changes` row are the same number about the same working -/// tree, and "Open diff preview from sidebar counts" turning one of them into -/// plain text while the other stayed clickable would be a setting that half -/// works. /// Where a click on a tab's diff counts opens the overlay: the focused pane's /// repo, when the setting allows a preview at all. fn git_click( @@ -2047,6 +2040,13 @@ fn git_click( ) } +/// Whether a `+N −M` is a button, and what it opens if it is. +/// +/// One function because the setting is one setting: the sidebar's counts and +/// the Info panel's `changes` row are the same number about the same working +/// tree, and "Open diff preview from sidebar counts" turning one of them into +/// plain text while the other stayed clickable would be a setting that half +/// works. pub(crate) fn diff_click_cwd(cfg: &Config, target: Option) -> Option { cfg.sidebar_diff_preview.then_some(target).flatten() }