Skip to main content

ravenrs_widgets/
extras.rs

1//! Spinner, carousel, splitter, menubar, one-time-code input and form fields.
2
3use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Effect, Insets, Key, Quad, Rect, Role};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle, Weight};
11
12use crate::context::{BuildCx, DragPhase, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::overlay::{Entry, MenuList};
14use crate::{AnyView, ButtonSize, ButtonStyle, IconButton, IconName, MenuItem, Motion, View, util};
15
16/// An indeterminate activity indicator. With reduced motion it is static.
17#[derive(Clone, Copy, Debug)]
18#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
19pub struct Spinner {
20    size: f32,
21    color: Option<Color>,
22}
23
24impl Default for Spinner {
25    fn default() -> Self {
26        Self { size: 20.0, color: None }
27    }
28}
29
30#[cfg_attr(feature = "live", ravenrs_live::expose)]
31impl Spinner {
32    /// A 20 px spinner.
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Diameter in logical pixels (default 20).
38    pub fn size(mut self, size: f32) -> Self {
39        self.size = size;
40        self
41    }
42
43    /// Sets the color; by default it comes from the theme accent, which disappears
44    /// on an accent fill.
45    pub fn color(mut self, color: Color) -> Self {
46        self.color = Some(color);
47        self
48    }
49}
50
51/// Paints a spinner of `size` at the center of the element's frame. Being only a paint, it
52/// can sit behind content that must keep its size, as in a loading button.
53pub(crate) fn spinner_paint(size: f32, color: Color) -> Paint {
54    Paint::Live(Rc::new(move |f: Rect, time, scene| {
55        let c = f.center();
56        let dot = (size * 0.18).max(2.0);
57        let r = size / 2.0 - dot / 2.0;
58        // One turn every 0.8 s; the brightness moves continuously around the ring.
59        let head = (time / 0.8).fract() as f32 * 8.0;
60        for i in 0..8 {
61            let a = i as f32 / 8.0 * std::f32::consts::TAU;
62            let age = (i as f32 - head).rem_euclid(8.0);
63            let alpha = 1.0 - age / 9.0;
64            let rect = Rect::new(c.x + r * a.cos() - dot / 2.0, c.y + r * a.sin() - dot / 2.0, dot, dot);
65            scene.push_quad(Quad::new(rect, color.with_alpha(color.a * alpha)).radius(dot / 2.0));
66        }
67    }))
68}
69
70impl View for Spinner {
71    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
72        let theme = *cx.theme();
73        let size = self.size;
74        let color =
75            self.color.unwrap_or_else(|| theme.color(Semantic::Accent).lerp(theme.color(Semantic::Ink), 0.2));
76        let node = cx.container(
77            LayoutStyle { width: Some(size), height: Some(size), ..LayoutStyle::leaf() },
78            &[],
79            spinner_paint(size, color),
80        );
81        let name = cx.labels().loading.clone();
82        cx.semantics(node, Semantics::new(Role::ProgressIndicator).name(name));
83        node
84    }
85}
86
87/// Slides with previous/next buttons, position dots and optional autoplay.
88#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
89pub struct Carousel {
90    slides: Vec<AnyView>,
91    height: f32,
92    autoplay: Option<f64>,
93}
94
95impl fmt::Debug for Carousel {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.debug_struct("Carousel").field("slides", &self.slides.len()).finish_non_exhaustive()
98    }
99}
100
101#[cfg_attr(feature = "live", ravenrs_live::expose)]
102impl Carousel {
103    /// A carousel over `slides`, starting at the first.
104    pub fn new(slides: Vec<AnyView>) -> Self {
105        Self { slides, height: 240.0, autoplay: None }
106    }
107
108    /// Slide area height in logical pixels (default 240).
109    pub fn height(mut self, height: f32) -> Self {
110        self.height = height;
111        self
112    }
113
114    /// Advances every `seconds`.
115    pub fn autoplay(mut self, seconds: f64) -> Self {
116        self.autoplay = Some(seconds.max(0.5));
117        self
118    }
119}
120
121struct Slide<'a>(&'a dyn View, f32);
122
123impl View for Slide<'_> {
124    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
125        let m = cx.theme().motion;
126        let child = cx.child(self.0);
127        let node = cx.container(
128            LayoutStyle {
129                grow: 1.0,
130                align: Align::Stretch,
131                justify: Justify::Center,
132                ..LayoutStyle::stack(Axis::Vertical)
133            },
134            &[child],
135            Paint::None,
136        );
137        cx.motion(
138            node,
139            Motion::new()
140                .enter(Effect::moved(32.0 * self.1, 0.0).with_opacity(0.0), m.snappy)
141                .exit(Effect::hidden(), m.fast),
142        );
143        node
144    }
145}
146
147impl View for Carousel {
148    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
149        let theme = *cx.theme();
150        let n = self.slides.len().max(1);
151        let index: State<usize> = cx.use_state(|| 0);
152        let since: State<f64> = cx.use_state(|| f64::NAN);
153        let now = cx.now();
154        if let Some(every) = self.autoplay {
155            let start = *since.peek();
156            if start.is_nan() {
157                since.set_if_changed(now);
158            } else if now - start >= every {
159                index.update(|i| *i = (*i + 1) % n);
160                since.set(now);
161            }
162            cx.wake_at(if start.is_nan() { now + every } else { start + every });
163        }
164        let current = index.get() % n;
165        // Slides arrive from the side the user is heading to.
166        let shown = cx.use_state(|| current);
167        let direction = cx.use_state(|| 1.0_f32);
168        let previous = *shown.peek();
169        if previous != current {
170            let forward = (current + n - previous) % n <= n / 2;
171            direction.set_if_changed(if forward { 1.0 } else { -1.0 });
172            shown.set_if_changed(current);
173        }
174        let heading = *direction.peek();
175        let slide = match self.slides.get(current) {
176            Some(s) => cx.keyed_child(current as u64, &Slide(s.as_ref(), heading)),
177            None => cx.empty(),
178        };
179        let stage = cx.container(
180            LayoutStyle {
181                height: Some(self.height),
182                align: Align::Stretch,
183                justify: Justify::Center,
184                overflow_hidden: true,
185                align_self: Some(Align::Stretch),
186                ..LayoutStyle::stack(Axis::Vertical)
187            },
188            &[slide],
189            Paint::Quad(QuadPaint {
190                corner_radius: theme.radii.lg,
191                ..QuadPaint::new(theme.color(Semantic::Control))
192            }),
193        );
194        cx.clip(stage);
195        let labels = cx.labels();
196        let (prev, next, s1, s2) = (index.clone(), index.clone(), since.clone(), since.clone());
197        let back = cx.child(
198            &IconButton::new(IconName::ChevronLeft)
199                .label(cx.labels().carousel_previous.clone())
200                .style(ButtonStyle::Secondary)
201                .size(ButtonSize::Small)
202                .on_click(move || {
203                    prev.update(|i| *i = (*i + n - 1) % n);
204                    s1.set(f64::NAN);
205                }),
206        );
207        let forward = cx.child(
208            &IconButton::new(IconName::ChevronRight)
209                .label(cx.labels().carousel_next.clone())
210                .style(ButtonStyle::Secondary)
211                .size(ButtonSize::Small)
212                .on_click(move || {
213                    next.update(|i| *i = (*i + 1) % n);
214                    s2.set(f64::NAN);
215                }),
216        );
217        let mut dots = Vec::new();
218        for i in 0..n {
219            let on = i == current;
220            let color = if on {
221                theme.color(Semantic::Accent)
222            } else {
223                theme.color(Semantic::InkMuted).with_alpha(0.4)
224            };
225            let dot = cx.container(
226                LayoutStyle {
227                    width: Some(if on { 20.0 } else { 8.0 }),
228                    height: Some(8.0),
229                    ..LayoutStyle::leaf()
230                },
231                &[],
232                Paint::Quad(QuadPaint { corner_radius: 4.0, ..QuadPaint::new(color) }),
233            );
234            cx.semantics(
235                dot,
236                Semantics::new(Role::Button)
237                    .name(format!("{} {} {} {n}", labels.carousel_slide, i + 1, labels.grid_of))
238                    .selected(on),
239            );
240            let idx = index.clone();
241            cx.on_click(dot, Rc::new(move || idx.set(i)));
242            cx.cursor(dot, CursorIcon::Pointer);
243            cx.motion(dot, Motion::new().key(i as u64).layout(theme.motion.snappy).paint(theme.motion.fast));
244            dots.push(dot);
245        }
246        let dots = cx.container(
247            LayoutStyle {
248                spacing: 6.0,
249                align: Align::Center,
250                grow: 1.0,
251                justify: Justify::Center,
252                ..LayoutStyle::stack(Axis::Horizontal)
253            },
254            &dots,
255            Paint::None,
256        );
257        let controls = cx.container(
258            LayoutStyle {
259                align: Align::Center,
260                align_self: Some(Align::Stretch),
261                ..LayoutStyle::stack(Axis::Horizontal)
262            },
263            &[back, dots, forward],
264            Paint::None,
265        );
266        cx.container(
267            LayoutStyle {
268                spacing: 12.0,
269                align: Align::Stretch,
270                align_self: Some(Align::Stretch),
271                ..LayoutStyle::stack(Axis::Vertical)
272            },
273            &[stage, controls],
274            Paint::None,
275        )
276    }
277}
278
279/// How far an arrow key moves the bar between panes, in logical pixels.
280const SPLIT_STEP: f32 = 16.0;
281/// How wide the bar between panes is, grip and room around it, in logical pixels.
282pub(crate) const SPLIT_BAR: f32 = 10.0;
283
284/// The line and the grip of the bar between panes.
285fn split_bar_paint(vertical_stack: bool, line: Color, grip: Color) -> Paint {
286    Paint::Custom(Rc::new(move |f: Rect, scene| {
287        let c = f.center();
288        if vertical_stack {
289            scene.push_quad(Quad::new(Rect::new(f.x(), c.y - 0.5, f.width(), 1.0), line));
290            scene.push_quad(Quad::new(Rect::new(c.x - 12.0, c.y - 2.0, 24.0, 4.0), grip).radius(2.0));
291        } else {
292            scene.push_quad(Quad::new(Rect::new(c.x - 0.5, f.y(), 1.0, f.height()), line));
293            scene.push_quad(Quad::new(Rect::new(c.x - 2.0, c.y - 12.0, 4.0, 24.0), grip).radius(2.0));
294        }
295    }))
296}
297
298/// What an arrow key does to the bar between panes: it moves the way the arrow points.
299/// Across a stack, Up and Down still work, as the keys assistive technology sends.
300fn split_arrow(key: &Key, vertical_stack: bool) -> Option<f32> {
301    match (key, vertical_stack) {
302        (Key::ArrowUp, true) | (Key::ArrowLeft | Key::ArrowDown, false) => Some(-1.0),
303        (Key::ArrowDown, true) | (Key::ArrowRight | Key::ArrowUp, false) => Some(1.0),
304        _ => None,
305    }
306}
307
308/// A bar to drag that changes a size, for a pane an app lays out itself, such as a strip
309/// along the bottom of a window. Arrow keys move it too, Shift goes further, and Home and
310/// End go to the limits. [`SplitPanes`] is built on it.
311///
312/// ```
313/// use ravenrs_layout::Axis;
314/// use ravenrs_reactive::State;
315/// use ravenrs_widgets::{SplitHandle, Ui};
316///
317/// let height = State::new(120.0_f32);
318/// // The strip is below the bar, so it grows as the bar goes up.
319/// let bar = SplitHandle::new(Axis::Vertical, height.binding()).range(80.0, 300.0).inverted();
320/// let mut ui = Ui::new(bar, ravenrs_style::Theme::raven_dark());
321/// ui.update();
322/// ```
323#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
324pub struct SplitHandle {
325    axis: Axis,
326    value: Binding<f32>,
327    min: f32,
328    max: f32,
329    inverted: bool,
330    label: Option<String>,
331    on_end: Option<Rc<dyn Fn()>>,
332}
333
334impl fmt::Debug for SplitHandle {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        f.debug_struct("SplitHandle").field("axis", &self.axis).finish_non_exhaustive()
337    }
338}
339
340#[cfg_attr(feature = "live", ravenrs_live::expose)]
341impl SplitHandle {
342    /// A bar between things laid out along `axis`, which writes the size it controls to
343    /// `value`. It lies across that axis: along `Axis::Horizontal`, it stands upright.
344    pub fn new(axis: Axis, value: Binding<f32>) -> Self {
345        Self { axis, value, min: 0.0, max: f32::MAX, inverted: false, label: None, on_end: None }
346    }
347
348    /// The smallest and the largest the size can get.
349    pub fn range(mut self, min: f32, max: f32) -> Self {
350        self.min = min;
351        self.max = max.max(min);
352        self
353    }
354
355    /// The size grows as the bar moves left or up, for a pane after the bar.
356    pub fn inverted(mut self) -> Self {
357        self.inverted = true;
358        self
359    }
360
361    /// Its name for screen readers (from [`Labels`](crate::Labels) by default).
362    pub fn label(mut self, label: impl Into<String>) -> Self {
363        self.label = Some(label.into());
364        self
365    }
366
367    /// Runs when a drag ends or a key moved it, the moment to save the size.
368    pub fn on_end(mut self, handler: impl Fn() + 'static) -> Self {
369        self.on_end = Some(Rc::new(handler));
370        self
371    }
372}
373
374impl View for SplitHandle {
375    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
376        let theme = *cx.theme();
377        let vertical = self.axis == Axis::Vertical;
378        let lit = cx.is_hovered() || cx.is_focus_visible();
379        let (line, grip) = (
380            theme.color(Semantic::Border),
381            theme.color(if lit { Semantic::InkMuted } else { Semantic::Border }),
382        );
383        let style = if vertical {
384            LayoutStyle { height: Some(SPLIT_BAR), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
385        } else {
386            LayoutStyle { width: Some(SPLIT_BAR), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
387        };
388        let node = cx.container(style, &[], split_bar_paint(vertical, line, grip));
389        let (min, max, sign) = (self.min, self.max, if self.inverted { -1.0 } else { 1.0 });
390        let name = self.label.clone().unwrap_or_else(|| cx.labels().split_resize.clone());
391        let step = Some(f64::from(SPLIT_STEP));
392        let current = self.value.get();
393        // There is no separator role with a value in the tree yet; a slider says what this does.
394        cx.semantics(
395            node,
396            Semantics::new(Role::Slider).name(name).numeric(
397                f64::from(current),
398                f64::from(min),
399                f64::from(max),
400                step,
401            ),
402        );
403        let start = cx.use_state(|| Rc::new(Cell::new((0.0_f32, 0.0_f32)))).get();
404        let (value, on_end) = (self.value.clone(), self.on_end.clone());
405        cx.on_drag(
406            node,
407            Rc::new(move |d| {
408                let position = if vertical { d.position.y } else { d.position.x };
409                match d.phase {
410                    DragPhase::Start => start.set((position, value.get())),
411                    DragPhase::Move => {
412                        let (from, base) = start.get();
413                        value.set((base + sign * (position - from)).clamp(min, max));
414                    }
415                    DragPhase::End => {
416                        if let Some(on_end) = &on_end {
417                            on_end();
418                        }
419                    }
420                }
421            }),
422        );
423        let (value, on_end) = (self.value.clone(), self.on_end.clone());
424        cx.on_key(
425            node,
426            Rc::new(move |key, modifiers| {
427                let next = match key {
428                    Key::Home => min,
429                    Key::End => max,
430                    _ => {
431                        let Some(arrow) = split_arrow(key, vertical) else { return false };
432                        let far = if modifiers.shift { 4.0 } else { 1.0 };
433                        (value.get() + sign * arrow * far * SPLIT_STEP).clamp(min, max)
434                    }
435                };
436                value.set(next);
437                if let Some(on_end) = &on_end {
438                    on_end();
439                }
440                true
441            }),
442        );
443        cx.focusable(node, 2.0);
444        cx.cursor(node, if vertical { CursorIcon::RowResize } else { CursorIcon::ColResize });
445        node
446    }
447}
448
449/// One pane of a [`SplitPanes`].
450#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
451pub struct SplitPane {
452    content: AnyView,
453    size: Option<f32>,
454    min: f32,
455    max: f32,
456    visible: bool,
457}
458
459impl fmt::Debug for SplitPane {
460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461        f.debug_struct("SplitPane").field("size", &self.size).finish_non_exhaustive()
462    }
463}
464
465#[cfg_attr(feature = "live", ravenrs_live::expose)]
466impl SplitPane {
467    /// A pane around `content`. Without [`Self::size`], it takes the room the others leave.
468    pub fn new(content: impl View + 'static) -> Self {
469        Self { content: Box::new(content), size: None, min: 0.0, max: f32::MAX, visible: true }
470    }
471
472    /// Its size to start with along the stack, in logical pixels.
473    pub fn size(mut self, size: f32) -> Self {
474        self.size = Some(size);
475        self
476    }
477
478    /// The smallest and the largest it can get, in logical pixels. A narrow window takes
479    /// room from the panes down to their smallest.
480    pub fn range(mut self, min: f32, max: f32) -> Self {
481        self.min = min;
482        self.max = max.max(min);
483        self
484    }
485
486    /// Whether it is showing (it is by default). A hidden pane keeps its size for later,
487    /// and takes its bar with it.
488    pub fn visible(mut self, visible: bool) -> Self {
489        self.visible = visible;
490        self
491    }
492}
493
494/// The sizes the visible panes get in `room`: what they ask for within their limits, with
495/// the free room going to those that do not ask, and any lack taken from the last ones.
496fn split_sizes(room: f32, asked: &[Option<f32>], limits: &[(f32, f32)]) -> Vec<f32> {
497    let mut sizes: Vec<f32> =
498        asked.iter().zip(limits).map(|(size, (min, max))| size.unwrap_or(*min).clamp(*min, *max)).collect();
499    let free = room - asked.iter().zip(&sizes).filter(|(a, _)| a.is_some()).map(|(_, s)| *s).sum::<f32>();
500    let flexible = asked.iter().filter(|a| a.is_none()).count();
501    if flexible > 0 {
502        let share = (free / flexible as f32).max(0.0);
503        for ((size, ask), (min, max)) in sizes.iter_mut().zip(asked).zip(limits) {
504            if ask.is_none() {
505                *size = share.clamp(*min, *max);
506            }
507        }
508    }
509    let mut over = sizes.iter().sum::<f32>() - room;
510    for (size, (min, _)) in sizes.iter_mut().zip(limits).rev() {
511        if over <= 0.0 {
512            break;
513        }
514        let taken = (*size - min).min(over).max(0.0);
515        *size -= taken;
516        over -= taken;
517    }
518    sizes
519}
520
521/// Any number of panes side by side or stacked, with a bar to drag between each two.
522/// Panes can have limits, and the sizes can be kept by the app to remember them.
523///
524/// ```
525/// use ravenrs_widgets::{SplitPane, SplitPanes, Text, Ui};
526///
527/// let columns = SplitPanes::horizontal()
528///     .pane(SplitPane::new(Text::new("Sessions")).size(280.0).range(200.0, 400.0))
529///     .pane(SplitPane::new(Text::new("Receipt")).range(320.0, f32::MAX))
530///     .pane(SplitPane::new(Text::new("Activity")).size(300.0).range(240.0, 420.0))
531///     .height(400.0);
532/// let mut ui = Ui::new(columns, ravenrs_style::Theme::raven_dark());
533/// ui.update();
534/// ```
535#[derive(Debug)]
536#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
537pub struct SplitPanes {
538    axis: Axis,
539    panes: Vec<SplitPane>,
540    sizes: Option<Binding<Vec<Option<f32>>>>,
541    height: Option<f32>,
542    on_end: Option<SplitEnd>,
543}
544
545/// Runs when a bar stops moving.
546struct SplitEnd(Rc<dyn Fn()>);
547
548impl fmt::Debug for SplitEnd {
549    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
550        f.write_str("SplitEnd")
551    }
552}
553
554#[cfg_attr(feature = "live", ravenrs_live::expose)]
555impl SplitPanes {
556    /// No panes yet, to go side by side.
557    pub fn horizontal() -> Self {
558        Self { axis: Axis::Horizontal, panes: Vec::new(), sizes: None, height: None, on_end: None }
559    }
560
561    /// No panes yet, to stack.
562    pub fn vertical() -> Self {
563        Self { axis: Axis::Vertical, ..Self::horizontal() }
564    }
565
566    /// Adds a pane.
567    pub fn pane(mut self, pane: SplitPane) -> Self {
568        self.panes.push(pane);
569        self
570    }
571
572    /// Keeps the sizes here, one for each pane, hidden ones included; `None` is a pane
573    /// that takes the room left. What is missing comes from [`SplitPane::size`].
574    pub fn sizes(mut self, sizes: Binding<Vec<Option<f32>>>) -> Self {
575        self.sizes = Some(sizes);
576        self
577    }
578
579    /// Runs when a drag ends or a key moved a bar, the moment to save the sizes.
580    pub fn on_end(mut self, handler: impl Fn() + 'static) -> Self {
581        self.on_end = Some(SplitEnd(Rc::new(handler)));
582        self
583    }
584
585    /// Its height, in logical pixels. Without it, it takes the room its parent has.
586    pub fn height(mut self, height: f32) -> Self {
587        self.height = Some(height);
588        self
589    }
590}
591
592impl View for SplitPanes {
593    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
594        let vertical = self.axis == Axis::Vertical;
595        let own: State<Vec<Option<f32>>> = cx.use_state(Vec::new);
596        let store = self.sizes.clone().unwrap_or_else(|| own.binding());
597        let stored = store.get();
598        let asked_of = |i: usize, pane: &SplitPane| stored.get(i).copied().unwrap_or(pane.size);
599        let shown: Vec<usize> =
600            self.panes.iter().enumerate().filter(|(_, p)| p.visible).map(|(i, _)| i).collect();
601        let asked: Vec<Option<f32>> = shown.iter().map(|&i| asked_of(i, &self.panes[i])).collect();
602        let limits: Vec<(f32, f32)> = shown.iter().map(|&i| (self.panes[i].min, self.panes[i].max)).collect();
603        // Known from the second build on, when the bars need it.
604        let room = cx.scroll_viewport().map(|v| {
605            (if vertical { v.height } else { v.width }) - shown.len().saturating_sub(1) as f32 * SPLIT_BAR
606        });
607        let sizes = room.map(|room| split_sizes(room, &asked, &limits));
608        let mut children = Vec::with_capacity(shown.len() * 2);
609        for (at, &index) in shown.iter().enumerate() {
610            if at > 0 {
611                let (a, b) = (at - 1, at);
612                let (size_a, size_b) = sizes.as_ref().map_or_else(
613                    || (asked[a].unwrap_or(limits[a].0), asked[b].unwrap_or(limits[b].0)),
614                    |s| (s[a], s[b]),
615                );
616                let both = size_a + size_b;
617                let min = limits[a].0.max(both - limits[b].1);
618                let max = limits[a].1.min(both - limits[b].0).max(min);
619                let write = store.clone();
620                let panes = (shown[a], shown[b]);
621                let fixed = (asked[a].is_some(), asked[b].is_some());
622                let defaults: Vec<Option<f32>> = self.panes.iter().map(|p| p.size).collect();
623                let value = Binding::new(
624                    move || size_a,
625                    move |next: f32| {
626                        let mut all = write.get();
627                        if all.len() < defaults.len() {
628                            let have = all.len();
629                            all.extend_from_slice(&defaults[have..]);
630                        }
631                        // A pane that takes what is left goes on doing so, unless both do.
632                        if fixed.0 || !fixed.1 {
633                            all[panes.0] = Some(next);
634                        }
635                        if fixed.1 {
636                            all[panes.1] = Some(both - next);
637                        }
638                        write.set(all);
639                    },
640                );
641                let mut bar = SplitHandle::new(self.axis, value).range(min, max);
642                if let Some(end) = &self.on_end {
643                    let end = end.0.clone();
644                    bar = bar.on_end(move || end());
645                }
646                children.push(cx.keyed_child(u64::MAX - index as u64, &bar));
647            }
648            let pane = &self.panes[index];
649            let content = cx.keyed_child(index as u64, pane.content.as_ref());
650            // What takes the room left grows; the others hold their size.
651            let size = asked[at].map(|asked| sizes.as_ref().map_or(asked, |s| s[at]));
652            let node = cx.container(
653                LayoutStyle {
654                    grow: if size.is_some() { 0.0 } else { 1.0 },
655                    width: if vertical { None } else { size.or(Some(0.0)) },
656                    height: if vertical { size.or(Some(0.0)) } else { None },
657                    min_width: Some(0.0),
658                    min_height: Some(0.0),
659                    align: Align::Stretch,
660                    overflow_hidden: true,
661                    ..LayoutStyle::stack(Axis::Vertical)
662                },
663                &[content],
664                Paint::None,
665            );
666            cx.clip(node);
667            children.push(node);
668        }
669        let node = cx.container(
670            LayoutStyle {
671                height: self.height,
672                grow: if self.height.is_none() { 1.0 } else { 0.0 },
673                min_width: Some(0.0),
674                min_height: Some(0.0),
675                align: Align::Stretch,
676                align_self: Some(Align::Stretch),
677                overflow_hidden: true,
678                ..LayoutStyle::stack(self.axis)
679            },
680            &children,
681            Paint::None,
682        );
683        cx.report_frame(node);
684        node
685    }
686}
687
688/// Two panes with a draggable divider between them. Arrow keys move the divider too. For
689/// more panes, or limits in logical pixels, see [`SplitPanes`].
690///
691/// ```
692/// use ravenrs_widgets::{Splitter, Text, Ui};
693///
694/// let split = Splitter::horizontal(Text::new("Files"), Text::new("Editor"))
695///     .ratio(0.3)
696///     .height(400.0);
697/// let mut ui = Ui::new(split, ravenrs_style::Theme::raven_dark());
698/// ui.update();
699/// ```
700pub struct Splitter<A, B> {
701    first: A,
702    second: B,
703    vertical: bool,
704    initial: f32,
705    height: f32,
706    limits: (f32, f32),
707}
708
709impl<A, B> fmt::Debug for Splitter<A, B> {
710    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
711        f.debug_struct("Splitter").field("vertical", &self.vertical).finish_non_exhaustive()
712    }
713}
714
715#[cfg_attr(feature = "live", ravenrs_live::expose)]
716impl<A: View, B: View> Splitter<A, B> {
717    /// Side-by-side panes.
718    pub fn horizontal(first: A, second: B) -> Self {
719        Self { first, second, vertical: false, initial: 0.5, height: 260.0, limits: (0.1, 0.9) }
720    }
721
722    /// Stacked panes.
723    pub fn vertical(first: A, second: B) -> Self {
724        Self { first, second, vertical: true, initial: 0.5, height: 260.0, limits: (0.1, 0.9) }
725    }
726
727    /// The first pane's initial share, within the limits (0.1 to 0.9 by default).
728    pub fn ratio(mut self, ratio: f32) -> Self {
729        self.initial = ratio;
730        self
731    }
732
733    /// The smallest and the largest share of the first pane (0.1 and 0.9 by default).
734    pub fn limits(mut self, min: f32, max: f32) -> Self {
735        let min = min.clamp(0.0, 1.0);
736        self.limits = (min, max.clamp(min, 1.0));
737        self
738    }
739
740    /// Total height in logical pixels (default 260).
741    pub fn height(mut self, height: f32) -> Self {
742        self.height = height;
743        self
744    }
745}
746
747impl<A: View, B: View> View for Splitter<A, B> {
748    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
749        let theme = *cx.theme();
750        let (min, max) = self.limits;
751        let initial = self.initial.clamp(min, max);
752        let ratio: State<f32> = cx.use_state(move || initial);
753        let r = ratio.get();
754        let pane = |grow: f32| LayoutStyle {
755            grow,
756            width: (!self.vertical).then_some(0.0),
757            height: self.vertical.then_some(0.0),
758            min_width: Some(0.0),
759            min_height: Some(0.0),
760            align: Align::Stretch,
761            overflow_hidden: true,
762            ..LayoutStyle::stack(Axis::Vertical)
763        };
764        let a = cx.child(&self.first);
765        let a = cx.container(pane(r), &[a], Paint::None);
766        cx.clip(a);
767        let b = cx.child(&self.second);
768        let b = cx.container(pane(1.0 - r), &[b], Paint::None);
769        cx.clip(b);
770        let vertical = self.vertical;
771        let (line, grip) = (theme.color(Semantic::Border), theme.color(Semantic::InkMuted));
772        let lit = cx.is_hover_within() || cx.is_focus_visible();
773        let handle_style = if vertical {
774            LayoutStyle { height: Some(SPLIT_BAR), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
775        } else {
776            LayoutStyle { width: Some(SPLIT_BAR), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
777        };
778        let handle =
779            cx.container(handle_style, &[], split_bar_paint(vertical, line, if lit { grip } else { line }));
780        let drag = ratio.clone();
781        cx.on_drag(
782            handle,
783            Rc::new(move |d| {
784                if d.phase == DragPhase::End {
785                    return;
786                }
787                let (pos, start, len) = if vertical {
788                    (d.position.y, d.container.y(), d.container.height())
789                } else {
790                    (d.position.x, d.container.x(), d.container.width())
791                };
792                if len > SPLIT_BAR {
793                    drag.set(((pos - start - SPLIT_BAR / 2.0) / (len - SPLIT_BAR)).clamp(min, max));
794                }
795            }),
796        );
797        let name = cx.labels().split_resize.clone();
798        cx.semantics(
799            handle,
800            Semantics::new(Role::Slider).name(name).numeric(
801                f64::from(r),
802                f64::from(min),
803                f64::from(max),
804                Some(0.02),
805            ),
806        );
807        let keys = ratio.clone();
808        cx.on_key(
809            handle,
810            Rc::new(move |key, modifiers| {
811                let next = match key {
812                    Key::Home => min,
813                    Key::End => max,
814                    _ => {
815                        let Some(arrow) = split_arrow(key, vertical) else { return false };
816                        let far = if modifiers.shift { 0.08 } else { 0.02 };
817                        (*keys.peek() + arrow * far).clamp(min, max)
818                    }
819                };
820                keys.set(next);
821                true
822            }),
823        );
824        cx.focusable(handle, 2.0);
825        cx.cursor(handle, if vertical { CursorIcon::RowResize } else { CursorIcon::ColResize });
826        let node = cx.container(
827            LayoutStyle {
828                height: Some(self.height),
829                align: Align::Stretch,
830                align_self: Some(Align::Stretch),
831                ..LayoutStyle::stack(if vertical { Axis::Vertical } else { Axis::Horizontal })
832            },
833            &[a, handle, b],
834            Paint::Quad(QuadPaint {
835                corner_radius: theme.radii.lg,
836                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
837                ..QuadPaint::new(theme.color(Semantic::Surface))
838            }),
839        );
840        cx.clip(node);
841        node
842    }
843}
844
845/// A horizontal bar of menus (File, Edit, View…).
846#[derive(Default)]
847#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
848pub struct Menubar {
849    menus: Vec<(String, Vec<Entry>)>,
850}
851
852impl fmt::Debug for Menubar {
853    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
854        f.debug_list().entries(self.menus.iter().map(|m| &m.0)).finish()
855    }
856}
857
858#[cfg_attr(feature = "live", ravenrs_live::expose)]
859impl Menubar {
860    /// An empty menubar; add menus with [`Menubar::menu`].
861    pub fn new() -> Self {
862        Self::default()
863    }
864
865    /// Starts a new menu; following `item`/`separator` calls add to it.
866    pub fn menu(mut self, title: impl Into<String>) -> Self {
867        self.menus.push((title.into(), Vec::new()));
868        self
869    }
870
871    /// Adds an item to the current menu (ignored before the first `menu`).
872    pub fn item(mut self, item: MenuItem) -> Self {
873        if let Some((_, e)) = self.menus.last_mut() {
874            e.push(Entry::Item(item));
875        }
876        self
877    }
878
879    /// Adds a separator to the current menu (ignored before the first `menu`).
880    pub fn separator(mut self) -> Self {
881        if let Some((_, e)) = self.menus.last_mut() {
882            e.push(Entry::Separator);
883        }
884        self
885    }
886}
887
888struct MenuTitle<'a> {
889    bar: &'a Menubar,
890    open: State<Option<usize>>,
891    roving: util::Roving,
892}
893
894impl View for MenuTitle<'_> {
895    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
896        let theme = *cx.theme();
897        let (i, count) = (self.roving.index, self.roving.count);
898        let is_open = self.open.get() == Some(i);
899        let hovered = cx.is_hovered();
900        let ink = theme.color(Semantic::Ink);
901        let label = util::label(cx, &self.bar.menus[i].0, TextStyle::Callout, Some(Weight::SEMIBOLD), ink);
902        let fill = if is_open {
903            theme.color(Semantic::Control)
904        } else if hovered {
905            ink.with_alpha(0.06)
906        } else {
907            Color::TRANSPARENT
908        };
909        let node = cx.container(
910            LayoutStyle {
911                height: Some(30.0),
912                padding: Insets::symmetric(0.0, 10.0),
913                align: Align::Center,
914                ..LayoutStyle::stack(Axis::Horizontal)
915            },
916            &[label],
917            Paint::Quad(QuadPaint { corner_radius: 6.0, ..QuadPaint::new(fill) }),
918        );
919        cx.semantics(node, Semantics::new(Role::MenuItem).expanded(is_open));
920        let open = self.open.clone();
921        cx.on_click(node, Rc::new(move || open.update(|o| *o = if *o == Some(i) { None } else { Some(i) })));
922        cx.cursor(node, CursorIcon::Pointer);
923        cx.focusable(node, 6.0);
924        // WAI-ARIA menubar: Left and Right move between titles (switching the
925        // open menu if one is open); Down, Enter and Space open the menu.
926        let opener = self.open.clone();
927        let before: crate::KeyHandler = Rc::new(move |key, _| {
928            let opens = matches!(key, Key::ArrowDown | Key::Enter | Key::Space);
929            if opens {
930                opener.set(Some(i));
931            }
932            opens || *key == Key::ArrowUp
933        });
934        let follow = self.open.clone();
935        let on_move: Rc<dyn Fn(usize)> = Rc::new(move |to| {
936            if follow.get().is_some() {
937                follow.set(Some(to));
938            }
939        });
940        self.roving.wire_with(cx, node, Some(on_move), Some(before));
941        let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
942        if is_open {
943            was_open.set(true);
944            let close: Rc<dyn Fn()> = {
945                let open = self.open.clone();
946                Rc::new(move || open.set(None))
947            };
948            // The neighbor menu opens and focuses its first item.
949            let side_open = self.open.clone();
950            let on_side: Rc<dyn Fn(bool)> = Rc::new(move |forward| {
951                side_open.set(Some(if forward { (i + 1) % count } else { (i + count - 1) % count }));
952            });
953            cx.overlay(
954                node,
955                Placement::Below,
956                &MenuList { entries: &self.bar.menus[i].1, close: close.clone(), on_side: Some(on_side) },
957                Some(close),
958            );
959        } else if was_open.replace(false) && self.open.get().is_none() {
960            // Focus returns to the title when the menubar's menu closes.
961            cx.request_focus_node(node);
962        }
963        node
964    }
965}
966
967impl View for Menubar {
968    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
969        let theme = *cx.theme();
970        let open: State<Option<usize>> = cx.use_state(|| None);
971        let (count, focus_to) = (self.menus.len(), util::roving_cell(cx, None));
972        let titles: Vec<NodeId> = (0..count)
973            .map(|index| {
974                let roving = util::Roving { index, count, focus_to: focus_to.clone() };
975                cx.keyed_child(index as u64, &MenuTitle { bar: self, open: open.clone(), roving })
976            })
977            .collect();
978        let node = cx.container(
979            LayoutStyle {
980                padding: Insets::uniform(4.0),
981                spacing: 2.0,
982                align_self: Some(Align::Fit),
983                ..LayoutStyle::stack(Axis::Horizontal)
984            },
985            &titles,
986            Paint::Quad(QuadPaint {
987                corner_radius: theme.radii.md,
988                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
989                ..QuadPaint::new(theme.color(Semantic::Surface))
990            }),
991        );
992        cx.semantics(node, Semantics::new(Role::MenuBar));
993        node
994    }
995}
996
997/// Separate boxes for a one-time code; accepts digits by default.
998#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
999pub struct InputOtp {
1000    value: Binding<String>,
1001    length: usize,
1002    on_complete: Option<Rc<dyn Fn(String)>>,
1003}
1004
1005impl fmt::Debug for InputOtp {
1006    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1007        f.debug_struct("InputOtp").field("length", &self.length).finish_non_exhaustive()
1008    }
1009}
1010
1011#[cfg_attr(feature = "live", ravenrs_live::expose)]
1012impl InputOtp {
1013    /// `length` boxes (clamped to 1–12) editing `value`.
1014    pub fn new(value: Binding<String>, length: usize) -> Self {
1015        Self { value, length: length.clamp(1, 12), on_complete: None }
1016    }
1017
1018    /// Called once all boxes are filled.
1019    pub fn on_complete(mut self, handler: impl Fn(String) + 'static) -> Self {
1020        self.on_complete = Some(Rc::new(handler));
1021        self
1022    }
1023}
1024
1025impl View for InputOtp {
1026    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1027        let theme = *cx.theme();
1028        let focused = cx.is_focused();
1029        let text: Vec<char> = self.value.get().chars().take(self.length).collect();
1030        let mut boxes = Vec::new();
1031        let mut attrs = util::attrs(&theme, TextStyle::Title, Some(Weight::SEMIBOLD));
1032        attrs.size = 20.0;
1033        for i in 0..self.length {
1034            let ch = text.get(i).map(|c| c.to_string()).unwrap_or_default();
1035            let label = cx.text(&ch, &attrs, theme.color(Semantic::Ink), LayoutStyle::leaf());
1036            let active = focused && i == text.len().min(self.length - 1);
1037            let border = if active {
1038                Border { width: 1.5, color: theme.color(Semantic::Accent) }
1039            } else {
1040                Border { width: 1.0, color: theme.color(Semantic::Border) }
1041            };
1042            boxes.push(cx.container(
1043                LayoutStyle {
1044                    width: Some(44.0),
1045                    height: Some(52.0),
1046                    align: Align::Center,
1047                    justify: Justify::Center,
1048                    ..LayoutStyle::stack(Axis::Horizontal)
1049                },
1050                &[label],
1051                Paint::Quad(QuadPaint {
1052                    corner_radius: theme.radii.md,
1053                    border: Some(border),
1054                    ..QuadPaint::new(theme.color(Semantic::Control))
1055                }),
1056            ));
1057        }
1058        let node = cx.container(
1059            LayoutStyle {
1060                spacing: 8.0,
1061                align_self: Some(Align::Fit),
1062                ..LayoutStyle::stack(Axis::Horizontal)
1063            },
1064            &boxes,
1065            Paint::None,
1066        );
1067        for b in &boxes {
1068            cx.semantics(*b, Semantics::hidden());
1069        }
1070        let typed: String = self.value.get().chars().take(self.length).collect();
1071        let mut semantics = Semantics::new(Role::TextInput)
1072            .name(cx.labels().verification_code.clone())
1073            .value(typed)
1074            .description(format!("{} digits", self.length));
1075        semantics.fallback_name = true;
1076        cx.semantics(node, semantics);
1077        let (value, len, done) = (self.value.clone(), self.length, self.on_complete.clone());
1078        cx.on_key(
1079            node,
1080            Rc::new(move |key, _| {
1081                let mut v: String = value.get().chars().take(len).collect();
1082                match key {
1083                    Key::Character(s) => {
1084                        for c in s.chars().filter(char::is_ascii_digit) {
1085                            if v.chars().count() < len {
1086                                v.push(c);
1087                            }
1088                        }
1089                    }
1090                    Key::Backspace => {
1091                        v.pop();
1092                    }
1093                    _ => return false,
1094                }
1095                let complete = v.chars().count() == len;
1096                value.set(v.clone());
1097                if complete && let Some(d) = &done {
1098                    d(v);
1099                }
1100                true
1101            }),
1102        );
1103        cx.focusable(node, theme.radii.md);
1104        cx.cursor(node, CursorIcon::Text);
1105        node
1106    }
1107}
1108
1109/// Common validation rules for [`FormField::validate`].
1110#[cfg_attr(feature = "live", ravenrs_live::expose)]
1111pub mod validate {
1112    /// The field must not be empty.
1113    pub fn required(value: &str) -> Result<(), String> {
1114        if value.trim().is_empty() { Err("This field is required.".into()) } else { Ok(()) }
1115    }
1116
1117    /// A plausible email address (something@something.tld).
1118    pub fn email(value: &str) -> Result<(), String> {
1119        let ok = value.split_once('@').is_some_and(|(user, domain)| {
1120            !user.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
1121        });
1122        if ok || value.is_empty() { Ok(()) } else { Err("Enter an email like name@example.com.".into()) }
1123    }
1124
1125    /// At least `n` characters.
1126    pub fn min_length(n: usize) -> impl Fn(&str) -> Result<(), String> {
1127        move |value| {
1128            if value.chars().count() >= n || value.is_empty() {
1129                Ok(())
1130            } else {
1131                Err(format!("Use at least {n} characters."))
1132            }
1133        }
1134    }
1135}
1136
1137/// A validation rule: `Ok(())` or a message for the user.
1138pub type Rule = Rc<dyn Fn(&str) -> Result<(), String>>;
1139
1140/// A labeled form field with a hint and validation messages.
1141///
1142/// Errors appear once the value is non-empty or the form was submitted
1143/// (see [`FormField::show_errors`]), so empty forms do not start out red.
1144///
1145/// ```
1146/// use ravenrs_reactive::State;
1147/// use ravenrs_widgets::{FormField, TextField, Ui, validate};
1148///
1149/// let email = State::new("raven@".to_string());
1150/// let field = FormField::new("Email", TextField::new(email.binding()))
1151///     .hint("We never share it.")
1152///     .validate(email.binding(), validate::email);
1153/// assert!(field.error().is_some());
1154///
1155/// email.set("raven@example.com".into());
1156/// assert_eq!(field.error(), None);
1157///
1158/// let mut ui = Ui::new(field, ravenrs_style::Theme::raven_dark());
1159/// ui.update();
1160/// ```
1161pub struct FormField<V> {
1162    label: crate::Label,
1163    content: V,
1164    hint: Option<String>,
1165    value: Option<Binding<String>>,
1166    rules: Vec<Rule>,
1167    show_errors: bool,
1168    required: bool,
1169    told_error: Option<String>,
1170    told_note: Option<(String, crate::Tone)>,
1171    fill: Option<Semantic>,
1172    accessible_name: Option<String>,
1173    accessible_description: Option<String>,
1174}
1175
1176impl<V> fmt::Debug for FormField<V> {
1177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1178        f.debug_struct("FormField").field("rules", &self.rules.len()).finish_non_exhaustive()
1179    }
1180}
1181
1182#[cfg_attr(feature = "live", ravenrs_live::expose)]
1183impl<V: View> FormField<V> {
1184    /// A field with this label above `content`.
1185    pub fn new(label: impl Into<String>, content: V) -> Self {
1186        Self {
1187            label: crate::Label::new(label),
1188            content,
1189            hint: None,
1190            value: None,
1191            rules: Vec::new(),
1192            show_errors: false,
1193            required: false,
1194            told_error: None,
1195            told_note: None,
1196            fill: None,
1197            accessible_name: None,
1198            accessible_description: None,
1199        }
1200    }
1201
1202    /// What screen readers call the control, instead of the label: for a label that only makes
1203    /// sense next to what is around it.
1204    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
1205        self.accessible_name = Some(name.into());
1206        self
1207    }
1208
1209    /// What screen readers say after its name, instead of the hint. An error or a note still
1210    /// comes first.
1211    pub fn accessible_description(mut self, text: impl Into<String>) -> Self {
1212        self.accessible_description = Some(text.into());
1213        self
1214    }
1215
1216    /// Marks the label with `*` and adds the [`validate::required`] rule (for the value set by [`FormField::validate`]).
1217    pub fn required(mut self) -> Self {
1218        self.label = self.label.required();
1219        self.required = true;
1220        self
1221    }
1222
1223    /// What is wrong, as something else found out, such as rules that read a database.
1224    pub fn error_text(mut self, error: Option<String>) -> Self {
1225        self.told_error = error;
1226        self
1227    }
1228
1229    /// The color of its label, instead of the usual one.
1230    pub fn label_ink(mut self, ink: Semantic) -> Self {
1231        self.label = self.label.ink(ink);
1232        self
1233    }
1234
1235    /// A faint color behind the field, to call attention to it.
1236    pub fn tint(mut self, fill: Semantic) -> Self {
1237        self.fill = Some(fill);
1238        self
1239    }
1240
1241    /// Something to know under the content, with an icon, which is not an error. An error replaces it.
1242    pub fn note_text(mut self, note: Option<String>, tone: crate::Tone) -> Self {
1243        self.told_note = note.map(|note| (note, tone));
1244        self
1245    }
1246
1247    /// A muted caption under the content.
1248    pub fn hint(mut self, hint: impl Into<String>) -> Self {
1249        self.hint = Some(hint.into());
1250        self
1251    }
1252
1253    /// Validates `value` with `rule` (rules run in order; the first error shows).
1254    pub fn validate(
1255        mut self,
1256        value: Binding<String>,
1257        rule: impl Fn(&str) -> Result<(), String> + 'static,
1258    ) -> Self {
1259        self.value = Some(value);
1260        self.rules.push(Rc::new(rule));
1261        self
1262    }
1263
1264    /// Shows errors even for empty values (e.g. after a submit attempt).
1265    pub fn show_errors(mut self, show: bool) -> Self {
1266        self.show_errors = show;
1267        self
1268    }
1269
1270    /// The first error for the current value, if any.
1271    pub fn error(&self) -> Option<String> {
1272        let v = self.value.as_ref()?.get();
1273        if self.required
1274            && let Err(e) = validate::required(&v)
1275        {
1276            return Some(e);
1277        }
1278        self.rules.iter().find_map(|r| r(&v).err())
1279    }
1280}
1281
1282impl<V: View> View for FormField<V> {
1283    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1284        let theme = *cx.theme();
1285        let label = cx.child(&self.label);
1286        let content = cx.child(&self.content);
1287        let mut children = vec![label, content];
1288        let value = self.value.as_ref().map(Binding::get).unwrap_or_default();
1289        let blank = self.required && self.value.is_some() && value.trim().is_empty();
1290        // The same check as `error`, in the words of this part of the app.
1291        let error = self.told_error.clone().or_else(|| {
1292            (self.show_errors || !value.is_empty())
1293                .then(|| if blank { Some(cx.labels().required.clone()) } else { self.error() })
1294                .flatten()
1295        });
1296        let note = self.told_note.as_ref().map(|(note, _)| note.as_str());
1297        cx.label_control(content, self.label.text(), error.as_deref().or(note).or(self.hint.as_deref()));
1298        if self.accessible_name.is_some() || self.accessible_description.is_some() {
1299            let told = error.as_deref().or(note);
1300            let description = match (told, self.accessible_description.as_deref()) {
1301                (Some(told), Some(own)) => Some(format!("{told}. {own}")),
1302                (None, Some(own)) => Some(own.to_owned()),
1303                (_, None) => None,
1304            };
1305            cx.rename_control(content, self.accessible_name.as_deref(), description.as_deref());
1306        }
1307        if let Some(e) = error {
1308            let icon = util::icon(cx, IconName::CircleAlert, 14.0, theme.color(Semantic::Danger));
1309            let text = util::label(cx, &e, TextStyle::Callout, None, theme.color(Semantic::Danger));
1310            children.push(cx.container(
1311                LayoutStyle { spacing: 6.0, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
1312                &[icon, text],
1313                Paint::None,
1314            ));
1315        } else if let Some((note, tone)) = &self.told_note {
1316            let color = tone.color(&theme);
1317            let shape = if *tone == crate::Tone::Warning { IconName::TriangleAlert } else { IconName::Info };
1318            let icon = util::icon(cx, shape, 14.0, color);
1319            let text = util::label(cx, note, TextStyle::Callout, None, theme.color(Semantic::InkMuted));
1320            children.push(cx.container(
1321                LayoutStyle { spacing: 6.0, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
1322                &[icon, text],
1323                Paint::None,
1324            ));
1325        } else if let Some(h) = &self.hint {
1326            children.push(util::label(cx, h, TextStyle::Callout, None, theme.color(Semantic::InkMuted)));
1327        }
1328        let (padding, paint) = match self.fill {
1329            Some(fill) => (
1330                Insets::uniform(6.0),
1331                Paint::Quad(QuadPaint {
1332                    corner_radius: theme.radii.md,
1333                    ..QuadPaint::new(theme.color(fill).with_alpha(0.14))
1334                }),
1335            ),
1336            None => (Insets::ZERO, Paint::None),
1337        };
1338        cx.container(
1339            LayoutStyle {
1340                spacing: 6.0,
1341                padding,
1342                align: Align::Stretch,
1343                ..LayoutStyle::stack(Axis::Vertical)
1344            },
1345            &children,
1346            paint,
1347        )
1348    }
1349}
1350
1351/// A control with a name and a description of its own for screen readers, for one that has no
1352/// label around it to take them from, such as a check box that says its own caption.
1353///
1354/// ```
1355/// use ravenrs_core::Role;
1356/// use ravenrs_reactive::Binding;
1357/// use ravenrs_widgets::{AccessibleControl, Checkbox, Ui};
1358///
1359/// let check = Checkbox::new("Ativo", Binding::constant(true));
1360/// let named = AccessibleControl::new(check).name("Conta ativa").description("Aparece nos lançamentos");
1361/// let mut ui = Ui::new(named, ravenrs_style::Theme::raven_dark());
1362/// ui.update();
1363/// let tree = ui.access_tree();
1364/// let node = tree.find(Role::CheckBox, "Conta ativa").expect("renamed");
1365/// assert_eq!(node.description.as_deref(), Some("Aparece nos lançamentos"));
1366/// ```
1367#[derive(Debug)]
1368pub struct AccessibleControl<V> {
1369    content: V,
1370    name: Option<String>,
1371    description: Option<String>,
1372}
1373
1374#[cfg_attr(feature = "live", ravenrs_live::expose)]
1375impl<V: View> AccessibleControl<V> {
1376    /// `content`, as it calls itself.
1377    pub fn new(content: V) -> Self {
1378        Self { content, name: None, description: None }
1379    }
1380
1381    /// What screen readers call it.
1382    pub fn name(mut self, name: impl Into<String>) -> Self {
1383        self.name = Some(name.into());
1384        self
1385    }
1386
1387    /// What screen readers say after its name.
1388    pub fn description(mut self, text: impl Into<String>) -> Self {
1389        self.description = Some(text.into());
1390        self
1391    }
1392}
1393
1394impl<V: View> View for AccessibleControl<V> {
1395    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1396        let node = cx.child(&self.content);
1397        cx.rename_control(node, self.name.as_deref(), self.description.as_deref());
1398        node
1399    }
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404    use super::validate;
1405
1406    #[test]
1407    fn validators() {
1408        assert!(validate::required("  ").is_err());
1409        assert!(validate::email("ana@raven.rs").is_ok());
1410        assert!(validate::email("ana@raven").is_err());
1411        assert!(validate::email("").is_ok(), "emptiness is required()'s job");
1412        assert!(validate::min_length(8)("short").is_err());
1413        assert!(validate::min_length(8)("long enough").is_ok());
1414    }
1415}