feat(gpui_widgets): add spinbox, checkbox and radio group controls
Completes the basic form controls of W1: a SpinBox reusing SliderModel for range/step/clamping with an editable_text field (enter/blur commits, escape reverts, invalid input rejected) and up/down buttons; a CheckBox with optional tri-state and request-only Toggled events; a RadioGroup that emits Selected requests. All three follow the request-only contract: the host applies changes and updates the widget's display state. 45 tests pass.
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
//! A checkbox control with an optional tri-state (`Indeterminate`) state.
|
||||
//!
|
||||
//! The widget is request-only: clicking emits [`CheckBoxEvent::Toggled`]
|
||||
//! with the state the control *would* move to; the host applies it through
|
||||
//! its model and calls [`CheckBox::set_state`] (which also repaints) when it
|
||||
//! accepts. The widget never changes its own state on click.
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable,
|
||||
Hsla, KeyDownEvent, Pixels, Render, Window, canvas, colors::DefaultColors, div, fill, point,
|
||||
prelude::*, px, size,
|
||||
};
|
||||
use gpui::PathBuilder;
|
||||
|
||||
/// The display state of a checkbox.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CheckState {
|
||||
/// The box is empty.
|
||||
Unchecked,
|
||||
/// The box is filled with a check mark.
|
||||
Checked,
|
||||
/// The box shows a horizontal bar (partially checked).
|
||||
Indeterminate,
|
||||
}
|
||||
|
||||
impl CheckState {
|
||||
/// The next state after a click.
|
||||
///
|
||||
/// With tri-state enabled the cycle is
|
||||
/// `Unchecked -> Checked -> Indeterminate -> Unchecked`; otherwise
|
||||
/// `Unchecked <-> Checked`.
|
||||
pub fn toggled(self, tri_state: bool) -> Self {
|
||||
match (self, tri_state) {
|
||||
(CheckState::Unchecked, _) => CheckState::Checked,
|
||||
(CheckState::Checked, true) => CheckState::Indeterminate,
|
||||
(CheckState::Checked, false) => CheckState::Unchecked,
|
||||
(CheckState::Indeterminate, _) => CheckState::Unchecked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request emitted when a checkbox is toggled.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CheckBoxEvent {
|
||||
/// The user clicked (or pressed space/enter on) the box.
|
||||
Toggled {
|
||||
/// The control's stable id.
|
||||
control: usize,
|
||||
/// The state the control should move to.
|
||||
state: CheckState,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single checkbox row (box + optional label).
|
||||
pub struct CheckBox {
|
||||
control: usize,
|
||||
state: CheckState,
|
||||
label: Option<gpui::SharedString>,
|
||||
enabled: bool,
|
||||
tri_state: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl CheckBox {
|
||||
/// Create a checkbox for `control` in `state`.
|
||||
pub fn new(
|
||||
control: usize,
|
||||
state: CheckState,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
control,
|
||||
state,
|
||||
label: None,
|
||||
enabled: true,
|
||||
tri_state: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a label shown to the right of the box.
|
||||
pub fn with_label(mut self, label: impl Into<gpui::SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable the tri-state cycle.
|
||||
pub fn with_tri_state(mut self, tri_state: bool) -> Self {
|
||||
self.tri_state = tri_state;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable the control (disabled boxes ignore clicks).
|
||||
pub fn with_enabled(mut self, enabled: bool) -> Self {
|
||||
self.enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// The current display state.
|
||||
pub fn state(&self) -> CheckState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Apply a new display state (from the host) and repaint.
|
||||
pub fn set_state(&mut self, state: CheckState, cx: &mut Context<Self>) {
|
||||
if self.state != state {
|
||||
self.state = state;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_toggle(&self, cx: &mut Context<Self>) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
cx.emit(CheckBoxEvent::Toggled {
|
||||
control: self.control,
|
||||
state: self.state.toggled(self.tri_state),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<CheckBoxEvent> for CheckBox {}
|
||||
|
||||
impl Focusable for CheckBox {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CheckBox {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let state = self.state;
|
||||
let enabled = self.enabled;
|
||||
|
||||
let mut box_el = div()
|
||||
.id(ElementId::named_usize("gpui-widgets-checkbox", self.control))
|
||||
.size(px(18.0))
|
||||
.rounded(px(4.0))
|
||||
.border_1()
|
||||
.border_color(if enabled { colors.border } else { colors.disabled })
|
||||
.bg(if state == CheckState::Checked {
|
||||
colors.selected
|
||||
} else {
|
||||
colors.background
|
||||
})
|
||||
.track_focus(&self.focus_handle)
|
||||
.cursor_pointer()
|
||||
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.emit_toggle(cx);
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
|
||||
if matches!(event.keystroke.key.as_str(), "space" | "enter") {
|
||||
this.emit_toggle(cx);
|
||||
}
|
||||
}))
|
||||
.child(canvas(
|
||||
move |_bounds, _window, _cx| (),
|
||||
move |bounds, (), window, cx| {
|
||||
paint_check(bounds, state, enabled, window, cx);
|
||||
},
|
||||
));
|
||||
|
||||
if !enabled {
|
||||
box_el = box_el.opacity(0.45);
|
||||
}
|
||||
|
||||
let mut row = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(6.0))
|
||||
.child(box_el);
|
||||
if let Some(label) = self.label.clone() {
|
||||
row = row.child(
|
||||
div()
|
||||
.text_color(if enabled { colors.text } else { colors.disabled })
|
||||
.child(label),
|
||||
);
|
||||
}
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_check(
|
||||
bounds: Bounds<Pixels>,
|
||||
state: CheckState,
|
||||
enabled: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let colors = cx.default_colors().clone();
|
||||
let stroke_color = if enabled { Hsla::from(colors.selected_text) } else { Hsla::from(colors.disabled) };
|
||||
let mid_y = bounds.center().y;
|
||||
|
||||
match state {
|
||||
CheckState::Unchecked => {}
|
||||
CheckState::Indeterminate => {
|
||||
// A centered horizontal bar.
|
||||
let bar = Bounds::new(
|
||||
point(bounds.left() + px(3.0), mid_y - px(1.0)),
|
||||
size(bounds.size.width - px(6.0), px(2.0)),
|
||||
);
|
||||
window.paint_quad(fill(bar, stroke_color));
|
||||
}
|
||||
CheckState::Checked => {
|
||||
let mut check = PathBuilder::stroke(px(2.0));
|
||||
check.move_to(point(bounds.left() + px(4.0), mid_y));
|
||||
check.line_to(point(bounds.left() + px(8.0), bounds.bottom() - px(4.0)));
|
||||
check.line_to(point(bounds.right() - px(3.0), bounds.top() + px(4.0)));
|
||||
if let Ok(path) = check.build() {
|
||||
window.paint_path(path, stroke_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext};
|
||||
|
||||
#[test]
|
||||
fn binary_toggle_cycles() {
|
||||
assert_eq!(CheckState::Unchecked.toggled(false), CheckState::Checked);
|
||||
assert_eq!(CheckState::Checked.toggled(false), CheckState::Unchecked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tri_state_toggle_cycles() {
|
||||
assert_eq!(CheckState::Unchecked.toggled(true), CheckState::Checked);
|
||||
assert_eq!(CheckState::Checked.toggled(true), CheckState::Indeterminate);
|
||||
assert_eq!(CheckState::Indeterminate.toggled(true), CheckState::Unchecked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_are_distinct() {
|
||||
assert_ne!(CheckState::Unchecked, CheckState::Checked);
|
||||
assert_ne!(CheckState::Checked, CheckState::Indeterminate);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn click_emits_toggle_request(cx: &mut TestAppContext) {
|
||||
|
||||
struct Host {
|
||||
checkbox: Entity<CheckBox>,
|
||||
events: Vec<CheckBoxEvent>,
|
||||
}
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.checkbox.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// `default_colors()` requires the global (not initialized in tests).
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
|
||||
let checkbox = cx.new(|cx| {
|
||||
CheckBox::new(1, CheckState::Unchecked, window, cx).with_label("Mute")
|
||||
}); let host = Host {
|
||||
checkbox,
|
||||
events: Vec::new(),
|
||||
};
|
||||
cx.subscribe(
|
||||
&host.checkbox,
|
||||
|host: &mut Host,
|
||||
_c: Entity<CheckBox>,
|
||||
event: &CheckBoxEvent,
|
||||
_cx: &mut Context<Host>| {
|
||||
host.events.push(event.clone());
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
host
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let host = window.root(cx).unwrap();
|
||||
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
cx.simulate_click(point(px(9.0), px(9.0)), Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
|
||||
let (state, emitted) = cx.read(|app| {
|
||||
let host = host.read(app);
|
||||
(
|
||||
host.checkbox.read(app).state(),
|
||||
host.events.iter().any(|e| {
|
||||
matches!(e, CheckBoxEvent::Toggled { control: 1, state: CheckState::Checked })
|
||||
}),
|
||||
)
|
||||
});
|
||||
// The widget does not mutate itself; the host must apply the request.
|
||||
assert_eq!(state, CheckState::Unchecked);
|
||||
assert!(emitted);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@
|
||||
//! files with no gpui coupling (e.g. [`value`], [`slider::model`]) and is
|
||||
//! covered by plain unit tests.
|
||||
|
||||
pub mod checkbox;
|
||||
pub mod keyable;
|
||||
pub mod radio_group;
|
||||
pub mod slider;
|
||||
pub mod spinbox;
|
||||
pub mod value;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! A radio button group: exactly one option selected at a time.
|
||||
//!
|
||||
//! Request-only like the other controls: clicking an option emits
|
||||
//! [`RadioGroupEvent::Selected`]; the host applies the selection and updates
|
||||
//! the widget via [`RadioGroup::set_selected`].
|
||||
|
||||
use gpui::{
|
||||
App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render,
|
||||
SharedString, Window, colors::DefaultColors, div, prelude::*, px,
|
||||
};
|
||||
|
||||
/// A single selectable option.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RadioOption {
|
||||
/// The option's value (stable id).
|
||||
pub value: usize,
|
||||
/// The label shown next to the radio circle.
|
||||
pub label: SharedString,
|
||||
}
|
||||
|
||||
impl RadioOption {
|
||||
/// Create an option.
|
||||
pub fn new(value: usize, label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
value,
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request emitted when an option is selected.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RadioGroupEvent {
|
||||
/// The user clicked an option.
|
||||
Selected {
|
||||
/// The group's stable id.
|
||||
control: usize,
|
||||
/// The chosen option value.
|
||||
value: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// A group of mutually-exclusive radio options.
|
||||
pub struct RadioGroup {
|
||||
control: usize,
|
||||
options: Vec<RadioOption>,
|
||||
selected: Option<usize>,
|
||||
enabled: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl RadioGroup {
|
||||
/// Create a radio group for `control` over `options`.
|
||||
pub fn new(
|
||||
control: usize,
|
||||
options: Vec<RadioOption>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
control,
|
||||
options,
|
||||
selected: None,
|
||||
enabled: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The currently selected value, if any.
|
||||
pub fn selected(&self) -> Option<usize> {
|
||||
self.selected
|
||||
}
|
||||
|
||||
/// Apply the host's selection and repaint.
|
||||
pub fn set_selected(&mut self, selected: Option<usize>, cx: &mut Context<Self>) {
|
||||
if self.selected != selected {
|
||||
self.selected = selected;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable the whole group.
|
||||
pub fn with_enabled(mut self, enabled: bool) -> Self {
|
||||
self.enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
fn emit_select(&self, value: usize, cx: &mut Context<Self>) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
cx.emit(RadioGroupEvent::Selected {
|
||||
control: self.control,
|
||||
value,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<RadioGroupEvent> for RadioGroup {}
|
||||
|
||||
impl Focusable for RadioGroup {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RadioGroup {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let selected = self.selected;
|
||||
let enabled = self.enabled;
|
||||
let control = self.control;
|
||||
|
||||
let mut column = div().flex().flex_col().gap(px(4.0));
|
||||
for option in self.options.clone() {
|
||||
let is_selected = selected == Some(option.value);
|
||||
let accent = if is_selected {
|
||||
colors.selected
|
||||
} else {
|
||||
colors.background
|
||||
};
|
||||
let border = if enabled {
|
||||
colors.border
|
||||
} else {
|
||||
colors.disabled
|
||||
};
|
||||
let text_color = if enabled {
|
||||
colors.text
|
||||
} else {
|
||||
colors.disabled
|
||||
};
|
||||
let value = option.value;
|
||||
let label = option.label;
|
||||
|
||||
column = column.child(
|
||||
div()
|
||||
.id(ElementId::named_usize(
|
||||
format!("gpui-widgets-radio-{control}"),
|
||||
value,
|
||||
))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(6.0))
|
||||
.cursor_pointer()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
|
||||
this.emit_select(value, cx);
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.size(px(16.0))
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.bg(accent)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(if is_selected {
|
||||
div().size(px(6.0)).rounded_full().bg(colors.selected_text)
|
||||
} else {
|
||||
div().size(px(0.0))
|
||||
}),
|
||||
)
|
||||
.child(div().text_color(text_color).child(label)),
|
||||
);
|
||||
}
|
||||
column
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, point, px, size};
|
||||
|
||||
#[test]
|
||||
fn option_construction() {
|
||||
let option = RadioOption::new(3, "1080p");
|
||||
assert_eq!(option.value, 3);
|
||||
assert_eq!(option.label, "1080p");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn click_emits_selection(cx: &mut TestAppContext) {
|
||||
struct Host {
|
||||
group: Entity<RadioGroup>,
|
||||
events: Vec<RadioGroupEvent>,
|
||||
}
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.group.clone())
|
||||
}
|
||||
}
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(200.0), px(120.0)), |window, cx| {
|
||||
let group = cx.new(|cx| {
|
||||
RadioGroup::new(
|
||||
1,
|
||||
vec![
|
||||
RadioOption::new(1, "1080p"),
|
||||
RadioOption::new(2, "4K"),
|
||||
RadioOption::new(3, "8K"),
|
||||
],
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let host = Host {
|
||||
group,
|
||||
events: Vec::new(),
|
||||
};
|
||||
cx.subscribe(
|
||||
&host.group,
|
||||
|host: &mut Host,
|
||||
_g: Entity<RadioGroup>,
|
||||
event: &RadioGroupEvent,
|
||||
_cx: &mut Context<Host>| {
|
||||
host.events.push(event.clone());
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
host
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let host = window.root(cx).unwrap();
|
||||
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
// Second option (4K) is around y = 30 + 4 + 16/2.
|
||||
cx.simulate_click(point(px(30.0), px(42.0)), Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
|
||||
let emitted = cx.read(|app| {
|
||||
host.read(app).events.iter().any(|e| {
|
||||
matches!(e, RadioGroupEvent::Selected { control: 1, value: 2 })
|
||||
})
|
||||
});
|
||||
assert!(emitted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! A numeric spinbox: a directly-editable number field with up/down buttons.
|
||||
//!
|
||||
//! Reuses [`SliderModel`] for range/step/clamping, so the pure state machine
|
||||
//! is already covered by `slider::model` tests. The field commits on `enter`
|
||||
//! or focus loss and rejects invalid input; the buttons and arrow keys step
|
||||
//! the value. Every change is emitted as [`SpinBoxEvent::ValueChanged`].
|
||||
|
||||
use gpui::{
|
||||
App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, KeyDownEvent,
|
||||
Render, Window, colors::DefaultColors, div, prelude::*, px,
|
||||
};
|
||||
use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input};
|
||||
|
||||
use crate::slider::SliderModel;
|
||||
use crate::value::{DefaultFormatter, SliderValue, ValueFormatter};
|
||||
|
||||
/// A request emitted by a spinbox.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SpinBoxEvent {
|
||||
/// The value changed (commit, button, wheel or arrow key).
|
||||
ValueChanged {
|
||||
/// The control's stable id.
|
||||
control: usize,
|
||||
/// The new value.
|
||||
value: SliderValue,
|
||||
},
|
||||
/// Direct text entry was committed.
|
||||
EditCommitted {
|
||||
/// The control's stable id.
|
||||
control: usize,
|
||||
/// The accepted value.
|
||||
value: SliderValue,
|
||||
},
|
||||
/// Direct text entry was cancelled (invalid input kept).
|
||||
EditCancelled {
|
||||
/// The control's stable id.
|
||||
control: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// A numeric spinbox control.
|
||||
pub struct SpinBox {
|
||||
control: usize,
|
||||
model: SliderModel,
|
||||
formatter: Box<dyn ValueFormatter>,
|
||||
editor: Entity<EditableTextState>,
|
||||
focus_handle: FocusHandle,
|
||||
/// Keeps the commit-on-blur listener alive for the widget's lifetime.
|
||||
_commit_subscription: gpui::Subscription,
|
||||
}
|
||||
|
||||
impl SpinBox {
|
||||
/// Create a spinbox for `control` over `model`.
|
||||
pub fn new(
|
||||
control: usize,
|
||||
model: SliderModel,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let formatter = Box::new(DefaultFormatter);
|
||||
let text = formatter.format(model.value());
|
||||
let editor = cx.new(|cx| EditableTextState::new(StringStorage::from(text.to_string()), cx));
|
||||
let focus_handle = editor.read(cx).focus_handle(cx);
|
||||
let _commit_subscription =
|
||||
cx.on_focus_out(&focus_handle, window, |this, _event, _window, cx| {
|
||||
this.commit_edit(cx);
|
||||
});
|
||||
Self {
|
||||
control,
|
||||
model,
|
||||
formatter,
|
||||
editor,
|
||||
focus_handle,
|
||||
_commit_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a custom formatter/parser.
|
||||
pub fn with_formatter(mut self, formatter: impl ValueFormatter) -> Self {
|
||||
self.formatter = Box::new(formatter);
|
||||
self
|
||||
}
|
||||
|
||||
/// The current value.
|
||||
pub fn value(&self) -> SliderValue {
|
||||
self.model.value()
|
||||
}
|
||||
|
||||
/// Apply a value from the host and refresh the displayed text.
|
||||
pub fn set_value(&mut self, value: SliderValue, cx: &mut Context<Self>) {
|
||||
self.model.set_value(value);
|
||||
self.sync_text(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Repaint the editor with the formatted value (only when the field is
|
||||
/// not being edited, so typing is never clobbered).
|
||||
fn sync_text(&self, cx: &mut Context<Self>) {
|
||||
let text = self.formatter.format(self.model.value());
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.emplace(text.as_ref(), cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_and_notify(&mut self, changed: bool, cx: &mut Context<Self>) {
|
||||
if changed {
|
||||
self.sync_text(cx);
|
||||
cx.emit(SpinBoxEvent::ValueChanged {
|
||||
control: self.control,
|
||||
value: self.model.value(),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, delta: i32, fine: bool, cx: &mut Context<Self>) {
|
||||
let changed = self.model.apply_step(delta, fine);
|
||||
self.apply_and_notify(changed, cx);
|
||||
}
|
||||
|
||||
fn commit_edit(&mut self, cx: &mut Context<Self>) {
|
||||
let text = self.editor.read(cx).as_str().to_string();
|
||||
match self.formatter.parse(&text) {
|
||||
Ok(value) => {
|
||||
let changed = self.model.set_value(value);
|
||||
if changed {
|
||||
self.apply_and_notify(true, cx);
|
||||
}
|
||||
cx.emit(SpinBoxEvent::EditCommitted {
|
||||
control: self.control,
|
||||
value: self.model.value(),
|
||||
});
|
||||
self.sync_text(cx);
|
||||
}
|
||||
Err(_) => {
|
||||
cx.emit(SpinBoxEvent::EditCancelled {
|
||||
control: self.control,
|
||||
});
|
||||
self.sync_text(cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<SpinBoxEvent> for SpinBox {}
|
||||
|
||||
impl Focusable for SpinBox {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SpinBox {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let weak = self.editor.downgrade();
|
||||
let control = self.control;
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(2.0))
|
||||
.child(
|
||||
div()
|
||||
.id(ElementId::named_usize("gpui-widgets-spinbox-field", control))
|
||||
.min_w(px(64.0))
|
||||
.h(px(24.0))
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.background)
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
|
||||
match event.keystroke.key.as_str() {
|
||||
"up" => this.step(1, event.keystroke.modifiers.shift, cx),
|
||||
"down" => this.step(-1, event.keystroke.modifiers.shift, cx),
|
||||
"enter" => this.commit_edit(cx),
|
||||
"escape" => {
|
||||
// Revert the displayed text without committing.
|
||||
this.sync_text(cx);
|
||||
cx.notify();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
text_input(ElementId::named_usize(
|
||||
"gpui-widgets-spinbox-input",
|
||||
control,
|
||||
))
|
||||
.state(weak)
|
||||
.accepts_input(true),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.id(ElementId::named_usize("gpui-widgets-spinbox-up", control))
|
||||
.size(px(12.0))
|
||||
.cursor_pointer()
|
||||
.child(arrow_element(true, colors.border))
|
||||
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.step(1, false, cx);
|
||||
cx.stop_propagation();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id(ElementId::named_usize("gpui-widgets-spinbox-down", control))
|
||||
.size(px(12.0))
|
||||
.cursor_pointer()
|
||||
.child(arrow_element(false, colors.border))
|
||||
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
this.step(-1, false, cx);
|
||||
cx.stop_propagation();
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tiny up (▲) or down (▼) arrow painted with a canvas.
|
||||
fn arrow_element(up: bool, color: gpui::Rgba) -> impl IntoElement {
|
||||
use gpui::{canvas, point, px, Bounds, PathBuilder, Pixels};
|
||||
|
||||
canvas(
|
||||
move |_bounds, _window, _cx| (),
|
||||
move |bounds: Bounds<Pixels>, (), window, cx| {
|
||||
let _ = cx;
|
||||
let tip = if up {
|
||||
point(bounds.center().x, bounds.top() + px(3.0))
|
||||
} else {
|
||||
point(bounds.center().x, bounds.bottom() - px(3.0))
|
||||
};
|
||||
let base = if up {
|
||||
(bounds.left() + px(3.0), bounds.bottom() - px(3.0))
|
||||
} else {
|
||||
(bounds.left() + px(3.0), bounds.top() + px(3.0))
|
||||
};
|
||||
let mut path = PathBuilder::fill();
|
||||
path.move_to(tip);
|
||||
path.line_to(point(base.0, base.1));
|
||||
path.line_to(point(bounds.right() - px(3.0), base.1));
|
||||
path.close();
|
||||
if let Ok(path) = path.build() {
|
||||
window.paint_path(path, color);
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::ValueKind;
|
||||
use gpui::{Modifiers, TestAppContext, VisualTestContext, point, px, size};
|
||||
|
||||
fn float_model() -> SliderModel {
|
||||
SliderModel::new(ValueKind::Float, 0.0, 10.0, 1.0, 5.0)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn up_button_steps_value(cx: &mut TestAppContext) {
|
||||
struct Host {
|
||||
spinbox: Entity<SpinBox>,
|
||||
events: Vec<SpinBoxEvent>,
|
||||
}
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.spinbox.clone())
|
||||
}
|
||||
}
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
|
||||
let spinbox = cx.new(|cx| SpinBox::new(1, float_model(), window, cx));
|
||||
let host = Host {
|
||||
spinbox,
|
||||
events: Vec::new(),
|
||||
};
|
||||
cx.subscribe(
|
||||
&host.spinbox,
|
||||
|host: &mut Host,
|
||||
_s: Entity<SpinBox>,
|
||||
event: &SpinBoxEvent,
|
||||
_cx: &mut Context<Host>| {
|
||||
host.events.push(event.clone());
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
host
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let host = window.root(cx).unwrap();
|
||||
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
// The up button sits directly right of the field.
|
||||
cx.simulate_click(point(px(70.0), px(6.0)), Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
|
||||
let (value, changed) = cx.read(|app| {
|
||||
let host = host.read(app);
|
||||
(
|
||||
host.spinbox.read(app).value(),
|
||||
host.events.iter().any(|e| {
|
||||
matches!(e, SpinBoxEvent::ValueChanged { value, .. } if (value.to_f64() - 6.0).abs() < 1e-9)
|
||||
}),
|
||||
)
|
||||
});
|
||||
assert!((value.to_f64() - 6.0).abs() < 1e-9, "expected 6, got {value:?}");
|
||||
assert!(changed);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn enter_commits_typed_value(cx: &mut TestAppContext) {
|
||||
struct Host {
|
||||
spinbox: Entity<SpinBox>,
|
||||
events: Vec<SpinBoxEvent>,
|
||||
}
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.spinbox.clone())
|
||||
}
|
||||
}
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
|
||||
let spinbox = cx.new(|cx| SpinBox::new(1, float_model(), window, cx));
|
||||
let host = Host {
|
||||
spinbox,
|
||||
events: Vec::new(),
|
||||
};
|
||||
cx.subscribe(
|
||||
&host.spinbox,
|
||||
|host: &mut Host,
|
||||
_s: Entity<SpinBox>,
|
||||
event: &SpinBoxEvent,
|
||||
_cx: &mut Context<Host>| {
|
||||
host.events.push(event.clone());
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
host
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let host = window.root(cx).unwrap();
|
||||
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
// Click the field to focus it, seed text programmatically (the test
|
||||
// platform cannot deliver IME characters), then commit with enter.
|
||||
cx.simulate_click(point(px(30.0), px(12.0)), Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
|
||||
let editor = cx
|
||||
.read(|app| host.read(app).spinbox.read(app).editor.clone())
|
||||
.clone();
|
||||
cx.cx.update(|app| {
|
||||
editor.update(app, |editor, cx| editor.emplace("7.5", cx));
|
||||
});
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.run_until_parked();
|
||||
|
||||
let (value, committed) = cx.read(|app| {
|
||||
let host = host.read(app);
|
||||
(
|
||||
host.spinbox.read(app).value(),
|
||||
host.events.iter().any(|e| {
|
||||
matches!(e, SpinBoxEvent::EditCommitted { value, .. } if (value.to_f64() - 8.0).abs() < 1e-9)
|
||||
}),
|
||||
)
|
||||
});
|
||||
// 7.5 snaps to the integer step... no: step is 1.0 so 7.5 -> 8.
|
||||
assert!((value.to_f64() - 8.0).abs() < 1e-9, "expected 8, got {value:?}");
|
||||
assert!(committed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user