add slider

This commit is contained in:
2026-10-28 19:52:35 +07:00
parent 818266a25f
commit 5f441518eb
4 changed files with 480 additions and 0 deletions
+3
View File
@@ -19,3 +19,6 @@ pub mod toggle;
#[cfg(feature = "link")]
pub mod link;
#[cfg(feature = "slider")]
pub mod slider;
+291
View File
@@ -0,0 +1,291 @@
use std::cell::Cell;
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, Bounds, BoxShadow, DispatchPhase, ElementId, InteractiveElement as _, IntoElement,
KeyDownEvent, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas,
div, px, relative, rems,
};
use gpui_native_theme::ActiveTheme as _;
use crate::common::h_flex;
/// The change reporter, fired with the next value by click, drag, and arrows.
type ChangeHandler = Rc<dyn Fn(f32, &mut Window, &mut App)>;
/// The pointer→value setter shared by the click and drag listeners.
type ValueSetter = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
/// Clamps `value` into `[min, max]` and snaps it to `step` when stepped.
fn constrain(min: f32, max: f32, step: f32, value: f32) -> f32 {
let clamped = value.clamp(min, max);
if step > 0.0 {
(min + ((clamped - min) / step).round() * step).clamp(min, max)
} else {
clamped
}
}
/// A controlled slider over `[min, max]`.
#[derive(IntoElement)]
pub struct Slider {
id: ElementId,
min: f32,
max: f32,
step: f32,
value: f32,
disabled: bool,
a11y_label: Option<SharedString>,
on_change: Option<ChangeHandler>,
}
impl Slider {
/// Creates a slider over `0..=1` with no stepping, at `value`.
pub fn new<I>(id: I, value: f32) -> Self
where
I: Into<ElementId>,
{
Self {
id: id.into(),
min: 0.0,
max: 1.0,
step: 0.0,
value,
disabled: false,
a11y_label: None,
on_change: None,
}
}
/// Sets the range's lower bound.
pub fn min(mut self, min: f32) -> Self {
self.min = min;
self
}
/// Sets the range's upper bound.
pub fn max(mut self, max: f32) -> Self {
self.max = max;
self
}
/// Sets the step; values snap to `min + n * step`. `0` is continuous.
pub fn step(mut self, step: f32) -> Self {
self.step = step;
self
}
/// Sets the current value; the slider never owns it.
pub fn value(mut self, value: f32) -> Self {
self.value = value;
self
}
/// Renders the slider inert, unfocusable, and dimmed; disabled always wins.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Sets the accessibility label, required for an unlabeled slider.
pub fn accessibility_label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.a11y_label = Some(label.into());
self
}
/// The change handler, fired with the next value by click, drag, and arrows.
pub fn on_change<F>(mut self, handler: F) -> Self
where
F: Fn(f32, &mut Window, &mut App) + 'static,
{
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Slider {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors;
let ramps = theme.ramps;
let radius = theme.radius;
let value = self.value.clamp(self.min, self.max);
let fraction = if self.max > self.min {
(value - self.min) / (self.max - self.min)
} else {
0.0
};
// The track bounds, recorded by the overlay canvas each frame,
// turn pointer positions into values inside the listeners.
let bounds_cell = Rc::new(Cell::new(None::<Bounds<Pixels>>));
// Dragging crosses frames, so it lives in keyed element state,
// a per-render flag would reset when the owner's update repaints.
let dragging = window.use_keyed_state("slider-dragging", cx, |_, _| false);
// A held handle gives the arrow-key listener something to check focus against,
// and the click-to-focus behavior a target.
let focus = window.use_keyed_state("slider-focus", cx, |_, cx| cx.focus_handle());
let focus_handle = focus.read(cx).clone();
let set_value: ValueSetter = {
let bounds_cell = bounds_cell.clone();
let on_change = self.on_change.clone();
let (min, max, step) = (self.min, self.max, self.step);
Rc::new(move |x: Pixels, window: &mut Window, cx: &mut App| {
if let (Some(bounds), Some(on_change)) = (bounds_cell.get(), on_change.as_ref())
&& bounds.size.width > Pixels::ZERO
{
let fraction = ((x - bounds.origin.x) / bounds.size.width).clamp(0.0, 1.0);
on_change(
constrain(min, max, step, min + fraction * (max - min)),
window,
cx,
);
}
})
};
let down_dragging = dragging.clone();
let down_set_value = set_value.clone();
let disabled = self.disabled;
h_flex()
.id(self.id)
.relative()
.w_full()
.h_5()
.role(Role::Slider)
.when_some(self.a11y_label.clone(), |slider, label| {
slider.aria_label(label)
})
.aria_numeric_value(f64::from(value))
.aria_min_numeric_value(f64::from(self.min))
.aria_max_numeric_value(f64::from(self.max))
.when(self.disabled, |slider| slider.opacity(0.5))
.when(!self.disabled, |slider| {
slider
.track_focus(&focus_handle)
.tab_stop(true)
.focus_visible(|style| {
style.shadow(vec![
BoxShadow::new(px(0.), px(0.), colors.ring).spread_radius(px(3.)),
])
})
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
down_dragging.update(cx, |dragging, _| *dragging = true);
down_set_value(event.position.x, window, cx);
})
.on_key_down(move |event: &KeyDownEvent, window, cx| {
if !focus_handle.contains_focused(window, cx) {
return;
}
let single_step = if self.step > 0.0 {
self.step
} else {
(self.max - self.min) / 20.0
};
let next = match event.keystroke.key.as_str() {
"left" | "down" => Some(value - single_step),
"right" | "up" => Some(value + single_step),
_ => None,
};
if let Some(next) = next
&& let Some(on_change) = self.on_change.as_ref()
{
on_change(constrain(self.min, self.max, self.step, next), window, cx);
cx.stop_propagation();
}
})
})
.child(
canvas(
{
let bounds_cell = bounds_cell.clone();
move |bounds, _, _| bounds_cell.set(Some(bounds))
},
{
let dragging = dragging.clone();
let set_value = set_value.clone();
move |_, _, window, _cx| {
if disabled {
return;
}
window.on_mouse_event({
let dragging = dragging.clone();
let set_value = set_value.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble
&& *dragging.read(cx)
&& event.pressed_button == Some(MouseButton::Left)
{
set_value(event.position.x, window, cx);
}
}
});
window.on_mouse_event({
let dragging = dragging.clone();
move |event: &MouseUpEvent, phase, _window, cx| {
if phase == DispatchPhase::Bubble
&& event.button == MouseButton::Left
{
dragging.update(cx, |dragging, _| *dragging = false);
}
}
});
}
},
)
.absolute()
.inset_0(),
)
.child(
div()
.absolute()
.left_0()
.top_2()
.h_1()
.w_full()
.rounded(radius.full)
.bg(colors.border),
)
.child(
div()
.absolute()
.left_0()
.top_2()
.h_1()
.w(relative(fraction))
.rounded(radius.full)
.bg(ramps.primary.base),
)
.child(
h_flex()
.absolute()
.left_0()
.top_0()
.h_full()
.w(relative(fraction))
.justify_end()
.items_center()
.child(
div()
.size_5()
.flex_none()
.rounded(radius.full)
.bg(colors.primary_foreground)
.border_1()
.border_color(colors.border)
.mr(rems(-0.625)),
),
)
}
}