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:
2026-08-09 04:54:31 +08:00
parent 6e0b541660
commit b65ccf1417
4 changed files with 928 additions and 0 deletions
+299
View File
@@ -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);
}
}
+3
View File
@@ -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;
+243
View File
@@ -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);
}
}
+383
View File
@@ -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);
}
}