From dd5dcb77917f25ceb6d8d10e9ead2abd77020bb7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:50:55 +0200 Subject: [PATCH] feat(settings): every pick-one setting is a dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single choices were drawn two ways — segmented buttons for most, a dropdown for a few — and the segmented rows ran to their labels' width, so the right-hand column never lined up. settings_choice now renders a dropdown; an off-preset value shows as a checked "Custom (N)" row. The SSH strip's forward form keeps the segmented control: it lives outside the settings window and has no popover state to use. --- CHANGELOG.md | 8 +++ docs/design-system.md | 5 +- src/ui/settings/pages.rs | 28 ++++---- src/ui/settings/shell.rs | 131 +++++++++++++++++++++-------------- src/ui/settings/shortcuts.rs | 4 +- 5 files changed, 105 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a090ed1b..0ef12458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Every pick-one setting is a dropdown.** Settings drew a single choice two + ways — a row of segmented buttons for most, a dropdown for the language, the + window backdrop and the rest — with nothing to say which got which, and the + segmented rows ran to whatever width their labels made, so the right-hand + column never lined up. All of them are dropdowns now, one width. A value set + by hand off the presets (scrollback, the notification threshold) shows as a + checked "Custom (N)" at the foot of the menu, as the extra segment did. + - **The mouse wheel no longer zooms the font by default.** ⌘ (Ctrl elsewhere) plus the wheel used to resize the font, and ⌘ is held for so much else that the text jumped size mid-scroll. `mouse_zoom_modifier` now defaults to diff --git a/docs/design-system.md b/docs/design-system.md index cb8c4a61..13e2ebbe 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -100,8 +100,9 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` 搜索框 28px 浅底(圆角 7);导航行 28px、圆角 7、左右内边距 8,当前项为 selected 阶 + medium,不用强调色。 页标题立在 48px 标题栏带内;分组标题 28px、11.5/16rem medium 辅助色;分组之间是 0.5px 分隔线,上下各 16px。 设置行:标签正文色 regular,描述 12/16rem 辅助色,行最低 28px、内边距 8(hover/搜索命中的底色外扩 8px); - 搜索命中行用浅中性底,不用强调色。输入框与下拉框 28px,按钮、分段控件、步进器 26px(圆角 6); - 输入框/下拉框/分段轨道/次要按钮都用同一浅底(surface 半阶)且无描边,分段当前格为 selected 阶 + medium。 + 搜索命中行用浅中性底,不用强调色。输入框与下拉框 28px,按钮、步进器 26px(圆角 6); + 输入框/下拉框/次要按钮都用同一浅底(surface 半阶)且无描边。控件一义一种:开关管开/关,下拉管单选 + (不再用分段控件),滑块管连续值,步进器管整数微调。 每个视图唯一的主操作(保存主题草稿、连接、安装更新)用反色中性,同 Git 提交按钮;不可执行时回到浅底。 开关与滑块仍用强调色。SSH 主机列表:标题行带 26px 图标按钮,28px 浅底搜索,分组标题 22px, 主机行 42px 两行(标题正文、地址 11.5/16rem 辅助色);主机表单字段名为辅助色右对齐标签列。 diff --git a/src/ui/settings/pages.rs b/src/ui/settings/pages.rs index 81d78c0f..a5d51441 100644 --- a/src/ui/settings/pages.rs +++ b/src/ui/settings/pages.rs @@ -93,7 +93,7 @@ impl Tty7App { |secs| format!("{secs}s"), ); - let startup = self.segmented( + let startup = self.settings_choice( "wt-startup", &[ t(L10nKey::SettingsStartupNormal), @@ -126,7 +126,7 @@ impl Tty7App { let tray = self.settings_switch("wt-tray-icon", show_tray_icon, cx, |this, on, _, cx| { this.set_show_tray_icon(on, cx) }); - let notify = self.segmented( + let notify = self.settings_choice( "wt-notify", &[ t(L10nKey::NotifyModeNever), @@ -144,7 +144,7 @@ impl Tty7App { this.set_notify_mode(mode, cx); }, ); - let threshold = self.segmented_valued( + let threshold = self.settings_choice_valued( "wt-notify-threshold", &NOTIFY_THRESHOLD_LABELS, threshold_sel, @@ -400,7 +400,7 @@ impl Tty7App { CursorStyle::Bar => 1, CursorStyle::Underline => 2, }; - let cursor_style_control = self.segmented( + let cursor_style_control = self.settings_choice( "cursor-style", &[ t(L10nKey::CursorShapeBlock), @@ -425,7 +425,7 @@ impl Tty7App { let prompt_cursor_labels = prompt_cursor_shape_labels(); let prompt_cursor_labels: Vec<&str> = prompt_cursor_labels.iter().map(String::as_str).collect(); - let prompt_cursor_style_control = self.segmented( + let prompt_cursor_style_control = self.settings_choice( "prompt-cursor-style", &prompt_cursor_labels, prompt_cursor_idx, @@ -800,7 +800,7 @@ impl Tty7App { WdStrategy::Home => 1, WdStrategy::Custom => 2, }; - let wd_radio = self.segmented( + let wd_radio = self.settings_choice( "wd-strategy", &[ t(L10nKey::SettingsWdInherit), @@ -909,7 +909,7 @@ impl Tty7App { cx, |this, on, _, cx| this.set_ssh_loopback_forward(on, cx), ); - let link_file_open_radio = self.segmented( + let link_file_open_radio = self.settings_choice( "term-link-file-open", &[ t(L10nKey::SettingsOpenFilesInternal), @@ -953,7 +953,7 @@ impl Tty7App { ) .into_any_element() }); - let scrollback = self.segmented_valued( + let scrollback = self.settings_choice_valued( "term-scrollback", &SCROLLBACK_LABELS, scrollback_sel, @@ -973,7 +973,7 @@ impl Tty7App { BellMode::Audible => 2, BellMode::Both => 3, }; - let bell_control = self.segmented( + let bell_control = self.settings_choice( "term-bell", &[ t(L10nKey::SettingsBellModeOff), @@ -1230,7 +1230,7 @@ impl Tty7App { (MouseZoomModifier::None, true) => 3, (MouseZoomModifier::None, false) => 2, }; - let zoom = self.segmented( + let zoom = self.settings_choice( "term-mouse-zoom", &zoom_labels, zoom_idx, @@ -1374,7 +1374,7 @@ impl Tty7App { SshTabTitle::ProfileName => 1, SshTabTitle::Hostname => 2, }; - let new_tab = self.segmented( + let new_tab = self.settings_choice( "wt-new-tab-pos", &[t(L10nKey::SettingsAfterCurrent), t(L10nKey::SettingsAtEnd)], new_tab_idx, @@ -1390,7 +1390,7 @@ impl Tty7App { ); }, ); - let tab_bar = self.segmented( + let tab_bar = self.settings_choice( "wt-tab-bar-pos", &[t(L10nKey::SettingsTop), t(L10nKey::SettingsLeft)], tab_bar_idx, @@ -1412,7 +1412,7 @@ impl Tty7App { cx, |this, on, _, cx| this.set_sidebar_auto_grouping(on, cx), ); - let ssh_tab_title = self.segmented( + let ssh_tab_title = self.settings_choice( "wt-ssh-tab-title", &[ t(L10nKey::SettingsSshTabTitleDynamic), @@ -1719,7 +1719,7 @@ impl Tty7App { UpdateChannel::Stable => 0, UpdateChannel::Nightly => 1, }; - let channel = self.segmented( + let channel = self.settings_choice( "wt-update-channel", &[ t(L10nKey::SettingsUpdateChannelStable), diff --git a/src/ui/settings/shell.rs b/src/ui/settings/shell.rs index fbcd292c..5867c294 100644 --- a/src/ui/settings/shell.rs +++ b/src/ui/settings/shell.rs @@ -595,6 +595,76 @@ impl Tty7App { .into_any_element() } + /// A single choice from a fixed list: a dropdown, the one control every + /// pick-one row on the page uses. + pub(crate) fn settings_choice( + &self, + id: impl Into, + options: &[&str], + selected: usize, + cx: &mut Context, + on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, + ) -> AnyElement { + self.settings_choice_full(id, options, Some(selected), None, cx, on_pick) + } + + /// A choice over a fixed set of values, used where the config accepts + /// anything in a range. When the live value matches a bucket exactly that + /// bucket is checked; when it does not, a trailing "Custom (N)" row carries + /// the check and the trigger's label instead of the nearest bucket getting + /// a label it does not have (#550). + /// + /// The custom row writes nothing — there is no bucket value behind it — so + /// picking it only closes the menu. + pub(crate) fn settings_choice_valued( + &self, + id: impl Into, + options: &[&str], + selected: Option, + custom_label: Option, + cx: &mut Context, + on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, + ) -> AnyElement { + self.settings_choice_full(id, options, selected, custom_label, cx, on_pick) + } + + fn settings_choice_full( + &self, + id: impl Into, + options: &[&str], + selected: Option, + custom_label: Option, + cx: &mut Context, + on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, + ) -> AnyElement { + let on_pick = Rc::new(on_pick); + let label: SharedString = selected + .and_then(|i| options.get(i)) + .map(|l| SharedString::from(l.to_string())) + .or_else(|| custom_label.clone().map(SharedString::from)) + .unwrap_or_default(); + let mut entries: Vec = options + .iter() + .enumerate() + .map(|(i, l)| { + let on_pick = on_pick.clone(); + MenuEntry::item( + l.to_string(), + selected == Some(i), + move |this, window, cx| on_pick(this, i, window, cx), + ) + }) + .collect(); + if let Some(custom) = custom_label.filter(|_| selected.is_none()) { + entries.push(MenuEntry::Separator); + entries.push(MenuEntry::item(custom, true, |_, _, _| {})); + } + self.settings_dropdown(id, label, None, entries, cx) + } + + /// A segmented control. Settings rows pick with [`Self::settings_choice`]; + /// this is for forms outside the settings window — the SSH strip's forward + /// form — which have no settings popover to hang a menu from. pub(crate) fn segmented( &self, id: impl Into, @@ -602,48 +672,10 @@ impl Tty7App { selected: usize, cx: &mut Context, on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, - ) -> AnyElement { - self.segmented_full(id, options, Some(selected), None, cx, on_pick) - } - - /// A segmented control over a fixed set of values, used where the config - /// accepts anything in a range. When the live value matches a bucket - /// exactly that bucket is highlighted; when it does not, a trailing - /// "Custom (N)" cell carries the highlight instead of the nearest bucket - /// getting a label it does not have (#550). - /// - /// The custom cell is not a button — there is no bucket value behind it to - /// write — so it takes neither a click handler nor a pointer cursor. - pub(crate) fn segmented_valued( - &self, - id: impl Into, - options: &[&str], - selected: Option, - custom_label: Option, - cx: &mut Context, - on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, - ) -> AnyElement { - self.segmented_full(id, options, selected, custom_label, cx, on_pick) - } - - fn segmented_full( - &self, - id: impl Into, - options: &[&str], - selected: Option, - custom_label: Option, - cx: &mut Context, - on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, ) -> AnyElement { let tk = Tk::of(cx); let id: SharedString = id.into(); let on_pick = Rc::new(on_pick); - let cells: Vec<(String, Option)> = options - .iter() - .enumerate() - .map(|(i, l)| (l.to_string(), Some(i))) - .chain(custom_label.map(|l| (l, None))) - .collect(); h_flex() .id(ElementId::Name(id.clone())) .flex_shrink_0() @@ -651,12 +683,10 @@ impl Tty7App { .gap(px(2.)) .rounded(px(7.)) .bg(tk.k05) - .children(cells.into_iter().enumerate().map(|(i, (label, bucket))| { - // A bucket is highlighted only on an exact match, and the - // custom cell (`bucket == None`) exactly when no bucket was. - let active = bucket == selected; + .children(options.iter().enumerate().map(|(i, label)| { + let active = i == selected; let on_pick = on_pick.clone(); - let cell = h_flex() + h_flex() .id(ElementId::NamedInteger(id.clone(), i as u64)) .items_center() .justify_center() @@ -671,16 +701,11 @@ impl Tty7App { .when(!active, |s| { s.text_color(tk.k5).hover(move |h| h.text_color(tk.fg)) }) - .child(label); - match bucket { - Some(ix) => { - cell.cursor_pointer() - .on_click(cx.listener(move |this, _, window, cx| { - on_pick(this, ix, window, cx); - })) - } - None => cell, - } + .child(label.to_string()) + .cursor_pointer() + .on_click(cx.listener(move |this, _, window, cx| { + on_pick(this, i, window, cx); + })) })) .into_any_element() } diff --git a/src/ui/settings/shortcuts.rs b/src/ui/settings/shortcuts.rs index 5cfa9126..5fe163d2 100644 --- a/src/ui/settings/shortcuts.rs +++ b/src/ui/settings/shortcuts.rs @@ -39,7 +39,7 @@ impl Tty7App { // The toolbar: the filter, a way back from every change at once, and // the preset the whole table starts from. - let presets = self.segmented( + let presets = self.settings_choice( "kb-preset", &[t(L10nKey::SettingsDefault), "tmux"], usize::from(tmux), @@ -91,7 +91,7 @@ impl Tty7App { .text_color(tk.k5) .child(t(L10nKey::SettingsPrefix)), ) - .child(self.segmented( + .child(self.settings_choice( "kb-prefix", &["⌃B", "⌃A"], usize::from(prefix == "ctrl-a"),