mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #823 from l0ng-ai/feat/sidebar-hierarchy
feat(sidebar): text hierarchy and state-driven colour
This commit is contained in:
+127
-11
@@ -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,
|
||||
|
||||
+51
-60
@@ -1840,11 +1840,7 @@ impl Tty7App {
|
||||
|
||||
fn render_footer(&self, cx: &mut Context<Self>) -> 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<gpui::Hsla> = 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<Self>,
|
||||
) -> 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)),
|
||||
)
|
||||
|
||||
+166
-27
@@ -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<usize>,
|
||||
}
|
||||
|
||||
/// 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<SharedGit> = 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::<Config>(),
|
||||
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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::<Config>(),
|
||||
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
|
||||
|
||||
+31
-6
@@ -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<crate::core::cli_agent::AgentStatus>,
|
||||
unread: usize,
|
||||
ssh: Option<u32>,
|
||||
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,
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user