From 75480cac054bca91ef9b138f9c14408ff37596f3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:51:59 +0800 Subject: [PATCH] fix(ui): improve panel readability at narrow sizes --- docs/design-system.md | 3 +++ src/ui/file_tree.rs | 4 +--- src/ui/presets.rs | 31 ++++++++++++++++++++---- src/ui/right_panel.rs | 35 ++++++++++++++------------- src/ui/scm/panel.rs | 40 +++++++++++++++++++++---------- src/ui/settings.rs | 55 +++++++++++++++++++++++++++++++++---------- 6 files changed, 119 insertions(+), 49 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 9bce0816..8af31419 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -58,6 +58,9 @@ - 行内辅助按钮至少保留现有 24px 命中区域;增大图标不能挤掉文本预算。 - 工具图标使用中性文字色。品牌用小图形识别,饱和色留给状态点。 - 字号跟随界面缩放;正文 14/16rem,说明 12/16rem,章节标题 16/16rem。 +- 左右侧栏的紧凑分组标题统一为 12/16rem、semibold;设置页章节标题仍为 16/16rem。Session 字段名和值同为正文尺寸,以颜色区分层级。 +- 辅助文字必须在实际的内容、侧栏、浮层背景上均达到 4.5:1;普通路径、数量和说明不再叠加透明度。静态 PID 使用中性文字。 +- 放大界面字号时,Git 行高与设置导航宽度同步调整;窄侧栏优先保留分支名,长文件名须明确省略并可查看完整路径。 - 选中名称用 medium,章节标题用 semibold;正文和辅助说明保持 regular。 - 以 4px 为间距基础:紧密内容 4–8,行内 8–12,区块 24–32。列表外边距与文字预算同步。 - 控件和列表行圆角 6–8px;浮层 12px。除头像/状态点外,避免胶囊泛滥。 diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index cf33ad58..b0683b75 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1807,9 +1807,7 @@ impl Tty7App { cx.theme().sidebar_foreground }) .when(selected, |d| d.font_weight(gpui::FontWeight::MEDIUM)) - .when(row.entry.ignored, |d| { - d.italic().text_color(muted.opacity(0.7)) - }) + .when(row.entry.ignored, |d| d.italic().text_color(muted)) // Ordinary changes belong in the status badge. Only a conflict // should turn an entire filename into an attention signal. .when_some(deco.tint, |d, status| { diff --git a/src/ui/presets.rs b/src/ui/presets.rs index 60f93a0a..438db55e 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -205,6 +205,14 @@ impl Theme { }; let border = hairline(0.16, BORDER_FLOOR); let divider = hairline(0.16, DIVIDER_FLOOR); + // This shared caption role is painted on all three opaque surfaces. + // Calibrating only against the window loses contrast on raised fills. + let muted_foreground = [bg, sidebar, popover] + .into_iter() + .fold(dim(fg, bg, state::TEXT_RESTING), |ink, surface| { + legible_ink(surface, ink, TEXT_FLOOR) + }); + let title_floor = TITLE_FLOOR.max(contrast(muted_foreground, sidebar) * state::TEXT_STEP); Neutrals { background: bg, foreground: fg, @@ -212,7 +220,7 @@ impl Theme { divider, secondary: mix(bg, fg, 0.09), muted: mix(bg, fg, 0.06), - muted_foreground: dim(fg, bg, state::TEXT_RESTING), + muted_foreground, popover, caret: legible_ink(bg, self.caret.unwrap_or(self.accent), ACCENT_FLOOR), selection: self.selection.unwrap_or_else(|| mix(bg, fg, 0.20)), @@ -227,14 +235,14 @@ impl Theme { // 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); + let title = legible_ink(sidebar, mix(fg, bg, 0.10), 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 { + 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, } @@ -2118,6 +2126,21 @@ mod tests { } } + #[test] + fn captions_are_readable_on_every_surface_they_are_used_on() { + for theme in builtins() { + let m = theme.neutrals(); + for surface in [m.background, m.sidebar, m.popover] { + assert!( + contrast(m.muted_foreground, surface) >= TEXT_FLOOR, + "{}: caption {:#08x} on {surface:#08x}", + theme.id, + m.muted_foreground, + ); + } + } + } + #[test] fn sidebar_title_is_stepped_off_its_caption() { // The three rungs of a sidebar row — title, branch line, group diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 797b4e1b..6cb31927 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -42,9 +42,8 @@ pub(crate) const TEXT_MONO: f32 = TEXT - STEP; pub(crate) const META: f32 = 12. * STEP; pub(crate) const META_MONO: f32 = META - STEP; -/// Uppercase section headings. Deliberately below `META` — it matches the tab -/// sidebar's group headings, which are the same thing one panel over. -pub(crate) const HEADING: f32 = 11. * STEP; +/// Compact section headings share the sidebar group-label size. +pub(crate) const HEADING: f32 = META; /// The leading glyph on a panel row — the file tree's folder and file marks. /// @@ -636,7 +635,7 @@ impl Tty7App { div() .text_size(rems(META_MONO)) .font_family(cx.theme().mono_font_family.clone()) - .text_color(cx.theme().muted_foreground.opacity(0.75)) + .text_color(cx.theme().muted_foreground) .child(c), ) }), @@ -731,7 +730,7 @@ impl Tty7App { .children(hint.map(|h| { div() .text_size(rems(META)) - .text_color(muted.opacity(0.75)) + .text_color(muted) .child(h.to_string()) })) .into_any_element() @@ -1053,7 +1052,7 @@ impl Tty7App { div() .flex_none() .w(label_w) - .text_size(rems(META)) + .text_size(rems(TEXT)) .whitespace_nowrap() .text_color(cx.theme().muted_foreground) .child(row.label), @@ -1093,7 +1092,9 @@ impl Tty7App { ) -> AnyElement { h_flex() .when(divider, |d| { - d.mt(px(6.)).border_t_1().border_color(cx.theme().border) + d.mt(px(6.)) + .border_t_1() + .border_color(cx.theme().sidebar_border) }) .items_center() .justify_between() @@ -1111,9 +1112,7 @@ impl Tty7App { })) .pb(px(if trailing.is_some() { 0. } else { 4. })) .child( - // A group header sits below the panel's own title in the - // hierarchy, so it sits below it in the ramp too: the smallest - // step, carried by weight and caps rather than by size. + // Weight and capitalization distinguish compact group headings. div() .text_size(rems(HEADING)) .font_weight(gpui::FontWeight::SEMIBOLD) @@ -1156,12 +1155,14 @@ impl Tty7App { .when(!p.foreground, |d| d.text_color(cx.theme().muted_foreground)) .child(p.name.clone()), ) - .child(info_chip( - &p.pid.to_string(), - cx.theme().accent, - cx.theme().muted_foreground, - &mono, - )), + .child( + div() + .flex_none() + .text_size(rems(META_MONO)) + .font_family(mono.clone()) + .text_color(cx.theme().muted_foreground) + .child(p.pid.to_string()), + ), ); } Some( @@ -1329,7 +1330,7 @@ impl Tty7App { .flex_none() .text_size(rems(META_MONO)) .font_family(mono.clone()) - .text_color(cx.theme().muted_foreground.opacity(0.8)) + .text_color(cx.theme().muted_foreground) .child(format!("→ :{local}")) })) .child(actions), diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 7954aa25..63013637 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -301,7 +301,9 @@ impl Tty7App { status.upstream.as_deref(), status.ahead_behind, unpushable.is_none(), - ) { + ) + .filter(|_| status.upstream.is_some()) + { notes.push(branch_note(&text, muted, &mono)); } @@ -313,7 +315,7 @@ impl Tty7App { // sync tile at the end plus 2px of air. The branch trigger beside // it is the same 24, so the row has one interior height and the // type sits inside it rather than setting it. - .h(px(28.)) + .min_h(rems(28. / 16.)) .pl(px(CONTENT_INSET)) .pr(px(crate::ui::app::tile_trailing_inset_sm())) .child( @@ -347,7 +349,7 @@ impl Tty7App { // branch name is wider than the row, and what got pushed off the // end was the sync tile: gone entirely, with no way to reach it. .child( - div().flex_1().min_w(px(0.)).child( + div().flex_1().min_w(rems(3.)).child( Button::new("scm-branch") .ghost() .small() @@ -368,7 +370,7 @@ impl Tty7App { .child(head_label(&status.head)), ) .w_full() - .h(px(24.)) + .h(rems(24. / 16.)) .rounded(px(5.)) .text_color(fg) .when(detached, |s| s.font_family(mono.clone())) @@ -1339,7 +1341,7 @@ impl Tty7App { .px(px(ROW_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() } @@ -1363,7 +1365,7 @@ impl Tty7App { .relative() .items_center() .gap(px(8.)) - .h(px(ROW_H)) + .min_h(rems(ROW_H / 16.)) .px(px(ROW_INSET)) .rounded(px(5.)) .cursor_pointer() @@ -1404,7 +1406,7 @@ impl Tty7App { .flex_none() .text_size(rems(META_MONO)) .font_family(mono) - .text_color(cx.theme().muted_foreground.opacity(0.75)) + .text_color(cx.theme().muted_foreground) .child(count.to_string()), ) .child(actions) @@ -1442,7 +1444,9 @@ impl Tty7App { .relative() .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.)) @@ -1463,6 +1467,12 @@ impl Tty7App { ); }) }) + .tooltip({ + let full_path = path.clone(); + move |window, cx| { + gpui_component::tooltip::Tooltip::new(full_path.clone()).build(window, cx) + } + }) .context_menu({ let app = cx.entity().downgrade(); let repo = repo.clone(); @@ -1475,7 +1485,9 @@ impl Tty7App { // Names use the same resting/selected hierarchy as sidebar rows. .child( div() - .flex_none() + .flex_1() + .min_w_0() + .truncate() .text_size(rems(crate::ui::right_panel::TEXT)) .text_color(if deco == DecoStatus::Conflict { status_color(deco, cx) @@ -1488,16 +1500,18 @@ impl Tty7App { .when(deco == DecoStatus::Deleted, |s| s.line_through()) .child(name.to_string()), ) - // The directory gives way first: which file it is matters more - // than where it lives, and the name is already the shorter half. + // 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_1() + .flex_none() + .max_w(rems(3.)) .min_w_0() .truncate() .text_size(rems(META)) - .text_color(cx.theme().muted_foreground.opacity(0.75)) + .text_color(cx.theme().muted_foreground) .child(dir.to_string()), ) }) diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 55751409..f4231008 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -130,11 +130,24 @@ struct SettingsColumns { /// nothing is not a `min_w` the row cannot honour — a floor a flex row cannot /// meet does not push back, it overflows, and overflow here means content /// painted off the edge of the window, which is the other half of this bug. +#[cfg(test)] fn settings_columns( section: SettingsSection, theme_panel_open: bool, viewport: f32, ) -> SettingsColumns { + settings_columns_scaled(section, theme_panel_open, viewport, 1.) +} + +fn settings_columns_scaled( + section: SettingsSection, + theme_panel_open: bool, + viewport: f32, + scale: f32, +) -> SettingsColumns { + // Reserve the same readable label width when the interface font grows. + let nav_width = NAV_W * scale.max(1.); + let nav_floor = NAV_W_MIN * scale.max(1.); let ssh = matches!(section, SettingsSection::Ssh); // The panel belongs to Appearance; a stale open flag on any other page is // not a column, the same way `render_settings` does not draw one. @@ -147,14 +160,14 @@ fn settings_columns( // column — this is the one place the *floor* is the test, because the panel // leaving the row is what buys the page its preferred width back. let panel_overlays = - theme_panel_open && viewport - NAV_W_MIN - THEME_PANEL_W_MIN - PAGE_PAD < CONTENT_MIN_W; + theme_panel_open && viewport - nav_floor - THEME_PANEL_W_MIN - PAGE_PAD < CONTENT_MIN_W; let beside = theme_panel_open && !panel_overlays; - let mut nav = NAV_W; + let mut nav = nav_width; let mut ssh_list = only_when(ssh, SSH_LIST_W); let mut theme_panel = only_when(beside, THEME_PANEL_W); let (nav_slack, list_slack, panel_slack) = ( - NAV_W - NAV_W_MIN, + nav_width - nav_floor, only_when(ssh, SSH_LIST_W - SSH_LIST_W_MIN), only_when(beside, THEME_PANEL_W - THEME_PANEL_W_MIN), ); @@ -193,7 +206,7 @@ fn settings_row_width( viewport: f32, ui_scale: f32, ) -> f32 { - let cols = settings_columns(section, theme_panel_open, viewport); + let cols = settings_columns_scaled(section, theme_panel_open, viewport, ui_scale); let panel = only_when(!cols.panel_overlays, cols.theme_panel); match section { SettingsSection::Ssh => (viewport - cols.nav - cols.ssh_list - SSH_DETAIL_PAD).max(0.), @@ -2487,7 +2500,7 @@ impl Tty7App { let viewport_w = window.viewport_size().width.as_f32(); let ui_scale = ui_scale(cx); self.settings_viewport_w.set(viewport_w); - let cols = settings_columns(layout_section, show_theme_panel, viewport_w); + let cols = settings_columns_scaled(layout_section, show_theme_panel, viewport_w, ui_scale); self.settings_row_width.set(settings_row_width( layout_section, show_theme_panel, @@ -3021,7 +3034,7 @@ impl Tty7App { /// The column widths this render settled on. `settings_columns` is pure and /// cheap, so the two pages that draw chrome of their own work them out /// again rather than have the answer threaded through every builder. - fn settings_columns_now(&self) -> SettingsColumns { + fn settings_columns_now(&self, cx: &App) -> SettingsColumns { let (section, panel_open) = match self.active_settings() { Some(s) => ( s.section, @@ -3029,7 +3042,12 @@ impl Tty7App { ), None => (SettingsSection::Appearance, false), }; - settings_columns(section, panel_open, self.settings_viewport_w.get()) + settings_columns_scaled( + section, + panel_open, + self.settings_viewport_w.get(), + ui_scale(cx), + ) } /// Whether the row measured this render came out narrower than a threshold @@ -3246,9 +3264,6 @@ impl Tty7App { .px_3() .mx_neg_3() .rounded(rounding::CARD_RADIUS) - .when(!hit, |row| { - row.hover(|s| s.bg(theme.secondary.opacity(0.45))) - }) .when(hit, |row| row.bg(theme.accent)) // Only the first hit on the page carries the anchor: it is the one // the page scrolls to, and a later row claiming it would drag the @@ -4018,7 +4033,7 @@ impl Tty7App { .child( v_flex() .flex_shrink_0() - .w(px(self.settings_columns_now().ssh_list)) + .w(px(self.settings_columns_now(cx).ssh_list)) .h_full() .border_r_1() .border_color(border) @@ -8212,7 +8227,7 @@ impl Tty7App { } v_flex() - .w(px(self.settings_columns_now().theme_panel)) + .w(px(self.settings_columns_now(cx).theme_panel)) .h_full() .flex_shrink_0() .bg(bg) @@ -9402,6 +9417,22 @@ mod tests { /// The row keeps its side-by-side shape while both halves fit, and stacks /// once they do not. The SSH page reaches that point first — it spends its /// host list before the row gets anything. + #[test] + fn enlarged_settings_text_gets_room_in_the_navigation() { + for scale in [1.25, 1.5] { + let columns = settings_columns_scaled(SettingsSection::Appearance, false, 1440., scale); + assert!(columns.nav >= NAV_W_MIN * scale); + let row = settings_row_width(SettingsSection::Appearance, false, 1440., scale); + assert!(row >= STACK_ROW_BELOW * scale); + let with_picker = + settings_columns_scaled(SettingsSection::Appearance, true, 720., scale); + assert!( + with_picker.panel_overlays, + "the picker must not squeeze enlarged labels" + ); + } + } + #[test] fn a_row_stacks_once_its_label_and_control_stop_fitting() { use SettingsSection::*;