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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 12:22:58 -04:00
1 parent 27c7cd965b
commit 380cdb0676
18 files changed
+2873 -27

No files matched your search

+1
View File
@@ -193,3 +193,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- 2026-09-25: SystemNotification is blocked. An unbundled app has no notification center (probed on macOS 27.2), and gpui 0.2.2 has no API.
- 2026-09-25: `unexpected_cfgs` declares `feature = "cargo-clippy"`, which the objc 0.2 macros test.
- 2026-09-26: Terminal proven with `alacritty_terminal` 0.26: its grid, parser and pseudo-terminal, drawn by Ely. A replayed grid serves recorded output and tests.
- 2026-09-27: ThemeSwitcher is `settings::ThemeSelector`: `src/theme` sits under every chapter and cannot draw with buttons.
+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),
),
)),
)
}
+9 -5
View File
@@ -3,7 +3,7 @@ use std::rc::Rc;
use gpui::{
App, Bounds, Corners, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, Rems, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Window, canvas, div, fill, point, prelude::*, size,
Window, canvas, div, fill, point, prelude::*, rems, size,
};
use super::super::options::Run;
@@ -192,7 +192,10 @@ impl ColorPalette {
}
impl RenderOnce for ColorPalette {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let gap = rems(0.375); // gap_1p5
let widest = (theme.control_height(ControlSize::Sm) + gap) * f32::from(self.columns) - gap;
let swatches: Vec<_> = self
.colors
.into_iter()
@@ -200,7 +203,7 @@ impl RenderOnce for ColorPalette {
.map(|(ix, (name, color))| {
let (id, on_change) = (self.id.clone(), self.on_change.clone());
let label = name.clone();
ColorSwatch::new(("swatch", ix), color)
ColorSwatch::new((self.id.clone(), format!("swatch-{ix}")), color)
.tooltip(name)
.selected(self.selected == Some(color))
.on_click(move |window, cx| {
@@ -213,9 +216,10 @@ impl RenderOnce for ColorPalette {
.collect();
div()
.id(self.id)
.grid()
.grid_cols(self.columns)
.flex()
.flex_wrap()
.gap_1p5()
.max_w(widest)
.children(swatches)
}
}
+1
View File
@@ -30,6 +30,7 @@ pub mod navigation;
pub mod overlays;
pub mod primitives;
pub mod project;
pub mod settings;
pub mod shell;
pub mod tables;
pub mod terminal;
+210
View File
@@ -0,0 +1,210 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
div,
};
use super::section::SettingsRow;
use crate::{
data_display::{Badge, Tone},
feedback::Callout,
forms::{Input, Switch, TextInput},
primitives::{Icon, IconName, Severity},
theme::{ActiveTheme, IconSize, TextSize},
};
/// How far along a feature is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
Stable,
Beta,
Experimental,
}
/// A feature flag: its key and name, what it does, how far along it is, and whether it is on.
#[derive(Clone, Debug, PartialEq)]
pub struct Flag {
pub key: SharedString,
pub name: SharedString,
pub description: SharedString,
pub stage: Stage,
pub on: bool,
}
type OnFlag = Rc<dyn Fn(&SharedString, bool, &mut Window, &mut App)>;
/// Features to turn on or off, each with what it does and how far along it is, found by a search over their names and words.
#[derive(IntoElement)]
pub struct FeatureFlags {
id: ElementId,
flags: Vec<Flag>,
search: Entity<TextInput>,
on_toggle: Option<OnFlag>,
}
impl FeatureFlags {
/// `search` is the find field's text, which the owner keeps.
pub fn new(
id: impl Into<ElementId>,
flags: impl IntoIterator<Item = Flag>,
search: &Entity<TextInput>,
) -> Self {
Self {
id: id.into(),
flags: flags.into_iter().collect(),
search: search.clone(),
on_toggle: None,
}
}
pub fn on_toggle(
mut self,
handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
/// The flags whose name or description holds every word of `query`.
fn kept_flags<'a>(flags: &'a [Flag], query: &str) -> Vec<&'a Flag> {
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
flags
.iter()
.filter(|flag| {
let (name, about) = (flag.name.to_lowercase(), flag.description.to_lowercase());
words
.iter()
.all(|word| name.contains(word) || about.contains(word))
})
.collect()
}
impl RenderOnce for FeatureFlags {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_toggle = self
.on_toggle
.unwrap_or_else(|| panic!("feature flags {:?} has no on_toggle", self.id));
let query = self.search.read(cx).text().to_string();
let theme = cx.theme();
let rows = kept_flags(&self.flags, &query).into_iter().map(|flag| {
let (key, on_toggle) = (flag.key.clone(), on_toggle.clone());
let (words, tone) = match flag.stage {
Stage::Stable => ("Stable", Tone::Neutral),
Stage::Beta => ("Beta", Tone::Info),
Stage::Experimental => ("Experimental", Tone::Warning),
};
SettingsRow::new(flag.name.clone())
.description(flag.description.clone())
.control(
div()
.flex()
.items_center()
.gap_2()
.child(Badge::new(words).tone(tone))
.child(
Switch::new((self.id.clone(), format!("flag-{}", flag.key)), flag.on)
.on_change(move |on, window, cx| {
log::info!("feature flag: {key} {on}");
on_toggle(&key, on, window, cx);
}),
),
)
});
div()
.flex()
.flex_col()
.gap_2()
.child(Input::new(&self.search).prefix(Icon::new(IconName::Search).size(IconSize::Sm)))
.children(rows)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child("Experimental features may change or go away."),
)
}
}
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// Developer mode's switch, with a warning of what it shows while on.
#[derive(IntoElement)]
pub struct DeveloperModeToggle {
id: ElementId,
on: bool,
on_change: Option<OnBool>,
}
impl DeveloperModeToggle {
pub fn new(id: impl Into<ElementId>, on: bool) -> Self {
Self {
id: id.into(),
on,
on_change: None,
}
}
pub fn on_change(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for DeveloperModeToggle {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("developer mode toggle {:?} has no on_change", self.id));
let on_change = on_change;
div()
.flex()
.flex_col()
.gap_2()
.child(
SettingsRow::new("Developer mode")
.description("Shows the inspector, logs and debug menus.")
.control(
Switch::new(self.id, self.on).on_change(move |on, window, cx| {
log::info!("developer mode: {on}");
on_change(on, window, cx);
}),
),
)
.children(self.on.then(|| {
Callout::new(Severity::Warning)
.title("Developer mode is on")
.child("Internal tools show, and some of them can change what the app stores.")
}))
}
}
#[cfg(test)]
mod tests {
use super::{Flag, Stage, kept_flags};
#[test]
fn a_flag_answers_in_its_name_or_what_it_does() {
let flag = |key: &str, name: &str, description: &str| Flag {
key: key.to_string().into(),
name: name.to_string().into(),
description: description.to_string().into(),
stage: Stage::Beta,
on: false,
};
let all = [
flag("tabs", "Vertical tabs", "Tabs down the side."),
flag("sync", "Settings sync", "The same settings everywhere."),
];
let keys = |query| {
kept_flags(&all, query)
.iter()
.map(|each| each.key.to_string())
.collect::<Vec<_>>()
};
assert_eq!(keys("side tabs"), ["tabs"]);
assert_eq!(keys("everywhere"), ["sync"]);
assert_eq!(keys(""), ["tabs", "sync"]);
}
}
+315
View File
@@ -0,0 +1,315 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Hsla, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, Styled,
Window, div,
};
use crate::{
buttons::{IconButton, SegmentedControl},
canvas::color_well,
forms::{ColorPalette, Slider},
primitives::IconName,
theme::{ActiveTheme, Density, Mode, TextSize},
};
/// How an app picks light or dark: always one, or as the system does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Appearance {
Light,
Dark,
System,
}
impl Appearance {
const ALL: [(Appearance, &'static str, IconName); 3] = [
(Appearance::Light, "Light", IconName::Sun),
(Appearance::Dark, "Dark", IconName::Moon),
(Appearance::System, "System", IconName::Monitor),
];
/// The mode it shows when the system is in `system`.
pub fn mode(self, system: Mode) -> Mode {
match self {
Appearance::Light => Mode::Light,
Appearance::Dark => Mode::Dark,
Appearance::System => system,
}
}
}
type OnAppearance = Rc<dyn Fn(Appearance, &mut Window, &mut App)>;
/// Light, dark, or as the system does, as three segments with their icons. The owner applies the choice, as through `Theme::set_mode`.
#[derive(IntoElement)]
pub struct ThemeSelector {
id: ElementId,
appearance: Appearance,
on_change: Option<OnAppearance>,
}
impl ThemeSelector {
pub fn new(id: impl Into<ElementId>, appearance: Appearance) -> Self {
Self {
id: id.into(),
appearance,
on_change: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(Appearance, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ThemeSelector {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("theme selector {:?} has no on_change", self.id));
let (_, words, _) = Appearance::ALL
.iter()
.find(|(each, _, _)| *each == self.appearance)
.expect("every appearance is offered");
Appearance::ALL
.iter()
.fold(
SegmentedControl::new(self.id, *words),
|control, (_, words, icon)| control.segment(*words, *words, Some(*icon)),
)
.on_change(move |value, window, cx| {
let (chosen, _, _) = Appearance::ALL
.iter()
.find(|(_, words, _)| *words == value.as_ref())
.expect("an appearance the control offers");
log::info!("theme selector: {chosen:?}");
on_change(*chosen, window, cx);
})
}
}
type OnColor = Rc<dyn Fn(Hsla, &mut Window, &mut App)>;
/// An accent picked from named presets, or any color from the well beside them.
#[derive(IntoElement)]
pub struct AccentColorPicker {
id: ElementId,
color: Hsla,
presets: Vec<(SharedString, Hsla)>,
on_change: Option<OnColor>,
}
impl AccentColorPicker {
pub fn new(
id: impl Into<ElementId>,
color: impl Into<Hsla>,
presets: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<Hsla>)>,
) -> Self {
Self {
id: id.into(),
color: color.into(),
presets: presets
.into_iter()
.map(|(name, color)| (name.into(), color.into()))
.collect(),
on_change: None,
}
}
pub fn on_change(mut self, handler: impl Fn(Hsla, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AccentColorPicker {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("accent color picker {:?} has no on_change", self.id));
let (picked, custom) = (on_change.clone(), on_change);
let report = |on_change: OnColor| {
move |color: Hsla, window: &mut Window, cx: &mut App| {
log::info!("accent: {color:?}");
on_change(color, window, cx);
}
};
let columns = u16::try_from(self.presets.len()).expect("a handful of presets");
div()
.flex()
.items_start()
.gap_2()
.child(
ColorPalette::new((self.id.clone(), "presets"), self.presets)
.columns(columns)
.selected(self.color)
.on_change(report(picked)),
)
.child(color_well(
(self.id, "custom").into(),
self.color,
report(custom),
))
}
}
type OnSize = Rc<dyn Fn(f32, &mut Window, &mut App)>;
/// A text size on a slider, in pixels, over a line set at it, with a way back to the default size.
#[derive(IntoElement)]
pub struct FontSizeControl {
id: ElementId,
size: f32,
default: f32,
range: (f32, f32),
on_change: Option<OnSize>,
}
impl FontSizeControl {
/// `size` and `default` lie within `range`, in pixels.
pub fn new(id: impl Into<ElementId>, size: f32, default: f32, range: (f32, f32)) -> Self {
assert!(
range.0 < range.1
&& (range.0..=range.1).contains(&size)
&& (range.0..=range.1).contains(&default),
"font size {size} and default {default} outside {range:?}"
);
Self {
id: id.into(),
size,
default,
range,
on_change: None,
}
}
pub fn on_change(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for FontSizeControl {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("font size control {:?} has no on_change", self.id));
let theme = cx.theme();
let (slid, reset, default) = (on_change.clone(), on_change, self.default);
let (low, high) = self.range;
div()
.flex()
.flex_col()
.gap_2()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div().flex_none().w(theme.label_width()).child(
Slider::new((self.id.clone(), "slider"), self.size as f64)
.range(low as f64, high as f64)
.step(1.0)
.on_change(move |size, window, cx| {
slid(size as f32, window, cx);
}),
),
)
.child(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Sm))
.child(format!("{} px", self.size)),
)
.child(
IconButton::new((self.id, "reset"), IconName::RotateCcw)
.tooltip("Back to the default size")
.disabled(self.size == default)
.on_click(move |_, window, cx| {
reset(default, window, cx);
}),
),
)
.child(
div()
.text_size(Pixels::from(self.size))
.text_color(theme.colors.fg)
.child("The quick brown fox jumps over the lazy dog."),
)
}
}
type OnDensity = Rc<dyn Fn(Density, &mut Window, &mut App)>;
/// How close things sit: compact, standard or comfortable, as three segments.
#[derive(IntoElement)]
pub struct DensitySelector {
id: ElementId,
density: Density,
on_change: Option<OnDensity>,
}
const DENSITIES: [(Density, &str); 3] = [
(Density::Compact, "Compact"),
(Density::Standard, "Standard"),
(Density::Comfortable, "Comfortable"),
];
impl DensitySelector {
pub fn new(id: impl Into<ElementId>, density: Density) -> Self {
Self {
id: id.into(),
density,
on_change: None,
}
}
pub fn on_change(mut self, handler: impl Fn(Density, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for DensitySelector {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let on_change = self
.on_change
.unwrap_or_else(|| panic!("density selector {:?} has no on_change", self.id));
let (_, words) = DENSITIES
.iter()
.find(|(each, _)| *each == self.density)
.expect("every density is offered");
DENSITIES
.iter()
.fold(
SegmentedControl::new(self.id, *words),
|control, (_, words)| control.segment(*words, *words, None),
)
.on_change(move |value, window, cx| {
let (chosen, _) = DENSITIES
.iter()
.find(|(_, words)| *words == value.as_ref())
.expect("a density the control offers");
log::info!("density: {chosen:?}");
on_change(*chosen, window, cx);
})
}
}
#[cfg(test)]
mod tests {
use super::Appearance;
use crate::theme::Mode;
#[test]
fn system_follows_the_system_and_the_rest_hold() {
assert_eq!(Appearance::System.mode(Mode::Dark), Mode::Dark);
assert_eq!(Appearance::Light.mode(Mode::Dark), Mode::Light);
assert_eq!(Appearance::Dark.mode(Mode::Light), Mode::Dark);
}
}
+24
View File
@@ -0,0 +1,24 @@
mod advanced;
mod appearance;
mod network;
mod preferences;
mod search;
mod section;
mod shortcuts;
mod storage;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use advanced::{DeveloperModeToggle, FeatureFlags, Flag, Stage};
pub use appearance::{
AccentColorPicker, Appearance, DensitySelector, FontSizeControl, ThemeSelector,
};
pub use network::{Proxy, ProxySettings, manual};
pub use preferences::{
Notices, NotificationSettings, Privacy, PrivacySettings, Startup, StartupSettings,
};
pub use search::{SettingEntry, SettingsSearch, matching};
pub use section::{SettingsLayout, SettingsRow, SettingsSection};
pub use shortcuts::{KeyboardShortcutsList, Shortcut};
pub use storage::{Changed, ImportExportSettings, ResetToDefault, StorageSettings, Store};
+212
View File
@@ -0,0 +1,212 @@
use std::rc::Rc;
use gpui::{
App, AppContext as _, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant, SegmentedControl},
forms::{FormField, Input, TextInput},
};
/// How requests reach the network: straight, through the system's proxy, or through one given here.
#[derive(Clone, Debug, PartialEq)]
pub enum Proxy {
Off,
System,
Manual {
host: SharedString,
port: u16,
bypass: Vec<SharedString>,
},
}
/// A manual proxy read from its fields: a host, a port from 1 to 65535, and hosts to reach straight, split at commas.
pub fn manual(host: &str, port: &str, bypass: &str) -> Option<Proxy> {
let host = host.trim();
let port: u16 = port.trim().parse().ok().filter(|port| *port > 0)?;
(!host.is_empty()).then(|| Proxy::Manual {
host: host.to_string().into(),
port,
bypass: bypass
.split(',')
.map(str::trim)
.filter(|each| !each.is_empty())
.map(|each| SharedString::from(each.to_string()))
.collect(),
})
}
/// The form's own fields and way, filled from the proxy it was given.
struct Fields {
seed: Proxy,
way: SharedString,
host: Entity<TextInput>,
port: Entity<TextInput>,
bypass: Entity<TextInput>,
}
type OnProxy = Rc<dyn Fn(Proxy, &mut Window, &mut App)>;
/// How requests reach the network: off, the system's proxy, or a manual one with its host, port and the hosts to reach straight. Apply hands the owner the proxy and waits for a host and a good port when manual; a new proxy from the owner refills it.
#[derive(IntoElement)]
pub struct ProxySettings {
id: ElementId,
proxy: Proxy,
on_apply: Option<OnProxy>,
}
impl ProxySettings {
pub fn new(id: impl Into<ElementId>, proxy: Proxy) -> Self {
Self {
id: id.into(),
proxy,
on_apply: None,
}
}
pub fn on_apply(mut self, handler: impl Fn(Proxy, &mut Window, &mut App) + 'static) -> Self {
self.on_apply = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ProxySettings {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (id, proxy) = (self.id, self.proxy);
let fill = |proxy: &Proxy, window: &mut Window, cx: &mut App| {
let (way, host, port, bypass) = match proxy {
Proxy::Off => ("off", String::new(), String::new(), String::new()),
Proxy::System => ("system", String::new(), String::new(), String::new()),
Proxy::Manual { host, port, bypass } => (
"manual",
host.to_string(),
port.to_string(),
bypass
.iter()
.map(|each| each.to_string())
.collect::<Vec<_>>()
.join(", "),
),
};
let mut field = |text: String| {
cx.new(|cx| {
let mut input = TextInput::new(window, cx);
input.set_text(text, cx);
input
})
};
Fields {
seed: proxy.clone(),
way: way.into(),
host: field(host),
port: field(port),
bypass: field(bypass),
}
};
let fields = window.use_keyed_state((id.clone(), "fields"), cx, |window, cx| {
fill(&proxy, window, cx)
});
if fields.read(cx).seed != proxy {
log::info!("proxy settings: refilled");
let fresh = fill(&proxy, window, cx);
fields.update(cx, |fields, _| *fields = fresh);
}
let (way, host, port, bypass) = {
let fields = fields.read(cx);
(
fields.way.clone(),
fields.host.clone(),
fields.port.clone(),
fields.bypass.clone(),
)
};
let text = |input: &Entity<TextInput>| input.read(cx).text().to_string();
let read = match way.as_ref() {
"off" => Some(Proxy::Off),
"system" => Some(Proxy::System),
"manual" => manual(&text(&host), &text(&port), &text(&bypass)),
other => panic!("proxy settings: no way named {other}"),
};
let (switched, on_apply) = (fields.clone(), self.on_apply);
div()
.flex()
.flex_col()
.gap_3()
.child(
SegmentedControl::new((id.clone(), "way"), way.clone())
.segment("off", "No proxy", None)
.segment("system", "System", None)
.segment("manual", "Manual", None)
.on_change(move |value, _, cx| {
switched.update(cx, |fields, cx| {
fields.way = value.clone();
cx.notify();
})
}),
)
.children((way == "manual").then(|| {
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.flex_wrap()
.gap_3()
.child(
div().flex_1().min_w_32().child(
FormField::new((id.clone(), "host"), "Host")
.child(Input::new(&host)),
),
)
.child(
div().w_24().flex_none().child(
FormField::new((id.clone(), "port"), "Port")
.child(Input::new(&port)),
),
),
)
.child(
FormField::new((id.clone(), "bypass"), "Reach straight")
.description("Hosts split by commas, such as localhost, *.internal")
.child(Input::new(&bypass)),
)
}))
.child(
div().flex().child(
Button::new((id, "apply"), "Apply")
.variant(ButtonVariant::Primary)
.disabled(read.is_none())
.on_click(move |_, window, cx| {
if let (Some(proxy), Some(on_apply)) = (read.clone(), &on_apply) {
log::info!("proxy settings: apply {proxy:?}");
on_apply(proxy, window, cx);
}
}),
),
)
}
}
#[cfg(test)]
mod tests {
use super::{Proxy, manual};
#[test]
fn a_manual_proxy_needs_a_host_and_a_good_port() {
assert_eq!(
manual(" proxy.corp ", "8080", "localhost, *.internal,"),
Some(Proxy::Manual {
host: "proxy.corp".into(),
port: 8080,
bypass: vec!["localhost".into(), "*.internal".into()]
})
);
assert_eq!(manual("", "8080", ""), None);
assert_eq!(manual("proxy.corp", "0", ""), None);
assert_eq!(manual("proxy.corp", "99999", ""), None);
}
}
+272
View File
@@ -0,0 +1,272 @@
use std::rc::Rc;
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
};
use super::section::{SettingsRow, SettingsSection};
use crate::{
canvas::{OnEdit, editing},
forms::{Checkbox, Choice, Select, Switch},
theme::{ActiveTheme, TextSize},
typography::Ellipsis,
};
/// What an app may share and keep.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Privacy {
pub telemetry: bool,
pub crash_reports: bool,
pub personalization: bool,
pub history: bool,
}
/// What an app may share and keep, a switch for each with what it means.
#[derive(IntoElement)]
pub struct PrivacySettings {
id: ElementId,
privacy: Privacy,
on_change: Option<OnEdit<Privacy>>,
}
impl PrivacySettings {
pub fn new(id: impl Into<ElementId>, privacy: Privacy) -> Self {
Self {
id: id.into(),
privacy,
on_change: None,
}
}
pub fn on_change(mut self, handler: impl Fn(Privacy, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PrivacySettings {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let (id, privacy, on_change) = (self.id, self.privacy, &self.on_change);
let row = |key: &'static str,
title: &'static str,
text: &'static str,
on: bool,
set: fn(&mut Privacy, bool)| {
SettingsRow::new(title).description(text).control(
Switch::new((id.clone(), key), on)
.on_change(editing("privacy", &privacy, on_change, set)),
)
};
SettingsSection::new("Privacy")
.description("What this app shares and keeps.")
.row(row(
"telemetry",
"Usage data",
"Counts of features used, without content.",
privacy.telemetry,
|p, on| p.telemetry = on,
))
.row(row(
"crashes",
"Crash reports",
"What went wrong when the app quits on its own.",
privacy.crash_reports,
|p, on| p.crash_reports = on,
))
.row(row(
"personal",
"Personal suggestions",
"Suggestions drawn from what you do here.",
privacy.personalization,
|p, on| p.personalization = on,
))
.row(row(
"history",
"Keep history",
"Recent files and searches, on this device only.",
privacy.history,
|p, on| p.history = on,
))
}
}
/// Which channels each kind of notice arrives by, and whether notices wait.
#[derive(Clone, Debug, PartialEq)]
pub struct Notices {
pub channels: Vec<SharedString>,
pub kinds: Vec<(SharedString, SharedString)>,
/// The (kind, channel) pairs that are on.
pub on: Vec<(SharedString, SharedString)>,
pub quiet: bool,
}
/// A grid of notices: a row for each kind, a box for each channel it may arrive by, and a switch that holds every notice back.
#[derive(IntoElement)]
pub struct NotificationSettings {
id: ElementId,
notices: Notices,
on_change: Option<OnEdit<Notices>>,
}
impl NotificationSettings {
pub fn new(id: impl Into<ElementId>, notices: Notices) -> Self {
Self {
id: id.into(),
notices,
on_change: None,
}
}
pub fn on_change(mut self, handler: impl Fn(Notices, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for NotificationSettings {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (id, notices, on_change) = (self.id, self.notices, &self.on_change);
let theme = cx.theme();
let cell = theme.label_width() * 0.5;
let head = div()
.flex()
.items_center()
.gap_2()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child(div().flex_1().min_w_0())
.children(notices.channels.iter().map(|channel| {
div()
.flex_none()
.w(cell)
.child(Ellipsis::new(channel.clone()))
}));
let rows = notices.kinds.iter().map(|(kind, name)| {
div()
.flex()
.items_center()
.gap_2()
.py_2()
.border_b_1()
.border_color(theme.colors.border)
.child(
div()
.flex_1()
.min_w_0()
.text_size(theme.text_size(TextSize::Sm))
.child(Ellipsis::new(name.clone())),
)
.children(notices.channels.iter().map(|channel| {
let pair = (kind.clone(), channel.clone());
let on = notices.on.contains(&pair);
let set = editing(
"notifications",
&notices,
on_change,
move |notices: &mut Notices, on: bool| {
notices.on.retain(|each| *each != pair);
if on {
notices.on.push(pair.clone());
}
},
);
div().flex_none().w(cell).child(
Checkbox::new((id.clone(), format!("{kind}-{channel}")), on).on_change(set),
)
}))
});
let quiet = editing(
"notifications",
&notices,
on_change,
|notices: &mut Notices, on: bool| notices.quiet = on,
);
SettingsSection::new("Notifications")
.description("Which notices reach you, and where.")
.row(
SettingsRow::new("Do not disturb")
.description("Hold every notice back until it is off.")
.control(Switch::new((id.clone(), "quiet"), notices.quiet).on_change(quiet)),
)
.row(div().pt_3().child(head).children(rows))
}
}
/// What an app does when it starts.
#[derive(Clone, Debug, PartialEq)]
pub struct Startup {
pub at_login: bool,
pub restore: bool,
pub opens: SharedString,
pub updates: bool,
}
/// What an app does when it starts: whether it opens at login, restores its windows and looks for updates, and what it opens to, from `pages`.
#[derive(IntoElement)]
pub struct StartupSettings {
id: ElementId,
startup: Startup,
pages: Vec<Choice>,
on_change: Option<OnEdit<Startup>>,
}
impl StartupSettings {
pub fn new(
id: impl Into<ElementId>,
startup: Startup,
pages: impl IntoIterator<Item = Choice>,
) -> Self {
Self {
id: id.into(),
startup,
pages: pages.into_iter().collect(),
on_change: None,
}
}
pub fn on_change(mut self, handler: impl Fn(Startup, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for StartupSettings {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (id, startup, on_change) = (self.id, self.startup, &self.on_change);
let switch = |key: &'static str, on: bool, set: fn(&mut Startup, bool)| {
Switch::new((id.clone(), key), on)
.on_change(editing("startup", &startup, on_change, set))
};
let open = editing(
"startup",
&startup,
on_change,
|startup: &mut Startup, page: SharedString| startup.opens = page,
);
SettingsSection::new("Startup")
.row(SettingsRow::new("Open at login").control(switch(
"login",
startup.at_login,
|s, on| s.at_login = on,
)))
.row(
SettingsRow::new("Restore windows")
.description("Reopen what was open when the app quit.")
.control(switch("restore", startup.restore, |s, on| s.restore = on)),
)
.row(
SettingsRow::new("Opens to").control(
div().w(cx.theme().label_width()).child(
Select::new((id.clone(), "opens"), self.pages)
.selected(startup.opens.clone())
.on_change(move |page, window, cx| open(page.clone(), window, cx)),
),
),
)
.row(SettingsRow::new("Look for updates").control(switch(
"updates",
startup.updates,
|s, on| s.updates = on,
)))
}
}
+149
View File
@@ -0,0 +1,149 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
div,
};
use crate::{
forms::{Input, TextInput},
lists::{ListItem, SelectableList},
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, TextSize},
};
/// A setting a search can find: its key, the section it lives in, its name, and other words it answers to.
#[derive(Clone, Debug, PartialEq)]
pub struct SettingEntry {
pub key: SharedString,
pub section: SharedString,
pub name: SharedString,
pub words: Vec<SharedString>,
}
/// The settings whose name, section or words hold every word of `query`, in order.
pub fn matching<'a>(entries: &'a [SettingEntry], query: &str) -> Vec<&'a SettingEntry> {
let terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
entries
.iter()
.filter(|entry| {
let haystack = std::iter::once(&entry.name)
.chain(std::iter::once(&entry.section))
.chain(&entry.words)
.map(|word| word.to_lowercase())
.collect::<Vec<_>>()
.join(" ");
terms.iter().all(|term| haystack.contains(term.as_str()))
})
.collect()
}
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A search across every setting: while it holds words, the settings that answer to them, each under its section; Enter or a double press goes to one.
#[derive(IntoElement)]
pub struct SettingsSearch {
id: ElementId,
entries: Vec<SettingEntry>,
search: Entity<TextInput>,
on_pick: Option<OnKey>,
}
impl SettingsSearch {
/// `search` is the field's text, which the owner keeps.
pub fn new(
id: impl Into<ElementId>,
entries: impl IntoIterator<Item = SettingEntry>,
search: &Entity<TextInput>,
) -> Self {
Self {
id: id.into(),
entries: entries.into_iter().collect(),
search: search.clone(),
on_pick: None,
}
}
pub fn on_pick(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SettingsSearch {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_pick = self
.on_pick
.unwrap_or_else(|| panic!("settings search {:?} has no on_pick", self.id));
let query = self.search.read(cx).text().trim().to_string();
let theme = cx.theme();
let found = if query.is_empty() {
Vec::new()
} else {
matching(&self.entries, &query)
};
let none = !query.is_empty() && found.is_empty();
let list = found.iter().fold(
SelectableList::new((self.id.clone(), "found")),
|list, entry| {
list.row(
entry.key.clone(),
ListItem::new(
(self.id.clone(), format!("found-{}", entry.key)),
entry.name.clone(),
)
.description(entry.section.clone()),
)
},
);
div()
.flex()
.flex_col()
.gap_2()
.child(Input::new(&self.search).prefix(Icon::new(IconName::Search).size(IconSize::Sm)))
.children((!found.is_empty()).then(|| {
list.on_activate(move |key, window, cx| {
log::info!("settings search: go to {key}");
on_pick(key, window, cx);
})
}))
.children(none.then(|| {
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_subtle)
.child(format!("No setting answers to \"{query}\"."))
}))
}
}
#[cfg(test)]
mod tests {
use super::{SettingEntry, matching};
#[test]
fn every_word_must_answer_in_the_name_the_section_or_the_words() {
let entry = |key: &str, section: &str, name: &str, words: &[&str]| SettingEntry {
key: key.to_string().into(),
section: section.to_string().into(),
name: name.to_string().into(),
words: words.iter().map(|word| word.to_string().into()).collect(),
};
let entries = [
entry("theme", "Appearance", "Theme", &["dark", "light"]),
entry("proxy", "Network", "Proxy", &["http", "socks"]),
entry("size", "Appearance", "Font size", &["text", "zoom"]),
];
let keys = |query: &str| {
matching(&entries, query)
.iter()
.map(|entry| entry.key.to_string())
.collect::<Vec<_>>()
};
assert_eq!(keys("DARK"), ["theme"]);
assert_eq!(keys("appearance zoom"), ["size"]);
assert_eq!(keys("network dark"), Vec::<String>::new());
}
}
+222
View File
@@ -0,0 +1,222 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use crate::{
lists::{ListItem, SelectableList},
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, TextSize},
};
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A settings page: its sections listed down the side with the one open beside them, the list going above the page in a narrow box. Up and Down walk the list and Enter opens a section.
#[derive(IntoElement)]
pub struct SettingsLayout {
id: ElementId,
sections: Vec<(SharedString, SharedString, IconName)>,
selected: SharedString,
page: Option<AnyElement>,
on_select: Option<OnKey>,
}
impl SettingsLayout {
/// `sections` are each a key, a name and an icon; `selected` names the one open.
pub fn new(
id: impl Into<ElementId>,
sections: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>, IconName)>,
selected: impl Into<SharedString>,
) -> Self {
let sections: Vec<_> = sections
.into_iter()
.map(|(key, name, icon)| (key.into(), name.into(), icon))
.collect();
let selected = selected.into();
assert!(
sections.iter().any(|(key, _, _)| *key == selected),
"settings layout: no section {selected}"
);
Self {
id: id.into(),
sections,
selected,
page: None,
on_select: None,
}
}
/// What the open section shows.
pub fn page(mut self, page: impl IntoElement) -> Self {
self.page = Some(page.into_any_element());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SettingsLayout {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_select = self
.on_select
.unwrap_or_else(|| panic!("settings layout {:?} has no on_select", self.id));
let theme = cx.theme();
let muted = theme.colors.fg_muted;
let list = self.sections.iter().fold(
SelectableList::new((self.id.clone(), "sections")).selected([self.selected.clone()]),
|list, (key, name, icon)| {
list.row(
key.clone(),
ListItem::new((self.id.clone(), format!("section-{key}")), name.clone())
.leading(Icon::new(*icon).size(IconSize::Sm).color(muted)),
)
},
);
let (picked, opened) = (on_select.clone(), on_select);
let list = list
.on_change(move |keys, window, cx| {
if let Some(key) = keys.first() {
log::info!("settings: open {key}");
picked(key, window, cx);
}
})
.on_activate(move |key, window, cx| {
opened(key, window, cx);
});
div()
.flex()
.flex_wrap()
.items_start()
.gap_6()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.max_w(theme.label_width() * 1.25)
.child(list),
)
.child(
div()
.flex_1()
.min_w(theme.label_width())
.children(self.page),
)
}
}
/// A titled group of settings rows, with a line of context under the title.
#[derive(IntoElement)]
pub struct SettingsSection {
title: SharedString,
description: Option<SharedString>,
rows: Vec<AnyElement>,
}
impl SettingsSection {
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
description: None,
rows: Vec::new(),
}
}
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
self.description = Some(text.into());
self
}
pub fn row(mut self, row: impl IntoElement) -> Self {
self.rows.push(row.into_any_element());
self
}
}
impl RenderOnce for SettingsSection {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.flex_col()
.gap_1()
.child(div().font_weight(FontWeight::SEMIBOLD).child(self.title))
.children(self.description.map(|text| {
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(text)
}))
.child(div().flex().flex_col().pt_2().children(self.rows))
}
}
/// One setting: its name and a line on what it does, and its control at the end of the line, or below them where the line is too narrow.
#[derive(IntoElement)]
pub struct SettingsRow {
title: SharedString,
description: Option<SharedString>,
control: Option<AnyElement>,
}
impl SettingsRow {
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
description: None,
control: None,
}
}
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
self.description = Some(text.into());
self
}
pub fn control(mut self, control: impl IntoElement) -> Self {
self.control = Some(control.into_any_element());
self
}
}
impl RenderOnce for SettingsRow {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_x_4()
.gap_y_2()
.py_3()
.border_b_1()
.border_color(theme.colors.border)
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.child(self.title),
)
.children(self.description.map(|text| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(text)
})),
)
.children(self.control.map(|control| div().flex_none().child(control)))
}
}
+132
View File
@@ -0,0 +1,132 @@
use gpui::{
App, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
div,
};
use crate::{
forms::{Input, TextInput},
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, TextSize},
typography::{Ellipsis, KbdCombo},
};
/// A shortcut: the group it sits in, what it does, and its keys as written, such as "cmd-shift-p".
#[derive(Clone, Debug, PartialEq)]
pub struct Shortcut {
pub group: SharedString,
pub name: SharedString,
pub keys: SharedString,
}
/// Shortcuts in their groups, each with what it does and its keys drawn as caps; a search keeps those whose name or keys hold its words. Editing them is `editor::KeybindingsEditor`.
#[derive(IntoElement)]
pub struct KeyboardShortcutsList {
shortcuts: Vec<Shortcut>,
search: Entity<TextInput>,
}
impl KeyboardShortcutsList {
/// `search` is the find field's text, which the owner keeps.
pub fn new(shortcuts: impl IntoIterator<Item = Shortcut>, search: &Entity<TextInput>) -> Self {
Self {
shortcuts: shortcuts.into_iter().collect(),
search: search.clone(),
}
}
}
/// The shortcuts whose name or keys hold every word of `query`.
pub(crate) fn kept<'a>(shortcuts: &'a [Shortcut], query: &str) -> Vec<&'a Shortcut> {
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
shortcuts
.iter()
.filter(|shortcut| {
let (name, keys) = (shortcut.name.to_lowercase(), shortcut.keys.to_lowercase());
words
.iter()
.all(|word| name.contains(word) || keys.contains(word))
})
.collect()
}
impl RenderOnce for KeyboardShortcutsList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let query = self.search.read(cx).text().to_string();
let theme = cx.theme();
let kept = kept(&self.shortcuts, &query);
let mut groups: Vec<SharedString> = Vec::new();
for shortcut in &kept {
if !groups.contains(&shortcut.group) {
groups.push(shortcut.group.clone());
}
}
let body =
groups.into_iter().map(|group| {
div()
.flex()
.flex_col()
.child(
div()
.pt_3()
.pb_1()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(theme.colors.fg_subtle)
.child(group.clone()),
)
.children(kept.iter().filter(|shortcut| shortcut.group == group).map(
|shortcut| {
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.py_1p5()
.border_b_1()
.border_color(theme.colors.border)
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex_1()
.min_w_0()
.child(Ellipsis::new(shortcut.name.clone())),
)
.child(div().flex_none().child(KbdCombo::new(&shortcut.keys)))
},
))
});
div()
.flex()
.flex_col()
.child(Input::new(&self.search).prefix(Icon::new(IconName::Search).size(IconSize::Sm)))
.children(body)
}
}
#[cfg(test)]
mod tests {
use super::{Shortcut, kept};
#[test]
fn every_word_answers_in_the_name_or_the_keys() {
let shortcut = |name: &str, keys: &str| Shortcut {
group: "General".into(),
name: name.to_string().into(),
keys: keys.to_string().into(),
};
let all = [shortcut("Undo", "cmd-z"), shortcut("Find", "cmd-f")];
let names = |query| {
kept(&all, query)
.iter()
.map(|each| each.name.to_string())
.collect::<Vec<_>>()
};
assert_eq!(names("cmd z"), ["Undo"], "each word may answer in the keys");
assert_eq!(
names("find cmd"),
["Find"],
"or across the name and the keys"
);
assert_eq!(names(""), ["Undo", "Find"]);
}
}
+309
View File
@@ -0,0 +1,309 @@
use std::{path::PathBuf, rc::Rc};
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant, ConfirmButton, ConfirmMode, IconButton},
data_display::UsageBar,
forms::DropZone,
primitives::IconName,
theme::{ActiveTheme, TextSize},
typography::{Ellipsis, format},
};
/// A store on disk: its key and name, its size in bytes, and whether it may be cleared.
#[derive(Clone, Debug, PartialEq)]
pub struct Store {
pub key: SharedString,
pub name: SharedString,
pub bytes: u64,
pub clearable: bool,
}
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// What an app keeps on disk: a bar of each store's share, then each store with its size and, where it may go, a Clear that asks twice.
#[derive(IntoElement)]
pub struct StorageSettings {
id: ElementId,
stores: Vec<Store>,
on_clear: Option<OnKey>,
}
impl StorageSettings {
pub fn new(id: impl Into<ElementId>, stores: impl IntoIterator<Item = Store>) -> Self {
Self {
id: id.into(),
stores: stores.into_iter().collect(),
on_clear: None,
}
}
pub fn on_clear(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_clear = Some(Rc::new(handler));
self
}
}
impl RenderOnce for StorageSettings {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_clear = self
.on_clear
.unwrap_or_else(|| panic!("storage settings {:?} has no on_clear", self.id));
let theme = cx.theme();
let total: u64 = self.stores.iter().map(|store| store.bytes).sum();
let bar = self.stores.iter().fold(
UsageBar::new(total.max(1) as f64)
.amounts(|bytes| format::file_size(bytes as u64, true)),
|bar, store| bar.part(store.name.clone(), store.bytes as f64),
);
let rows = self.stores.iter().map(|store| {
let (key, on_clear) = (store.key.clone(), on_clear.clone());
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.py_2()
.border_b_1()
.border_color(theme.colors.border)
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.child(Ellipsis::new(store.name.clone())),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(format::file_size(store.bytes, true)),
),
)
.children(store.clearable.then(|| {
ConfirmButton::new(
(self.id.clone(), format!("clear-{}", store.key)),
"Clear",
ConfirmMode::Twice,
)
.on_confirm(move |window, cx| {
log::info!("storage: clear {key}");
on_clear(&key, window, cx);
})
}))
});
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(format!("{} in all", format::file_size(total, true))),
)
.child(bar)
.child(div().flex().flex_col().children(rows))
}
}
/// A setting changed from its default: its key and name, and both values as words.
#[derive(Clone, Debug, PartialEq)]
pub struct Changed {
pub key: SharedString,
pub name: SharedString,
pub now: SharedString,
pub default: SharedString,
}
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
/// The settings changed from their defaults, each with what it is and what it was, and a way back for each; Reset all asks twice. With none changed it says so.
#[derive(IntoElement)]
pub struct ResetToDefault {
id: ElementId,
changed: Vec<Changed>,
on_reset: Option<OnKey>,
on_reset_all: Option<Run>,
}
impl ResetToDefault {
pub fn new(id: impl Into<ElementId>, changed: impl IntoIterator<Item = Changed>) -> Self {
Self {
id: id.into(),
changed: changed.into_iter().collect(),
on_reset: None,
on_reset_all: None,
}
}
pub fn on_reset(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_reset = Some(Rc::new(handler));
self
}
pub fn on_reset_all(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_reset_all = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ResetToDefault {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_reset = self
.on_reset
.unwrap_or_else(|| panic!("reset to default {:?} has no on_reset", self.id));
let on_reset_all = self
.on_reset_all
.unwrap_or_else(|| panic!("reset to default {:?} has no on_reset_all", self.id));
let theme = cx.theme();
if self.changed.is_empty() {
return div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child("Every setting is at its default.")
.into_any_element();
}
let rows = self.changed.iter().map(|changed| {
let (key, on_reset) = (changed.key.clone(), on_reset.clone());
div()
.flex()
.items_center()
.gap_2()
.py_2()
.border_b_1()
.border_color(theme.colors.border)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.child(Ellipsis::new(changed.name.clone())),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(Ellipsis::new(format!(
"{} · was {}",
changed.now, changed.default
))),
),
)
.child(
IconButton::new(
(self.id.clone(), format!("reset-{}", changed.key)),
IconName::RotateCcw,
)
.tooltip("Back to the default")
.on_click(move |_, window, cx| {
log::info!("reset: {key}");
on_reset(&key, window, cx);
}),
)
});
let on_reset_all = on_reset_all;
div()
.flex()
.flex_col()
.gap_3()
.child(div().flex().flex_col().children(rows))
.child(
div().flex().child(
ConfirmButton::new(
(self.id, "all"),
format!("Reset all {}", self.changed.len()),
ConfirmMode::Twice,
)
.on_confirm(move |window, cx| {
log::info!("reset: all");
on_reset_all(window, cx);
}),
),
)
.into_any_element()
}
}
type OnFile = Rc<dyn Fn(PathBuf, &mut Window, &mut App)>;
/// Settings out to a file and back: Export asks the owner to write them, and a JSON file dropped or picked asks it to read one.
#[derive(IntoElement)]
pub struct ImportExportSettings {
id: ElementId,
on_export: Option<Run>,
on_import: Option<OnFile>,
}
impl ImportExportSettings {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
on_export: None,
on_import: None,
}
}
pub fn on_export(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_export = Some(Rc::new(handler));
self
}
pub fn on_import(mut self, handler: impl Fn(PathBuf, &mut Window, &mut App) + 'static) -> Self {
self.on_import = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ImportExportSettings {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let on_export = self
.on_export
.unwrap_or_else(|| panic!("import export settings {:?} has no on_export", self.id));
let on_import = self
.on_import
.unwrap_or_else(|| panic!("import export settings {:?} has no on_import", self.id));
let (on_export, on_import) = (on_export, on_import);
div()
.flex()
.flex_col()
.gap_3()
.child(
div().flex().child(
Button::new((self.id.clone(), "export"), "Export settings")
.variant(ButtonVariant::Secondary)
.on_click(move |_, window, cx| {
log::info!("settings: export");
on_export(window, cx);
}),
),
)
.child(DropZone::new((self.id, "import")).kinds(&["json"]).on_drop(
move |paths, window, cx| {
let Some(path) = paths.first().cloned() else {
return;
};
log::info!("settings: import {}", path.display());
on_import(path, window, cx);
},
))
}
}
+432
View File
@@ -0,0 +1,432 @@
use gpui::{
AnyElement, App, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled,
TestAppContext, VisualTestContext, Window, div, hsla, px,
};
use super::{
AccentColorPicker, Appearance, Changed, DeveloperModeToggle, FeatureFlags, Flag,
FontSizeControl, ImportExportSettings, Notices, NotificationSettings, Privacy, PrivacySettings,
Proxy, ProxySettings, ResetToDefault, SettingEntry, SettingsLayout, SettingsSearch, Stage,
Startup, StartupSettings, StorageSettings, Store, ThemeSelector,
};
use crate::{
forms::{self, Choice, TextInput},
primitives::{FocusNext, IconName},
theme::Theme,
};
type Part = fn(&mut Window, &mut App, Entity<Desk>) -> AnyElement;
/// A view that shows one setting's part and keeps the words it heard.
struct Desk {
part: Part,
said: Vec<String>,
}
impl Render for Desk {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
let part = (self.part)(window, cx, owner);
div().w(px(640.0)).child(part)
}
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
fn desk(part: Part, cx: &mut TestAppContext) -> (Entity<Desk>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
forms::bind_keys(cx);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
let (host, cx) = cx.add_window_view(|_, _| Desk {
part,
said: Vec::new(),
});
settle(cx);
(host, cx)
}
fn say(owner: &Entity<Desk>, words: String, cx: &mut App) {
owner.update(cx, |desk, cx| {
desk.said.push(words);
cx.notify();
});
}
fn said(host: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<String> {
host.read_with(cx, |desk, _| desk.said.clone())
}
fn tab(stops: usize, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
(0..stops).for_each(|_| window.focus_next());
});
settle(cx);
}
fn tap(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
cx.simulate_event(gpui::KeyUpEvent {
keystroke: gpui::Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
fn search(window: &mut Window, cx: &mut App, owner: Entity<Desk>) -> AnyElement {
let field = window.use_keyed_state("search", cx, TextInput::new);
let entry = |key: &str, name: &str, words: &[&str]| SettingEntry {
key: key.to_string().into(),
section: "Appearance".into(),
name: name.to_string().into(),
words: words.iter().map(|word| word.to_string().into()).collect(),
};
SettingsSearch::new(
"search",
[
entry("theme", "Theme", &["dark"]),
entry("size", "Font size", &["zoom"]),
],
&field,
)
.on_pick(move |key, _, cx| say(&owner, format!("go {key}"), cx))
.into_any_element()
}
/// Stops: the field, then the list of what answers.
#[gpui::test]
fn a_search_narrows_the_settings_and_enter_goes_to_one(cx: &mut TestAppContext) {
let (host, cx) = desk(search, cx);
tab(1, cx);
cx.simulate_input("zoom");
settle(cx);
tab(2, cx);
tap("enter", cx);
assert_eq!(said(&host, cx), ["go size"]);
}
fn reset(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
let changed = |key: &str| Changed {
key: key.to_string().into(),
name: key.to_string().into(),
now: "on".into(),
default: "off".into(),
};
let (one, all) = (owner.clone(), owner);
ResetToDefault::new("reset", [changed("a"), changed("b")])
.on_reset(move |key, _, cx| say(&one, format!("reset {key}"), cx))
.on_reset_all(move |_, cx| say(&all, "reset all".into(), cx))
.into_any_element()
}
/// Stops: each row's way back, then Reset all, which asks twice.
#[gpui::test]
fn a_row_goes_back_alone_and_reset_all_asks_twice(cx: &mut TestAppContext) {
let (host, cx) = desk(reset, cx);
tab(2, cx);
tap("space", cx);
tab(3, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["reset b"], "the first press only arms");
tap("space", cx);
assert_eq!(said(&host, cx), ["reset b", "reset all"]);
}
fn flags(window: &mut Window, cx: &mut App, owner: Entity<Desk>) -> AnyElement {
let field = window.use_keyed_state("flags-search", cx, TextInput::new);
let flag = |key: &str, on| Flag {
key: key.to_string().into(),
name: key.to_string().into(),
description: "does a thing".into(),
stage: Stage::Beta,
on,
};
FeatureFlags::new("flags", [flag("tabs", false), flag("sync", true)], &field)
.on_toggle(move |key, on, _, cx| say(&owner, format!("{key} {on}"), cx))
.into_any_element()
}
/// Stops: the search, then each flag's switch.
#[gpui::test]
fn a_flags_switch_turns_it(cx: &mut TestAppContext) {
let (host, cx) = desk(flags, cx);
tab(3, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["sync false"]);
}
fn notices(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
let notices = Notices {
channels: vec!["App".into(), "Email".into()],
kinds: vec![("mention".into(), "Mentions".into())],
on: vec![("mention".into(), "App".into())],
quiet: false,
};
NotificationSettings::new("notices", notices)
.on_change(move |notices, _, cx| {
say(&owner, format!("{:?} {}", notices.on, notices.quiet), cx)
})
.into_any_element()
}
/// Stops: Do not disturb, then a box per channel.
#[gpui::test]
fn a_box_turns_its_kind_on_its_channel(cx: &mut TestAppContext) {
let (host, cx) = desk(notices, cx);
tab(3, cx);
tap("space", cx);
tab(1, cx);
tap("space", cx);
assert_eq!(
said(&host, cx),
[
"[(\"mention\", \"App\"), (\"mention\", \"Email\")] false",
"[(\"mention\", \"App\")] true"
]
);
}
fn storage(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
StorageSettings::new(
"storage",
[
Store {
key: "cache".into(),
name: "Cache".into(),
bytes: 3 << 20,
clearable: true,
},
Store {
key: "data".into(),
name: "Data".into(),
bytes: 9 << 20,
clearable: false,
},
],
)
.on_clear(move |key, _, cx| say(&owner, format!("clear {key}"), cx))
.into_any_element()
}
#[gpui::test]
fn a_store_clears_on_the_second_press(cx: &mut TestAppContext) {
let (host, cx) = desk(storage, cx);
tab(1, cx);
tap("space", cx);
assert!(said(&host, cx).is_empty(), "the first press only arms");
tap("space", cx);
assert_eq!(said(&host, cx), ["clear cache"]);
}
fn size(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
FontSizeControl::new("size", 18.0, 14.0, (12.0, 24.0))
.on_change(move |size, _, cx| say(&owner, format!("size {size}"), cx))
.into_any_element()
}
/// Stops: the slider, then the way back.
#[gpui::test]
fn the_way_back_gives_the_default_size(cx: &mut TestAppContext) {
let (host, cx) = desk(size, cx);
tab(2, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["size 14"]);
}
fn theme(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
ThemeSelector::new("theme", Appearance::Light)
.on_change(move |appearance, _, cx| say(&owner, format!("{appearance:?}"), cx))
.into_any_element()
}
/// Stops: a segment each.
#[gpui::test]
fn a_segment_picks_the_appearance(cx: &mut TestAppContext) {
let (host, cx) = desk(theme, cx);
tab(3, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["System"]);
}
fn proxy(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
let proxy = Proxy::Manual {
host: "proxy.corp".into(),
port: 8080,
bypass: vec!["localhost".into()],
};
ProxySettings::new("proxy", proxy)
.on_apply(move |proxy, _, cx| say(&owner, format!("{proxy:?}"), cx))
.into_any_element()
}
/// Apply is the last stop, so a step back from nothing reaches it.
#[gpui::test]
fn apply_hands_on_the_manual_proxy(cx: &mut TestAppContext) {
let (host, cx) = desk(proxy, cx);
cx.update(|window, _| {
window.blur();
window.focus_prev();
});
settle(cx);
tap("space", cx);
assert_eq!(
said(&host, cx),
["Manual { host: \"proxy.corp\", port: 8080, bypass: [\"localhost\"] }"]
);
}
fn layout(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
SettingsLayout::new(
"layout",
[
("a", "Appearance", IconName::Palette),
("b", "Network", IconName::Globe),
],
"a",
)
.on_select(move |key, _, cx| say(&owner, format!("open {key}"), cx))
.page(div())
.into_any_element()
}
/// Stops: the section list.
#[gpui::test]
fn the_arrows_open_the_next_section(cx: &mut TestAppContext) {
let (host, cx) = desk(layout, cx);
tab(1, cx);
tap("down", cx);
assert_eq!(said(&host, cx), ["open b"]);
}
fn accent(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
let (red, blue) = (hsla(0.0, 0.6, 0.5, 1.0), hsla(0.6, 0.6, 0.5, 1.0));
AccentColorPicker::new("accent", red, [("Red", red), ("Blue", blue)])
.on_change(move |color, _, cx| say(&owner, format!("blue {}", color == blue), cx))
.into_any_element()
}
/// Stops: the presets, Red then Blue, then the custom well.
#[gpui::test]
fn a_preset_sets_the_accent(cx: &mut TestAppContext) {
let (host, cx) = desk(accent, cx);
tab(2, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["blue true"]);
}
fn privacy(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
let privacy = Privacy {
telemetry: true,
crash_reports: true,
personalization: false,
history: true,
};
PrivacySettings::new("privacy", privacy)
.on_change(move |next, _, cx| say(&owner, format!("{next:?}"), cx))
.into_any_element()
}
/// Stops: a switch per field, telemetry first.
#[gpui::test]
fn a_privacy_switch_turns_its_own_field(cx: &mut TestAppContext) {
let (host, cx) = desk(privacy, cx);
tab(1, cx);
tap("space", cx);
assert_eq!(
said(&host, cx),
[
"Privacy { telemetry: false, crash_reports: true, personalization: false, history: true }"
]
);
}
fn quiet(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
let notices = Notices {
channels: vec!["App".into()],
kinds: vec![("replies".into(), "Replies".into())],
on: vec![("replies".into(), "App".into())],
quiet: false,
};
NotificationSettings::new("notices", notices)
.on_change(move |next, _, cx| {
say(
&owner,
format!("quiet {} on {}", next.quiet, next.on.len()),
cx,
)
})
.into_any_element()
}
/// Stops: Do not disturb, then a box per kind and channel.
#[gpui::test]
fn do_not_disturb_holds_notices_and_keeps_the_boxes(cx: &mut TestAppContext) {
let (host, cx) = desk(quiet, cx);
tab(1, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["quiet true on 1"]);
}
fn startup(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
let startup = Startup {
at_login: false,
restore: true,
opens: "last".into(),
updates: true,
};
StartupSettings::new(
"startup",
startup,
[Choice::new("last", "Where I left off")],
)
.on_change(move |next, _, cx| say(&owner, format!("login {}", next.at_login), cx))
.into_any_element()
}
/// Stops: Open at login first.
#[gpui::test]
fn open_at_login_turns_on(cx: &mut TestAppContext) {
let (host, cx) = desk(startup, cx);
tab(1, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["login true"]);
}
fn developer(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
DeveloperModeToggle::new("developer", false)
.on_change(move |on, _, cx| say(&owner, format!("developer {on}"), cx))
.into_any_element()
}
/// Stops: the switch.
#[gpui::test]
fn developer_mode_turns_on(cx: &mut TestAppContext) {
let (host, cx) = desk(developer, cx);
tab(1, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["developer true"]);
}
fn files(_: &mut Window, _: &mut App, owner: Entity<Desk>) -> AnyElement {
ImportExportSettings::new("files")
.on_export(move |_, cx| say(&owner, "export".into(), cx))
.on_import(|_, _, _| {})
.into_any_element()
}
/// Stops: Export first.
#[gpui::test]
fn export_asks_the_owner(cx: &mut TestAppContext) {
let (host, cx) = desk(files, cx);
tab(1, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["export"]);
}
+22 -22
View File
@@ -146,27 +146,27 @@
## T35 Settings — `src/settings`
- [ ] SettingsLayout
- [ ] SettingsSection
- [ ] SettingsRow
- [ ] SettingsSearch
- [ ] ThemeSelector → `theme::ThemeSwitcher`
- [ ] AccentColorPicker
- [ ] FontSizeControl
- [ ] FontFamilySelect → `forms::FontPicker`
- [ ] DensitySelector
- [ ] LanguageSelector → `forms::Combobox::languages` (T06f)
- [ ] KeyboardShortcutsList
- [ ] ShortcutEditor → `editor::KeybindingsEditor`
- [ ] ProxySettings
- [ ] StorageSettings / CacheClear
- [ ] PrivacySettings
- [ ] NotificationSettings
- [ ] StartupSettings
- [ ] ImportExportSettings
- [ ] ResetToDefault
- [ ] AdvancedSettings / FeatureFlags
- [ ] DeveloperMode Toggle
- [x] SettingsLayout
- [x] SettingsSection
- [x] SettingsRow
- [x] SettingsSearch
- [x] ThemeSelector (T39's ThemeSwitcher points here)
- [x] AccentColorPicker
- [x] FontSizeControl
- [x] FontFamilySelect → `forms::FontPicker`
- [x] DensitySelector
- [x] LanguageSelector → `forms::Combobox::languages` (T06f)
- [x] KeyboardShortcutsList
- [x] ShortcutEditor → `editor::KeybindingsEditor`
- [x] ProxySettings
- [x] StorageSettings / CacheClear
- [x] PrivacySettings
- [x] NotificationSettings
- [x] StartupSettings
- [x] ImportExportSettings
- [x] ResetToDefault
- [x] AdvancedSettings / FeatureFlags
- [x] DeveloperMode Toggle
## T36 Account — `src/account`
@@ -237,7 +237,7 @@
## T39 Theme — `src/theme`
- [x] ThemeProvider (T00)
- [ ] ThemeSwitcher
- [ ] ThemeSwitcher → `settings::ThemeSelector` (T35)
- [x] ColorTokens / DesignTokens (T00)
- [ ] ThemeEditor
- [ ] ThemeImporter — VS Code theme JSON