Settings: a settings window, its sections, a search, and what fills them

SettingsLayout lists sections down the side and shows the open one, the
list going above the page in a narrow box; SettingsSection and
SettingsRow title and hold settings, a row's control wrapping under its
name. SettingsSearch finds a setting by its name or words and opens its
section. ThemeSelector, AccentColorPicker, FontSizeControl and
DensitySelector set the look; KeyboardShortcutsList finds shortcuts by
every word; ProxySettings, PrivacySettings, NotificationSettings,
StartupSettings, StorageSettings with its clear, ImportExportSettings,
ResetToDefault, FeatureFlags and DeveloperModeToggle fill the rest.
Every handler a control needs is required and fails loud when missing.
ColorPalette wraps its swatches in a row capped at its columns, so it
keeps their size in a box sized by its content, and its swatches carry
the palette's id. ThemeSwitcher points here: src/theme sits under the
chapters that draw with buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 12:22:58 -04:00
1 parent 4ab478c3c3
commit 93de2a6bd6
18 files changed
+2873 -27

No files matched your search

+2
View File
@@ -28,6 +28,7 @@ mod navigation;
mod overlays;
mod primitives;
mod project;
mod settings;
mod shell;
mod tables;
mod terminal;
@@ -83,6 +84,7 @@ pub const ALL: &[Page] = &[
canvas::PAGE,
devtools::PAGE,
dashboard::PAGE,
settings::PAGE,
theme::PAGE,
];
+46
View File
@@ -0,0 +1,46 @@
use gpui::{AnyElement, App, Window, div, prelude::*};
mod pages;
mod window;
use super::Page;
use crate::script::Step;
pub const PAGE: Page = Page {
number: 35,
slug: "settings",
title: "Settings",
summary: "A settings window: its sections, a search over them, and the controls each one holds.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::DownAt("settings-layout", 60.0, 16.0),
Step::UpAt("settings-layout", 60.0, 16.0),
Step::Key("down"),
Step::Wait(200),
Step::Shot("keyboard"),
Step::Key("down"),
Step::Wait(200),
Step::Shot("network"),
Step::Key("down"),
Step::Wait(200),
Step::Shot("privacy"),
Step::Key("down"),
Step::Wait(200),
Step::Shot("notifications"),
Step::Key("down"),
Step::Wait(200),
Step::Shot("startup"),
Step::Key("down"),
Step::Wait(200),
Step::Shot("storage"),
Step::Key("down"),
Step::Wait(200),
Step::Shot("advanced"),
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div().child(window::settings(window, cx)).into_any_element()
}
+241
View File
@@ -0,0 +1,241 @@
use ely_gpui_component::{
forms::{Choice, Combobox, FontPicker},
settings::{
AccentColorPicker, Changed, DensitySelector, DeveloperModeToggle, FeatureFlags,
FontSizeControl, ImportExportSettings, KeyboardShortcutsList, NotificationSettings,
PrivacySettings, ProxySettings, ResetToDefault, SettingsRow, SettingsSection, Shortcut,
StartupSettings, StorageSettings, ThemeSelector,
},
theme::{ActiveTheme, HUE_NAMES},
};
use gpui::{AnyElement, App, Entity, IntoElement, ParentElement, Styled, div, px};
use super::window::{Fields, Prefs, SIZE};
use crate::ui::change;
/// The open section's page.
pub(super) fn page(prefs: &Entity<Prefs>, fields: &Fields, section: &str, cx: &App) -> AnyElement {
match section {
"appearance" => appearance(prefs, fields, cx),
"keyboard" => keyboard(prefs, fields, cx),
"network" => network(prefs, cx),
"privacy" => privacy(prefs, cx),
"notifications" => notifications(prefs, cx),
"startup" => startup(prefs, cx),
"storage" => storage(prefs, cx),
"advanced" => advanced(prefs, fields, cx),
other => panic!("settings demo: no page {other}"),
}
}
fn appearance(prefs: &Entity<Prefs>, fields: &Fields, cx: &App) -> AnyElement {
let now = prefs.read(cx);
let presets = HUE_NAMES
.iter()
.take(6)
.enumerate()
.map(|(ix, name)| (*name, cx.theme().colors.hue(ix, "accent presets")));
let [theme, accent, size, density, font] = [(); 5].map(|_| prefs.clone());
SettingsSection::new("Appearance")
.description("How the app looks.")
.row(
SettingsRow::new("Theme").control(
ThemeSelector::new("settings-theme", now.appearance).on_change(
move |next, _, cx| change(&theme, cx, |prefs| prefs.appearance = next),
),
),
)
.row(
SettingsRow::new("Accent color")
.description("Buttons, links and what is chosen.")
.control(
AccentColorPicker::new("settings-accent", now.accent, presets).on_change(
move |next, _, cx| change(&accent, cx, |prefs| prefs.accent = next),
),
),
)
.row(
SettingsRow::new("Font size").control(
FontSizeControl::new("settings-size", now.size, SIZE, (11.0, 20.0))
.on_change(move |next, _, cx| change(&size, cx, |prefs| prefs.size = next)),
),
)
.row(
SettingsRow::new("Density")
.description("How close things sit.")
.control(
DensitySelector::new("settings-density", now.density).on_change(
move |next, _, cx| change(&density, cx, |prefs| prefs.density = next),
),
),
)
.row(
SettingsRow::new("Font")
.description("FontFamilySelect → forms::FontPicker")
.control(
div().w(px(220.)).child(
FontPicker::new("settings-font", &fields.font)
.selected(now.family.clone())
.on_change(move |next, _, cx| {
change(&font, cx, |prefs| prefs.family = next.clone())
}),
),
),
)
.into_any_element()
}
fn shortcut(group: &str, name: &str, keys: &str) -> Shortcut {
Shortcut {
group: group.to_string().into(),
name: name.to_string().into(),
keys: keys.to_string().into(),
}
}
fn keyboard(prefs: &Entity<Prefs>, fields: &Fields, cx: &App) -> AnyElement {
let language = prefs.clone();
SettingsSection::new("Keyboard and language")
.description("ShortcutEditor → editor::KeybindingsEditor")
.row(
SettingsRow::new("Language")
.description("LanguageSelector → forms::Combobox::languages")
.control(
div().w(px(220.)).child(
Combobox::languages("settings-language", &fields.language)
.selected(prefs.read(cx).language.clone())
.on_change(move |next, _, cx| {
change(&language, cx, |prefs| prefs.language = next.clone())
}),
),
),
)
.row(KeyboardShortcutsList::new(
[
shortcut("General", "Command palette", "cmd-shift-p"),
shortcut("General", "Settings", "cmd-,"),
shortcut("Editing", "Undo", "cmd-z"),
shortcut("Editing", "Find", "cmd-f"),
shortcut("Window", "New window", "cmd-shift-n"),
],
&fields.shortcuts,
))
.into_any_element()
}
fn network(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
let proxy = prefs.clone();
SettingsSection::new("Network")
.row(
ProxySettings::new("settings-proxy", prefs.read(cx).proxy.clone())
.on_apply(move |next, _, cx| change(&proxy, cx, |prefs| prefs.proxy = next)),
)
.into_any_element()
}
fn privacy(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
let privacy = prefs.clone();
PrivacySettings::new("settings-privacy", prefs.read(cx).privacy)
.on_change(move |next, _, cx| change(&privacy, cx, |prefs| prefs.privacy = next))
.into_any_element()
}
fn notifications(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
let notices = prefs.clone();
NotificationSettings::new("settings-notices", prefs.read(cx).notices.clone())
.on_change(move |next, _, cx| change(&notices, cx, |prefs| prefs.notices = next))
.into_any_element()
}
fn startup(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
let startup = prefs.clone();
let pages = [
Choice::new("last", "Where I left off"),
Choice::new("home", "The home page"),
Choice::new("blank", "A blank window"),
];
StartupSettings::new("settings-startup", prefs.read(cx).startup.clone(), pages)
.on_change(move |next, _, cx| change(&startup, cx, |prefs| prefs.startup = next))
.into_any_element()
}
fn storage(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
let now = prefs.read(cx);
let [cleared, reset, all] = [(); 3].map(|_| prefs.clone());
let changed = (now.size != SIZE).then(|| Changed {
key: "size".into(),
name: "Font size".into(),
now: format!("{} pt", now.size).into(),
default: format!("{SIZE} pt").into(),
});
div()
.flex()
.flex_col()
.gap_8()
.child(
SettingsSection::new("Storage")
.description("What the app keeps on this device.")
.row(
StorageSettings::new("settings-storage", now.stores.clone()).on_clear(
move |key, _, cx| {
change(&cleared, cx, |prefs| {
prefs
.stores
.iter_mut()
.filter(|store| store.key == *key)
.for_each(|store| store.bytes = 0)
})
},
),
),
)
.child(
SettingsSection::new("Import and export").row(
ImportExportSettings::new("settings-files")
.on_export(|_, _| log::info!("gallery: settings exported"))
.on_import(|path, _, _| {
log::info!("gallery: settings imported from {}", path.display())
}),
),
)
.child(
SettingsSection::new("Defaults")
.description("A larger font size shows here.")
.row(
ResetToDefault::new("settings-reset", changed)
.on_reset(move |_, _, cx| change(&reset, cx, |prefs| prefs.size = SIZE))
.on_reset_all(move |_, cx| change(&all, cx, |prefs| prefs.size = SIZE)),
),
)
.into_any_element()
}
fn advanced(prefs: &Entity<Prefs>, fields: &Fields, cx: &App) -> AnyElement {
let now = prefs.read(cx);
let [toggled, developer] = [(); 2].map(|_| prefs.clone());
div()
.flex()
.flex_col()
.gap_8()
.child(
SettingsSection::new("Features")
.description("AdvancedSettings / FeatureFlags")
.row(
FeatureFlags::new("settings-flags", now.flags.clone(), &fields.flags)
.on_toggle(move |key, on, _, cx| {
change(&toggled, cx, |prefs| {
prefs
.flags
.iter_mut()
.filter(|flag| flag.key == *key)
.for_each(|flag| flag.on = on)
})
}),
),
)
.child(
DeveloperModeToggle::new("settings-developer", now.developer)
.on_change(move |on, _, cx| change(&developer, cx, |prefs| prefs.developer = on)),
)
.into_any_element()
}
+274
View File
@@ -0,0 +1,274 @@
use ely_gpui_component::{
forms::TextInput,
primitives::IconName,
settings::{
Appearance, Flag, Notices, Privacy, Proxy, SettingEntry, SettingsLayout, SettingsSearch,
Stage, Startup, Store,
},
theme::{ActiveTheme, Density},
};
use gpui::{App, Entity, Hsla, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use super::pages;
use crate::{
probe::probe,
ui::{change, keep, section},
};
pub(super) const SECTIONS: [(&str, &str, IconName); 8] = [
("appearance", "Appearance", IconName::Palette),
("keyboard", "Keyboard", IconName::Keyboard),
("network", "Network", IconName::Globe),
("privacy", "Privacy", IconName::Shield),
("notifications", "Notifications", IconName::Bell),
("startup", "Startup", IconName::Power),
("storage", "Storage", IconName::HardDrive),
("advanced", "Advanced", IconName::Wrench),
];
/// Everything the settings window holds.
pub(super) struct Prefs {
pub section: SharedString,
pub appearance: Appearance,
pub accent: Hsla,
pub size: f32,
pub density: Density,
pub family: SharedString,
pub language: SharedString,
pub proxy: Proxy,
pub privacy: Privacy,
pub notices: Notices,
pub startup: Startup,
pub stores: Vec<Store>,
pub flags: Vec<Flag>,
pub developer: bool,
}
pub(super) const SIZE: f32 = 14.0;
fn flag(key: &str, name: &str, description: &str, stage: Stage, on: bool) -> Flag {
Flag {
key: key.to_string().into(),
name: name.to_string().into(),
description: description.to_string().into(),
stage,
on,
}
}
fn pair(kind: &str, channel: &str) -> (SharedString, SharedString) {
(kind.to_string().into(), channel.to_string().into())
}
impl Prefs {
fn new(accent: Hsla) -> Self {
Self {
section: "appearance".into(),
appearance: Appearance::System,
accent,
size: SIZE,
density: Density::Standard,
family: "Inter".into(),
language: "en".into(),
proxy: Proxy::System,
privacy: Privacy {
telemetry: true,
crash_reports: true,
personalization: false,
history: true,
},
notices: Notices {
channels: vec!["App".into(), "Email".into(), "Push".into()],
kinds: vec![
pair("mentions", "Mentions"),
pair("replies", "Replies"),
pair("digest", "Weekly digest"),
],
on: vec![
pair("mentions", "App"),
pair("mentions", "Push"),
pair("replies", "App"),
pair("digest", "Email"),
],
quiet: false,
},
startup: Startup {
at_login: false,
restore: true,
opens: "last".into(),
updates: true,
},
stores: vec![
Store {
key: "cache".into(),
name: "Cache".into(),
bytes: 312 << 20,
clearable: true,
},
Store {
key: "logs".into(),
name: "Logs".into(),
bytes: 48 << 20,
clearable: true,
},
Store {
key: "documents".into(),
name: "Documents".into(),
bytes: 1_240 << 20,
clearable: false,
},
],
flags: vec![
flag(
"tabs",
"Vertical tabs",
"Tabs down the side of the window.",
Stage::Beta,
false,
),
flag(
"sync",
"Settings sync",
"The same settings on every device.",
Stage::Stable,
true,
),
flag(
"suggest",
"Inline suggestions",
"The rest of a line, offered as you type.",
Stage::Experimental,
false,
),
],
developer: false,
}
}
}
/// The window's text fields, each kept across frames.
pub(super) struct Fields {
pub font: Entity<TextInput>,
pub language: Entity<TextInput>,
pub shortcuts: Entity<TextInput>,
pub flags: Entity<TextInput>,
}
fn field(
key: &'static str,
hint: &'static str,
window: &mut Window,
cx: &mut App,
) -> Entity<TextInput> {
window.use_keyed_state(key, cx, move |window, cx| {
TextInput::new(window, cx).placeholder(hint)
})
}
fn entry(section: &str, key: &str, name: &str, words: &[&str]) -> SettingEntry {
let (_, title, _) = SECTIONS
.iter()
.find(|(each, _, _)| *each == section)
.expect("a settings section");
SettingEntry {
key: key.to_string().into(),
section: (*title).into(),
name: name.to_string().into(),
words: words
.iter()
.map(|word| SharedString::from(word.to_string()))
.collect(),
}
}
/// The section each searchable setting lives in, by the setting's key.
fn home(key: &str) -> &'static str {
match key {
"theme" | "accent" | "size" | "density" | "font" => "appearance",
"language" | "shortcuts" => "keyboard",
"proxy" => "network",
"telemetry" => "privacy",
"quiet" => "notifications",
"login" => "startup",
"cache" => "storage",
"developer" => "advanced",
other => panic!("settings demo: no home for {other}"),
}
}
pub fn settings(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let accent = cx.theme().colors.accent;
let prefs = keep("settings-prefs", move || Prefs::new(accent), window, cx);
let search = field("settings-search", "Search settings", window, cx);
let fields = Fields {
font: field("settings-font", "Font", window, cx),
language: field("settings-language", "Language", window, cx),
shortcuts: field("settings-shortcuts", "Find a shortcut", window, cx),
flags: field("settings-flags", "Find a feature", window, cx),
};
let entries = [
entry("appearance", "theme", "Theme", &["dark", "light", "mode"]),
entry("appearance", "accent", "Accent color", &["color", "hue"]),
entry("appearance", "size", "Font size", &["text", "zoom"]),
entry("appearance", "density", "Density", &["compact", "spacing"]),
entry("appearance", "font", "Font", &["typeface", "family"]),
entry(
"keyboard",
"language",
"Language",
&["locale", "translation"],
),
entry(
"keyboard",
"shortcuts",
"Keyboard shortcuts",
&["keys", "bindings"],
),
entry("network", "proxy", "Proxy", &["http", "socks"]),
entry(
"privacy",
"telemetry",
"Usage data",
&["telemetry", "analytics"],
),
entry(
"notifications",
"quiet",
"Hold notices",
&["do not disturb", "quiet"],
),
entry("startup", "login", "Open at login", &["launch", "boot"]),
entry("storage", "cache", "Clear cache", &["disk", "space"]),
entry(
"advanced",
"developer",
"Developer mode",
&["debug", "inspector"],
),
];
let selected = prefs.read(cx).section.clone();
let page = pages::page(&prefs, &fields, &selected, cx);
let [found, picked] = [(); 2].map(|_| prefs.clone());
section(
"SettingsLayout · SettingsSection · SettingsRow · SettingsSearch",
"A settings window: sections down the side and the open one beside them, the list going above in a narrow window. The search finds a setting by its name or the words it answers to, and Enter opens its section.",
cx,
)
.child(
div()
.flex()
.flex_col()
.gap_6()
.child(div().w(px(360.)).child(SettingsSearch::new("settings-search", entries, &search).on_pick(
move |key, _, cx| change(&found, cx, |prefs| prefs.section = home(key).into()),
)))
.child(probe(
"settings-layout",
div().w(px(880.)).child(
SettingsLayout::new("settings-layout", SECTIONS, selected)
.on_select(move |key, _, cx| change(&picked, cx, |prefs| prefs.section = key.clone()))
.page(page),
),
)),
)
}