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..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(), ), @@ -2916,6 +2910,7 @@ impl Tty7App { tab.status, tab.unread, tab.ssh, + picked, ROW_AVATAR, cx, )) @@ -2938,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)), ) diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index ca2841b8..3873db87 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 @@ -1901,6 +2023,23 @@ fn group_names(roots: &[&PathBuf]) -> Vec { } } +/// 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)) + }), + ) +} + /// 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 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, ))