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: 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-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-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 overlays;
|
||||||
mod primitives;
|
mod primitives;
|
||||||
mod project;
|
mod project;
|
||||||
|
mod settings;
|
||||||
mod shell;
|
mod shell;
|
||||||
mod tables;
|
mod tables;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
@@ -83,6 +84,7 @@ pub const ALL: &[Page] = &[
|
|||||||
canvas::PAGE,
|
canvas::PAGE,
|
||||||
devtools::PAGE,
|
devtools::PAGE,
|
||||||
dashboard::PAGE,
|
dashboard::PAGE,
|
||||||
|
settings::PAGE,
|
||||||
theme::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::{
|
use gpui::{
|
||||||
App, Bounds, Corners, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
|
App, Bounds, Corners, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
|
||||||
ParentElement, Pixels, Rems, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
|
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;
|
use super::super::options::Run;
|
||||||
@@ -192,7 +192,10 @@ impl ColorPalette {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for 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
|
let swatches: Vec<_> = self
|
||||||
.colors
|
.colors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -200,7 +203,7 @@ impl RenderOnce for ColorPalette {
|
|||||||
.map(|(ix, (name, color))| {
|
.map(|(ix, (name, color))| {
|
||||||
let (id, on_change) = (self.id.clone(), self.on_change.clone());
|
let (id, on_change) = (self.id.clone(), self.on_change.clone());
|
||||||
let label = name.clone();
|
let label = name.clone();
|
||||||
ColorSwatch::new(("swatch", ix), color)
|
ColorSwatch::new((self.id.clone(), format!("swatch-{ix}")), color)
|
||||||
.tooltip(name)
|
.tooltip(name)
|
||||||
.selected(self.selected == Some(color))
|
.selected(self.selected == Some(color))
|
||||||
.on_click(move |window, cx| {
|
.on_click(move |window, cx| {
|
||||||
@@ -213,9 +216,10 @@ impl RenderOnce for ColorPalette {
|
|||||||
.collect();
|
.collect();
|
||||||
div()
|
div()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
.grid()
|
.flex()
|
||||||
.grid_cols(self.columns)
|
.flex_wrap()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
|
.max_w(widest)
|
||||||
.children(swatches)
|
.children(swatches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub mod navigation;
|
|||||||
pub mod overlays;
|
pub mod overlays;
|
||||||
pub mod primitives;
|
pub mod primitives;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
pub mod settings;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod tables;
|
pub mod tables;
|
||||||
pub mod terminal;
|
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`
|
## T35 Settings — `src/settings`
|
||||||
|
|
||||||
- [ ] SettingsLayout
|
- [x] SettingsLayout
|
||||||
- [ ] SettingsSection
|
- [x] SettingsSection
|
||||||
- [ ] SettingsRow
|
- [x] SettingsRow
|
||||||
- [ ] SettingsSearch
|
- [x] SettingsSearch
|
||||||
- [ ] ThemeSelector → `theme::ThemeSwitcher`
|
- [x] ThemeSelector (T39's ThemeSwitcher points here)
|
||||||
- [ ] AccentColorPicker
|
- [x] AccentColorPicker
|
||||||
- [ ] FontSizeControl
|
- [x] FontSizeControl
|
||||||
- [ ] FontFamilySelect → `forms::FontPicker`
|
- [x] FontFamilySelect → `forms::FontPicker`
|
||||||
- [ ] DensitySelector
|
- [x] DensitySelector
|
||||||
- [ ] LanguageSelector → `forms::Combobox::languages` (T06f)
|
- [x] LanguageSelector → `forms::Combobox::languages` (T06f)
|
||||||
- [ ] KeyboardShortcutsList
|
- [x] KeyboardShortcutsList
|
||||||
- [ ] ShortcutEditor → `editor::KeybindingsEditor`
|
- [x] ShortcutEditor → `editor::KeybindingsEditor`
|
||||||
- [ ] ProxySettings
|
- [x] ProxySettings
|
||||||
- [ ] StorageSettings / CacheClear
|
- [x] StorageSettings / CacheClear
|
||||||
- [ ] PrivacySettings
|
- [x] PrivacySettings
|
||||||
- [ ] NotificationSettings
|
- [x] NotificationSettings
|
||||||
- [ ] StartupSettings
|
- [x] StartupSettings
|
||||||
- [ ] ImportExportSettings
|
- [x] ImportExportSettings
|
||||||
- [ ] ResetToDefault
|
- [x] ResetToDefault
|
||||||
- [ ] AdvancedSettings / FeatureFlags
|
- [x] AdvancedSettings / FeatureFlags
|
||||||
- [ ] DeveloperMode Toggle
|
- [x] DeveloperMode Toggle
|
||||||
|
|
||||||
## T36 Account — `src/account`
|
## T36 Account — `src/account`
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
## T39 Theme — `src/theme`
|
## T39 Theme — `src/theme`
|
||||||
|
|
||||||
- [x] ThemeProvider (T00)
|
- [x] ThemeProvider (T00)
|
||||||
- [ ] ThemeSwitcher
|
- [ ] ThemeSwitcher → `settings::ThemeSelector` (T35)
|
||||||
- [x] ColorTokens / DesignTokens (T00)
|
- [x] ColorTokens / DesignTokens (T00)
|
||||||
- [ ] ThemeEditor
|
- [ ] ThemeEditor
|
||||||
- [ ] ThemeImporter — VS Code theme JSON
|
- [ ] ThemeImporter — VS Code theme JSON
|
||||||
|
|||||||
Reference in new issue
Block a user