Files
Ely-GPUI-Components/src/editor/settings.rs
T

433 lines
16 KiB
Rust

use std::rc::Rc;
use gpui::{
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{ButtonVariant, IconButton, SegmentedControl},
forms::{Choice, JsonInput, NumberInput, Select, Switch, TextInput},
primitives::IconName,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::format::decimals,
};
/// A setting's value and the form it takes.
#[derive(Clone, Debug, PartialEq)]
pub enum SettingValue {
Bool(bool),
Number {
value: f64,
min: f64,
max: f64,
step: f64,
},
Choice {
value: SharedString,
choices: Vec<SharedString>,
},
}
impl SettingValue {
fn json(&self) -> serde_json::Value {
match self {
SettingValue::Bool(on) => (*on).into(),
SettingValue::Number { value, .. } => serde_json::Number::from_f64(*value)
.map(serde_json::Value::Number)
.expect("a setting's number is finite"),
SettingValue::Choice { value, .. } => value.to_string().into(),
}
}
}
/// One setting: its dotted key, title, what it does, its value and the value it starts from.
#[derive(Clone, Debug, PartialEq)]
pub struct Setting {
pub key: SharedString,
pub title: SharedString,
pub description: SharedString,
pub value: SettingValue,
pub default: SettingValue,
}
impl Setting {
pub fn modified(&self) -> bool {
self.value != self.default
}
}
/// The settings changed from their defaults, as a settings file holds them.
pub fn settings_json(groups: &[(SharedString, Vec<Setting>)]) -> String {
let map: serde_json::Map<String, serde_json::Value> = groups
.iter()
.flat_map(|(_, settings)| settings)
.filter(|setting| setting.modified())
.map(|setting| (setting.key.to_string(), setting.value.json()))
.collect();
serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("settings serialize")
}
type OnChange = Rc<dyn Fn(&SharedString, SettingValue, &mut Window, &mut App)>;
type OnIndex = Rc<dyn Fn(usize, &mut Window, &mut App)>;
type OnFlag = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// Settings two ways: as rows of controls by section, each changed one marked with a way back, or as the JSON a settings file holds.
#[derive(IntoElement)]
pub struct SettingsEditor {
id: ElementId,
groups: Vec<(SharedString, Vec<Setting>)>,
section: usize,
json: Option<Entity<TextInput>>,
on_change: Option<OnChange>,
on_section: Option<OnIndex>,
on_json: Option<OnFlag>,
}
impl SettingsEditor {
pub fn new(
id: impl Into<ElementId>,
groups: impl IntoIterator<Item = (impl Into<SharedString>, Vec<Setting>)>,
) -> Self {
let groups: Vec<(SharedString, Vec<Setting>)> = groups
.into_iter()
.map(|(title, settings)| (title.into(), settings))
.collect();
assert!(!groups.is_empty(), "settings come in at least one section");
Self {
id: id.into(),
groups,
section: 0,
json: None,
on_change: None,
on_section: None,
on_json: None,
}
}
pub fn section(mut self, section: usize) -> Self {
self.section = section;
self
}
/// Shows the JSON, held in `field`; seed it with `settings_json`.
pub fn json(mut self, field: &Entity<TextInput>) -> Self {
self.json = Some(field.clone());
self
}
pub fn on_change(
mut self,
handler: impl Fn(&SharedString, SettingValue, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
pub fn on_section(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_section = Some(Rc::new(handler));
self
}
/// Gets whether the JSON view is wanted.
pub fn on_json(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_json = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SettingsEditor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let mode = SegmentedControl::new(
(self.id.clone(), "mode"),
if self.json.is_some() { "json" } else { "ui" },
)
.size(ControlSize::Sm)
.segment("ui", "Settings", None)
.segment("json", "JSON", None);
let mode = match self.on_json.clone() {
Some(on_json) => {
mode.on_change(move |key, window, cx| on_json(key.as_ref() == "json", window, cx))
}
None => mode,
};
let header = div().flex().items_center().justify_between().child(
div()
.text_size(theme.text_size(TextSize::Md))
.font_weight(FontWeight::SEMIBOLD)
.child("Settings"),
);
let header = header.child(mode);
if let Some(field) = self.json {
return div()
.flex()
.flex_col()
.gap_3()
.child(header)
.child(JsonInput::new(&field))
.into_any_element();
}
let section = self.section.min(self.groups.len() - 1);
let nav = self
.groups
.iter()
.enumerate()
.map(|(ix, (title, settings))| {
let pick = self.on_section.clone();
let changed = settings.iter().filter(|setting| setting.modified()).count();
div()
.id((self.id.clone(), format!("section-{ix}")))
.flex()
.items_center()
.justify_between()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Sm))
.cursor_pointer()
.when(ix == section, |row| {
row.bg(colors.active).text_color(colors.fg)
})
.when(ix != section, |row| {
row.text_color(colors.fg_muted)
.hover(|row| row.bg(colors.hover))
})
.when_some(pick, |row, pick| {
row.on_click(move |_, window, cx| pick(ix, window, cx))
})
.child(title.clone())
.when(changed > 0, |row| {
row.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.accent)
.child(changed.to_string()),
)
})
});
let rows = self.groups[section]
.1
.iter()
.enumerate()
.map(|(ix, setting)| {
let id = ElementId::from((self.id.clone(), format!("{section}-{ix}")));
let (change, key) = (self.on_change.clone(), setting.key.clone());
let tell = move |value: SettingValue, window: &mut Window, cx: &mut App| {
log::info!("settings: {key} = {value:?}");
if let Some(change) = &change {
change(&key, value, window, cx);
}
};
let tell = Rc::new(tell);
let control = match &setting.value {
SettingValue::Bool(on) => {
let tell = tell.clone();
Switch::new(id.clone(), *on)
.on_change(move |on, window, cx| {
tell(SettingValue::Bool(on), window, cx)
})
.into_any_element()
}
SettingValue::Number {
value,
min,
max,
step,
} => {
let (tell, min, max, step) = (tell.clone(), *min, *max, *step);
div()
.w(theme.label_width())
.child(
NumberInput::new(id.clone(), *value)
.range(min, max)
.step(step)
.precision(places(*value, step))
.size(ControlSize::Sm)
.on_change(move |value, window, cx| {
tell(
SettingValue::Number {
value,
min,
max,
step,
},
window,
cx,
)
}),
)
.into_any_element()
}
SettingValue::Choice { value, choices } => {
let (tell, all) = (tell.clone(), choices.clone());
div()
.w(theme.label_width() * 1.5)
.child(
Select::new(
id.clone(),
choices
.iter()
.map(|choice| Choice::new(choice.clone(), choice.clone())),
)
.selected(value.clone())
.size(ControlSize::Sm)
.on_change(
move |picked, window, cx| {
let value = SettingValue::Choice {
value: picked.clone(),
choices: all.clone(),
};
tell(value, window, cx)
},
),
)
.into_any_element()
}
};
let reset = setting.modified().then(|| {
let (tell, default) = (tell.clone(), setting.default.clone());
IconButton::new(
(self.id.clone(), format!("reset-{section}-{ix}")),
IconName::RotateCcw,
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Reset to default")
.on_click(move |_, window, cx| tell(default.clone(), window, cx))
});
div()
.flex()
.items_start()
.gap_3()
.py_3()
.pl_3()
.border_l_2()
.border_color(if setting.modified() {
colors.accent
} else {
gpui::transparent_black()
})
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.flex()
.items_baseline()
.gap_2()
.child(
div()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(setting.title.clone()),
)
.child(
div()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(setting.key.clone()),
),
)
.child(
div()
.text_color(colors.fg_muted)
.child(setting.description.clone()),
),
)
.child(
div()
.flex()
.items_center()
.gap_1()
.children(reset)
.child(control),
)
});
div()
.flex()
.flex_col()
.gap_3()
.text_size(theme.text_size(TextSize::Sm))
.child(header)
.child(
div()
.flex()
.gap_6()
.child(
div()
.w(theme.label_width() * 1.25)
.flex()
.flex_col()
.gap_0p5()
.children(nav),
)
.child(div().flex_1().min_w_0().flex().flex_col().children(rows)),
)
.into_any_element()
}
}
/// Places a number setting shows: enough for its step and its value.
fn places(value: f64, step: f64) -> usize {
let own = if value == 0.0 {
0
} else {
decimals(value.abs())
};
decimals(step).max(own)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numbers_show_the_places_their_value_and_step_need() {
assert_eq!(places(0.5, 0.1), 1);
assert_eq!(places(14.0, 1.0), 0);
assert_eq!(places(0.25, 0.05), 2);
assert_eq!(places(0.0, 0.5), 1);
}
#[test]
fn json_holds_only_what_changed() {
let groups = vec![(
SharedString::from("Editor"),
vec![
Setting {
key: "editor.fontSize".into(),
title: "Font size".into(),
description: "".into(),
value: SettingValue::Number {
value: 15.0,
min: 8.0,
max: 32.0,
step: 1.0,
},
default: SettingValue::Number {
value: 14.0,
min: 8.0,
max: 32.0,
step: 1.0,
},
},
Setting {
key: "editor.minimap".into(),
title: "Minimap".into(),
description: "".into(),
value: SettingValue::Bool(true),
default: SettingValue::Bool(true),
},
],
)];
let json: serde_json::Value = serde_json::from_str(&settings_json(&groups)).expect("json");
assert_eq!(json, serde_json::json!({ "editor.fontSize": 15.0 }));
}
}