Skip to main content

jag_ui/elements/
card.rs

1//! Card element with optional title, shadow, and content slots.
2
3use jag_draw::{Brush, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    EventHandler, EventResult, KeyboardEvent, MouseClickEvent, MouseMoveEvent, ScrollEvent,
8};
9use crate::focus::FocusId;
10
11use super::Element;
12
13/// Layout information for a card's header and content areas.
14#[derive(Debug, Clone, Copy)]
15pub struct CardLayout {
16    pub header: Rect,
17    pub content: Rect,
18}
19
20/// A simple card with optional title, rounded background, and shadow.
21pub struct Card {
22    pub rect: Rect,
23    /// Optional title displayed at the top of the card.
24    pub title: Option<String>,
25    /// Title font size.
26    pub title_size: f32,
27    /// Title text color.
28    pub title_color: ColorLinPremul,
29    /// Background fill color.
30    pub bg: ColorLinPremul,
31    /// Border color.
32    pub border_color: ColorLinPremul,
33    /// Border width (0 = no border).
34    pub border_width: f32,
35    /// Corner radius.
36    pub radius: f32,
37    /// Height reserved for the title/header area.
38    pub header_height: f32,
39    /// Whether to render a drop shadow.
40    pub show_shadow: bool,
41    /// Shadow color.
42    pub shadow_color: ColorLinPremul,
43    /// Shadow offset in pixels [x, y].
44    pub shadow_offset: [f32; 2],
45    /// Shadow spread (extra pixels in each direction).
46    pub shadow_spread: f32,
47}
48
49impl Card {
50    /// Create a card with default styling.
51    pub fn new(rect: Rect) -> Self {
52        Self {
53            rect,
54            title: None,
55            title_size: 16.0,
56            title_color: ColorLinPremul::from_srgba_u8([20, 20, 20, 255]),
57            bg: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
58            border_color: ColorLinPremul::from_srgba_u8([226, 232, 240, 255]),
59            border_width: 1.0,
60            radius: 8.0,
61            header_height: 48.0,
62            show_shadow: true,
63            shadow_color: ColorLinPremul::from_srgba_u8([0, 0, 0, 30]),
64            shadow_offset: [0.0, 2.0],
65            shadow_spread: 4.0,
66        }
67    }
68
69    /// Set the card title.
70    pub fn with_title(mut self, title: impl Into<String>) -> Self {
71        self.title = Some(title.into());
72        self
73    }
74
75    /// Compute header and content rectangles inside the card.
76    pub fn layout(&self) -> CardLayout {
77        let has_title = self.title.is_some();
78        let header_h = if has_title {
79            self.header_height.clamp(0.0, self.rect.h)
80        } else {
81            0.0
82        };
83        let content_h = (self.rect.h - header_h).max(0.0);
84
85        CardLayout {
86            header: Rect {
87                x: self.rect.x,
88                y: self.rect.y,
89                w: self.rect.w,
90                h: header_h,
91            },
92            content: Rect {
93                x: self.rect.x,
94                y: self.rect.y + header_h,
95                w: self.rect.w,
96                h: content_h,
97            },
98        }
99    }
100
101    /// Hit-test: is `(x, y)` inside the card rect?
102    pub fn hit_test(&self, x: f32, y: f32) -> bool {
103        x >= self.rect.x
104            && x <= self.rect.x + self.rect.w
105            && y >= self.rect.y
106            && y <= self.rect.y + self.rect.h
107    }
108}
109
110// ---------------------------------------------------------------------------
111// Element trait
112// ---------------------------------------------------------------------------
113
114impl Element for Card {
115    fn rect(&self) -> Rect {
116        self.rect
117    }
118
119    fn set_rect(&mut self, rect: Rect) {
120        self.rect = rect;
121    }
122
123    fn render(&self, canvas: &mut Canvas, z: i32) {
124        // Shadow (simple offset rectangle)
125        if self.show_shadow {
126            let shadow_rect = Rect {
127                x: self.rect.x + self.shadow_offset[0] - self.shadow_spread,
128                y: self.rect.y + self.shadow_offset[1] - self.shadow_spread,
129                w: self.rect.w + self.shadow_spread * 2.0,
130                h: self.rect.h + self.shadow_spread * 2.0,
131            };
132            let shadow_rrect = RoundedRect {
133                rect: shadow_rect,
134                radii: RoundedRadii {
135                    tl: self.radius + self.shadow_spread,
136                    tr: self.radius + self.shadow_spread,
137                    br: self.radius + self.shadow_spread,
138                    bl: self.radius + self.shadow_spread,
139                },
140            };
141            canvas.rounded_rect(shadow_rrect, Brush::Solid(self.shadow_color), z);
142        }
143
144        // Background + border
145        let rrect = RoundedRect {
146            rect: self.rect,
147            radii: RoundedRadii {
148                tl: self.radius,
149                tr: self.radius,
150                br: self.radius,
151                bl: self.radius,
152            },
153        };
154
155        let border_w = if self.border_width > 0.0 {
156            Some(self.border_width)
157        } else {
158            None
159        };
160        let border_b = if self.border_width > 0.0 {
161            Some(Brush::Solid(self.border_color))
162        } else {
163            None
164        };
165
166        jag_surface::shapes::draw_snapped_rounded_rectangle(
167            canvas,
168            rrect,
169            Some(Brush::Solid(self.bg)),
170            border_w,
171            border_b,
172            z + 1,
173        );
174
175        // Title text
176        if let Some(ref title) = self.title {
177            let layout = self.layout();
178            let text_x = layout.header.x + 16.0;
179            let text_y = layout.header.y + layout.header.h * 0.5 + self.title_size * 0.35;
180            canvas.draw_text_run_weighted(
181                [text_x, text_y],
182                title.clone(),
183                self.title_size,
184                600.0,
185                self.title_color,
186                z + 2,
187            );
188        }
189    }
190
191    /// Cards are not focusable.
192    fn focus_id(&self) -> Option<FocusId> {
193        None
194    }
195}
196
197// ---------------------------------------------------------------------------
198// EventHandler trait
199// ---------------------------------------------------------------------------
200
201impl EventHandler for Card {
202    fn handle_mouse_click(&mut self, _event: &MouseClickEvent) -> EventResult {
203        EventResult::Ignored
204    }
205
206    fn handle_keyboard(&mut self, _event: &KeyboardEvent) -> EventResult {
207        EventResult::Ignored
208    }
209
210    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
211        EventResult::Ignored
212    }
213
214    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
215        EventResult::Ignored
216    }
217
218    fn is_focused(&self) -> bool {
219        false
220    }
221
222    fn set_focused(&mut self, _focused: bool) {}
223
224    fn contains_point(&self, x: f32, y: f32) -> bool {
225        self.hit_test(x, y)
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Tests
231// ---------------------------------------------------------------------------
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn card_defaults() {
239        let card = Card::new(Rect {
240            x: 0.0,
241            y: 0.0,
242            w: 300.0,
243            h: 200.0,
244        });
245        assert!(card.title.is_none());
246        assert!(card.show_shadow);
247        assert!((card.radius - 8.0).abs() < f32::EPSILON);
248    }
249
250    #[test]
251    fn card_with_title() {
252        let card = Card::new(Rect {
253            x: 0.0,
254            y: 0.0,
255            w: 300.0,
256            h: 200.0,
257        })
258        .with_title("My Card");
259        assert_eq!(card.title.as_deref(), Some("My Card"));
260    }
261
262    #[test]
263    fn card_layout_with_title() {
264        let card = Card::new(Rect {
265            x: 10.0,
266            y: 20.0,
267            w: 300.0,
268            h: 200.0,
269        })
270        .with_title("Header");
271        let layout = card.layout();
272        assert!((layout.header.h - 48.0).abs() < f32::EPSILON);
273        assert!((layout.content.h - 152.0).abs() < f32::EPSILON);
274        assert!((layout.content.y - 68.0).abs() < f32::EPSILON);
275    }
276
277    #[test]
278    fn card_layout_without_title() {
279        let card = Card::new(Rect {
280            x: 0.0,
281            y: 0.0,
282            w: 300.0,
283            h: 200.0,
284        });
285        let layout = card.layout();
286        assert!((layout.header.h).abs() < f32::EPSILON);
287        assert!((layout.content.h - 200.0).abs() < f32::EPSILON);
288    }
289
290    #[test]
291    fn card_hit_test() {
292        let card = Card::new(Rect {
293            x: 10.0,
294            y: 10.0,
295            w: 100.0,
296            h: 80.0,
297        });
298        assert!(card.hit_test(50.0, 50.0));
299        assert!(!card.hit_test(0.0, 0.0));
300    }
301
302    #[test]
303    fn card_not_focusable() {
304        let card = Card::new(Rect {
305            x: 0.0,
306            y: 0.0,
307            w: 100.0,
308            h: 100.0,
309        });
310        assert!(card.focus_id().is_none());
311    }
312}