From faac27fe969f18c3c924070730bb18e9ac3d7d2d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:58:07 +0800 Subject: [PATCH] feat(scm): restyle the Changes tab after the v4 design - Pinned block keeps 8/10/14 rhythm; branch name medium, 26px sync tile. - Commit message box rests at 56px with a 7px radius. - Split commit control: inverted neutral fill when committable, faint fill otherwise; 6px radius and an inset 0.5px seam. - Change groups sit 16px apart under 22px sentence-case medium headers; file names take width first and directories right-align, eliding from the start. - History: 32px header, 26px rows inset with rounded hover, 1px lines and 7px beads (HEAD filled, others hollow), neutral inks on a single-lane page, age column always shown, faint HEAD pill. --- docs/design-system.md | 10 +- src/ui/scm/graph.rs | 183 ++++++++++++++++++++++------------ src/ui/scm/panel.rs | 224 ++++++++++++++++++++++++++++-------------- 3 files changed, 278 insertions(+), 139 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 2b07c5ea..e5afaa39 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -78,7 +78,15 @@ 页签栏底部的细分隔线上;「更改」后附改动数。 - **右侧文字层级**:文件树名称使用侧栏标题色,不能整体降为辅助灰;当前项使用 selected 文字色与 medium 字重,根目录以字重区分。Git 普通文件名使用 resting 文字色与界面字体,路径、数量与说明保持次级;信息页的路径前缀弱化,末级名称突出。 - **文件树**:文件名中性;A/M/? 等尾部字符承载 Git 状态;目录用小点表示子项变化;冲突才整行提升。 -- **Git 面板**:改动列表是主体。可执行的提交按钮才突出;未填写信息或不可提交时保持次级。 +- **Git 面板**:改动列表是主体。顶部固定块(分支行 28px / 提交信息框 / 提交行)上 8、块间 10、下 14。 + 分支名 medium,末尾同步按钮 26px。提交信息框静止 56px、淡底、`ROW_RADIUS`。 + 提交按钮是分体控件(26px、圆角 6、0.5px 内嵌分隔):可提交时整体反色中性(正文色底 + 表面色字), + 这是面板里唯一的实心形状;不可提交时回到信息框同款淡底,不宣传不能执行的动作。 + 分组之间空 16px,组标题 22px、11.5/16rem medium 辅助色、句首大写(不再全大写),数量右对齐。 + 文件行 26px、圆角 6:状态字母等宽 semibold 按状态着色,文件名正文色优先占宽(下限 40px), + 目录右对齐、从开头省略。History 区以细线分隔,标题 32px;提交行 26px,1px 连线 + 7px 节点, + HEAD 实心、其余空心环;整页只有一条 lane 时连线与节点改用中性色;时间列固定宽度右对齐, + HEAD 引用用淡底胶囊。 - **设置**:章节标题 > 标签 > 描述;开关/滑块/主操作共享强调色;分隔线弱于控件边界。 - **菜单与命令面板**:亮度抬升、有限阴影;当前键盘行清楚,普通项不着色;小窗口内可滚动。 - **终端**:ANSI 是用户内容,不拿来充当 UI 错误/成功配色;应用设计调整不得反转状态含义。 diff --git a/src/ui/scm/graph.rs b/src/ui/scm/graph.rs index 01a08ac2..64a25dbd 100644 --- a/src/ui/scm/graph.rs +++ b/src/ui/scm/graph.rs @@ -78,7 +78,10 @@ use crate::ui::scm::state::RepoKey; /// A taller row against an unchanged [`GRAPH_LANE_W`] steepens a merge's /// diagonal, which is the safe direction: the failure this pitch is guarding /// against is an angle read as *shallower* than the jump it draws. -const GRAPH_ROW_H: f32 = 24.; +/// +/// Now 26, the file list's own pitch: the section reads as one more list of +/// rows under the groups rather than as a denser instrument bolted on below. +const GRAPH_ROW_H: f32 = 26.; /// Rows materialized above and below the visible band, so a fast scroll never /// outruns the window into blank space, and the "load more" band — laid out /// one row past the window's end — stays below the fold until it is real. @@ -92,7 +95,10 @@ const GRAPH_WINDOW_MARGIN: usize = 4; /// `round(12 × 1.618) = 20px` — so 24 is that line plus 2px of air on each /// side. The resize constants below are counted against it, which is why it /// has to stay honest. -const GRAPH_HEADER_H: f32 = 24.; +/// +/// 32 since the section took the panel's own band rhythm: the header is where +/// the section starts, and it wants more air than a row of it. +const GRAPH_HEADER_H: f32 = 32.; /// The header's controls: the filter tile and the scope picker beside it. /// @@ -102,6 +108,15 @@ const GRAPH_HEADER_H: f32 = 24.; const GRAPH_TILE: f32 = 18.; const GRAPH_TILE_GLYPH: f32 = 12.; +/// Space after the age column, inside the section's own inset. +const GRAPH_ROW_PAD_R: f32 = 8.; + +/// The section's rows sit inside this much of the panel's edge on each side, +/// so a hovered or selected commit wears a rounded band like a file row does +/// rather than a full-bleed stripe. Added to the row's own padding it lands on +/// `CONTENT_INSET`. +const GRAPH_SIDE: f32 = CONTENT_INSET - 6.; + /// Horizontal distance between lane centres. const GRAPH_LANE_W: f32 = 12.; @@ -118,8 +133,12 @@ const GRAPH_PAD_R: f32 = 6.; /// It stays 3 while the row grows, because what a bead is measured against is /// the string it is on — `GRAPH_LANE_W`, which has not moved. A quarter is the /// floor the test below holds it to. -const GRAPH_DOT_R: f32 = 3.; -const GRAPH_LINE_W: f32 = 1.5; +/// +/// Since the section went hairline: a 7px node on a 1px string, and the ring a +/// node wears is a touch heavier than the string so it closes cleanly. +const GRAPH_DOT_R: f32 = 3.5; +const GRAPH_LINE_W: f32 = 1.; +const GRAPH_RING_W: f32 = 1.2; /// Most of the panel's width belongs to the message. Thirty percent is what /// leaves five lanes at the 260px default and still keeps a readable column. @@ -138,17 +157,17 @@ const _: () = assert!(GRAPH_MAX_LANES <= LANE_SLOTS); /// file list has to keep a usable part of a short one. /// /// Both of the fixed ones are counted in rows against the header: what they -/// mean is a number of commits, not a number of pixels. 260 is `24 + 9.8 × 24` +/// mean is a number of commits, not a number of pixels. 287 is `32 + 9.8 × 26` /// — nine commits and most of a tenth, and the fraction is deliberate, because /// a row cut by the bottom edge is the only honest way a fixed-height list says -/// there is more below it. 100 is `24 + 3.2 × 24`, three and a bit, which is +/// there is more below it. 116 is `32 + 3.2 × 26`, three and a bit, which is /// the least that still looks like history rather than like a mistake. /// -/// They grew with [`GRAPH_ROW_H`] — 220 and 88 around a 20px row — precisely +/// They grew with [`GRAPH_ROW_H`] — 220 and 88 around a 20px row, 260 and 100 around 24 — precisely /// because they are counted in commits: holding the pixels would have quietly /// bought the file list 40px by showing two fewer commits. -const GRAPH_H_DEFAULT: f32 = 260.; -const GRAPH_H_MIN: f32 = 100.; +const GRAPH_H_DEFAULT: f32 = 287.; +const GRAPH_H_MIN: f32 = 116.; const GRAPH_H_MAX_RATIO: f32 = 0.65; /// The divider's grab area, matching `RESIZE_HANDLE_WIDTH` on the other axis. @@ -343,6 +362,18 @@ struct GraphPaint { selected_surface: Hsla, /// Whether a "load more" band follows the last row. more: bool, + /// Set when the whole page is one lane. A straight line of history has no + /// branch identity to colour, so it is drawn in neutrals: a hairline + /// string and muted rings, with only HEAD filled in ink. + linear: Option, +} + +/// The inks a single-lane page is painted in. +#[derive(Clone, Copy)] +struct Neutral { + line: Hsla, + ring: Hsla, + head: Hsla, } /// Paint the whole gutter in one pass. @@ -369,6 +400,12 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { .max(0) as usize; let cx_of = |column: Lane| left + lane_center_x(column, scale); + let line_ink = |ink: u32| -> Hsla { + match p.linear { + Some(n) => n.line, + None => gpui::rgb(ink).into(), + } + }; let vline = |x: f32, y0: f32, y1: f32| { Bounds::from_corners( point(px(x - GRAPH_LINE_W / 2.), px(y0)), @@ -388,14 +425,14 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { for (column, ink) in band.top.iter().enumerate() { if let Some(ink) = ink { - window.paint_quad(fill(vline(cx_of(column as Lane), y0, mid), gpui::rgb(*ink))); + window.paint_quad(fill(vline(cx_of(column as Lane), y0, mid), line_ink(*ink))); } } for (column, ink) in band.bottom.iter().enumerate() { if let Some(ink) = ink { window.paint_quad(fill( vline(cx_of(column as Lane), mid, y0 + GRAPH_ROW_H), - gpui::rgb(*ink), + line_ink(*ink), )); } } @@ -420,18 +457,28 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { px(mid + GRAPH_LINE_W / 2.), ), ), - gpui::rgb(*ink), + line_ink(*ink), )); } let node = project(row.node, p.max_lanes); - let ink = gpui::rgb(column_ink( - node, - row.color, - p.max_lanes, - p.overflowing, - &p.lanes, - )); + let is_head = p + .page + .commits + .get(i) + .is_some_and(|c| c.refs.iter().any(|r| r.kind == RefKind::Head)); + let ink: Hsla = match p.linear { + Some(n) if is_head => n.head, + Some(n) => n.ring, + None => gpui::rgb(column_ink( + node, + row.color, + p.max_lanes, + p.overflowing, + &p.lanes, + )) + .into(), + }; let cx = cx_of(node); let dot = |r: f32| { Bounds::from_corners( @@ -452,32 +499,34 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { p.surface }; if row.parents > 1 { - // A merge is a ring. It is the one row shape a reader scans for, - // and an outline reads at 8px where a second fill colour does not. + // A merge is the larger ring. It is the one row shape a reader + // scans for, and an outline reads at 9px where a second fill + // colour does not. let r = GRAPH_DOT_R + 1.; window.paint_quad(quad( dot(r), Corners::all(px(r)), hole, - Edges::all(px(GRAPH_LINE_W)), + Edges::all(px(GRAPH_RING_W)), ink, BorderStyle::Solid, )); - } else if row.parents == 0 { - // A root has nothing below it; hollow says "the line stops here" - // without needing a second glyph. + } else if is_head { + // Where you are is the one filled bead on the string. + window.paint_quad( + fill(dot(GRAPH_DOT_R), ink).corner_radii(Corners::all(px(GRAPH_DOT_R))), + ); + } else { + // Every other commit is a hollow bead, so HEAD is found by shape + // before it is found by the chip beside it. window.paint_quad(quad( dot(GRAPH_DOT_R), Corners::all(px(GRAPH_DOT_R)), hole, - Edges::all(px(GRAPH_LINE_W)), + Edges::all(px(GRAPH_RING_W)), ink, BorderStyle::Solid, )); - } else { - window.paint_quad( - fill(dot(GRAPH_DOT_R), ink).corner_radii(Corners::all(px(GRAPH_DOT_R))), - ); } } @@ -491,21 +540,16 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { let column = project(*lane, p.max_lanes); let ink = column_ink(column, *lane, p.max_lanes, p.overflowing, &p.lanes); let x = cx_of(column); + let ink = line_ink(ink); if p.more { - window.paint_quad(fill( - vline(x, y0, y0 + GRAPH_ROW_H), - Hsla::from(gpui::rgb(ink)).opacity(0.3), - )); + window.paint_quad(fill(vline(x, y0, y0 + GRAPH_ROW_H), ink.opacity(0.3))); } else { // Three steps rather than a gradient: a gradient would be a // second `Background` kind for four pixels of ink. const STEP: f32 = 3.; for (step, alpha) in [0.5f32, 0.3, 0.15].into_iter().enumerate() { let a = y0 + step as f32 * STEP; - window.paint_quad(fill( - vline(x, a, a + STEP), - Hsla::from(gpui::rgb(ink)).opacity(alpha), - )); + window.paint_quad(fill(vline(x, a, a + STEP), ink.opacity(alpha))); } } } @@ -537,8 +581,8 @@ impl Tty7App { return Some( div() .flex_none() - .pt(px(2.)) - .pb(px(4.)) + .pt(px(4.)) + .pb(px(12.)) .border_t_1() .border_color(cx.theme().border) .child(self.graph_header(repo, None, cx)) @@ -826,6 +870,11 @@ impl Tty7App { .and_then(|oid| page.commits.iter().position(|c| c.oid == oid)), selected_surface: gpui::rgb(sf.selected).into(), more, + linear: (page.max_lanes <= 1).then(|| Neutral { + line: cx.theme().border, + ring: cx.theme().muted_foreground, + head: cx.theme().foreground, + }), }; stack = stack.child( canvas( @@ -846,7 +895,7 @@ impl Tty7App { .min_h_0() .overflow_y_scroll() .track_scroll(&self.scm.graph.scroll) - .child(stack); + .child(div().px(px(GRAPH_SIDE)).child(stack)); crate::ui::scrollbar::with_vertical_scrollbar( "scm-graph-scrollbar", scroller, @@ -858,9 +907,9 @@ impl Tty7App { /// /// Column order is fixed and every optional part has a hard cap, so the /// worst case cannot squeeze the message to nothing: type prefix, message, - /// ref chip, age. The age goes when a ref chip is present — a chip says - /// where a branch is, which is worth more here than three characters of - /// "3d", and the full timestamp is in the tooltip either way. + /// ref chip, age. The age is a fixed right-aligned column on every row, + /// chip or not, so the times stack into one ragged-left column down the + /// section and the eye can run down it. fn graph_row( &self, repo: &RepoKey, @@ -883,10 +932,11 @@ impl Tty7App { h_flex() .id(SharedString::from(format!("scm-graph-row-{i}"))) .items_center() - .gap(px(4.)) + .gap(px(6.)) .h(px(GRAPH_ROW_H)) .pl(px(gutter)) - .pr(px(CONTENT_INSET)) + .pr(px(GRAPH_ROW_PAD_R)) + .rounded(px(6.)) .cursor_pointer() // The panel's own neutral selection and hover fills, the same two // the file list above uses. A tinted band would make this one list @@ -903,7 +953,7 @@ impl Tty7App { h_flex() .flex_1() .min_w(px(0.)) - .gap(px(2.)) + .gap(px(4.)) .children( prefix.map(|(kind, breaking)| self.graph_type_prefix(kind, breaking, cx)), ) @@ -924,21 +974,21 @@ impl Tty7App { // The age is a mono token, so its column is measured in characters: // the widest it ever prints is four (`12mo`), and four of the mono // face's advances at `META_MONO` is `4 × 11 × 0.6` = 26.4, rounded - // up to 27. - .when(deco.is_none(), |d| { - d.child( - div() - .flex_none() - .min_w(px(27.)) - .text_size(rems(META_MONO)) - .font_family(mono.clone()) - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(relative_time( - now, - commit.author.at.unix, - ))), - ) - }) + // up to 27. Right-aligned inside it, so `3d` and `12mo` end on the + // same edge. + .child( + div() + .flex_none() + .w(px(27.)) + .text_right() + .text_size(rems(META_MONO)) + .font_family(mono.clone()) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(relative_time( + now, + commit.author.at.unix, + ))), + ) .tooltip({ let text = commit_tooltip(commit, now); move |window, cx| { @@ -1093,10 +1143,13 @@ impl Tty7App { // weight; everything else is context. The fill is grey, not brand: // `theme.accent` is a neutral surface tint here, and the only // saturated colour the section spends is a lane's. + // + // A faint neutral pill in muted ink: the filled bead in the gutter + // already says "you are here", so the chip only has to name it. RefKind::Head => ( - theme.accent.opacity(0.28), - theme.foreground, - gpui::FontWeight::SEMIBOLD, + theme.foreground.opacity(0.06), + theme.muted_foreground, + gpui::FontWeight::NORMAL, ), // Tags are yellow because tags are yellow — in git's own output, // in every other client, and in the reader's memory. diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 63013637..b87aca78 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -32,9 +32,9 @@ use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App}; use crate::ui::host_ops::{HostId, SharedHost}; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::{ - HEADING, META, META_MONO, ROW_INSET, SEARCH_H, action_strip, git_badge, info_chip, + META, META_MONO, ROW_INSET, SEARCH_H, action_strip, git_badge, info_chip, }; -use crate::ui::rounding::{CARD_RADIUS, HAIRLINE, RoundedCorners as _, segment_corners}; +use crate::ui::rounding::{HAIRLINE, ROW_RADIUS, RoundedCorners as _, segment_corners}; use crate::ui::scm::ScmIntent; use crate::ui::scm::path::split_display_path; use crate::ui::scm::state::{RepoKey, ScmGroup}; @@ -65,25 +65,21 @@ const BRANCHES_IN_MENU: usize = 12; /// changes the user came to look at. const UNTRACKED_AUTO_COLLAPSE: usize = 20; -/// Message fields share the search field's 30px height and grow with content. +/// The message box rests at 56px — room for a subject and the start of a body +/// — and grows with content from there. const MSG_LINE: f32 = 20.; -const MSG_PAD_X: f32 = 9.; -const MSG_PAD_Y: f32 = 5.; -const MSG_MIN_H: f32 = MSG_PAD_Y + MSG_LINE + MSG_PAD_Y; +const MSG_PAD_X: f32 = 10.; +const MSG_PAD_Y: f32 = 8.; +const MSG_MIN_H: f32 = 56.; /// The ceiling, as `MSG_ROWS_MAX` bare lines. It is a rail, not a border-box /// sum: the wrapper's own hairline and padding are not in it, so at the very /// top of the box's growth the last row is clipped rather than framed. const MSG_MAX_H: f32 = MSG_ROWS_MAX as f32 * MSG_LINE; -/// The invariant the comment above describes, made unbreakable: the resting -/// message box and the panel's search strip are the same height, or this does -/// not build. -const _: () = assert!(MSG_MIN_H == SEARCH_H); - -/// One line at rest and it grows into the message. The box is one row in a -/// column of rows, and a box that stands four lines tall before anything has -/// been typed pushes the file list — the thing the panel is for — off the -/// bottom of a 260px-wide sidebar. +/// One line of input at rest inside the 56px box, growing into the message. +/// The box's resting height is the wrapper's floor, not the row count: a box +/// that stood six lines tall before anything was typed would push the file +/// list — the thing the panel is for — off the bottom of the panel. const MSG_ROWS: usize = 1; const MSG_ROWS_MAX: usize = 6; @@ -110,6 +106,27 @@ const MSG_ROWS_MAX: usize = 6; const COMMIT_H: f32 = ROW_H; const COMMIT_CHEVRON_W: f32 = COMMIT_H - 2.; const COMMIT_GLYPH: f32 = 11.; +const COMMIT_RADIUS: gpui::Pixels = px(6.); + +/// The block pinned above the file list — branch, message, commit — reads as +/// one unit: 8px under the tab row, 10px between its three parts and 14px +/// before the first group. +const PINNED_TOP: f32 = 8.; +const PINNED_GAP: f32 = 10.; +const PINNED_BOTTOM: f32 = 14.; + +/// The branch row's trailing refresh/sync tile. +const BRANCH_TILE: f32 = 26.; + +/// Space between two change groups, and the height of a group's header. +const GROUP_GAP: f32 = 16.; +const GROUP_HEADER_H: f32 = 22.; +/// Group headers are band labels: a half step under `META`, medium weight, +/// muted — the file names under them are what the panel is for. +const GROUP_HEADING: f32 = 11.5 / 16.; +/// A file row's name keeps at least this much before the directory beside it +/// has given up all of its width. +const NAME_FLOOR: f32 = 40.; /// How long to wait before asking git again about a directory that answered /// with nothing. @@ -316,6 +333,7 @@ impl Tty7App { // it is the same 24, so the row has one interior height and the // type sits inside it rather than setting it. .min_h(rems(28. / 16.)) + .mt(px(PINNED_TOP)) .pl(px(CONTENT_INSET)) .pr(px(crate::ui::app::tile_trailing_inset_sm())) .child( @@ -367,6 +385,7 @@ impl Tty7App { .min_w(px(0.)) .line_height(relative(1.)) .truncate() + .font_weight(gpui::FontWeight::MEDIUM) .child(head_label(&status.head)), ) .w_full() @@ -409,12 +428,12 @@ impl Tty7App { } else { Icon::empty().path("icons/git-sync.svg") }), - crate::ui::app::TILE_SIZE_SM, + BRANCH_TILE, crate::ui::app::TILE_GLYPH_SM, false, cx, ) - .rounded_md() + .rounded(px(6.)) // With no branch to move the tile could only ever lose (#545): // upstream is None at a detached or unborn HEAD by definition, // so sync degenerates into scm_push, which has nothing to @@ -797,7 +816,7 @@ impl Tty7App { .key_context(COMMIT_KEY_CONTEXT) .flex_none() .px(px(CONTENT_INSET)) - .pt(px(6.)) + .pt(px(PINNED_GAP)) .child( div() // `MSG_MIN_H` is `panel_search`'s height and the paddings @@ -808,7 +827,7 @@ impl Tty7App { // ever measures itself differently. .min_h(px(MSG_MIN_H)) .max_h(px(MSG_MAX_H)) - .rounded(CARD_RADIUS) + .rounded(ROW_RADIUS) // Half a rung, not a whole one. The hover step is what a // row wears when the pointer is on it — a transient state — // and the message box wears its fill all the time, so at @@ -928,16 +947,15 @@ impl Tty7App { let repo_for_button = repo.clone(); let live = plan.enabled; let staged = status.staged().count(); - let theme = cx.theme(); - let (muted, fg) = (theme.muted_foreground, theme.primary_foreground); - let sf = cx.global::().sidebar; + let muted = cx.theme().muted_foreground; + let paint = commit_paint(live, cx); h_flex() .flex_none() .items_center() .gap(px(8.)) .px(px(CONTENT_INSET)) - .pt(px(6.)) - .pb(px(8.)) + .pt(px(PINNED_GAP)) + .pb(px(PINNED_BOTTOM)) // The reading the button acts on, in the row it acts from. It // gives way first: a long count is still a count, and the control // beside it is the thing that has to keep its shape. @@ -965,13 +983,18 @@ impl Tty7App { // `segment_corners` so their own hit shapes stay inside the // frame's radius — `overflow_hidden` would do that too, and // would take the chevron's popup menu with it. + // + // Committable, the whole frame inverts — ink fill, surface + // label — which is the one prominent shape on the panel. With + // nothing to commit it sinks back to the message box's faint + // fill, so it never advertises an action that cannot run. h_flex() .flex_none() .items_center() .h(px(COMMIT_H)) - .rounded(CARD_RADIUS) - .bg(gpui::rgb(field_fill(sf))) - .hover(|s| s.bg(gpui::rgb(sf.hover))) + .rounded(COMMIT_RADIUS) + .bg(paint.fill) + .hover(move |s| s.bg(paint.hover)) .child( // `xsmall` is the token that sets a `Button`'s label to // `META`, and a control's label is what `META` is for: @@ -985,19 +1008,19 @@ impl Tty7App { // ghost hovers to a colour close enough to it that // the pointer would get no answer. This walks the // same two rungs the file rows walk. - .custom(commit_half(cx)) - .when(live, |button| button.primary()) + .custom(commit_half(paint.ink, cx)) .xsmall() .label(t(plan.label)) .h_full() - .px(px(10.)) - .rounded_corners(segment_corners(0, 2, CARD_RADIUS, HAIRLINE)) + .px(px(12.)) + .font_weight(gpui::FontWeight::MEDIUM) + .rounded_corners(segment_corners(0, 2, COMMIT_RADIUS, HAIRLINE)) // The ink is named here rather than left to the // variant, so the disabled half greys out: it lands // over the variant's own paint because // `refine_style` runs after everything the variant // does. - .text_color(if live { fg } else { muted }) + .text_color(if live { paint.ink } else { muted }) .disabled(!live) .when(!live, |b| b.tooltip(t(plan.reason))) .on_click(cx.listener(move |this, _, window, cx| { @@ -1010,20 +1033,20 @@ impl Tty7App { ); })), ) - // The seam between the halves is the panel showing through - // the fill, not a line drawn on top of it — a rule would be - // the one stroke left on the panel. A div rather than + // The seam is a short rule in the label's own ink, inset + // from both edges so it reads as a divider inside one shape + // rather than a cut through it. A div rather than // `border_l_1` on the chevron, because a ghost `Button` // repaints its border colour transparent in every state it // has and the seam would vanish under the pointer. .child( div() .flex_none() - .w(HAIRLINE) - .h_full() - .bg(gpui::rgb(sf.base)), + .w(px(0.5)) + .h(px(COMMIT_H - 12.)) + .bg(paint.seam), ) - .child(self.scm_commit_menu(repo, cx)), + .child(self.scm_commit_menu(repo, paint.ink, cx)), ) .into_any_element() } @@ -1033,10 +1056,15 @@ impl Tty7App { /// Never disabled, whatever the button beside it is doing: stash and amend /// still mean something with nothing staged, and this is the only way to /// reach them. - fn scm_commit_menu(&self, repo: &RepoKey, cx: &mut Context) -> AnyElement { + fn scm_commit_menu( + &self, + repo: &RepoKey, + ink: gpui::Hsla, + cx: &mut Context, + ) -> AnyElement { let amend = self.scm.amend; Button::new("scm-commit-menu") - .custom(commit_half(cx)) + .custom(commit_half(ink, cx)) .icon(Icon::new(IconName::ChevronDown)) // `Button` sizes an icon off its own `Size`, so the glyph is asked // for by way of the size that produces it — the same conversion @@ -1044,7 +1072,7 @@ impl Tty7App { .with_size(px(COMMIT_GLYPH / crate::ui::tab_strip::BUTTON_ICON_SCALE)) .w(px(COMMIT_CHEVRON_W)) .h_full() - .rounded_corners(segment_corners(1, 2, CARD_RADIUS, HAIRLINE)) + .rounded_corners(segment_corners(1, 2, COMMIT_RADIUS, HAIRLINE)) .dropdown_menu_with_anchor(gpui::Anchor::TopRight, { let app = cx.entity().downgrade(); let repo = repo.clone(); @@ -1291,7 +1319,8 @@ impl Tty7App { status: &Arc, cx: &mut Context, ) -> AnyElement { - let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.)); + let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).pb(px(12.)); + let mut first = true; for group in ScmGroup::ORDER { let entries: Vec<&StatusEntry> = status .entries @@ -1302,6 +1331,12 @@ impl Tty7App { continue; } let collapsed = self.scm.group_collapsed(group, entries.len()); + // Groups are told apart by the pause before each one, not by a + // rule: 16px, and none above the first. + if !first { + list = list.child(div().flex_none().h(px(GROUP_GAP))); + } + first = false; list = list.child(self.scm_group_header(repo, group, &entries, collapsed, cx)); if collapsed { continue; @@ -1356,7 +1391,6 @@ impl Tty7App { ) -> AnyElement { let count = entries.len(); let sf = cx.global::().sidebar; - let mono = cx.theme().mono_font_family.clone(); let id = SharedString::from(format!("scm-group-{group:?}")); let actions = self.scm_group_actions(&id, repo, group, entries, sf.hover, cx); h_flex() @@ -1364,10 +1398,11 @@ impl Tty7App { .group(id) .relative() .items_center() - .gap(px(8.)) - .min_h(rems(ROW_H / 16.)) + .gap(px(6.)) + .min_h(rems(GROUP_HEADER_H / 16.)) + .mb(px(1.)) .px(px(ROW_INSET)) - .rounded(px(5.)) + .rounded(px(6.)) .cursor_pointer() .hover(|s| s.bg(gpui::rgb(sf.hover))) .on_click(cx.listener(move |this, _, _window, cx| { @@ -1388,24 +1423,25 @@ impl Tty7App { } else { IconName::ChevronDown }) - .xsmall(), + .size(px(COMMIT_GLYPH)), ), ) + // Sentence case, medium, muted: a band label over the rows rather + // than a heading competing with them. .child( div() .flex_1() .min_w_0() .truncate() - .text_size(rems(HEADING)) - .font_weight(gpui::FontWeight::SEMIBOLD) + .text_size(rems(GROUP_HEADING)) + .font_weight(gpui::FontWeight::MEDIUM) .text_color(cx.theme().muted_foreground) - .child(t(group_label(group)).to_uppercase()), + .child(t(group_label(group))), ) .child( div() .flex_none() - .text_size(rems(META_MONO)) - .font_family(mono) + .text_size(rems(GROUP_HEADING)) .text_color(cx.theme().muted_foreground) .child(count.to_string()), ) @@ -1449,7 +1485,7 @@ impl Tty7App { .min_w_0() .px(px(ROW_INSET)) .py(px(3.)) - .rounded(px(5.)) + .rounded(px(6.)) .cursor_pointer() .hover(|s| s.bg(gpui::rgb(sf.hover))) .when(selected, |s| s.bg(gpui::rgb(sf.selected))) @@ -1483,10 +1519,12 @@ impl Tty7App { }) .child(git_badge(letter, status_color(deco, cx), &mono)) // Names use the same resting/selected hierarchy as sidebar rows. + // The name takes its own width first and only then shrinks, down + // to a floor; the directory beside it gets whatever is left. .child( div() - .flex_1() - .min_w_0() + .flex_shrink(1.) + .min_w(px(NAME_FLOOR)) .truncate() .text_size(rems(crate::ui::right_panel::TEXT)) .text_color(if deco == DecoStatus::Conflict { @@ -1500,21 +1538,22 @@ impl Tty7App { .when(deco == DecoStatus::Deleted, |s| s.line_through()) .child(name.to_string()), ) - // Cap the secondary directory while the filename fills the rest. - // A rem cap also gives short paths their intrinsic width inside - // the context-menu wrapper's flex layout. - .when(!dir.is_empty(), |this| { - this.child( - div() - .flex_none() - .max_w(rems(3.)) - .min_w_0() - .truncate() - .text_size(rems(META)) - .text_color(cx.theme().muted_foreground) - .child(dir.to_string()), - ) - }) + // The directory is flush right and gives way from its *start*: + // the folder nearest the file is the part that says where it is, + // and `src/ui/…` cut from the end keeps the part that says least. + // The row tooltip has the whole path either way. + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis_start() + .text_right() + .text_size(rems(META)) + .text_color(cx.theme().muted_foreground) + .child(dir.to_string()), + ) .child(actions) .into_any_element() } @@ -1954,15 +1993,54 @@ fn field_fill(sf: crate::ui::presets::Surface) -> u32 { /// `active` slot, and a dropdown marks its trigger selected for as long as /// the menu is open. Anything but transparent there parks a block on the /// chevron for the whole time the user is reading the menu. -fn commit_half(cx: &gpui::App) -> ButtonCustomVariant { +fn commit_half(ink: gpui::Hsla, cx: &gpui::App) -> ButtonCustomVariant { let clear = gpui::transparent_black(); ButtonCustomVariant::new(cx) .color(clear) - .foreground(cx.theme().foreground) + .foreground(ink) .hover(clear) .active(clear) } +/// How the split commit control is painted. +#[derive(Clone, Copy)] +struct CommitPaint { + fill: gpui::Hsla, + hover: gpui::Hsla, + ink: gpui::Hsla, + seam: gpui::Hsla, +} + +/// Committable: an inverted neutral — the panel's ink as the fill and its own +/// surface as the label, so it is the one solid shape in a panel of rows, and +/// it stays neutral rather than borrowing the accent the sidebar and focus +/// rings already spend. Not committable: the message box's faint fill and the +/// panel's ink, a control that is there but is not asking for anything. +/// +/// The label ink is the opaque surface rather than `theme.background`, which +/// carries the window's transparency when one is configured. +fn commit_paint(live: bool, cx: &gpui::App) -> CommitPaint { + let sf = cx.global::().sidebar; + let fg = cx.theme().foreground; + match live { + true => { + let ink: gpui::Hsla = gpui::rgb(sf.base).into(); + CommitPaint { + fill: fg, + hover: fg.blend(ink.opacity(0.14)), + ink, + seam: ink.opacity(0.24), + } + } + false => CommitPaint { + fill: gpui::rgb(field_fill(sf)).into(), + hover: gpui::rgb(sf.hover).into(), + ink: fg, + seam: fg.opacity(0.18), + }, + } +} + fn branch_note(text: &str, ink: gpui::Hsla, mono: &SharedString) -> AnyElement { div() .flex_none()