1use 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
18const UNLIT_STAR: f32 = 0.35;
20const SHIMMER_PERIOD: f64 = 1.8;
22const SHIMMER_REACH: f32 = 36.0;
24const SHIMMER_FLOOR: f32 = 0.04;
26
27#[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 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn tone(mut self, tone: Tone) -> Self {
55 self.tone = tone;
56 self
57 }
58
59 pub fn color(mut self, color: Color) -> Self {
61 self.color = Some(color);
62 self
63 }
64
65 pub fn size(mut self, size: f32) -> Self {
67 self.size = Some(size);
68 self
69 }
70
71 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#[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 pub fn new(content: C, badge: B) -> Self {
124 Self { content, badge, corner: FloatingCorner::TopRight, inset: 0.0, visible: true }
125 }
126
127 pub fn corner(mut self, corner: FloatingCorner) -> Self {
129 self.corner = corner;
130 self
131 }
132
133 pub fn inset(mut self, inset: f32) -> Self {
136 self.inset = inset;
137 self
138 }
139
140 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 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 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#[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 pub fn new(value: Binding<u32>) -> Self {
225 Self::with(Some(value), 0)
226 }
227
228 pub fn read_only(value: u32) -> Self {
231 Self::with(None, value)
232 }
233
234 pub fn max(mut self, max: u32) -> Self {
236 self.max = max;
237 self
238 }
239
240 pub fn size(mut self, size: f32) -> Self {
242 self.size = size;
243 self
244 }
245
246 pub fn tone(mut self, tone: Tone) -> Self {
248 self.tone = tone;
249 self
250 }
251
252 pub fn color(mut self, color: Color) -> Self {
254 self.color = Some(color);
255 self
256 }
257
258 pub fn label(mut self, label: impl Into<String>) -> Self {
260 self.label = Some(label.into());
261 self
262 }
263
264 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 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
386struct 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#[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 pub fn new(icon: IconName) -> Self {
435 Self { icon, tone: Tone::Neutral, color: None, size: 48.0, label: None }
436 }
437
438 pub fn tone(mut self, tone: Tone) -> Self {
440 self.tone = tone;
441 self
442 }
443
444 pub fn color(mut self, color: Color) -> Self {
446 self.color = Some(color);
447 self
448 }
449
450 pub fn size(mut self, size: f32) -> Self {
452 self.size = size;
453 self
454 }
455
456 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#[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 pub fn new(title: impl Into<String>) -> Self {
522 Self { title: title.into(), description: None, icon: None, actions: Vec::new() }
523 }
524
525 pub fn description(mut self, text: impl Into<String>) -> Self {
527 self.description = Some(text.into());
528 self
529 }
530
531 pub fn icon(mut self, icon: IconName) -> Self {
533 self.icon = Some(icon);
534 self
535 }
536
537 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 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#[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 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 pub fn description(mut self, text: impl Into<String>) -> Self {
626 self.description = Some(text.into());
627 self
628 }
629
630 pub fn icon(mut self, icon: IconName) -> Self {
632 self.icon = Some(icon);
633 self
634 }
635
636 pub fn tone(mut self, tone: Tone) -> Self {
638 self.tone = tone;
639 self
640 }
641
642 pub fn color(mut self, color: Color) -> Self {
644 self.color = Some(color);
645 self
646 }
647
648 pub fn action(mut self, action: impl View + 'static) -> Self {
650 self.actions.push(Box::new(action));
651 self
652 }
653}
654
655fn 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#[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 pub fn new() -> Self {
727 Self::default()
728 }
729
730 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 pub fn bar(mut self, bar: ProgressBar) -> Self {
739 self.bars.push(bar);
740 self
741 }
742
743 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#[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 pub fn new(text: impl Into<String>) -> Self {
793 Self { text: text.into(), style: TextStyle::Callout, active: true }
794 }
795
796 pub fn style(mut self, style: TextStyle) -> Self {
798 self.style = style;
799 self
800 }
801
802 pub fn active(mut self, active: bool) -> Self {
804 self.active = active;
805 self
806 }
807}
808
809#[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 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#[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 pub fn new(text: impl Into<String>) -> Self {
915 Self { text: DayText::Free(text.into()) }
916 }
917
918 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}