Skip to main content

egui_components/
card.rs

1//! `Card` — a rounded surface that groups related content.
2//!
3//! Doubles as gpui-component's section/`GroupBox` container: pass an optional
4//! [`title`](Card::title) / [`description`](Card::description) to render a
5//! header, then add the body in the `show` closure, just like
6//! [`List`](crate::list::List).
7//!
8//! Mirroring upstream `GroupBox`, the surface uses one of three mutually
9//! exclusive styles — it never combines a fill *and* a border (doing so makes
10//! the border invisible in themes where the muted surface and border share a
11//! color, e.g. the default dark theme):
12//!
13//! * [`CardVariant::Fill`] (default) — filled `muted_background`, no border.
14//! * [`CardVariant::Outline`] — a border, no fill.
15//! * [`CardVariant::Normal`] — neither; just padded content.
16//!
17//! ```ignore
18//! sc::Card::new()
19//!     .title("Account")
20//!     .description("Manage your profile settings.")
21//!     .show(ui, |ui| {
22//!         ui.add(sc::Input::new(&mut name));
23//!     });
24//! ```
25
26use egui::{Color32, Frame, InnerResponse, Margin, Stroke, Ui};
27use egui_components_theme::Theme;
28
29use crate::common::Size;
30use crate::label::Label;
31use crate::separator::Separator;
32
33/// How a [`Card`]'s surface is drawn. Matches upstream `GroupBoxVariant`:
34/// a card is either filled *or* outlined, never both.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
36pub enum CardVariant {
37    /// Filled `muted_background` surface with no border (default).
38    #[default]
39    Fill,
40    /// A border with a transparent fill.
41    Outline,
42    /// Neither fill nor border — just padded content.
43    Normal,
44}
45
46/// A surface container with an optional header (title + description).
47pub struct Card {
48    title: Option<String>,
49    description: Option<String>,
50    padding: f32,
51    /// Draw a separator between the header and the body.
52    divider: bool,
53    variant: CardVariant,
54}
55
56impl Default for Card {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62impl Card {
63    pub fn new() -> Self {
64        Self {
65            title: None,
66            description: None,
67            padding: 16.0,
68            divider: false,
69            variant: CardVariant::default(),
70        }
71    }
72
73    pub fn title(mut self, t: impl Into<String>) -> Self {
74        self.title = Some(t.into());
75        self
76    }
77
78    pub fn description(mut self, d: impl Into<String>) -> Self {
79        self.description = Some(d.into());
80        self
81    }
82
83    pub fn padding(mut self, p: f32) -> Self {
84        self.padding = p;
85        self
86    }
87
88    /// Render a horizontal separator between the header and the body.
89    pub fn divider(mut self) -> Self {
90        self.divider = true;
91        self
92    }
93
94    pub fn variant(mut self, v: CardVariant) -> Self {
95        self.variant = v;
96        self
97    }
98    /// Filled surface, no border (the default).
99    pub fn fill(self) -> Self {
100        self.variant(CardVariant::Fill)
101    }
102    /// Bordered, transparent fill.
103    pub fn outline(self) -> Self {
104        self.variant(CardVariant::Outline)
105    }
106    /// No fill and no border.
107    pub fn normal(self) -> Self {
108        self.variant(CardVariant::Normal)
109    }
110
111    /// Render the card frame and run `body` inside it, returning the body's
112    /// value alongside the frame [`egui::Response`].
113    pub fn show<R>(self, ui: &mut Ui, body: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
114        let theme = Theme::get(ui.ctx());
115        let c = theme.colors;
116
117        let (fill, stroke) = match self.variant {
118            CardVariant::Fill => (c.muted_background, Stroke::NONE),
119            CardVariant::Outline => (Color32::TRANSPARENT, theme.border_stroke()),
120            CardVariant::Normal => (Color32::TRANSPARENT, Stroke::NONE),
121        };
122
123        Frame::new()
124            .fill(fill)
125            .stroke(stroke)
126            .corner_radius(theme.corner_lg())
127            .inner_margin(Margin::same(self.padding as i8))
128            .show(ui, |ui| {
129                let has_header = self.title.is_some() || self.description.is_some();
130                if let Some(title) = self.title {
131                    ui.add(Label::new(title).strong().size(Size::Large));
132                }
133                if let Some(desc) = self.description {
134                    ui.add(Label::new(desc).muted().size(Size::Small));
135                }
136                if has_header {
137                    if self.divider {
138                        ui.add_space(self.padding * 0.5);
139                        ui.add(Separator::horizontal());
140                    }
141                    ui.add_space(self.padding * 0.75);
142                }
143                body(ui)
144            })
145    }
146}