mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-01 08:01:47 +00:00
fix(ui): improve panel readability at narrow sizes
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user