1use std::cell::Cell;
29use std::rc::Rc;
30
31use teksilo_canvas::{Rect, SizeProposal};
32use teksilo_core::accessibility::AccessNodeBuilder;
33use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
34use teksilo_core::focus::FocusOrigin;
35use teksilo_core::gesture::DragPhase;
36use teksilo_core::signal::{Prop, Signal};
37use teksilo_core::styles::{
38 SharedSliderStyle, SliderOrientation, SliderStyle, SliderStyleConfig, SliderVariant,
39};
40use teksilo_core::widget::{CursorIcon, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
41use teksilo_core::widget_builder::HandlerSet;
42use teksilo_core::widget_id::WidgetId;
43use teksilo_tokens::Orientation;
44
45pub use teksilo_core::styles::SliderVariant as SliderVariantExport;
49use teksilo_i18n::LocalizedString;
50
51pub struct Slider {
55 value: Signal<f32>,
56 min: f32,
57 max: f32,
58 step: Option<f32>,
59 orientation: Orientation,
60 enabled: Prop<bool>,
63 label: Option<LocalizedString>,
65 variant: SliderVariant,
66 tick_count: Option<u32>,
67 style_override: Option<SharedSliderStyle>,
68 hovered: Signal<bool>,
69 dragging: Signal<bool>,
70 focused: Signal<bool>,
74 cached_bounds: Rc<Cell<Rect>>,
75 body_id: Option<WidgetId>,
76 tooltip_text: Option<LocalizedString>,
80 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
82 composite_tooltip_content: Option<Box<dyn Widget>>,
84}
85
86impl Slider {
87 pub fn new(value: Signal<f32>, min: f32, max: f32) -> Self {
90 Self {
91 value,
92 min,
93 max,
94 step: None,
95 orientation: Orientation::Horizontal,
96 enabled: Prop::Static(true),
97 label: None,
98 variant: SliderVariant::default(),
99 tick_count: None,
100 style_override: None,
101 hovered: Signal::new(false),
102 dragging: Signal::new(false),
103 focused: Signal::new(false),
104 cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
105 body_id: None,
106 tooltip_text: None,
107 rich_tooltip_source: None,
108 composite_tooltip_content: None,
109 }
110 }
111
112 pub fn step(mut self, step: f32) -> Self {
116 self.step = Some(step);
117 self
118 }
119
120 pub fn orientation(mut self, orientation: Orientation) -> Self {
123 self.orientation = orientation;
124 self
125 }
126
127 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
131 self.enabled = enabled.into();
132 self
133 }
134
135 pub fn variant(mut self, variant: SliderVariant) -> Self {
142 self.variant = variant;
143 self
144 }
145
146 pub fn tick_count(mut self, count: u32) -> Self {
150 self.tick_count = Some(count);
151 self
152 }
153
154 pub fn style(mut self, style: impl SliderStyle) -> Self {
157 self.style_override = Some(Rc::new(style));
158 self
159 }
160
161 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
165 let ls: LocalizedString = label.into();
166 self.label = Some(ls);
167 self
168 }
169
170 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
176 self.tooltip_text = Some(text.into());
177 self.rich_tooltip_source = None;
178 self.composite_tooltip_content = None;
179 self
180 }
181
182 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
186 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
187 self.tooltip_text = None;
188 self.composite_tooltip_content = None;
189 self
190 }
191
192 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
196 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
197 self.tooltip_text = None;
198 self.composite_tooltip_content = None;
199 self
200 }
201
202 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
206 self.composite_tooltip_content = Some(Box::new(content));
207 self.tooltip_text = None;
208 self.rich_tooltip_source = None;
209 self
210 }
211}
212
213impl std::fmt::Debug for Slider {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 f.debug_struct("Slider")
216 .field("min", &self.min)
217 .field("max", &self.max)
218 .field("enabled", &self.enabled.get())
219 .field("variant", &self.variant)
220 .finish()
221 }
222}
223
224impl Widget for Slider {
225 fn build(
226 &mut self,
227 ctx: &mut teksilo_core::build_context::BuildContext,
228 ) -> Vec<teksilo_core::widget_id::WidgetId> {
229 let self_id = ctx.self_id();
230 ctx.enabled_when(self_id, self.enabled.clone());
232 let effective_enabled = ctx.effective_enabled_signal(self_id);
233
234 let style: SharedSliderStyle = self
237 .style_override
238 .clone()
239 .or_else(|| ctx.theme().style_slots.slider.clone())
240 .unwrap_or_else(|| Rc::new(crate::styles::RecipeSliderStyle::default()));
241
242 let min = self.min;
245 let max = self.max;
246 let value_normalized = self.value.map(move |v| {
247 let range = max - min;
248 if range <= 0.0 {
249 0.0
250 } else {
251 ((*v - min) / range).clamp(0.0, 1.0)
252 }
253 });
254
255 let orientation = match self.orientation {
256 Orientation::Horizontal => SliderOrientation::Horizontal,
257 Orientation::Vertical => SliderOrientation::Vertical,
258 };
259
260 let cfg = SliderStyleConfig {
261 value_normalized,
262 is_hovered: self.hovered.clone(),
263 is_dragging: self.dragging.clone(),
264 is_disabled: effective_enabled.map(|on| !*on),
265 focus_origin: self.focused.zip(&ctx.focus_visible()).map(|(f, v)| {
270 if !*f {
271 None
272 } else if *v {
273 Some(FocusOrigin::Keyboard)
274 } else {
275 Some(FocusOrigin::Pointer)
276 }
277 }),
278 orientation,
279 tick_count: self.tick_count,
280 variant: self.variant,
281 };
282 let body_id = style.make_body(&cfg, ctx);
283 self.body_id = Some(body_id);
284
285 let thumb_radius = style.thumb_diameter(&cfg) * 0.5;
292
293 let value = self.value.clone();
294 let step = self.step;
295 let orientation = self.orientation;
296 let hovered = self.hovered.clone();
297 let dragging = self.dragging.clone();
298 let focused = self.focused.clone();
299 let cached_bounds = self.cached_bounds.clone();
300
301 let adjust_by_step = {
302 let value = value.clone();
303 move |positive: bool| {
304 let s = step.unwrap_or((max - min) * 0.01);
305 let current = value.get();
306 let new_val = if positive { current + s } else { current - s };
307 value.set(new_val.clamp(min, max));
308 }
309 };
310
311 let set_value_from_position = {
312 let value = value.clone();
313 let cached_bounds = cached_bounds.clone();
314 move |x: f32, y: f32| {
315 let bounds = cached_bounds.get();
316 let pos = match orientation {
317 Orientation::Horizontal => x,
318 Orientation::Vertical => y,
319 };
320 let usable = match orientation {
321 Orientation::Horizontal => bounds.width,
322 Orientation::Vertical => bounds.height,
323 } - thumb_radius * 2.0;
324 if usable <= 0.0 {
325 return;
326 }
327 let t = ((pos - thumb_radius) / usable).clamp(0.0, 1.0);
331 let mut val = min + t * (max - min);
332 if let Some(s) = step
333 && s > 0.0
334 {
335 val = ((val - min) / s).round() * s + min;
336 }
337 value.set(val.clamp(min, max));
338 }
339 };
340
341 let mut handlers = HandlerSet::new()
344 .focusable(true)
345 .cursor(CursorIcon::Pointer);
346
347 {
349 let dragging = dragging.clone();
350 let set_value = set_value_from_position.clone();
351 handlers = handlers.on_drag(move |phase, _ctx| match phase {
352 DragPhase::Started {
353 position,
354 button: PointerButton::Primary,
355 } => {
356 dragging.set(true);
357 set_value(position.x, position.y);
358 }
359 DragPhase::Moved { position, .. } if dragging.get() => {
360 set_value(position.x, position.y);
361 }
362 DragPhase::Ended { .. } => {
363 dragging.set(false);
364 }
365 _ => {}
366 });
367 }
368
369 {
371 let set_value = set_value_from_position.clone();
372 handlers = handlers.on_tap(move |event, _ctx| {
373 set_value(event.position.x, event.position.y);
374 });
375 }
376
377 {
379 let hovered = hovered.clone();
380 handlers = handlers.on_hover(move |entered, _ctx| {
381 hovered.set(entered);
382 });
383 }
384
385 {
387 let adjust = adjust_by_step.clone();
388 let value = value.clone();
389 handlers = handlers.on_key(move |event, _ctx| match event {
390 WidgetEvent::KeyDown { key, .. } => match key {
391 Key::ArrowRight | Key::ArrowUp => {
392 adjust(true);
393 EventResponse::Handled
394 }
395 Key::ArrowLeft | Key::ArrowDown => {
396 adjust(false);
397 EventResponse::Handled
398 }
399 Key::Home => {
400 value.set(min);
401 EventResponse::Handled
402 }
403 Key::End => {
404 value.set(max);
405 EventResponse::Handled
406 }
407 _ => EventResponse::Ignored,
408 },
409 _ => EventResponse::Ignored,
410 });
411 }
412
413 {
418 let focused = focused.clone();
419 handlers = handlers.on_focus(move |gained, _ctx| {
420 focused.set(gained);
421 });
422 }
423
424 {
426 let adjust = adjust_by_step.clone();
427 handlers = handlers.on_access_action(move |action, _ctx| match action {
428 teksilo_core::accesskit::Action::Increment => {
429 adjust(true);
430 EventResponse::Handled
431 }
432 teksilo_core::accesskit::Action::Decrement => {
433 adjust(false);
434 EventResponse::Handled
435 }
436 _ => EventResponse::Ignored,
437 });
438 }
439
440 ctx.apply_self_handlers(handlers);
441
442 if let Some(content) = self.composite_tooltip_content.take() {
445 let delay = ctx.theme().motion.tooltip_delay_heavy;
446 crate::tooltip::attach_composite_tooltip_boxed(ctx, body_id, content, delay);
447 } else if let Some(source) = self.rich_tooltip_source.clone() {
448 let delay = ctx.theme().motion.tooltip_delay;
449 crate::tooltip::attach_rich_tooltip_source(ctx, body_id, source, delay);
450 } else if let Some(text) = self.tooltip_text.clone() {
451 let delay = ctx.theme().motion.tooltip_delay;
452 crate::tooltip::attach_plain_tooltip(ctx, body_id, text, delay);
453 }
454
455 vec![body_id]
456 }
457
458 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
459 self.body_id
460 .and_then(|id| ctx.child_size(id, proposal))
461 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
462 .into()
463 }
464
465 fn place_children(
466 &self,
467 bounds: Rect,
468 _proposal: SizeProposal,
469 children: &mut [WidgetPlacement],
470 _ctx: &LayoutContext,
471 ) {
472 self.cached_bounds.set(bounds);
474 if let Some(child) = children.first_mut() {
475 child.origin = bounds.origin();
476 child.size = bounds.size();
477 }
478 }
479
480 fn children(&self) -> Vec<WidgetId> {
481 self.body_id.into_iter().collect()
482 }
483
484 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
485 builder.set_role(teksilo_core::accesskit::Role::Slider);
486 if let Some(ref label) = self.label {
487 builder.set_name(label.resolve_now());
488 }
489 builder.set_numeric_value(self.value.get() as f64);
490 builder.set_min_numeric_value(self.min as f64);
491 builder.set_max_numeric_value(self.max as f64);
492 let step = self.step.unwrap_or((self.max - self.min) * 0.01);
497 builder.set_numeric_value_step(step as f64);
498 let orientation = match self.orientation {
499 Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
500 Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
501 };
502 builder.set_orientation(orientation);
503 builder.add_action(teksilo_core::accesskit::Action::Increment);
505 builder.add_action(teksilo_core::accesskit::Action::Decrement);
506 builder.add_action(teksilo_core::accesskit::Action::Focus);
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use teksilo_canvas::Point;
514 use teksilo_core::event::Modifiers;
515 use teksilo_core::widget_tree::WidgetTree;
516
517 #[test]
518 fn focus_ring_only_under_focus_visible() {
519 let theme = teksilo_core::presets::intui::light();
524 let ring = theme.colors.focus_ring.to_array();
525 let mut tree = WidgetTree::new().with_theme(theme);
526 let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
527 tree.layout(SizeProposal::exact(200.0, 60.0));
528
529 tree.focus(s);
530 assert!(
531 !frame_has_ring(&tree.render(), ring),
532 "no focus ring while focus-visible is false (pointer modality)",
533 );
534
535 tree.press_key(Key::ArrowDown, Modifiers::NONE);
536 assert!(
537 frame_has_ring(&tree.render(), ring),
538 "focus ring shows under keyboard modality",
539 );
540 }
541
542 fn frame_has_ring(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
547 frame
548 .shapes
549 .iter()
550 .any(|s| s.color == color && s.stroke_width > 0.0)
551 || frame.cosmetic_lines.iter().any(|l| l.color == color)
552 }
553
554 #[test]
555 fn keyboard_adjusts_value() {
556 let value = Signal::new(50.0_f32);
557 let mut tree = WidgetTree::new();
558 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
559 tree.layout(SizeProposal::exact(200.0, 60.0));
560
561 tree.focus(s);
562 tree.press_key(Key::ArrowRight, Modifiers::NONE);
563 assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
564
565 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
566 assert!((value.get() - 50.0).abs() < 0.01);
567 }
568
569 #[test]
570 fn home_end_jump_to_bounds() {
571 let value = Signal::new(50.0_f32);
572 let mut tree = WidgetTree::new();
573 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
574 tree.layout(SizeProposal::exact(200.0, 60.0));
575
576 tree.focus(s);
577 tree.press_key(Key::Home, Modifiers::NONE);
578 assert!((value.get() - 0.0).abs() < 0.01);
579
580 tree.press_key(Key::End, Modifiers::NONE);
581 assert!((value.get() - 100.0).abs() < 0.01);
582 }
583
584 #[test]
585 fn track_click_sets_value() {
586 let value = Signal::new(0.0_f32);
587 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
588 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
589 tree.layout(SizeProposal::exact(200.0, 60.0));
590 tree.render();
592
593 tree.click(s);
595
596 let val = value.get();
598 assert!(
599 (val - 50.0).abs() < 15.0,
600 "track click at center should set value near 50, got {}",
601 val
602 );
603 }
604
605 #[test]
606 fn track_click_sets_value_at_nonzero_origin() {
607 use crate::primitives::{FixedSize, HStack};
610 use teksilo_canvas::Point;
611 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
612
613 let value = Signal::new(0.0_f32);
614 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
615 let sid = tree.add(Slider::new(value.clone(), 0.0, 100.0));
616 let _row = tree.add(
617 HStack::new()
618 .child(FixedSize::new().width(40.0).height(60.0))
619 .add_child(sid),
620 );
621 tree.layout(SizeProposal::exact(240.0, 60.0));
622 tree.render();
623
624 let b = tree.bounds(sid);
628 assert!(
629 (b.x - 40.0).abs() < 0.5,
630 "slider should be offset, x={}",
631 b.x
632 );
633 for ev in [
634 WidgetEvent::PointerDown {
635 position: Point::new(140.0, 30.0),
636 button: PointerButton::Primary,
637 modifiers: Modifiers::NONE,
638 },
639 WidgetEvent::PointerUp {
640 position: Point::new(140.0, 30.0),
641 button: PointerButton::Primary,
642 modifiers: Modifiers::NONE,
643 },
644 ] {
645 tree.dispatch_event(ev);
646 }
647 assert!(
648 (value.get() - 50.0).abs() < 1.0,
649 "click at the offset slider's centre should set ~50, got {}",
650 value.get()
651 );
652 }
653
654 #[test]
655 fn accessibility() {
656 let value = Signal::new(25.0_f32);
657 let mut tree = WidgetTree::new();
658 let s = tree.add(Slider::new(value, 0.0, 100.0));
659 tree.layout(SizeProposal::exact(200.0, 60.0));
660 let info = tree.accessibility_node(s);
661 assert_eq!(info.role(), teksilo_core::accesskit::Role::Slider);
662 }
663
664 #[test]
665 fn step_snaps_value() {
666 let value = Signal::new(0.0_f32);
667 let mut tree = WidgetTree::new();
668 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(25.0));
669 tree.layout(SizeProposal::exact(200.0, 60.0));
670
671 tree.focus(s);
672 tree.press_key(Key::ArrowRight, Modifiers::NONE);
673 assert!((value.get() - 25.0).abs() < 0.01);
674 tree.press_key(Key::ArrowRight, Modifiers::NONE);
675 assert!((value.get() - 50.0).abs() < 0.01);
676 }
677
678 #[test]
679 fn thumb_drag_updates_value() {
680 let theme = teksilo_core::presets::intui::light();
681 let thumb_radius = crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER * 0.5;
682 let value = Signal::new(50.0_f32);
683 let mut tree = WidgetTree::new().with_theme(theme);
684 let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
685 tree.layout(SizeProposal::exact(200.0, 60.0));
686 tree.render(); let bounds = tree.bounds(s);
689 let thumb_cx = bounds.x + thumb_radius + (bounds.width - thumb_radius * 2.0) * 0.5;
691 let center_y = bounds.y + bounds.height / 2.0;
692
693 tree.pointer_down_button(Point::new(thumb_cx, center_y), PointerButton::Primary);
695
696 let target_x = bounds.x + thumb_radius + (bounds.width - thumb_radius * 2.0) * 0.75;
701 tree.pointer_move(Point::new(thumb_cx + 10.0, center_y));
702 tree.pointer_move(Point::new(target_x, center_y));
703
704 let val = value.get();
705 assert!(
706 (val - 75.0).abs() < 5.0,
707 "dragging to 75% should set value near 75, got {}",
708 val
709 );
710
711 tree.pointer_up_button(Point::new(target_x, center_y), PointerButton::Primary);
713 }
714
715 #[test]
716 fn accessibility_has_actions() {
717 let value = Signal::new(25.0_f32);
718 let mut tree = WidgetTree::new();
719 let s = tree.add(Slider::new(value, 0.0, 100.0));
720 tree.layout(SizeProposal::exact(200.0, 60.0));
721 let info = tree.accessibility_node(s);
722 assert!(
723 info.actions()
724 .contains(&teksilo_core::accesskit::Action::Increment)
725 );
726 assert!(
727 info.actions()
728 .contains(&teksilo_core::accesskit::Action::Decrement)
729 );
730 }
731}