Skip to main content

armas_basic/components/
card.rs

1//! Card Component
2//!
3//! Card container styled like shadcn/ui Card.
4//! A surface for displaying grouped content with consistent styling.
5//!
6//! # Variants
7//!
8//! - **Filled**: Muted background, no border
9//! - **Outlined**: Card background with border (shadcn default)
10//! - **Elevated**: Card background with shadow effect
11//!
12//! # Example
13//!
14//! ```rust,no_run
15//! use armas_basic::{Card, CardVariant};
16//!
17//! fn show_cards(ui: &mut egui::Ui) {
18//!     // Outlined card (shadcn default)
19//!     Card::new()
20//!         .variant(CardVariant::Outlined)
21//!         .title("Card Title")
22//!         .show(ui, |ui| {
23//!             ui.label("Content goes here");
24//!         });
25//! }
26//! ```
27
28use crate::ext::ArmasContextExt;
29use egui::{self, Color32, CornerRadius};
30
31// shadcn Card constants
32const CORNER_RADIUS: f32 = 8.0; // rounded-lg
33const PADDING: f32 = 24.0; // p-6
34const BORDER_WIDTH: f32 = 1.0;
35
36/// Card variant (shadcn/ui style)
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum CardVariant {
39    /// Filled card - muted background, no border
40    Filled,
41    /// Outlined card - card background with border (shadcn default)
42    #[default]
43    Outlined,
44    /// Elevated card - card background with shadow effect
45    Elevated,
46}
47
48/// Card component styled like shadcn/ui
49pub struct Card<'a> {
50    /// Optional title for the card
51    pub title: Option<&'a str>,
52    /// Card variant (Filled, Outlined, Elevated)
53    pub variant: CardVariant,
54    /// Whether the card is clickable (adds hover effect)
55    pub clickable: bool,
56    /// Custom width (None = fill available)
57    pub width: Option<f32>,
58    /// Custom height (None = determined by content)
59    pub height: Option<f32>,
60    /// Custom min height
61    pub min_height: Option<f32>,
62    /// Custom max height
63    pub max_height: Option<f32>,
64    /// Custom inner margin (None = use theme default)
65    pub inner_margin: Option<f32>,
66    /// Custom asymmetric margin (overrides `inner_margin` if set)
67    pub margin: Option<egui::Margin>,
68    /// Custom background color (None = use theme default)
69    pub fill_color: Option<Color32>,
70    /// Custom border color (None = use theme default)
71    pub stroke_color: Option<Color32>,
72    /// Custom corner radius (None = use theme default)
73    pub corner_radius: Option<f32>,
74}
75
76impl<'a> Card<'a> {
77    /// Create a new card with default Filled variant
78    #[must_use]
79    pub const fn new() -> Self {
80        Self {
81            title: None,
82            variant: CardVariant::Filled,
83            clickable: false,
84            width: None,
85            height: None,
86            min_height: None,
87            max_height: None,
88            inner_margin: None,
89            margin: None,
90            fill_color: None,
91            stroke_color: None,
92            corner_radius: None,
93        }
94    }
95
96    /// Set the card title
97    #[must_use]
98    pub const fn title(mut self, title: &'a str) -> Self {
99        self.title = Some(title);
100        self
101    }
102
103    /// Set custom height (forces exact height regardless of content)
104    #[must_use]
105    pub const fn height(mut self, height: f32) -> Self {
106        self.height = Some(height);
107        self
108    }
109
110    /// Set minimum height
111    #[must_use]
112    pub const fn min_height(mut self, height: f32) -> Self {
113        self.min_height = Some(height);
114        self
115    }
116
117    /// Set maximum height
118    #[must_use]
119    pub const fn max_height(mut self, height: f32) -> Self {
120        self.max_height = Some(height);
121        self
122    }
123
124    /// Set the card variant
125    #[must_use]
126    pub const fn variant(mut self, variant: CardVariant) -> Self {
127        self.variant = variant;
128        self
129    }
130
131    /// Make the card clickable (adds hover effect)
132    #[must_use]
133    pub const fn clickable(mut self, clickable: bool) -> Self {
134        self.clickable = clickable;
135        self
136    }
137
138    /// Set custom width
139    #[must_use]
140    pub const fn width(mut self, width: f32) -> Self {
141        self.width = Some(width);
142        self
143    }
144
145    /// Set custom inner margin (overrides theme default)
146    #[must_use]
147    pub const fn inner_margin(mut self, margin: f32) -> Self {
148        self.inner_margin = Some(margin);
149        self
150    }
151
152    /// Set custom asymmetric margin (overrides `inner_margin`)
153    /// Use this for different padding on each side
154    #[must_use]
155    pub const fn margin(mut self, margin: egui::Margin) -> Self {
156        self.margin = Some(margin);
157        self
158    }
159
160    /// Set custom fill/background color (overrides theme default)
161    #[must_use]
162    pub const fn fill(mut self, color: Color32) -> Self {
163        self.fill_color = Some(color);
164        self
165    }
166
167    /// Set custom stroke/border color (overrides theme default)
168    #[must_use]
169    pub const fn stroke(mut self, color: Color32) -> Self {
170        self.stroke_color = Some(color);
171        self
172    }
173
174    /// Set custom corner radius (overrides theme default)
175    #[must_use]
176    pub const fn corner_radius(mut self, radius: f32) -> Self {
177        self.corner_radius = Some(radius);
178        self
179    }
180
181    /// Alias for `corner_radius` for backwards compatibility
182    #[must_use]
183    pub const fn rounding(mut self, radius: f32) -> Self {
184        self.corner_radius = Some(radius);
185        self
186    }
187
188    /// Enable hover effect (same as clickable)
189    #[must_use]
190    pub const fn hover_effect(mut self, enable: bool) -> Self {
191        self.clickable = enable;
192        self
193    }
194
195    /// Show the card with content
196    ///
197    /// # Panics
198    ///
199    /// Panics if the content closure is not invoked during frame rendering.
200    pub fn show<R>(
201        self,
202        ui: &mut egui::Ui,
203        content: impl FnOnce(&mut egui::Ui) -> R,
204    ) -> CardResponse<R> {
205        let theme = ui.ctx().armas_theme();
206        // shadcn/ui variant styling
207        let (fill_color, border_width, border_color) = match self.variant {
208            CardVariant::Filled => {
209                // Filled: muted background, no border
210                (
211                    self.fill_color.unwrap_or_else(|| theme.muted()),
212                    0.0,
213                    Color32::TRANSPARENT,
214                )
215            }
216            CardVariant::Outlined => {
217                // Outlined: card background with border (shadcn default)
218                (
219                    self.fill_color.unwrap_or_else(|| theme.card()),
220                    BORDER_WIDTH,
221                    self.stroke_color.unwrap_or_else(|| theme.border()),
222                )
223            }
224            CardVariant::Elevated => {
225                // Elevated: card background with shadow effect (simulated via border)
226                (
227                    self.fill_color.unwrap_or_else(|| theme.card()),
228                    BORDER_WIDTH,
229                    self.stroke_color
230                        .unwrap_or_else(|| theme.border().gamma_multiply(0.5)),
231                )
232            }
233        };
234
235        let corner_rad = self.corner_radius.unwrap_or(CORNER_RADIUS) as u8;
236
237        let sense = if self.clickable {
238            egui::Sense::click()
239        } else {
240            egui::Sense::hover()
241        };
242
243        // Use asymmetric margin if provided, otherwise uniform margin (using shadcn PADDING)
244        let frame_margin = self.margin.unwrap_or_else(|| {
245            let margin_val = self.inner_margin.unwrap_or(PADDING) as i8;
246            egui::Margin::same(margin_val)
247        });
248        let mut content_result = None;
249
250        // If both width and height are specified, use exact size allocation
251        let outer_response = if let (Some(width), Some(height)) = (self.width, self.height) {
252            let desired_size = egui::Vec2::new(width, height);
253            let (rect, _) = ui.allocate_exact_size(desired_size, sense);
254
255            // Create a child UI at the exact allocated rect
256            // Use top-down layout
257            let mut child_ui = ui.new_child(
258                egui::UiBuilder::new()
259                    .max_rect(rect)
260                    .layout(egui::Layout::top_down(egui::Align::Min)),
261            );
262
263            let frame_response = egui::Frame::new()
264                .fill(fill_color)
265                .corner_radius(CornerRadius::same(corner_rad))
266                .stroke(egui::Stroke::new(border_width, border_color))
267                .inner_margin(frame_margin)
268                .outer_margin(0.0) // No outer margin to prevent spacing issues
269                .show(&mut child_ui, |ui| {
270                    // Title if provided
271                    if let Some(title) = self.title {
272                        ui.label(
273                            egui::RichText::new(title)
274                                .size(ui.spacing().interact_size.y * 0.7)
275                                .color(theme.foreground())
276                                .strong(),
277                        );
278                        ui.add_space(theme.spacing.sm);
279                    }
280
281                    // User content (no wrapping - components handle their own layout)
282                    content_result = Some(content(ui));
283                });
284
285            frame_response
286        } else {
287            // Fallback to flexible sizing for cases where exact size is not specified
288            ui.vertical(|ui| {
289                // Apply width constraint
290                if let Some(width) = self.width {
291                    ui.set_max_width(width);
292                }
293
294                // Apply height constraints
295                if let Some(height) = self.height {
296                    ui.set_height(height);
297                }
298                if let Some(min_height) = self.min_height {
299                    ui.set_min_height(min_height);
300                }
301                if let Some(max_height) = self.max_height {
302                    ui.set_max_height(max_height);
303                }
304
305                let frame_response = egui::Frame::new()
306                    .fill(fill_color)
307                    .corner_radius(CornerRadius::same(corner_rad))
308                    .stroke(egui::Stroke::new(border_width, border_color))
309                    .inner_margin(frame_margin)
310                    .outer_margin(0.0) // No outer margin to prevent spacing issues
311                    .show(ui, |ui| {
312                        // Title if provided
313                        if let Some(title) = self.title {
314                            ui.label(
315                                egui::RichText::new(title)
316                                    .size(ui.spacing().interact_size.y * 0.7)
317                                    .color(theme.foreground())
318                                    .strong(),
319                            );
320                            ui.add_space(theme.spacing.sm);
321                        }
322
323                        // User content
324                        content_result = Some(content(ui));
325                    });
326
327                frame_response
328            })
329            .inner
330        };
331
332        // Make the entire frame interactive if clickable
333        let rect = outer_response.response.rect;
334        let response = if self.clickable {
335            ui.interact(rect, ui.id().with("card"), sense)
336        } else {
337            outer_response.response
338        };
339
340        // Apply hover background if clickable and hovered
341        if self.clickable && response.hovered() {
342            ui.painter()
343                .rect_filled(rect, CornerRadius::same(corner_rad), theme.accent());
344        }
345
346        CardResponse {
347            response,
348            inner: content_result.expect("content should be set during frame render"),
349        }
350    }
351}
352
353impl Default for Card<'_> {
354    fn default() -> Self {
355        Self::new()
356    }
357}
358
359/// Response from showing a card
360pub struct CardResponse<R> {
361    /// The interaction response for the card
362    pub response: egui::Response,
363    /// The result from the content closure
364    pub inner: R,
365}
366
367impl<R> CardResponse<R> {
368    /// Whether the card was clicked (if clickable)
369    pub fn clicked(&self) -> bool {
370        self.response.clicked()
371    }
372
373    /// Whether the card is hovered
374    pub fn hovered(&self) -> bool {
375        self.response.hovered()
376    }
377}