From 85e2a72d039cabfc53443de419d3b3390672a936 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 Aug 2025 16:59:23 +0800 Subject: [PATCH] slider: Add to support range to Slider. (#1121) - Add to support Styled for Slider, and `bg`, `text_color`, `radius` can change the slider bar and thumb. ## Break Changes - The `reverse` method is removed from Slider, the Slider with vertical now is always display from bottom to top. image --- crates/story/src/slider_story.rs | 88 +++++-- crates/ui/src/slider.rs | 396 +++++++++++++++++++++++-------- crates/ui/src/styled.rs | 12 +- 3 files changed, 365 insertions(+), 131 deletions(-) diff --git a/crates/story/src/slider_story.rs b/crates/story/src/slider_story.rs index 835002e6..ebb5a10b 100644 --- a/crates/story/src/slider_story.rs +++ b/crates/story/src/slider_story.rs @@ -1,13 +1,13 @@ use gpui::{ - hsla, App, AppContext, Context, Entity, Focusable, Hsla, IntoElement, ParentElement, Render, - SharedString, Styled, Subscription, Window, + hsla, px, App, AppContext, Context, Entity, Focusable, Hsla, IntoElement, ParentElement, + Render, SharedString, Styled, Subscription, Window, }; use gpui_component::{ checkbox::Checkbox, clipboard::Clipboard, h_flex, slider::{Slider, SliderEvent, SliderState}, - v_flex, Colorize as _, ContextModal, StyledExt, + v_flex, ActiveTheme, Colorize as _, ContextModal, StyledExt, }; use crate::section; @@ -18,8 +18,10 @@ pub struct SliderStory { slider1_value: f32, slider2: Entity, slider2_value: f32, + slider3: Entity, slider_hsl: [Entity; 4], slider_hsl_value: Hsla, + slider4: Entity, disabled: bool, _subscritions: Vec, } @@ -48,11 +50,17 @@ impl SliderStory { SliderState::new() .min(-255.) .max(255.) - .default_value(15.) + .default_value(75.) .step(15.) }); - let slider2 = cx.new(|_| SliderState::new().min(0.).max(5.).step(1.0)); + let slider2 = cx.new(|_| { + SliderState::new() + .min(0.) + .max(5.) + .step(1.0) + .default_value(2.) + }); let slider_hsl = [ cx.new(|_| { SliderState::new() @@ -84,16 +92,32 @@ impl SliderStory { }), ]; + let slider3 = cx.new(|_| { + SliderState::new() + .min(0.) + .max(100.) + .default_value(12.0..45.0) + .step(1.) + }); + + let slider4 = cx.new(|_| { + SliderState::new() + .min(0.) + .max(360.) + .default_value(100.0..300.0) + .step(1.) + }); + let mut _subscritions = vec![ cx.subscribe(&slider1, |this, _, event: &SliderEvent, cx| match event { SliderEvent::Change(value) => { - this.slider1_value = *value; + this.slider1_value = value.start(); cx.notify(); } }), cx.subscribe(&slider2, |this, _, event: &SliderEvent, cx| match event { SliderEvent::Change(value) => { - this.slider2_value = *value; + this.slider2_value = value.start(); cx.notify(); } }), @@ -106,10 +130,10 @@ impl SliderStory { cx.subscribe(slider, |this, _, event: &SliderEvent, cx| match event { SliderEvent::Change(_) => { this.slider_hsl_value = hsla( - this.slider_hsl[0].read(cx).value(), - this.slider_hsl[1].read(cx).value(), - this.slider_hsl[2].read(cx).value(), - this.slider_hsl[3].read(cx).value(), + this.slider_hsl[0].read(cx).value().start(), + this.slider_hsl[1].read(cx).value().start(), + this.slider_hsl[2].read(cx).value().start(), + this.slider_hsl[3].read(cx).value().start(), ); cx.notify(); } @@ -128,6 +152,8 @@ impl SliderStory { slider2_value: 0., slider1, slider2, + slider3, + slider4, slider_hsl, slider_hsl_value: gpui::red(), disabled: false, @@ -147,10 +173,9 @@ impl Render for SliderStory { let rgb = SharedString::from(self.slider_hsl_value.to_hex()); v_flex() - .items_center() .gap_y_3() .child( - h_flex().justify_between().child( + h_flex().child( Checkbox::new("disabled") .checked(self.disabled) .label("Disabled") @@ -164,24 +189,41 @@ impl Render for SliderStory { section("Horizontal Slider") .max_w_md() .v_flex() - .child( - Slider::new(&self.slider1) - .horizontal() - .disabled(self.disabled), - ) + .child(Slider::new(&self.slider1).disabled(self.disabled)) .child(format!("Value: {}", self.slider1_value)), ) .child( - section("Slider (0 - 5)") + section("Slider (0 - 5) and with color") .max_w_md() .v_flex() .child( Slider::new(&self.slider2) - .horizontal() - .disabled(self.disabled), + .disabled(self.disabled) + .bg(cx.theme().success) + .text_color(cx.theme().success_foreground), ) .child(format!("Value: {}", self.slider2_value)), ) + .child( + section("Range Mode") + .max_w_md() + .v_flex() + .child(Slider::new(&self.slider3).disabled(self.disabled)) + .child(format!("Value: {}", self.slider3.read(cx).value())), + ) + .child( + section("Vertical with Range") + .max_w_md() + .v_flex() + .child( + Slider::new(&self.slider4) + .vertical() + .h(px(200.)) + .rounded(px(2.)) + .disabled(self.disabled), + ) + .child(format!("Value: {}", self.slider4.read(cx).value())), + ) .child( section( h_flex() @@ -215,7 +257,6 @@ impl Render for SliderStory { .child( Slider::new(&self.slider_hsl[0]) .vertical() - .reverse() .disabled(self.disabled), ) .child( @@ -234,7 +275,6 @@ impl Render for SliderStory { .child( Slider::new(&self.slider_hsl[1]) .vertical() - .reverse() .disabled(self.disabled), ) .child( @@ -253,7 +293,6 @@ impl Render for SliderStory { .child( Slider::new(&self.slider_hsl[2]) .vertical() - .reverse() .disabled(self.disabled), ) .child( @@ -272,7 +311,6 @@ impl Render for SliderStory { .child( Slider::new(&self.slider_hsl[3]) .vertical() - .reverse() .disabled(self.disabled), ) .child( diff --git a/crates/ui/src/slider.rs b/crates/ui/src/slider.rs index 80dd8d92..9fb05220 100644 --- a/crates/ui/src/slider.rs +++ b/crates/ui/src/slider.rs @@ -1,13 +1,15 @@ -use crate::{h_flex, tooltip::Tooltip, ActiveTheme, AxisExt}; +use std::ops::Range; + +use crate::{h_flex, tooltip::Tooltip, ActiveTheme, AxisExt, StyledExt}; use gpui::{ - canvas, div, prelude::FluentBuilder as _, px, App, AppContext as _, Axis, Bounds, Context, - DragMoveEvent, Empty, Entity, EntityId, EventEmitter, InteractiveElement, IntoElement, - MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, Render, RenderOnce, - StatefulInteractiveElement as _, Styled, Window, + canvas, div, prelude::FluentBuilder as _, px, Along, App, AppContext as _, Axis, Background, + Bounds, Context, Corners, DragMoveEvent, Empty, Entity, EntityId, EventEmitter, Hsla, + InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, + Point, Render, RenderOnce, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, }; #[derive(Clone)] -pub struct DragThumb(EntityId); +pub struct DragThumb((EntityId, bool)); impl Render for DragThumb { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { @@ -16,7 +18,104 @@ impl Render for DragThumb { } pub enum SliderEvent { - Change(f32), + Change(SliderValue), +} + +/// The value of the slider, can be a single value or a range of values. +/// +/// - Can from a f32 value, which will be treated as a single value. +/// - Or from a (f32, f32) tuple, which will be treated as a range of values. +/// +/// The default value is `SliderValue::Single(0.0)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SliderValue { + Single(f32), + Range(f32, f32), +} + +impl std::fmt::Display for SliderValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SliderValue::Single(value) => write!(f, "{}", value), + SliderValue::Range(start, end) => write!(f, "{}..{}", start, end), + } + } +} + +impl From for SliderValue { + fn from(value: f32) -> Self { + SliderValue::Single(value) + } +} + +impl From<(f32, f32)> for SliderValue { + fn from(value: (f32, f32)) -> Self { + SliderValue::Range(value.0, value.1) + } +} + +impl From> for SliderValue { + fn from(value: Range) -> Self { + SliderValue::Range(value.start, value.end) + } +} + +impl Default for SliderValue { + fn default() -> Self { + SliderValue::Single(0.) + } +} + +impl SliderValue { + /// Clamp the value to the given range. + pub fn clamp(self, min: f32, max: f32) -> Self { + match self { + SliderValue::Single(value) => SliderValue::Single(value.clamp(min, max)), + SliderValue::Range(start, end) => { + SliderValue::Range(start.clamp(min, max), end.clamp(min, max)) + } + } + } + + #[inline] + pub fn is_single(&self) -> bool { + matches!(self, SliderValue::Single(_)) + } + + #[inline] + pub fn is_range(&self) -> bool { + matches!(self, SliderValue::Range(_, _)) + } + + pub fn start(&self) -> f32 { + match self { + SliderValue::Single(value) => *value, + SliderValue::Range(start, _) => *start, + } + } + + pub fn end(&self) -> f32 { + match self { + SliderValue::Single(value) => *value, + SliderValue::Range(_, end) => *end, + } + } + + fn set_start(&mut self, value: f32) { + if let SliderValue::Range(_, end) = self { + *self = SliderValue::Range(value.min(*end), *end); + } else { + *self = SliderValue::Single(value); + } + } + + fn set_end(&mut self, value: f32) { + if let SliderValue::Range(start, _) = self { + *self = SliderValue::Range(*start, value.max(*start)); + } else { + *self = SliderValue::Single(value); + } + } } /// State of the [`Slider`]. @@ -24,9 +123,11 @@ pub struct SliderState { min: f32, max: f32, step: f32, - value: f32, + value: SliderValue, + /// When is single value mode, only `end` is used, the start is always 0.0. + percentage: Range, + /// The bounds of the slider after rendered. bounds: Bounds, - percentage: f32, } impl SliderState { @@ -35,8 +136,8 @@ impl SliderState { min: 0.0, max: 100.0, step: 1.0, - value: 0.0, - percentage: 0.0, + value: SliderValue::default(), + percentage: (0.0..0.0), bounds: Bounds::default(), } } @@ -62,72 +163,81 @@ impl SliderState { } /// Set the default value of the slider, default: 0.0 - pub fn default_value(mut self, value: f32) -> Self { - self.value = value; + pub fn default_value(mut self, value: impl Into) -> Self { + self.value = value.into(); self.update_thumb_pos(); self } /// Set the value of the slider. - pub fn set_value(&mut self, value: f32, _: &mut gpui::Window, cx: &mut gpui::Context) { - self.value = value; + pub fn set_value( + &mut self, + value: impl Into, + _: &mut Window, + cx: &mut Context, + ) { + self.value = value.into(); self.update_thumb_pos(); cx.notify(); } /// Get the value of the slider. - pub fn value(&self) -> f32 { + pub fn value(&self) -> SliderValue { self.value } fn update_thumb_pos(&mut self) { - self.percentage = self.value.clamp(self.min, self.max) / self.max; + match self.value { + SliderValue::Single(value) => { + let percentage = value.clamp(self.min, self.max) / self.max; + self.percentage = 0.0..percentage; + } + SliderValue::Range(start, end) => { + let clamped_start = start.clamp(self.min, self.max); + let clamped_end = end.clamp(self.min, self.max); + self.percentage = (clamped_start / self.max)..(clamped_end / self.max); + } + } } /// Update value by mouse position fn update_value_by_position( &mut self, axis: Axis, - reverse: bool, position: Point, - _: &mut gpui::Window, - cx: &mut gpui::Context, + is_start: bool, + _: &mut Window, + cx: &mut Context, ) { let bounds = self.bounds; let min = self.min; let max = self.max; let step = self.step; - let percentage = match axis { - Axis::Horizontal => { - if reverse { - 1. - (position.x - bounds.left()).clamp(px(0.), bounds.size.width) - / bounds.size.width - } else { - (position.x - bounds.left()).clamp(px(0.), bounds.size.width) - / bounds.size.width - } - } - Axis::Vertical => { - if reverse { - 1. - (position.y - bounds.top()).clamp(px(0.), bounds.size.height) - / bounds.size.height - } else { - (position.y - bounds.top()).clamp(px(0.), bounds.size.height) - / bounds.size.height - } - } - }; - - let value = match axis { - Axis::Horizontal => min + (max - min) * percentage, - Axis::Vertical => max - (max - min) * percentage, + let inner_pos = if axis.is_horizontal() { + position.x - bounds.left() + } else { + bounds.bottom() - position.y + }; + let total_size = bounds.size.along(axis); + let percentage = inner_pos.clamp(px(0.), total_size) / total_size; + + let percentage = if is_start { + percentage.clamp(0.0, self.percentage.end) + } else { + percentage.clamp(self.percentage.start, 1.0) }; + let value = min + (max - min) * percentage; let value = (value / step).round() * step; - self.percentage = percentage; - self.value = value.clamp(self.min, self.max); + if is_start { + self.percentage.start = percentage; + self.value.set_start(value); + } else { + self.percentage.end = percentage; + self.value.set_end(value); + } cx.emit(SliderEvent::Change(self.value)); cx.notify(); } @@ -145,7 +255,7 @@ impl Render for SliderState { pub struct Slider { state: Entity, axis: Axis, - reverse: bool, + style: StyleRefinement, disabled: bool, } @@ -154,8 +264,8 @@ impl Slider { pub fn new(state: &Entity) -> Self { Self { axis: Axis::Horizontal, - reverse: false, state: state.clone(), + style: StyleRefinement::default(), disabled: false, } } @@ -172,37 +282,62 @@ impl Slider { self } - /// Set the reverse direction of the slider, default: false - pub fn reverse(mut self) -> Self { - self.reverse = true; - self - } - /// Set the disabled state of the slider, default: false pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } + #[allow(clippy::too_many_arguments)] fn render_thumb( &self, - thumb_bar_size: Pixels, + start_pos: Pixels, + is_start: bool, + bar_color: Background, + thumb_color: Hsla, + radius: Corners, window: &mut Window, cx: &mut App, ) -> impl gpui::IntoElement { let state = self.state.read(cx); let entity_id = self.state.entity_id(); let value = state.value; - let reverse = self.reverse; let axis = self.axis; + let id = ("slider-thumb", is_start as u32); if self.disabled { - return div().id("slider-thumb"); + return div().id(id); } div() - .id("slider-thumb") - .on_drag(DragThumb(entity_id), |drag, _, _, cx| { + .id(id) + .absolute() + .when(axis.is_horizontal(), |this| { + this.top(px(-5.)).left(start_pos).ml(-px(8.)) + }) + .when(axis.is_vertical(), |this| { + this.bottom(start_pos).left(px(-5.)).mb(-px(8.)) + }) + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .corner_radii(radius) + .bg(bar_color.opacity(0.5)) + .when(cx.theme().shadow, |this| this.shadow_md()) + .size_4() + .p(px(1.)) + .child( + div() + .flex_shrink_0() + .size_full() + .corner_radii(radius) + .bg(thumb_color), + ) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_drag(DragThumb((entity_id, is_start)), |drag, _, _, cx| { cx.stop_propagation(); cx.new(|_| drag.clone()) }) @@ -210,7 +345,7 @@ impl Slider { &self.state, move |view, e: &DragMoveEvent, window, cx| { match e.drag(cx) { - DragThumb(id) => { + DragThumb((id, is_start)) => { if *id != entity_id { return; } @@ -218,8 +353,8 @@ impl Slider { // set value by mouse position view.update_value_by_position( axis, - reverse, e.event.position, + *is_start, window, cx, ) @@ -227,51 +362,76 @@ impl Slider { } }, )) - .absolute() - .map(|this| match reverse { - true => this - .when(axis.is_horizontal(), |this| { - this.bottom(px(-5.)).right(thumb_bar_size).mr(-px(8.)) - }) - .when(axis.is_vertical(), |this| { - this.bottom(thumb_bar_size).right(px(-5.)).mb(-px(8.)) - }), - false => this - .when(axis.is_horizontal(), |this| { - this.top(px(-5.)).left(thumb_bar_size).ml(-px(8.)) - }) - .when(axis.is_vertical(), |this| { - this.top(thumb_bar_size).left(px(-5.)).mt(-px(8.)) - }), + .tooltip(move |window, cx| { + Tooltip::new(format!( + "{}", + if is_start { value.start() } else { value.end() } + )) + .build(window, cx) }) - .size_4() - .rounded_full() - .border_1() - .border_color(cx.theme().slider_bar.opacity(0.9)) - .when(cx.theme().shadow, |this| this.shadow_md()) - .bg(cx.theme().slider_thumb) - .tooltip(move |window, cx| Tooltip::new(format!("{}", value)).build(window, cx)) + } +} + +impl Styled for Slider { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style } } impl RenderOnce for Slider { fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { - let state = self.state.read(cx); let axis = self.axis; - let reverse = self.reverse; - let thumb_bar_size = match axis { - Axis::Horizontal => state.percentage * state.bounds.size.width, - Axis::Vertical => state.percentage * state.bounds.size.height, + let state = self.state.read(cx); + let is_range = state.value().is_range(); + let bar_size = state.bounds.size.along(axis); + let bar_start = state.percentage.start * bar_size; + let bar_end = state.percentage.end * bar_size; + let rem_size = window.rem_size(); + + let bar_color = self + .style + .background + .clone() + .and_then(|bg| bg.color()) + .unwrap_or(cx.theme().slider_bar.into()); + let thumb_color = self + .style + .text + .clone() + .and_then(|text| text.color) + .unwrap_or_else(|| cx.theme().slider_thumb); + let corner_radii = self.style.corner_radii.clone(); + let default_radius = px(999.); + let radius = Corners { + top_left: corner_radii + .top_left + .map(|v| v.to_pixels(rem_size)) + .unwrap_or(default_radius), + top_right: corner_radii + .top_right + .map(|v| v.to_pixels(rem_size)) + .unwrap_or(default_radius), + bottom_left: corner_radii + .bottom_left + .map(|v| v.to_pixels(rem_size)) + .unwrap_or(default_radius), + bottom_right: corner_radii + .bottom_right + .map(|v| v.to_pixels(rem_size)) + .unwrap_or(default_radius), }; div() .id(("slider", self.state.entity_id())) .flex() .flex_1() - .when(axis.is_vertical(), |this| { - this.items_center().justify_center() - }) + .items_center() + .justify_center() + .when(axis.is_vertical(), |this| this.h(px(120.))) .when(axis.is_horizontal(), |this| this.w_full()) + .refine_style(&self.style) + .bg(cx.theme().transparent) + .text_color(cx.theme().foreground) .child( h_flex() .when(!self.disabled, |this| { @@ -279,9 +439,20 @@ impl RenderOnce for Slider { MouseButton::Left, window.listener_for( &self.state, - move |view, e: &MouseDownEvent, window, cx| { - view.update_value_by_position( - axis, reverse, e.position, window, cx, + move |state, e: &MouseDownEvent, window, cx| { + let mut is_start = false; + if is_range { + let inner_pos = if axis.is_horizontal() { + e.position.x - state.bounds.left() + } else { + state.bounds.bottom() - e.position.y + }; + let center = (bar_end - bar_start) / 2.0 + bar_start; + is_start = inner_pos < center; + } + + state.update_value_by_position( + axis, e.position, is_start, window, cx, ) }, ), @@ -300,24 +471,41 @@ impl RenderOnce for Slider { .relative() .when(axis.is_horizontal(), |this| this.w_full().h_1p5()) .when(axis.is_vertical(), |this| this.h_full().w_1p5()) - .bg(cx.theme().slider_bar.opacity(0.2)) - .active(|this| this.bg(cx.theme().slider_bar.opacity(0.4))) - .rounded(px(3.)) + .bg(bar_color.opacity(0.2)) + .active(|this| this.bg(bar_color.opacity(0.4))) + .corner_radii(radius) .child( div() .absolute() - .when(!reverse, |this| this.top_0().left_0()) - .when(reverse, |this| this.bottom_0().right_0()) .when(axis.is_horizontal(), |this| { - this.h_full().w(thumb_bar_size) + this.h_full().left(bar_start).right(bar_size - bar_end) }) .when(axis.is_vertical(), |this| { - this.w_full().h(thumb_bar_size) + this.w_full().bottom(bar_start).top(bar_size - bar_end) }) - .bg(cx.theme().slider_bar) + .bg(bar_color) .rounded_full(), ) - .child(self.render_thumb(thumb_bar_size, window, cx)) + .when(is_range, |this| { + this.child(self.render_thumb( + bar_start, + true, + bar_color, + thumb_color, + radius, + window, + cx, + )) + }) + .child(self.render_thumb( + bar_end, + false, + bar_color, + thumb_color, + radius, + window, + cx, + )) .child({ let state = self.state.clone(); canvas( diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 1528dad4..2b01cf15 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -5,8 +5,8 @@ use crate::{ ActiveTheme, }; use gpui::{ - div, point, px, App, Axis, BoxShadow, DefiniteLength, Div, Edges, Element, FocusHandle, Hsla, - Pixels, Refineable, StyleRefinement, Styled, Window, + div, point, px, App, Axis, BoxShadow, Corners, DefiniteLength, Div, Edges, Element, + FocusHandle, Hsla, Pixels, Refineable, StyleRefinement, Styled, Window, }; use serde::{Deserialize, Serialize}; @@ -193,6 +193,14 @@ pub trait StyledExt: Styled + Sized { .shadow_lg() .rounded(cx.theme().radius) } + + /// Set corner radii for the element. + fn corner_radii(self, radius: Corners) -> Self { + self.rounded_tl(radius.top_left) + .rounded_tr(radius.top_right) + .rounded_bl(radius.bottom_left) + .rounded_br(radius.bottom_right) + } } impl StyledExt for E {}