Skip to main content

ravenrs_widgets/
card.rs

1use std::fmt;
2use std::rc::Rc;
3
4use ravenrs_core::{Border, Color, CursorIcon, Insets, Role, Shadow};
5use ravenrs_layout::{Align, Axis, LayoutStyle};
6use ravenrs_style::Semantic;
7
8use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
9use crate::{Fill, View};
10
11/// A grouped surface lifted off the canvas: `bg-200` fill, `radius-xl` corners,
12/// `shadow-soft` and a hairline border, padded by `space-6`.
13pub struct Card<V> {
14    content: V,
15    fill: Fill,
16    corner_radius: Option<f32>,
17    shadow: Option<Option<Shadow>>,
18    padding: Option<Insets>,
19    border: bool,
20    spacing: Option<f32>,
21    on_click: Option<Rc<dyn Fn()>>,
22    selected: Option<bool>,
23}
24
25impl<V: fmt::Debug> fmt::Debug for Card<V> {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        f.debug_struct("Card").field("content", &self.content).finish_non_exhaustive()
28    }
29}
30
31#[cfg_attr(feature = "live", ravenrs_live::expose)]
32impl<V: View> Card<V> {
33    /// A card around `content` with the theme's surface, radius, shadow and padding.
34    pub fn new(content: V) -> Self {
35        Self {
36            content,
37            fill: Semantic::SurfaceElevated.into(),
38            corner_radius: None,
39            shadow: None,
40            padding: None,
41            border: true,
42            spacing: None,
43            on_click: None,
44            selected: None,
45        }
46    }
47
48    /// Corner radius in logical pixels (theme `radius-xl` by default).
49    pub fn corner_radius(mut self, radius: f32) -> Self {
50        self.corner_radius = Some(radius);
51        self
52    }
53
54    /// Replaces the theme's `shadow-soft`.
55    pub fn shadow(mut self, shadow: Shadow) -> Self {
56        self.shadow = Some(Some(shadow));
57        self
58    }
59
60    /// Removes the shadow.
61    pub fn no_shadow(mut self) -> Self {
62        self.shadow = Some(None);
63        self
64    }
65
66    /// Replaces the theme's card padding.
67    pub fn padding(mut self, padding: impl Into<Insets>) -> Self {
68        self.padding = Some(padding.into());
69        self
70    }
71
72    /// Background, as a theme role (`SurfaceElevated` by default) or a literal color.
73    pub fn fill(mut self, fill: impl Into<Fill>) -> Self {
74        self.fill = fill.into();
75        self
76    }
77
78    /// Shows or hides the hairline border (shown by default).
79    pub fn border(mut self, visible: bool) -> Self {
80        self.border = visible;
81        self
82    }
83
84    /// Makes the whole card a button: it runs `handler` when clicked, or on
85    /// Enter and Space while focused, and marks hover with the accent.
86    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
87        self.on_click = Some(Rc::new(handler));
88        self
89    }
90
91    /// Marks it as the chosen one among others: a thicker accent border and a tint of
92    /// the accent, with screen readers told which. Works with or without
93    /// [`Self::on_click`], and shows even with [`Self::border`] off.
94    pub fn selected(mut self, selected: bool) -> Self {
95        self.selected = Some(selected);
96        self
97    }
98
99    fn paint(&self, cx: &BuildCx<'_>, fill: Color) -> QuadPaint {
100        let theme = cx.theme();
101        let active = self.on_click.is_some() && (cx.is_hovered() || cx.is_focus_visible());
102        let selected = self.selected == Some(true);
103        let accent = theme.color(Semantic::Accent);
104        QuadPaint {
105            fill: if selected { fill.lerp(accent, 0.12).into() } else { fill.into() },
106            corner_radius: self.corner_radius.unwrap_or(theme.radii.xl),
107            // The width says it too, so being chosen is not told by color alone.
108            border: if selected {
109                Some(Border { width: 2.0, color: accent })
110            } else {
111                self.border.then(|| Border {
112                    width: 1.0,
113                    color: theme.color(if active { Semantic::Accent } else { Semantic::Border }),
114                })
115            },
116            shadow: self.shadow.unwrap_or(Some(theme.elevation.soft)),
117        }
118    }
119
120    fn build_with(&self, cx: &mut BuildCx<'_>, fill: Color) -> NodeId {
121        let paint = self.paint(cx, fill);
122        let style = LayoutStyle {
123            padding: self.padding.unwrap_or_else(|| cx.theme().card_padding()),
124            spacing: self.spacing.unwrap_or(cx.theme().spacing.s2),
125            align: Align::Stretch,
126            ..LayoutStyle::stack(Axis::Vertical)
127        };
128        let corner_radius = paint.corner_radius;
129        let child = cx.child(&self.content);
130        let node = cx.container(style, &[child], Paint::Quad(paint));
131        if self.on_click.is_some() {
132            crate::motion::control(cx, node);
133        }
134        if let Some(handler) = &self.on_click {
135            let mut semantics = Semantics::new(Role::Button);
136            if let Some(selected) = self.selected {
137                semantics = semantics.selected(selected);
138            }
139            cx.semantics(node, semantics);
140            cx.on_click(node, handler.clone());
141            cx.focusable(node, corner_radius);
142            cx.cursor(node, CursorIcon::Pointer);
143        } else if let Some(selected) = self.selected {
144            cx.semantics(node, Semantics::new(Role::Group).selected(selected));
145        }
146        node
147    }
148}
149
150impl<V: View> View for Card<V> {
151    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
152        let fill = self.fill.resolve(cx.theme());
153        self.build_with(cx, fill)
154    }
155}
156
157/// A translucent card for Raven Glass compositions: a light, see-through fill
158/// and a brighter hairline over whatever is behind it.
159///
160/// Status: the backdrop blur of the glass material needs the renderer's layer
161/// compositor (planned); until then the card renders its translucent fill only.
162#[derive(Debug)]
163pub struct GlassCard<V> {
164    card: Card<V>,
165}
166
167#[cfg_attr(feature = "live", ravenrs_live::expose)]
168impl<V: View> GlassCard<V> {
169    /// A glass card around `content`.
170    pub fn new(content: V) -> Self {
171        Self { card: Card::new(content).fill(Color::hex_alpha(0xffffff1a)) }
172    }
173
174    /// Corner radius in logical pixels (theme `radius-xl` by default).
175    pub fn corner_radius(mut self, radius: f32) -> Self {
176        self.card = self.card.corner_radius(radius);
177        self
178    }
179
180    /// Replaces the theme's `shadow-soft`.
181    pub fn shadow(mut self, shadow: Shadow) -> Self {
182        self.card = self.card.shadow(shadow);
183        self
184    }
185
186    /// Replaces the theme's card padding.
187    pub fn padding(mut self, padding: impl Into<Insets>) -> Self {
188        self.card = self.card.padding(padding);
189        self
190    }
191}
192
193impl<V: View> View for GlassCard<V> {
194    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
195        let fill = self.card.fill.resolve(cx.theme());
196        self.card.build_with(cx, fill)
197    }
198}