Settings: a settings window, its sections, a search, and what fills them
SettingsLayout lists sections down the side and shows the open one, the list going above the page in a narrow box; SettingsSection and SettingsRow title and hold settings, a row's control wrapping under its name. SettingsSearch finds a setting by its name or words and opens its section. ThemeSelector, AccentColorPicker, FontSizeControl and DensitySelector set the look; KeyboardShortcutsList finds shortcuts by every word; ProxySettings, PrivacySettings, NotificationSettings, StartupSettings, StorageSettings with its clear, ImportExportSettings, ResetToDefault, FeatureFlags and DeveloperModeToggle fill the rest. Every handler a control needs is required and fails loud when missing. ColorPalette wraps its swatches in a row capped at its columns, so it keeps their size in a box sized by its content, and its swatches carry the palette's id. ThemeSwitcher points here: src/theme sits under the chapters that draw with buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ab478c3c3
commit
93de2a6bd6
18 files changed
+2873
-27
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(¬ices, 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()
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
¬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<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,
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user