diff --git a/docs/design-system.md b/docs/design-system.md index 785b4c12..13e2ebbe 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -101,9 +101,7 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` 页标题立在 48px 标题栏带内;分组标题 28px、11.5/16rem medium 辅助色;分组之间是 0.5px 分隔线,上下各 16px。 设置行:标签正文色 regular,描述 12/16rem 辅助色,行最低 28px、内边距 8(hover/搜索命中的底色外扩 8px); 搜索命中行用浅中性底,不用强调色。输入框与下拉框 28px,按钮、步进器 26px(圆角 6); - 输入框/下拉框/次要按钮都用同一浅底(主题 `muted`,与对话框输入井、侧栏搜索同色)且无描边、无阴影; - 输入框只在聚焦(强调色)或出错(红)时显示 1px 内环。右侧控件列统一 180px 宽(`kit::CONTROL_W`), - 只有路径/命令类输入加宽到 240px;过长的值省略号截断。控件一义一种:开关管开/关,下拉管单选 + 输入框/下拉框/次要按钮都用同一浅底(surface 半阶)且无描边。控件一义一种:开关管开/关,下拉管单选 (不再用分段控件),滑块管连续值,步进器管整数微调。 每个视图唯一的主操作(保存主题草稿、连接、安装更新)用反色中性,同 Git 提交按钮;不可执行时回到浅底。 开关与滑块仍用强调色。SSH 主机列表:标题行带 26px 图标按钮,28px 浅底搜索,分组标题 22px, @@ -128,8 +126,7 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` - **SFTP 浏览器**:行与本地文件树一致(26px、圆角 6、16px 图标辅助色、文件名侧栏标题色、大小等宽数字); 编辑框为无边框淡底井(圆角 7);传输进度为正文色细条压在淡底轨道上,失败才转红。 - **面板里的主按钮**(SFTP 确认、添加转发、SSH 断线条的重连):反色中性,同对话框主按钮。 -- **对话框**(SSH 认证/主机密钥、新建 worktree,以及所有确认框——关闭标签、退出、丢弃更改、删除文件等 `window.prompt`, - 三个平台都不用系统原生弹窗):`ui::dialog` 统一外壳,同切换器的 12px 圆角、 +- **对话框**(SSH 认证/主机密钥、新建 worktree):`ui::dialog` 统一外壳,同切换器的 12px 圆角、 浮层阴影与遮罩、距顶 112px;标题行 48px(正文字号 medium,右侧 `esc` 键帽,下接细线), 内容区左右 18px、字段间距 14px;字段名 11.5/16rem medium 辅助色,输入框为无边框 28px 淡底井(`muted`、圆角 7)。页脚 40px 细线分隔,按钮 28px / 圆角 6、右对齐、主操作在最右: @@ -137,9 +134,6 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` 退回淡底 + 辅助色且不响应点击。主机名与指纹放在淡底信息井里用等宽字。唯一的红色按钮是 「主机密钥已变更」的 Override,该卡片也是唯一带红色描边的对话框。 - **浮动提示条**:边缘保持中性细线,严重程度只由行首 6px 状态点表达(警告琥珀、断开红)。 - 文档栏里的「文件已在磁盘上更改」横条同理:中性底 + 底部细线 + 琥珀点,按钮用对话框按钮,安全的「保留我的」为主。 -- **通知(toast)**:出错时带标题说明哪个操作失败(如「git push 失败」),原因作为次级文字,带状态图标; - 不直接把一行 stderr 当作全部内容。设置窗口是独立窗口,同样要渲染通知层。 - **首页**:快捷操作列表 340px 宽,行高 28px、圆角 7,静止无底、hover 取窗口 hover 阶; 快捷键用 18px 淡底键帽。 - **终端**:ANSI 是用户内容,不拿来充当 UI 错误/成功配色;应用设计调整不得反转状态含义。 diff --git a/src/ui/settings/kit.rs b/src/ui/settings/kit.rs index 26dfcf64..9872f677 100644 --- a/src/ui/settings/kit.rs +++ b/src/ui/settings/kit.rs @@ -47,15 +47,8 @@ pub(crate) struct Tk { pub heading: Hsla, /// A nav item at rest. pub nav: Hsla, - /// The one fill every control on the page stands on — field, dropdown, - /// secondary button, stepper — the theme's `muted`, the same well the - /// dialogs' fields and the sidebar's search sit in. No outline, no drop. - pub well: Hsla, - /// [`Self::well`] a step firmer, under the pointer or while open. - pub well_hover: Hsla, - /// The focus ring: the accent, the one colour kept for "the keyboard - /// is here". - pub focus: Hsla, + /// A raised button's fill. + pub btn: Hsla, /// A popover's fill. pub menu: Hsla, /// A switch or slider knob at rest. @@ -83,7 +76,6 @@ impl Tk { let page = theme.background; let a = |light: f32, dark_a: f32| fg.opacity(if dark { dark_a } else { light }); let white = gpui::white(); - let well = theme.muted; Self { dark, fg, @@ -102,9 +94,7 @@ impl Tk { k6: a(0.6, 0.65), heading: a(0.9, 0.9), nav: a(0.82, 0.8), - well, - well_hover: well.blend(fg.opacity(if dark { 0.06 } else { 0.05 })), - focus: theme.ring, + btn: if dark { fg.opacity(0.1) } else { white }, menu: if dark { lift(page, 0.06) } else { white }, knob: if dark { fg } else { white }, chip: if dark { fg.opacity(0.16) } else { white }, @@ -127,6 +117,18 @@ impl Tk { } } + /// The same, a step firmer, for a hovered raised control. + pub(crate) fn raised_hover(&self) -> Vec { + if self.dark { + vec![ring(self.fg.opacity(0.2), 0.5, false)] + } else { + vec![ + ring(gpui::black().opacity(0.16), 0.5, false), + drop(gpui::black().opacity(0.08), 1., 2., 0.), + ] + } + } + pub(crate) fn popover(&self) -> Vec { if self.dark { vec![ @@ -316,9 +318,6 @@ impl RenderOnce for Btn { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let tk = Tk::of(cx); let kind = self.kind; - // A primary with nothing to do yet — disabled, or dimmed with nothing - // to save — is drawn on the well rather than faded. - let resting_primary = kind == BtnKind::Primary && (self.disabled || self.dimmed); let base = h_flex() .id(self.id) .flex_shrink_0() @@ -331,31 +330,23 @@ impl RenderOnce for Btn { .px(px(12.)) .when(self.icon.is_some(), |b| b.pl(px(9.)).pr(px(11.))) .rounded(px(6.)) - .bg(tk.well) + .bg(tk.btn) + .shadow(tk.raised()) .text_size(self.size.unwrap_or(fs(12.5))) .text_color(tk.fg) .when(!self.disabled, |b| { - b.hover(move |s| s.bg(tk.well_hover)) - .active(move |s| s.bg(tk.well_hover.blend(tk.k05))) + b.hover(move |s| s.shadow(tk.raised_hover())) + .active(move |s| s.bg(tk.k05)) }), - // Disabled, the ink fill sinks to the well with secondary text — - // the fall the dialogs' Create and the Git panel's Commit take. At - // 45% opacity a disabled Save was a washed-out black button that - // still looked like the thing to press. BtnKind::Primary => base .h(px(26.)) .px(px(12.)) .rounded(px(6.)) - .bg(if resting_primary { tk.well } else { tk.fg }) + .bg(tk.fg) .text_size(self.size.unwrap_or(fs(12.5))) .font_weight(FontWeight::MEDIUM) - .text_color(if resting_primary { tk.k6 } else { tk.page }) - .when(!self.disabled && !resting_primary, |b| { - b.hover(|s| s.opacity(0.88)) - }) - .when(resting_primary && !self.disabled, |b| { - b.hover(move |s| s.bg(tk.well_hover)) - }), + .text_color(tk.page) + .when(!self.disabled, |b| b.hover(|s| s.opacity(0.88))), BtnKind::Link | BtnKind::Danger => base .text_size(self.size.unwrap_or(fs(12.))) .text_color(if kind == BtnKind::Danger { @@ -373,21 +364,16 @@ impl RenderOnce for Btn { }) }), }; - el.when(self.disabled && kind != BtnKind::Primary, |b| { - b.opacity(0.45) - }) - .when( - self.dimmed && !self.disabled && kind != BtnKind::Primary, - |b| b.opacity(0.55), - ) - .when_some(self.icon, |b, path| { - b.child(Icon::empty().path(path).size(px(10.)).text_color(tk.k6)) - }) - .child(self.label) - .when_some(self.handler.filter(|_| !self.disabled), |b, handler| { - b.cursor_pointer() - .on_click(move |ev, window, cx| handler(ev, window, cx)) - }) + el.when(self.disabled, |b| b.opacity(0.45)) + .when(self.dimmed && !self.disabled, |b| b.opacity(0.55)) + .when_some(self.icon, |b, path| { + b.child(Icon::empty().path(path).size(px(10.)).text_color(tk.k6)) + }) + .child(self.label) + .when_some(self.handler.filter(|_| !self.disabled), |b, handler| { + b.cursor_pointer() + .on_click(move |ev, window, cx| handler(ev, window, cx)) + }) } } @@ -423,19 +409,38 @@ pub(crate) fn search_glass(size: f32, tk: &Tk) -> Icon { .text_color(tk.k35) } -/// The height of a field or a dropdown. Buttons and steppers are a step -/// lower, at 26. -pub(crate) const CONTROL_H: f32 = 28.; +/// A hairline: one device pixel. The design draws field edges at half a +/// point, which is exactly one pixel on a Retina panel — and on a 1x display +/// half a pixel at 15% ink is anti-aliased to nothing, so the shell and proxy +/// fields read as loose monospace text beside the boxed dropdowns. +/// +/// Read from the scale the settings window last rendered at: the page's +/// controls are built from `cx` alone, and threading a `Window` through every +/// row to reach two fields would be a lot of signature for one number. A +/// thread-local rather than a global, because setting a global from `render` +/// queues an observer effect on every frame. +pub(crate) fn hairline() -> f32 { + (1. / SCALE.with(std::cell::Cell::get)).max(0.5) +} -/// A search field on the well, for filtering a list on the page. +/// Record the scale the page is being drawn at, once per frame. +pub(crate) fn note_scale(window: &Window) { + SCALE.with(|s| s.set(window.scale_factor().max(1.))); +} + +thread_local! { + static SCALE: std::cell::Cell = const { std::cell::Cell::new(2.) }; +} + +/// A search field with a hairline ring, for filtering a list on the page. pub(crate) fn search_field(input: &gpui::Entity, tk: &Tk) -> Div { h_flex() - .h(px(CONTROL_H)) + .h(px(26.)) .px(px(8.)) .gap(px(6.)) .items_center() - .rounded(px(7.)) - .bg(tk.well) + .rounded(px(6.)) + .shadow(vec![ring(tk.k15, hairline(), true)]) .child(search_glass(10., tk)) .child( // On the input, not around it: `Input` sets its own `text_sm` @@ -460,22 +465,24 @@ pub(crate) fn text_field( tk: &Tk, cx: &App, ) -> Div { - // At rest a field is only its well. The ring is kept for the two states - // that have to be seen at a glance: where the keyboard is, and what is - // wrong. - let ring_color = match (invalid, focused) { - (true, _) => Some(tk.danger), - (false, true) => Some(tk.focus), - (false, false) => None, + let ring_color = if invalid { + tk.danger + } else if focused { + tk.k45 + } else { + tk.k15 }; div() - .h(px(CONTROL_H)) + .h(px(26.)) .px(px(10.)) .flex() .items_center() .rounded(px(6.)) - .bg(tk.well) - .when_some(ring_color, |f, c| f.shadow(vec![ring(c, 1., true)])) + .shadow(vec![ring( + ring_color, + if focused || invalid { 1. } else { hairline() }, + true, + )]) .font_family(Tk::mono(cx)) .text_color(tk.fg) .child( @@ -535,7 +542,7 @@ pub(crate) fn select_trigger( ) -> Stateful
{ h_flex() .id(id) - .h(px(CONTROL_H)) + .h(px(26.)) .w(px(CONTROL_W)) .flex_shrink_0() .pl(px(if leading.is_some() { 8. } else { 10. })) @@ -543,8 +550,9 @@ pub(crate) fn select_trigger( .gap(px(8.)) .items_center() .rounded(px(6.)) - .bg(if open { tk.well_hover } else { tk.well }) - .hover(move |s| s.bg(tk.well_hover)) + .bg(if open { tk.k05 } else { tk.btn }) + .shadow(tk.raised()) + .hover(move |s| s.shadow(tk.raised_hover())) .cursor_pointer() .text_size(fs(12.5)) .text_color(tk.fg) @@ -676,9 +684,7 @@ pub(crate) fn stepper( .text_color(tk.k6) .when(!enabled, |d| d.opacity(0.35)) .when(enabled, |d| { - d.cursor_pointer() - .hover(move |s| s.bg(tk.well_hover)) - .active(move |s| s.bg(tk.well_hover.blend(tk.k05))) + d.cursor_pointer().active(move |s| s.bg(tk.k05)) }) .child(glyph) }; @@ -688,7 +694,8 @@ pub(crate) fn stepper( .h(px(26.)) .items_center() .rounded(px(6.)) - .bg(tk.well) + .bg(tk.btn) + .shadow(tk.raised()) .child( end("dec", "−", s.can_dec) .rounded_l(px(6.)) diff --git a/src/ui/settings_window.rs b/src/ui/settings_window.rs index 52db8f2d..c1b5e887 100644 --- a/src/ui/settings_window.rs +++ b/src/ui/settings_window.rs @@ -83,6 +83,7 @@ impl Render for SettingsWindow { // The workspace window sets this in its own render; a second window // has to, or the page lays out against the default 16px rem. window.set_rem_size(px(cx.global::().ui_font_size)); + crate::ui::settings::kit::note_scale(window); let page = self.app.upgrade().map(|app| { let page = app.update(cx, |this, cx| { // The state can be gone for a frame between closing and the