Files
oak-gpui/crates/gpui_widgets/src/slider/mod.rs
T
Mike-Solar af5482d774 style: cargo fmt with the workspace tab policy (hard_tabs)
Whitespace only; the fork has no own rustfmt.toml so the Oak root
policy applies.
2026-08-11 23:06:13 +08:00

826 lines
24 KiB
Rust

//! The slider family: float, integer, rational (fraction) and angle sliders.
//!
//! A [`Slider`] is a thin view over the pure [`SliderModel`] state machine.
//! Gestures:
//!
//! * **Drag vertically** (up increases) to change the value; hold `shift` for
//! fine adjustment. A drag starts once the cursor moves past a small
//! threshold, so plain clicks are still clicks.
//! * **Double-click** to type a value directly. `enter` commits, `escape`
//! cancels, and losing focus commits what was typed. Invalid text is
//! rejected and the previous value is kept.
//! * **Mouse wheel** steps the value (coarse, or fine with `shift`).
//! * **Middle-click** resets to the default.
//! * **Arrow keys** step when the slider is focused.
//!
//! Every change is emitted as [`SliderEvent::ValueChanged`]; the widget never
//! mutates engine state. Formatting/parsing is injectable via
//! [`ValueFormatter`](crate::value::ValueFormatter).
mod model;
use std::sync::{Arc, RwLock};
use gpui::{
App, BorderStyle, Bounds, ClickEvent, Context, Corners, DragMoveEvent, Edges, ElementId,
Entity, EventEmitter, FocusHandle, Focusable, KeyDownEvent, MouseButton, MouseDownEvent,
Pixels, Point, Render, ScrollWheelEvent, Subscription, Window, canvas, colors::DefaultColors,
div, fill, point, prelude::*, px, quad, size,
};
use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input};
pub use model::SliderModel;
use crate::keyable::{KeyingRequest, KeyingState, keying_diamond, keying_request};
use crate::value::{SliderValue, ValueFormatter};
/// How far (in pixels) the cursor must move vertically to sweep the whole
/// value range. Larger values make the slider less sensitive.
const VERTICAL_DRAG_RANGE_PX: f32 = 100.0;
/// A request emitted by a slider.
#[derive(Debug, Clone, PartialEq)]
pub enum SliderEvent {
/// The value changed (drag, wheel, reset, typed commit).
ValueChanged {
/// The control's stable id.
control: usize,
/// The new value.
value: SliderValue,
},
/// A drag gesture started.
DragStarted {
/// The control's stable id.
control: usize,
},
/// A drag gesture ended.
DragFinished {
/// The control's stable id.
control: usize,
},
/// Direct text entry was committed (or the editor lost focus).
EditCommitted {
/// The control's stable id.
control: usize,
/// The accepted value.
value: SliderValue,
},
/// Direct text entry was cancelled (escape or rejected input).
EditCancelled {
/// The control's stable id.
control: usize,
},
/// The keying diamond was clicked.
Keying(KeyingRequest),
}
/// Transient payload carried by an in-flight drag gesture.
#[derive(Clone, Copy, Debug)]
struct SliderDrag {
/// Cursor Y at drag start (window coords, px).
start_y: f32,
}
/// Invisible ghost view that follows the cursor during a drag.
#[derive(Clone, Copy, Debug)]
struct SliderDragGhost;
impl Render for SliderDragGhost {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().w(px(0.0)).h(px(0.0))
}
}
/// A single form-control slider.
pub struct Slider {
/// Stable id used for element ids, events and keying requests.
control: usize,
/// The pure state machine.
model: SliderModel,
/// Injectable formatting/parsing.
formatter: Box<dyn ValueFormatter>,
/// The keying state reported at the current frame.
keying: KeyingState,
/// For arrow-key navigation.
focus_handle: FocusHandle,
/// Active direct-entry editor, if any.
editing: Option<Entity<EditableTextState>>,
/// Focus-out subscription of the active editor, kept alive while editing.
edit_subscription: Option<Subscription>,
/// Set by the escape path so a later focus-lost does not commit.
edit_cancelled: bool,
/// True while the user is dragging (so we can report drag start/finish).
dragging: bool,
}
impl Slider {
/// Create a slider for `control` over `model`.
pub fn new(
control: usize,
model: SliderModel,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
model,
formatter: Box::new(crate::value::DefaultFormatter),
keying: KeyingState::NoKey,
focus_handle: cx.focus_handle(),
editing: None,
edit_subscription: None,
edit_cancelled: false,
dragging: false,
}
}
/// Inject a custom formatter/parser for display and direct entry.
pub fn with_formatter(mut self, formatter: impl ValueFormatter) -> Self {
self.formatter = Box::new(formatter);
self
}
/// Report the control's keying state for the current frame.
pub fn with_keying(mut self, state: KeyingState) -> Self {
self.keying = state;
self
}
/// The current value.
pub fn value(&self) -> SliderValue {
self.model.value()
}
/// A copy of the pure model state.
pub fn model(&self) -> SliderModel {
self.model
}
/// Whether direct entry is currently active.
pub fn is_editing(&self) -> bool {
self.editing.is_some()
}
/// The active editor entity, if direct entry is open.
///
/// Hosts and tests can use this to seed or inspect the text being
/// edited (e.g. `editor.update(cx, |editor, cx| editor.emplace("0.7", cx))`).
pub fn editor(&self) -> Option<Entity<EditableTextState>> {
self.editing.clone()
}
/// Set the value directly (clamped and snapped by the model).
pub fn set_value(&mut self, value: SliderValue) {
self.model.set_value(value);
}
/// Emit `ValueChanged` if `changed` is true and repaint.
fn apply_and_notify(&mut self, changed: bool, cx: &mut Context<Self>) {
if changed {
cx.emit(SliderEvent::ValueChanged {
control: self.control,
value: self.model.value(),
});
cx.notify();
}
}
/// Begin double-click direct entry.
fn begin_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.editing.is_some() {
return;
}
let text = self.formatter.format(self.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 subscription = cx.on_focus_out(&focus_handle, window, |this, _event, _window, cx| {
// Clicking away commits what was typed, unless escape already
// cancelled the session.
if this.editing.is_some() {
if this.edit_cancelled {
this.cancel_edit(cx);
} else {
this.commit_edit(cx);
}
}
});
self.edit_cancelled = false;
self.edit_subscription = Some(subscription);
self.editing = Some(editor.clone());
window.focus(&focus_handle, cx);
cx.notify();
}
/// Commit the current editor text. Invalid text is rejected (the
/// previous value is kept) and the editor closes either way.
fn commit_edit(&mut self, cx: &mut Context<Self>) {
if let Some(editor) = self.editing.take() {
self.edit_subscription = None;
let text = editor.read(cx).as_str().to_string();
match self.formatter.parse(&text) {
Ok(value) => {
let changed = self.model.set_value(value);
self.apply_and_notify(changed, cx);
cx.emit(SliderEvent::EditCommitted {
control: self.control,
value: self.model.value(),
});
}
Err(_) => {
cx.emit(SliderEvent::EditCancelled {
control: self.control,
});
}
}
cx.notify();
}
}
/// Cancel direct entry, keeping the previous value.
fn cancel_edit(&mut self, cx: &mut Context<Self>) {
if self.editing.take().is_some() {
self.edit_subscription = None;
cx.emit(SliderEvent::EditCancelled {
control: self.control,
});
cx.notify();
}
}
/// Apply a wheel step.
fn apply_scroll(&mut self, event: &ScrollWheelEvent, cx: &mut Context<Self>) {
let delta = event.delta.pixel_delta(px(16.0));
let steps = (f32::from(delta.y) / 16.0).round() as i32;
if steps == 0 {
return;
}
let fine = event.modifiers.shift;
let changed = self.model.apply_step(steps, fine);
self.apply_and_notify(changed, cx);
}
}
impl EventEmitter<SliderEvent> for Slider {}
impl Focusable for Slider {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Slider {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let fraction = self.model.fraction();
let drag_payload = Arc::new(RwLock::new(SliderDrag { start_y: 0.0 }));
let control = self.control;
let mut track = div()
.id(ElementId::named_usize("gpui-widgets-slider-track", control))
.flex_1()
.h(px(18.0))
.relative()
.rounded_md()
.bg(colors.container)
.track_focus(&self.focus_handle)
.cursor_pointer()
.debug_selector(|| "slider-track".into())
.on_drag(drag_payload.clone(), slider_drag_ghost)
.on_drag_move(cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<SliderDrag>>>, _window, cx| {
let drag = event.drag(cx).clone();
let drag = drag.read().unwrap();
let dy = f32::from(event.event.position.y) - drag.start_y;
let fine = event.event.modifiers.shift;
let changed = this.model.drag_delta(dy, VERTICAL_DRAG_RANGE_PX, fine);
this.apply_and_notify(changed, cx);
},
))
.on_drag_move(cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<SliderDrag>>>, _window, cx| {
if !this.dragging {
this.dragging = true;
cx.emit(SliderEvent::DragStarted {
control: this.control,
});
}
let _ = event;
cx.notify();
},
))
.on_drop(
cx.listener(|this, _drag: &Arc<RwLock<SliderDrag>>, _window, cx| {
if this.dragging {
this.dragging = false;
cx.emit(SliderEvent::DragFinished {
control: this.control,
});
}
}),
)
.on_click(cx.listener(|this, event: &ClickEvent, window, cx| {
if event.click_count() >= 2 {
this.begin_edit(window, cx);
}
}))
.on_mouse_down(
MouseButton::Middle,
cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
let changed = this.model.reset();
this.apply_and_notify(changed, cx);
}),
)
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| {
this.apply_scroll(event, cx);
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"left" | "down" => {
let changed = this.model.apply_step(-1, event.keystroke.modifiers.shift);
this.apply_and_notify(changed, cx);
}
"right" | "up" => {
let changed = this.model.apply_step(1, event.keystroke.modifiers.shift);
this.apply_and_notify(changed, cx);
}
_ => {}
}
}))
.child(canvas(
move |bounds, _window, _cx| bounds,
move |bounds, content, window, cx| {
paint_slider(bounds, content, fraction, window, cx);
},
));
// Direct-entry editor overlay while editing.
if let Some(editor) = self.editing.clone() {
let weak = editor.downgrade();
track = track.child(
div()
.absolute()
.left_0()
.right_0()
.top_0()
.bottom_0()
.flex()
.items_center()
.px_1()
.bg(colors.background)
.on_mouse_down_out(cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
// Clicking outside the editor commits.
this.commit_edit(cx);
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"enter" => this.commit_edit(cx),
"escape" => {
this.edit_cancelled = true;
this.cancel_edit(cx);
}
_ => {}
}
}))
.child(
text_input(ElementId::named_usize(
"gpui-widgets-slider-edit-input",
control,
))
.state(weak)
.accepts_input(true),
),
);
}
div()
.w_full()
.flex()
.items_center()
.gap(px(6.0))
.child(track)
.child(
keying_diamond(self.control, self.keying).on_click(cx.listener(
|this, _event: &ClickEvent, _window, cx| {
cx.emit(SliderEvent::Keying(keying_request(
this.control,
this.keying,
)));
cx.stop_propagation();
},
)),
)
}
}
/// Build the (invisible) ghost view that accompanies a slider drag.
fn slider_drag_ghost(
drag: &Arc<RwLock<SliderDrag>>,
_offset: Point<Pixels>,
window: &mut Window,
cx: &mut App,
) -> Entity<SliderDragGhost> {
if let Ok(mut drag) = drag.write() {
drag.start_y = f32::from(window.mouse_position().y);
}
cx.new(|_| SliderDragGhost)
}
fn paint_slider(
bounds: Bounds<Pixels>,
_content: Bounds<Pixels>,
fraction: f64,
window: &mut Window,
cx: &mut App,
) {
use gpui::Hsla;
let colors = cx.default_colors().clone();
let center_y = px(f32::from(bounds.top()) + f32::from(bounds.size.height) / 2.0);
// Track.
let track_bounds = Bounds::new(
point(bounds.left(), center_y - px(2.0)),
size(bounds.size.width, px(4.0)),
);
window.paint_quad(fill(track_bounds, Hsla::from(colors.border)));
// Active fill up to the fraction.
let width = f32::from(bounds.size.width);
let fill_width = (width * fraction as f32).clamp(0.0, width);
if fill_width > 0.0 {
let fill_bounds = Bounds::new(
point(bounds.left(), track_bounds.top()),
size(px(fill_width), track_bounds.size.height),
);
window.paint_quad(fill(fill_bounds, Hsla::from(colors.selected)));
}
// Handle: a small circle at the fraction position.
let handle_radius = 5.0_f32;
let handle_bounds = Bounds::new(
point(
bounds.left() + px(fill_width - handle_radius),
center_y - px(handle_radius),
),
size(px(handle_radius * 2.0), px(handle_radius * 2.0)),
);
window.paint_quad(quad(
handle_bounds,
Corners::all(px(handle_radius)),
Hsla::from(colors.text),
Edges::all(px(1.0)),
Hsla::from(colors.border),
BorderStyle::Solid,
));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::ValueKind;
use gpui::{Modifiers, MouseUpEvent, ScrollDelta, TestAppContext, VisualTestContext};
#[derive(Clone)]
struct Host {
slider: Entity<Slider>,
events: Vec<SliderEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.flex_col()
.justify_center()
.child(self.slider.clone())
}
}
fn make_host(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
// `default_colors()` (used by the widgets) requires the global to be
// initialized; the test platform does not do it automatically.
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(100.0)), |window, cx| {
let slider = cx.new(|cx| {
Slider::new(
1,
SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.1, 0.5),
window,
cx,
)
});
let host = Host {
slider,
events: Vec::new(),
};
cx.subscribe(
&host.slider,
|host: &mut Host,
_slider: Entity<Slider>,
event: &SliderEvent,
_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, host)
}
fn value_events(events: &[SliderEvent]) -> Vec<f64> {
events
.iter()
.filter_map(|event| match event {
SliderEvent::ValueChanged { value, .. } => Some(value.to_f64()),
_ => None,
})
.collect()
}
fn double_click(cx: &mut VisualTestContext, position: Point<Pixels>) {
let modifiers = Modifiers::none();
cx.simulate_event(MouseDownEvent {
position,
modifiers,
button: MouseButton::Left,
click_count: 2,
first_mouse: false,
});
cx.simulate_event(MouseUpEvent {
position,
modifiers,
button: MouseButton::Left,
click_count: 2,
});
cx.run_until_parked();
// Render the editor overlay so its input handler is registered.
redraw(cx);
}
/// A point comfortably inside the track (row is vertically centered).
fn track_point() -> Point<Pixels> {
point(px(200.0), px(50.0))
}
/// The test platform does not redraw dirty windows on its own, so state
/// changes (which only call `notify`) do not repaint until we force a
/// draw. This is required for freshly-created elements (e.g. the editor
/// overlay) to register their listeners and input handlers.
fn redraw(cx: &mut VisualTestContext) {
cx.update(|window, cx| {
window.draw(cx).clear();
});
}
/// Compare a slider value to an expected float with tolerance (stepping
/// through non-binary steps like 0.1 accumulates representation error).
fn assert_value_close(actual: SliderValue, expected: f64) {
assert!(
(actual.to_f64() - expected).abs() < 1e-6,
"expected {expected}, got {actual:?}"
);
}
#[gpui::test]
async fn track_hitbox_is_rendered(cx: &mut TestAppContext) {
let (cx, _host) = make_host(cx);
let bounds = cx.debug_bounds("slider-track");
assert!(
bounds.is_some(),
"slider track was not painted into the rendered frame"
);
if let Some(bounds) = bounds {
eprintln!("track bounds: {bounds:?}, track point: {:?}", track_point());
assert!(bounds.contains(&track_point()));
}
}
/// Seed the active editor with `text` (the test platform cannot deliver
/// IME characters, so tests set the text directly and exercise the
/// commit/cancel paths).
fn seed_editor_text(cx: &mut VisualTestContext, host: &Entity<Host>, text: &str) {
let editor = cx
.read(|app| host.read(app).slider.read(app).editor())
.expect("editor should be open");
// `cx.update` on VisualTestContext takes a window closure; use the
// underlying TestAppContext to get at the app directly.
cx.cx.update(|app| {
editor.update(app, |editor, cx| editor.emplace(text, cx));
});
}
#[gpui::test]
async fn double_click_opens_editor_and_enter_commits(cx: &mut TestAppContext) {
let (mut cx, host) = make_host(cx);
double_click(&mut cx, track_point());
assert!(cx.read(|app| host.read(app).slider.read(app).is_editing()));
seed_editor_text(&mut cx, &host, "0.7");
cx.simulate_keystrokes("enter");
cx.run_until_parked();
let (value, is_editing, committed) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value(),
host.slider.read(app).is_editing(),
host.events.iter().any(|e| {
matches!(e, SliderEvent::EditCommitted { value, .. } if (value.to_f64() - 0.7).abs() < 1e-6)
}),
)
});
assert!(!is_editing);
assert_value_close(value, 0.7);
assert!(committed);
}
#[gpui::test]
async fn double_click_rejects_invalid_text(cx: &mut TestAppContext) {
let (mut cx, host) = make_host(cx);
double_click(&mut cx, track_point());
seed_editor_text(&mut cx, &host, "abc");
cx.simulate_keystrokes("enter");
cx.run_until_parked();
let (value, is_editing, cancelled) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value(),
host.slider.read(app).is_editing(),
host.events
.iter()
.any(|e| matches!(e, SliderEvent::EditCancelled { .. })),
)
});
assert!(!is_editing);
// The previous value is kept.
assert_eq!(value, SliderValue::Float(0.5));
assert!(cancelled);
}
#[gpui::test]
async fn escape_cancels_editing(cx: &mut TestAppContext) {
let (mut cx, host) = make_host(cx);
double_click(&mut cx, track_point());
cx.simulate_keystrokes("escape");
cx.run_until_parked();
let (value, is_editing, cancelled) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value(),
host.slider.read(app).is_editing(),
host.events
.iter()
.any(|e| matches!(e, SliderEvent::EditCancelled { .. })),
)
});
assert!(!is_editing);
assert_eq!(value, SliderValue::Float(0.5));
assert!(cancelled);
}
#[gpui::test]
async fn middle_click_resets_to_default(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// Move the value first.
cx.simulate_event(ScrollWheelEvent {
position: track_point(),
delta: ScrollDelta::Pixels(point(px(0.0), px(-80.0))),
..Default::default()
});
cx.run_until_parked();
{
let moved = cx.read(|app| host.read(app).slider.read(app).value().to_f64());
assert!(
(moved - 0.5).abs() > 0.01,
"value should have moved, got {moved}"
);
}
// Middle-click resets.
cx.simulate_mouse_down(track_point(), MouseButton::Middle, Modifiers::none());
cx.simulate_mouse_up(track_point(), MouseButton::Middle, Modifiers::none());
cx.run_until_parked();
let (value, events) = cx.read(|app| {
let host = host.read(app);
(host.slider.read(app).value(), host.events.clone())
});
assert_eq!(value, SliderValue::Float(0.5));
assert!(value_events(&events).contains(&0.5));
}
#[gpui::test]
async fn wheel_steps_value(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// Scrolling up (positive y) increases the value.
cx.simulate_event(ScrollWheelEvent {
position: track_point(),
delta: ScrollDelta::Pixels(point(px(0.0), px(80.0))),
..Default::default()
});
cx.run_until_parked();
let (value, events) = cx.read(|app| {
let host = host.read(app);
(host.slider.read(app).value(), host.events.clone())
});
// 80px at 16px per notch = 5 coarse steps of 0.1, clamped to max.
assert_value_close(value, 1.0);
assert!(!value_events(&events).is_empty());
}
#[gpui::test]
async fn vertical_drag_changes_value(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// Press on the track, then drag up. The first move past the drag
// threshold anchors the gesture; the second move applies the delta.
let modifiers = Modifiers::none();
cx.simulate_mouse_down(track_point(), MouseButton::Left, modifiers);
cx.simulate_mouse_move(point(px(200.0), px(45.0)), MouseButton::Left, modifiers);
cx.simulate_mouse_move(point(px(200.0), px(25.0)), MouseButton::Left, modifiers);
// Release inside the track so the drop listener on the slider fires.
cx.simulate_mouse_up(point(px(200.0), px(45.0)), MouseButton::Left, modifiers);
cx.run_until_parked();
let (value, started, finished) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value().to_f64(),
host.events
.iter()
.any(|e| matches!(e, SliderEvent::DragStarted { .. })),
host.events
.iter()
.any(|e| matches!(e, SliderEvent::DragFinished { .. })),
)
});
// 20px / 100px of range = +0.2 over the default 0.5.
assert!((value - 0.7).abs() < 0.001, "expected ~0.7, got {value}");
assert!(started);
assert!(finished);
}
#[gpui::test]
async fn focused_slider_steps_with_arrow_keys(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// Click once to focus the slider (click_count 1 is a no-op for value).
cx.simulate_click(track_point(), Modifiers::none());
cx.simulate_keystrokes("right");
cx.run_until_parked();
let (value, events) = cx.read(|app| {
let host = host.read(app);
(host.slider.read(app).value(), host.events.clone())
});
assert_value_close(value, 0.6);
assert!(value_events(&events).iter().any(|v| (v - 0.6).abs() < 1e-6));
}
#[gpui::test]
async fn keying_diamond_emits_request(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
// The diamond sits at the right edge of the row.
cx.simulate_click(point(px(390.0), px(50.0)), Modifiers::none());
cx.run_until_parked();
let requested = cx.read(|app| {
host.read(app)
.events
.iter()
.any(|e| matches!(e, SliderEvent::Keying(request) if request.control == 1))
});
assert!(requested);
}
#[gpui::test]
async fn clicking_away_commits_editor(cx: &mut TestAppContext) {
let (mut cx, host) = make_host(cx);
double_click(&mut cx, track_point());
// 0.3 is exactly representable at the slider's 0.1 step.
seed_editor_text(&mut cx, &host, "0.3");
// Clicking outside the editor overlay commits the typed value.
cx.simulate_click(point(px(200.0), px(90.0)), Modifiers::none());
cx.run_until_parked();
let (value, is_editing, committed) = cx.read(|app| {
let host = host.read(app);
(
host.slider.read(app).value(),
host.slider.read(app).is_editing(),
host.events.iter().any(|e| {
matches!(e, SliderEvent::EditCommitted { value, .. } if (value.to_f64() - 0.3).abs() < 1e-6)
}),
)
});
assert!(!is_editing);
assert_value_close(value, 0.3);
assert!(committed);
}
}