Skip to main content

gpui_kit/display/
card.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window,
5    div, prelude::FluentBuilder, px,
6};
7use gpui_kit_semantics::{NodeSpec, Role, Semantic};
8use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface};
9
10use crate::foundation::{FocusRing, HoverLift, Ident, Pressable, Selectable, StyledExt};
11
12type ClickHandler = Rc<dyn Fn(&mut Window, &mut App)>;
13
14/// A raised panel that groups related rows or content.
15#[derive(IntoElement)]
16pub struct Card {
17    ident: Option<Ident>,
18    children: Vec<AnyElement>,
19    padded: bool,
20    on_click: Option<ClickHandler>,
21}
22
23impl std::fmt::Debug for Card {
24    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        formatter
26            .debug_struct("Card")
27            .field("ident", &self.ident)
28            .field("children", &self.children.len())
29            .finish()
30    }
31}
32
33impl Default for Card {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl Card {
40    pub fn new() -> Self {
41        Self {
42            ident: None,
43            children: Vec::new(),
44            padded: false,
45            on_click: None,
46        }
47    }
48
49    pub fn id(mut self, ident: impl Into<Ident>) -> Self {
50        self.ident = Some(ident.into());
51        self
52    }
53
54    /// Adds interior padding. Row-based cards leave this off so a row's own
55    /// hover wash can reach the card edge.
56    pub fn padded(mut self, padded: bool) -> Self {
57        self.padded = padded;
58        self
59    }
60
61    /// Makes the whole card one action.
62    ///
63    /// Only a card that carries an identity can be one: an action nothing can
64    /// address is an action no test and no reader can reach, so the handler is
65    /// ignored without [`Card::id`].
66    pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
67        self.on_click = Some(Rc::new(handler));
68        self
69    }
70
71    fn actionable(&self) -> bool {
72        self.ident.is_some() && self.on_click.is_some()
73    }
74}
75
76impl ParentElement for Card {
77    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
78        self.children.extend(elements);
79    }
80}
81
82impl RenderOnce for Card {
83    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
84        let theme = cx.theme().clone();
85        let actionable = self.actionable();
86        let frame = div()
87            .w_full()
88            .radius(&theme, Radius::Card)
89            .frame(&theme, Surface::Panel, Elevation::Raised)
90            .overflow_hidden()
91            .column()
92            .when(self.padded, |element| element.p_token(&theme, Space::Lg))
93            .children(self.children);
94
95        let Some(ident) = self.ident else {
96            return frame.into_any_element();
97        };
98        if !actionable {
99            return frame
100                .semantic_in(cx, NodeSpec::new(ident.semantic_id(), Role::Group))
101                .into_any_element();
102        }
103
104        // A card is a surface, so it is the one place in the library where
105        // rising off the page reads as a response rather than as a component
106        // climbing out of its own frame.
107        let mut card = frame
108            .id(ident.element_id())
109            .cursor_pointer()
110            .tab_index(0)
111            .focus_ring(&theme)
112            .hover_lift(cx)
113            .pressable(cx);
114        let handler = self.on_click.clone().expect("an actionable card has one");
115        let click = Rc::clone(&handler);
116        card.interactivity()
117            .on_click(move |_, window, cx| click(window, cx));
118        card.interactivity().on_key_down(move |event, window, cx| {
119            if matches!(event.keystroke.key.as_str(), "enter" | "space") {
120                handler(window, cx);
121                cx.stop_propagation();
122            }
123        });
124
125        card.semantic_in(cx, NodeSpec::new(ident.semantic_id(), Role::Button))
126            .into_any_element()
127    }
128}
129
130/// One row inside a [`Card`].
131#[derive(IntoElement)]
132pub struct ListRow {
133    ident: Option<Ident>,
134    selected: bool,
135    children: Vec<AnyElement>,
136    on_click: Option<ClickHandler>,
137}
138
139impl std::fmt::Debug for ListRow {
140    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        formatter
142            .debug_struct("ListRow")
143            .field("ident", &self.ident)
144            .field("selected", &self.selected)
145            .finish()
146    }
147}
148
149impl Default for ListRow {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl ListRow {
156    pub fn new() -> Self {
157        Self {
158            ident: None,
159            selected: false,
160            children: Vec::new(),
161            on_click: None,
162        }
163    }
164
165    pub fn id(mut self, ident: impl Into<Ident>) -> Self {
166        self.ident = Some(ident.into());
167        self
168    }
169
170    /// Makes the row one action, which it can only be once it has an identity.
171    pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
172        self.on_click = Some(Rc::new(handler));
173        self
174    }
175
176    fn actionable(&self) -> bool {
177        self.ident.is_some() && self.on_click.is_some()
178    }
179}
180
181impl Selectable for ListRow {
182    fn selected(mut self, selected: bool) -> Self {
183        self.selected = selected;
184        self
185    }
186}
187
188impl ParentElement for ListRow {
189    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
190        self.children.extend(elements);
191    }
192}
193
194impl RenderOnce for ListRow {
195    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
196        let theme = cx.theme().clone();
197        let selected = self.selected;
198        let actionable = self.actionable();
199        let row = div()
200            .w_full()
201            .px(px(theme.spacing.lg + theme.spacing.xs))
202            .py(px(theme.spacing.md + 2.0))
203            .when(selected, |element| element.bg(theme.colors.selected))
204            .when(!selected, |element| {
205                element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
206            })
207            .row()
208            .gap(px(theme.spacing.md + 2.0))
209            .children(self.children);
210
211        let Some(ident) = self.ident else {
212            return row.into_any_element();
213        };
214        let spec = NodeSpec::new(ident.semantic_id(), Role::Row).selected(selected);
215        if !actionable {
216            return row.semantic_in(cx, spec).into_any_element();
217        }
218
219        // A row lives inside a card's frame, so it takes the press response
220        // and not the lift: a row that rose would leave the frame it belongs
221        // to.
222        let mut row = row
223            .id(ident.element_id())
224            .cursor_pointer()
225            .tab_index(0)
226            .focus_ring(&theme)
227            .pressable(cx);
228        let handler = self.on_click.clone().expect("an actionable row has one");
229        let click = Rc::clone(&handler);
230        row.interactivity()
231            .on_click(move |_, window, cx| click(window, cx));
232        row.interactivity().on_key_down(move |event, window, cx| {
233            if matches!(event.keystroke.key.as_str(), "enter" | "space") {
234                handler(window, cx);
235                cx.stop_propagation();
236            }
237        });
238        row.semantic_in(cx, spec).into_any_element()
239    }
240}