fix(settings): one control language down the right-hand column

The page had drifted from the design system: dropdowns and secondary
buttons were raised white with a drop shadow, fields had a half-pixel
inset ring that anti-aliased to nothing on a 1x display (the shell and
proxy rows read as loose monospace text), and dropdowns sized to their
value beside fixed-width fields, so the column's left edge zig-zagged.

Fields, dropdowns, secondary buttons, steppers and search fields now all
stand on the theme's muted well with no outline or shadow; a field shows
a ring only for focus (accent) or error. Fields and dropdowns are 28px
and one width (kit::CONTROL_W); only path and command fields are wider.
Field text is sized on the Input itself, since its own text_sm outranked
the size set around it. A host saved under its own address no longer
repeats it as a subtitle.
This commit is contained in:
l0ng-ai
2026-09-27 16:50:19 +02:00
parent 19efc5fc51
commit 5928b34da7
6 changed files with 111 additions and 56 deletions
+8 -2
View File
@@ -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 错误/成功配色;应用设计调整不得反转状态含义。
+7 -2
View File
@@ -88,8 +88,13 @@ fn ui_scale(cx: &App) -> f32 {
cx.global::<Config>().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.;
+16 -9
View File
@@ -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()
+72 -39
View File
@@ -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<InputState>, 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<String>, 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<ElementId>,
label: impl Into<SharedString>,
@@ -500,17 +533,16 @@ pub(crate) fn select_trigger(
) -> Stateful<Div> {
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.))
+6 -3
View File
@@ -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),
+2 -1
View File
@@ -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()