Skip to main content

ravenrs_widgets/
display.rs

1//! Components that present information: badges, avatars, alerts, stats, progress.
2
3use std::rc::Rc;
4
5use ravenrs_core::{Border, Color, Insets, Quad, Rect, Role};
6use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
7use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
8
9use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
10use crate::{Button, ButtonSize, ButtonStyle, Fill, IconName, Image, View, ViewSeq, util};
11
12/// How far a badge's text goes from its tone toward the ink.
13const BADGE_TEXT_TOWARD_INK: f32 = 0.3;
14/// How far an avatar's initials go from its color toward the ink.
15const AVATAR_TEXT_TOWARD_INK: f32 = 0.5;
16
17/// Tone of a badge or alert. Status tones always come with a word or an icon,
18/// never color alone.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20#[non_exhaustive]
21#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
22pub enum Tone {
23    /// Gray, for neutral information.
24    #[default]
25    Neutral,
26    /// The theme accent color.
27    Accent,
28    /// Green, for success.
29    Success,
30    /// Amber, for warnings.
31    Warning,
32    /// Red, for errors and destructive states.
33    Danger,
34}
35
36#[cfg_attr(feature = "live", ravenrs_live::expose)]
37impl Tone {
38    pub(crate) fn color(self, theme: &Theme) -> Color {
39        match self {
40            Tone::Neutral => theme.color(Semantic::InkMuted),
41            Tone::Accent => theme.color(Semantic::FocusRing).lerp(theme.color(Semantic::Accent), 0.35),
42            Tone::Success => theme.color(Semantic::Success),
43            Tone::Warning => theme.color(Semantic::Warning),
44            Tone::Danger => theme.color(Semantic::Danger),
45        }
46    }
47
48    fn icon(self) -> IconName {
49        match self {
50            Tone::Neutral | Tone::Accent => IconName::Info,
51            Tone::Success => IconName::CircleCheck,
52            Tone::Warning => IconName::TriangleAlert,
53            Tone::Danger => IconName::CircleAlert,
54        }
55    }
56}
57
58/// A small pill label: status, counts, categories.
59#[derive(Clone, Debug)]
60#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
61pub struct Badge {
62    text: String,
63    tone: Tone,
64    icon: Option<IconName>,
65    color: Option<Color>,
66    outline: bool,
67    height: Option<f32>,
68}
69
70#[cfg_attr(feature = "live", ravenrs_live::expose)]
71impl Badge {
72    /// A neutral badge with this text.
73    pub fn new(text: impl Into<String>) -> Self {
74        Self { text: text.into(), tone: Tone::Neutral, icon: None, color: None, outline: false, height: None }
75    }
76
77    /// Sets the color tone.
78    pub fn tone(mut self, tone: Tone) -> Self {
79        self.tone = tone;
80        self
81    }
82
83    /// Shows an icon before the text.
84    pub fn icon(mut self, icon: IconName) -> Self {
85        self.icon = Some(icon);
86        self
87    }
88
89    /// Uses this color instead of the tone's, for categories that have their own.
90    /// The text moves toward the ink, so that it stays readable on the tint.
91    pub fn color(mut self, color: Color) -> Self {
92        self.color = Some(color);
93        self
94    }
95
96    /// Only the outline, without the tinted fill.
97    pub fn outline(mut self) -> Self {
98        self.outline = true;
99        self
100    }
101
102    /// Fixes the height in logical pixels, to line up with what is beside it.
103    pub fn height(mut self, height: f32) -> Self {
104        self.height = Some(height);
105        self
106    }
107
108    pub(crate) fn text(&self) -> &str {
109        &self.text
110    }
111}
112
113impl View for Badge {
114    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
115        let theme = *cx.theme();
116        let color = self.color.unwrap_or_else(|| self.tone.color(&theme));
117        // Toward the ink, so that small text on its own tint keeps 4.5:1 in every theme.
118        let ink = color.lerp(theme.color(Semantic::Ink), BADGE_TEXT_TOWARD_INK);
119        let mut children = Vec::new();
120        if let Some(icon) = self.icon {
121            children.push(util::icon(cx, icon, 12.0, ink));
122        }
123        children.push(util::label(cx, &self.text, TextStyle::Caption, Some(Weight::SEMIBOLD), ink));
124        let style = LayoutStyle {
125            padding: Insets::symmetric(if self.height.is_some() { 0.0 } else { 3.0 }, 9.0),
126            height: self.height,
127            spacing: 4.0,
128            align: Align::Center,
129            align_self: Some(Align::Fit),
130            ..LayoutStyle::stack(Axis::Horizontal)
131        };
132        let (fill, edge) =
133            if self.outline { (Color::TRANSPARENT, 0.6) } else { (color.with_alpha(0.14), 0.28) };
134        let paint = QuadPaint {
135            fill: fill.into(),
136            corner_radius: 999.0,
137            border: Some(Border { width: 1.0, color: color.with_alpha(edge) }),
138            shadow: None,
139        };
140        cx.container(style, &children, Paint::Quad(paint))
141    }
142}
143
144/// A round avatar showing a picture, or initials on a color picked from the name.
145#[derive(Clone, Debug)]
146#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
147pub struct Avatar {
148    name: String,
149    size: f32,
150    image: Option<Image>,
151    border: Option<(f32, Fill)>,
152}
153
154#[cfg_attr(feature = "live", ravenrs_live::expose)]
155impl Avatar {
156    /// An avatar showing the initials of `name`, 36 logical pixels across.
157    pub fn new(name: impl Into<String>) -> Self {
158        Self { name: name.into(), size: 36.0, image: None, border: None }
159    }
160
161    /// Sets the diameter in logical pixels.
162    pub fn size(mut self, size: f32) -> Self {
163        self.size = size;
164        self
165    }
166
167    /// Shows this picture instead of the initials, cropped to the circle. The name still
168    /// tells screen readers who it is.
169    pub fn image(mut self, image: Image) -> Self {
170        self.image = Some(image);
171        self
172    }
173
174    /// Draws a ring of `width` logical pixels, as on the selected one of a row. A picture
175    /// shrinks to fit inside it.
176    pub fn border(mut self, width: f32, fill: impl Into<Fill>) -> Self {
177        self.border = Some((width, fill.into()));
178        self
179    }
180
181    /// The picture inside its ring, which would otherwise paint over the ring.
182    fn draw_image(&self, cx: &mut BuildCx<'_>, image: &Image) -> NodeId {
183        let theme = *cx.theme();
184        let ring = self.border.map(|(width, fill)| Border { width, color: fill.resolve(&theme) });
185        let inset = ring.map_or(0.0, |r| r.width);
186        let inner = (self.size - 2.0 * inset).max(0.0);
187        let picture = cx.child(
188            &image
189                .clone()
190                .size(inner, inner)
191                .corner_radius(inner / 2.0)
192                .content_mode(crate::ContentMode::Cover),
193        );
194        cx.semantics(picture, Semantics::hidden());
195        let style = LayoutStyle {
196            width: Some(self.size),
197            height: Some(self.size),
198            padding: Insets::uniform(inset),
199            ..LayoutStyle::stack(Axis::Horizontal)
200        };
201        let paint = QuadPaint {
202            fill: Color::TRANSPARENT.into(),
203            corner_radius: self.size / 2.0,
204            border: ring,
205            shadow: None,
206        };
207        let node = cx.container(style, &[picture], Paint::Quad(paint));
208        cx.semantics(node, Semantics::new(Role::Image).name(self.name.clone()));
209        node
210    }
211
212    fn initials(&self) -> String {
213        self.name
214            .split_whitespace()
215            .filter_map(|w| w.chars().next())
216            .take(2)
217            .flat_map(char::to_uppercase)
218            .collect()
219    }
220}
221
222impl View for Avatar {
223    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
224        if let Some(image) = &self.image {
225            return self.draw_image(cx, image);
226        }
227        let theme = *cx.theme();
228        let palette = [
229            theme.color(Semantic::Accent),
230            theme.color(Semantic::Success),
231            theme.color(Semantic::Warning),
232            theme.color(Semantic::Danger),
233            theme.color(Semantic::FocusRing),
234        ];
235        let hash = self.name.bytes().fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
236        let base = palette[(hash as usize) % palette.len()];
237        let mut attrs = util::attrs(&theme, TextStyle::Body, Some(Weight::SEMIBOLD));
238        attrs.size = (self.size * 0.4).round();
239        attrs.line_height = attrs.size * 1.2;
240        let text = cx.text(
241            &self.initials(),
242            &attrs,
243            base.lerp(theme.color(Semantic::Ink), AVATAR_TEXT_TOWARD_INK),
244            LayoutStyle::leaf(),
245        );
246        let style = LayoutStyle {
247            width: Some(self.size),
248            height: Some(self.size),
249            align: Align::Center,
250            justify: Justify::Center,
251            ..LayoutStyle::stack(Axis::Horizontal)
252        };
253        let paint = QuadPaint {
254            fill: base.with_alpha(0.28).into(),
255            corner_radius: self.size / 2.0,
256            border: Some(match self.border {
257                Some((width, fill)) => Border { width, color: fill.resolve(&theme) },
258                None => Border { width: 1.0, color: base.with_alpha(0.5) },
259            }),
260            shadow: None,
261        };
262        let node = cx.container(style, &[text], Paint::Quad(paint));
263        cx.semantics(text, Semantics::hidden());
264        cx.semantics(node, Semantics::new(Role::Image).name(self.name.clone()));
265        node
266    }
267}
268
269/// A keyboard key, for shortcuts: `Kbd::new("⌘K")`.
270#[derive(Clone, Debug)]
271#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
272pub struct Kbd {
273    keys: String,
274    inherit: bool,
275}
276
277#[cfg_attr(feature = "live", ravenrs_live::expose)]
278impl Kbd {
279    /// A key cap showing `keys`.
280    pub fn new(keys: impl Into<String>) -> Self {
281        Self { keys: keys.into(), inherit: false }
282    }
283
284    /// Takes its color from what it is in ([`BuildCx::with_foreground`]) and drops its own
285    /// fill, so that on a filled button the key reads as part of the button instead of a
286    /// gray box. [`Button::shortcut`] and [`Tooltip::shortcut`](crate::Tooltip::shortcut)
287    /// do this. With no color around it, it uses its usual one.
288    pub fn inherit_color(mut self) -> Self {
289        self.inherit = true;
290        self
291    }
292}
293
294impl View for Kbd {
295    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
296        let theme = *cx.theme();
297        let inherited = cx.foreground().filter(|_| self.inherit);
298        let ink = inherited.unwrap_or_else(|| theme.color(Semantic::InkMuted));
299        let text = util::label(cx, &self.keys, TextStyle::Caption, Some(Weight::MEDIUM), ink);
300        let style = LayoutStyle {
301            padding: Insets { top: 1.0, bottom: 2.0, left: 6.0, right: 6.0 },
302            align_self: Some(Align::Center),
303            ..LayoutStyle::stack(Axis::Horizontal)
304        };
305        let paint = QuadPaint {
306            fill: if inherited.is_some() { Color::TRANSPARENT } else { theme.color(Semantic::Control) }
307                .into(),
308            corner_radius: theme.radii.sm,
309            border: Some(Border {
310                width: 1.0,
311                color: inherited.map_or(theme.color(Semantic::Border), |c| c.with_alpha(c.a * 0.4)),
312            }),
313            shadow: None,
314        };
315        cx.container(style, &[text], Paint::Quad(paint))
316    }
317}
318
319/// A hairline separator. Horizontal dividers stretch across their stack.
320#[derive(Clone, Copy, Debug, Default)]
321#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
322pub struct Divider {
323    vertical: bool,
324}
325
326#[cfg_attr(feature = "live", ravenrs_live::expose)]
327impl Divider {
328    /// A horizontal line that stretches across its stack.
329    pub fn horizontal() -> Self {
330        Self { vertical: false }
331    }
332
333    /// A vertical line.
334    pub fn vertical() -> Self {
335        Self { vertical: true }
336    }
337}
338
339impl View for Divider {
340    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
341        let color = cx.theme().color(Semantic::Border);
342        let style = if self.vertical {
343            LayoutStyle { width: Some(1.0), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
344        } else {
345            LayoutStyle { height: Some(1.0), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
346        };
347        let node = cx.container(style, &[], Paint::Quad(QuadPaint::new(color)));
348        cx.semantics(node, Semantics::new(Role::Separator));
349        node
350    }
351}
352
353/// A horizontal progress bar; `value` from 0.0 to 1.0. It can carry a label and a
354/// percentage above it and a detail below.
355///
356/// ```
357/// use ravenrs_widgets::{ProgressBar, Ui};
358///
359/// let bar = ProgressBar::new(0.4).label("Copying").percent().detail("12 of 30 photos").height(4.0);
360/// let mut ui = Ui::new(bar, ravenrs_style::Theme::raven_dark());
361/// ui.update();
362/// ```
363#[derive(Clone, Debug)]
364#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
365pub struct ProgressBar {
366    value: f32,
367    tone: Tone,
368    height: f32,
369    color: Option<Color>,
370    label: Option<String>,
371    detail: Option<String>,
372    percent: bool,
373}
374
375#[cfg_attr(feature = "live", ravenrs_live::expose)]
376impl ProgressBar {
377    /// A progress bar at `value`, clamped to 0.0–1.0.
378    pub fn new(value: f32) -> Self {
379        Self {
380            value: value.clamp(0.0, 1.0),
381            tone: Tone::Accent,
382            height: 6.0,
383            color: None,
384            label: None,
385            detail: None,
386            percent: false,
387        }
388    }
389
390    /// Sets the fill color tone (accent by default).
391    pub fn tone(mut self, tone: Tone) -> Self {
392        self.tone = tone;
393        self
394    }
395
396    /// Fills with this color instead of the tone's.
397    pub fn color(mut self, color: Color) -> Self {
398        self.color = Some(color);
399        self
400    }
401
402    /// Sets the thickness of the bar in logical pixels (6 by default).
403    pub fn height(mut self, height: f32) -> Self {
404        self.height = height.max(1.0);
405        self
406    }
407
408    /// What is progressing, above the bar. It also names it for screen readers.
409    pub fn label(mut self, label: impl Into<String>) -> Self {
410        self.label = Some(label.into());
411        self
412    }
413
414    /// Shows the percentage above the bar, at its end.
415    pub fn percent(mut self) -> Self {
416        self.percent = true;
417        self
418    }
419
420    /// A line in small text below the bar, such as how many are done.
421    pub fn detail(mut self, detail: impl Into<String>) -> Self {
422        self.detail = Some(detail.into());
423        self
424    }
425}
426
427impl View for ProgressBar {
428    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
429        let theme = *cx.theme();
430        let track = theme.color(Semantic::Control);
431        let fill = self.color.unwrap_or_else(|| {
432            if self.tone == Tone::Accent { theme.color(Semantic::Accent) } else { self.tone.color(&theme) }
433        });
434        let h = self.height;
435        let value = cx.animated(self.value, theme.motion.snappy).clamp(0.0, 1.0);
436        let style = LayoutStyle { height: Some(h), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() };
437        let node = cx.container(
438            style,
439            &[],
440            Paint::Custom(std::rc::Rc::new(move |frame: Rect, scene| {
441                scene.push_quad(Quad::new(frame, track).radius(h / 2.0));
442                if value > 0.0 {
443                    let w = (frame.width() * value).max(h);
444                    let bar = Rect::new(frame.x(), frame.y(), w, h);
445                    scene.push_quad(Quad::new(bar, fill).radius(h / 2.0));
446                }
447            })),
448        );
449        let percent = (f64::from(self.value) * 100.0).round();
450        let mut semantics = Semantics::new(Role::ProgressIndicator)
451            .numeric(percent, 0.0, 100.0, None)
452            .value(format!("{percent}%"));
453        if let Some(label) = &self.label {
454            semantics = semantics.name(label.clone());
455        }
456        if let Some(detail) = &self.detail {
457            semantics = semantics.description(detail.clone());
458        }
459        if self.label.is_none() && self.detail.is_none() && !self.percent {
460            cx.semantics(node, semantics);
461            return node;
462        }
463        let (ink, muted) = (theme.color(Semantic::Ink), theme.color(Semantic::InkMuted));
464        let mut rows = Vec::with_capacity(3);
465        if self.label.is_some() || self.percent {
466            let mut head = Vec::with_capacity(2);
467            let name = self.label.as_deref().unwrap_or_default();
468            let name = util::label(cx, name, TextStyle::Callout, None, ink);
469            cx.semantics(name, Semantics::hidden());
470            head.push(cx.container(
471                LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::stack(Axis::Horizontal) },
472                &[name],
473                Paint::None,
474            ));
475            if self.percent {
476                let number = util::label(cx, &format!("{percent}%"), TextStyle::Callout, None, muted);
477                cx.semantics(number, Semantics::hidden());
478                head.push(number);
479            }
480            rows.push(cx.container(
481                LayoutStyle {
482                    spacing: theme.spacing.s2,
483                    align_self: Some(Align::Stretch),
484                    ..LayoutStyle::stack(Axis::Horizontal)
485                },
486                &head,
487                Paint::None,
488            ));
489        }
490        rows.push(node);
491        if let Some(detail) = &self.detail {
492            let line = util::label(cx, detail, TextStyle::Caption, None, muted);
493            cx.semantics(line, Semantics::hidden());
494            rows.push(line);
495        }
496        let column = cx.container(
497            LayoutStyle {
498                spacing: theme.spacing.s1,
499                align_self: Some(Align::Stretch),
500                ..LayoutStyle::stack(Axis::Vertical)
501            },
502            &rows,
503            Paint::None,
504        );
505        cx.semantics(column, semantics);
506        column
507    }
508}
509
510/// A placeholder block shown while content loads.
511#[derive(Clone, Copy, Debug)]
512#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
513pub struct Skeleton {
514    width: Option<f32>,
515    height: f32,
516    radius: Option<f32>,
517}
518
519#[cfg_attr(feature = "live", ravenrs_live::expose)]
520impl Skeleton {
521    /// A block that stretches across its stack.
522    pub fn line(height: f32) -> Self {
523        Self { width: None, height, radius: None }
524    }
525
526    /// A rectangle of fixed size in logical pixels.
527    pub fn block(width: f32, height: f32) -> Self {
528        Self { width: Some(width), height, radius: None }
529    }
530
531    /// A circle `size` logical pixels across.
532    pub fn circle(size: f32) -> Self {
533        Self { width: Some(size), height: size, radius: Some(size / 2.0) }
534    }
535}
536
537impl View for Skeleton {
538    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
539        let theme = *cx.theme();
540        let style = LayoutStyle {
541            width: self.width,
542            height: Some(self.height),
543            align_self: self.width.is_none().then_some(Align::Stretch),
544            ..LayoutStyle::leaf()
545        };
546        let radius = self.radius.unwrap_or(theme.radii.sm);
547        let (base, ink) = (theme.color(Semantic::Control), theme.color(Semantic::Ink));
548        let node = cx.container(
549            style,
550            &[],
551            Paint::Live(std::rc::Rc::new(move |frame: Rect, time, scene| {
552                // A slow breath, which starts at rest under reduced motion.
553                let breath = (1.0 - (time * std::f64::consts::TAU / 1.6).cos()) as f32 / 2.0;
554                scene.push_quad(Quad::new(frame, base.lerp(ink, 0.08 * breath)).radius(radius));
555            })),
556        );
557        cx.semantics(node, Semantics::hidden());
558        node
559    }
560}
561
562/// A tinted message box with an icon, a title and a description.
563#[derive(Clone)]
564pub struct Alert<A = ()> {
565    title: String,
566    message: String,
567    tone: Tone,
568    compact: bool,
569    action: A,
570}
571
572/// What [`Alert::action`] adds to an alert. Its own type parameter, so an alert
573/// without a button stays `Send`.
574#[derive(Clone)]
575#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
576pub struct AlertAction {
577    label: String,
578    on_click: Rc<dyn Fn()>,
579}
580
581impl std::fmt::Debug for AlertAction {
582    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583        f.debug_struct("AlertAction").field("label", &self.label).finish_non_exhaustive()
584    }
585}
586
587impl<A> std::fmt::Debug for Alert<A> {
588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589        f.debug_struct("Alert").field("title", &self.title).field("tone", &self.tone).finish_non_exhaustive()
590    }
591}
592
593#[cfg_attr(feature = "live", ravenrs_live::expose)]
594impl Alert {
595    /// An accent-toned alert with a title and a message.
596    pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
597        Self { title: title.into(), message: message.into(), tone: Tone::Accent, compact: false, action: () }
598    }
599
600    /// An alert that is only its message, for when a title would just repeat where it is.
601    pub fn message(message: impl Into<String>) -> Self {
602        Self::new(String::new(), message)
603    }
604
605    /// A button beside the message, so that what it asks for is one click away.
606    #[must_use]
607    pub fn action(self, label: impl Into<String>, on_click: impl Fn() + 'static) -> Alert<AlertAction> {
608        Alert {
609            title: self.title,
610            message: self.message,
611            tone: self.tone,
612            compact: self.compact,
613            action: AlertAction { label: label.into(), on_click: Rc::new(on_click) },
614        }
615    }
616}
617
618#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1", with = "A = (); A = crate::AlertAction"))]
619impl<A> Alert<A> {
620    /// Sets the color tone and icon.
621    pub fn tone(mut self, tone: Tone) -> Self {
622        self.tone = tone;
623        self
624    }
625
626    /// A thin strip in small text with a stripe at its edge, for a warning that sits
627    /// right next to what it is about, as in a side panel.
628    pub fn compact(mut self) -> Self {
629        self.compact = true;
630        self
631    }
632
633    /// The strip of [`Self::compact`]. The fill stays a tint: a solid color in a dark
634    /// panel would shout over the text it carries.
635    fn draw_compact(&self, cx: &mut BuildCx<'_>, action: Option<&AlertAction>) -> NodeId {
636        let theme = *cx.theme();
637        let color = self.tone.color(&theme);
638        let ink = color.lerp(theme.color(Semantic::Ink), BADGE_TEXT_TOWARD_INK);
639        let stripe = cx.container(
640            LayoutStyle { width: Some(2.0), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() },
641            &[],
642            Paint::Quad(QuadPaint::new(color)),
643        );
644        let icon = util::icon(cx, self.tone.icon(), 14.0, color);
645        let mut lines = Vec::with_capacity(2);
646        if !self.title.is_empty() {
647            lines.push(util::label(cx, &self.title, TextStyle::Caption, Some(Weight::SEMIBOLD), ink));
648        }
649        lines.push(util::label(cx, &self.message, TextStyle::Caption, None, ink));
650        let text = cx.container(
651            LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::stack(Axis::Vertical) },
652            &lines,
653            Paint::None,
654        );
655        let mut items = vec![icon, text];
656        if let Some(action) = action {
657            let on_click = action.on_click.clone();
658            let button = Button::new(action.label.clone())
659                .style(ButtonStyle::Ghost)
660                .size(ButtonSize::Mini)
661                .on_click(move || on_click());
662            items.push(cx.child(&button));
663        }
664        let inner = cx.container(
665            LayoutStyle {
666                padding: Insets::symmetric(theme.spacing.s1, theme.spacing.s2),
667                spacing: theme.spacing.s2,
668                grow: 1.0,
669                min_width: Some(0.0),
670                align: Align::Center,
671                ..LayoutStyle::stack(Axis::Horizontal)
672            },
673            &items,
674            Paint::None,
675        );
676        let node = cx.container(
677            LayoutStyle {
678                align_self: Some(Align::Stretch),
679                overflow_hidden: true,
680                ..LayoutStyle::stack(Axis::Horizontal)
681            },
682            &[stripe, inner],
683            Paint::Quad(QuadPaint {
684                corner_radius: theme.radii.sm,
685                ..QuadPaint::new(color.with_alpha(0.15))
686            }),
687        );
688        cx.clip(node);
689        let role = if self.tone == Tone::Danger { Role::Alert } else { Role::Status };
690        cx.semantics(node, Semantics::new(role));
691        node
692    }
693
694    fn draw(&self, cx: &mut BuildCx<'_>, action: Option<&AlertAction>) -> NodeId {
695        if self.compact {
696            return self.draw_compact(cx, action);
697        }
698        let theme = *cx.theme();
699        let color = self.tone.color(&theme);
700        let icon = util::icon(cx, self.tone.icon(), 18.0, color);
701        let mut lines = Vec::with_capacity(2);
702        if self.title.is_empty() {
703            lines.push(util::label(cx, &self.message, TextStyle::Body, None, theme.color(Semantic::Ink)));
704        } else {
705            lines.push(util::label(
706                cx,
707                &self.title,
708                TextStyle::Body,
709                Some(Weight::SEMIBOLD),
710                theme.color(Semantic::Ink),
711            ));
712            lines.push(util::label(
713                cx,
714                &self.message,
715                TextStyle::Callout,
716                None,
717                theme.color(Semantic::InkMuted),
718            ));
719        }
720        let text = cx.container(
721            LayoutStyle { spacing: 2.0, grow: 1.0, ..LayoutStyle::stack(Axis::Vertical) },
722            &lines,
723            Paint::None,
724        );
725        let style = LayoutStyle {
726            padding: Insets::symmetric(14.0, 16.0),
727            spacing: 12.0,
728            align_self: Some(Align::Stretch),
729            ..LayoutStyle::stack(Axis::Horizontal)
730        };
731        let paint = QuadPaint {
732            fill: color.with_alpha(0.10).into(),
733            corner_radius: theme.radii.lg,
734            border: Some(Border { width: 1.0, color: color.with_alpha(0.30) }),
735            shadow: None,
736        };
737        let mut children = vec![icon, text];
738        if let Some(action) = action {
739            let on_click = action.on_click.clone();
740            let button = Button::new(action.label.clone())
741                .style(ButtonStyle::Secondary)
742                .size(ButtonSize::Small)
743                .on_click(move || on_click());
744            children.push(cx.child(&button));
745        }
746        let node = cx.container(style, &children, Paint::Quad(paint));
747        let role = if self.tone == Tone::Danger { Role::Alert } else { Role::Status };
748        cx.semantics(node, Semantics::new(role));
749        node
750    }
751}
752
753impl View for Alert {
754    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
755        self.draw(cx, None)
756    }
757}
758
759impl View for Alert<AlertAction> {
760    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
761        self.draw(cx, Some(&self.action))
762    }
763}
764
765/// A headline number with a label and an optional change indicator.
766#[derive(Clone, Debug)]
767#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
768pub struct Stat {
769    label: String,
770    value: String,
771    delta: Option<(String, bool, bool)>,
772    large: bool,
773}
774
775#[cfg_attr(feature = "live", ravenrs_live::expose)]
776impl Stat {
777    /// A stat showing `value` under `label`.
778    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
779        Self { label: label.into(), value: value.into(), delta: None, large: false }
780    }
781
782    /// The one number a screen is about, in the `metric` size.
783    pub fn large(mut self) -> Self {
784        self.large = true;
785        self
786    }
787
788    /// A change such as `"+12.4%"`; `up` picks the color and arrow.
789    pub fn delta(mut self, text: impl Into<String>, up: bool) -> Self {
790        self.delta = Some((text.into(), up, up));
791        self
792    }
793
794    /// A change where going up is not always good, such as spending: `up` picks
795    /// the arrow and `good` the color.
796    pub fn delta_toned(mut self, text: impl Into<String>, up: bool, good: bool) -> Self {
797        self.delta = Some((text.into(), up, good));
798        self
799    }
800}
801
802impl View for Stat {
803    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
804        let theme = *cx.theme();
805        let label = util::label(
806            cx,
807            &self.label.to_uppercase(),
808            TextStyle::Caption,
809            None,
810            theme.color(Semantic::InkMuted),
811        );
812        let mut value_attrs = util::attrs(&theme, TextStyle::Metric, None);
813        if !self.large {
814            value_attrs.size = 30.0;
815            value_attrs.line_height = 36.0;
816        }
817        let value = cx.text(&self.value, &value_attrs, theme.color(Semantic::Ink), LayoutStyle::leaf());
818        let mut children = vec![label, value];
819        if let Some((text, up, good)) = &self.delta {
820            let badge = Badge::new(text.clone())
821                .tone(if *good { Tone::Success } else { Tone::Danger })
822                .icon(if *up { IconName::TrendingUp } else { IconName::TrendingDown });
823            children.push(cx.child(&badge));
824        }
825        cx.container(
826            LayoutStyle { spacing: 6.0, ..LayoutStyle::stack(Axis::Vertical) },
827            &children,
828            Paint::None,
829        )
830    }
831}
832
833/// A titled group of content with an optional description, and actions at the end of
834/// its header.
835pub struct Section<C> {
836    title: String,
837    description: Option<String>,
838    actions: Vec<crate::AnyView>,
839    content: C,
840}
841
842impl<C> std::fmt::Debug for Section<C> {
843    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
844        f.debug_struct("Section").field("title", &self.title).finish_non_exhaustive()
845    }
846}
847
848#[cfg_attr(feature = "live", ravenrs_live::expose)]
849impl<C: ViewSeq> Section<C> {
850    /// A section titled `title` around `content`.
851    pub fn new(title: impl Into<String>, content: C) -> Self {
852        Self { title: title.into(), description: None, actions: Vec::new(), content }
853    }
854
855    /// Adds a muted description under the title.
856    pub fn description(mut self, text: impl Into<String>) -> Self {
857        self.description = Some(text.into());
858        self
859    }
860
861    /// Adds a view, usually a button, at the end of the header. Several line up in order.
862    pub fn action(mut self, action: impl View + 'static) -> Self {
863        self.actions.push(Box::new(action));
864        self
865    }
866}
867
868impl<C: ViewSeq> View for Section<C> {
869    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
870        let theme = *cx.theme();
871        let title = util::label(cx, &self.title, TextStyle::Title, None, theme.color(Semantic::Ink));
872        cx.semantics(title, Semantics::new(Role::Heading).level(2));
873        let mut header = vec![title];
874        if let Some(d) = &self.description {
875            header.push(util::label(cx, d, TextStyle::Body, None, theme.color(Semantic::InkMuted)));
876        }
877        let header = cx.container(
878            LayoutStyle { spacing: 4.0, ..LayoutStyle::stack(Axis::Vertical) },
879            &header,
880            Paint::None,
881        );
882        let header = if self.actions.is_empty() {
883            header
884        } else {
885            let header = cx.container(
886                LayoutStyle { grow: 1.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
887                &[header],
888                Paint::None,
889            );
890            let mut row = vec![header];
891            for (i, action) in self.actions.iter().enumerate() {
892                row.push(cx.keyed_child(u64::MAX - i as u64, action.as_ref()));
893            }
894            cx.container(
895                LayoutStyle {
896                    spacing: theme.spacing.s2,
897                    align: Align::Center,
898                    ..LayoutStyle::stack(Axis::Horizontal)
899                },
900                &row,
901                Paint::None,
902            )
903        };
904        let mut children = vec![header];
905        children.extend(cx.children(&self.content));
906        let style =
907            LayoutStyle { spacing: 16.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) };
908        cx.container(style, &children, Paint::None)
909    }
910}