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 .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}