1use 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
12const BADGE_TEXT_TOWARD_INK: f32 = 0.3;
14const AVATAR_TEXT_TOWARD_INK: f32 = 0.5;
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20#[non_exhaustive]
21#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
22pub enum Tone {
23 #[default]
25 Neutral,
26 Accent,
28 Success,
30 Warning,
32 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#[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 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 pub fn tone(mut self, tone: Tone) -> Self {
79 self.tone = tone;
80 self
81 }
82
83 pub fn icon(mut self, icon: IconName) -> Self {
85 self.icon = Some(icon);
86 self
87 }
88
89 pub fn color(mut self, color: Color) -> Self {
92 self.color = Some(color);
93 self
94 }
95
96 pub fn outline(mut self) -> Self {
98 self.outline = true;
99 self
100 }
101
102 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 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#[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 pub fn new(name: impl Into<String>) -> Self {
158 Self { name: name.into(), size: 36.0, image: None, border: None }
159 }
160
161 pub fn size(mut self, size: f32) -> Self {
163 self.size = size;
164 self
165 }
166
167 pub fn image(mut self, image: Image) -> Self {
170 self.image = Some(image);
171 self
172 }
173
174 pub fn border(mut self, width: f32, fill: impl Into<Fill>) -> Self {
177 self.border = Some((width, fill.into()));
178 self
179 }
180
181 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#[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 pub fn new(keys: impl Into<String>) -> Self {
281 Self { keys: keys.into(), inherit: false }
282 }
283
284 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#[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 pub fn horizontal() -> Self {
330 Self { vertical: false }
331 }
332
333 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#[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 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 pub fn tone(mut self, tone: Tone) -> Self {
392 self.tone = tone;
393 self
394 }
395
396 pub fn color(mut self, color: Color) -> Self {
398 self.color = Some(color);
399 self
400 }
401
402 pub fn height(mut self, height: f32) -> Self {
404 self.height = height.max(1.0);
405 self
406 }
407
408 pub fn label(mut self, label: impl Into<String>) -> Self {
410 self.label = Some(label.into());
411 self
412 }
413
414 pub fn percent(mut self) -> Self {
416 self.percent = true;
417 self
418 }
419
420 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#[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 pub fn line(height: f32) -> Self {
523 Self { width: None, height, radius: None }
524 }
525
526 pub fn block(width: f32, height: f32) -> Self {
528 Self { width: Some(width), height, radius: None }
529 }
530
531 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 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#[derive(Clone)]
564pub struct Alert<A = ()> {
565 title: String,
566 message: String,
567 tone: Tone,
568 compact: bool,
569 action: A,
570}
571
572#[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 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 pub fn message(message: impl Into<String>) -> Self {
602 Self::new(String::new(), message)
603 }
604
605 #[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 pub fn tone(mut self, tone: Tone) -> Self {
622 self.tone = tone;
623 self
624 }
625
626 pub fn compact(mut self) -> Self {
629 self.compact = true;
630 self
631 }
632
633 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#[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 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 pub fn large(mut self) -> Self {
784 self.large = true;
785 self
786 }
787
788 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 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
833pub 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 pub fn new(title: impl Into<String>, content: C) -> Self {
852 Self { title: title.into(), description: None, actions: Vec::new(), content }
853 }
854
855 pub fn description(mut self, text: impl Into<String>) -> Self {
857 self.description = Some(text.into());
858 self
859 }
860
861 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}