Skip to main content

ravenrs_widgets/
inputs.rs

1//! Form controls bound to state through [`Binding`].
2
3use std::rc::Rc;
4
5use ravenrs_core::{
6    Border, Color, CursorIcon, Effect, Insets, Key, LinearGradient, Point, Quad, Rect, Role, Scene, Shadow,
7};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::Binding;
10use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
11
12use crate::context::{BuildCx, DragPhase, NodeId, Paint, QuadPaint, Semantics, WidgetId};
13use crate::{IconName, Motion, View, motion, util};
14
15fn row(cx: &mut BuildCx<'_>, children: &[NodeId]) -> NodeId {
16    let style = LayoutStyle {
17        spacing: 10.0,
18        align: Align::Center,
19        align_self: Some(Align::Fit),
20        ..LayoutStyle::stack(Axis::Horizontal)
21    };
22    cx.container(style, children, Paint::None)
23}
24
25fn make_interactive(cx: &mut BuildCx<'_>, node: NodeId, radius: f32, on_click: Rc<dyn Fn()>) {
26    cx.on_click(node, on_click);
27    cx.focusable(node, radius);
28    cx.cursor(node, CursorIcon::Pointer);
29}
30
31/// Option of a single-choice group (radio buttons, segments): arrow keys select
32/// and focus the neighbor, following the WAI-ARIA radio group pattern.
33struct ChoiceOption {
34    roving: util::Roving,
35    selected: Binding<usize>,
36}
37
38#[cfg_attr(feature = "live", ravenrs_live::expose)]
39impl ChoiceOption {
40    fn index(&self) -> usize {
41        self.roving.index
42    }
43
44    fn wire(&self, cx: &mut BuildCx<'_>, node: NodeId, radius: f32) {
45        let (selected, index) = (self.selected.clone(), self.index());
46        make_interactive(cx, node, radius, Rc::new(move || selected.set(index)));
47        let selected = self.selected.clone();
48        self.roving.wire(cx, node, Some(Rc::new(move |i| selected.set(i))));
49    }
50}
51
52/// An on/off switch.
53#[derive(Debug)]
54#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
55pub struct Toggle {
56    value: Binding<bool>,
57    label: Option<String>,
58    small: bool,
59    state: util::EditorState,
60}
61
62#[cfg_attr(feature = "live", ravenrs_live::expose)]
63impl Toggle {
64    /// A switch bound to `value`.
65    pub fn new(value: Binding<bool>) -> Self {
66        Self { value, label: None, small: false, state: util::EditorState::default() }
67    }
68
69    /// A smaller switch, 32 by 18 logical pixels instead of 40 by 24, for dense lists.
70    pub fn small(mut self) -> Self {
71        self.small = true;
72        self
73    }
74
75    /// Text after the switch; clicking it toggles too.
76    pub fn label(mut self, label: impl Into<String>) -> Self {
77        self.label = Some(label.into());
78        self
79    }
80
81    /// Turns the control off: it fades, leaves the tab order and ignores input.
82    pub fn disabled(mut self, disabled: bool) -> Self {
83        self.state.disabled = disabled;
84        self
85    }
86
87    /// Shows the value without letting it change; it can still be focused and read.
88    pub fn read_only(mut self, read_only: bool) -> Self {
89        self.state.read_only = read_only;
90        self
91    }
92}
93
94impl View for Toggle {
95    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
96        let theme = *cx.theme();
97        let on = self.value.get();
98        let state = self.state;
99        let hovered = cx.is_hovered() && state.accepts_input();
100        let track = if on {
101            theme.color(if hovered { Semantic::AccentHover } else { Semantic::Accent })
102        } else {
103            theme.color(Semantic::Control).lerp(theme.color(Semantic::Ink), if hovered { 0.08 } else { 0.0 })
104        };
105        let track = state.fill(track);
106        let border = (!on).then(|| Border { width: 1.0, color: state.ink(theme.color(Semantic::Border)) });
107        let (track_width, track_height, knob, inset) =
108            if self.small { (32.0, 18.0, 14.0, 2.0) } else { (40.0, 24.0, 18.0, 3.0) };
109        // The thumb stretches while held and is kept apart from the track so it
110        // can glide from one end to the other.
111        let thumb = cx.container(
112            LayoutStyle {
113                width: Some(if cx.is_pressed() && state.accepts_input() { knob + 4.0 } else { knob }),
114                height: Some(knob),
115                ..LayoutStyle::leaf()
116            },
117            &[],
118            Paint::Quad(QuadPaint {
119                corner_radius: knob / 2.0,
120                shadow: Some(Shadow {
121                    offset: Point::new(0.0, 1.0),
122                    blur: 3.0,
123                    color: Color::rgba(0.0, 0.0, 0.0, 0.3),
124                }),
125                ..QuadPaint::new(state.ink(Color::WHITE))
126            }),
127        );
128        let switch = cx.container(
129            LayoutStyle {
130                width: Some(track_width),
131                height: Some(track_height),
132                padding: Insets::uniform(inset),
133                justify: if on { Justify::End } else { Justify::Start },
134                ..LayoutStyle::stack(Axis::Horizontal)
135            },
136            &[thumb],
137            Paint::Quad(QuadPaint { corner_radius: track_height / 2.0, border, ..QuadPaint::new(track) }),
138        );
139        cx.motion(thumb, Motion::new().layout(theme.motion.snappy));
140        motion::control(cx, switch);
141        let mut children = vec![switch];
142        if let Some(label) = &self.label {
143            children.push(util::label(
144                cx,
145                label,
146                TextStyle::Body,
147                None,
148                state.ink(theme.color(Semantic::Ink)),
149            ));
150        }
151        let node = row(cx, &children);
152        cx.semantics(node, state.semantics(Semantics::new(Role::Switch).toggled(on)));
153        if state.accepts_input() {
154            let value = self.value.clone();
155            make_interactive(cx, node, 8.0, Rc::new(move || value.update(|v| *v = !*v)));
156        } else if !state.disabled {
157            cx.focusable(node, 8.0);
158        }
159        node
160    }
161}
162
163/// A check box with a label.
164#[derive(Debug)]
165#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
166pub struct Checkbox {
167    value: Binding<bool>,
168    label: String,
169    leading: Option<f32>,
170    state: util::EditorState,
171}
172
173#[cfg_attr(feature = "live", ravenrs_live::expose)]
174impl Checkbox {
175    /// A check box bound to `value`, with this label.
176    pub fn new(label: impl Into<String>, value: Binding<bool>) -> Self {
177        Self { value, label: label.into(), leading: None, state: util::EditorState::default() }
178    }
179
180    /// Reserves this width for the box, so labels line up in a column. A list
181    /// that mixes check boxes with [`Toggle`]s passes the toggle's width, 40.
182    pub fn leading(mut self, width: f32) -> Self {
183        self.leading = Some(width);
184        self
185    }
186
187    /// Turns the control off: it fades, leaves the tab order and ignores input.
188    pub fn disabled(mut self, disabled: bool) -> Self {
189        self.state.disabled = disabled;
190        self
191    }
192
193    /// Shows the value without letting it change; it can still be focused and read.
194    pub fn read_only(mut self, read_only: bool) -> Self {
195        self.state.read_only = read_only;
196        self
197    }
198}
199
200pub(crate) fn check_box(cx: &mut BuildCx<'_>, theme: &Theme, checked: bool, hovered: bool) -> NodeId {
201    let mark = if checked {
202        vec![util::icon(cx, IconName::Check, 14.0, theme.color(Semantic::OnAccent))]
203    } else {
204        vec![]
205    };
206    check_box_around(cx, theme, checked, hovered, util::EditorState::default(), &mark)
207}
208
209pub(crate) fn check_box_around(
210    cx: &mut BuildCx<'_>,
211    theme: &Theme,
212    checked: bool,
213    hovered: bool,
214    state: util::EditorState,
215    mark: &[NodeId],
216) -> NodeId {
217    let paint = if checked {
218        QuadPaint { corner_radius: 5.0, ..QuadPaint::new(state.fill(theme.color(Semantic::Accent))) }
219    } else {
220        let edge = if hovered { theme.color(Semantic::Ink) } else { theme.color(Semantic::InkMuted) };
221        QuadPaint {
222            corner_radius: 5.0,
223            border: Some(Border { width: 1.5, color: state.ink(edge.with_alpha(0.7)) }),
224            ..QuadPaint::new(Color::TRANSPARENT)
225        }
226    };
227    let style = LayoutStyle {
228        width: Some(18.0),
229        height: Some(18.0),
230        align: Align::Center,
231        justify: Justify::Center,
232        ..LayoutStyle::stack(Axis::Horizontal)
233    };
234    cx.container(style, mark, Paint::Quad(paint))
235}
236
237/// The check or dot inside a selected control pops in and fades away.
238fn pop_mark(cx: &mut BuildCx<'_>, mark: NodeId) {
239    let m = cx.theme().motion;
240    cx.motion(
241        mark,
242        Motion::new()
243            .enter(Effect::hidden().with_scale(0.5), m.snappy)
244            .exit(Effect::hidden().with_scale(0.5), m.fast),
245    );
246}
247
248impl View for Checkbox {
249    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
250        let theme = *cx.theme();
251        let checked = self.value.get();
252        let state = self.state;
253        let mark = checked.then(|| util::icon(cx, IconName::Check, 14.0, theme.color(Semantic::OnAccent)));
254        let hovered = cx.is_hovered() && state.accepts_input();
255        let mut b = check_box_around(cx, &theme, checked, hovered, state, mark.as_slice());
256        motion::control(cx, b);
257        if let Some(mark) = mark {
258            pop_mark(cx, mark);
259        }
260        if let Some(width) = self.leading {
261            b = cx.container(
262                LayoutStyle { width: Some(width), ..LayoutStyle::stack(Axis::Horizontal) },
263                &[b],
264                Paint::None,
265            );
266        }
267        let label =
268            util::label(cx, &self.label, TextStyle::Body, None, state.ink(theme.color(Semantic::Ink)));
269        let node = row(cx, &[b, label]);
270        cx.semantics(node, state.semantics(Semantics::new(Role::CheckBox).toggled(checked)));
271        if state.accepts_input() {
272            let value = self.value.clone();
273            make_interactive(cx, node, 6.0, Rc::new(move || value.update(|v| *v = !*v)));
274        } else if !state.disabled {
275            cx.focusable(node, 6.0);
276        }
277        node
278    }
279}
280
281/// One choice among several, shown as radio buttons. Arrow keys move the
282/// selection and the focus together (wrapping); Home and End jump to the ends.
283#[derive(Debug)]
284#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
285pub struct RadioGroup {
286    selected: Binding<usize>,
287    options: Vec<String>,
288    label: Option<String>,
289}
290
291#[cfg_attr(feature = "live", ravenrs_live::expose)]
292impl RadioGroup {
293    /// Radio buttons for `options`; `selected` holds the zero-based index.
294    pub fn new(selected: Binding<usize>, options: impl IntoIterator<Item = impl Into<String>>) -> Self {
295        Self { selected, options: options.into_iter().map(Into::into).collect(), label: None }
296    }
297
298    /// The name screen readers announce for the group, such as "Plan".
299    pub fn label(mut self, label: impl Into<String>) -> Self {
300        self.label = Some(label.into());
301        self
302    }
303}
304
305struct RadioOption {
306    choice: ChoiceOption,
307    label: String,
308}
309
310impl View for RadioOption {
311    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
312        let theme = *cx.theme();
313        let on = self.choice.selected.get() == self.choice.index();
314        let hovered = cx.is_hovered();
315        let dot = if on {
316            vec![cx.container(
317                LayoutStyle { width: Some(8.0), height: Some(8.0), ..LayoutStyle::leaf() },
318                &[],
319                Paint::Quad(QuadPaint {
320                    corner_radius: 4.0,
321                    ..QuadPaint::new(theme.color(Semantic::OnAccent))
322                }),
323            )]
324        } else {
325            vec![]
326        };
327        let edge = if hovered { theme.color(Semantic::Ink) } else { theme.color(Semantic::InkMuted) };
328        let paint = if on {
329            QuadPaint { corner_radius: 9.0, ..QuadPaint::new(theme.color(Semantic::Accent)) }
330        } else {
331            QuadPaint {
332                corner_radius: 9.0,
333                border: Some(Border { width: 1.5, color: edge.with_alpha(0.7) }),
334                ..QuadPaint::new(Color::TRANSPARENT)
335            }
336        };
337        let circle = cx.container(
338            LayoutStyle {
339                width: Some(18.0),
340                height: Some(18.0),
341                align: Align::Center,
342                justify: Justify::Center,
343                ..LayoutStyle::stack(Axis::Horizontal)
344            },
345            &dot,
346            Paint::Quad(paint),
347        );
348        motion::control(cx, circle);
349        if let Some(&dot) = dot.first() {
350            pop_mark(cx, dot);
351        }
352        let label = util::label(cx, &self.label, TextStyle::Body, None, theme.color(Semantic::Ink));
353        let node = row(cx, &[circle, label]);
354        cx.semantics(node, Semantics::new(Role::RadioButton).selected(on).toggled(on));
355        self.choice.wire(cx, node, 6.0);
356        node
357    }
358}
359
360impl View for RadioGroup {
361    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
362        let focus_to = util::roving_cell(cx, None);
363        let count = self.options.len();
364        let options: Vec<NodeId> = self
365            .options
366            .iter()
367            .enumerate()
368            .map(|(index, label)| {
369                let roving = util::Roving { index, count, focus_to: focus_to.clone() };
370                let choice = ChoiceOption { roving, selected: self.selected.clone() };
371                cx.child(&RadioOption { choice, label: label.clone() })
372            })
373            .collect();
374        let node = cx.container(
375            LayoutStyle { spacing: 10.0, ..LayoutStyle::stack(Axis::Vertical) },
376            &options,
377            Paint::None,
378        );
379        cx.semantics(node, group_semantics(self.label.as_deref()));
380        node
381    }
382}
383
384fn group_semantics(label: Option<&str>) -> Semantics {
385    let semantics = Semantics::new(Role::RadioGroup);
386    match label {
387        Some(l) => semantics.name(l),
388        None => semantics,
389    }
390}
391
392/// A draggable value between `min` and `max`. Arrow keys adjust it by `step`.
393///
394/// ```
395/// use ravenrs_reactive::State;
396/// use ravenrs_widgets::{Slider, Ui};
397///
398/// let volume = State::new(40.0_f32);
399/// let slider = Slider::new(volume.binding()).range(0.0, 100.0).step(5.0).width(240.0).show_value();
400/// let mut ui = Ui::new(slider, ravenrs_style::Theme::raven_dark());
401/// ui.update();
402/// ```
403#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
404pub struct Slider {
405    value: Binding<f32>,
406    min: f32,
407    max: f32,
408    step: f32,
409    width: Option<f32>,
410    label: Option<String>,
411    marks: Vec<f32>,
412    track_colors: Vec<Color>,
413    neutral: Option<f32>,
414    show_value: bool,
415    format: Option<Rc<dyn Fn(f32) -> String>>,
416}
417
418impl std::fmt::Debug for Slider {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        f.debug_struct("Slider").field("min", &self.min).field("max", &self.max).finish_non_exhaustive()
421    }
422}
423
424#[cfg_attr(feature = "live", ravenrs_live::expose)]
425impl Slider {
426    /// A slider over 0.0–1.0 bound to `value`, stepping by 0.01.
427    pub fn new(value: Binding<f32>) -> Self {
428        Self {
429            value,
430            min: 0.0,
431            max: 1.0,
432            step: 0.01,
433            width: None,
434            label: None,
435            marks: Vec::new(),
436            track_colors: Vec::new(),
437            neutral: None,
438            show_value: false,
439            format: None,
440        }
441    }
442
443    /// The value range; also resets the step to 1/100 of the range.
444    pub fn range(mut self, min: f32, max: f32) -> Self {
445        self.min = min;
446        self.max = max.max(min + f32::EPSILON);
447        self.step = (self.max - self.min) / 100.0;
448        self
449    }
450
451    /// The keyboard increment (call after [`Slider::range`]).
452    pub fn step(mut self, step: f32) -> Self {
453        self.step = step.max(f32::EPSILON);
454        self
455    }
456
457    /// Fixed width in logical pixels; by default the slider stretches.
458    pub fn width(mut self, width: f32) -> Self {
459        self.width = Some(width);
460        self
461    }
462
463    /// The name screen readers announce, such as "Volume".
464    pub fn label(mut self, label: impl Into<String>) -> Self {
465        self.label = Some(label.into());
466        self
467    }
468
469    /// Notches on the track at these values, such as where chapters begin.
470    pub fn marks(mut self, marks: impl IntoIterator<Item = f32>) -> Self {
471        self.marks = marks.into_iter().collect();
472        self
473    }
474
475    /// Paints the track with these colors, evenly spaced from one end to the other, when
476    /// the colors are what the value means, as from cold to warm. There is then no fill.
477    /// The colors are data, so they come from whoever knows what they mean.
478    pub fn track_colors(mut self, colors: impl IntoIterator<Item = Color>) -> Self {
479        self.track_colors = colors.into_iter().collect();
480        self
481    }
482
483    /// The value at rest, such as zero in the middle of the range. The fill starts there
484    /// instead of at the minimum, and a double click, Backspace or Delete return to it.
485    pub fn neutral(mut self, neutral: f32) -> Self {
486        self.neutral = Some(neutral);
487        self
488    }
489
490    /// Shows the value at the right of the track, with as many decimals as the step has.
491    pub fn show_value(mut self) -> Self {
492        self.show_value = true;
493        self
494    }
495
496    /// Shows the value at the right of the track as this function writes it, which screen
497    /// readers read too.
498    pub fn value_format(mut self, format: impl Fn(f32) -> String + 'static) -> Self {
499        self.show_value = true;
500        self.format = Some(Rc::new(format));
501        self
502    }
503
504    fn value_text(&self, value: f32) -> String {
505        match &self.format {
506            Some(format) => format(value),
507            None => {
508                let decimals = step_decimals(self.step);
509                format!("{value:.decimals$}")
510            }
511        }
512    }
513}
514
515/// As many decimals as a value moving by `step` needs.
516pub(crate) fn step_decimals(step: f32) -> usize {
517    // Less a hair, since the logarithm of 0.1 can land just above 1.
518    (-step.log10() - 1e-3).ceil().clamp(0.0, 4.0) as usize
519}
520
521/// Draws a track through `colors`. Gradients have two stops and square ends: one for each
522/// pair of colors, between round caps in the end colors.
523fn paint_color_track(scene: &mut Scene, track: Rect, colors: &[Color]) {
524    let (Some(first), Some(last)) = (colors.first(), colors.last()) else { return };
525    let radius = track.height() / 2.0;
526    let cap = track.height();
527    scene.push_quad(Quad::new(Rect::new(track.x(), track.y(), cap, cap), *first).radius(radius));
528    scene.push_quad(Quad::new(Rect::new(track.max_x() - cap, track.y(), cap, cap), *last).radius(radius));
529    let inner = (track.width() - cap).max(0.0);
530    let bands = colors.len().saturating_sub(1);
531    if bands == 0 {
532        scene.push_quad(Quad::new(Rect::new(track.x() + radius, track.y(), inner, cap), *first));
533        return;
534    }
535    let band = inner / bands as f32;
536    for (i, pair) in colors.windows(2).enumerate() {
537        // A little overlap, so that no seam shows between bands on fractional pixels.
538        let overlap = if i + 1 < bands { 0.5 } else { 0.0 };
539        let rect = Rect::new(track.x() + radius + band * i as f32, track.y(), band + overlap, track.height());
540        let gradient = LinearGradient {
541            start: Point::new(0.0, 0.5),
542            end: Point::new(1.0, 0.5),
543            from: pair[0],
544            to: pair[1],
545        };
546        scene.push_quad(Quad::new(rect, gradient));
547    }
548}
549
550/// The part of a [`Slider`] that is dragged. Its own view when the value sits beside it, so
551/// that hover and focus are about the track.
552struct SliderTrack<'a> {
553    slider: &'a Slider,
554    /// Takes what the row leaves it, instead of being the whole slider.
555    in_row: bool,
556}
557
558impl View for SliderTrack<'_> {
559    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
560        let slider = self.slider;
561        let theme = *cx.theme();
562        let (min, max, step) = (slider.min, slider.max, slider.step);
563        let value = slider.value.get().clamp(min, max);
564        let t = (value - min) / (max - min);
565        let rest = slider.neutral.map_or(0.0, |n| ((n - min) / (max - min)).clamp(0.0, 1.0));
566        let (track, fill) = (theme.color(Semantic::Control), theme.color(Semantic::Accent));
567        let ring = theme.color(Semantic::Accent).with_alpha(if cx.is_pressed() || cx.is_hovered() {
568            0.25
569        } else {
570            0.0
571        });
572        let style = LayoutStyle {
573            width: if self.in_row { None } else { slider.width },
574            height: Some(24.0),
575            min_width: Some(120.0),
576            grow: if self.in_row { 1.0 } else { 0.0 },
577            align_self: Some(if slider.width.is_some() && !self.in_row {
578                Align::Start
579            } else {
580                Align::Stretch
581            }),
582            ..LayoutStyle::leaf()
583        };
584        let notch = theme.color(Semantic::InkMuted);
585        let marks: Vec<f32> =
586            slider.marks.iter().map(|m| ((m - min) / (max - min)).clamp(0.0, 1.0)).collect();
587        let colors = slider.track_colors.clone();
588        let node = cx.container(
589            style,
590            &[],
591            Paint::Custom(Rc::new(move |f: Rect, scene| {
592                let usable = f.width() - 18.0;
593                let cy = f.center().y;
594                let bar = Rect::new(f.x() + 9.0, cy - 2.0, usable, 4.0);
595                if colors.is_empty() {
596                    scene.push_quad(Quad::new(bar, track).radius(2.0));
597                    let (from, to) = (rest.min(t), rest.max(t));
598                    scene.push_quad(
599                        Quad::new(
600                            Rect::new(bar.x() + usable * from, bar.y(), usable * (to - from), 4.0),
601                            fill,
602                        )
603                        .radius(2.0),
604                    );
605                } else {
606                    paint_color_track(scene, bar, &colors);
607                }
608                for mark in &marks {
609                    let x = f.x() + 9.0 + usable * mark;
610                    scene.push_quad(Quad::new(Rect::new(x - 1.0, cy - 5.0, 2.0, 10.0), notch).radius(1.0));
611                }
612                let cx_ = f.x() + 9.0 + usable * t;
613                if ring.a > 0.0 {
614                    scene.push_quad(
615                        Quad::new(Rect::new(cx_ - 14.0, cy - 14.0, 28.0, 28.0), ring).radius(14.0),
616                    );
617                }
618                scene.push_quad(
619                    Quad::new(Rect::new(cx_ - 9.0, cy - 9.0, 18.0, 18.0), Color::WHITE).radius(9.0).shadow(
620                        Shadow {
621                            offset: Point::new(0.0, 1.0),
622                            blur: 4.0,
623                            color: Color::rgba(0.0, 0.0, 0.0, 0.35),
624                        },
625                    ),
626                );
627            })),
628        );
629        let mut semantics =
630            Semantics::new(Role::Slider).numeric(value as f64, min as f64, max as f64, Some(step as f64));
631        if let Some(label) = &slider.label {
632            semantics = semantics.name(label.clone());
633        }
634        if slider.show_value {
635            semantics = semantics.value(slider.value_text(value));
636        }
637        cx.semantics(node, semantics);
638        let snap = move |v: f32| ((v - min) / step).round() * step + min;
639        let drag_value = slider.value.clone();
640        cx.on_drag(
641            node,
642            Rc::new(move |d| {
643                if d.phase == DragPhase::End {
644                    return;
645                }
646                let usable = (d.frame.width() - 18.0).max(1.0);
647                let frac = ((d.position.x - d.frame.x() - 9.0) / usable).clamp(0.0, 1.0);
648                drag_value.set(snap(min + frac * (max - min)).clamp(min, max));
649            }),
650        );
651        let neutral = slider.neutral.map(|n| n.clamp(min, max));
652        if let Some(neutral) = neutral {
653            let reset = slider.value.clone();
654            cx.on_double_click(node, Rc::new(move || reset.set(neutral)));
655        }
656        let key_value = slider.value.clone();
657        cx.on_key(
658            node,
659            Rc::new(move |key, _| {
660                if let (Key::Backspace | Key::Delete, Some(neutral)) = (key, neutral) {
661                    key_value.set(neutral);
662                    return true;
663                }
664                let delta = match key {
665                    Key::ArrowLeft | Key::ArrowDown => -step,
666                    Key::ArrowRight | Key::ArrowUp => step,
667                    Key::Home => -(max - min),
668                    Key::End => max - min,
669                    _ => return false,
670                };
671                key_value.update(|v| *v = snap(*v + delta).clamp(min, max));
672                true
673            }),
674        );
675        cx.focusable(node, 12.0);
676        cx.cursor(node, CursorIcon::Pointer);
677        node
678    }
679}
680
681impl View for Slider {
682    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
683        if !self.show_value {
684            return SliderTrack { slider: self, in_row: false }.build(cx);
685        }
686        let theme = *cx.theme();
687        let track = cx.child(&SliderTrack { slider: self, in_row: true });
688        let value = self.value.get().clamp(self.min, self.max);
689        let text = util::label(
690            cx,
691            &self.value_text(value),
692            TextStyle::Callout,
693            None,
694            theme.color(Semantic::InkMuted),
695        );
696        // Part of the track's semantics, as its value.
697        cx.semantics(text, Semantics::hidden());
698        // As wide as the widest value, so that the track does not move while it is dragged.
699        let attrs = util::attrs(&theme, TextStyle::Callout, None);
700        let widest = cx
701            .measure_text(&self.value_text(self.min), &attrs)
702            .width
703            .max(cx.measure_text(&self.value_text(self.max), &attrs).width);
704        let number = cx.container(
705            LayoutStyle {
706                min_width: Some(widest.ceil()),
707                justify: Justify::End,
708                ..LayoutStyle::stack(Axis::Horizontal)
709            },
710            &[text],
711            Paint::None,
712        );
713        cx.container(
714            LayoutStyle {
715                width: self.width,
716                spacing: theme.spacing.s2,
717                align: Align::Center,
718                align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
719                ..LayoutStyle::stack(Axis::Horizontal)
720            },
721            &[track, number],
722            Paint::None,
723        )
724    }
725}
726
727/// Decrement / value / increment, for small integer quantities. When focused,
728/// arrow keys step by one and Home / End jump to the limits.
729#[derive(Debug)]
730#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
731pub struct Stepper {
732    value: Binding<i32>,
733    min: i32,
734    max: i32,
735    label: Option<String>,
736    state: util::EditorState,
737}
738
739#[cfg_attr(feature = "live", ravenrs_live::expose)]
740impl Stepper {
741    /// A stepper bound to `value`, unbounded.
742    pub fn new(value: Binding<i32>) -> Self {
743        Self { value, min: i32::MIN, max: i32::MAX, label: None, state: util::EditorState::default() }
744    }
745
746    /// Clamps the value to `min..=max`.
747    pub fn range(mut self, min: i32, max: i32) -> Self {
748        self.min = min;
749        self.max = max.max(min);
750        self
751    }
752
753    /// The name screen readers announce, such as "Seats".
754    pub fn label(mut self, label: impl Into<String>) -> Self {
755        self.label = Some(label.into());
756        self
757    }
758
759    /// Turns the control off: it fades, leaves the tab order and ignores input.
760    pub fn disabled(mut self, disabled: bool) -> Self {
761        self.state.disabled = disabled;
762        self
763    }
764
765    /// Shows the value without letting it change; it can still be focused and read.
766    pub fn read_only(mut self, read_only: bool) -> Self {
767        self.state.read_only = read_only;
768        self
769    }
770}
771
772impl View for Stepper {
773    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
774        let theme = *cx.theme();
775        let v = self.value.get();
776        let (min, max, state) = (self.min, self.max, self.state);
777        let dec = self.value.clone();
778        let inc = self.value.clone();
779        let minus = cx.child(
780            &crate::IconButton::new(IconName::Minus)
781                .label(cx.labels().decrease.clone())
782                .size(crate::ButtonSize::Small)
783                .disabled(!state.accepts_input())
784                .on_click(move || dec.update(|v| *v = (*v - 1).max(min))),
785        );
786        let mut attrs = util::attrs(&theme, TextStyle::Body, Some(Weight::SEMIBOLD));
787        attrs.size = 15.0;
788        let text =
789            cx.text(&v.to_string(), &attrs, state.ink(theme.color(Semantic::Ink)), LayoutStyle::leaf());
790        let value = cx.container(
791            LayoutStyle {
792                min_width: Some(36.0),
793                justify: Justify::Center,
794                ..LayoutStyle::stack(Axis::Horizontal)
795            },
796            &[text],
797            Paint::None,
798        );
799        // Read through the spin button's value instead.
800        cx.semantics(value, Semantics::hidden());
801        let plus = cx.child(
802            &crate::IconButton::new(IconName::Plus)
803                .label(cx.labels().increase.clone())
804                .size(crate::ButtonSize::Small)
805                .disabled(!state.accepts_input())
806                .on_click(move || inc.update(|v| *v = (*v + 1).min(max))),
807        );
808        let style = LayoutStyle {
809            padding: Insets::uniform(3.0),
810            spacing: 2.0,
811            align: Align::Center,
812            align_self: Some(Align::Fit),
813            ..LayoutStyle::stack(Axis::Horizontal)
814        };
815        let paint = QuadPaint {
816            corner_radius: theme.radii.md,
817            border: Some(Border { width: 1.0, color: state.ink(theme.color(Semantic::Border)) }),
818            ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
819        };
820        let node = cx.container(style, &[minus, value, plus], Paint::Quad(paint));
821        let mut semantics =
822            Semantics::new(Role::SpinButton).numeric(v as f64, min as f64, max as f64, Some(1.0));
823        if let Some(label) = &self.label {
824            semantics = semantics.name(label.clone());
825        }
826        cx.semantics(node, state.semantics(semantics));
827        if state.disabled {
828            return node;
829        }
830        let value = self.value.clone();
831        cx.on_key(
832            node,
833            Rc::new(move |key, _| {
834                if !state.accepts_input() {
835                    return false;
836                }
837                let next = |v: i32| match key {
838                    Key::ArrowUp | Key::ArrowRight => Some(v.saturating_add(1)),
839                    Key::ArrowDown | Key::ArrowLeft => Some(v.saturating_sub(1)),
840                    Key::Home => Some(min),
841                    Key::End => Some(max),
842                    _ => None,
843                };
844                let Some(v) = next(value.get()) else { return false };
845                value.set(v.clamp(min, max));
846                true
847            }),
848        );
849        cx.focusable(node, theme.radii.md);
850        node
851    }
852}
853
854/// A row of mutually exclusive segments, like a compact tab bar. Arrow keys
855/// move the selection and the focus together (wrapping); Home and End jump to the ends.
856#[derive(Debug)]
857#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
858pub struct SegmentedControl {
859    selected: Binding<usize>,
860    options: Vec<(Option<IconName>, String)>,
861    label: Option<String>,
862}
863
864#[cfg_attr(feature = "live", ravenrs_live::expose)]
865impl SegmentedControl {
866    /// Segments for `options`; `selected` holds the zero-based index.
867    pub fn new(selected: Binding<usize>, options: impl IntoIterator<Item = impl Into<String>>) -> Self {
868        Self { selected, options: options.into_iter().map(|o| (None, o.into())).collect(), label: None }
869    }
870
871    /// Segments with an icon before each label.
872    pub fn with_icons(
873        selected: Binding<usize>,
874        options: impl IntoIterator<Item = (IconName, impl Into<String>)>,
875    ) -> Self {
876        Self {
877            selected,
878            options: options.into_iter().map(|(i, l)| (Some(i), l.into())).collect(),
879            label: None,
880        }
881    }
882
883    /// The name screen readers announce for the group, such as "Density".
884    pub fn label(mut self, label: impl Into<String>) -> Self {
885        self.label = Some(label.into());
886        self
887    }
888}
889
890struct Segment {
891    scope: WidgetId,
892    choice: ChoiceOption,
893    icon: Option<IconName>,
894    label: String,
895}
896
897impl View for Segment {
898    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
899        let theme = *cx.theme();
900        let on = self.choice.selected.get() == self.choice.index();
901        let hovered = cx.is_hovered();
902        let color = if on || hovered { theme.color(Semantic::Ink) } else { theme.color(Semantic::InkMuted) };
903        let mut children = Vec::new();
904        if let Some(icon) = self.icon {
905            children.push(util::icon(cx, icon, 14.0, color));
906        }
907        let label = util::label(cx, &self.label, TextStyle::Callout, Some(Weight::SEMIBOLD), color);
908        motion::control(cx, label);
909        children.push(label);
910        let style = LayoutStyle {
911            padding: Insets::symmetric(0.0, 14.0),
912            height: Some(30.0),
913            spacing: 6.0,
914            align: Align::Center,
915            justify: Justify::Center,
916            ..LayoutStyle::stack(Axis::Horizontal)
917        };
918        let paint = if on {
919            Paint::Quad(QuadPaint {
920                corner_radius: 7.0,
921                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
922                shadow: Some(Shadow {
923                    offset: Point::new(0.0, 1.0),
924                    blur: 4.0,
925                    color: Color::rgba(0.0, 0.0, 0.0, 0.25),
926                }),
927                ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
928            })
929        } else {
930            Paint::None
931        };
932        let node = cx.container(style, &children, paint);
933        if on {
934            // The selected pill is one object that slides under the labels.
935            cx.motion(
936                node,
937                Motion::new().shared_layout(self.scope, 0, theme.motion.snappy).shape_only().underlay(),
938            );
939        }
940        cx.semantics(node, Semantics::new(Role::RadioButton).selected(on).toggled(on));
941        self.choice.wire(cx, node, 7.0);
942        node
943    }
944}
945
946impl View for SegmentedControl {
947    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
948        let theme = *cx.theme();
949        let focus_to = util::roving_cell(cx, None);
950        let count = self.options.len();
951        let scope = cx.widget_id();
952        let segments: Vec<NodeId> = self
953            .options
954            .iter()
955            .enumerate()
956            .map(|(index, (icon, label))| {
957                let roving = util::Roving { index, count, focus_to: focus_to.clone() };
958                let choice = ChoiceOption { roving, selected: self.selected.clone() };
959                cx.child(&Segment { scope, choice, icon: *icon, label: label.clone() })
960            })
961            .collect();
962        let style = LayoutStyle {
963            padding: Insets::uniform(3.0),
964            spacing: 2.0,
965            align_self: Some(Align::Fit),
966            ..LayoutStyle::stack(Axis::Horizontal)
967        };
968        let paint =
969            QuadPaint { corner_radius: theme.radii.md, ..QuadPaint::new(theme.color(Semantic::Control)) };
970        let node = cx.container(style, &segments, Paint::Quad(paint));
971        cx.semantics(node, group_semantics(self.label.as_deref()));
972        node
973    }
974}
975
976/// Switches the app between its dark, light and glass themes: Raven Dark, Raven Light and
977/// Raven Glass, unless the app has its own.
978#[derive(Clone)]
979#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
980pub struct ThemeSwitcher {
981    themes: [Theme; 3],
982    on_change: Option<Rc<dyn Fn(Theme)>>,
983}
984
985impl std::fmt::Debug for ThemeSwitcher {
986    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
987        f.debug_struct("ThemeSwitcher")
988            .field("themes", &self.themes.map(|theme| theme.name))
989            .field("on_change", &self.on_change.is_some())
990            .finish()
991    }
992}
993
994impl Default for ThemeSwitcher {
995    fn default() -> Self {
996        Self::new()
997    }
998}
999
1000#[cfg_attr(feature = "live", ravenrs_live::expose)]
1001impl ThemeSwitcher {
1002    /// A switcher over the themes that come with RavenRS.
1003    pub fn new() -> Self {
1004        Self { themes: [Theme::raven_dark(), Theme::raven_light(), Theme::raven_glass()], on_change: None }
1005    }
1006
1007    /// Offers the application's own themes in the same three places. Each is told apart by
1008    /// its name, so it needs one of its own.
1009    pub fn themes(mut self, dark: Theme, light: Theme, glass: Theme) -> Self {
1010        self.themes = [dark, light, glass];
1011        self
1012    }
1013
1014    /// Runs `action` with the theme the user picks, after the app switches to it, so the
1015    /// app can remember the choice for the next launch.
1016    pub fn on_change(mut self, action: impl Fn(Theme) + 'static) -> Self {
1017        self.on_change = Some(Rc::new(action));
1018        self
1019    }
1020}
1021
1022impl View for ThemeSwitcher {
1023    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1024        let themes = self.themes;
1025        let on_change = self.on_change.clone();
1026        let labels = cx.labels();
1027        let state = cx.theme_state();
1028        let read = state.clone();
1029        let binding = Binding::new(
1030            move || read.with(|t| themes.iter().position(|x| x.name == t.name).unwrap_or(0)),
1031            move |i: usize| {
1032                let theme = themes[i.min(themes.len() - 1)];
1033                state.set(theme);
1034                if let Some(action) = &on_change {
1035                    action(theme);
1036                }
1037            },
1038        );
1039        cx.child(
1040            &SegmentedControl::with_icons(
1041                binding,
1042                [
1043                    (IconName::Moon, labels.theme_dark.as_str()),
1044                    (IconName::Sun, labels.theme_light.as_str()),
1045                    (IconName::Layers, labels.theme_glass.as_str()),
1046                ],
1047            )
1048            .label(labels.theme.clone()),
1049        )
1050    }
1051}