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();