app: click-to-jump and a persistent value box on sliders

- clicking anywhere on the track moves the thumb to that position
  (double-click still opens the inline editor)
- a compact numeric field next to every slider shows the value and
  accepts typed input, clamped to the slider's min/max range
This commit is contained in:
2026-08-27 17:32:48 +08:00
parent a372d72446
commit 2dfdf8a5b0
+151 -42
View File
@@ -23,8 +23,8 @@
//!
//! - [`Slider`] — horizontal drag (the gpui_widgets slider only reacted
//! to *vertical* cursor movement, so a normal horizontal drag looked
//! dead), click-to-jump, wheel, and the arrow keys (Shift = fine steps,
//! Home/End = range ends).
//! dead), click-to-jump, wheel, arrow keys (Shift = fine steps, Home/End
//! = range ends), and a persistent value box (Enter commits).
//! - [`SpinBox`] / [`ComboBox`] / [`CheckBox`] — the gpui_widgets
//! implementations, re-exported here so the app never reaches into
//! gpui_widgets directly.
@@ -36,13 +36,12 @@
//! text inputs and menus.
use gpui::{
colors::{Colors, DefaultColors},
div, px, App, Context, ElementId, EventEmitter, FocusHandle, IntoElement, InteractiveElement, ParentElement,
Entity, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
colors::DefaultColors,
canvas, div, px, Context, ElementId, EventEmitter, FocusHandle, IntoElement, InteractiveElement, ParentElement,
Entity, KeyDownEvent, MouseButton, MouseDownEvent, MouseUpEvent,
Render, ScrollWheelEvent, SharedString, StatefulInteractiveElement, Styled, Window, AppContext, Focusable,
};
use gpui::prelude::FluentBuilder;
use std::sync::Arc;
pub use gpui_widgets::value::{SliderValue, ValueKind};
@@ -70,16 +69,6 @@ pub enum SliderEvent {
DragFinished { control: usize },
}
/// The number of horizontal pixels that maps to the full slider range.
const HORIZONTAL_DRAG_RANGE_PX: f32 = 240.0;
/// A horizontal slider: track + filled portion + handle.
///
/// Mouse: press on the track jumps the value to the click position, a
/// drag adjusts continuously (Shift = fine), the wheel steps, middle
/// click resets.
/// Keyboard (once focused): Left/Right step by one step, Shift-Left/Right
/// by 1/10 step, Home/End jump to the range ends.
/// The in-flight drag gesture payload shared with the drag events.
struct SliderDrag {
/// The control that STARTED the drag. gpui delivers drag-move events
@@ -108,15 +97,20 @@ impl Render for SliderDragGhost {
/// A horizontal slider: track + filled portion + handle.
///
/// Mouse: a drag adjusts the value with 1:1 cursor tracking (the handle
/// follows the cursor; Shift = 1/10 sensitivity), the wheel steps, middle
/// click resets, double click opens a numeric editor.
/// Mouse: a single click on the track jumps the value to the click
/// position (a double click opens the editor instead), a drag adjusts the
/// value with 1:1 cursor tracking (the handle follows the cursor; Shift =
/// 1/10 sensitivity), the wheel steps, middle click resets.
/// Keyboard (once focused): Left/Right step by one step, Shift-Left/Right
/// by 1/10 step, Home/End jump to the range ends.
/// A persistent value box shows the current value and accepts typed input
/// (Enter commits, clamped to the range); double-clicking the track opens
/// the same editor full-width over the track.
///
/// A gesture (drag, wheel tick or arrow key) emits [`SliderEvent::ValueChanged`]
/// exactly once — on drop for drags — so the host applies one undoable
/// edit per gesture instead of one per mouse move.
/// A gesture (drag, wheel tick, arrow key, click or typed commit) emits
/// [`SliderEvent::ValueChanged`] once per gesture — on drop for drags — so
/// the host applies one undoable edit per gesture instead of one per mouse
/// move.
pub struct Slider {
control: usize,
model: SliderModel,
@@ -128,6 +122,9 @@ pub struct Slider {
/// Whether the open editor should cancel (Escape) instead of commit
/// on blur.
edit_cancel: bool,
/// The persistent value box: shows the current value and accepts typed
/// input (Enter commits, exactly like the double-click editor).
value_editor: Entity<gpui_elements::editable_text::EditableTextState>,
}
impl Slider {
@@ -139,6 +136,13 @@ impl Slider {
cx: &mut Context<Self>,
) -> Self {
let _ = window;
let display = Self::display_value(&model);
let value_editor = cx.new(|cx| {
gpui_elements::editable_text::EditableTextState::new(
gpui_elements::editable_text::StringStorage::from(display.to_string()),
cx,
)
});
Self {
control,
model,
@@ -146,6 +150,7 @@ impl Slider {
edit: None,
dragging: false,
edit_cancel: false,
value_editor,
}
}
@@ -225,7 +230,12 @@ impl Slider {
/// The current value formatted for display.
fn display(&self) -> SharedString {
match self.model.value() {
Self::display_value(&self.model)
}
/// Format a model's value for display.
fn display_value(model: &SliderModel) -> SharedString {
match model.value() {
SliderValue::Integer(v) => v.to_string().into(),
SliderValue::Float(v) => format_value(v).into(),
SliderValue::Angle(v) => format!("{v:.1}°").into(),
@@ -242,6 +252,11 @@ impl Render for Slider {
let fraction = self.model.fraction();
let control = self.control;
let handle_x = (fraction * 100.0).clamp(0.0, 100.0) as f32;
// The track's layout bounds, recorded by an invisible canvas every
// frame so the click-to-jump handler can map a click x to a value
// (mouse events carry no bounds of their own).
let track_bounds =
std::sync::Arc::new(std::sync::RwLock::new(None::<gpui::Bounds<gpui::Pixels>>));
let drag_payload = std::sync::Arc::new(std::sync::RwLock::new(SliderDrag {
control,
first_x: 0.0,
@@ -318,6 +333,33 @@ impl Render for Slider {
this.begin_edit(window, cx);
}
}))
.on_mouse_down(
MouseButton::Left,
{
let click_bounds = track_bounds.clone();
cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
// A single click jumps to the click position. Double
// clicks skip the jump so the editor opens at the value
// that was already there.
if event.click_count >= 2 || this.edit.is_some() {
return;
}
let Some(bounds) = click_bounds.read().unwrap().as_ref().copied() else {
return;
};
let width = f32::from(bounds.size.width);
if width <= 0.0 {
return;
}
let t = ((f32::from(event.position.x) - f32::from(bounds.left())) / width)
as f64;
let changed = this.model.set_fraction(t);
if changed {
this.emit_changed(cx);
}
})
}
)
.on_mouse_down(
MouseButton::Middle,
cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
@@ -365,6 +407,24 @@ impl Render for Slider {
cx.stop_propagation();
}));
// Invisible overlay recording the track's bounds each frame so the
// click-to-jump handler above can map a click x to a value.
let record_bounds = track_bounds.clone();
track = track.child(
canvas(
move |bounds, _window, _cx| {
*record_bounds.write().unwrap() = Some(bounds);
bounds
},
|_bounds, _content, _window, _cx| {},
)
.absolute()
.left(px(0.0))
.right(px(0.0))
.top(px(0.0))
.bottom(px(0.0)),
);
if let Some(editor) = self.edit.clone() {
// Numeric editor: an app text input bound to the editor state.
track = track.child(
@@ -375,29 +435,78 @@ impl Render for Slider {
.state(editor.downgrade())
.accepts_input(true),
);
return track;
} else {
track = track
.child(
div()
.absolute()
.left(px(0.0))
.top(px(0.0))
.bottom(px(0.0))
.w(px(handle_x))
.rounded_md()
.bg(colors.selected),
)
.child(
div()
.absolute()
.left(px(handle_x))
.top(px(3.0))
.size(px(12.0))
.rounded_full()
.bg(colors.text),
);
}
track
// Persistent value box: shows the current value and accepts typed
// input (Enter commits). The text is re-synced to the model's
// display format whenever the box isn't focused.
let display_text = self.display();
let value_editor = self.value_editor.clone();
if !value_editor.read(cx).focus_handle(cx).is_focused(window)
&& value_editor.read(cx).as_str() != display_text.as_ref()
{
value_editor.update(cx, |editor, cx| {
editor.emplace(display_text.as_ref(), cx);
});
}
let value_box = div()
.id(ElementId::named_usize("oak-slider-value", control))
.w(px(56.0))
.flex_shrink_0()
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.background)
.px_1()
.flex()
.items_center()
.child(
div()
.absolute()
.left(px(0.0))
.top(px(0.0))
.bottom(px(0.0))
.w(px(handle_x))
.rounded_md()
.bg(colors.selected),
)
.child(
div()
.absolute()
.left(px(handle_x))
.top(px(3.0))
.size(px(12.0))
.rounded_full()
.bg(colors.text),
crate::oakui::component::text_input(
ElementId::named_usize("oak-slider-value-input", control),
cx,
)
.state(value_editor.downgrade())
.accepts_input(true),
)
.on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
if event.keystroke.key.as_str() != "enter" {
return;
}
let text = this.value_editor.read(cx).as_str().to_string();
this.apply_text(&text, cx);
// Move focus to the slider so the box re-syncs to the
// normalized display text on the next frame.
window.focus(&this.focus, cx);
cx.stop_propagation();
}));
div()
.flex()
.items_center()
.gap_1()
.child(track)
.child(value_box)
}
}