Files
tty7/src/ui/settings/shell.rs
T
l0ng-ai c103a57c01 feat(settings): every pick-one setting is a dropdown (#979)
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.
2026-09-27 17:12:03 +08:00

1623 lines
62 KiB
Rust

//! The settings window's frame: the navigation, the page header, the rows and
//! groups every page is built from, the popovers controls open, and the
//! search and "modified" views that stand in for a page.
use std::rc::Rc;
use super::kit::{self, BtnKind, Tk, fs};
use super::*;
/// What a menu row does when picked.
pub(crate) type PickFn = Rc<dyn Fn(&mut Tty7App, &mut Window, &mut Context<Tty7App>)>;
/// One line of a dropdown's menu.
pub(crate) enum MenuEntry {
Item {
label: SharedString,
checked: bool,
/// Draw the label in this family — a font menu shows each font.
font: Option<SharedString>,
danger: bool,
trailing: Option<SharedString>,
on_pick: PickFn,
},
Heading(SharedString),
Separator,
Note(SharedString),
}
impl MenuEntry {
pub(crate) fn item(
label: impl Into<SharedString>,
checked: bool,
on_pick: impl Fn(&mut Tty7App, &mut Window, &mut Context<Tty7App>) + 'static,
) -> Self {
MenuEntry::Item {
label: label.into(),
checked,
font: None,
danger: false,
trailing: None,
on_pick: Rc::new(on_pick),
}
}
}
/// A searchable menu's rows: what each says, and the family to draw it in.
pub(crate) struct SearchOption {
pub label: SharedString,
pub font: Option<SharedString>,
}
impl Tty7App {
// ── Popovers ─────────────────────────────────────────────────────────
pub(crate) fn settings_menu_open(&self, id: &str) -> bool {
self.active_settings()
.and_then(|s| s.menu.as_ref())
.is_some_and(|m| m.as_ref() == id)
}
/// Opens the popover `id` — closing whichever one was open — or closes it
/// when it is the one already open. `highlight` is the row the arrow keys
/// start from; a popover with a query box gets the caret in it.
pub(crate) fn toggle_settings_menu(
&mut self,
id: SharedString,
highlight: usize,
with_query: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(s) = self.active_settings_mut() else {
return;
};
if s.menu.as_ref() == Some(&id) {
s.menu = None;
let handle = s.focus_handle.clone();
window.focus(&handle, cx);
cx.notify();
return;
}
s.menu = Some(id);
s.menu_hi = highlight;
let query = s.menu_query.clone();
let scroll = s.menu_scroll.clone();
query.update(cx, |q, cx| q.set_value("", window, cx));
if let Some(s) = self.active_settings_mut() {
// Clearing the query resets the highlight; put it back.
s.menu_hi = highlight;
}
scroll.scroll_to_item(highlight);
if with_query {
let handle = query.read(cx).focus_handle(cx);
window.focus(&handle, cx);
}
cx.notify();
}
pub(crate) fn close_settings_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(s) = self.active_settings_mut() else {
return;
};
if s.menu.take().is_some() {
let handle = s.focus_handle.clone();
window.focus(&handle, cx);
cx.notify();
}
}
/// A dropdown: the trigger showing the current value, and while open, its
/// menu hanging from the trigger's right edge.
pub(crate) fn settings_dropdown(
&self,
id: impl Into<SharedString>,
label: impl Into<SharedString>,
leading: Option<AnyElement>,
entries: Vec<MenuEntry>,
cx: &mut Context<Self>,
) -> AnyElement {
let tk = Tk::of(cx);
let id: SharedString = id.into();
let open = self.settings_menu_open(&id);
let checked_at = entries
.iter()
.position(|e| matches!(e, MenuEntry::Item { checked: true, .. }))
.unwrap_or(0);
let toggle_id = id.clone();
let trigger = kit::select_trigger(
ElementId::Name(SharedString::from(format!("{id}-trigger"))),
label,
leading,
open,
&tk,
)
.on_click(cx.listener(move |this, _, window, cx| {
this.toggle_settings_menu(toggle_id.clone(), checked_at, false, window, cx)
}));
div()
.relative()
.flex_shrink_0()
.child(trigger)
.when(open, |d| {
d.child(kit::popover_below(
true,
4.,
self.settings_menu_panel(&id, entries, cx).min_w(px(148.)),
))
})
.into_any_element()
}
pub(crate) fn settings_menu_panel(
&self,
id: &str,
entries: Vec<MenuEntry>,
cx: &mut Context<Self>,
) -> Div {
let tk = Tk::of(cx);
let mut panel = kit::menu_panel(&tk);
for (i, entry) in entries.into_iter().enumerate() {
panel = match entry {
MenuEntry::Item {
label,
checked,
font,
danger,
trailing,
on_pick,
} => panel.child(
kit::menu_row(
ElementId::Name(SharedString::from(format!("{id}-item-{i}"))),
false,
&tk,
)
.child(kit::check_mark(checked, &tk))
.child(
div()
.flex_1()
.when_some(font, |d, f| d.font_family(f))
.when(danger, |d| d.text_color(tk.danger))
.child(label),
)
.when_some(trailing, |r, text| {
r.child(
div()
.pl(px(12.))
.text_size(fs(11.5))
.text_color(tk.k4)
.child(text),
)
})
.on_click(cx.listener(move |this, _, window, cx| {
this.close_settings_menu(window, cx);
on_pick(this, window, cx);
})),
),
MenuEntry::Heading(text) => panel.child(kit::menu_heading(text, &tk)),
MenuEntry::Separator => panel.child(kit::menu_separator(&tk)),
MenuEntry::Note(text) => panel.child(
div()
.px(px(8.))
.pt(px(4.))
.pb(px(6.))
.text_size(fs(11.5))
.line_height(px(16.))
.text_color(tk.k45)
.child(text),
),
};
}
panel
}
/// A dropdown over a long list — the font menus — with a query box at the
/// top and the arrow keys to walk what it leaves.
pub(crate) fn settings_search_dropdown(
&self,
id: impl Into<SharedString>,
label: impl Into<SharedString>,
options: Rc<Vec<SearchOption>>,
current: Option<usize>,
on_pick: Rc<dyn Fn(&mut Tty7App, usize, &mut Window, &mut Context<Tty7App>)>,
cx: &mut Context<Self>,
) -> AnyElement {
let tk = Tk::of(cx);
let id: SharedString = id.into();
let open = self.settings_menu_open(&id);
let toggle_id = id.clone();
let start = current.unwrap_or(0);
let trigger = kit::select_trigger(
ElementId::Name(SharedString::from(format!("{id}-trigger"))),
label,
None,
open,
&tk,
)
.on_click(cx.listener(move |this, _, window, cx| {
this.toggle_settings_menu(toggle_id.clone(), start, true, window, cx)
}));
let popover = open.then(|| {
let Some(s) = self.active_settings() else {
return div();
};
let query = s.menu_query.read(cx).value().trim().to_lowercase();
let visible: Rc<Vec<usize>> = Rc::new(
options
.iter()
.enumerate()
.filter(|(_, o)| query.is_empty() || o.label.to_lowercase().contains(&query))
.map(|(i, _)| i)
.collect(),
);
let hi = s.menu_hi.min(visible.len().saturating_sub(1));
let keys_visible = visible.clone();
let keys_pick = on_pick.clone();
let mut list = v_flex()
.id(ElementId::Name(SharedString::from(format!("{id}-list"))))
.max_h(px(240.))
.overflow_y_scroll()
.track_scroll(&s.menu_scroll)
.p(px(4.))
.gap(px(1.));
for (row, &ix) in visible.iter().enumerate() {
let option = &options[ix];
let pick = on_pick.clone();
list = list.child(
kit::menu_row(
ElementId::NamedInteger(id.clone(), ix as u64),
row == hi,
&tk,
)
.h(px(28.))
.child(kit::check_mark(Some(ix) == current, &tk))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.when_some(option.font.clone(), |d, f| d.font_family(f))
.child(option.label.clone()),
)
.on_mouse_move(cx.listener(move |this, _, _, cx| {
if let Some(s) = this.active_settings_mut()
&& s.menu_hi != row
{
s.menu_hi = row;
cx.notify();
}
}))
.on_click(cx.listener(move |this, _, window, cx| {
this.close_settings_menu(window, cx);
pick(this, ix, window, cx);
})),
);
}
if visible.is_empty() {
list = list.child(
div()
.h(px(28.))
.px(px(8.))
.flex()
.items_center()
.text_color(tk.k35)
.child(t(L10nKey::SettingsNoMatchesShort)),
);
}
kit::menu_panel(&tk)
.w(px(260.))
.p_0()
.gap_0()
.overflow_hidden()
.capture_key_down(cx.listener(move |this, ev: &KeyDownEvent, window, cx| {
let key = ev.keystroke.key.as_str();
let len = keys_visible.len();
match key {
"down" | "up" => {
if let Some(s) = this.active_settings_mut() {
let hi = s.menu_hi.min(len.saturating_sub(1));
s.menu_hi = if key == "down" {
(hi + 1).min(len.saturating_sub(1))
} else {
hi.saturating_sub(1)
};
s.menu_scroll.scroll_to_item(s.menu_hi);
}
cx.stop_propagation();
cx.notify();
}
"enter" => {
let hi = this.active_settings().map(|s| s.menu_hi).unwrap_or(0);
if let Some(&ix) = keys_visible.get(hi.min(len.saturating_sub(1))) {
this.close_settings_menu(window, cx);
keys_pick(this, ix, window, cx);
}
cx.stop_propagation();
}
"escape" => {
this.close_settings_menu(window, cx);
cx.stop_propagation();
}
_ => {}
}
}))
.child(
h_flex()
.h(px(32.))
.px(px(12.))
.gap(px(7.))
.items_center()
.border_b_1()
.border_color(tk.k08)
.child(kit::search_glass(11., &tk))
.child(
div().flex_1().min_w_0().child(
Input::new(&s.menu_query)
.appearance(false)
.px_0()
.py_0()
.h(px(28.)),
),
)
.child(div().text_size(fs(11.)).text_color(tk.k35).child("↑↓ ↵")),
)
.child(list)
});
div()
.relative()
.flex_shrink_0()
.child(trigger)
.when_some(popover, |d, p| d.child(kit::popover_below(true, 4., p)))
.into_any_element()
}
// ── Rows and groups ──────────────────────────────────────────────────
/// A group of rows under a quiet heading, with the sentence that explains
/// the group when it needs one.
pub(crate) fn settings_group(
&self,
title: Option<&str>,
desc: Option<String>,
body: impl IntoIterator<Item = AnyElement>,
cx: &Context<Self>,
) -> AnyElement {
let tk = Tk::of(cx);
v_flex()
.when_some(title, |col, title| {
col.child(
kit::section_head(title, desc, &tk)
.id(settings_header_id(title))
.anchor_scroll(self.first_hit_anchor(title, cx)),
)
})
.children(body)
.into_any_element()
}
/// A page: its groups, forty points apart.
pub(crate) fn settings_page(groups: impl IntoIterator<Item = AnyElement>) -> AnyElement {
v_flex().gap(px(40.)).children(groups).into_any_element()
}
pub(crate) fn settings_row(
&self,
label: impl Into<String>,
desc: impl Into<String>,
control: AnyElement,
cx: &Context<Self>,
) -> Stateful<Div> {
self.settings_row_gated_when(label, desc, control, false, cx)
}
/// The same row, greyed out when `gated` — for a control another setting
/// has switched off, whose own value is still there for when it comes back.
///
/// Only the text dims. The control draws its own disabled state.
pub(crate) fn settings_row_gated_when(
&self,
label: impl Into<String>,
desc: impl Into<String>,
control: AnyElement,
gated: bool,
cx: &Context<Self>,
) -> Stateful<Div> {
let tk = Tk::of(cx);
let label = label.into();
let desc = desc.into();
let entry = settings_search_entries()
.iter()
.find(|entry| t(entry.title) == label);
let modified = entry.is_some_and(|entry| entry.modified(cx.global::<Config>()));
let capture = self
.active_settings()
.is_some_and(|s| s.search_rows.borrow().is_some());
// Descriptions can carry live status, so they must not take part in
// the identity that preserves GPUI's hover state.
let element_id = settings_row_id(&label, &desc);
let (hit, miss) = match self
.active_settings()
.filter(|s| !s.search_active && !capture)
{
Some(s) => {
let query = s.search.read(cx).value().trim().to_lowercase();
let focused = s.focused_setting.is_some_and(|k| t(k) == label);
match query.is_empty() || section_match_count(s.section, &query) == 0 {
true => (focused, false),
false => {
let hit = row_matches_query(s.section, &label, &query);
(hit, !hit)
}
}
}
None => (false, false),
};
let first_hit_anchor = self.first_hit_anchor(&label, cx);
// The control never shrinks, so on a narrow pane it would squeeze the
// label column to a letter per line. Below the width where both still
// fit, the control goes on a line of its own instead.
let stacked = self.settings_row_under(STACK_ROW_BELOW, cx);
// The reset sits on the label's own line, beside the name it resets,
// so a changed row keeps the height of an unchanged one. Rows the
// catalog cannot reset in place still say they differ.
let resettable = entry.filter(|e| {
e.title != L10nKey::SettingsSearchKeybindingsTitle
&& e.title != L10nKey::SettingsThemeIntroTitle
});
let title_line = h_flex()
.gap(px(8.))
.items_center()
.min_w_0()
.child(div().text_size(fs(13.)).text_color(tk.fg).child(label))
.when(modified, |line| match resettable {
Some(entry) => {
let key = entry.title;
line.child(
div()
.id(SharedString::from(format!("reset-setting-{key:?}")))
.flex_shrink_0()
.text_size(fs(11.5))
.text_color(tk.k4)
.cursor_pointer()
.hover(move |s| s.text_color(tk.fg))
.child(t(L10nKey::Reset))
.on_click(cx.listener(move |this, _, window, cx| {
this.reset_settings_value(key, window, cx)
})),
)
}
None => line.child(
div()
.flex_shrink_0()
.text_size(fs(11.5))
.text_color(tk.k4)
.child(t(L10nKey::SettingsModified)),
),
});
let labels = v_flex()
.gap(px(2.))
.min_w_0()
.when(gated, |col| col.opacity(0.45))
.child(title_line)
.when(!desc.is_empty(), |col| {
col.child(
div()
.text_size(fs(12.))
.line_height(px(17.))
.text_color(tk.k5)
.child(desc),
)
});
let row = div()
.id(element_id)
.flex()
.when(stacked, |row| row.flex_col().items_start().gap(px(8.)))
.when(!stacked, |row| {
row.flex_row().items_center().justify_between().gap(px(32.))
})
.min_h(px(52.))
.py(px(12.))
.when(hit, |row| {
row.bg(tk.k04).rounded(px(7.)).mx(px(-8.)).px(px(8.))
})
// Only the first hit on the page carries the anchor: it is the one
// the page scrolls to.
.anchor_scroll(first_hit_anchor)
.when(miss, |row| row.opacity(0.45))
.child(labels)
.child(
h_flex()
.when(stacked, |c| c.w_full())
.when(!stacked, |c| c.flex_shrink_0().justify_end())
.gap(px(8.))
.child(control),
);
if capture {
if let Some(entry) = entry {
self.active_settings()
.unwrap()
.search_rows
.borrow_mut()
.as_mut()
.unwrap()
.push((entry.title, row.into_any_element()));
return div().id("captured-setting");
}
}
row
}
/// A text field for the right-hand column, with its focus ring.
pub(crate) fn settings_text_input(
&self,
input: &Entity<InputState>,
width: f32,
invalid: bool,
cx: &App,
) -> Div {
let tk = Tk::of(cx);
let focused = self.settings_input_focused(input, cx);
kit::text_field(input, focused, invalid, &tk, cx)
.w(px(width))
.max_w_full()
}
pub(crate) fn settings_input_focused(&self, input: &Entity<InputState>, cx: &App) -> bool {
let handle = input.read(cx).focus_handle(cx);
self.active_settings()
.and_then(|s| s.focused.borrow().clone())
.is_some_and(|f| f == handle)
}
/// A text field with the line that says what is wrong with it.
pub(crate) fn settings_checked_input(
&self,
input: &Entity<InputState>,
width: f32,
error: Option<String>,
cx: &App,
) -> AnyElement {
let tk = Tk::of(cx);
let invalid = error.is_some();
v_flex()
.items_end()
.gap(px(4.))
.child(self.settings_text_input(input, width, invalid, cx))
.when_some(error, |col, e| {
col.child(
div()
.max_w(px(width.max(220.)))
.text_size(fs(12.))
.text_color(tk.danger)
.child(e),
)
})
.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<SharedString>,
options: &[&str],
selected: usize,
cx: &mut Context<Self>,
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + '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<SharedString>,
options: &[&str],
selected: Option<usize>,
custom_label: Option<String>,
cx: &mut Context<Self>,
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + 'static,
) -> AnyElement {
self.settings_choice_full(id, options, selected, custom_label, cx, on_pick)
}
fn settings_choice_full(
&self,
id: impl Into<SharedString>,
options: &[&str],
selected: Option<usize>,
custom_label: Option<String>,
cx: &mut Context<Self>,
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + '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<MenuEntry> = 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<SharedString>,
options: &[&str],
selected: usize,
cx: &mut Context<Self>,
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + 'static,
) -> AnyElement {
let tk = Tk::of(cx);
let id: SharedString = id.into();
let on_pick = Rc::new(on_pick);
h_flex()
.id(ElementId::Name(id.clone()))
.flex_shrink_0()
.p(px(2.))
.gap(px(2.))
.rounded(px(7.))
.bg(tk.k05)
.children(options.iter().enumerate().map(|(i, label)| {
let active = i == selected;
let on_pick = on_pick.clone();
h_flex()
.id(ElementId::NamedInteger(id.clone(), i as u64))
.items_center()
.justify_center()
.h(px(22.))
.px(px(10.))
.rounded(px(5.))
.whitespace_nowrap()
.text_size(fs(12.))
.when(active, |s| {
s.bg(tk.chip).shadow(tk.raised()).text_color(tk.fg)
})
.when(!active, |s| {
s.text_color(tk.k5).hover(move |h| h.text_color(tk.fg))
})
.child(label.to_string())
.cursor_pointer()
.on_click(cx.listener(move |this, _, window, cx| {
on_pick(this, i, window, cx);
}))
}))
.into_any_element()
}
/// A switch bound to a setter.
pub(crate) fn settings_switch(
&self,
id: &'static str,
on: bool,
cx: &mut Context<Self>,
set: impl Fn(&mut Self, bool, &mut Window, &mut Context<Self>) + 'static,
) -> AnyElement {
kit::switch(id)
.checked(on)
.on_click(cx.listener(move |this, on: &bool, window, cx| set(this, *on, window, cx)))
.into_any_element()
}
pub(crate) fn settings_button(
&self,
id: impl Into<ElementId>,
label: impl Into<SharedString>,
cx: &mut Context<Self>,
act: impl Fn(&mut Self, &mut Window, &mut Context<Self>) + 'static,
) -> kit::Btn {
kit::button(id, label, BtnKind::Secondary)
.on_click(cx.listener(move |this, _, window, cx| act(this, window, cx)))
}
// ── The frame ────────────────────────────────────────────────────────
pub(crate) fn render_settings(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let tk = Tk::of(cx);
// Opaque, so workspace translucency never shows through the page, but
// with the preset's gradient rather than a flat color.
let background: Background = crate::ui::theme::overlay_background(cx);
// That fill also covers the theme's background image, so the page
// carries its own copy, dimmed the way the workspace dims it.
let background_layers = crate::ui::app::overlay_surface_layers(cx);
let (focus_handle, section, search) = match self.active_settings() {
Some(s) => (s.focus_handle.clone(), s.section, s.search.clone()),
None => return div(),
};
if let Some(s) = self.active_settings() {
s.focused.replace(window.focused(cx));
}
let query = search.read(cx).value().trim().to_lowercase();
let searching = self.active_settings().is_some_and(|s| s.search_active);
let menu_open = self.active_settings().is_some_and(|s| s.menu.is_some());
let viewport_w = window.viewport_size().width.as_f32();
let scale = ui_scale(cx);
self.settings_viewport_w.set(viewport_w);
self.settings_row_width
.set(settings_row_width(viewport_w, scale));
self.settings_hit_anchored.set(false);
// A query that matched here has to be reachable, not just counted: the
// anchor lands on the first matching row, and `scroll_to` reads where
// it ended up on the frame after this one.
if searching
&& let Some(s) = self.active_settings()
&& s.reveal_first_hit.replace(false)
{
s.search_anchor.scroll_to(window, cx);
}
if let Some(s) = self.active_settings()
&& !searching
&& s.reveal_first_hit.get()
{
s.reveal_first_hit.set(false);
let matched_here =
section_match_count(section, &query) > 0 || s.focused_setting.is_some();
if s.focused_setting.is_some() || (!query.is_empty() && matched_here) {
s.search_anchor.scroll_to(window, cx);
}
}
let prof = crate::ui::perf::enabled()
.then(|| (std::time::Instant::now(), section.profile_label()));
let nav = self.render_settings_nav(nav_width(viewport_w, scale), searching, cx);
let (title, subtitle, content) = if searching {
self.render_settings_results(cx)
} else {
let content = match section {
SettingsSection::General => self.render_settings_general(cx),
SettingsSection::Appearance => self.render_settings_appearance(cx),
SettingsSection::Terminal => self.render_settings_terminal(cx),
SettingsSection::KeyboardMouse => self.render_settings_input(cx),
SettingsSection::Ssh => self.render_settings_ssh(cx),
SettingsSection::Agents => self.render_settings_agents(cx),
SettingsSection::WindowTabs => self.render_window_preferences(cx),
SettingsSection::Keybindings => self.render_settings_keybindings(cx),
SettingsSection::About => self.render_settings_about(cx),
};
(t(section.title()).to_string(), None, content)
};
let back = (!searching && section == SettingsSection::Keybindings).then(|| {
h_flex()
.id("settings-back")
.mt(px(-20.))
.mb(px(-24.))
.ml(px(-2.))
.gap(px(4.))
.items_center()
.text_size(fs(12.))
.text_color(tk.k5)
.cursor_pointer()
.hover(move |s| s.text_color(tk.fg))
.child(Icon::empty().path("icons/settings/back.svg").size(px(10.)))
.child(t(L10nKey::SettingsNavInput))
.on_click(cx.listener(|this, _, window, cx| {
this.navigate_settings(SettingsSection::KeyboardMouse, None, window, cx)
}))
});
let header = h_flex()
.items_baseline()
.gap(px(10.))
.child(
div()
.text_size(fs(20.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(tk.fg)
.child(title),
)
.when_some(subtitle, |row, sub| {
row.child(div().text_size(fs(12.)).text_color(tk.k4).child(sub))
});
let notices = self.render_settings_notices(cx);
let body = v_flex()
.id("settings-content")
.size_full()
.overflow_y_scroll()
.when_some(self.active_settings(), |pane, s| {
pane.track_scroll(&s.content_scroll)
})
.child(
// A block, not a flex box: as a flex item it would negotiate a
// height with the pane and leave most of the page outside the
// scroll range.
div().w_full().px(px(56.)).pb(px(48.)).child(
v_flex()
.w_full()
.max_w(px(READING_COLUMN * scale))
.gap(px(40.))
.children(back)
.child(header)
.children(notices)
.child(content),
),
);
let main = v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(div().h(px(TITLE_BAR_HEIGHT)).flex_shrink_0())
.when_some(self.active_settings(), |pane, s| {
pane.child(crate::ui::scrollbar::with_inset_vertical_scrollbar(
"settings-content-scrollbar",
body,
&s.content_scroll,
px(SCROLLBAR_WINDOW_INSET),
))
});
let root = div()
.size_full()
.relative()
.flex()
.flex_row()
.bg(background)
.text_color(tk.fg)
.text_size(fs(13.))
.track_focus(&focus_handle)
.capture_key_down(cx.listener(move |this, ev: &KeyDownEvent, window, cx| {
this.settings_capture_key(searching, ev, window, cx)
}))
.on_key_down(cx.listener(move |this, ev: &KeyDownEvent, window, cx| {
if ev.keystroke.key.as_str() != "escape" {
return;
}
// Escape peels one layer at a time: an open popover, a
// pending shortcut conflict, the search, then the window.
if menu_open {
this.close_settings_menu(window, cx);
cx.stop_propagation();
return;
}
if let Some(s) = this.active_settings_mut()
&& s.kb_conflict.take().is_some()
{
cx.notify();
cx.stop_propagation();
return;
}
if searching {
if let Some(s) = this.active_settings_mut() {
s.modified_only = false;
}
if let Some(s) = this.active_settings() {
s.search
.clone()
.update(cx, |s, cx| s.set_value("", window, cx));
}
this.autoselect_settings_search(cx);
cx.stop_propagation();
return;
}
this.close_settings_checked(window, cx);
}))
.children(background_layers)
.child(nav)
.child(main)
.child(
crate::ui::app::window_move_gesture(
div()
.id("settings-titlebar-drag")
.absolute()
.top_0()
.left_0()
.right_0()
.h(px(TITLE_BAR_HEIGHT)),
"settings-titlebar-drag",
window,
cx,
)
.on_double_click(|_, window, _| window.titlebar_double_click()),
)
.child(
div()
.id("settings-close")
.absolute()
.top(px((TITLE_BAR_HEIGHT - 26.) / 2.))
.right(px(12.))
.size(px(26.))
.rounded(px(6.))
.flex()
.items_center()
.justify_center()
.occlude()
.cursor_pointer()
.hover(move |s| s.bg(tk.k05))
.child(
Icon::empty()
.path("icons/settings/close.svg")
.size(px(10.))
.text_color(tk.k45),
)
.tooltip(|window, cx| {
gpui_component::tooltip::Tooltip::new(t(L10nKey::Close)).build(window, cx)
})
.on_click(
cx.listener(|this, _, window, cx| this.close_settings_checked(window, cx)),
),
)
// While a popover is open the page under it only takes one click:
// the one that closes it.
.when(menu_open, |r| {
r.child(
div()
.id("settings-popover-scrim")
.absolute()
.top_0()
.left_0()
.size_full()
.occlude()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, window, cx| this.close_settings_menu(window, cx)),
)
.on_mouse_down(
MouseButton::Right,
cx.listener(|this, _, window, cx| this.close_settings_menu(window, cx)),
),
)
});
if let Some((start, label)) = prof {
crate::ui::perf::record(label, start.elapsed());
}
root
}
/// The save failures and the unsaved theme draft, said at the top of the
/// page with what can be done about them.
fn render_settings_notices(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let tk = Tk::of(cx);
let mut out = Vec::new();
let notice = |text: String, tk: &Tk| {
div()
.text_size(fs(12.5))
.line_height(px(18.))
.text_color(tk.fg)
.child(text)
};
if self.theme_draft_dirty() {
let error = self
.active_settings()
.and_then(|s| s.theme_draft_error.clone());
out.push(
v_flex()
.gap(px(10.))
.child(notice(t(L10nKey::SettingsThemeDraft).to_string(), &tk))
.when_some(error, |v, error| {
v.child(
div()
.text_size(fs(12.))
.text_color(tk.danger)
.child(t_fmt(L10nKey::SettingsSaveError, &[("error", &error)])),
)
})
.child(
h_flex()
.gap(px(8.))
.child(
kit::button(
"save-theme-draft",
t(L10nKey::SettingsSaveChanges),
BtnKind::Primary,
)
.on_click(cx.listener(
|this, _, window, cx| {
this.save_theme_draft(window, cx);
},
)),
)
.child(
kit::button(
"cancel-theme-draft",
t(L10nKey::Cancel),
BtnKind::Secondary,
)
.on_click(cx.listener(
|this, _, window, cx| this.cancel_theme_draft(window, cx),
)),
),
)
.into_any_element(),
);
}
if let Some(error) = self.active_settings().and_then(|s| s.save_error.clone()) {
out.push(
v_flex()
.gap(px(10.))
.child(
div()
.text_size(fs(12.5))
.text_color(tk.danger)
.child(t_fmt(L10nKey::SettingsSaveError, &[("error", &error)])),
)
.child(
h_flex().child(
kit::button(
"retry-settings-save",
t(L10nKey::SettingsRetrySave),
BtnKind::Secondary,
)
.on_click(
cx.listener(|this, _, _window, cx| {
this.persist_settings_config(cx)
}),
),
),
)
.into_any_element(),
);
}
out
}
fn render_settings_nav(
&self,
width: f32,
searching: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let tk = Tk::of(cx);
let Some(s) = self.active_settings() else {
return div().into_any_element();
};
let section = s.section.navigation_section();
let search = s.search.clone();
let modified_only = s.modified_only;
let cfg = cx.global::<Config>();
let modified_in = |target: SettingsSection| {
settings_search_entries()
.iter()
.filter(|e| e.section == target && e.modified(cfg))
.count()
};
let counts: Vec<(SettingsSection, usize)> = SettingsSection::ALL
.iter()
.map(|&target| (target, modified_in(target)))
.collect();
let total: usize = counts.iter().map(|(_, n)| n).sum();
let search_focused = self.settings_input_focused(&search, cx);
let items = counts.into_iter().map(|(target, count)| {
let active = !searching && section == target;
h_flex()
.id(SharedString::from(format!(
"settings-nav-{}",
target.profile_label()
)))
.h(px(28.))
.px(px(8.))
.gap(px(10.))
.items_center()
.rounded(px(7.))
.cursor_pointer()
.when(active, |r| r.bg(tk.k07))
.when(!active, |r| r.hover(move |s| s.bg(tk.k04)))
.child(
Icon::empty()
.path(target.icon_path())
.size(px(14.))
.text_color(if active { tk.fg } else { tk.nav }),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_color(if active { tk.fg } else { tk.nav })
.when(active, |d| d.font_weight(FontWeight::MEDIUM))
.child(t(target.title())),
)
.when(count > 0, |r| {
r.child(
div()
.text_size(fs(11.5))
.text_color(tk.k4)
.child(count.to_string()),
)
})
.on_click(cx.listener(move |this, _, window, cx| {
this.navigate_settings(target, None, window, cx)
}))
});
v_flex()
.w(px(width))
.flex_shrink_0()
.h_full()
// The main window's tab rail fill, so the two sidebars read as the
// same surface. Opaque: the rail's translucency rule would let the
// page behind show through.
.bg(gpui::rgb(cx.global::<presets::Surfaces>().rail.base))
.border_r_1()
.border_color(tk.k08)
.child(div().h(px(TITLE_BAR_HEIGHT)).flex_shrink_0())
.child(
div()
.px(px(12.))
.pt(px(4.))
.pb(px(16.))
.flex_shrink_0()
.child(
h_flex()
.h(px(28.))
.px(px(8.))
.gap(px(7.))
.items_center()
.rounded(px(7.))
.bg(tk.k04)
.child(kit::search_glass(11., &tk))
.child(
div().flex_1().min_w_0().child(
Input::new(&search)
.appearance(false)
.px_0()
.py_0()
.h(px(26.)),
),
)
.when(!search_focused, |r| {
r.child(div().text_size(fs(11.)).text_color(tk.k35).child(
if cfg!(target_os = "macos") {
"⌘F"
} else {
"Ctrl F"
},
))
}),
),
)
.child(
v_flex()
.id("settings-nav")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.px(px(12.))
.gap(px(1.))
.children(items),
)
.child(
div().p(px(12.)).border_t_1().border_color(tk.k08).child(
h_flex()
.id("settings-modified-filter")
.h(px(28.))
.px(px(8.))
.gap(px(10.))
.items_center()
.rounded(px(7.))
.cursor_pointer()
.hover(move |s| s.bg(tk.k05))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_color(tk.k6)
.child(t(L10nKey::SettingsModifiedOnly)),
)
.when(total > 0, |r| {
r.child(
div()
.text_size(fs(11.5))
.text_color(tk.k4)
.child(total.to_string()),
)
})
.child(
kit::switch("settings-modified-switch")
.small()
.checked(modified_only),
)
.on_click(cx.listener(|this, _, _window, cx| {
if let Some(s) = this.active_settings_mut() {
s.modified_only = !s.modified_only;
}
this.autoselect_settings_search(cx);
})),
),
)
.into_any_element()
}
/// Keys the page handles before any control sees them: ⌘F to the search,
/// and the arrows and Enter walking the search results.
fn settings_capture_key(
&mut self,
searching: bool,
ev: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let key = ev.keystroke.key.as_str();
if key == "f" && ev.keystroke.modifiers.secondary() && !ev.keystroke.modifiers.shift {
if let Some(s) = self.active_settings() {
let handle = s.search.read(cx).focus_handle(cx);
window.focus(&handle, cx);
if let Some(s) = self.active_settings_mut() {
s.menu = None;
}
cx.stop_propagation();
cx.notify();
}
return;
}
if !searching
|| !self
.active_settings()
.is_some_and(|s| s.search.read(cx).focus_handle(cx).is_focused(window))
|| !matches!(key, "up" | "down" | "enter")
{
return;
}
let Some(s) = self.active_settings() else {
return;
};
let query = s.search.read(cx).value().trim().to_lowercase();
let entries = search_result_order(&query, s.modified_only, cx.global::<Config>());
let actions = search_result_actions(&query, s.modified_only);
let count = entries.len() + actions.len();
if count > 0 {
let index = s.search_selection.min(count - 1);
if key == "enter" && index >= entries.len() {
let action = actions[index - entries.len()].clone();
self.open_shortcut_search(action, window, cx);
} else if key == "enter" {
let entry = entries[index];
let target = if entry.title == L10nKey::SettingsSearchKeybindingsTitle {
SettingsSection::Keybindings
} else {
entry.section
};
self.navigate_settings(target, Some(entry.title), window, cx);
} else if let Some(s) = self.active_settings_mut() {
s.search_selection = if key == "down" {
(index + 1).min(count - 1)
} else {
index.saturating_sub(1)
};
s.reveal_first_hit.set(true);
cx.notify();
}
}
cx.stop_propagation();
}
/// Opens the shortcut editor filtered to one action.
pub(crate) fn open_shortcut_search(
&mut self,
action: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.with_settings_edits_resolved(window, cx, move |this, window, cx| {
this.navigate_settings(SettingsSection::Keybindings, None, window, cx);
if let Some(s) = this.active_settings() {
s.shortcut_search
.clone()
.update(cx, |s, cx| s.set_value(action, window, cx));
}
});
}
/// Search results and the "modified" filter: the matching rows, live,
/// grouped under the page each lives on.
fn render_settings_results(
&self,
cx: &mut Context<Self>,
) -> (String, Option<String>, AnyElement) {
let tk = Tk::of(cx);
let Some(state) = self.active_settings() else {
return (String::new(), None, div().into_any_element());
};
let query = state.search.read(cx).value().trim().to_lowercase();
let modified_only = state.modified_only;
let selection = state.search_selection;
*state.search_rows.borrow_mut() = Some(Vec::new());
// Build each page that has a match once, and keep only the matching
// rows. Discarded page chrome never enters the element tree, so the
// controls keep their usual ids.
for section in SettingsSection::ALL {
if !settings_search_entries().iter().any(|entry| {
entry.section == section
&& entry_matches(entry, &query)
&& (!modified_only || entry.modified(cx.global::<Config>()))
}) {
continue;
}
match section {
SettingsSection::General => {
self.render_settings_general(cx);
}
SettingsSection::Appearance => {
self.render_settings_appearance(cx);
}
SettingsSection::Terminal => {
self.render_settings_terminal(cx);
}
SettingsSection::KeyboardMouse => {
self.render_settings_input(cx);
}
SettingsSection::WindowTabs => {
self.render_window_preferences(cx);
}
SettingsSection::Ssh => {
self.render_ssh_connection_rows(cx);
}
SettingsSection::Agents => {
self.render_command_line_rows(cx);
}
SettingsSection::About => {
self.render_settings_about(cx);
}
SettingsSection::Keybindings => {}
}
}
let mut controls = self
.active_settings()
.unwrap()
.search_rows
.borrow_mut()
.take()
.unwrap_or_default();
let matches = search_result_order(&query, modified_only, cx.global::<Config>());
let actions = search_result_actions(&query, modified_only);
let mut groups: Vec<AnyElement> = Vec::new();
let mut index = 0usize;
for section in SettingsSection::ALL {
let here: Vec<&&SearchEntry> =
matches.iter().filter(|e| e.section == section).collect();
if here.is_empty() {
continue;
}
let mut rows = Vec::new();
for entry in here {
let title = entry.title;
let target = if title == L10nKey::SettingsSearchKeybindingsTitle {
SettingsSection::Keybindings
} else {
entry.section
};
let control = controls
.iter()
.position(|(key, _)| *key == title)
.map(|i| controls.remove(i).1);
let selected = index == selection;
let row = control.unwrap_or_else(|| {
self.settings_row(
t(title),
entry.description(),
kit::button(
SharedString::from(format!("search-open-{title:?}")),
t(L10nKey::SettingsOpenSetting),
BtnKind::Secondary,
)
.on_click(cx.listener(move |this, _, window, cx| {
this.navigate_settings(target, Some(title), window, cx)
}))
.into_any_element(),
cx,
)
.into_any_element()
});
rows.push(
div()
.id(SharedString::from(format!("search-result-{title:?}")))
.when(selected, |d| {
d.bg(tk.k04).rounded(px(7.)).mx(px(-8.)).px(px(8.))
})
.when_some(
self.active_settings()
.filter(|_| selected)
.map(|s| s.search_anchor.clone()),
|d, anchor| d.anchor_scroll(Some(anchor)),
)
.child(row)
.into_any_element(),
);
index += 1;
}
groups.push(
v_flex()
.child(
div()
.id(SharedString::from(format!(
"search-page-{}",
section.profile_label()
)))
.pb(px(8.))
.text_size(fs(11.5))
.font_weight(FontWeight::SEMIBOLD)
.text_color(tk.heading)
.cursor_pointer()
.hover(move |s| s.text_color(tk.fg))
.child(t(section.title()))
.on_click(cx.listener(move |this, _, window, cx| {
this.navigate_settings(section, None, window, cx)
})),
)
.children(rows)
.into_any_element(),
);
}
// Action names stay searchable without a whole shortcut editor in the
// results: each one is a row that opens the editor on it.
if !actions.is_empty() {
let effective = crate::ui::keymap::effective_chords(cx);
let mut rows = Vec::new();
for action in actions.iter() {
let (_, label) = crate::ui::keymap::action_entry(action);
let chords = effective
.iter()
.find(|(a, _)| a == action)
.and_then(|(_, keys)| keys.first().cloned())
.unwrap_or_default();
let selected = index == selection;
let action = action.clone();
rows.push(
h_flex()
.id(SharedString::from(format!("search-shortcut-{index}")))
.h(px(34.))
.mx(px(-8.))
.px(px(8.))
.gap(px(16.))
.items_center()
.rounded(px(7.))
.cursor_pointer()
.when(selected, |d| d.bg(tk.k04))
.hover(move |s| s.bg(tk.k04))
.when_some(
self.active_settings()
.filter(|_| selected)
.map(|s| s.search_anchor.clone()),
|d, anchor| d.anchor_scroll(Some(anchor)),
)
.child(div().flex_1().min_w_0().truncate().child(label))
.child(Self::keycap_row(&chords, &tk))
.on_click(cx.listener(move |this, _, window, cx| {
this.open_shortcut_search(action.clone(), window, cx)
}))
.into_any_element(),
);
index += 1;
}
groups.push(
v_flex()
.child(kit::section_head(
t(L10nKey::SettingsSearchKeybindingsTitle),
None,
&tk,
))
.children(rows)
.into_any_element(),
);
}
let total = index;
let title = if modified_only && query.is_empty() {
t(L10nKey::SettingsModifiedTitle).to_string()
} else {
t(L10nKey::SettingsSearchResults).to_string()
};
let subtitle = (!(modified_only && query.is_empty()))
.then(|| t_plural(L10nKey::SettingsMatchCount, total, &[]));
if groups.is_empty() {
let text = if modified_only && query.is_empty() {
t(L10nKey::SettingsNothingModified).to_string()
} else if modified_only {
t(L10nKey::SettingsNoModified).to_string()
} else {
t_fmt(L10nKey::SettingsNothingMatches, &[("query", &query)])
};
groups.push(
div()
.py(px(24.))
.text_color(tk.k35)
.child(text)
.into_any_element(),
);
}
(title, subtitle, Self::settings_page(groups))
}
/// A shortcut as keycaps: `⌘ T`, `⌃B then X`.
pub(crate) fn keycap_row(spec: &str, tk: &Tk) -> Div {
let chords = crate::ui::keymap::key_chords(spec);
h_flex()
.flex_shrink_0()
.gap(px(5.))
.items_center()
.children(chords.into_iter().enumerate().map(|(i, chord)| {
h_flex()
.gap(px(5.))
.items_center()
.when(i > 0, |d| {
d.child(
div()
.text_size(fs(11.))
.text_color(tk.k4)
.child(t(L10nKey::SettingsKeyThen)),
)
})
.child(
h_flex()
.gap(px(2.))
.children(chord.into_iter().map(|key| kit::keycap(key, tk))),
)
}))
}
}
/// The search results in the order the page draws them: grouped by page in
/// nav order, best match first within each.
pub(crate) fn search_result_order<'a>(
query: &str,
modified_only: bool,
cfg: &Config,
) -> Vec<&'a SearchEntry> {
let mut matches: Vec<&SearchEntry> = settings_search_entries()
.iter()
.filter(|entry| entry_matches(entry, query) && (!modified_only || entry.modified(cfg)))
.collect();
matches.sort_by_key(|entry| {
(
SettingsSection::ALL
.iter()
.position(|&s| s == entry.section)
.unwrap_or(0),
entry.rank(query),
)
});
matches
}
/// The shortcut actions a query names, after the settings it matched.
pub(crate) fn search_result_actions(query: &str, modified_only: bool) -> Vec<String> {
if query.is_empty() || modified_only {
return Vec::new();
}
crate::ui::keymap::default_bindings()
.into_iter()
.filter(|(action, _)| keybinding_matches_query(action, query))
.map(|(action, _)| action.to_string())
.collect()
}