Merge pull request #946 from l0ng-ai/feat/v5-ui-restyle

feat(ui): restyle the workspace after the v5 design
This commit is contained in:
l0ng-ai
2026-09-23 21:38:10 +08:00
committed by GitHub
11 changed files with 364 additions and 223 deletions
+16 -9
View File
@@ -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 面板**:改动列表是主体。可执行的提交按钮才突出;未填写信息或不可提交时保持次级。
+1 -1
View File
@@ -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]
+2 -2
View File
@@ -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 => {
+2 -2
View File
@@ -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 => {
+2 -2
View File
@@ -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、目录和进程。",
+29 -47
View File
@@ -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]
+100 -41
View File
@@ -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<Self>) {
@@ -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<Self>) -> (AnyElement, AnyElement) {
fn right_panel_resize(
&self,
window: &Window,
cx: &mut Context<Self>,
) -> (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<Self>,
) -> 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()
+5 -1
View File
@@ -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.);
+46 -25
View File
@@ -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();
+156 -90
View File
@@ -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<Self>) -> Vec<AnyElement> {
/// 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<Self>,
) -> Vec<AnyElement> {
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::<crate::ui::presets::Surfaces>().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, &regular, 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
+5 -3
View File
@@ -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();