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

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

No files matched your search

+309
View File
@@ -0,0 +1,309 @@
use std::{path::PathBuf, rc::Rc};
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant, ConfirmButton, ConfirmMode, IconButton},
data_display::UsageBar,
forms::DropZone,
primitives::IconName,
theme::{ActiveTheme, TextSize},
typography::{Ellipsis, format},
};
/// A store on disk: its key and name, its size in bytes, and whether it may be cleared.
#[derive(Clone, Debug, PartialEq)]
pub struct Store {
pub key: SharedString,
pub name: SharedString,
pub bytes: u64,
pub clearable: bool,
}
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// What an app keeps on disk: a bar of each store's share, then each store with its size and, where it may go, a Clear that asks twice.
#[derive(IntoElement)]
pub struct StorageSettings {
id: ElementId,
stores: Vec<Store>,
on_clear: Option<OnKey>,
}
impl StorageSettings {
pub fn new(id: impl Into<ElementId>, stores: impl IntoIterator<Item = Store>) -> Self {
Self {
id: id.into(),
stores: stores.into_iter().collect(),
on_clear: None,
}
}
pub fn on_clear(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_clear = Some(Rc::new(handler));
self
}
}
impl RenderOnce for StorageSettings {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_clear = self
.on_clear
.unwrap_or_else(|| panic!("storage settings {:?} has no on_clear", self.id));
let theme = cx.theme();
let total: u64 = self.stores.iter().map(|store| store.bytes).sum();
let bar = self.stores.iter().fold(
UsageBar::new(total.max(1) as f64)
.amounts(|bytes| format::file_size(bytes as u64, true)),
|bar, store| bar.part(store.name.clone(), store.bytes as f64),
);
let rows = self.stores.iter().map(|store| {
let (key, on_clear) = (store.key.clone(), on_clear.clone());
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.py_2()
.border_b_1()
.border_color(theme.colors.border)
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.child(Ellipsis::new(store.name.clone())),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(format::file_size(store.bytes, true)),
),
)
.children(store.clearable.then(|| {
ConfirmButton::new(
(self.id.clone(), format!("clear-{}", store.key)),
"Clear",
ConfirmMode::Twice,
)
.on_confirm(move |window, cx| {
log::info!("storage: clear {key}");
on_clear(&key, window, cx);
})
}))
});
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(format!("{} in all", format::file_size(total, true))),
)
.child(bar)
.child(div().flex().flex_col().children(rows))
}
}
/// A setting changed from its default: its key and name, and both values as words.
#[derive(Clone, Debug, PartialEq)]
pub struct Changed {
pub key: SharedString,
pub name: SharedString,
pub now: SharedString,
pub default: SharedString,
}
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
/// The settings changed from their defaults, each with what it is and what it was, and a way back for each; Reset all asks twice. With none changed it says so.
#[derive(IntoElement)]
pub struct ResetToDefault {
id: ElementId,
changed: Vec<Changed>,
on_reset: Option<OnKey>,
on_reset_all: Option<Run>,
}
impl ResetToDefault {
pub fn new(id: impl Into<ElementId>, changed: impl IntoIterator<Item = Changed>) -> Self {
Self {
id: id.into(),
changed: changed.into_iter().collect(),
on_reset: None,
on_reset_all: None,
}
}
pub fn on_reset(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_reset = Some(Rc::new(handler));
self
}
pub fn on_reset_all(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_reset_all = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ResetToDefault {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let on_reset = self
.on_reset
.unwrap_or_else(|| panic!("reset to default {:?} has no on_reset", self.id));
let on_reset_all = self
.on_reset_all
.unwrap_or_else(|| panic!("reset to default {:?} has no on_reset_all", self.id));
let theme = cx.theme();
if self.changed.is_empty() {
return div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child("Every setting is at its default.")
.into_any_element();
}
let rows = self.changed.iter().map(|changed| {
let (key, on_reset) = (changed.key.clone(), on_reset.clone());
div()
.flex()
.items_center()
.gap_2()
.py_2()
.border_b_1()
.border_color(theme.colors.border)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.child(Ellipsis::new(changed.name.clone())),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(Ellipsis::new(format!(
"{} · was {}",
changed.now, changed.default
))),
),
)
.child(
IconButton::new(
(self.id.clone(), format!("reset-{}", changed.key)),
IconName::RotateCcw,
)
.tooltip("Back to the default")
.on_click(move |_, window, cx| {
log::info!("reset: {key}");
on_reset(&key, window, cx);
}),
)
});
let on_reset_all = on_reset_all;
div()
.flex()
.flex_col()
.gap_3()
.child(div().flex().flex_col().children(rows))
.child(
div().flex().child(
ConfirmButton::new(
(self.id, "all"),
format!("Reset all {}", self.changed.len()),
ConfirmMode::Twice,
)
.on_confirm(move |window, cx| {
log::info!("reset: all");
on_reset_all(window, cx);
}),
),
)
.into_any_element()
}
}
type OnFile = Rc<dyn Fn(PathBuf, &mut Window, &mut App)>;
/// Settings out to a file and back: Export asks the owner to write them, and a JSON file dropped or picked asks it to read one.
#[derive(IntoElement)]
pub struct ImportExportSettings {
id: ElementId,
on_export: Option<Run>,
on_import: Option<OnFile>,
}
impl ImportExportSettings {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
on_export: None,
on_import: None,
}
}
pub fn on_export(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_export = Some(Rc::new(handler));
self
}
pub fn on_import(mut self, handler: impl Fn(PathBuf, &mut Window, &mut App) + 'static) -> Self {
self.on_import = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ImportExportSettings {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let on_export = self
.on_export
.unwrap_or_else(|| panic!("import export settings {:?} has no on_export", self.id));
let on_import = self
.on_import
.unwrap_or_else(|| panic!("import export settings {:?} has no on_import", self.id));
let (on_export, on_import) = (on_export, on_import);
div()
.flex()
.flex_col()
.gap_3()
.child(
div().flex().child(
Button::new((self.id.clone(), "export"), "Export settings")
.variant(ButtonVariant::Secondary)
.on_click(move |_, window, cx| {
log::info!("settings: export");
on_export(window, cx);
}),
),
)
.child(DropZone::new((self.id, "import")).kinds(&["json"]).on_drop(
move |paths, window, cx| {
let Some(path) = paths.first().cloned() else {
return;
};
log::info!("settings: import {}", path.display());
on_import(path, window, cx);
},
))
}
}