From f1648117b7f5088a9867407cc0e69a1057d32b23 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:35:28 +0800 Subject: [PATCH] feat(ui): restyle the workspace after the v5 design - Rails share the window fill and are separated by a hairline only; the current tab is a neutral selected step plus a semibold title instead of the blue navigation wash (the unused role is removed). - Default Light theme takes ink #0F1419 and accent #1F6BF0; the caret follows the accent. - Sidebar: 28px pill rows with 18px avatars (two-line rows keep card corners), pill search field and workspace switcher, group headings in body ink with the chevron shown only when folded. - Right panel: word tabs (Info / Changes / Files) with an underline bar on a closing hairline. The Changes count is dropped before any label is cut, and the panel floor grows with the measured labels so the chrome tiles are never pushed off the edge. - Headings are no longer uppercased; popovers use a 10px radius. - docs/design-system.md updated to match. --- docs/design-system.md | 25 +++-- src/ui/app.rs | 2 +- src/ui/i18n/en.rs | 4 +- src/ui/i18n/ja.rs | 4 +- src/ui/i18n/zh.rs | 4 +- src/ui/presets.rs | 76 +++++-------- src/ui/right_panel.rs | 141 +++++++++++++++++------- src/ui/rounding.rs | 6 +- src/ui/tab_sidebar.rs | 71 +++++++----- src/ui/tab_strip.rs | 246 ++++++++++++++++++++++++++---------------- src/ui/theme.rs | 8 +- 11 files changed, 364 insertions(+), 223 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 8af31419..ea634160 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -12,22 +12,22 @@ 4. **说明与元数据**:路径、分支、数量、快捷键、设置描述。次级文字,不用饱和色抢正文。 5. **异常**:失败、冲突、需要处理的警告。局部状态色,加文字或形状,不单靠颜色。 -同屏不能把所有选中项都画成主按钮。工作区使用淡强调色;右侧面板当前页签使用中性底; -真正可执行的主按钮才使用实色强调色。每个任务区只突出一个主操作。 +同屏不能把所有选中项都画成主按钮。当前标签页用中性灰底 + semibold;右侧面板当前页签用 +正文色文字 + 底部 2px 横条;真正可执行的主按钮才使用实色强调色。每个任务区只突出一个主操作。 ## 颜色角色 | 角色 | 使用位置 | 规则 | | --- | --- | --- | | `Neutrals.background` | 工作内容、设置内容、输入框 | 安静的基础表面。输入框未聚焦时不能像已选中列表行 | -| `Neutrals.sidebar` | 两侧导航 | 与内容轻微分层;用细分隔线补充边界 | +| `Neutrals.sidebar` | 两侧导航 | 与内容同色,连成一整块表面;只用细分隔线划界 | | `Neutrals.popover` | 菜单、命令面板、对话框 | 浅色下保持明亮,深色下抬高亮度;只给浮层阴影 | | `foreground` | 正文、标题、当前项 | 正常文字至少 4.5:1;不把普通正文全部加粗 | | `muted_foreground` | 描述、路径、快捷键 | 与主文字明确区分,但仍保持可读性 | | `border` / `divider` | 控件边界 / 区域分隔 | 控件边界较强,区域分隔较弱;不能把页面画成表格线框 | | `Interactions.input_border` | 输入框与普通按钮轮廓 | 对应组件库的 `input` 边框角色,不能误用内容背景色导致边界消失 | | `Interactions.primary` | 保存、连接、有效的提交等主操作 | 强调色实底,成对配置前景色;hover/pressed 保持标签对比度 | -| `Interactions.navigation` | 当前标签页、设置导航 | 低饱和淡色底 + 清晰文字;不使用主按钮实色 | +| `Surfaces.sidebar.selected` | 当前标签页 | 中性 selected 阶 + semibold;不用强调色,蓝色留给可按的东西 | | `Interactions.choice` | 菜单、命令面板的键盘当前行 | 强调色浅底,区别于鼠标经过的中性 hover | | 中性 `secondary` | 面板页签、分段选项、普通按钮 | 次级导航不能与主要操作争夺注意力 | | `Semantics.danger` | 失败、Git 冲突、破坏性动作 | 红;必须同时有文字/图标/确认语义 | @@ -35,7 +35,7 @@ | `Semantics.success` | 成功、Git 新增标记 | 绿;不用于装饰性按钮 | | `Semantics.info/link` | 信息、可点击链接 | 使用当前主题的强调色,与焦点指示同族 | -默认浅色以白/浅灰为底,蓝色 `#007AFF` 为强调色。 +默认浅色以纯白为底,正文 `#0F1419`,蓝色 `#1F6BF0` 为强调色(光标同强调色)。 默认深色以 `#191B20` 为内容底,`#E2E5EB` 为正文,`#78A8F5` 为强调色。 其他主题保留自己的背景与强调色,但红/绿/琥珀的**状态含义**不随 ANSI 槽位变化。 所有文字、状态色和主按钮前景色须在实际背景上验证对比度。 @@ -44,7 +44,7 @@ - Rest:中性、稳定。工具按钮不因鼠标移出而消失。 - Hover:轻微中性填充,只用于可交互目标。设置说明行不整体发亮。 -- Selected:表达持久的当前位置。工作区用淡强调色,辅助页签用中性底。 +- Selected:表达持久的当前位置。侧栏当前行用中性底 + semibold,右侧页签用文字色 + 底部横条。 - Keyboard focus:清晰的强调色焦点环/当前行,不能仅靠 hover 表达。 - Pressed:同一色系的短暂加深,不能跳到另一个主题色。 - Disabled:保留边界与标签;降低对比,不能像可执行的主按钮。 @@ -61,14 +61,21 @@ - 左右侧栏的紧凑分组标题统一为 12/16rem、semibold;设置页章节标题仍为 16/16rem。Session 字段名和值同为正文尺寸,以颜色区分层级。 - 辅助文字必须在实际的内容、侧栏、浮层背景上均达到 4.5:1;普通路径、数量和说明不再叠加透明度。静态 PID 使用中性文字。 - 放大界面字号时,Git 行高与设置导航宽度同步调整;窄侧栏优先保留分支名,长文件名须明确省略并可查看完整路径。 -- 选中名称用 medium,章节标题用 semibold;正文和辅助说明保持 regular。 +- 侧栏当前标签页标题用 semibold(测宽与渲染同一字重),章节/分组标题用 semibold;正文和辅助说明保持 regular。 +- 右侧面板和侧栏的紧凑标题不再大写,靠字重与颜色区分层级。 - 以 4px 为间距基础:紧密内容 4–8,行内 8–12,区块 24–32。列表外边距与文字预算同步。 -- 控件和列表行圆角 6–8px;浮层 12px。除头像/状态点外,避免胶囊泛滥。 +- 形状(2026-09 v5 风格):单行导航行、侧栏搜索框、工作区切换用胶囊 + (`rounding::PILL_RADIUS`);图标按钮保持圆角方形(`rounded_lg` / 小号 4px);会长出第二行(分支行)的标签页行保持 + `CARD_RADIUS`,全圆角会把两行文字的两端挤窄。浮层 `POPOVER_RADIUS` 10px。 +- 侧栏单行标签页行高 28px,头像 18px 圆形品牌底;搜索框与工作区切换同为 28px。 ## 各界面约束 -- **侧栏**:名称优先于分支和 diff 数量;当前项清晰,品牌头像不形成彩色墙。 +- **侧栏**:名称优先于分支和 diff 数量;当前项清晰,品牌头像不形成彩色墙。分组标题用正文色 + semibold,分支与 diff 数量右对齐作次级信息;展开的分组不画 chevron,折叠后才显示。 - **侧栏顶部**:导航与内容用留白衔接,不加贯穿的 header 底部分隔线;左右侧栏保持连续表面。 +- **右侧面板页签**:文字页签(信息 / 更改 / 文件),当前页签正文色 + 底部 2px 横条落在 + 页签栏底部的细分隔线上;「更改」后附改动数。 - **右侧文字层级**:文件树名称使用侧栏标题色,不能整体降为辅助灰;当前项使用 selected 文字色与 medium 字重,根目录以字重区分。Git 普通文件名使用 resting 文字色与界面字体,路径、数量与说明保持次级;信息页的路径前缀弱化,末级名称突出。 - **文件树**:文件名中性;A/M/? 等尾部字符承载 Git 状态;目录用小点表示子项变化;冲突才整行提升。 - **Git 面板**:改动列表是主体。可执行的提交按钮才突出;未填写信息或不可提交时保持次级。 diff --git a/src/ui/app.rs b/src/ui/app.rs index f05426fe..b22ffd4c 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -10139,7 +10139,7 @@ mod tests { // Both panels pinned to their floors leaves the terminal the rest — // less than its floor, but more than the 260-odd points two saved // widths used to leave it. - assert!(narrow - SIDEBAR_MIN - PANEL_MIN > 300.); + assert!(narrow - SIDEBAR_MIN - PANEL_MIN >= 300.); } #[test] diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 0c1e8dc8..d0e8621a 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1105,8 +1105,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorFileTooLarge => "\"{path}\" is too large for the editor ({size} MB)", L10nKey::EditorBinaryFile => "\"{path}\" looks like a binary file", L10nKey::PanelInfoTitle => "Info", - L10nKey::PanelChangesTitle => "Source Control", - L10nKey::PanelScmTitle => "Source Control", + L10nKey::PanelChangesTitle => "Changes", + L10nKey::PanelScmTitle => "Changes", L10nKey::PanelFilesTitle => "Files", L10nKey::PanelNoSession => "No active session.", L10nKey::PanelNoSessionHint => { diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 3efdcd73..56470abf 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1170,8 +1170,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorFileTooLarge => "「{path}」はエディタで開くには大きすぎます({size} MB)", L10nKey::EditorBinaryFile => "「{path}」はバイナリファイルのようです", L10nKey::PanelInfoTitle => "情報", - L10nKey::PanelChangesTitle => "ソース管理", - L10nKey::PanelScmTitle => "ソース管理", + L10nKey::PanelChangesTitle => "変更", + L10nKey::PanelScmTitle => "変更", L10nKey::PanelFilesTitle => "ファイル", L10nKey::PanelNoSession => "アクティブなセッションがありません", L10nKey::PanelNoSessionHint => { diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 0c96789f..82d96fb2 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1051,8 +1051,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorFileTooLarge => "“{path}”太大,无法在编辑器中打开({size} MB)", L10nKey::EditorBinaryFile => "“{path}”看起来是二进制文件", L10nKey::PanelInfoTitle => "信息", - L10nKey::PanelChangesTitle => "源代码管理", - L10nKey::PanelScmTitle => "源代码管理", + L10nKey::PanelChangesTitle => "更改", + L10nKey::PanelScmTitle => "更改", L10nKey::PanelFilesTitle => "文件", L10nKey::PanelNoSession => "没有活动会话。", L10nKey::PanelNoSessionHint => "打开一个标签页以在此处查看其 shell、目录和进程。", diff --git a/src/ui/presets.rs b/src/ui/presets.rs index 438db55e..273364fb 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -85,8 +85,6 @@ pub struct Interactions { pub primary: Semantic, pub primary_hover: u32, pub primary_pressed: u32, - pub navigation: u32, - pub navigation_ink: u32, pub choice: u32, pub choice_ink: u32, pub input_border: u32, @@ -101,16 +99,6 @@ pub mod state { pub const CURSOR: f32 = 1.70; pub const TEXT_RESTING: f32 = 4.6; pub const TEXT_STEP: f32 = 1.4; - - /// The sidebar's selection ladder sits one rung above the window's. The - /// window paints a selected row inside a list the user is already looking - /// at; the sidebar paints the one tab out of twenty that owns the pane - /// area, and at 1.30:1 that tint measured as the faintest mark in the - /// column — fainter than a group header's count. `PRESSED` and `CURSOR` - /// climb with it so the ladder keeps its spacing. - pub const SIDEBAR_SELECTED: f32 = 1.50; - pub const SIDEBAR_PRESSED: f32 = 1.75; - pub const SIDEBAR_CURSOR: f32 = 1.92; } #[derive(Debug, Clone, Copy)] @@ -119,6 +107,9 @@ pub struct Surface { pub hover: u32, pub selected: u32, pub pressed: u32, + /// The top rung. No surface paints it today; the ladder tests hold the + /// rungs below it apart against it. + #[cfg_attr(not(test), allow(dead_code))] pub cursor: u32, pub text_resting: u32, pub text_selected: u32, @@ -177,7 +168,10 @@ impl Theme { pub fn neutrals(&self) -> Neutrals { let bg = self.background_color(); let fg = legible_foreground(bg, self.foreground); - let sidebar = mix(bg, fg, 0.03); + // One continuous surface: the rails share the window's fill and are + // told apart from the pane area by a hairline alone. A tinted rail + // made the workspace read as three boxes set side by side. + let sidebar = bg; // Light overlays sit above the grey rail; dark overlays lift toward // the foreground. A light menu must not be darker than its backdrop. let popover = if self.dark { mix(bg, fg, 0.06) } else { bg }; @@ -267,15 +261,8 @@ impl Theme { } } - /// One selection treatment for workspace, settings and panel navigation. - pub(crate) fn navigation_colors(&self) -> (u32, u32) { - let colors = self.interactions(); - (colors.navigation, colors.navigation_ink) - } - pub(crate) fn interactions(&self) -> Interactions { let m = self.neutrals(); - let navigation = mix(m.sidebar, 0x2878df, if self.dark { 0.28 } else { 0.14 }); // Menus use a blue wash; the switcher uses the solid companion fill. // Keep labels and shortcut hints readable on the shared menu surface. let choice = mix(m.popover, 0x2878df, if self.dark { 0.34 } else { 0.20 }); @@ -296,8 +283,6 @@ impl Theme { }, primary_hover: mix(fill, away, 0.08), primary_pressed: mix(fill, away, 0.16), - navigation, - navigation_ink: legible_foreground(navigation, m.foreground), choice, choice_ink: legible_foreground(choice, m.foreground), input_border: m.border, @@ -392,10 +377,12 @@ impl Theme { pub fn surfaces(&self) -> Surfaces { let m = self.neutrals(); let fg = legible_foreground(self.background_color(), self.foreground); + // The rail climbs the window's ladder. It used to take a rung above + // (1.50) because a regular-weight current tab measured as the faintest + // mark in its column; the current title is SEMIBOLD now and carries + // that on its own, and at 1.50 on a white rail the row read as a + // pressed button rather than as a place. let mut sidebar = self.surface(m.sidebar); - sidebar.selected = raise(sidebar.base, fg, state::SIDEBAR_SELECTED); - sidebar.pressed = raise(sidebar.base, fg, state::SIDEBAR_PRESSED); - sidebar.cursor = raise(sidebar.base, fg, state::SIDEBAR_CURSOR); sidebar.text_resting = m.sidebar_fg; sidebar.text_selected = stepped_ink(sidebar.selected, sidebar.base, fg, sidebar.text_resting); @@ -1214,9 +1201,9 @@ static BUILTINS: [BuiltinSpec; 13] = [ id: "light", name: "Light", background: 0xffffff, - foreground: 0x111111, - accent: 0x007aff, - caret: Some(0xf5a15c), + foreground: 0x0f1419, + accent: 0x1f6bf0, + caret: None, ansi16: [ (0x24, 0x29, 0x2e), (0xd1, 0x24, 0x2f), @@ -1557,15 +1544,15 @@ mod tests { use super::*; #[test] - fn navigation_labels_remain_legible_in_every_builtin_theme() { + fn the_current_tab_row_stays_legible_in_every_builtin_theme() { for theme in builtins() { - let (fill, ink) = theme.navigation_colors(); + let s = theme.surfaces().sidebar; assert!( - contrast(fill, ink) >= 4.5, - "{} navigation contrast", + contrast(s.selected, s.text_selected) >= 4.5, + "{} current tab contrast", theme.id ); - assert_ne!(fill, theme.neutrals().sidebar, "{} selection", theme.id); + assert_ne!(s.selected, s.base, "{} selection", theme.id); } } @@ -1794,16 +1781,16 @@ mod tests { let bg = dracula.background_color(); let s = dracula.surfaces(); // The resting rung is checked as `state::SELECTED` on the sidebar - // fill — the surface it was signed off on — rather than as the rail's - // own fill: the rail was lifted off this value on purpose - // (`state::SIDEBAR_SELECTED`) once the tab that owns the pane area - // measured as the faintest mark in its own column, and this pin is - // here to catch the constant drifting, not that decision. + // fill it was signed off on — the old rail, 3% toward the foreground — + // rather than on today's rail, which has since gone flat onto the + // window fill. This pin is here to catch the constant drifting, not + // that decision. let fg = legible_foreground(bg, dracula.foreground); + let signed_off_rail = mix(bg, fg, 0.03); for (what, now, legacy) in [ ( "resting", - raise(s.sidebar.base, fg, state::SELECTED), + raise(signed_off_rail, fg, state::SELECTED), mix(bg, dracula.foreground, 0.12), ), ("cursor", s.window.cursor, mix(bg, dracula.foreground, 0.17)), @@ -2113,11 +2100,6 @@ mod tests { "{} choice", theme.id ); - assert!( - contrast(c.navigation, c.navigation_ink) >= 4.5, - "{} navigation", - theme.id - ); assert_ne!( c.input_border, c.choice, "{} field boundaries must not use selected-row fills", @@ -2215,10 +2197,10 @@ mod tests { m.caret ); } - // The default theme is the one that used to fail: an orange caret on - // pure white read at 2.07:1. + // The default theme used to ship an orange caret that read at 2.07:1 + // on pure white. It now takes the accent, like the focus ring. let light = builtins().into_iter().find(|t| t.id == DEFAULT_ID).unwrap(); - assert_ne!(light.neutrals().caret, light.caret.unwrap()); + assert_eq!(light.neutrals().caret, light.neutrals().accent); } #[test] diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 0702ab7d..2d2c4672 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -16,7 +16,26 @@ use crate::ui::app::{ use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::scrollbar::with_vertical_scrollbar; -pub(crate) const MIN_WIDTH: f32 = 216.; +/// Wide enough for the three word tabs, bare, beside the two chrome tiles at +/// the default interface size — the Changes count is dropped before a label is +/// ever cut. A larger UI font raises the floor past this; see +/// [`right_panel_tabs_floor`]. +pub(crate) const MIN_WIDTH: f32 = 240.; + +/// What the tab row needs with the count dropped: the labels, the row's lead, +/// its 2px gaps, and the chrome tiles at the trailing end. +fn right_panel_tabs_floor(window: &Window, cx: &gpui::App) -> f32 { + let chrome = match cfg!(target_os = "macos") { + true => crate::ui::tab_strip::trailing_chrome_tiles_w() + TAB_ROW_GAPS, + false => tile_trailing_inset(), + }; + (TAB_ROW_LEAD + crate::ui::tab_strip::right_panel_tab_labels_w(window, cx) + chrome).ceil() +} + +/// The tab row's leading inset, and the 2px gaps it puts between the three +/// tabs, the spacer and the chrome. +const TAB_ROW_LEAD: f32 = 4.; +const TAB_ROW_GAPS: f32 = 4. * 2.; /// How wide a panel edge is to grab. Both edges a window can drag — the tab /// sidebar's and this panel's — are the same target, so they are one number. @@ -385,18 +404,26 @@ impl Tty7App { } } + /// The narrowest the panel may be drawn: `MIN_WIDTH`, or wider when the + /// interface font or the language makes the three tab labels and the two + /// chrome tiles beside them need more than that. + pub(crate) fn right_panel_min_px(&self, window: &Window, cx: &gpui::App) -> f32 { + MIN_WIDTH.max(right_panel_tabs_floor(window, cx)) + } + pub(crate) fn right_panel_max_px(&self, window: &Window, cx: &gpui::App) -> f32 { crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), - MIN_WIDTH, + self.right_panel_min_px(window, cx), self.sidebar_floor(cx) + self.document_floor(cx), ) } pub(crate) fn right_panel_px(&self, window: &Window, cx: &gpui::App) -> f32 { - self.right_panel_width - .get() - .clamp(MIN_WIDTH, self.right_panel_max_px(window, cx)) + self.right_panel_width.get().clamp( + self.right_panel_min_px(window, cx), + self.right_panel_max_px(window, cx), + ) } pub(crate) fn toggle_right_panel(&mut self, cx: &mut Context) { @@ -439,7 +466,7 @@ impl Tty7App { RightPanelTab::Scm => self.render_panel_scm(window, cx), RightPanelTab::Files => self.render_panel_files(window, cx), }; - let (backing, handle) = self.right_panel_resize(cx); + let (backing, handle) = self.right_panel_resize(window, cx); Some( v_flex() @@ -466,16 +493,25 @@ impl Tty7App { .on_double_click(|_, window, _| window.titlebar_double_click()) .items_center() .gap(px(2.)) - .pl(px(tile_trailing_inset())) + .pl(px(TAB_ROW_LEAD)) + // The tab bar is a strip of labels over a body, so it + // closes on a hairline the current tab's bar sits on. + .border_b_1() + .border_color(cx.theme().sidebar_border) .relative() - .children(self.right_panel_tabs(cx)) + .children(self.right_panel_tabs( + width + - TAB_ROW_LEAD + - crate::ui::tab_strip::trailing_chrome_tiles_w() + - TAB_ROW_GAPS, + window, + cx, + )) .child(div().flex_1()) // Navigation controls stay visible on both sidebars. .child(self.window_chrome(window, cx)) })) - // Separate navigation from content with space, matching the - // left sidebar's continuous surface. - .children(cfg!(target_os = "macos").then(|| div().flex_none().h(px(8.)))) + .children(cfg!(target_os = "macos").then(|| div().flex_none().h(px(10.)))) .child(body) .children(self.sftp_transfers_footer(cx)) .child(handle) @@ -483,7 +519,11 @@ impl Tty7App { ) } - fn right_panel_resize(&self, cx: &mut Context) -> (AnyElement, AnyElement) { + fn right_panel_resize( + &self, + window: &Window, + cx: &mut Context, + ) -> (AnyElement, AnyElement) { use gpui::{Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, canvas}; use std::cell::Cell as StdCell; use std::rc::Rc; @@ -494,6 +534,7 @@ impl Tty7App { // be the same one the layout applies or the panel springs back from // wherever it was dropped. let others_floor = self.sidebar_floor(cx) + self.document_floor(cx); + let own_floor = self.right_panel_min_px(window, cx); let backing = canvas( { let container = container.clone(); @@ -519,10 +560,10 @@ impl Tty7App { let raw = (right - ev.position.x).as_f32(); let max = crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), - MIN_WIDTH, + own_floor, others_floor, ); - width_cell.set(raw.clamp(MIN_WIDTH, max)); + width_cell.set(raw.clamp(own_floor, max)); window.refresh(); } }); @@ -590,8 +631,16 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) -> AnyElement { - let tabs = (!cfg!(target_os = "macos")).then(|| self.right_panel_tabs(cx)); let has_trailing = trailing.is_some(); + let tabs = (!cfg!(target_os = "macos")).then(|| { + let width = self.right_panel_px(window, cx); + let trailing_w = match has_trailing { + true => 2. * TILE_SIZE_SM + 6., + false => 0., + }; + let avail = width - TAB_ROW_LEAD - tile_trailing_inset() - trailing_w; + self.right_panel_tabs(avail, window, cx) + }); if tabs.is_none() && !has_trailing { return div().flex_none().into_any_element(); } @@ -614,33 +663,43 @@ impl Tty7App { (None, true) => tile_trailing_inset_sm(), (None, false) => CONTENT_INSET, })) - .child( - h_flex() - .flex_shrink_0() - .items_baseline() - .gap(px(7.)) - .child( - // The title step of the panel ramp, SEMIBOLD and - // uppercased. It reads as a label rather than as - // content because of the weight and the caps. - div() - .text_size(rems(META)) - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(cx.theme().secondary_foreground) - .child(text.to_uppercase()), - ) - .when_some(count, |this, c| { - this.child( - // A count is a token hanging off the heading, not - // part of it: one step down, mono, regular weight. + // Where the tabs live in this row, they are the heading: the + // current one already names the panel, and the Changes tab carries + // the count. The row closes on the hairline their bar sits on. + .when(tabs.is_some(), |row| { + row.pl(px(TAB_ROW_LEAD)) + .border_b_1() + .border_color(cx.theme().sidebar_border) + }) + .when(tabs.is_none(), |row| { + row.child( + h_flex() + .flex_shrink_0() + .items_baseline() + .gap(px(7.)) + .child( + // The title step of the panel ramp, SEMIBOLD and + // uppercased. It reads as a label rather than as + // content because of the weight and the caps. div() - .text_size(rems(META_MONO)) - .font_family(cx.theme().mono_font_family.clone()) + .text_size(rems(META)) + .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(cx.theme().muted_foreground) - .child(c), + .child(text.to_string()), ) - }), - ) + .when_some(count, |this, c| { + this.child( + // A count is a token hanging off the heading, not + // part of it: one step down, mono, regular weight. + div() + .text_size(rems(META_MONO)) + .font_family(cx.theme().mono_font_family.clone()) + .text_color(cx.theme().muted_foreground) + .child(c), + ) + }), + ) + }) .child(div().flex_1().min_w_0()) .when_some(trailing, |this, t| this.child(t)) .when_some(tabs, |this, tiles| { @@ -1113,12 +1172,12 @@ impl Tty7App { })) .pb(px(if trailing.is_some() { 0. } else { 4. })) .child( - // Weight and capitalization distinguish compact group headings. + // Weight and ink, not capitals, set a compact heading apart. div() .text_size(rems(HEADING)) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(cx.theme().muted_foreground) - .child(text.to_uppercase()), + .child(text.to_string()), ) .when_some(trailing, |this, t| this.child(t)) .into_any_element() diff --git a/src/ui/rounding.rs b/src/ui/rounding.rs index 5bb6ec6d..7ab0628d 100644 --- a/src/ui/rounding.rs +++ b/src/ui/rounding.rs @@ -16,7 +16,11 @@ pub(crate) const TRACK_RADIUS: Pixels = px(8.); /// Inset cards, interactive rows, and floating panels share the A style scale. pub(crate) const CARD_RADIUS: Pixels = px(10.); pub(crate) const ROW_RADIUS: Pixels = px(7.); -pub(crate) const POPOVER_RADIUS: Pixels = px(13.); +pub(crate) const POPOVER_RADIUS: Pixels = px(10.); + +/// Single-line navigation rows, search fields and round icon buttons. Larger +/// than any row is tall, so it always resolves to a full half-height cap. +pub(crate) const PILL_RADIUS: Pixels = px(999.); pub(crate) const HAIRLINE: Pixels = px(1.); diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 309e75ec..52ac33d9 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -31,6 +31,10 @@ const GRAB_HANDLE_W: f32 = 48.; const ROW_GAP: f32 = 2.; +/// A single-line tab row: one line of `text_sm` and a little air, the same +/// 28px the search field and the workspace chip above it stand at. +const ROW_HEIGHT: f32 = 28.; + /// The row chrome the text budget has to be measured around. These are the /// numbers the layout below is built from, not a second guess at it — a row /// that elides against a budget wider than it really has falls back to CSS @@ -43,7 +47,7 @@ mod row_metrics { /// `pl_2` + `pr_2` on the row. pub(super) const ROW_PAD: f32 = 8.; /// The avatar handed to `tab_avatar`. - pub(super) const AVATAR: f32 = 22.; + pub(super) const AVATAR: f32 = 18.; /// `gap_2` between the row's children. pub(super) const GAP: f32 = 8.; /// The ⌘N badge, when one is shown. @@ -181,9 +185,9 @@ impl Tty7App { } /// What the right panel has reserved, from the sidebar's point of view. - pub(crate) fn right_panel_floor(&self, cx: &gpui::App) -> f32 { + pub(crate) fn right_panel_floor(&self, window: &Window, cx: &gpui::App) -> f32 { if self.right_panel_open(cx) { - crate::ui::right_panel::MIN_WIDTH + self.right_panel_min_px(window, cx) } else { 0. } @@ -193,7 +197,7 @@ impl Tty7App { crate::ui::app::side_panel_max( window.viewport_size().width.as_f32(), MIN_SIDEBAR_WIDTH, - self.right_panel_floor(cx) + self.document_floor(cx), + self.right_panel_floor(window, cx) + self.document_floor(cx), ) } @@ -317,7 +321,7 @@ impl Tty7App { // it as regular would let the one row the user is looking at overflow // into the truncation this is here to avoid. let title_font_active = gpui::Font { - weight: FontWeight::MEDIUM, + weight: FontWeight::SEMIBOLD, ..font.clone() }; let rem = window.rem_size().as_f32(); @@ -703,6 +707,10 @@ impl Tty7App { let success = added_ink; let danger = removed_ink; + // A row that is one line is a pill; one that grows a branch + // line under its title keeps a card's corners, since a full + // radius on a two-line box pinches the text at both ends. + let two_line = git_line.is_some(); let label_region = match rename_input { Some(input) => div() .id(("sidebar-rename", i)) @@ -819,7 +827,7 @@ impl Tty7App { .min_w_0() .truncate() .text_sm() - .when(is_active, |d| d.font_weight(FontWeight::MEDIUM)) + .when(is_active, |d| d.font_weight(FontWeight::SEMIBOLD)) .child(shown_title), ) // The path is elided to the room the title @@ -866,13 +874,17 @@ impl Tty7App { } }) .w_full() - .py_2() + .min_h(px(ROW_HEIGHT)) + .when(two_line, |s| s.py_1p5()) .items_center() .justify_between() .gap_2() .pl_2() .pr_2() - .rounded(crate::ui::rounding::CARD_RADIUS) + .rounded(match two_line { + true => crate::ui::rounding::CARD_RADIUS, + false => crate::ui::rounding::PILL_RADIUS, + }) .when(is_active, |s| { s.bg(cx.theme().sidebar_accent) .text_color(cx.theme().sidebar_accent_foreground) @@ -921,7 +933,7 @@ impl Tty7App { agent_status, agent_unread, ssh_dot, - 22., + row_metrics::AVATAR, cx, )) // Leading, like the chip's: the trailing end of a row is @@ -959,7 +971,10 @@ impl Tty7App { row.child( h_flex() .absolute() - .top(px(4.)) + .top(px(match two_line { + true => 4., + false => (ROW_HEIGHT - crate::ui::tab_strip::MIN_TARGET) / 2., + })) .right(px(6.)) .opacity(0.) .group_hover(SharedString::from(format!("tab-row-{i}")), |s| { @@ -973,7 +988,7 @@ impl Tty7App { ), )) .child( - div().bg(backing).child( + div().bg(backing).rounded_full().child( crate::ui::tab_strip::hit_target( Button::new(("sidebar-close", i)) .icon(IconName::Close) @@ -1142,15 +1157,16 @@ impl Tty7App { } }) }) - .child( - div().flex_shrink_0().child( - Icon::new(match folded { - true => IconName::ChevronRight, - false => IconName::ChevronDown, - }) - .xsmall(), - ), - ) + // The heading names the group; the chevron only says + // something when there is something behind it. An open + // group shows its rows, which is its own answer. + .when(folded, |header| { + header.child( + div() + .flex_shrink_0() + .child(Icon::new(IconName::ChevronRight).xsmall()), + ) + }) .when(pinned, |header| { header.child( div() @@ -1179,6 +1195,10 @@ impl Tty7App { .min_w_0() .truncate() .font_weight(FontWeight::SEMIBOLD) + // Body ink, not caption grey: the heading is the + // name of what sits under it, and the branch and + // counts beside it are the metadata. + .text_color(cx.theme().foreground) .child(label) .into_any_element(), }) @@ -1447,12 +1467,13 @@ impl Tty7App { .flex_shrink_0() .items_center() .gap(px(6.)) - .h(px(34.)) + .h(px(ROW_HEIGHT)) .mx_2() - .mt_1() + .mt_1p5() .mb_1() - .px_2() - .rounded_lg() + .pl(px(6.)) + .pr_1() + .rounded(crate::ui::rounding::PILL_RADIUS) .bg(cx.theme().muted) .child( div() @@ -1481,7 +1502,7 @@ impl Tty7App { // below only ever sees a `Window`, and the cap it clamps against has to // be the same one the layout applies or the sidebar springs back from // wherever it was dropped. - let others_floor = self.right_panel_floor(cx) + self.document_floor(cx); + let others_floor = self.right_panel_floor(window, cx) + self.document_floor(cx); let backing = canvas( { let container = container.clone(); diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index f5ae5e78..56adee93 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -600,20 +600,47 @@ pub(crate) fn chrome_tile(button: Button, selected: bool, cx: &gpui::App) -> But chrome_tile_sized(button, TILE_SIZE, TILE_GLYPH, selected, cx) } -/// Secondary panel navigation stays neutral; workspace selection owns accent. -pub(crate) fn chrome_tile_marked(button: Button, current: bool, cx: &gpui::App) -> Button { - button - .custom(chrome_tile_variant_for(current, cx)) - .when(current, |button| { - button - .bg(cx.theme().secondary) - .text_color(cx.theme().foreground) - }) - .with_size(px(TILE_GLYPH / BUTTON_ICON_SCALE)) - .w(px(TILE_SIZE)) - .h(px(TILE_SIZE)) +const RIGHT_PANEL_TABS: [(RightPanelTab, L10nKey); 3] = [ + (RightPanelTab::Info, L10nKey::PanelInfoTitle), + (RightPanelTab::Scm, L10nKey::PanelChangesTitle), + (RightPanelTab::Files, L10nKey::PanelFilesTitle), +]; + +fn right_panel_tab_size(window: &Window) -> f32 { + window.rem_size().as_f32() * crate::ui::right_panel::META } +fn right_panel_tab_font(cx: &gpui::App) -> gpui::Font { + gpui::Font { + family: cx.theme().font_family.clone(), + features: Default::default(), + fallbacks: None, + weight: FontWeight::SEMIBOLD, + style: Default::default(), + } +} + +/// What the three bare tab labels take, padding included, at the live +/// interface size and language. The panel's floor is built on it, so a larger +/// UI font widens the panel instead of pushing its chrome tiles off the edge. +pub(crate) fn right_panel_tab_labels_w(window: &Window, cx: &gpui::App) -> f32 { + let size = right_panel_tab_size(window); + let font = right_panel_tab_font(cx); + RIGHT_PANEL_TABS + .iter() + .map(|(_, key)| { + measure_text(window.text_system(), &font, size, t(*key)) + + 2. * (TAB_OUTER_PAD + TAB_INNER_PAD) + }) + .sum() +} + +/// Right panel tab geometry: the gap between two tabs' hover pills, the pill's +/// own inset around its label, and the gap before the Changes count. +const TAB_OUTER_PAD: f32 = 2.; +const TAB_INNER_PAD: f32 = 8.; +const TAB_COUNT_GAP: f32 = 5.; + /// How wide the two chrome tiles at the trailing end of the title bar are, with /// the padding around them. pub(crate) fn trailing_chrome_tiles_w() -> f32 { @@ -981,10 +1008,10 @@ impl Tty7App { .id("workspace-rename") .flex_shrink_0() .items_center() - .h(px(30.)) + .h(px(28.)) .w_full() - .px(px(7.)) - .rounded_md() + .px(px(9.)) + .rounded(crate::ui::rounding::PILL_RADIUS) .bg(cx.theme().sidebar_accent) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child(Input::new(&rename.input).appearance(false).xsmall()) @@ -1050,8 +1077,9 @@ impl Tty7App { .flex_shrink(1.) .min_w_0() .truncate() - .text_size(px(12.5)) + .text_size(px(13.)) .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) .child(SharedString::from(current.clone())), ) .child( @@ -1065,8 +1093,8 @@ impl Tty7App { ) .xsmall() .w_full() - .h(px(30.)) - .rounded_md() + .h(px(28.)) + .rounded(crate::ui::rounding::PILL_RADIUS) .tooltip_element(chord_tooltip( t(L10nKey::HomeSwitchWorkspace), "ToggleSwitcher", @@ -1158,7 +1186,18 @@ impl Tty7App { .child(self.app_menu_tile(window, cx)) } - pub(crate) fn right_panel_tabs(&self, cx: &mut Context) -> Vec { + /// The right panel's word tabs, laid out in `avail` px. + /// + /// A label is never elided — "C…" is not a tab anyone can read — so when + /// the row runs short the Changes count is what gives way: it repeats what + /// the panel body says, the label does not. `right_panel::MIN_WIDTH` is + /// what guarantees the three bare labels always fit. + pub(crate) fn right_panel_tabs( + &self, + avail: f32, + window: &Window, + cx: &mut Context, + ) -> Vec { let active_tab = self.right_panel_tab; // The count the source control tile carries. It reads the same status // the panel draws, so the badge and the group headers can never @@ -1171,78 +1210,105 @@ impl Tty7App { .and_then(|repo| crate::terminal::git_data::status_of(cx, repo.host, &repo.root)) .map(|status| status.entries.len()) .filter(|n| *n > 0); - [ - ( - RightPanelTab::Info, - Icon::empty().path("icons/info.svg"), - L10nKey::PanelInfoTitle, - ), - ( - RightPanelTab::Scm, - Icon::empty().path("icons/git-branch.svg"), - L10nKey::PanelChangesTitle, - ), - ( - RightPanelTab::Files, - Icon::new(IconName::FolderClosed), - L10nKey::PanelFilesTitle, - ), - ] - .into_iter() - .map(|(tab, icon, label_key)| { - let current = active_tab == tab; - let tile = chrome_tile_marked( - Button::new(("right-panel-tab", tab as usize)).icon(icon), - current, - cx, - ) - .rounded_lg() - .tooltip(match (tab, changed) { - (RightPanelTab::Scm, Some(n)) => { - SharedString::from(format!("{} · {n}", t(label_key))) - } - _ => SharedString::from(t(label_key)), + let hover = gpui::rgb(cx.global::().sidebar.hover); + let size = right_panel_tab_size(window); + let font = right_panel_tab_font(cx); + let ts = window.text_system(); + let labels_w = right_panel_tab_labels_w(window, cx); + let changed = changed.filter(|n| { + let regular = gpui::Font { + weight: FontWeight::NORMAL, + ..font.clone() + }; + labels_w + TAB_COUNT_GAP + measure_text(ts, ®ular, size, &n.to_string()) <= avail + }); + RIGHT_PANEL_TABS + .into_iter() + .map(|(tab, label_key)| { + let current = active_tab == tab; + // Words, not glyphs: three tabs is few enough to name, and a name + // is what a new user has to guess at when the tab is an icon. The + // current one is told apart by ink and the bar under it, both + // neutral — this is secondary navigation, not an action. + let ink = match current { + true => cx.theme().foreground, + false => cx.theme().muted_foreground, + }; + let count = match tab { + RightPanelTab::Scm => changed, + _ => None, + }; + div() + .id(("right-panel-tab", tab as usize)) + // The press must not start a window drag from the title bar + // the tabs sit in. + .occlude() + .flex_shrink_0() + // Full height and `relative` so the bar below can be pinned to + // the row's own bottom edge, where it lands on the hairline + // that closes the row rather than floating under the label. + .h_full() + .relative() + .flex() + .items_center() + .px(px(TAB_OUTER_PAD)) + .cursor_pointer() + .child( + h_flex() + .flex_shrink_0() + .h(px(crate::ui::app::TILE_SIZE_SM + 2.)) + .px(px(TAB_INNER_PAD)) + .gap(px(TAB_COUNT_GAP)) + .items_center() + .rounded_full() + .hover(|s| s.bg(hover)) + .text_size(gpui::rems(crate::ui::right_panel::META)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(ink) + .child(div().flex_shrink_0().child(t(label_key))) + .when_some(count, |row, n| { + row.child( + div() + .flex_shrink_0() + .font_weight(FontWeight::NORMAL) + .text_color(cx.theme().muted_foreground) + .child(n.to_string()), + ) + }), + ) + .child( + div() + .absolute() + .bottom(px(-1.)) + .left(px(TAB_OUTER_PAD + TAB_INNER_PAD)) + .right(px(TAB_OUTER_PAD + TAB_INNER_PAD)) + .h(px(2.)) + .rounded_full() + .when(current, |bar| bar.bg(cx.theme().foreground)), + ) + // Another tab switches to it; the current one puts the panel + // away, the way an activity bar behaves everywhere else. + // (These only exist while the panel is open, so + // `ToggleRightPanel` and the chrome tile beside them are still + // what brings it back.) + .on_click(cx.listener(move |this, _, window, cx| { + match this.right_panel_open(cx) && this.right_panel_tab == tab { + true => { + this.toggle_right_panel(cx); + // These tabs live inside the panel, so closing + // from one destroys the element that holds the + // focus and leaves it nowhere — and a keymap whose + // bindings are scoped to a focused thing goes + // quiet with it. Hand the terminal back what it + // lost. + this.focus_active(window, cx); + } + false => this.set_right_panel_tab(tab, cx), + } + })) + .into_any_element() }) - // A tile for another tab switches to it; the lit one puts - // the panel away, the way an activity bar behaves - // everywhere else. Pressing it used to do nothing at all - // — a dead click on the one control in the row that looks - // like it should undo itself. (These tiles only exist - // while the panel is open, so `ToggleRightPanel` and the - // chrome tile beside them are still what brings it back.) - .on_click(cx.listener(move |this, _, window, cx| { - match this.right_panel_open(cx) && this.right_panel_tab == tab { - true => { - this.toggle_right_panel(cx); - // These tiles live inside the panel, so - // closing from one destroys the element that - // holds the focus and leaves it nowhere — - // and a keymap whose bindings are scoped to a - // focused thing goes quiet with it, so the - // ⌘J that would undo this did nothing at all. - // Hand the terminal back what it lost. - this.focus_active(window, cx); - } - false => this.set_right_panel_tab(tab, cx), - } - })); - div() - .flex_shrink_0() - // Full height and `relative` so the bar below can be pinned to - // the row's own bottom edge, where it lands on the hairline - // that closes the row rather than floating under the glyph. - // The `occlude` that keeps a press from dragging the window - // stays on the tile: grown to the whole row it would take the - // few pixels above and below each glyph out of the drag - // region and hand them to nothing. - .h_full() - .relative() - .flex() - .items_center() - .child(div().occlude().flex_shrink_0().child(tile)) - .into_any_element() - }) - .collect() + .collect() } /// Working and Done differ only in hue (blue vs green), and Waiting vs Done diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 3d4f173c..e69433d2 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -832,8 +832,10 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.shadow = false; let sidebar_bg = Hsla::from(rgb(m.sidebar)); - let (navigation_fill, navigation_ink) = theme.navigation_colors(); - let sidebar_sel = rgb(navigation_fill); + // The current tab is a place, not an action: a neutral step of the rail's + // own ladder plus a heavier title, never the accent. Blue is left to the + // things that can be pressed. + let sidebar_sel = rgb(surfaces.sidebar.selected); // `t.sidebar` stays the opaque theme token: the settings theme picker // paints with it on top of the (opaque) settings overlay, so diluting // it would wash out that panel. The workspace sidebar/right-panel @@ -847,7 +849,7 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.sidebar_foreground = rgb(surfaces.sidebar.text_resting).into(); t.sidebar_accent = sidebar_sel.into(); t.tokens.sidebar_accent = Hsla::from(sidebar_sel).into(); - t.sidebar_accent_foreground = rgb(navigation_ink).into(); + t.sidebar_accent_foreground = rgb(surfaces.sidebar.text_selected).into(); t.list.active_highlight = true; t.list_active = rgb(interaction.choice).into();