diff --git a/docs/design-system.md b/docs/design-system.md index 13e2ebbe..785b4c12 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -101,7 +101,9 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` 页标题立在 48px 标题栏带内;分组标题 28px、11.5/16rem medium 辅助色;分组之间是 0.5px 分隔线,上下各 16px。 设置行:标签正文色 regular,描述 12/16rem 辅助色,行最低 28px、内边距 8(hover/搜索命中的底色外扩 8px); 搜索命中行用浅中性底,不用强调色。输入框与下拉框 28px,按钮、步进器 26px(圆角 6); - 输入框/下拉框/次要按钮都用同一浅底(surface 半阶)且无描边。控件一义一种:开关管开/关,下拉管单选 + 输入框/下拉框/次要按钮都用同一浅底(主题 `muted`,与对话框输入井、侧栏搜索同色)且无描边、无阴影; + 输入框只在聚焦(强调色)或出错(红)时显示 1px 内环。右侧控件列统一 180px 宽(`kit::CONTROL_W`), + 只有路径/命令类输入加宽到 240px;过长的值省略号截断。控件一义一种:开关管开/关,下拉管单选 (不再用分段控件),滑块管连续值,步进器管整数微调。 每个视图唯一的主操作(保存主题草稿、连接、安装更新)用反色中性,同 Git 提交按钮;不可执行时回到浅底。 开关与滑块仍用强调色。SSH 主机列表:标题行带 26px 图标按钮,28px 浅底搜索,分组标题 22px, @@ -126,7 +128,8 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` - **SFTP 浏览器**:行与本地文件树一致(26px、圆角 6、16px 图标辅助色、文件名侧栏标题色、大小等宽数字); 编辑框为无边框淡底井(圆角 7);传输进度为正文色细条压在淡底轨道上,失败才转红。 - **面板里的主按钮**(SFTP 确认、添加转发、SSH 断线条的重连):反色中性,同对话框主按钮。 -- **对话框**(SSH 认证/主机密钥、新建 worktree):`ui::dialog` 统一外壳,同切换器的 12px 圆角、 +- **对话框**(SSH 认证/主机密钥、新建 worktree,以及所有确认框——关闭标签、退出、丢弃更改、删除文件等 `window.prompt`, + 三个平台都不用系统原生弹窗):`ui::dialog` 统一外壳,同切换器的 12px 圆角、 浮层阴影与遮罩、距顶 112px;标题行 48px(正文字号 medium,右侧 `esc` 键帽,下接细线), 内容区左右 18px、字段间距 14px;字段名 11.5/16rem medium 辅助色,输入框为无边框 28px 淡底井(`muted`、圆角 7)。页脚 40px 细线分隔,按钮 28px / 圆角 6、右对齐、主操作在最右: @@ -134,6 +137,9 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` 退回淡底 + 辅助色且不响应点击。主机名与指纹放在淡底信息井里用等宽字。唯一的红色按钮是 「主机密钥已变更」的 Override,该卡片也是唯一带红色描边的对话框。 - **浮动提示条**:边缘保持中性细线,严重程度只由行首 6px 状态点表达(警告琥珀、断开红)。 + 文档栏里的「文件已在磁盘上更改」横条同理:中性底 + 底部细线 + 琥珀点,按钮用对话框按钮,安全的「保留我的」为主。 +- **通知(toast)**:出错时带标题说明哪个操作失败(如「git push 失败」),原因作为次级文字,带状态图标; + 不直接把一行 stderr 当作全部内容。设置窗口是独立窗口,同样要渲染通知层。 - **首页**:快捷操作列表 340px 宽,行高 28px、圆角 7,静止无底、hover 取窗口 hover 阶; 快捷键用 18px 淡底键帽。 - **终端**:ANSI 是用户内容,不拿来充当 UI 错误/成功配色;应用设计调整不得反转状态含义。 diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 3fbb1c7c..e0338afa 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -88,8 +88,13 @@ fn ui_scale(cx: &App) -> f32 { cx.global::().ui_font_size / UI_FONT_SIZE_DEFAULT } -/// How wide a text field in the right-hand column is. -const FIELD_W: f32 = 180.; +/// How wide a text field in the right-hand column is — the dropdowns' width, +/// so the column is one straight edge whichever control a row holds. +const FIELD_W: f32 = crate::ui::settings::kit::CONTROL_W; + +/// A field for a path or a command line: the one kind of value unreadable cut +/// short, so the one kind allowed past [`FIELD_W`]. +const PATH_FIELD_W: f32 = 240.; /// The host editor's label column, and how wide a field beside one grows to. const SSH_LABEL_W: f32 = 96.; diff --git a/src/ui/settings/hosts.rs b/src/ui/settings/hosts.rs index 2b2b2404..ca23dd03 100644 --- a/src/ui/settings/hosts.rs +++ b/src/ui/settings/hosts.rs @@ -419,6 +419,11 @@ impl Tty7App { (false, None) => (t(L10nKey::SettingsNewHost).to_string(), String::new()), }; let blank_title = editing && p.is_none() && title == t(L10nKey::SettingsNewHost); + // A host saved under its own address is titled with that address, and + // a second line saying it again is noise — the rule the New Tab menu + // already keeps. While editing, the line is the form's live preview + // and stays. + let echo = !editing && address == title; let (meta, meta_color) = if dirty { (t(L10nKey::SettingsUnsaved).to_string(), tk.warn_text) } else if live { @@ -463,15 +468,17 @@ impl Tty7App { .text_color(if blank_title { tk.k4 } else { tk.fg }) .child(title), ) - .child( - div() - .font_family(Tk::mono(cx)) - .text_size(fs(11.)) - .line_height(px(15.)) - .text_color(tk.k45) - .truncate() - .child(address), - ), + .when(!echo, |col| { + col.child( + div() + .font_family(Tk::mono(cx)) + .text_size(fs(11.)) + .line_height(px(15.)) + .text_color(tk.k45) + .truncate() + .child(address), + ) + }), ) .child( div() diff --git a/src/ui/settings/kit.rs b/src/ui/settings/kit.rs index 2058fcf6..5fb6500c 100644 --- a/src/ui/settings/kit.rs +++ b/src/ui/settings/kit.rs @@ -49,6 +49,15 @@ pub(crate) struct Tk { pub nav: Hsla, /// A raised button's fill. pub btn: 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 popover's fill. pub menu: Hsla, /// A switch or slider knob at rest. @@ -76,6 +85,7 @@ 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, @@ -95,6 +105,9 @@ impl Tk { heading: a(0.9, 0.9), nav: a(0.82, 0.8), btn: if dark { fg.opacity(0.1) } else { white }, + well, + well_hover: well.blend(fg.opacity(if dark { 0.06 } else { 0.05 })), + focus: theme.ring, menu: if dark { lift(page, 0.06) } else { white }, knob: if dark { fg } else { white }, chip: if dark { fg.opacity(0.16) } else { white }, @@ -330,13 +343,12 @@ 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.btn) - .shadow(tk.raised()) + .bg(tk.well) .text_size(self.size.unwrap_or(fs(12.5))) .text_color(tk.fg) .when(!self.disabled, |b| { - b.hover(move |s| s.shadow(tk.raised_hover())) - .active(move |s| s.bg(tk.k05)) + b.hover(move |s| s.bg(tk.well_hover)) + .active(move |s| s.bg(tk.well_hover.blend(tk.k05))) }), BtnKind::Primary => base .h(px(26.)) @@ -409,22 +421,31 @@ 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 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(26.)) + .h(px(CONTROL_H)) .px(px(8.)) .gap(px(6.)) .items_center() - .rounded(px(6.)) - .shadow(vec![ring(tk.k15, 0.5, true)]) + .rounded(px(7.)) + .bg(tk.well) .child(search_glass(10., tk)) .child( - div() - .flex_1() - .min_w_0() - .text_size(fs(12.5)) - .child(Input::new(input).appearance(false).px_0().py_0().h(px(24.))), + // On the input, not around it: `Input` sets its own `text_sm` + // and only a size given to it directly outranks that. + div().flex_1().min_w_0().child( + Input::new(input) + .appearance(false) + .px_0() + .py_0() + .h(px(24.)) + .text_size(fs(12.5)), + ), ) } @@ -437,32 +458,36 @@ pub(crate) fn text_field( tk: &Tk, cx: &App, ) -> Div { - let ring_color = if invalid { - tk.danger - } else if focused { - tk.k45 - } else { - tk.k15 + // 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, }; div() - .h(px(26.)) + .h(px(CONTROL_H)) .px(px(10.)) .flex() .items_center() .rounded(px(6.)) - .shadow(vec![ring( - ring_color, - if focused || invalid { 1. } else { 0.5 }, - true, - )]) + .bg(tk.well) + .when_some(ring_color, |f, c| f.shadow(vec![ring(c, 1., true)])) .font_family(Tk::mono(cx)) - .text_size(fs(12.)) .text_color(tk.fg) .child( - div() - .flex_1() - .min_w_0() - .child(Input::new(input).appearance(false).px_0().py_0().h(px(24.))), + div().flex_1().min_w_0().child( + Input::new(input) + .appearance(false) + .px_0() + .py_0() + .h(px(24.)) + // The input's own `text_sm` beat the 12px set on this + // box, so the value stood a size above every dropdown + // label in the same column. + .text_size(fs(12.)), + ), ) } @@ -489,8 +514,16 @@ pub(crate) fn section_head(title: &str, desc: Option, tk: &Tk) -> Div { }) } +/// The width of a control in the page's right-hand column — dropdown or text +/// field alike. The column used to size each dropdown to its value (anything +/// from 148 to 260) beside fields fixed at 180, so its left edge zig-zagged +/// down the page. One width, and a value too long for it elides. Only fields +/// that hold a path or a command line are wider, because those are unreadable +/// cut short. +pub(crate) const CONTROL_W: f32 = 180.; + /// The trigger of a dropdown: a raised button with the current value, an -/// optional mark before it, and a chevron. +/// optional mark before it, and a chevron, [`CONTROL_W`] wide. pub(crate) fn select_trigger( id: impl Into, label: impl Into, @@ -500,17 +533,16 @@ pub(crate) fn select_trigger( ) -> Stateful
{ h_flex() .id(id) - .h(px(26.)) - .min_w(px(148.)) - .max_w(px(260.)) + .h(px(CONTROL_H)) + .w(px(CONTROL_W)) + .flex_shrink_0() .pl(px(if leading.is_some() { 8. } else { 10. })) .pr(px(7.)) .gap(px(8.)) .items_center() .rounded(px(6.)) - .bg(if open { tk.k05 } else { tk.btn }) - .shadow(tk.raised()) - .hover(move |s| s.shadow(tk.raised_hover())) + .bg(if open { tk.well_hover } else { tk.well }) + .hover(move |s| s.bg(tk.well_hover)) .cursor_pointer() .text_size(fs(12.5)) .text_color(tk.fg) @@ -642,7 +674,9 @@ pub(crate) fn stepper( .text_color(tk.k6) .when(!enabled, |d| d.opacity(0.35)) .when(enabled, |d| { - d.cursor_pointer().active(move |s| s.bg(tk.k05)) + d.cursor_pointer() + .hover(move |s| s.bg(tk.well_hover)) + .active(move |s| s.bg(tk.well_hover.blend(tk.k05))) }) .child(glyph) }; @@ -652,8 +686,7 @@ pub(crate) fn stepper( .h(px(26.)) .items_center() .rounded(px(6.)) - .bg(tk.btn) - .shadow(tk.raised()) + .bg(tk.well) .child( end("dec", "−", s.can_dec) .rounded_l(px(6.)) diff --git a/src/ui/settings/pages.rs b/src/ui/settings/pages.rs index dfa84bdc..a668fb41 100644 --- a/src/ui/settings/pages.rs +++ b/src/ui/settings/pages.rs @@ -756,7 +756,10 @@ impl Tty7App { .id("shell-program-detected-trigger") .flex_shrink_0() .ml(px(4.)) - .mr(px(-4.)) + // Pulled into the field's 10px padding far enough that the + // chevron's centre sits 12px from the edge — where a + // dropdown's does, one row down. + .mr(px(-7.)) .size(px(18.)) .rounded(px(4.)) .flex() @@ -849,7 +852,7 @@ impl Tty7App { let value = wd_path_input.read(cx).value(); let error = (!crate::ui::app::wd_path_saveable(&value)) .then(|| t(L10nKey::SettingsWdPathInvalid).to_string()); - let control = self.settings_checked_input(&wd_path_input, 240., error, cx); + let control = self.settings_checked_input(&wd_path_input, PATH_FIELD_W, error, cx); rows.push( self.settings_row( t(L10nKey::SettingsCustomPath), @@ -936,7 +939,7 @@ impl Tty7App { // reads as "this is what file links do". let link_file_command_row = (link_file_open == LinkFileOpen::Command).then(|| { let control = self - .settings_text_input(&link_file_command_input, 240., false, cx) + .settings_text_input(&link_file_command_input, PATH_FIELD_W, false, cx) .into_any_element(); self.settings_row( t(L10nKey::SettingsOpenFilesCommand), diff --git a/src/ui/settings/shell.rs b/src/ui/settings/shell.rs index e3e4479e..5882d3dd 100644 --- a/src/ui/settings/shell.rs +++ b/src/ui/settings/shell.rs @@ -143,7 +143,8 @@ impl Tty7App { d.child(kit::popover_below( true, 4., - self.settings_menu_panel(&id, entries, cx).min_w(px(148.)), + self.settings_menu_panel(&id, entries, cx) + .min_w(px(kit::CONTROL_W)), )) }) .into_any_element()