diff --git a/AGENTS.md b/AGENTS.md index d4e007c..b076533 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index aa56159..ac75974 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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, ]; diff --git a/examples/gallery/pages/settings/mod.rs b/examples/gallery/pages/settings/mod.rs new file mode 100644 index 0000000..2cbd924 --- /dev/null +++ b/examples/gallery/pages/settings/mod.rs @@ -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() +} diff --git a/examples/gallery/pages/settings/pages.rs b/examples/gallery/pages/settings/pages.rs new file mode 100644 index 0000000..0caacef --- /dev/null +++ b/examples/gallery/pages/settings/pages.rs @@ -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, 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, 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, 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, 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, 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, cx: &App) -> AnyElement { + let notices = prefs.clone(); + NotificationSettings::new("settings-notices", prefs.read(cx).notices.clone()) + .on_change(move |next, _, cx| change(¬ices, cx, |prefs| prefs.notices = next)) + .into_any_element() +} + +fn startup(prefs: &Entity, 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, 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, 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() +} diff --git a/examples/gallery/pages/settings/window.rs b/examples/gallery/pages/settings/window.rs new file mode 100644 index 0000000..97e198d --- /dev/null +++ b/examples/gallery/pages/settings/window.rs @@ -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, + pub flags: Vec, + 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, + pub language: Entity, + pub shortcuts: Entity, + pub flags: Entity, +} + +fn field( + key: &'static str, + hint: &'static str, + window: &mut Window, + cx: &mut App, +) -> Entity { + 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), + ), + )), + ) +} diff --git a/src/forms/color/swatch.rs b/src/forms/color/swatch.rs index f18152b..092602d 100644 --- a/src/forms/color/swatch.rs +++ b/src/forms/color/swatch.rs @@ -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) } } diff --git a/src/lib.rs b/src/lib.rs index e89c3ed..93adcb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/settings/advanced.rs b/src/settings/advanced.rs new file mode 100644 index 0000000..a642304 --- /dev/null +++ b/src/settings/advanced.rs @@ -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; + +/// 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, + search: Entity, + on_toggle: Option, +} + +impl FeatureFlags { + /// `search` is the find field's text, which the owner keeps. + pub fn new( + id: impl Into, + flags: impl IntoIterator, + search: &Entity, + ) -> 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 = 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; + +/// 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, +} + +impl DeveloperModeToggle { + pub fn new(id: impl Into, 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::>() + }; + assert_eq!(keys("side tabs"), ["tabs"]); + assert_eq!(keys("everywhere"), ["sync"]); + assert_eq!(keys(""), ["tabs", "sync"]); + } +} diff --git a/src/settings/appearance.rs b/src/settings/appearance.rs new file mode 100644 index 0000000..8b94284 --- /dev/null +++ b/src/settings/appearance.rs @@ -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; + +/// 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, +} + +impl ThemeSelector { + pub fn new(id: impl Into, 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; + +/// 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, +} + +impl AccentColorPicker { + pub fn new( + id: impl Into, + color: impl Into, + presets: impl IntoIterator, impl Into)>, + ) -> 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; + +/// 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, +} + +impl FontSizeControl { + /// `size` and `default` lie within `range`, in pixels. + pub fn new(id: impl Into, 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; + +/// How close things sit: compact, standard or comfortable, as three segments. +#[derive(IntoElement)] +pub struct DensitySelector { + id: ElementId, + density: Density, + on_change: Option, +} + +const DENSITIES: [(Density, &str); 3] = [ + (Density::Compact, "Compact"), + (Density::Standard, "Standard"), + (Density::Comfortable, "Comfortable"), +]; + +impl DensitySelector { + pub fn new(id: impl Into, 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); + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs new file mode 100644 index 0000000..46c41ec --- /dev/null +++ b/src/settings/mod.rs @@ -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}; diff --git a/src/settings/network.rs b/src/settings/network.rs new file mode 100644 index 0000000..98935f2 --- /dev/null +++ b/src/settings/network.rs @@ -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, + }, +} + +/// 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 { + 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, + port: Entity, + bypass: Entity, +} + +type OnProxy = Rc; + +/// 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, +} + +impl ProxySettings { + pub fn new(id: impl Into, 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::>() + .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| 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); + } +} diff --git a/src/settings/preferences.rs b/src/settings/preferences.rs new file mode 100644 index 0000000..b12c33d --- /dev/null +++ b/src/settings/preferences.rs @@ -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>, +} + +impl PrivacySettings { + pub fn new(id: impl Into, 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, + 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>, +} + +impl NotificationSettings { + pub fn new(id: impl Into, 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", + ¬ices, + 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", + ¬ices, + 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, + on_change: Option>, +} + +impl StartupSettings { + pub fn new( + id: impl Into, + startup: Startup, + pages: impl IntoIterator, + ) -> 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, + ))) + } +} diff --git a/src/settings/search.rs b/src/settings/search.rs new file mode 100644 index 0000000..faf4a70 --- /dev/null +++ b/src/settings/search.rs @@ -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, +} + +/// 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 = 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::>() + .join(" "); + terms.iter().all(|term| haystack.contains(term.as_str())) + }) + .collect() +} + +type OnKey = Rc; + +/// 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, + search: Entity, + on_pick: Option, +} + +impl SettingsSearch { + /// `search` is the field's text, which the owner keeps. + pub fn new( + id: impl Into, + entries: impl IntoIterator, + search: &Entity, + ) -> 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::>() + }; + assert_eq!(keys("DARK"), ["theme"]); + assert_eq!(keys("appearance zoom"), ["size"]); + assert_eq!(keys("network dark"), Vec::::new()); + } +} diff --git a/src/settings/section.rs b/src/settings/section.rs new file mode 100644 index 0000000..bfd11c0 --- /dev/null +++ b/src/settings/section.rs @@ -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; + +/// 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, + on_select: Option, +} + +impl SettingsLayout { + /// `sections` are each a key, a name and an icon; `selected` names the one open. + pub fn new( + id: impl Into, + sections: impl IntoIterator, impl Into, IconName)>, + selected: impl Into, + ) -> 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, + rows: Vec, +} + +impl SettingsSection { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + description: None, + rows: Vec::new(), + } + } + + pub fn description(mut self, text: impl Into) -> 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, + control: Option, +} + +impl SettingsRow { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + description: None, + control: None, + } + } + + pub fn description(mut self, text: impl Into) -> 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))) + } +} diff --git a/src/settings/shortcuts.rs b/src/settings/shortcuts.rs new file mode 100644 index 0000000..6a85542 --- /dev/null +++ b/src/settings/shortcuts.rs @@ -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, + search: Entity, +} + +impl KeyboardShortcutsList { + /// `search` is the find field's text, which the owner keeps. + pub fn new(shortcuts: impl IntoIterator, search: &Entity) -> 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 = 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 = 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::>() + }; + 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"]); + } +} diff --git a/src/settings/storage.rs b/src/settings/storage.rs new file mode 100644 index 0000000..9ecb913 --- /dev/null +++ b/src/settings/storage.rs @@ -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; + +/// 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, + on_clear: Option, +} + +impl StorageSettings { + pub fn new(id: impl Into, stores: impl IntoIterator) -> 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; + +/// 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, + on_reset: Option, + on_reset_all: Option, +} + +impl ResetToDefault { + pub fn new(id: impl Into, changed: impl IntoIterator) -> 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; + +/// 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, + on_import: Option, +} + +impl ImportExportSettings { + pub fn new(id: impl Into) -> 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); + }, + )) + } +} diff --git a/src/settings/tests.rs b/src/settings/tests.rs new file mode 100644 index 0000000..fccf2d9 --- /dev/null +++ b/src/settings/tests.rs @@ -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) -> AnyElement; + +/// A view that shows one setting's part and keeps the words it heard. +struct Desk { + part: Part, + said: Vec, +} + +impl Render for Desk { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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, &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, words: String, cx: &mut App) { + owner.update(cx, |desk, cx| { + desk.said.push(words); + cx.notify(); + }); +} + +fn said(host: &Entity, cx: &mut VisualTestContext) -> Vec { + 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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"]); +} diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index ac5f083..8444cde 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -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