diff --git a/crates/gpui_widgets/src/checkbox.rs b/crates/gpui_widgets/src/checkbox.rs new file mode 100644 index 0000000000..9de6d7db73 --- /dev/null +++ b/crates/gpui_widgets/src/checkbox.rs @@ -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, + 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 { + 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) -> 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) { + if self.state != state { + self.state = state; + cx.notify(); + } + } + + fn emit_toggle(&self, cx: &mut Context) { + if !self.enabled { + return; + } + cx.emit(CheckBoxEvent::Toggled { + control: self.control, + state: self.state.toggled(self.tri_state), + }); + cx.notify(); + } +} + +impl EventEmitter 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) -> 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, + 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, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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, + event: &CheckBoxEvent, + _cx: &mut Context| { + 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); + } +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index e048bf7b58..342ab95521 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -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; diff --git a/crates/gpui_widgets/src/radio_group.rs b/crates/gpui_widgets/src/radio_group.rs new file mode 100644 index 0000000000..e4fb1bef94 --- /dev/null +++ b/crates/gpui_widgets/src/radio_group.rs @@ -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) -> 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, + selected: Option, + enabled: bool, + focus_handle: FocusHandle, +} + +impl RadioGroup { + /// Create a radio group for `control` over `options`. + pub fn new( + control: usize, + options: Vec, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + control, + options, + selected: None, + enabled: true, + focus_handle: cx.focus_handle(), + } + } + + /// The currently selected value, if any. + pub fn selected(&self) -> Option { + self.selected + } + + /// Apply the host's selection and repaint. + pub fn set_selected(&mut self, selected: Option, cx: &mut Context) { + 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) { + if !self.enabled { + return; + } + cx.emit(RadioGroupEvent::Selected { + control: self.control, + value, + }); + cx.notify(); + } +} + +impl EventEmitter 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) -> 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, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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, + event: &RadioGroupEvent, + _cx: &mut Context| { + 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); + } +} diff --git a/crates/gpui_widgets/src/spinbox.rs b/crates/gpui_widgets/src/spinbox.rs new file mode 100644 index 0000000000..16dc425152 --- /dev/null +++ b/crates/gpui_widgets/src/spinbox.rs @@ -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, + editor: Entity, + 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 { + 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.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) { + 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) { + 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) { + let changed = self.model.apply_step(delta, fine); + self.apply_and_notify(changed, cx); + } + + fn commit_edit(&mut self, cx: &mut Context) { + 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 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) -> 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, (), 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, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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, + event: &SpinBoxEvent, + _cx: &mut Context| { + 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, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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, + event: &SpinBoxEvent, + _cx: &mut Context| { + 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); + } +}