From 9b6261e64452977e5dea204ea7ea022564ea2c2f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:23:22 +0800 Subject: [PATCH] feat(diff): restyle the diff overlay and commit detail after the v4 design Carry the v4 language into the diff overlay and the commit detail view: 0.5px hairlines at 8% ink, 26px row pills with a 6px radius, the rem type ladder from right_panel.rs, neutral chips instead of accent washes, the shared git_badge for status letters, and tabular figures on counts. Layout, spacing, type and colour only; no behaviour or i18n changes. --- src/ui/diff_overlay.rs | 236 ++++++++++++++++++++++++++++------------- src/ui/scm/detail.rs | 218 +++++++++++++++++++++---------------- 2 files changed, 289 insertions(+), 165 deletions(-) diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 61e746e7..e3151ce1 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use gpui::{ AnyElement, Background, FocusHandle, FontWeight, Hsla, KeyDownEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, Pixels, SharedString, Window, div, prelude::*, px, + MouseDownEvent, MouseMoveEvent, Pixels, SharedString, Window, div, prelude::*, px, rems, }; use gpui_component::button::Button; use gpui_component::menu::{ContextMenuExt as _, PopupMenuItem}; @@ -27,7 +27,9 @@ use crate::ui::diff_list::{DiffRow, FileHead, RowAt}; use crate::ui::diff_rows::{DiffSelection, Side, SplitCell, SplitRow, UnifiedRow}; use crate::ui::document_column::DocumentChrome; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; -use crate::ui::right_panel::info_chip; +use crate::ui::right_panel::{ + HEADING, META, META_MONO, TAB_TEXT, TEXT, TEXT_MONO, git_badge, info_chip, +}; use crate::ui::rounding; use crate::ui::scm::path::relative_time; use crate::ui::scm::status::{status_color, status_glyph}; @@ -633,6 +635,7 @@ impl Tty7App { let subject_takes_the_slack = chrome.is_dock() && !subject.is_rev && subject.label.is_none(); let menu_app = cx.entity().downgrade(); + let hover = gpui::rgb(cx.global::().window.hover); let row = h_flex().id("diff-overlay-header"); let row = if chrome.header_is_title_strip() { crate::ui::app::title_bar_drag(row, "diff-overlay-header", window, cx) @@ -642,11 +645,15 @@ impl Tty7App { row.flex_shrink_0() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .pl(px(lead)) - .pr(px(crate::ui::app::tile_trailing_inset())) + // The close tile is a notch smaller than the title bar's own, and + // is pulled in by half the difference so its glyph stays on the + // column every other trailing tile in the window is centred on. + .pr(px(crate::ui::app::tile_trailing_inset() + + (crate::ui::app::TILE_SIZE - CLOSE_TILE) / 2.)) .gap_2() .items_center() - .border_b_1() - .border_color(cx.theme().border) + .border_b(HAIRLINE_W) + .border_color(hairline(cx)) .child( gpui::svg() .path(subject.icon) @@ -659,7 +666,8 @@ impl Tty7App { // same monospace the patch below it is set in. div() .flex_shrink_0() - .text_size(px(13.)) + .text_size(rems(TEXT_MONO)) + .font_weight(FontWeight::MEDIUM) .font_family(self.font_family.clone()) .child(subject.text) .into_any_element() @@ -674,16 +682,19 @@ impl Tty7App { div() .when(subject_takes_the_slack, |d| d.flex_1().min_w_0().truncate()) .when(!subject_takes_the_slack, |d| d.flex_shrink_0()) - .text_sm() + .text_size(rems(TEXT)) .font_weight(FontWeight::MEDIUM) .child(subject.text) .into_any_element() }) + // A faint neutral pill, the one the graph gives HEAD: the chip + // qualifies the branch beside it rather than competing with it, + // and the title bar spends no accent on anything. .when_some(subject.chip, |bar, text| { bar.child(info_chip( text, - cx.theme().accent.opacity(0.16), - cx.theme().foreground, + cx.theme().foreground.opacity(CHIP_FILL), + cx.theme().muted_foreground, &mono, )) }) @@ -705,7 +716,7 @@ impl Tty7App { .flex_auto() .min_w_0() .truncate() - .text_sm() + .text_size(rems(TEXT)) .child(SharedString::from(label.subject.clone())), ) // Yields before the subject does, for the same reason the @@ -717,7 +728,8 @@ impl Tty7App { .min_w_0() .flex_shrink(999.) .truncate() - .text_xs() + .text_size(rems(META)) + .font_features(tabular()) .text_color(cx.theme().muted_foreground) .child(label_byline(label, now_unix())), ) @@ -740,11 +752,12 @@ impl Tty7App { .items_center() .min_w_0() .gap_1() - .px_1p5() - .py_0p5() - .rounded_md() + .h(CONTROL_H) + .pl(px(4.)) + .pr(ROW_INSET) + .rounded(ROW_RADIUS) .cursor_pointer() - .hover(|s| s.bg(cx.theme().list_hover)) + .hover(|s| s.bg(hover)) .on_click(cx.listener(|this, _, _window, cx| { let active = this.active; if let Some(overlay) = this @@ -765,7 +778,7 @@ impl Tty7App { .child( h_flex() .min_w_0() - .text_xs() + .text_size(rems(META_MONO)) .font_family(self.font_family.clone()) .child(div().min_w_0().flex_shrink(999.).truncate().child(head)) .child(div().min_w_0().flex_shrink(1.).truncate().child(leaf)), @@ -786,7 +799,8 @@ impl Tty7App { bar.when(!chrome.is_dock(), |bar| { bar.child( div() - .text_xs() + .text_size(rems(META)) + .font_features(tabular()) .text_color(cx.theme().muted_foreground) .child(summary), ) @@ -794,7 +808,8 @@ impl Tty7App { .when(added > 0, |bar| { bar.child( div() - .text_xs() + .text_size(rems(META)) + .font_features(tabular()) .text_color(cx.theme().success) .child(format!("+{added}")), ) @@ -802,7 +817,8 @@ impl Tty7App { .when(removed > 0, |bar| { bar.child( div() - .text_xs() + .text_size(rems(META)) + .font_features(tabular()) .text_color(cx.theme().danger) .child(format!("−{removed}")), ) @@ -814,7 +830,7 @@ impl Tty7App { |bar| { bar.child( div() - .text_xs() + .text_size(rems(META)) .text_color(cx.theme().muted_foreground) .child(t(L10nKey::Refreshing)), ) @@ -833,12 +849,12 @@ impl Tty7App { div().occlude().flex_shrink_0().child( crate::ui::tab_strip::chrome_tile_sized( Button::new("diff-overlay-close").icon(Icon::new(IconName::Close)), - crate::ui::app::TILE_SIZE, - crate::ui::app::TILE_GLYPH_LINE, + CLOSE_TILE, + crate::ui::tab_strip::RAIL_TILE_GLYPH, false, cx, ) - .rounded_lg() + .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS)) .tooltip(t(L10nKey::DiffCloseTooltip)) .on_click(cx.listener(|this, _, window, cx| { this.close_diff_overlay(window, cx); @@ -859,6 +875,10 @@ impl Tty7App { /// glyph, and so is every tile at the other end of the window — so it read /// as pasted on. Same two choices, no frame: the live one carries a soft /// fill, the other is quiet text that lights up under the pointer. + /// + /// The cells are the list's own row pills — row height, row radius — set + /// in the right panel's tab type, so the switch reads as two more rows of + /// the same app rather than as a widget. fn diff_view_switch(&self, cx: &mut Context) -> AnyElement { let sf = cx.global::().window; let current = view_mode(cx); @@ -875,10 +895,10 @@ impl Tty7App { h_flex() .id(("diff-overlay-view-cell", i)) .items_center() - .h(px(22.)) - .px(px(8.)) + .h(CONTROL_H) + .px(px(10.)) .rounded(ROW_RADIUS) - .text_sm() + .text_size(rems(TAB_TEXT)) .cursor_pointer() .when(live, |cell| { cell.bg(gpui::rgb(sf.selected)) @@ -977,7 +997,7 @@ impl Tty7App { .flex() .items_center() .justify_center() - .text_sm() + .text_size(rems(TEXT)) .text_color(cx.theme().muted_foreground) .child(text) .into_any_element() @@ -1101,7 +1121,7 @@ impl Tty7App { // full width and puts it at its own left edge, so a horizontal // padding here would be silently ignored. The rows carry their own — // see `diff_row_element`. - .py_4(); + .py(px(LIST_INSET)); // The bar reads the list's own height, and a list only counts the // rows it has measured. Left at that, a patch of any length would // report itself as one screen long and the thumb would fill the @@ -1281,17 +1301,61 @@ fn spliced_range(old: &[DiffRow], new: &[DiffRow]) -> (std::ops::Range, u (prefix..old.len() - suffix, new.len() - prefix - suffix) } -/// The row inset every row of the list shares, matching the source control -/// panel's — the overlay is a second view of that panel's list, and the two -/// stopped looking like one app when this one drew cards. -const ROW_INSET: Pixels = px(10.); +/// How far a row's pill sits in from the edge of the list: the window's +/// `CONTENT_INSET`, the same 12px the rail and the right panel keep, and the +/// same inset the header's subject starts at. +const LIST_INSET: f32 = crate::ui::app::CONTENT_INSET; + +/// The padding a row keeps inside its pill — the rail's 8px — so a hovered +/// row's fill reaches past its text on both sides. The overlay is a second +/// view of the Changes tab's list, and the two stopped looking like one app +/// when this one drew cards. +const ROW_INSET: Pixels = px(8.); /// The height of a row that is a *file* rather than a line of one: the same /// 26px the panel gives its file rows. const FILE_ROW_H: Pixels = px(26.); -/// The radius on a row that lights up under the pointer. Matches the panel's. -const ROW_RADIUS: Pixels = px(5.); +/// The radius on a row that lights up under the pointer: the 6px the Changes +/// tab's 26px rows use. +const ROW_RADIUS: Pixels = px(6.); + +/// The header's pills — the view switch and the way back to the list — are +/// one row tall, so the title bar's controls are the list's rows in another +/// place rather than a smaller widget of their own. +const CONTROL_H: Pixels = FILE_ROW_H; + +/// The header's close tile: the rail header's 26px tile, not the title bar's +/// 32. The diff header already carries a subject, a switch and a path; the +/// way out should be the quietest shape on it. +const CLOSE_TILE: f32 = crate::ui::tab_strip::RAIL_TILE; + +/// The fill under a neutral chip, the same faint ink the graph's HEAD chip +/// wears. +const CHIP_FILL: f32 = 0.06; + +/// A section label's line in the list — the untracked heading — at the +/// Changes tab's group-header height. +const GROUP_HEADER_H: Pixels = px(22.); + +/// The width of every rule the diff views draw: the header's bottom edge, +/// the hunk breaks, the split view's centre and the unified gutter. +pub(crate) const HAIRLINE_W: Pixels = px(0.5); + +/// The ink of those rules: a hairline at 8% of the foreground, which reads as +/// an edge on the window fill without ever drawing a box. Derived from the +/// foreground rather than `border`, the control-edge role, because a +/// half-pixel of a colour tuned for 1px outlines reads heavier than the rest +/// of the v4 chrome. +pub(crate) fn hairline(cx: &gpui::App) -> Hsla { + cx.theme().foreground.opacity(0.08) +} + +/// Tabular figures for counts set in the interface face, so `+12 −3` and a +/// file count keep their width as they change. Mono text needs none of this. +pub(crate) fn tabular() -> gpui::FontFeatures { + gpui::FontFeatures(Arc::new(vec![("tnum".to_string(), 1)])) +} /// The height of one line of a patch, in either view. /// @@ -1307,9 +1371,9 @@ const DIFF_LINE_H: Pixels = px(19.); /// /// Barely there on purpose: with the cards gone it is the only line left in /// the list, and it is separating two parts of one file rather than two -/// files. +/// files. It is the header's hairline, so the view has one kind of line. fn hunk_rule(cx: &gpui::App) -> Hsla { - cx.theme().border.opacity(0.6) + hairline(cx) } /// One row, inset the way every row in the list is. @@ -1334,9 +1398,9 @@ fn diff_row_element( .px(ROW_INSET) .py_1() .when(!leads, |h| { - h.mt_1().border_t_1().border_color(hunk_rule(cx)) + h.mt_1().border_t(HAIRLINE_W).border_color(hunk_rule(cx)) }) - .text_xs() + .text_size(rems(META_MONO)) .font_family(font.clone()) .text_color(cx.theme().muted_foreground) .truncate() @@ -1373,15 +1437,19 @@ fn diff_row_element( DiffRow::MoreFiles { rest } => { padded(note_row(t_plural(L10nKey::DiffMoreFiles, *rest, &[]), cx)) } - // A section label, in the shape the sidebar gives its group headings: - // small, quiet, and carried by the space around it rather than a bar - // of its own. + // A section label, in the shape the Changes tab gives its group + // headings: 11.5px, medium, muted, sentence case — small, quiet, and + // carried by the space around it rather than a bar of its own. DiffRow::UntrackedHeader { total } => padded( div() .w_full() + .flex() + .items_center() + .h(GROUP_HEADER_H) .px(ROW_INSET) - .py_1() - .text_xs() + .text_size(rems(HEADING)) + .font_weight(FontWeight::MEDIUM) + .font_features(tabular()) .text_color(cx.theme().muted_foreground) .child(t_plural(L10nKey::DiffUntrackedHeader, *total, &[])) .into_any_element(), @@ -1464,7 +1532,11 @@ fn diff_row_drag( /// The margin the file rows keep from the edge of the list. fn padded(row: AnyElement) -> AnyElement { - div().w_full().px_2().child(row).into_any_element() + div() + .w_full() + .px(px(LIST_INSET)) + .child(row) + .into_any_element() } /// An aside in the list's own voice — a cap that was hit, a tail that was not @@ -1474,7 +1546,7 @@ fn note_row(text: String, cx: &gpui::App) -> AnyElement { .w_full() .px(ROW_INSET) .py_1() - .text_xs() + .text_size(rems(META)) .text_color(cx.theme().muted_foreground) .child(text) .into_any_element() @@ -1486,13 +1558,16 @@ fn diff_oversized_notice(snap: &DiffSnapshot, cx: &gpui::App) -> AnyElement { L10nKey::DiffOversizedNotice, &[("summary", &oversized_summary(snap, &stats))], ); + // The row pill at rest, filled with the hover step: a notice sits in the + // list's own shape, one quiet rung off the window, rather than in a card. + let fill = gpui::rgb(cx.global::().window.hover); div() .w_full() .px(ROW_INSET) .py_2() - .rounded(rounding::CARD_RADIUS) - .bg(cx.theme().secondary) - .text_xs() + .rounded(rounding::ROW_RADIUS) + .bg(fill) + .text_size(rems(META)) .text_color(cx.theme().muted_foreground) .child(text) .into_any_element() @@ -1504,7 +1579,8 @@ fn diff_file_header( app: &gpui::WeakEntity, cx: &gpui::App, ) -> AnyElement { - let hover = gpui::rgb(cx.global::().window.hover); + let sf = cx.global::().window; + let hover = gpui::rgb(sf.hover); let deco = deco_status(head.status); let (glyph, glyph_color) = (status_glyph(deco), status_color(deco, cx)); let mut header = h_flex() @@ -1542,33 +1618,29 @@ fn diff_file_header( } else { IconName::ChevronRight }) - .small() + .size(px(crate::ui::app::TILE_GLYPH_XS)) .text_color(cx.theme().muted_foreground), ) }) - .child( - div() - .flex_shrink_0() - .font_family(font.clone()) - .text_xs() - .font_weight(FontWeight::BOLD) - .text_color(glyph_color) - .child(glyph), - ) + // The status letter is the Changes tab's badge — same cell, weight + // and ink — so M reads amber and A/U green here exactly as it does + // one click away. + .child(git_badge(glyph, glyph_color, font)) .child( div() .flex_1() .min_w_0() .truncate() - .text_xs() + .text_size(rems(TEXT_MONO)) .font_family(font.clone()) + .text_color(gpui::rgb(sf.text_resting)) .child(head.shown_path.clone()), ); if head.binary { header = header.child( div() .flex_shrink_0() - .text_xs() + .text_size(rems(META)) .text_color(cx.theme().muted_foreground) .child(t(L10nKey::Binary)), ); @@ -1577,7 +1649,8 @@ fn diff_file_header( header = header.child( div() .flex_shrink_0() - .text_xs() + .text_size(rems(META_MONO)) + .font_family(font.clone()) .text_color(cx.theme().success) .child(format!("+{}", head.added)), ); @@ -1586,7 +1659,8 @@ fn diff_file_header( header = header.child( div() .flex_shrink_0() - .text_xs() + .text_size(rems(META_MONO)) + .font_family(font.clone()) .text_color(cx.theme().danger) .child(format!("−{}", head.removed)), ); @@ -1606,7 +1680,8 @@ fn diff_untracked_row( app: &gpui::WeakEntity, cx: &gpui::App, ) -> AnyElement { - let hover = gpui::rgb(cx.global::().window.hover); + let sf = cx.global::().window; + let hover = gpui::rgb(sf.hover); let for_focus = path.to_string(); let app = app.clone(); h_flex() @@ -1617,8 +1692,6 @@ fn diff_untracked_row( .h(FILE_ROW_H) .px(ROW_INSET) .rounded(ROW_RADIUS) - .text_xs() - .font_family(font.clone()) .cursor_pointer() .hover(|s| s.bg(hover)) .on_click(move |_, window, cx| { @@ -1636,14 +1709,21 @@ fn diff_untracked_row( }) .ok(); }) + .child(git_badge( + status_glyph(DecoStatus::Untracked), + status_color(DecoStatus::Untracked, cx), + font, + )) .child( div() - .flex_shrink_0() - .font_weight(FontWeight::BOLD) - .text_color(status_color(DecoStatus::Untracked, cx)) - .child(status_glyph(DecoStatus::Untracked)), + .flex_1() + .min_w_0() + .truncate() + .text_size(rems(TEXT_MONO)) + .font_family(font.clone()) + .text_color(gpui::rgb(sf.text_resting)) + .child(path.to_string()), ) - .child(div().flex_1().min_w_0().truncate().child(path.to_string())) .into_any_element() } @@ -1669,7 +1749,7 @@ fn diff_split_row( app, cx, )) - .child(div().flex_shrink_0().w(px(1.)).bg(hunk_rule(cx))) + .child(div().flex_shrink_0().w(HAIRLINE_W).bg(hunk_rule(cx))) .child(diff_split_cell( row.right.as_ref(), Side::New, @@ -1723,7 +1803,7 @@ fn diff_split_cell( .w(px(42.)) .justify_end() .pr_1p5() - .text_color(cx.theme().muted_foreground.opacity(0.7)) + .text_color(cx.theme().muted_foreground) .child(cell.no.map(|n| n.to_string()).unwrap_or_default()), ) .child( @@ -1768,7 +1848,7 @@ fn diff_unified_row( .w(px(34.)) .justify_end() .pr_1p5() - .text_color(cx.theme().muted_foreground.opacity(0.7)) + .text_color(cx.theme().muted_foreground) .child(no.map(|n| n.to_string()).unwrap_or_default()) }; let fill = match drag.covers(at, None) { @@ -1787,7 +1867,13 @@ fn diff_unified_row( // The split view's centre rule, in the one place it still means the // same thing: everything left of it is a number, everything right of // it is the file. - .child(div().flex_shrink_0().w(px(1.)).h_full().bg(hunk_rule(cx))) + .child( + div() + .flex_shrink_0() + .w(HAIRLINE_W) + .h_full() + .bg(hunk_rule(cx)), + ) .child( div() .flex_shrink_0() diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs index f169122f..0157931d 100644 --- a/src/ui/scm/detail.rs +++ b/src/ui/scm/detail.rs @@ -46,7 +46,7 @@ use tty7_core::core::git::status::DecoStatus; use crate::terminal::git_diff::DiffSource; use crate::ui::app::{CONTENT_INSET, Tty7App}; use crate::ui::i18n::{L10nKey, t, t_plural}; -use crate::ui::right_panel::{META, META_MONO, ROW_INSET, TEXT, git_badge, info_chip}; +use crate::ui::right_panel::{HEADING, META, META_MONO, ROW_INSET, TEXT, git_badge, info_chip}; use crate::ui::scm::path::{relative_time, split_display_path}; use crate::ui::scm::state::{CommitDetailView, RepoKey}; use crate::ui::scm::status::{status_color, status_glyph}; @@ -80,6 +80,26 @@ const BODY_PAD_T: f32 = 2. / 16.; /// this repository but a handful, and a cap for the ones that are a paragraph. const SUBJECT_LINES: usize = 3; +/// The radius on every pill in this view — the rows, the way back, the object +/// ids: the 6px the Changes tab's 26px rows use. +const PILL_RADIUS: gpui::Pixels = px(6.); + +/// How tall the header's two affordances and the parent links are: a row's +/// height less a couple of pixels either side, so a hovered pill reads as the +/// target inside its row rather than as the whole row lighting up. +const PILL_H: f32 = ROW_H - 4.; + +/// The pause before the file list — the one the Changes tab leaves between +/// two groups — and the height of the summary line that heads it, which is a +/// group header there too. +const GROUP_GAP: f32 = 16.; +const GROUP_HEADER_H: f32 = 22.; + +/// A file row's name keeps at least this much before the directory beside it +/// has given up all of its width. `panel.rs`'s floor, restated because the +/// two rows are the same row. +const NAME_FLOOR: f32 = 40.; + // Which step of the right panel's ramp does what in this view. // // The names come from `right_panel.rs` and the sizes are not this view's to @@ -159,7 +179,7 @@ impl Tty7App { // out a `ROW_INSET` short of it so the fill is wider than the text, and // an outer inset would have to be undone by every one of them. let mut body = v_flex() - .py(px(2.)) + .pb(px(12.)) .child(self.detail_header_row(detail, &mono, cx)); match detail.commit.as_deref() { @@ -176,8 +196,10 @@ impl Tty7App { None => { body = body.child( div() + .flex() + .items_center() + .min_h(px(ROW_H)) .px(px(CONTENT_INSET)) - .py(px(4.)) .text_size(rems(META)) .text_color(muted) .child(if detail.loaded { @@ -268,6 +290,10 @@ impl Tty7App { /// reader came to read. The oid is set in the same mono at the same token /// size as the parent links below, so the two read as the same kind of /// thing. + /// + /// Both pills pad themselves by `ROW_INSET` inside a row inset by + /// `CONTENT_INSET - ROW_INSET`, so the chevron and the oid land on the + /// same 12px column as every line of text under them. fn detail_header_row( &self, detail: &CommitDetailView, @@ -287,9 +313,10 @@ impl Tty7App { .id("scm-detail-back") .items_center() .gap(px(2.)) - .px(px(4.)) - .py(px(1.)) - .rounded(px(4.)) + .h(px(PILL_H)) + .pl(px(ROW_INSET - 2.)) + .pr(px(ROW_INSET)) + .rounded(PILL_RADIUS) .cursor_pointer() .hover(|s| s.bg(hover_bg)) .on_click(cx.listener(|this, _, _window, cx| this.close_commit_detail(cx))) @@ -307,12 +334,13 @@ impl Tty7App { ) .child(div().flex_1().min_w_0()) .child( - div() + h_flex() .id("scm-detail-sha") .flex_none() - .px(px(4.)) - .py(px(1.)) - .rounded(px(4.)) + .items_center() + .h(px(PILL_H)) + .px(px(ROW_INSET)) + .rounded(PILL_RADIUS) .cursor_pointer() .hover(|s| s.bg(hover_bg)) .text_size(rems(META_MONO)) @@ -342,17 +370,19 @@ impl Tty7App { let folded = !detail.body_expanded && lines > BODY_LINES; v_flex() .px(px(CONTENT_INSET)) - .pb(px(4.)) - .gap(px(3.)) + .pt(px(4.)) + .pb(px(6.)) + .gap(px(4.)) .child( // Wrapping, not truncating: this view exists because the graph // row could only show the first 26 characters. It carries the // weight and the full foreground while everything under it is // muted, and that is the whole of its emphasis — it sits on - // the same 12px step as the file rows below it. + // the same step as the file rows below it. Medium, the weight + // the rest of the panel gives a title, not semibold. div() .text_size(rems(TEXT)) - .font_weight(gpui::FontWeight::SEMIBOLD) + .font_weight(gpui::FontWeight::MEDIUM) .text_color(cx.theme().foreground) .line_clamp(SUBJECT_LINES) .child(SharedString::from(commit.summary.clone())), @@ -360,6 +390,7 @@ impl Tty7App { .child( div() .text_size(rems(META)) + .font_features(crate::ui::diff_overlay::tabular()) .text_color(cx.theme().muted_foreground) .child(byline(commit, now_unix())), ) @@ -399,7 +430,11 @@ impl Tty7App { body.to_string() })), ) + // A control, not a link: muted like the rest of the chrome, + // lifted to the foreground and medium under the pointer. The + // accent stays with focus rings and the primary actions. .when(lines > BODY_LINES, |this| { + let fg = cx.theme().foreground; this.child( div() .id("scm-detail-body-fold") @@ -408,7 +443,9 @@ impl Tty7App { .py(px(1.)) .cursor_pointer() .text_size(rems(META)) - .text_color(cx.theme().info) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(cx.theme().muted_foreground) + .hover(move |s| s.text_color(fg)) .on_click(cx.listener(|this, _, _window, cx| { if let Some(open) = this.scm.detail.as_mut() { open.body_expanded = !open.body_expanded; @@ -432,9 +469,10 @@ impl Tty7App { /// of them once there is a whole column to put them in. /// /// Exactly two of them get a fill, and they are the two that mean - /// something. HEAD is where you are, washed in `accent` under the full - /// foreground — one emphasised token on the row. A tag is yellow because a - /// tag is yellow everywhere in git. Everything else — the other local + /// something. HEAD is where you are: the faint neutral pill the graph + /// gives it, under the full foreground — one emphasised token on the row, + /// and no accent spent on it. A tag is yellow because a tag is yellow + /// everywhere in git. Everything else — the other local /// branches, every remote-tracking ref — is a bare muted span: no fill, and /// therefore no padding either, because padding exists to hold text off a /// background and there is no background to hold it off. A ref name is @@ -442,10 +480,10 @@ impl Tty7App { /// them turns a list of names into a wall of blocks, which is what the /// panel's language is trying not to be. /// - /// `theme.accent` is a neutral surface tint in tty7 rather than the brand - /// colour, which is exactly why 0.28 of it works: it is a raised patch, not - /// a wash of hue, and the foreground stays legible on it. Do not substitute - /// `theme.ring` here and then have to drop the opacity to compensate. + /// The fill is the foreground at 6%, the same patch the graph's HEAD chip + /// wears, so one ref looks the same in both places. The foreground over + /// it, not the muted ink the graph uses: the graph has a filled bead in + /// the gutter to say "you are here", and this view has only the chip. /// /// What this must never go back to is the bug that predated all of it: the /// fallback arm painted `theme.accent` at *full* opacity under muted text, @@ -461,12 +499,7 @@ impl Tty7App { return None; } let theme = cx.theme(); - let (accent, warning, fg, muted) = ( - theme.accent, - theme.warning, - theme.foreground, - theme.muted_foreground, - ); + let (warning, fg, muted) = (theme.warning, theme.foreground, theme.muted_foreground); let mut row = h_flex() .flex_wrap() .items_center() @@ -479,7 +512,7 @@ impl Tty7App { for deco in &commit.refs { row = row.child(match deco.kind { RefKind::Tag => info_chip(&deco.short, warning.opacity(0.16), warning, mono), - _ if deco.is_head => info_chip(&deco.short, accent.opacity(0.28), fg, mono), + _ if deco.is_head => info_chip(&deco.short, fg.opacity(0.06), fg, mono), _ => ref_span(&deco.short, muted, mono), }); } @@ -489,11 +522,11 @@ impl Tty7App { /// The parents, as links. Following one is the only way to walk history /// backwards from a commit the graph's window does not reach. /// - /// `theme.info` and nothing else at rest — the panel's link ink, the same - /// one the "show more" fold uses a few lines above. A filled pill would - /// make the parents a second block competing with the ref chips, and this - /// is a link, not a state. The hover fill is what says the oid is a target, - /// and it is the same fill, radius and inset the header's two affordances + /// The resting row ink and nothing else at rest — no accent, the way no + /// other chrome in the panel spends one. A filled pill would make the + /// parents a second block competing with the ref chips, and this is a + /// link, not a state. The hover fill is what says the oid is a target, and + /// it is the same fill, radius and height the header's two affordances /// use. The oids themselves are token-sized mono, matching the sha in the /// header so that every object id in this view is one recognisable shape. fn detail_parents( @@ -506,19 +539,21 @@ impl Tty7App { if commit.parents.is_empty() { return None; } - let hover_bg = gpui::rgb(panel_surface(cx).hover); - let (muted, link) = { - let theme = cx.theme(); - (theme.muted_foreground, theme.info) - }; + let sf = panel_surface(cx); + let hover_bg = gpui::rgb(sf.hover); + let (muted, link) = (cx.theme().muted_foreground, gpui::rgb(sf.text_resting)); + // The label sits on the text column and each oid's pill pads back out + // by `ROW_INSET`, so the row starts one inset short of it: the first + // link's text then lines up with the label's gap, not its pill edge. let mut row = h_flex() .flex_wrap() .items_center() - .gap(px(6.)) + .gap(px(2.)) .px(px(CONTENT_INSET)) .pb(px(4.)) .child( div() + .mr(px(ROW_INSET - 2.)) .text_size(rems(META)) .text_color(muted) .child(t(L10nKey::ScmCommitParents)), @@ -527,11 +562,12 @@ impl Tty7App { let repo = detail.repo.clone(); let oid = parent.clone(); row = row.child( - div() + h_flex() .id(SharedString::from(format!("scm-detail-parent-{parent}"))) - .px(px(4.)) - .py(px(1.)) - .rounded(px(4.)) + .items_center() + .h(px(PILL_H)) + .px(px(ROW_INSET)) + .rounded(PILL_RADIUS) .cursor_pointer() .hover(|s| s.bg(hover_bg)) .text_size(rems(META_MONO)) @@ -603,30 +639,18 @@ impl Tty7App { /// in the diff views: the ASCII one sits too high and too short beside a /// `+` of the same size. /// - /// Still not `panel_subtitle`: that helper uppercases its label and puts - /// anything in its trailing slot hard against the right edge, because the - /// slot was built for a button. Both are wrong here. "3 FILES CHANGED" is - /// a heading's voice and this is a sentence about the commit, and the - /// counts are not a control off in the corner — they qualify the words and - /// have to sit next to them, which is the one thing the layout round got - /// right and the user asked to keep. + /// Still not `panel_subtitle`: that helper puts anything in its trailing + /// slot hard against the right edge, because the slot was built for a + /// button. The counts are not a control off in the corner — they qualify + /// the words and have to sit next to them, which is the one thing the + /// layout round got right and the user asked to keep. /// - /// What the helper *is* copied on is its frame: the hairline and the six - /// above it, so the file list starts on exactly the line the working tree's - /// does. A rule is how this panel divides sections; the round that replaced - /// it with a raised card is the round being undone. - /// - /// The two paddings are that frame re-derived rather than copied, because - /// the tallest line in each block is a different size. gpui leads a plain - /// `div` at phi: the helper's 10.5px uppercase label measures - /// `round(10.5 × 1.618) = 17px`, and the tallest thing in this row is the - /// 11px file count at `round(11 × 1.618) = 18`. The helper's block is - /// `6 + 1 + 12 + 17 + 4 = 40px` tall, so this one has 15px of padding to - /// spend instead of 16 — half a pixel off each side, which keeps the total - /// at 40 *and* puts both lines' optical centre 27.5px below the top of the - /// margin, so nothing shifts when the reader opens a commit. Change either - /// side's type and this has to be worked out again on both, or one list - /// quietly starts a pixel or two below the other and nobody can see why. + /// What it *is* copied on is the Changes tab's group header, which is what + /// it is: the line that heads a list of files. 16px of air above it and no + /// rule — the working tree tells its groups apart by the pause before each + /// one, and a hairline here would be the only one left in the tab — then + /// 22px of 11.5px medium, muted, sentence case. "3 files changed" is a + /// sentence about the commit, never "3 FILES CHANGED". fn detail_summary( &self, files: &[CommitFile], @@ -634,21 +658,21 @@ impl Tty7App { cx: &mut Context, ) -> AnyElement { let theme = cx.theme(); - let (muted, border) = (theme.muted_foreground, theme.border); + let muted = theme.muted_foreground; let (added_ink, removed_ink) = (theme.success, theme.danger); let counts = diff_totals(files); h_flex() .items_center() .gap(px(6.)) - .mt(px(6.)) - .border_t_1() - .border_color(border) + .mt(px(GROUP_GAP)) + .mb(px(1.)) + .min_h(rems(GROUP_HEADER_H / 16.)) .px(px(CONTENT_INSET)) - .pt(px(11.5)) - .pb(px(3.5)) .child( div() - .text_size(rems(META)) + .text_size(rems(HEADING)) + .font_weight(gpui::FontWeight::MEDIUM) + .font_features(crate::ui::diff_overlay::tabular()) .text_color(muted) .child(t_plural(L10nKey::ScmFilesChanged, files.len(), &[])), ) @@ -691,10 +715,12 @@ impl Tty7App { .id(SharedString::from(format!("scm-detail-file-{}", file.path))) .items_center() .gap(px(8.)) - .h(px(ROW_H)) + .min_h(rems(ROW_H / 16.)) + .w_full() + .min_w_0() .px(px(ROW_INSET)) .py(px(3.)) - .rounded(px(5.)) + .rounded(PILL_RADIUS) .cursor_pointer() .hover(|s| s.bg(gpui::rgb(sf.hover))) .when(selected, |s| s.bg(gpui::rgb(sf.selected))) @@ -717,9 +743,13 @@ impl Tty7App { }) }) .child(git_badge(status_glyph(deco), status_color(deco, cx), mono)) + // 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_none() + .flex_shrink(1.) + .min_w(px(NAME_FLOOR)) + .truncate() // Match the working-tree list: UI names, secondary paths. .text_size(rems(TEXT)) .text_color(if deco == DecoStatus::Conflict { @@ -733,26 +763,34 @@ impl Tty7App { .when(deco == DecoStatus::Deleted, |s| s.line_through()) .child(name.to_string()), ) - .when(!dir.is_empty(), |this| { - this.child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(rems(META)) - .text_color(cx.theme().muted_foreground.opacity(0.75)) - .child(dir.to_string()), - ) - }) + // Flush right, giving way from its *start*: the folder nearest the + // file is the part that says where it is. The same column the + // working tree's rows keep, in the plain caption ink — no extra + // transparency on top of a colour already floored for legibility. + .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()), + ) .into_any_element() } fn detail_note(&self, text: String, cx: &mut Context) -> AnyElement { div() + .flex() + .items_center() + .min_h(px(ROW_H)) + .mt(px(GROUP_GAP)) .px(px(CONTENT_INSET)) - .py(px(3.)) .text_size(rems(META)) - .text_color(cx.theme().muted_foreground.opacity(0.75)) + .text_color(cx.theme().muted_foreground) .child(text) .into_any_element() }