use std::{cell::RefCell, rc::Rc};
use gpui_kit::{
Anchor, AnyElement, App, AppContext as _, Axis, ElementId, Entity, FontWeight, Hsla,
InteractiveElement as _, IntoElement, ParentElement as _, PathPromptOptions, SharedString,
Styled as _, Subscription, Window,
component::{
ActiveTheme as _, AxisExt as _, Disableable as _, Sizable as _,
button::Button,
h_flex,
input::{Input, InputEvent, InputState, NumberInput, NumberInputEvent, StepAction},
menu::{DropdownMenu, PopupMenuItem},
setting::{NumberFieldOptions, RenderOptions, SettingField, SettingItem},
slider::{Slider, SliderEvent, SliderState},
switch::Switch,
v_flex,
},
div, hsla,
prelude::FluentBuilder as _,
px,
};
struct PathInputState {
input: Entity<InputState>,
_subscription: Subscription,
}
type PathSetter = Rc<dyn Fn(String, &mut App)>;
type TokenSetter = Rc<dyn Fn(usize, &mut App)>;
type TextSetter = Rc<dyn Fn(String, &mut App)>;
type NumberSetter = Rc<dyn Fn(f64, &mut App)>;
type NumberReader = Rc<dyn Fn(&App) -> f64>;
type SwitchSetter = Rc<dyn Fn(bool, &mut App)>;
pub fn path_setting<G, S>(
key: &'static str,
prompt: &'static str,
directories: bool,
files: bool,
value: G,
set_value: S,
) -> SettingField<SharedString>
where
G: Fn(&App) -> String + 'static,
S: Fn(String, &mut App) + 'static,
{
SettingField::render(path_control(
key,
prompt,
directories,
files,
value,
set_value,
))
}
pub fn path_control<G, S>(
key: &'static str,
prompt: &'static str,
directories: bool,
files: bool,
value: G,
set_value: S,
) -> Control
where
G: Fn(&App) -> String + 'static,
S: Fn(String, &mut App) + 'static,
{
let setter: PathSetter = Rc::new(set_value);
control(move |options, window, cx| {
let current: SharedString = value(cx).into();
let keyed = SharedString::from(format!(
"path-field-{key}-{}-{}-{}",
options.page_ix(),
options.group_ix(),
options.item_ix()
));
let setter_for_input = setter.clone();
let state = window.use_keyed_state(keyed.clone(), cx, {
let current = current.clone();
move |window, cx| {
let input = cx.new(|cx| InputState::new(window, cx).default_value(current.clone()));
let subscription = cx.subscribe(&input, move |_, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
setter_for_input(input.read(cx).value().trim().to_owned(), cx);
}
});
PathInputState {
input,
_subscription: subscription,
}
}
});
let input = state.read(cx).input.clone();
if input.read(cx).value() != current {
input.update(cx, |input, cx| {
input.set_value(current, window, cx);
});
}
let browse_id = ElementId::Name(SharedString::from(format!("{keyed}-browse")));
let setter_for_browse = setter.clone();
let browse = {
let input = input.clone();
Button::new(browse_id)
.label("Browse…")
.small()
.outline()
.disabled(options.is_disabled())
.on_click(move |_, window, cx| {
let receiver = cx.prompt_for_paths(PathPromptOptions {
files,
directories,
multiple: false,
prompt: Some(prompt.into()),
});
let input = input.clone();
let setter = setter_for_browse.clone();
window
.spawn(cx, async move |cx| {
let Ok(Ok(Some(paths))) = receiver.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
let value = path.display().to_string();
let _ = input.update_in(cx, |input, window, cx| {
input.set_value(value.clone(), window, cx);
});
let _ = cx.update(|_window, cx| setter(value, cx));
})
.detach();
})
};
h_flex()
.w(px(340.))
.gap_2()
.items_center()
.child(
Input::new(&input)
.disabled(options.is_disabled())
.with_size(options.size())
.flex_1(),
)
.child(browse)
.into_any_element()
})
}
pub fn token_slider<G, S>(
key: &'static str,
min_exponent: u32,
max_exponent: u32,
value: G,
set_value: S,
) -> SettingField<SharedString>
where
G: Fn(&App) -> usize + 'static,
S: Fn(usize, &mut App) + 'static,
{
SettingField::render(token_control(
key,
min_exponent,
max_exponent,
value,
set_value,
))
}
pub fn token_control<G, S>(
key: &'static str,
min_exponent: u32,
max_exponent: u32,
value: G,
set_value: S,
) -> Control
where
G: Fn(&App) -> usize + 'static,
S: Fn(usize, &mut App) + 'static,
{
let setter: TokenSetter = Rc::new(set_value);
let min = min_exponent as f32;
let max = max_exponent as f32;
control(move |options, window, cx| {
let tokens = value(cx);
let exponent = exponent_for(tokens, min_exponent, max_exponent);
let keyed = SharedString::from(format!(
"token-slider-{key}-{}-{}-{}",
options.page_ix(),
options.group_ix(),
options.item_ix()
));
let setter_for_slider = setter.clone();
let state = window.use_keyed_state(keyed, cx, move |_, cx| {
let slider = cx.new(|_| {
SliderState::new()
.min(min)
.max(max)
.step(1.0)
.default_value(exponent as f32)
});
let subscription = cx.subscribe(&slider, move |_, _, event: &SliderEvent, cx| {
let exponent = match event {
SliderEvent::Change(value) | SliderEvent::Release(value) => {
value.end().round() as u32
}
};
let tokens = 1usize << exponent;
setter_for_slider(tokens, cx);
cx.notify();
});
SliderFieldState {
slider,
_subscription: subscription,
}
});
let slider = state.read(cx).slider.clone();
let expected = exponent as f32;
if (slider.read(cx).value().end() - expected).abs() > f32::EPSILON {
slider.update(cx, |slider, cx| {
slider.set_value(expected, window, cx);
});
}
let shown = 1usize << exponent;
h_flex()
.w(px(280.))
.gap_3()
.items_center()
.child(
Slider::new(&slider)
.disabled(options.is_disabled())
.flex_1(),
)
.child(
gpui_kit::div()
.w(px(96.))
.text_sm()
.child(format_tokens(shown)),
)
.into_any_element()
})
}
struct SliderFieldState {
slider: Entity<SliderState>,
_subscription: Subscription,
}
fn exponent_for(tokens: usize, min: u32, max: u32) -> u32 {
let clamped = tokens.max(1);
let exponent = usize::BITS - 1 - clamped.leading_zeros();
let lower = 1usize << exponent;
let upper = lower.saturating_mul(2);
let rounded = if clamped.saturating_sub(lower) > upper.saturating_sub(clamped) {
exponent + 1
} else {
exponent
};
rounded.clamp(min, max)
}
fn format_tokens(tokens: usize) -> String {
let digits = tokens.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (index, digit) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
out.push(',');
}
out.push(digit);
}
out
}
pub type Control = Box<dyn Fn(&RenderOptions, &mut Window, &mut App) -> AnyElement>;
pub fn control<E>(render: impl Fn(&RenderOptions, &mut Window, &mut App) -> E + 'static) -> Control
where
E: IntoElement + 'static,
{
Box::new(move |options, window, cx| render(options, window, cx).into_any_element())
}
pub fn dirty_color(cx: &App) -> Hsla {
if cx.theme().is_dark() {
hsla(266.0 / 360.0, 0.62, 0.74, 1.0)
} else {
hsla(266.0 / 360.0, 0.55, 0.36, 1.0)
}
}
pub struct SettingRow {
title: SharedString,
description: Option<SharedString>,
control: Control,
is_dirty: Rc<dyn Fn(&App) -> bool>,
layout: Option<Axis>,
}
impl SettingRow {
pub fn new(title: impl Into<SharedString>, control: Control) -> Self {
Self {
title: title.into(),
description: None,
control,
is_dirty: Rc::new(|_| false),
layout: None,
}
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn vertical(mut self) -> Self {
self.layout = Some(Axis::Vertical);
self
}
pub fn dirty<F>(mut self, is_dirty: F) -> Self
where
F: Fn(&App) -> bool + 'static,
{
self.is_dirty = Rc::new(is_dirty);
self
}
pub fn into_item(self) -> SettingItem {
let title = self.title;
let keywords = [title.clone()];
let description = self.description;
let control = self.control;
let is_dirty = self.is_dirty;
let layout = self.layout;
SettingItem::render(move |options, window, cx| {
let dirty = is_dirty(cx);
let layout = layout.unwrap_or(options.layout());
let title = gpui_kit::div()
.text_sm()
.when(dirty, |this| {
this.font_weight(FontWeight::BOLD)
.text_color(dirty_color(cx))
})
.child(title.clone());
let text = v_flex()
.map(|this| {
if layout.is_horizontal() {
this.flex_1().max_w(gpui_kit::relative(0.6))
} else {
this.w_full()
}
})
.child(title)
.when_some(description.clone(), |this, description| {
this.child(
gpui_kit::div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(description),
)
});
let control = (control)(&options.with_layout(layout), window, cx);
if layout.is_horizontal() {
h_flex()
.w_full()
.justify_between()
.items_center()
.gap_3()
.child(text)
.child(control)
.into_any_element()
} else {
v_flex()
.w_full()
.gap_3()
.child(text)
.child(control)
.into_any_element()
}
})
.keywords(keywords)
}
}
pub fn dirty_header(cx: &App) -> gpui_kit::StyleRefinement {
let mut style = gpui_kit::StyleRefinement::default();
style.text.color = Some(dirty_color(cx));
style.text.font_weight = Some(FontWeight::BOLD);
style
}
struct TextControlState {
input: Entity<InputState>,
_subscription: Subscription,
}
struct NumberControlState {
input: Entity<InputState>,
shown: f64,
warning: Rc<RefCell<Option<SharedString>>>,
_subscriptions: Vec<Subscription>,
}
pub fn text_control<V, W>(key: &'static str, value: V, write: W) -> Control
where
V: Fn(&App) -> String + 'static,
W: Fn(String, &mut App) + 'static,
{
let write: TextSetter = Rc::new(write);
let key = SharedString::from(key);
control(move |options, window, cx| {
let current: SharedString = value(cx).into();
let keyed = SharedString::from(format!(
"row-input-{key}-{}-{}-{}",
options.page_ix(),
options.group_ix(),
options.item_ix()
));
let setter = write.clone();
let state = window.use_keyed_state(keyed, cx, {
let current = current.clone();
move |window, cx| {
let input = cx.new(|cx| InputState::new(window, cx).default_value(current.clone()));
let subscription = cx.subscribe(&input, move |_, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
setter(input.read(cx).value().to_string(), cx);
}
});
TextControlState {
input,
_subscription: subscription,
}
}
});
let input = state.read(cx).input.clone();
if input.read(cx).value() != current {
input.update(cx, |input, cx| {
input.set_value(current, window, cx);
});
}
Input::new(&input)
.disabled(options.is_disabled())
.with_size(options.size())
.map(|this| {
if options.layout().is_horizontal() {
this.w_64()
} else {
this.w_full()
}
})
.into_any_element()
})
}
pub fn number_control<V, W>(
key: &'static str,
options: NumberFieldOptions,
value: V,
write: W,
) -> Control
where
V: Fn(&App) -> f64 + 'static,
W: Fn(f64, &mut App) + 'static,
{
let write: NumberSetter = Rc::new(write);
let read: NumberReader = Rc::new(value);
let key = SharedString::from(key);
control(move |render_options, window, cx| {
let current = read(cx);
let keyed = SharedString::from(format!(
"row-number-{key}-{}-{}-{}",
render_options.page_ix(),
render_options.group_ix(),
render_options.item_ix()
));
let options_for_state = options.clone();
let setter = write.clone();
let reader = read.clone();
let state = window.use_keyed_state(keyed, cx, move |window, cx| {
let input = cx.new(|cx| {
InputState::new(window, cx).default_value(current.to_string())
});
let draft = Rc::new(RefCell::new(current.to_string()));
let warning = Rc::new(RefCell::new(None::<SharedString>));
let commit = {
let setter = setter.clone();
let reader = reader.clone();
let warning = warning.clone();
let options = options_for_state.clone();
move |text: &str, input: &Entity<InputState>, window: &mut Window, cx: &mut App| {
let previous = reader(cx);
match validate_number(text, options.min, options.max) {
Ok(value) => {
*warning.borrow_mut() = None;
setter(value, cx);
}
Err(message) => {
input.update(cx, |input, cx| {
input.set_value(previous.to_string(), window, cx);
});
*warning.borrow_mut() = Some(message.into());
setter(previous, cx);
}
}
}
};
let input_events = cx.subscribe_in(&input, window, {
let setter = setter.clone();
let reader = reader.clone();
let warning = warning.clone();
let draft = draft.clone();
move |_, input, event: &InputEvent, window, cx| match event {
InputEvent::Change => {
let text = input.read(cx).value().to_string();
*draft.borrow_mut() = text.clone();
if warning.borrow().is_some() && text != reader(cx).to_string() {
*warning.borrow_mut() = None;
setter(reader(cx), cx);
}
}
InputEvent::PressEnter { .. } => {
let text = input.read(cx).value().to_string();
commit(&text, input, window, cx);
}
InputEvent::Blur => {
let text = draft.borrow().clone();
commit(&text, input, window, cx);
}
InputEvent::Focus => {}
}
});
let step_events = cx.subscribe_in(&input, window, {
let setter = setter.clone();
let reader = reader.clone();
let warning = warning.clone();
let draft = draft.clone();
let options = options_for_state.clone();
move |_, input, event: &NumberInputEvent, window, cx| {
let NumberInputEvent::Step(action) = event;
let next = step_number(reader(cx), *action, &options);
*warning.borrow_mut() = None;
*draft.borrow_mut() = next.to_string();
input.update(cx, |input, cx| {
input.set_value(next.to_string(), window, cx);
});
setter(next, cx);
}
});
NumberControlState {
input,
shown: current,
warning,
_subscriptions: vec![input_events, step_events],
}
});
let (input, shown, warning) = {
let state = state.read(cx);
(
state.input.clone(),
state.shown,
state.warning.borrow().clone(),
)
};
if shown != current {
input.update(cx, |input, cx| {
input.set_value(current.to_string(), window, cx);
});
state.update(cx, |state, _| state.shown = current);
}
let field = div().debug_selector(|| format!("{key}-number")).child(
NumberInput::new(&input)
.disabled(render_options.is_disabled())
.with_size(render_options.size())
.map(|this| {
if render_options.layout().is_horizontal() {
this.w_32()
} else {
this.w_full()
}
}),
);
match warning {
Some(message) => v_flex()
.gap_1()
.child(field)
.child(
div()
.debug_selector(|| format!("{key}-number-warning"))
.text_xs()
.text_color(cx.theme().danger)
.child(message),
)
.into_any_element(),
None => field.into_any_element(),
}
})
}
fn validate_number(text: &str, min: f64, max: f64) -> Result<f64, String> {
let Ok(value) = text.trim().parse::<f64>() else {
return Err(number_range_message(min, max));
};
if !value.is_finite() {
return Err("Enter a finite number.".to_owned());
}
if !(min..=max).contains(&value) {
return Err(number_range_message(min, max));
}
Ok(value)
}
fn number_range_message(min: f64, max: f64) -> String {
match (min > f64::MIN, max < f64::MAX) {
(true, true) => format!("Enter a number between {min} and {max}."),
(true, false) => format!("Enter a number of at least {min}."),
(false, true) => format!("Enter a number of at most {max}."),
(false, false) => "Enter a valid number.".to_owned(),
}
}
fn step_number(current: f64, action: StepAction, options: &NumberFieldOptions) -> f64 {
let delta = match action {
StepAction::Increment => options.step,
StepAction::Decrement => -options.step,
};
round_to_step(
(current + delta).clamp(options.min, options.max),
options.step,
)
}
fn round_to_step(value: f64, step: f64) -> f64 {
let step = step.abs();
if !step.is_finite() || step == 0.0 {
return value;
}
let decimals = step
.to_string()
.split_once('.')
.map_or(0, |(_, fraction)| fraction.len());
let factor = 10f64.powi(decimals as i32);
(value * factor).round() / factor
}
pub fn switch_control<V, W>(key: &'static str, value: V, write: W) -> Control
where
V: Fn(&App) -> bool + 'static,
W: Fn(bool, &mut App) + 'static,
{
let key = SharedString::from(key);
let write: SwitchSetter = Rc::new(write);
control(move |options, _window, cx| {
let checked = value(cx);
let setter = write.clone();
Switch::new(ElementId::Name(
format!("row-switch-{key}-{}", options.item_ix()).into(),
))
.checked(checked)
.disabled(options.is_disabled())
.with_size(options.size())
.on_change(move |value, _window, cx| setter(*value, cx))
.into_any_element()
})
}
pub fn dropdown_control<O, V, W>(key: &'static str, options: O, value: V, write: W) -> Control
where
O: Fn(&App) -> Vec<(SharedString, SharedString)> + 'static,
V: Fn(&App) -> String + 'static,
W: Fn(String, &mut App) + 'static,
{
let key = SharedString::from(key);
let write: TextSetter = Rc::new(write);
control(move |render_options, _window, cx| {
let current: SharedString = value(cx).into();
let options = options(cx);
let label = options
.iter()
.find(|(value, _)| *value == current)
.map(|(_, label)| label.clone())
.unwrap_or_else(|| {
if current.is_empty() {
SharedString::from("Choose…")
} else {
current.clone()
}
});
let setter = write.clone();
Button::new(ElementId::Name(
format!("row-dropdown-{key}-{}", render_options.item_ix()).into(),
))
.when(render_options.layout().is_vertical(), |this| this.w_full())
.label(label)
.dropdown_caret(true)
.outline()
.disabled(render_options.is_disabled())
.with_size(render_options.size())
.dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
options
.iter()
.fold(menu, |menu, (value, label)| {
let checked = *value == current;
let value = value.clone();
let setter = setter.clone();
menu.item(
PopupMenuItem::new(label.clone())
.checked(checked)
.on_click(move |_, _, cx| setter(value.to_string(), cx)),
)
})
.scrollable(true)
})
.into_any_element()
})
}
#[cfg(test)]
mod tests {
use gpui_kit::{Context, Modifiers, Render, TestAppContext, VisualTestContext};
use super::*;
struct NumberModel {
value: f64,
}
struct NumberHost {
model: Entity<NumberModel>,
control: Control,
}
impl NumberHost {
fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
let model = cx.new(|_| NumberModel { value: 0.5 });
let reader = model.clone();
let writer = model.clone();
let control = number_control(
"test-threshold",
NumberFieldOptions {
min: 0.0,
max: 1.0,
step: 0.01,
},
move |cx| reader.read(cx).value,
move |value, cx| {
writer.update(cx, |model, cx| {
model.value = value;
cx.notify();
});
},
);
cx.observe(&model, |_, _, cx| cx.notify()).detach();
Self { model, control }
}
}
impl Render for NumberHost {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.w(px(320.))
.p_4()
.child((self.control)(&RenderOptions::new(), window, cx))
}
}
fn value(host: &Entity<NumberHost>, cx: &mut VisualTestContext) -> f64 {
cx.update(|_, cx| host.read(cx).model.read(cx).value)
}
fn focus_and_replace(cx: &mut VisualTestContext) {
let bounds = cx
.debug_bounds("test-threshold-number")
.expect("number field bounds");
cx.simulate_click(
gpui_kit::point(bounds.origin.x + px(64.), bounds.center().y),
Modifiers::default(),
);
cx.simulate_keystrokes("ctrl-a");
}
#[test]
fn token_values_snap_to_powers_of_two() {
assert_eq!(exponent_for(512, 9, 20), 9);
assert_eq!(exponent_for(700, 9, 20), 9);
assert_eq!(exponent_for(900, 9, 20), 10);
assert_eq!(exponent_for(262_144, 9, 20), 18);
assert_eq!(exponent_for(1, 9, 20), 9);
assert_eq!(exponent_for(usize::MAX, 9, 20), 20);
}
#[test]
fn token_labels_use_thousands_separators() {
assert_eq!(format_tokens(512), "512");
assert_eq!(format_tokens(1_048_576), "1,048,576");
}
#[test]
fn a_non_power_value_rounds_to_the_nearest_step() {
assert_eq!(exponent_for(1_500, 9, 20), 10);
assert_eq!(exponent_for(1_800, 9, 20), 11);
}
#[test]
fn decimal_drafts_are_judged_only_when_complete() {
assert_eq!(validate_number("0.03", 0.0, 1.0), Ok(0.03));
assert_eq!(validate_number("0.", 0.0, 1.0), Ok(0.0));
assert!(validate_number("", 0.0, 1.0).is_err());
assert!(validate_number("1.5", 0.0, 1.0).is_err());
assert!(validate_number("abc", 0.0, 1.0).is_err());
}
#[test]
fn an_invalid_draft_explains_the_bound() {
assert_eq!(
number_range_message(0.0, 1.0),
"Enter a number between 0 and 1."
);
}
#[test]
fn stepping_clamps_and_drops_binary_noise() {
let options = NumberFieldOptions {
min: 0.0,
max: 1.0,
step: 0.01,
};
assert_eq!(step_number(0.5, StepAction::Increment, &options), 0.51);
assert_eq!(step_number(0.0, StepAction::Decrement, &options), 0.0);
assert_eq!(step_number(1.0, StepAction::Increment, &options), 1.0);
}
#[gpui_kit::test]
fn a_decimal_commits_on_enter_not_per_keystroke(cx: &mut TestAppContext) {
cx.update(crate::init);
let (host, cx) = cx.add_window_view(NumberHost::new);
cx.update(|window, cx| window.draw(cx).clear(cx));
focus_and_replace(cx);
cx.simulate_input("0.03");
cx.update(|window, cx| window.draw(cx).clear(cx));
assert_eq!(
value(&host, cx),
0.5,
"the setting must not see intermediate keystrokes"
);
cx.simulate_keystrokes("enter");
cx.run_until_parked();
cx.update(|window, cx| window.draw(cx).clear(cx));
assert_eq!(value(&host, cx), 0.03);
}
#[gpui_kit::test]
fn an_out_of_range_decimal_warns_and_restores(cx: &mut TestAppContext) {
cx.update(crate::init);
let (host, cx) = cx.add_window_view(NumberHost::new);
cx.update(|window, cx| window.draw(cx).clear(cx));
focus_and_replace(cx);
cx.simulate_input("1.5");
cx.simulate_keystrokes("enter");
cx.run_until_parked();
for _ in 0..2 {
cx.update(|window, cx| window.draw(cx).clear(cx));
}
assert_eq!(value(&host, cx), 0.5);
assert!(
cx.debug_bounds("test-threshold-number-warning").is_some(),
"the row must explain the bound"
);
}
}