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}