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}