Skip to main content

ravenrs_widgets/
choice.rs

1//! Toggle buttons, toggle groups, pagination, labels and aspect-ratio boxes.
2
3use std::collections::BTreeSet;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Modifiers, PathFill, Quad, Rect, Role, Scene};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_reactive::Binding;
9use ravenrs_style::{Semantic, TextStyle, Weight};
10
11use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
12use crate::{ButtonSize, ButtonStyle, IconButton, IconName, Tone, View, util};
13
14/// A button that stays pressed: bold, italic, "pin", "mute".
15#[derive(Debug)]
16#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
17pub struct ToggleButton {
18    value: Binding<bool>,
19    label: String,
20    icon: Option<IconName>,
21    look: ToggleLook,
22}
23
24#[cfg_attr(feature = "live", ravenrs_live::expose)]
25impl ToggleButton {
26    /// A toggle button bound to `value`; clicking flips it.
27    pub fn new(value: Binding<bool>, label: impl Into<String>) -> Self {
28        Self { value, label: label.into(), icon: None, look: ToggleLook::default() }
29    }
30
31    /// An icon before the label.
32    pub fn icon(mut self, icon: IconName) -> Self {
33        self.icon = Some(icon);
34        self
35    }
36
37    /// Shows how many things this button stands for after the label, as in a filter.
38    pub fn count(mut self, count: usize) -> Self {
39        self.look.count = Some(count);
40        self
41    }
42
43    /// Fully rounded ends instead of the usual corners, for filter chips.
44    pub fn pill(mut self) -> Self {
45        self.look.pill = true;
46        self
47    }
48}
49
50/// The options of a toggle item that a toggle group does not use.
51#[derive(Clone, Copy, Debug, Default)]
52struct ToggleLook {
53    count: Option<usize>,
54    pill: bool,
55}
56
57/// The look shared by toggle buttons and toggle group items.
58fn toggle_item(
59    cx: &mut BuildCx<'_>,
60    on: bool,
61    icon: Option<IconName>,
62    label: &str,
63    look: ToggleLook,
64    on_click: Rc<dyn Fn()>,
65) -> NodeId {
66    let theme = *cx.theme();
67    let hovered = cx.is_hovered();
68    let ink = theme.color(Semantic::Ink);
69    let color = if on || hovered { ink } else { theme.color(Semantic::InkMuted) };
70    let mut children = Vec::new();
71    if let Some(i) = icon {
72        children.push(util::icon(cx, i, 16.0, color));
73    }
74    if !label.is_empty() {
75        children.push(util::label(cx, label, TextStyle::Callout, Some(Weight::SEMIBOLD), color));
76    }
77    if let Some(count) = look.count {
78        let text = util::label(cx, &count.to_string(), TextStyle::Caption, Some(Weight::SEMIBOLD), color);
79        let chip = cx.container(
80            LayoutStyle { padding: Insets::symmetric(0.0, 6.0), ..LayoutStyle::stack(Axis::Horizontal) },
81            &[text],
82            Paint::Quad(QuadPaint { corner_radius: 999.0, ..QuadPaint::new(ink.with_alpha(0.1)) }),
83        );
84        // Announced as the value, so that the name stays the label.
85        cx.semantics(chip, Semantics::hidden());
86        children.push(chip);
87    }
88    let fill = if on {
89        theme.color(Semantic::Control)
90    } else if hovered {
91        ink.with_alpha(0.06)
92    } else {
93        Color::TRANSPARENT
94    };
95    let border = on.then(|| Border { width: 1.0, color: theme.color(Semantic::Border) });
96    let icon_only = label.is_empty() && look.count.is_none();
97    let radius = if look.pill { 17.0 } else { 8.0 };
98    let node = cx.container(
99        LayoutStyle {
100            height: Some(34.0),
101            width: icon_only.then_some(34.0),
102            padding: Insets::symmetric(0.0, if icon_only { 0.0 } else { 12.0 }),
103            spacing: 6.0,
104            align: Align::Center,
105            justify: Justify::Center,
106            ..LayoutStyle::stack(Axis::Horizontal)
107        },
108        &children,
109        Paint::Quad(QuadPaint { corner_radius: radius, border, ..QuadPaint::new(fill) }),
110    );
111    let mut semantics = Semantics::new(Role::Button).toggled(on);
112    if let Some(count) = look.count {
113        semantics = semantics.value(count.to_string());
114    }
115    if icon_only && let Some(i) = icon {
116        semantics = semantics.name(i.name().replace('-', " "));
117        semantics.fallback_name = true;
118    }
119    cx.semantics(node, semantics);
120    cx.on_click(node, on_click);
121    cx.focusable(node, radius);
122    cx.cursor(node, CursorIcon::Pointer);
123    node
124}
125
126impl View for ToggleButton {
127    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
128        let v = self.value.clone();
129        let toggle = Rc::new(move || v.update(|b| *b = !*b));
130        toggle_item(cx, self.value.get(), self.icon, &self.label, self.look, toggle)
131    }
132}
133
134/// A row of toggle buttons where one (or several) can be on. Arrow keys move
135/// the focus between buttons; Space or Enter toggles the focused one.
136#[derive(Debug)]
137#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
138pub struct ToggleGroup {
139    items: Vec<(Option<IconName>, String)>,
140    single: Option<Binding<usize>>,
141    multiple: Option<Binding<BTreeSet<usize>>>,
142}
143
144#[cfg_attr(feature = "live", ravenrs_live::expose)]
145impl ToggleGroup {
146    /// Exactly one item is on (like text alignment).
147    pub fn single(
148        selected: Binding<usize>,
149        items: impl IntoIterator<Item = (Option<IconName>, &'static str)>,
150    ) -> Self {
151        Self { items: collect(items), single: Some(selected), multiple: None }
152    }
153
154    /// Any number of items are on (like bold / italic / underline).
155    pub fn multiple(
156        selected: Binding<BTreeSet<usize>>,
157        items: impl IntoIterator<Item = (Option<IconName>, &'static str)>,
158    ) -> Self {
159        Self { items: collect(items), single: None, multiple: Some(selected) }
160    }
161}
162
163fn collect(
164    items: impl IntoIterator<Item = (Option<IconName>, &'static str)>,
165) -> Vec<(Option<IconName>, String)> {
166    items.into_iter().map(|(i, l)| (i, l.to_owned())).collect()
167}
168
169struct GroupItem<'a> {
170    index: usize,
171    group: &'a ToggleGroup,
172    roving: util::Roving,
173}
174
175impl View for GroupItem<'_> {
176    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
177        let g = self.group;
178        let i = self.index;
179        let (icon, label) = &g.items[i];
180        let (on, action): (bool, Rc<dyn Fn()>) = match (&g.single, &g.multiple) {
181            (Some(b), _) => {
182                let b2 = b.clone();
183                (b.get() == i, Rc::new(move || b2.set(i)))
184            }
185            (_, Some(b)) => {
186                let b2 = b.clone();
187                (
188                    b.get().contains(&i),
189                    Rc::new(move || {
190                        b2.update(|s| {
191                            if !s.remove(&i) {
192                                s.insert(i);
193                            }
194                        })
195                    }),
196                )
197            }
198            _ => (false, Rc::new(|| {})),
199        };
200        let node = toggle_item(cx, on, *icon, label, ToggleLook::default(), action);
201        // A toolbar: arrows move focus only; Space or Enter toggles.
202        self.roving.wire(cx, node, None);
203        node
204    }
205}
206
207impl View for ToggleGroup {
208    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
209        let (count, focus_to) = (self.items.len(), util::roving_cell(cx, None));
210        let items: Vec<NodeId> = (0..count)
211            .map(|index| {
212                let roving = util::Roving { index, count, focus_to: focus_to.clone() };
213                cx.keyed_child(index as u64, &GroupItem { index, group: self, roving })
214            })
215            .collect();
216        let node = cx.container(
217            LayoutStyle {
218                spacing: 4.0,
219                align: Align::Center,
220                align_self: Some(Align::Fit),
221                ..LayoutStyle::stack(Axis::Horizontal)
222            },
223            &items,
224            Paint::None,
225        );
226        cx.semantics(node, Semantics::new(Role::Toolbar));
227        node
228    }
229}
230
231/// Page navigation: Previous, numbered pages with ellipses, Next.
232#[derive(Debug)]
233#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
234pub struct Pagination {
235    page: Binding<usize>,
236    pages: usize,
237    previous: String,
238    next: String,
239    page_name: String,
240    compact: bool,
241}
242
243#[cfg_attr(feature = "live", ravenrs_live::expose)]
244impl Pagination {
245    /// `page` is zero-based; `pages` is the total count.
246    pub fn new(page: Binding<usize>, pages: usize) -> Self {
247        Self {
248            page,
249            pages: pages.max(1),
250            previous: "Previous".into(),
251            next: "Next".into(),
252            page_name: "Page".into(),
253            compact: false,
254        }
255    }
256
257    /// Shows "1 / 12" between the buttons instead of the page numbers, so that the
258    /// buttons stay in the same place whatever the number of pages.
259    pub fn compact(mut self) -> Self {
260        self.compact = true;
261        self
262    }
263
264    /// The text of the two buttons and the word screen readers say before a
265    /// page number, for apps in other languages.
266    pub fn labels(
267        mut self,
268        previous: impl Into<String>,
269        next: impl Into<String>,
270        page_name: impl Into<String>,
271    ) -> Self {
272        self.previous = previous.into();
273        self.next = next.into();
274        self.page_name = page_name.into();
275        self
276    }
277}
278
279/// Room for "123 / 456" in a compact [`Pagination`].
280const COMPACT_PAGE_WIDTH: f32 = 72.0;
281
282/// The page numbers to show (zero-based), with `None` for an ellipsis.
283pub(crate) fn page_window(current: usize, pages: usize) -> Vec<Option<usize>> {
284    if pages <= 7 {
285        return (0..pages).map(Some).collect();
286    }
287    let mut out = vec![Some(0)];
288    let (lo, hi) = if current <= 3 {
289        (1, 4)
290    } else if current >= pages - 4 {
291        (pages - 5, pages - 2)
292    } else {
293        (current - 1, current + 1)
294    };
295    if lo > 1 {
296        out.push(None);
297    }
298    out.extend((lo..=hi).map(Some));
299    if hi < pages - 2 {
300        out.push(None);
301    }
302    out.push(Some(pages - 1));
303    out
304}
305
306impl View for Pagination {
307    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
308        let theme = *cx.theme();
309        let current = self.page.get().min(self.pages - 1);
310        let mut items = Vec::new();
311        let prev = self.page.clone();
312        items.push(
313            cx.child(
314                &crate::Button::new(self.previous.clone())
315                    .style(ButtonStyle::Ghost)
316                    .size(ButtonSize::Small)
317                    .icon(IconName::ChevronLeft)
318                    .disabled(current == 0)
319                    .on_click(move || prev.update(|p| *p = p.saturating_sub(1))),
320            ),
321        );
322        if self.compact {
323            let color = theme.color(Semantic::Ink);
324            let text = format!("{} / {}", current + 1, self.pages);
325            let label = util::label(cx, &text, TextStyle::Callout, Some(Weight::MEDIUM), color);
326            let node = cx.container(
327                LayoutStyle {
328                    width: Some(COMPACT_PAGE_WIDTH),
329                    justify: Justify::Center,
330                    ..LayoutStyle::stack(Axis::Horizontal)
331                },
332                &[label],
333                Paint::None,
334            );
335            cx.semantics(node, Semantics::new(Role::Label).name(format!("{} {text}", self.page_name)));
336            items.push(node);
337        }
338        let window = if self.compact { Vec::new() } else { page_window(current, self.pages) };
339        for (k, entry) in window.into_iter().enumerate() {
340            match entry {
341                Some(p) => {
342                    let b = self.page.clone();
343                    let style = if p == current { ButtonStyle::Secondary } else { ButtonStyle::Ghost };
344                    let button = crate::Button::new((p + 1).to_string())
345                        .accessible_name(format!("{} {}", self.page_name, p + 1))
346                        .style(style)
347                        .size(ButtonSize::Small)
348                        .on_click(move || b.set(p));
349                    let node = cx.keyed_child(p as u64, &button);
350                    cx.update_semantics(node, |s| s.selected = Some(p == current));
351                    items.push(node);
352                }
353                None => {
354                    let dots = util::icon(cx, IconName::Ellipsis, 16.0, theme.color(Semantic::InkMuted));
355                    items.push(cx.container(
356                        LayoutStyle {
357                            width: Some(28.0),
358                            justify: Justify::Center,
359                            ..LayoutStyle::stack(Axis::Horizontal)
360                        },
361                        &[dots],
362                        Paint::None,
363                    ));
364                    let _ = k;
365                }
366            }
367        }
368        let (next, last) = (self.page.clone(), self.pages - 1);
369        items.push(
370            cx.child(
371                &crate::Button::new(self.next.clone())
372                    .style(ButtonStyle::Ghost)
373                    .size(ButtonSize::Small)
374                    .trailing_icon(IconName::ChevronRight)
375                    .disabled(current == last)
376                    .on_click(move || next.update(|p| *p = (*p + 1).min(last))),
377            ),
378        );
379        let node = cx.container(
380            LayoutStyle {
381                spacing: 4.0,
382                align: Align::Center,
383                wrap: true,
384                ..LayoutStyle::stack(Axis::Horizontal)
385            },
386            &items,
387            Paint::None,
388        );
389        cx.semantics(node, Semantics::new(Role::Group).name("Pagination"));
390        node
391    }
392}
393
394/// A round color to pick, from a palette of [`ColorSwatch`]es. Its hit target is
395/// 32 px; the color itself is 20 px.
396#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
397pub struct ColorSwatch {
398    color: Option<Color>,
399    label: String,
400    selected: bool,
401    on_click: Option<Rc<dyn Fn()>>,
402}
403
404impl std::fmt::Debug for ColorSwatch {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        f.debug_struct("ColorSwatch").field("label", &self.label).finish_non_exhaustive()
407    }
408}
409
410#[cfg_attr(feature = "live", ravenrs_live::expose)]
411impl ColorSwatch {
412    /// A swatch of `color`. `label` is what screen readers announce, such as "Accent".
413    pub fn new(color: Color, label: impl Into<String>) -> Self {
414        Self { color: Some(color), label: label.into(), selected: false, on_click: None }
415    }
416
417    /// The "no color" choice, drawn empty with a bar across it in `danger`.
418    pub fn none(label: impl Into<String>) -> Self {
419        Self { color: None, label: label.into(), selected: false, on_click: None }
420    }
421
422    /// Marks the swatch as the current choice.
423    pub fn selected(mut self, selected: bool) -> Self {
424        self.selected = selected;
425        self
426    }
427
428    /// Runs `handler` when the swatch is clicked, or on Enter and Space while focused.
429    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
430        self.on_click = Some(Rc::new(handler));
431        self
432    }
433}
434
435impl View for ColorSwatch {
436    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
437        let theme = *cx.theme();
438        let (color, selected) = (self.color, self.selected);
439        let ring = theme.color(if selected { Semantic::Accent } else { Semantic::Border });
440        let strike = theme.color(Semantic::Danger);
441        let dot = cx.container(
442            LayoutStyle { width: Some(20.0), height: Some(20.0), ..LayoutStyle::leaf() },
443            &[],
444            Paint::Custom(Rc::new(move |f: Rect, scene| {
445                let fill = color.unwrap_or(Color::TRANSPARENT);
446                scene.push_quad(
447                    Quad::new(f, fill).radius(10.0).border(if selected { 2.5 } else { 1.0 }, ring),
448                );
449                if color.is_none() {
450                    let bar = Rect::new(f.x() + 3.0, f.center().y - 0.75, f.width() - 6.0, 1.5);
451                    scene.push_quad(Quad::new(bar, strike));
452                }
453            })),
454        );
455        let fill =
456            if cx.is_hovered() { theme.color(Semantic::Ink).with_alpha(0.06) } else { Color::TRANSPARENT };
457        let node = cx.container(
458            LayoutStyle {
459                width: Some(32.0),
460                height: Some(32.0),
461                align: Align::Center,
462                justify: Justify::Center,
463                ..LayoutStyle::stack(Axis::Horizontal)
464            },
465            &[dot],
466            Paint::Quad(QuadPaint { corner_radius: theme.radii.full, ..QuadPaint::new(fill) }),
467        );
468        cx.semantics(node, Semantics::new(Role::Button).name(self.label.clone()).selected(selected));
469        if let Some(handler) = &self.on_click {
470            cx.on_click(node, handler.clone());
471        }
472        cx.focusable(node, theme.radii.full);
473        cx.cursor(node, CursorIcon::Pointer);
474        node
475    }
476}
477
478/// A heart that marks something as a favorite: filled when on, an outline when off.
479///
480/// ```
481/// use ravenrs_reactive::State;
482/// use ravenrs_widgets::FavoriteToggle;
483///
484/// let liked = State::new(false);
485/// let heart = FavoriteToggle::new(liked.binding()).label("Warm portrait");
486/// ```
487#[derive(Debug)]
488#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
489pub struct FavoriteToggle {
490    value: Binding<bool>,
491    label: Option<String>,
492    star: bool,
493    size: f32,
494    color: Option<Color>,
495}
496
497#[cfg_attr(feature = "live", ravenrs_live::expose)]
498impl FavoriteToggle {
499    /// A heart bound to `value`; clicking flips it.
500    pub fn new(value: Binding<bool>) -> Self {
501        Self { value, label: None, star: false, size: 16.0, color: None }
502    }
503
504    /// What it marks, for screen readers, instead of the word in [`Labels`](crate::Labels).
505    pub fn label(mut self, label: impl Into<String>) -> Self {
506        self.label = Some(label.into());
507        self
508    }
509
510    /// A star instead of a heart.
511    pub fn star(mut self) -> Self {
512        self.star = true;
513        self
514    }
515
516    /// Sets the size of the mark in logical pixels (16 by default). The target around it follows.
517    pub fn size(mut self, size: f32) -> Self {
518        self.size = size;
519        self
520    }
521
522    /// Sets the color when on (the danger tone for a heart, the warning tone for a star).
523    pub fn color(mut self, color: Color) -> Self {
524        self.color = Some(color);
525        self
526    }
527}
528
529impl View for FavoriteToggle {
530    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
531        let theme = *cx.theme();
532        let on = self.value.get();
533        let hovered = cx.is_hovered();
534        let (size, star) = (self.size, self.star);
535        let mark = if on {
536            let tone = if star { Tone::Warning } else { Tone::Danger };
537            let color = self.color.unwrap_or_else(|| tone.color(&theme));
538            cx.container(
539                LayoutStyle { width: Some(size), height: Some(size), ..LayoutStyle::leaf() },
540                &[],
541                Paint::Custom(Rc::new(move |frame: Rect, scene: &mut Scene| {
542                    let path = if star {
543                        util::star_path(frame.center(), size * 0.5)
544                    } else {
545                        util::heart_path(frame)
546                    };
547                    scene.push_path(PathFill::solid(path, color));
548                })),
549            )
550        } else {
551            let ink = theme.color(if hovered { Semantic::Ink } else { Semantic::InkMuted });
552            let glyph = util::icon(cx, if star { IconName::Star } else { IconName::Heart }, size, ink);
553            cx.semantics(glyph, Semantics::hidden());
554            glyph
555        };
556        let side = size + 3.0 * theme.spacing.s1;
557        let fill = if hovered { theme.color(Semantic::Ink).with_alpha(0.06) } else { Color::TRANSPARENT };
558        let node = cx.container(
559            LayoutStyle {
560                width: Some(side),
561                height: Some(side),
562                align: Align::Center,
563                justify: Justify::Center,
564                ..LayoutStyle::stack(Axis::Horizontal)
565            },
566            &[mark],
567            Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
568        );
569        let name = self.label.clone().unwrap_or_else(|| cx.labels().favorite.clone());
570        cx.semantics(node, Semantics::new(Role::Button).name(name).toggled(on));
571        let value = self.value.clone();
572        cx.on_click(node, Rc::new(move || value.update(|v| *v = !*v)));
573        cx.focusable(node, theme.radii.sm);
574        cx.cursor(node, CursorIcon::Pointer);
575        node
576    }
577}
578
579/// A grid of [`ColorSwatch`]es. The arrow keys walk through it, and the handler gets the keys
580/// held with the click, so that a modifier can mean something else, such as "for the background".
581///
582/// ```
583/// use ravenrs_core::Color;
584/// use ravenrs_widgets::SwatchGrid;
585///
586/// let grid = SwatchGrid::new([(Color::hex(0xe11d48), "Rose"), (Color::hex(0x2563eb), "Blue")])
587///     .columns(8)
588///     .selected(Some(Color::hex(0x2563eb)))
589///     .on_pick(|color, modifiers| {
590///         let for_background = modifiers.meta || modifiers.ctrl;
591///         # let _ = (color, for_background);
592///     });
593/// ```
594#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
595pub struct SwatchGrid {
596    colors: Vec<(Color, String)>,
597    selected: Option<Color>,
598    columns: usize,
599    label: Option<String>,
600    on_pick: Option<Rc<dyn Fn(Color, Modifiers)>>,
601}
602
603impl std::fmt::Debug for SwatchGrid {
604    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605        f.debug_struct("SwatchGrid").field("colors", &self.colors.len()).finish_non_exhaustive()
606    }
607}
608
609#[cfg_attr(feature = "live", ravenrs_live::expose)]
610impl SwatchGrid {
611    /// A grid of these colors, each with the name screen readers announce.
612    pub fn new<S: Into<String>>(colors: impl IntoIterator<Item = (Color, S)>) -> Self {
613        Self {
614            colors: colors.into_iter().map(|(color, name)| (color, name.into())).collect(),
615            selected: None,
616            columns: 8,
617            label: None,
618            on_pick: None,
619        }
620    }
621
622    /// Marks the swatch of this color as the current one.
623    pub fn selected(mut self, color: Option<Color>) -> Self {
624        self.selected = color;
625        self
626    }
627
628    /// Sets how many swatches go in a row (8 by default).
629    pub fn columns(mut self, columns: usize) -> Self {
630        self.columns = columns.max(1);
631        self
632    }
633
634    /// What the colors are for, for screen readers, instead of the word in [`Labels`](crate::Labels).
635    pub fn label(mut self, label: impl Into<String>) -> Self {
636        self.label = Some(label.into());
637        self
638    }
639
640    /// Runs `handler` with the color and the keys held, on a click or on Enter and Space.
641    pub fn on_pick(mut self, handler: impl Fn(Color, Modifiers) + 'static) -> Self {
642        self.on_pick = Some(Rc::new(handler));
643        self
644    }
645}
646
647/// Where an arrow key leads from `index`, in a grid of `count` items in rows of `columns`.
648fn grid_step(key: &Key, index: usize, count: usize, columns: usize) -> Option<usize> {
649    let target = match key {
650        Key::ArrowRight => index + 1,
651        Key::ArrowLeft => index.checked_sub(1)?,
652        Key::ArrowDown => index + columns,
653        Key::ArrowUp => index.checked_sub(columns)?,
654        Key::Home => 0,
655        Key::End => count.checked_sub(1)?,
656        _ => return None,
657    };
658    (target < count).then_some(target)
659}
660
661impl View for SwatchGrid {
662    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
663        let focus_to = util::roving_cell(cx, None);
664        let held = cx.modifiers();
665        let (count, columns) = (self.colors.len(), self.columns);
666        let mut rows = Vec::with_capacity(count.div_ceil(columns));
667        let mut row = Vec::with_capacity(columns);
668        for (index, (color, name)) in self.colors.iter().enumerate() {
669            let mut swatch = ColorSwatch::new(*color, name.clone()).selected(self.selected == Some(*color));
670            if let Some(on_pick) = &self.on_pick {
671                let (on_pick, held, color) = (on_pick.clone(), held.clone(), *color);
672                swatch = swatch.on_click(move || on_pick(color, held.get()));
673            }
674            let node = cx.child(&swatch);
675            if focus_to.take(index) {
676                cx.request_focus_node(node);
677            }
678            let focus_to = focus_to.clone();
679            cx.on_key(
680                node,
681                Rc::new(move |key, _| match grid_step(key, index, count, columns) {
682                    Some(target) => {
683                        focus_to.request(target);
684                        true
685                    }
686                    // At an edge the key is used up all the same, so the page does not scroll.
687                    None => matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown),
688                }),
689            );
690            row.push(node);
691            if row.len() == columns || index + 1 == count {
692                rows.push(cx.container(LayoutStyle::stack(Axis::Horizontal), &row, Paint::None));
693                row.clear();
694            }
695        }
696        let node = cx.container(
697            LayoutStyle { align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Vertical) },
698            &rows,
699            Paint::None,
700        );
701        let name = self.label.clone().unwrap_or_else(|| cx.labels().swatches.clone());
702        cx.semantics(node, Semantics::new(Role::Group).name(name));
703        node
704    }
705}
706
707/// One of the nine points of a [`NinePointAnchor`].
708#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
709#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
710pub enum AnchorPoint {
711    /// The top left corner.
712    TopLeft,
713    /// The middle of the top edge.
714    Top,
715    /// The top right corner.
716    TopRight,
717    /// The middle of the left edge.
718    Left,
719    /// The middle.
720    #[default]
721    Center,
722    /// The middle of the right edge.
723    Right,
724    /// The bottom left corner.
725    BottomLeft,
726    /// The middle of the bottom edge.
727    Bottom,
728    /// The bottom right corner.
729    BottomRight,
730}
731
732#[cfg_attr(feature = "live", ravenrs_live::expose)]
733impl AnchorPoint {
734    /// All nine, row by row from the top left.
735    pub const ALL: [Self; 9] = [
736        Self::TopLeft,
737        Self::Top,
738        Self::TopRight,
739        Self::Left,
740        Self::Center,
741        Self::Right,
742        Self::BottomLeft,
743        Self::Bottom,
744        Self::BottomRight,
745    ];
746
747    /// Where it falls in a box, as a share of the width and of the height.
748    ///
749    /// ```
750    /// use ravenrs_widgets::AnchorPoint;
751    ///
752    /// assert_eq!(AnchorPoint::Right.fraction(), (1.0, 0.5));
753    /// ```
754    pub fn fraction(self) -> (f32, f32) {
755        let index = self as usize;
756        ((index % 3) as f32 / 2.0, (index / 3) as f32 / 2.0)
757    }
758
759    /// The point nearest to these shares of a box.
760    pub fn from_fraction(x: f32, y: f32) -> Self {
761        let cell = |share: f32| (share.clamp(0.0, 1.0) * 2.0).round() as usize;
762        Self::ALL[cell(y) * 3 + cell(x)]
763    }
764}
765
766/// A three by three grid for choosing a point of reference on a box. The arrow keys move it.
767///
768/// ```
769/// use ravenrs_reactive::State;
770/// use ravenrs_widgets::{AnchorPoint, NinePointAnchor};
771///
772/// let point = State::new(AnchorPoint::Center);
773/// let picker = NinePointAnchor::new(point.binding());
774/// ```
775#[derive(Debug)]
776#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
777pub struct NinePointAnchor {
778    value: Binding<AnchorPoint>,
779    cell: f32,
780    label: Option<String>,
781}
782
783#[cfg_attr(feature = "live", ravenrs_live::expose)]
784impl NinePointAnchor {
785    /// A grid bound to `value`.
786    pub fn new(value: Binding<AnchorPoint>) -> Self {
787        Self { value, cell: 10.0, label: None }
788    }
789
790    /// Sets the side of a square in logical pixels (10 by default).
791    pub fn cell_size(mut self, size: f32) -> Self {
792        self.cell = size;
793        self
794    }
795
796    /// What it is for, for screen readers, instead of the words in [`Labels`](crate::Labels).
797    pub fn label(mut self, label: impl Into<String>) -> Self {
798        self.label = Some(label.into());
799        self
800    }
801}
802
803/// One square of a [`NinePointAnchor`]. The grid as a whole takes the focus, so the keyboard
804/// moves the value and not a cursor over the squares.
805struct AnchorCell {
806    point: AnchorPoint,
807    on: bool,
808    size: f32,
809    name: String,
810    value: Binding<AnchorPoint>,
811}
812
813impl View for AnchorCell {
814    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
815        let theme = *cx.theme();
816        let ink = theme.color(if self.on || cx.is_hovered() { Semantic::Ink } else { Semantic::InkMuted });
817        let node = cx.container(
818            LayoutStyle { width: Some(self.size), height: Some(self.size), ..LayoutStyle::leaf() },
819            &[],
820            Paint::Quad(QuadPaint {
821                border: Some(Border { width: 1.0, color: ink }),
822                ..QuadPaint::new(if self.on { ink } else { Color::TRANSPARENT })
823            }),
824        );
825        cx.semantics(
826            node,
827            Semantics::new(Role::RadioButton).name(self.name.clone()).selected(self.on).toggled(self.on),
828        );
829        let (value, point) = (self.value.clone(), self.point);
830        cx.on_click(node, Rc::new(move || value.set(point)));
831        cx.cursor(node, CursorIcon::Pointer);
832        node
833    }
834}
835
836impl View for NinePointAnchor {
837    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
838        let theme = *cx.theme();
839        let labels = cx.labels();
840        let current = self.value.get();
841        let mut rows = Vec::with_capacity(3);
842        for row in AnchorPoint::ALL.chunks(3) {
843            let cells: Vec<NodeId> = row
844                .iter()
845                .map(|&point| {
846                    cx.child(&AnchorCell {
847                        point,
848                        on: point == current,
849                        size: self.cell,
850                        name: labels.anchor_names[point as usize].clone(),
851                        value: self.value.clone(),
852                    })
853                })
854                .collect();
855            rows.push(cx.container(
856                LayoutStyle { spacing: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
857                &cells,
858                Paint::None,
859            ));
860        }
861        let node = cx.container(
862            LayoutStyle { spacing: 1.0, align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Vertical) },
863            &rows,
864            Paint::None,
865        );
866        let name = self.label.clone().unwrap_or_else(|| labels.anchor_point.clone());
867        cx.semantics(
868            node,
869            Semantics::new(Role::RadioGroup).name(name).value(labels.anchor_names[current as usize].clone()),
870        );
871        let value = self.value.clone();
872        cx.on_key(
873            node,
874            Rc::new(move |key, _| {
875                if !matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown) {
876                    return false;
877                }
878                let index = value.get() as usize;
879                // Stays put at an edge: on a grid of positions, going round would be a jump.
880                if let Some(target) = grid_step(key, index, 9, 3).filter(|target| match key {
881                    Key::ArrowLeft | Key::ArrowRight => target / 3 == index / 3,
882                    _ => true,
883                }) {
884                    value.set(AnchorPoint::ALL[target]);
885                }
886                true
887            }),
888        );
889        cx.focusable(node, theme.radii.sm);
890        node
891    }
892}
893
894/// A foreground and a background color, one over the other, with a button that swaps them.
895///
896/// It holds the two colors and swaps them. Choosing a new one is up to whoever uses it, as
897/// when a click on a square opens a [`ColorPicker`](crate::ColorPicker).
898///
899/// ```
900/// use ravenrs_core::Color;
901/// use ravenrs_reactive::State;
902/// use ravenrs_widgets::ColorPair;
903///
904/// let (front, back) = (State::new(Color::BLACK), State::new(Color::WHITE));
905/// let pair = ColorPair::new(front.binding(), back.binding())
906///     .defaults(Color::BLACK, Color::WHITE)
907///     .on_foreground(|| { /* open a picker */ });
908/// ```
909#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
910pub struct ColorPair {
911    foreground: Binding<Color>,
912    background: Binding<Color>,
913    defaults: Option<(Color, Color)>,
914    on_foreground: Option<Rc<dyn Fn()>>,
915    on_background: Option<Rc<dyn Fn()>>,
916    size: f32,
917}
918
919impl std::fmt::Debug for ColorPair {
920    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
921        f.debug_struct("ColorPair").field("size", &self.size).finish_non_exhaustive()
922    }
923}
924
925#[cfg_attr(feature = "live", ravenrs_live::expose)]
926impl ColorPair {
927    /// A pair bound to these two colors.
928    pub fn new(foreground: Binding<Color>, background: Binding<Color>) -> Self {
929        Self { foreground, background, defaults: None, on_foreground: None, on_background: None, size: 24.0 }
930    }
931
932    /// Adds a button that puts these colors back.
933    pub fn defaults(mut self, foreground: Color, background: Color) -> Self {
934        self.defaults = Some((foreground, background));
935        self
936    }
937
938    /// Runs `handler` when the foreground square is clicked, or on Enter and Space while focused.
939    pub fn on_foreground(mut self, handler: impl Fn() + 'static) -> Self {
940        self.on_foreground = Some(Rc::new(handler));
941        self
942    }
943
944    /// Runs `handler` when the background square is clicked, or on Enter and Space while focused.
945    pub fn on_background(mut self, handler: impl Fn() + 'static) -> Self {
946        self.on_background = Some(Rc::new(handler));
947        self
948    }
949
950    /// Sets the side of each square in logical pixels (24 by default).
951    pub fn size(mut self, size: f32) -> Self {
952        self.size = size;
953        self
954    }
955}
956
957/// One square of a [`ColorPair`]. Its color is also said in words, and without a handler it
958/// only shows the color.
959struct PairSquare {
960    color: Color,
961    name: String,
962    at: (f32, f32),
963    size: f32,
964    on_click: Option<Rc<dyn Fn()>>,
965}
966
967impl View for PairSquare {
968    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
969        let theme = *cx.theme();
970        let node = cx.container(
971            LayoutStyle {
972                absolute: Some(self.at),
973                width: Some(self.size),
974                height: Some(self.size),
975                ..LayoutStyle::leaf()
976            },
977            &[],
978            Paint::Quad(QuadPaint {
979                corner_radius: theme.radii.sm,
980                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
981                ..QuadPaint::new(self.color)
982            }),
983        );
984        let role = if self.on_click.is_some() { Role::Button } else { Role::Image };
985        let spoken = crate::color_picker::to_hex(self.color);
986        cx.semantics(node, Semantics::new(role).name(self.name.clone()).value(spoken));
987        if let Some(handler) = &self.on_click {
988            cx.on_click(node, handler.clone());
989            cx.focusable(node, theme.radii.sm);
990            cx.cursor(node, CursorIcon::Pointer);
991        }
992        node
993    }
994}
995
996impl View for ColorPair {
997    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
998        let theme = *cx.theme();
999        let labels = cx.labels();
1000        let (front, back) = (self.foreground.get(), self.background.get());
1001        let offset = (self.size / 2.0).round();
1002        // The background first, so that it stays under the foreground.
1003        let lower = cx.child(&PairSquare {
1004            color: back,
1005            name: labels.color_background.clone(),
1006            at: (offset, offset),
1007            size: self.size,
1008            on_click: self.on_background.clone(),
1009        });
1010        let upper = cx.child(&PairSquare {
1011            color: front,
1012            name: labels.color_foreground.clone(),
1013            at: (0.0, 0.0),
1014            size: self.size,
1015            on_click: self.on_foreground.clone(),
1016        });
1017        let side = self.size + offset;
1018        let squares = cx.container(
1019            LayoutStyle { width: Some(side), height: Some(side), ..LayoutStyle::stack(Axis::Horizontal) },
1020            &[lower, upper],
1021            Paint::None,
1022        );
1023        let (foreground, background) = (self.foreground.clone(), self.background.clone());
1024        let swap = cx.child(
1025            &IconButton::new(IconName::ArrowUpDown)
1026                .label(labels.color_swap.clone())
1027                .size(ButtonSize::Mini)
1028                .on_click(move || {
1029                    let front = foreground.get();
1030                    foreground.set(background.get());
1031                    background.set(front);
1032                }),
1033        );
1034        let mut buttons = vec![swap];
1035        if let Some((default_front, default_back)) = self.defaults {
1036            let (foreground, background) = (self.foreground.clone(), self.background.clone());
1037            buttons.push(
1038                cx.child(
1039                    &IconButton::new(IconName::RotateCcw)
1040                        .label(labels.color_reset.clone())
1041                        .size(ButtonSize::Mini)
1042                        .on_click(move || {
1043                            foreground.set(default_front);
1044                            background.set(default_back);
1045                        }),
1046                ),
1047            );
1048        }
1049        let buttons = cx.container(
1050            LayoutStyle { spacing: theme.spacing.s1, ..LayoutStyle::stack(Axis::Vertical) },
1051            &buttons,
1052            Paint::None,
1053        );
1054        cx.container(
1055            LayoutStyle {
1056                spacing: theme.spacing.s1,
1057                align: Align::Center,
1058                align_self: Some(Align::Fit),
1059                ..LayoutStyle::stack(Axis::Horizontal)
1060            },
1061            &[squares, buttons],
1062            Paint::None,
1063        )
1064    }
1065}
1066
1067/// A form label with an optional required marker and hint.
1068#[derive(Clone, Debug)]
1069#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1070pub struct Label {
1071    text: String,
1072    required: bool,
1073    hint: Option<String>,
1074    ink: Option<Semantic>,
1075}
1076
1077#[cfg_attr(feature = "live", ravenrs_live::expose)]
1078impl Label {
1079    /// A label with this text.
1080    pub fn new(text: impl Into<String>) -> Self {
1081        Self { text: text.into(), required: false, hint: None, ink: None }
1082    }
1083
1084    /// The label text.
1085    pub fn text(&self) -> &str {
1086        &self.text
1087    }
1088
1089    /// Adds a red `*` after the text.
1090    pub fn required(mut self) -> Self {
1091        self.required = true;
1092        self
1093    }
1094
1095    /// A muted caption under the text.
1096    pub fn hint(mut self, hint: impl Into<String>) -> Self {
1097        self.hint = Some(hint.into());
1098        self
1099    }
1100
1101    /// The color of the text, instead of the usual one.
1102    pub fn ink(mut self, ink: Semantic) -> Self {
1103        self.ink = Some(ink);
1104        self
1105    }
1106}
1107
1108impl View for Label {
1109    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1110        let theme = *cx.theme();
1111        let mut row = vec![util::label(
1112            cx,
1113            &self.text,
1114            TextStyle::Callout,
1115            Some(Weight::SEMIBOLD),
1116            theme.color(self.ink.unwrap_or(Semantic::Ink)),
1117        )];
1118        if self.required {
1119            row.push(util::label(
1120                cx,
1121                "*",
1122                TextStyle::Callout,
1123                Some(Weight::SEMIBOLD),
1124                theme.color(Semantic::Danger),
1125            ));
1126        }
1127        let row = cx.container(
1128            LayoutStyle { spacing: 3.0, ..LayoutStyle::stack(Axis::Horizontal) },
1129            &row,
1130            Paint::None,
1131        );
1132        let mut children = vec![row];
1133        if let Some(h) = &self.hint {
1134            children.push(util::label(cx, h, TextStyle::Caption, None, theme.color(Semantic::InkMuted)));
1135        }
1136        cx.container(
1137            LayoutStyle { spacing: 2.0, ..LayoutStyle::stack(Axis::Vertical) },
1138            &children,
1139            Paint::None,
1140        )
1141    }
1142}
1143
1144/// Keeps its content at a width-to-height ratio while filling the width.
1145#[derive(Debug)]
1146pub struct AspectRatio<V> {
1147    ratio: f32,
1148    content: V,
1149}
1150
1151#[cfg_attr(feature = "live", ravenrs_live::expose)]
1152impl<V: View> AspectRatio<V> {
1153    /// Sizes `content` to `ratio` = width / height (e.g. `16.0 / 9.0`); at least 0.01.
1154    pub fn new(ratio: f32, content: V) -> Self {
1155        Self { ratio: ratio.max(0.01), content }
1156    }
1157}
1158
1159impl<V: View> View for AspectRatio<V> {
1160    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1161        let child = cx.child(&self.content);
1162        cx.container(
1163            LayoutStyle {
1164                aspect_ratio: Some(self.ratio),
1165                align_self: Some(Align::Stretch),
1166                overflow_hidden: true,
1167                // A one-cell grid stretches the content over both axes, so any
1168                // view (an image, a styled box) fills the ratio box.
1169                grid_columns: Some(1),
1170                ..LayoutStyle::stack(Axis::Vertical)
1171            },
1172            &[child],
1173            Paint::None,
1174        )
1175    }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::page_window;
1181
1182    #[test]
1183    fn page_window_keeps_ends_and_neighbors() {
1184        let show = |c, n| {
1185            page_window(c, n)
1186                .iter()
1187                .map(|p| p.map_or("…".into(), |p: usize| (p + 1).to_string()))
1188                .collect::<Vec<String>>()
1189                .join(" ")
1190        };
1191        assert_eq!(show(0, 5), "1 2 3 4 5");
1192        assert_eq!(show(0, 20), "1 2 3 4 5 … 20");
1193        assert_eq!(show(9, 20), "1 … 9 10 11 … 20");
1194        assert_eq!(show(19, 20), "1 … 16 17 18 19 20");
1195    }
1196}