Skip to main content

sequoia_elements/
slider1d.rs

1#![forbid(unsafe_code)]
2
3use std::rc::Rc;
4
5use sequoia_core::{BrushStop, Color, Edges, Fill, GradientStops, Length, Point, Rect};
6use sequoia_input::{CursorIcon, Event, EventPhase, Key, PointerButton, PointerEvent};
7use sequoia_layout::LayoutStyle;
8use sequoia_reactive::Signal;
9use sequoia_style::VisualState;
10use sequoia_view::{EventCtx, Orientation, PaintCtx, Painter, Track1D, Unit, View};
11
12pub const TRACK_H: f32 = 8.0;
13
14pub const KNOB_R: f32 = 7.0;
15
16pub const HEIGHT: f32 = 26.0;
17
18pub const WIDTH: f32 = 26.0;
19
20#[derive(Clone)]
21pub struct Slider1D {
22    value: Signal<f32>,
23    min: f32,
24    max: f32,
25    stops: GradientStops,
26    orientation: Orientation,
27    solid: Color,
28    track_height: f32,
29    knob_radius: f32,
30    style: LayoutStyle,
31    margin: Edges,
32    on_commit: Option<Rc<dyn Fn(f32)>>,
33}
34
35impl Slider1D {
36    pub fn new(value: Signal<f32>, min: f32, max: f32) -> Self {
37        Self {
38            value,
39            min: min.min(max),
40            max: min.max(max),
41            stops: GradientStops::new(Vec::new()),
42            orientation: Orientation::Horizontal,
43            solid: Color::hex(0x3E3E3E),
44            track_height: TRACK_H,
45            knob_radius: KNOB_R,
46            style: LayoutStyle {
47                width: Length::Fill(1.0),
48                height: Length::Px(HEIGHT),
49                ..LayoutStyle::default()
50            },
51            margin: Edges::ZERO,
52            on_commit: None,
53        }
54    }
55
56    pub fn vertical(value: Signal<f32>, min: f32, max: f32) -> Self {
57        let mut s = Self::new(value, min, max);
58        s.orientation = Orientation::Vertical;
59        s.style = LayoutStyle {
60            width: Length::Px(WIDTH),
61            height: Length::Fill(1.0),
62            ..LayoutStyle::default()
63        };
64        s
65    }
66
67    pub fn gradient(mut self, stops: Vec<BrushStop>, orientation: Orientation) -> Self {
68        self.stops = GradientStops::new(stops);
69        self.orientation = orientation;
70        self
71    }
72
73    pub fn solid(mut self, color: Color) -> Self {
74        self.solid = color;
75        self
76    }
77
78    pub fn track_height(mut self, h: f32) -> Self {
79        self.track_height = h.max(1.0);
80        self
81    }
82
83    pub fn knob_radius(mut self, r: f32) -> Self {
84        self.knob_radius = r.clamp(3.0, 16.0);
85        self
86    }
87
88    pub fn on_commit(mut self, f: impl Fn(f32) + 'static) -> Self {
89        self.on_commit = Some(Rc::new(f));
90        self
91    }
92
93    pub fn height(mut self, height: impl Into<Length>) -> Self {
94        self.style = self.style.height(height.into());
95        self
96    }
97
98    pub fn width(mut self, width: impl Into<Length>) -> Self {
99        self.style = self.style.width(width.into());
100        self
101    }
102
103    pub fn flex(mut self, weight: f32) -> Self {
104        self.style = self.style.flex(weight);
105        self
106    }
107
108    pub fn expand(self) -> Self {
109        self.flex(1.0)
110    }
111
112    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
113        let m = margin.into();
114        self.margin = Edges::only(
115            sanitize_margin(m.left),
116            sanitize_margin(m.top),
117            sanitize_margin(m.right),
118            sanitize_margin(m.bottom),
119        );
120        self
121    }
122
123    pub fn margin_xy(self, x: f32, y: f32) -> Self {
124        self.margin(Edges::xy(x, y))
125    }
126
127    pub fn margin_x(mut self, x: f32) -> Self {
128        let x = sanitize_margin(x);
129        self.margin.left = x;
130        self.margin.right = x;
131        self
132    }
133
134    pub fn margin_y(mut self, y: f32) -> Self {
135        let y = sanitize_margin(y);
136        self.margin.top = y;
137        self.margin.bottom = y;
138        self
139    }
140
141    pub fn margin_left(mut self, v: f32) -> Self {
142        self.margin.left = sanitize_margin(v);
143        self
144    }
145
146    pub fn margin_right(mut self, v: f32) -> Self {
147        self.margin.right = sanitize_margin(v);
148        self
149    }
150
151    pub fn margin_top(mut self, v: f32) -> Self {
152        self.margin.top = sanitize_margin(v);
153        self
154    }
155
156    pub fn margin_bottom(mut self, v: f32) -> Self {
157        self.margin.bottom = sanitize_margin(v);
158        self
159    }
160
161    fn span(&self) -> f32 {
162        (self.max - self.min).max(f32::EPSILON)
163    }
164
165    fn ratio(&self) -> f32 {
166        ((self.value.get_untracked() - self.min) / self.span()).clamp(0.0, 1.0)
167    }
168
169    fn set_ratio(&self, t: f32) {
170        self.value.set(self.min + t.clamp(0.0, 1.0) * self.span());
171    }
172
173    fn track_of(&self, rect: Rect) -> Track1D {
174        match self.orientation {
175            Orientation::Horizontal => Track1D::new(rect.left(), rect.size.width, 0.0),
176            Orientation::Vertical => Track1D::new(rect.top(), rect.size.height, 0.0),
177        }
178    }
179
180    fn bar_rect(&self, rect: Rect) -> Rect {
181        let th = self.track_height.min(if self.is_horizontal() {
182            rect.size.height
183        } else {
184            rect.size.width
185        });
186        if self.is_horizontal() {
187            let cy = rect.top() + rect.size.height * 0.5;
188            Rect::from_xywh(rect.left(), cy - th * 0.5, rect.size.width, th)
189        } else {
190            let cx = rect.left() + rect.size.width * 0.5;
191            Rect::from_xywh(cx - th * 0.5, rect.top(), th, rect.size.height)
192        }
193    }
194
195    fn is_horizontal(&self) -> bool {
196        self.orientation == Orientation::Horizontal
197    }
198
199    fn knob_fill(&self, t: f32) -> Color {
200        if self.stops.is_empty() {
201            return Color::WHITE;
202        }
203
204        let [r, g, b, a] = self.stops.sample_linear(t.clamp(0.0, 1.0));
205        Color { r, g, b, a }.from_linear()
206    }
207
208    fn bar_fill(&self) -> Fill {
209        if self.stops.is_empty() {
210            return Fill::solid(self.solid);
211        }
212        match self.orientation {
213            Orientation::Horizontal => Fill::horizontal(self.stops.clone()),
214            Orientation::Vertical => Fill::vertical(self.stops.clone()),
215        }
216    }
217
218    fn apply_at(&self, rect: Rect, pos: Point) {
219        let track = self.track_of(rect);
220        let t = if self.is_horizontal() {
221            track.t_of(pos.x)
222        } else {
223            track.t_of(pos.y)
224        };
225        self.set_ratio(t);
226    }
227}
228
229impl View for Slider1D {
230    fn body(&self) -> impl View + 'static {
231        let _ = self.value.get();
232        Unit
233    }
234
235    fn is_leaf(&self) -> bool {
236        true
237    }
238
239    fn focusable(&self) -> bool {
240        true
241    }
242
243    fn captures_pointer(&self) -> bool {
244        true
245    }
246
247    fn has_visual_state(&self) -> bool {
248        true
249    }
250
251    fn cursor_icon(&self, _state: VisualState) -> CursorIcon {
252        if self.is_horizontal() {
253            CursorIcon::ColResize
254        } else {
255            CursorIcon::RowResize
256        }
257    }
258
259    fn layout_style(&self) -> LayoutStyle {
260        let mut s = self.style;
261        s.margin = self.margin;
262        s
263    }
264
265    fn clip_self(&self) -> bool {
266        false
267    }
268
269    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
270        if ctx.phase != EventPhase::Target {
271            return;
272        }
273        match event {
274            Event::Pointer(PointerEvent::Down {
275                button: PointerButton::Left,
276                pos,
277                ..
278            }) => {
279                self.apply_at(ctx.rect, *pos);
280                ctx.capture_pointer();
281                ctx.handled();
282            }
283            Event::Pointer(PointerEvent::Move { pos, .. }) => {
284                if ctx.state.pressed {
285                    self.apply_at(ctx.rect, *pos);
286                    ctx.handled();
287                }
288            }
289            Event::Pointer(PointerEvent::Up {
290                button: PointerButton::Left,
291                ..
292            }) => {
293                if let Some(f) = &self.on_commit {
294                    f(self.value.get_untracked());
295                }
296            }
297            Event::Pointer(PointerEvent::Wheel {
298                delta, modifiers, ..
299            }) => {
300                let mult = if modifiers.contains(sequoia_input::Modifiers::SHIFT) {
301                    0.1
302                } else if modifiers.contains(sequoia_input::Modifiers::CONTROL) {
303                    10.0
304                } else {
305                    1.0
306                };
307                let dir = if delta.y < 0.0 { 1.0 } else { -1.0 };
308                let step = self.span() / 100.0 * mult * dir;
309                self.value
310                    .set((self.value.get_untracked() + step).clamp(self.min, self.max));
311                ctx.handled();
312            }
313            Event::Key(key) => {
314                let step = match key.key {
315                    Key::ARROW_LEFT | Key::ARROW_DOWN => -self.span() * 0.05,
316                    Key::ARROW_RIGHT | Key::ARROW_UP => self.span() * 0.05,
317                    _ => return,
318                };
319                self.value
320                    .set((self.value.get_untracked() + step).clamp(self.min, self.max));
321                if let Some(f) = &self.on_commit {
322                    f(self.value.get_untracked());
323                }
324                ctx.handled();
325            }
326            _ => {}
327        }
328    }
329
330    fn paint_self(&self, ctx: &mut PaintCtx) {
331        let mut g = Painter::new(ctx);
332        let rect = g.rect();
333        if rect.is_empty() {
334            return;
335        }
336
337        let overflow = rect.inflate(self.knob_radius, self.knob_radius);
338        g.clipped(overflow, |g| {
339            let bar = self.bar_rect(rect);
340            if !bar.is_empty() {
341                g.fill(bar, self.bar_fill());
342            }
343            let t = self.ratio();
344            let track = self.track_of(rect);
345            let c = track.pos_of(t);
346            let center = if self.is_horizontal() {
347                Point::new(c, bar.top() + bar.size.height * 0.5)
348            } else {
349                Point::new(bar.left() + bar.size.width * 0.5, c)
350            };
351            g.knob(center, self.knob_radius, self.knob_fill(t));
352        });
353    }
354}
355
356fn sanitize_margin(v: f32) -> f32 {
357    if v.is_finite() { v.max(0.0) } else { 0.0 }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn defaults_are_horizontal_fill() {
366        let v = Signal::new(0.5f32);
367        let s = Slider1D::new(v, 0.0, 1.0);
368        assert_eq!(s.layout_style().width, Length::Fill(1.0));
369        assert!(!s.clip_self(), "knob sticks out - extended clip");
370        let vert = Slider1D::vertical(Signal::new(0.0f32), 0.0, 360.0);
371        assert_eq!(vert.layout_style().width, Length::Px(WIDTH));
372    }
373
374    #[test]
375    fn ratio_clamps_out_of_range_values() {
376        let v = Signal::new(99.0f32);
377        let s = Slider1D::new(v, 0.0, 1.0);
378        assert_eq!(s.ratio(), 1.0);
379        s.set_ratio(-5.0);
380        assert_eq!(s.value.get_untracked(), 0.0);
381        s.set_ratio(0.25);
382        assert!((s.value.get_untracked() - 0.25).abs() < 1e-6);
383    }
384
385    #[test]
386    fn knob_samples_gradient_at_position() {
387        let v = Signal::new(0.0f32);
388        let s = Slider1D::new(v, 0.0, 1.0).gradient(
389            vec![
390                BrushStop::new(0.0, Color::BLACK),
391                BrushStop::new(1.0, Color::WHITE),
392            ],
393            Orientation::Horizontal,
394        );
395        let lo = s.knob_fill(0.0).to_rgb8();
396        let hi = s.knob_fill(1.0).to_rgb8();
397        assert_eq!(lo, (0, 0, 0));
398        assert_eq!(hi, (255, 255, 255));
399    }
400}