Skip to main content

liora_components/
drawer.rs

1use crate::gpui_compat::element_id;
2use crate::motion::{fade_in, pop_in};
3use gpui::{
4    AnyElement, App, Context, IntoElement, KeyBinding, MouseButton, Pixels, Render, SharedString,
5    Window, actions, div, prelude::*, px,
6};
7use liora_core::Config;
8use liora_icons::Icon;
9use liora_icons_lucide::IconName;
10use std::sync::Arc;
11
12actions!(drawer, [DrawerClose]);
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum DrawerPlacement {
16    #[default]
17    Right,
18    Left,
19    Top,
20    Bottom,
21}
22
23pub struct Drawer {
24    id: SharedString,
25    title: SharedString,
26    content: Arc<dyn Fn(&mut Window, &mut Context<DrawerView>) -> AnyElement + 'static>,
27    placement: DrawerPlacement,
28    width: Pixels,
29    height: Pixels,
30    close_on_click_outside: bool,
31    close_on_escape: bool,
32}
33
34pub struct DrawerView {
35    id: SharedString,
36    title: SharedString,
37    content: Arc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static>,
38    placement: DrawerPlacement,
39    width: Pixels,
40    height: Pixels,
41    close_on_click_outside: bool,
42    close_on_escape: bool,
43    on_close: Arc<dyn Fn(&mut Window, &mut App) + 'static>,
44}
45
46impl DrawerView {
47    fn new(
48        id: SharedString,
49        title: SharedString,
50        content: Arc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static>,
51        placement: DrawerPlacement,
52        width: Pixels,
53        height: Pixels,
54        close_on_click_outside: bool,
55        close_on_escape: bool,
56        on_close: impl Fn(&mut Window, &mut App) + 'static,
57    ) -> Self {
58        Self {
59            id,
60            title,
61            content,
62            placement,
63            width,
64            height,
65            close_on_click_outside,
66            close_on_escape,
67            on_close: Arc::new(on_close),
68        }
69    }
70}
71
72impl Render for DrawerView {
73    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
74        let theme = cx.global::<Config>().theme.clone();
75        let id = self.id.clone();
76        let title = self.title.clone();
77        let content_fn = self.content.clone();
78        let on_close = self.on_close.clone();
79        let placement = self.placement;
80        let width = self.width;
81        let height = self.height;
82        let close_on_click_outside = self.close_on_click_outside;
83        let close_on_escape = self.close_on_escape;
84
85        let mut container = div()
86            .id(id.clone())
87            .absolute()
88            .size_full()
89            .cursor_default()
90            .bg(theme.neutral.overlay)
91            .on_mouse_move(|_, _, cx| {
92                cx.stop_propagation();
93            })
94            .when(close_on_click_outside, |s| {
95                s.on_mouse_down(MouseButton::Left, {
96                    let on_close = on_close.clone();
97                    move |_, window, cx| {
98                        on_close(window, cx);
99                    }
100                })
101            })
102            .when(close_on_escape, |s| {
103                s.on_action(cx.listener({
104                    let on_close = on_close.clone();
105                    move |_, _action: &DrawerClose, window, cx| {
106                        on_close(window, cx);
107                    }
108                }))
109            });
110
111        let mut panel = div()
112            .bg(theme.neutral.card)
113            .cursor_default()
114            .shadow_xl()
115            // CONSUME mouse down inside the panel so it doesn't trigger the overlay close
116            .on_mouse_move(|_, _, cx| {
117                cx.stop_propagation();
118            })
119            .on_mouse_down(MouseButton::Left, |_, _, cx| {
120                cx.stop_propagation();
121            });
122
123        match placement {
124            DrawerPlacement::Left => {
125                container = container.flex().flex_row().justify_start();
126                panel = panel
127                    .h_full()
128                    .w(width)
129                    .border_r_1()
130                    .border_color(theme.neutral.border);
131            }
132            DrawerPlacement::Right => {
133                container = container.flex().flex_row().justify_end();
134                panel = panel
135                    .h_full()
136                    .w(width)
137                    .border_l_1()
138                    .border_color(theme.neutral.border);
139            }
140            DrawerPlacement::Top => {
141                container = container.flex().flex_col().justify_start();
142                panel = panel
143                    .w_full()
144                    .h(height)
145                    .border_b_1()
146                    .border_color(theme.neutral.border);
147            }
148            DrawerPlacement::Bottom => {
149                container = container.flex().flex_col().justify_end();
150                panel = panel
151                    .w_full()
152                    .h(height)
153                    .border_t_1()
154                    .border_color(theme.neutral.border);
155            }
156        }
157
158        fade_in(
159            element_id(format!("{id}-overlay-motion")),
160            container.child(pop_in(
161                element_id(format!("{id}-panel-motion")),
162                panel
163                    .child(
164                        div()
165                            .p_4()
166                            .border_b_1()
167                            .border_color(theme.neutral.border)
168                            .flex()
169                            .justify_between()
170                            .items_center()
171                            .child(div().font_weight(gpui::FontWeight::BOLD).child(title))
172                            .child(
173                                div()
174                                    .id(element_id(format!("{id}-close-btn")))
175                                    .cursor_pointer()
176                                    .child(
177                                        Icon::new(IconName::X)
178                                            .size(px(16.0))
179                                            .color(theme.neutral.icon),
180                                    )
181                                    .on_mouse_down(MouseButton::Left, move |_, window, cx| {
182                                        on_close(window, cx);
183                                    }),
184                            ),
185                    )
186                    .child(div().flex_1().p_4().child(content_fn(_window, cx))),
187            )),
188        )
189    }
190}
191
192impl Drawer {
193    pub fn register_key_bindings(cx: &mut App) {
194        cx.bind_keys([KeyBinding::new("escape", DrawerClose, None)]);
195    }
196
197    pub fn new() -> Self {
198        Self {
199            id: liora_core::unique_id("drawer"),
200            title: SharedString::default(),
201            content: Arc::new(|_, _| div().child("Drawer Content").into_any_element()),
202            placement: DrawerPlacement::Right,
203            width: px(300.0),
204            height: px(300.0),
205            close_on_click_outside: true,
206            close_on_escape: true,
207        }
208    }
209
210    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
211        self.id = id.into();
212        self
213    }
214
215    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
216        self.title = title.into();
217        self
218    }
219
220    pub fn placement(mut self, p: DrawerPlacement) -> Self {
221        self.placement = p;
222        self
223    }
224
225    pub fn width(mut self, w: impl Into<Pixels>) -> Self {
226        self.width = w.into();
227        self
228    }
229
230    pub fn width_lg(self) -> Self {
231        self.width(px(480.0))
232    }
233
234    pub fn height(mut self, h: impl Into<Pixels>) -> Self {
235        self.height = h.into();
236        self
237    }
238
239    pub fn height_sm(self) -> Self {
240        self.height(px(200.0))
241    }
242
243    pub fn height_lg(self) -> Self {
244        self.height(px(360.0))
245    }
246
247    pub fn close_on_click_outside(mut self, c: bool) -> Self {
248        self.close_on_click_outside = c;
249        self
250    }
251
252    pub fn close_on_escape(mut self, c: bool) -> Self {
253        self.close_on_escape = c;
254        self
255    }
256
257    pub fn content<F, E>(mut self, f: F) -> Self
258    where
259        F: Fn(&mut Window, &mut Context<DrawerView>) -> E + 'static,
260        E: IntoElement,
261    {
262        self.content = Arc::new(move |window, cx| f(window, cx).into_any_element());
263        self
264    }
265
266    pub fn show(self, cx: &mut App) {
267        let id = self.id;
268        let title = self.title;
269        let content = self.content;
270        let placement = self.placement;
271        let width = self.width;
272        let height = self.height;
273        let close_on_click_outside = self.close_on_click_outside;
274        let close_on_escape = self.close_on_escape;
275
276        let id_for_close = id.clone();
277        let view = cx.new(|_cx| {
278            DrawerView::new(
279                id.clone(),
280                title,
281                content,
282                placement,
283                width,
284                height,
285                close_on_click_outside,
286                close_on_escape,
287                move |_window, _cx| {
288                    liora_core::clear_drawer(&id_for_close, _cx);
289                },
290            )
291        });
292
293        liora_core::set_active_drawer(id, view.into(), cx);
294    }
295
296    pub fn close(cx: &mut App) {
297        liora_core::clear_active_drawer(cx);
298    }
299
300    pub fn close_id(id: impl Into<SharedString>, cx: &mut App) {
301        let id = id.into();
302        liora_core::clear_drawer(&id, cx);
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn drawer_size_helpers_set_demo_sizes() {
312        assert_eq!(Drawer::new().width_lg().width, px(480.0));
313        assert_eq!(Drawer::new().height_sm().height, px(200.0));
314        assert_eq!(Drawer::new().height_lg().height, px(360.0));
315    }
316
317    #[test]
318    fn drawer_uses_liora_motion_on_overlay_and_panel() {
319        let source = include_str!("drawer.rs")
320            .split("#[cfg(test)]")
321            .next()
322            .unwrap();
323
324        assert!(source.contains("fade_in("));
325        assert!(source.contains("pop_in("));
326        assert!(source.contains("panel-motion"));
327    }
328}