fix(ui): improve panel readability at narrow sizes

This commit is contained in:
l0ng-ai
2026-09-23 16:51:59 +08:00
parent 60296567f9
commit 75480cac05
6 changed files with 119 additions and 49 deletions
+3
View File
@@ -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。除头像/状态点外,避免胶囊泛滥。
+1 -3
View File
@@ -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| {
+27 -4
View File
@@ -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
+18 -17
View File
@@ -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),
+27 -13
View File
@@ -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()),
)
})
+43 -12
View File
@@ -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::*;