Skip to main content

ravenrs_widgets/
status.rs

1//! Pieces that show a state at a glance: dots, ratings, tiles, empty areas and banners.
2
3use std::cell::RefCell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{
8    Color, CursorIcon, GlyphRun, Insets, Key, PathFill, Point, Primitive, Rect, Role, Scene, Size,
9};
10use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
11use ravenrs_reactive::Binding;
12use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
13use ravenrs_text::TextAttrs;
14
15use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
16use crate::{AnyView, Date, FloatingCorner, IconName, ProgressBar, Tone, View, util};
17
18/// How much of a color remains in an unlit star.
19const UNLIT_STAR: f32 = 0.35;
20/// One pass of the light over a [`ShimmerText`], in seconds.
21const SHIMMER_PERIOD: f64 = 1.8;
22/// How far the light of a [`ShimmerText`] reaches to each side, in logical pixels.
23const SHIMMER_REACH: f32 = 36.0;
24/// Below this the light on a glyph would not show, so the glyph is not drawn twice.
25const SHIMMER_FLOOR: f32 = 0.04;
26
27/// A small round mark of state, such as "not saved".
28///
29/// A color alone tells a screen reader nothing, so give a dot that means something a
30/// [`label`](Self::label). Without one it is left out of the accessibility tree.
31///
32/// ```
33/// use ravenrs_widgets::{Dot, Tone};
34///
35/// let unsaved = Dot::new().tone(Tone::Warning).label("Not saved");
36/// ```
37#[derive(Clone, Debug, Default)]
38#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
39pub struct Dot {
40    tone: Tone,
41    color: Option<Color>,
42    size: Option<f32>,
43    label: Option<String>,
44}
45
46#[cfg_attr(feature = "live", ravenrs_live::expose)]
47impl Dot {
48    /// A neutral dot, 8 logical pixels across.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Sets the color tone.
54    pub fn tone(mut self, tone: Tone) -> Self {
55        self.tone = tone;
56        self
57    }
58
59    /// Uses this color instead of the tone's.
60    pub fn color(mut self, color: Color) -> Self {
61        self.color = Some(color);
62        self
63    }
64
65    /// Sets the diameter in logical pixels.
66    pub fn size(mut self, size: f32) -> Self {
67        self.size = Some(size);
68        self
69    }
70
71    /// What the dot means, for screen readers.
72    pub fn label(mut self, label: impl Into<String>) -> Self {
73        self.label = Some(label.into());
74        self
75    }
76}
77
78impl View for Dot {
79    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
80        let theme = *cx.theme();
81        let color = self.color.unwrap_or_else(|| self.tone.color(&theme));
82        let size = self.size.unwrap_or(theme.spacing.s2);
83        let style = LayoutStyle {
84            width: Some(size),
85            height: Some(size),
86            align_self: Some(Align::Center),
87            ..LayoutStyle::leaf()
88        };
89        let paint = QuadPaint { corner_radius: size / 2.0, ..QuadPaint::new(color) };
90        let node = cx.container(style, &[], Paint::Quad(paint));
91        cx.semantics(
92            node,
93            match &self.label {
94                Some(label) => Semantics::new(Role::Image).name(label.clone()),
95                None => Semantics::hidden(),
96            },
97        );
98        node
99    }
100}
101
102/// A small view on a corner of another, such as a channel icon on an avatar or a
103/// count on a button. It sits centered on the corner, half outside.
104///
105/// ```
106/// use ravenrs_widgets::{Avatar, Dot, FloatingCorner, OverlayBadge, Tone};
107///
108/// let online = OverlayBadge::new(Avatar::new("Ana Lima"), Dot::new().tone(Tone::Success).label("Online"))
109///     .corner(FloatingCorner::BottomRight);
110/// ```
111#[derive(Debug)]
112pub struct OverlayBadge<C, B> {
113    content: C,
114    badge: B,
115    corner: FloatingCorner,
116    inset: f32,
117    visible: bool,
118}
119
120#[cfg_attr(feature = "live", ravenrs_live::expose)]
121impl<C: View, B: View> OverlayBadge<C, B> {
122    /// `badge` on the top right corner of `content`.
123    pub fn new(content: C, badge: B) -> Self {
124        Self { content, badge, corner: FloatingCorner::TopRight, inset: 0.0, visible: true }
125    }
126
127    /// Which corner it sits on.
128    pub fn corner(mut self, corner: FloatingCorner) -> Self {
129        self.corner = corner;
130        self
131    }
132
133    /// Moves it toward the middle of the content, in logical pixels, as for a round
134    /// content whose corners are empty.
135    pub fn inset(mut self, inset: f32) -> Self {
136        self.inset = inset;
137        self
138    }
139
140    /// Hides the badge and keeps the content, as when a count reaches zero.
141    pub fn visible(mut self, visible: bool) -> Self {
142        self.visible = visible;
143        self
144    }
145}
146
147impl<C: View, B: View> View for OverlayBadge<C, B> {
148    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
149        let content = cx.child(&self.content);
150        let mut children = vec![content];
151        if self.visible {
152            let badge = cx.child(&self.badge);
153            // The corner depends on the content's size, which the last layout reports.
154            let size = cx.scroll_viewport().unwrap_or(Size::ZERO);
155            let right = matches!(self.corner, FloatingCorner::TopRight | FloatingCorner::BottomRight);
156            let bottom = matches!(self.corner, FloatingCorner::BottomLeft | FloatingCorner::BottomRight);
157            let x = if right { size.width - self.inset } else { self.inset };
158            let y = if bottom { size.height - self.inset } else { self.inset };
159            // A box of no size: what is inside centers on the corner without knowing its own size.
160            children.push(cx.container(
161                LayoutStyle {
162                    absolute: Some((x, y)),
163                    width: Some(0.0),
164                    height: Some(0.0),
165                    align: Align::Center,
166                    justify: Justify::Center,
167                    ..LayoutStyle::stack(Axis::Horizontal)
168                },
169                &[badge],
170                Paint::None,
171            ));
172        }
173        let root = cx.container(
174            LayoutStyle { align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Horizontal) },
175            &children,
176            Paint::None,
177        );
178        cx.report_frame(root);
179        root
180    }
181}
182
183/// Stars for a score, to read or to give.
184///
185/// Giving one works with the pointer, the arrow keys, Home and End, and the digits.
186///
187/// ```
188/// use ravenrs_reactive::State;
189/// use ravenrs_widgets::Rating;
190///
191/// let score = State::new(3u32);
192/// let editable = Rating::new(score.binding()).label("Photo rating").clearable();
193/// let shown = Rating::read_only(4);
194/// ```
195#[derive(Debug)]
196#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
197pub struct Rating {
198    value: Option<Binding<u32>>,
199    fixed: u32,
200    max: u32,
201    size: f32,
202    tone: Tone,
203    color: Option<Color>,
204    label: Option<String>,
205    clearable: bool,
206}
207
208#[cfg_attr(feature = "live", ravenrs_live::expose)]
209impl Rating {
210    fn with(value: Option<Binding<u32>>, fixed: u32) -> Self {
211        Self {
212            value,
213            fixed,
214            max: 5,
215            size: 18.0,
216            tone: Tone::Warning,
217            color: None,
218            label: None,
219            clearable: false,
220        }
221    }
222
223    /// Stars bound to `value`, from none up to the maximum.
224    pub fn new(value: Binding<u32>) -> Self {
225        Self::with(Some(value), 0)
226    }
227
228    /// Stars that only show `value`. They cost one element, however many are drawn, so
229    /// they can go in every cell of a long list.
230    pub fn read_only(value: u32) -> Self {
231        Self::with(None, value)
232    }
233
234    /// Sets how many stars there are (5 by default).
235    pub fn max(mut self, max: u32) -> Self {
236        self.max = max;
237        self
238    }
239
240    /// Sets the size of a star in logical pixels (18 by default).
241    pub fn size(mut self, size: f32) -> Self {
242        self.size = size;
243        self
244    }
245
246    /// Sets the tone of the lit stars.
247    pub fn tone(mut self, tone: Tone) -> Self {
248        self.tone = tone;
249        self
250    }
251
252    /// Uses this color for the lit stars, as when what is behind them changes.
253    pub fn color(mut self, color: Color) -> Self {
254        self.color = Some(color);
255        self
256    }
257
258    /// What is being rated, for screen readers.
259    pub fn label(mut self, label: impl Into<String>) -> Self {
260        self.label = Some(label.into());
261        self
262    }
263
264    /// Clicking the current score takes it away.
265    pub fn clearable(mut self) -> Self {
266        self.clearable = true;
267        self
268    }
269
270    fn colors(&self, theme: &Theme) -> (Color, Color) {
271        let lit = self.color.unwrap_or_else(|| self.tone.color(theme));
272        (lit, theme.color(Semantic::InkMuted).with_alpha(UNLIT_STAR))
273    }
274
275    fn read_only_view(&self, cx: &mut BuildCx<'_>) -> NodeId {
276        let theme = *cx.theme();
277        let (lit, unlit) = self.colors(&theme);
278        let (max, size) = (self.max, self.size);
279        let value = self.fixed.min(max);
280        let node = cx.container(
281            LayoutStyle {
282                width: Some(max as f32 * size),
283                height: Some(size),
284                align_self: Some(Align::Center),
285                ..LayoutStyle::leaf()
286            },
287            &[],
288            Paint::Custom(Rc::new(move |frame: Rect, scene: &mut Scene| {
289                for star in 0..max {
290                    let at = Point::new(frame.x() + (star as f32 + 0.5) * size, frame.center().y);
291                    let color = if star < value { lit } else { unlit };
292                    scene.push_path(PathFill::solid(util::star_path(at, size * 0.48), color));
293                }
294            })),
295        );
296        let labels = cx.labels();
297        let name = self.label.clone().unwrap_or_else(|| labels.rating.clone());
298        cx.semantics(node, Semantics::new(Role::Image).name(name).value(labels.rating_value(value, max)));
299        node
300    }
301
302    fn editable_view(&self, cx: &mut BuildCx<'_>, binding: &Binding<u32>) -> NodeId {
303        let theme = *cx.theme();
304        let (lit, unlit) = self.colors(&theme);
305        let (max, clearable) = (self.max, self.clearable);
306        let value = binding.get().min(max);
307        let preview = cx.use_state(|| None::<u32>);
308        // Where the pointer is shows what a click would give, until it leaves.
309        let shown = if cx.is_hover_within() { preview.get().unwrap_or(value) } else { value };
310        let stars: Vec<NodeId> = (0..max)
311            .map(|star| {
312                let score = star + 1;
313                cx.child(&Star {
314                    size: self.size,
315                    color: if star < shown { lit } else { unlit },
316                    preview: {
317                        let preview = preview.clone();
318                        Rc::new(move || {
319                            if preview.get() != Some(score) {
320                                preview.set(Some(score));
321                            }
322                        })
323                    },
324                    pick: {
325                        let binding = binding.clone();
326                        Rc::new(move || {
327                            let again = clearable && binding.get() == score;
328                            binding.set(if again { 0 } else { score });
329                        })
330                    },
331                })
332            })
333            .collect();
334        let node = cx.container(
335            LayoutStyle {
336                align: Align::Center,
337                align_self: Some(Align::Fit),
338                ..LayoutStyle::stack(Axis::Horizontal)
339            },
340            &stars,
341            Paint::None,
342        );
343        let labels = cx.labels();
344        let name = self.label.clone().unwrap_or_else(|| labels.rating.clone());
345        cx.semantics(
346            node,
347            Semantics::new(Role::Slider)
348                .name(name)
349                .numeric(f64::from(value), 0.0, f64::from(max), Some(1.0))
350                .value(labels.rating_value(value, max)),
351        );
352        let keys = binding.clone();
353        cx.on_key(
354            node,
355            Rc::new(move |key, _| {
356                let current = keys.get().min(max);
357                let next = match key {
358                    Key::ArrowRight | Key::ArrowUp => (current + 1).min(max),
359                    Key::ArrowLeft | Key::ArrowDown => current.saturating_sub(1),
360                    Key::Home => 0,
361                    Key::End => max,
362                    Key::Character(c) => match c.parse::<u32>() {
363                        Ok(digit) if digit <= max => digit,
364                        _ => return false,
365                    },
366                    _ => return false,
367                };
368                keys.set(next);
369                true
370            }),
371        );
372        cx.focusable(node, theme.radii.sm);
373        node
374    }
375}
376
377impl View for Rating {
378    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
379        match &self.value {
380            Some(binding) => self.editable_view(cx, binding),
381            None => self.read_only_view(cx),
382        }
383    }
384}
385
386/// One star that can be clicked. The rating as a whole is what screen readers and the
387/// keyboard see, as a slider.
388struct Star {
389    size: f32,
390    color: Color,
391    preview: Rc<dyn Fn()>,
392    pick: Rc<dyn Fn()>,
393}
394
395impl View for Star {
396    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
397        let (color, size) = (self.color, self.size);
398        let node = cx.container(
399            LayoutStyle { width: Some(size), height: Some(size), ..LayoutStyle::leaf() },
400            &[],
401            Paint::Custom(Rc::new(move |frame: Rect, scene: &mut Scene| {
402                scene.push_path(PathFill::solid(util::star_path(frame.center(), size * 0.48), color));
403            })),
404        );
405        cx.semantics(node, Semantics::hidden());
406        let preview = self.preview.clone();
407        cx.on_pointer_move(node, Rc::new(move |_, _| preview()));
408        cx.on_click(node, self.pick.clone());
409        cx.cursor(node, CursorIcon::Pointer);
410        node
411    }
412}
413
414/// An icon in a square tinted with its color, beside a title or at the top of a notice.
415///
416/// ```
417/// use ravenrs_widgets::{IconName, IconTile, Tone};
418///
419/// let tile = IconTile::new(IconName::TriangleAlert).tone(Tone::Warning);
420/// ```
421#[derive(Clone, Debug)]
422#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
423pub struct IconTile {
424    icon: IconName,
425    tone: Tone,
426    color: Option<Color>,
427    size: f32,
428    label: Option<String>,
429}
430
431#[cfg_attr(feature = "live", ravenrs_live::expose)]
432impl IconTile {
433    /// A neutral tile of 48 logical pixels showing `icon`.
434    pub fn new(icon: IconName) -> Self {
435        Self { icon, tone: Tone::Neutral, color: None, size: 48.0, label: None }
436    }
437
438    /// Sets the color tone.
439    pub fn tone(mut self, tone: Tone) -> Self {
440        self.tone = tone;
441        self
442    }
443
444    /// Uses this color instead of the tone's.
445    pub fn color(mut self, color: Color) -> Self {
446        self.color = Some(color);
447        self
448    }
449
450    /// Sets the side of the square in logical pixels. The icon follows it.
451    pub fn size(mut self, size: f32) -> Self {
452        self.size = size;
453        self
454    }
455
456    /// What the icon means, for screen readers. Without it, the tile is decoration.
457    pub fn label(mut self, label: impl Into<String>) -> Self {
458        self.label = Some(label.into());
459        self
460    }
461}
462
463impl View for IconTile {
464    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
465        let theme = *cx.theme();
466        let color = self.color.unwrap_or_else(|| self.tone.color(&theme));
467        let icon = util::icon(cx, self.icon, (self.size * 0.58).round(), color);
468        cx.semantics(icon, Semantics::hidden());
469        let node = cx.container(
470            LayoutStyle {
471                width: Some(self.size),
472                height: Some(self.size),
473                align: Align::Center,
474                justify: Justify::Center,
475                ..LayoutStyle::stack(Axis::Horizontal)
476            },
477            &[icon],
478            Paint::Quad(QuadPaint {
479                corner_radius: theme.radii.lg,
480                ..QuadPaint::new(color.with_alpha(0.14))
481            }),
482        );
483        cx.semantics(
484            node,
485            match &self.label {
486                Some(label) => Semantics::new(Role::Image).name(label.clone()),
487                None => Semantics::hidden(),
488            },
489        );
490        node
491    }
492}
493
494/// What an area shows when it has nothing: an icon, a title, a sentence and what to do.
495///
496/// ```
497/// use ravenrs_widgets::{Button, EmptyState, IconName};
498///
499/// let empty = EmptyState::new("No photos yet")
500///     .icon(IconName::Image)
501///     .description("Import a folder to start.")
502///     .action(Button::new("Import"));
503/// ```
504#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
505pub struct EmptyState {
506    title: String,
507    description: Option<String>,
508    icon: Option<IconName>,
509    actions: Vec<AnyView>,
510}
511
512impl fmt::Debug for EmptyState {
513    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
514        f.debug_struct("EmptyState").field("title", &self.title).finish_non_exhaustive()
515    }
516}
517
518#[cfg_attr(feature = "live", ravenrs_live::expose)]
519impl EmptyState {
520    /// An empty state with this title.
521    pub fn new(title: impl Into<String>) -> Self {
522        Self { title: title.into(), description: None, icon: None, actions: Vec::new() }
523    }
524
525    /// The sentence under the title, saying why it is empty or what would fill it.
526    pub fn description(mut self, text: impl Into<String>) -> Self {
527        self.description = Some(text.into());
528        self
529    }
530
531    /// The icon above the title.
532    pub fn icon(mut self, icon: IconName) -> Self {
533        self.icon = Some(icon);
534        self
535    }
536
537    /// Adds a view under the text, usually a button. Call again for more, side by side.
538    pub fn action(mut self, action: impl View + 'static) -> Self {
539        self.actions.push(Box::new(action));
540        self
541    }
542}
543
544impl View for EmptyState {
545    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
546        let theme = *cx.theme();
547        let mut children = Vec::new();
548        if let Some(icon) = self.icon {
549            children.push(cx.child(&IconTile::new(icon)));
550        }
551        let title = util::label(cx, &self.title, TextStyle::Headline, None, theme.color(Semantic::Ink));
552        cx.semantics(title, Semantics::new(Role::Heading).level(3));
553        children.push(title);
554        if let Some(text) = &self.description {
555            let attrs = util::attrs(&theme, TextStyle::Body, None);
556            // A long sentence reads better in a short line than across a wide area.
557            let style = LayoutStyle { max_width: Some(420.0), ..LayoutStyle::leaf() };
558            children.push(cx.text(text, &attrs, theme.color(Semantic::InkMuted), style));
559        }
560        if !self.actions.is_empty() {
561            let actions: Vec<NodeId> = self.actions.iter().map(|a| cx.child(a.as_ref())).collect();
562            children.push(cx.container(
563                LayoutStyle {
564                    spacing: theme.spacing.s2,
565                    padding: Insets { top: theme.spacing.s2, ..Insets::ZERO },
566                    ..LayoutStyle::stack(Axis::Horizontal)
567                },
568                &actions,
569                Paint::None,
570            ));
571        }
572        cx.container(
573            LayoutStyle {
574                padding: Insets::uniform(theme.spacing.s8),
575                spacing: theme.spacing.s2,
576                align: Align::Center,
577                justify: Justify::Center,
578                align_self: Some(Align::Stretch),
579                ..LayoutStyle::stack(Axis::Vertical)
580            },
581            &children,
582            Paint::None,
583        )
584    }
585}
586
587/// A wide band in a strong color, readable from across the room: "You can remove the card".
588///
589/// ```
590/// use ravenrs_widgets::{HeroBanner, IconName, Tone};
591///
592/// let banner = HeroBanner::new("You can remove the card").icon(IconName::CircleCheck).tone(Tone::Success);
593/// ```
594#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
595pub struct HeroBanner {
596    title: String,
597    description: Option<String>,
598    icon: Option<IconName>,
599    tone: Tone,
600    color: Option<Color>,
601    actions: Vec<AnyView>,
602}
603
604impl fmt::Debug for HeroBanner {
605    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
606        f.debug_struct("HeroBanner").field("title", &self.title).finish_non_exhaustive()
607    }
608}
609
610#[cfg_attr(feature = "live", ravenrs_live::expose)]
611impl HeroBanner {
612    /// A banner saying `title`, in the accent tone.
613    pub fn new(title: impl Into<String>) -> Self {
614        Self {
615            title: title.into(),
616            description: None,
617            icon: None,
618            tone: Tone::Accent,
619            color: None,
620            actions: Vec::new(),
621        }
622    }
623
624    /// A line under the title.
625    pub fn description(mut self, text: impl Into<String>) -> Self {
626        self.description = Some(text.into());
627        self
628    }
629
630    /// The icon before the title.
631    pub fn icon(mut self, icon: IconName) -> Self {
632        self.icon = Some(icon);
633        self
634    }
635
636    /// Sets the tone. Warning and danger are announced as alerts.
637    pub fn tone(mut self, tone: Tone) -> Self {
638        self.tone = tone;
639        self
640    }
641
642    /// Uses this color instead of the tone's.
643    pub fn color(mut self, color: Color) -> Self {
644        self.color = Some(color);
645        self
646    }
647
648    /// Adds a view after the text, usually a button.
649    pub fn action(mut self, action: impl View + 'static) -> Self {
650        self.actions.push(Box::new(action));
651        self
652    }
653}
654
655/// Black or white, whichever reads better on `fill`.
656fn ink_on(fill: Color) -> Color {
657    let luminance = 0.2126 * fill.r + 0.7152 * fill.g + 0.0722 * fill.b;
658    if luminance > 0.55 { Color::BLACK } else { Color::WHITE }
659}
660
661impl View for HeroBanner {
662    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
663        let theme = *cx.theme();
664        let fill = self.color.unwrap_or_else(|| self.tone.color(&theme));
665        let ink = ink_on(fill);
666        let mut text = vec![util::label(cx, &self.title, TextStyle::Title, Some(Weight::BOLD), ink)];
667        if let Some(description) = &self.description {
668            text.push(util::label(cx, description, TextStyle::Body, None, ink));
669        }
670        let text = cx.container(
671            LayoutStyle {
672                spacing: theme.spacing.s1,
673                min_width: Some(0.0),
674                ..LayoutStyle::stack(Axis::Vertical)
675            },
676            &text,
677            Paint::None,
678        );
679        let mut children = Vec::new();
680        if let Some(icon) = self.icon {
681            let icon = util::icon(cx, icon, 32.0, ink);
682            cx.semantics(icon, Semantics::hidden());
683            children.push(icon);
684        }
685        children.push(text);
686        for action in &self.actions {
687            children.push(cx.child(action.as_ref()));
688        }
689        let node = cx.container(
690            LayoutStyle {
691                padding: Insets::symmetric(theme.spacing.s4 + theme.spacing.s1 / 2.0, theme.spacing.s4),
692                spacing: theme.spacing.s3,
693                align: Align::Center,
694                justify: Justify::Center,
695                align_self: Some(Align::Stretch),
696                ..LayoutStyle::stack(Axis::Horizontal)
697            },
698            &children,
699            Paint::Quad(QuadPaint { corner_radius: theme.radii.lg, ..QuadPaint::new(fill) }),
700        );
701        let role = if matches!(self.tone, Tone::Warning | Tone::Danger) { Role::Alert } else { Role::Status };
702        cx.semantics(node, Semantics::new(role).name(self.title.clone()));
703        node
704    }
705}
706
707/// Several progress bars stacked, for work that has more than one part.
708///
709/// ```
710/// use ravenrs_widgets::{MultiProgress, Tone};
711///
712/// let copying = MultiProgress::new()
713///     .meter("Local copy", 40, 120)
714///     .meter("Previews", 12, 40);
715/// ```
716#[derive(Debug, Default)]
717#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
718pub struct MultiProgress {
719    bars: Vec<ProgressBar>,
720    label: Option<String>,
721}
722
723#[cfg_attr(feature = "live", ravenrs_live::expose)]
724impl MultiProgress {
725    /// No bars yet.
726    pub fn new() -> Self {
727        Self::default()
728    }
729
730    /// Adds a bar for `done` of `total`, with both numbers below it. Nothing to do counts as
731    /// nothing done, and more than the total counts as all of it.
732    pub fn meter(self, label: impl Into<String>, done: u64, total: u64) -> Self {
733        let fraction = if total == 0 { 0.0 } else { (done as f64 / total as f64) as f32 };
734        self.bar(ProgressBar::new(fraction).label(label).percent().detail(format!("{done} / {total}")))
735    }
736
737    /// Adds a bar set up by hand, with its own color or height.
738    pub fn bar(mut self, bar: ProgressBar) -> Self {
739        self.bars.push(bar);
740        self
741    }
742
743    /// What the bars are, as a whole, for screen readers.
744    pub fn label(mut self, label: impl Into<String>) -> Self {
745        self.label = Some(label.into());
746        self
747    }
748}
749
750impl View for MultiProgress {
751    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
752        let spacing = cx.theme().spacing.s3;
753        let bars: Vec<NodeId> = self.bars.iter().map(|bar| cx.child(bar)).collect();
754        let node = cx.container(
755            LayoutStyle {
756                spacing,
757                align: Align::Stretch,
758                align_self: Some(Align::Stretch),
759                ..LayoutStyle::stack(Axis::Vertical)
760            },
761            &bars,
762            Paint::None,
763        );
764        if let Some(label) = &self.label {
765            cx.semantics(node, Semantics::new(Role::Group).name(label.clone()));
766        }
767        node
768    }
769}
770
771/// One line of text with a light passing over it, to say that something is under way.
772///
773/// When motion is reduced, or while [`active`](Self::active) is off, it is plain muted text
774/// that asks for no frames.
775///
776/// ```
777/// use ravenrs_widgets::ShimmerText;
778///
779/// let waiting = ShimmerText::new("Looking for updates");
780/// ```
781#[derive(Clone, Debug)]
782#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
783pub struct ShimmerText {
784    text: String,
785    style: TextStyle,
786    active: bool,
787}
788
789#[cfg_attr(feature = "live", ravenrs_live::expose)]
790impl ShimmerText {
791    /// `text` with the light passing over it.
792    pub fn new(text: impl Into<String>) -> Self {
793        Self { text: text.into(), style: TextStyle::Callout, active: true }
794    }
795
796    /// Sets the text style.
797    pub fn style(mut self, style: TextStyle) -> Self {
798        self.style = style;
799        self
800    }
801
802    /// Turns the light on or off, as the work starts and ends, without moving the text.
803    pub fn active(mut self, active: bool) -> Self {
804        self.active = active;
805        self
806    }
807}
808
809/// The shaped text, measured from its own corner, which a paint-only frame cannot shape again.
810#[derive(Default)]
811struct ShimmerGlyphs {
812    width: f32,
813    runs: Vec<GlyphRun>,
814}
815
816fn shape_for_shimmer(
817    text: &str,
818    attrs: &TextAttrs,
819    frame: Rect,
820    cx: &mut crate::PaintCx<'_>,
821) -> ShimmerGlyphs {
822    let mut layout = cx.text.layout(text, attrs);
823    layout.measure(Some(frame.width() + 0.5));
824    let mut scene = Scene::new();
825    layout.paint(Point::ZERO, Color::WHITE, &mut scene);
826    let runs = scene
827        .primitives()
828        .iter()
829        .filter_map(|p| if let Primitive::Glyphs(run) = p { Some(run.clone()) } else { None })
830        .collect();
831    ShimmerGlyphs { width: frame.width(), runs }
832}
833
834fn paint_shimmer(glyphs: &ShimmerGlyphs, origin: Point, time: f64, light: Color, scene: &mut Scene) {
835    let progress = (time / SHIMMER_PERIOD).fract() as f32;
836    let center = -SHIMMER_REACH + (glyphs.width + 2.0 * SHIMMER_REACH) * progress;
837    for run in &glyphs.runs {
838        for glyph in &run.glyphs {
839            let strength = 1.0 - (glyph.x - center).abs() / SHIMMER_REACH;
840            if strength < SHIMMER_FLOOR {
841                continue;
842            }
843            let mut lit = *glyph;
844            lit.x += origin.x;
845            lit.y += origin.y;
846            scene.push_glyphs(GlyphRun {
847                font: run.font.clone(),
848                size: run.size,
849                color: light.with_alpha(light.a * strength),
850                glyphs: vec![lit],
851            });
852        }
853    }
854}
855
856impl View for ShimmerText {
857    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
858        let theme = *cx.theme();
859        let attrs = util::attrs(&theme, self.style, None);
860        let (base, light) = (theme.color(Semantic::InkMuted), theme.color(Semantic::Ink));
861        let text = cx.text(&self.text, &attrs, base, LayoutStyle::leaf());
862        if !self.active || cx.reduced_motion() {
863            return text;
864        }
865        let glyphs = Rc::new(RefCell::new(ShimmerGlyphs::default()));
866        let drawn = glyphs.clone();
867        // Live drawing has the clock but cannot shape text, so a node with rich drawing shapes
868        // it first. Only the glyphs under the light are drawn again, on top of the plain text.
869        let light_node = cx.container(
870            LayoutStyle { absolute: Some((0.0, 0.0)), ..LayoutStyle::leaf() },
871            &[],
872            Paint::Live(Rc::new(move |frame: Rect, time, scene: &mut Scene| {
873                paint_shimmer(&drawn.borrow(), frame.origin, time, light, scene);
874            })),
875        );
876        cx.semantics(light_node, Semantics::hidden());
877        let source = self.text.clone();
878        cx.container(
879            LayoutStyle { align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Horizontal) },
880            &[text, light_node],
881            Paint::Rich(Rc::new(move |frame: Rect, cx: &mut crate::PaintCx<'_>| {
882                if glyphs.borrow().width != frame.width() {
883                    *glyphs.borrow_mut() = shape_for_shimmer(&source, &attrs, frame, cx);
884                }
885            })),
886        )
887    }
888}
889
890/// A line with a word in the middle that divides a list by day: "Today", "Yesterday".
891///
892/// ```
893/// use ravenrs_widgets::{Date, DaySeparator};
894///
895/// let today = Date::new(2026, 11, 4).unwrap_or(Date::from_days(0));
896/// let heading = DaySeparator::of(today.add_days(-1), today);
897/// let free = DaySeparator::new("Last week");
898/// ```
899#[derive(Clone, Debug)]
900#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
901pub struct DaySeparator {
902    text: DayText,
903}
904
905#[derive(Clone, Debug)]
906enum DayText {
907    Free(String),
908    Day { day: Date, today: Date },
909}
910
911#[cfg_attr(feature = "live", ravenrs_live::expose)]
912impl DaySeparator {
913    /// A separator with this text.
914    pub fn new(text: impl Into<String>) -> Self {
915        Self { text: DayText::Free(text.into()) }
916    }
917
918    /// A separator for `day`: the words for today and yesterday from
919    /// [`Labels`](crate::Labels), and otherwise the short date.
920    pub fn of(day: Date, today: Date) -> Self {
921        Self { text: DayText::Day { day, today } }
922    }
923}
924
925impl View for DaySeparator {
926    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
927        let theme = *cx.theme();
928        let text = match &self.text {
929            DayText::Free(text) => text.clone(),
930            DayText::Day { day, today } => {
931                let labels = cx.labels();
932                match today.to_days() - day.to_days() {
933                    0 => labels.today.clone(),
934                    1 => labels.yesterday.clone(),
935                    _ => labels.short_date(*day),
936                }
937            }
938        };
939        let border = theme.color(Semantic::Border);
940        let line = |cx: &mut BuildCx<'_>| {
941            cx.container(
942                LayoutStyle { height: Some(1.0), grow: 1.0, ..LayoutStyle::leaf() },
943                &[],
944                Paint::Quad(QuadPaint::new(border)),
945            )
946        };
947        let before = line(cx);
948        let word =
949            util::label(cx, &text, TextStyle::Caption, Some(Weight::MEDIUM), theme.color(Semantic::InkMuted));
950        cx.semantics(word, Semantics::hidden());
951        let after = line(cx);
952        let node = cx.container(
953            LayoutStyle {
954                spacing: theme.spacing.s3,
955                align: Align::Center,
956                align_self: Some(Align::Stretch),
957                ..LayoutStyle::stack(Axis::Horizontal)
958            },
959            &[before, word, after],
960            Paint::None,
961        );
962        cx.semantics(node, Semantics::new(Role::Separator).name(text));
963        node
964    }
965}