1use crate::Theme;
33use egui::{vec2, Color32, Key, Pos2, Rect, Sense, Stroke, Ui};
34
35const DRAWER_MAX_HEIGHT_RATIO: f32 = 0.96; const DRAWER_DEFAULT_HEIGHT: f32 = 400.0; const _DRAWER_MIN_HEIGHT: f32 = 100.0; const HANDLE_WIDTH: f32 = 48.0; const HANDLE_HEIGHT: f32 = 6.0; const HANDLE_TOP_MARGIN: f32 = 16.0; const HANDLE_ROUNDING: f32 = 9999.0; const HEADER_PADDING: f32 = 16.0; const CONTENT_PADDING: f32 = 16.0; const GAP_Y: f32 = 4.0; const BACKDROP_ALPHA: f32 = 0.8; const BORDER_ROUNDING: f32 = 10.0; const DRAG_CLOSE_THRESHOLD: f32 = 0.5; const DRAG_VELOCITY_THRESHOLD: f32 = 500.0; #[derive(Debug, Clone, Copy, PartialEq)]
58pub enum DrawerSnapPoint {
59 Closed,
61 Partial(f32),
63 Full,
65}
66
67impl DrawerSnapPoint {
68 #[must_use]
70 pub const fn to_ratio(&self) -> f32 {
71 match self {
72 Self::Closed => 0.0,
73 Self::Partial(ratio) => ratio.clamp(0.0, 1.0),
74 Self::Full => 1.0,
75 }
76 }
77}
78
79pub struct Drawer {
84 id: egui::Id,
85 title: Option<String>,
86 description: Option<String>,
87 show_handle: bool,
88 show_backdrop: bool,
89 is_open: bool,
90 snap_points: Vec<DrawerSnapPoint>,
91 height: Option<f32>,
92}
93
94impl Drawer {
95 pub fn new(id: impl Into<egui::Id>) -> Self {
97 Self {
98 id: id.into(),
99 title: None,
100 description: None,
101 show_handle: true,
102 show_backdrop: true,
103 is_open: false,
104 snap_points: vec![DrawerSnapPoint::Full],
105 height: None,
106 }
107 }
108
109 #[must_use]
111 pub const fn open(mut self, open: bool) -> Self {
112 self.is_open = open;
113 self
114 }
115
116 #[must_use]
118 pub fn title(mut self, title: impl Into<String>) -> Self {
119 self.title = Some(title.into());
120 self
121 }
122
123 #[must_use]
125 pub fn description(mut self, description: impl Into<String>) -> Self {
126 self.description = Some(description.into());
127 self
128 }
129
130 #[must_use]
132 pub const fn show_handle(mut self, show: bool) -> Self {
133 self.show_handle = show;
134 self
135 }
136
137 #[must_use]
139 pub const fn show_backdrop(mut self, show: bool) -> Self {
140 self.show_backdrop = show;
141 self
142 }
143
144 #[must_use]
146 pub fn snap_points(mut self, points: Vec<DrawerSnapPoint>) -> Self {
147 self.snap_points = points;
148 self
149 }
150
151 #[must_use]
153 pub const fn height(mut self, height: f32) -> Self {
154 self.height = Some(height);
155 self
156 }
157
158 pub fn show(
160 &mut self,
161 ctx: &egui::Context,
162 theme: &Theme,
163 content: impl FnOnce(&mut Ui),
164 ) -> DrawerResponse {
165 let mut closed = false;
166 let snap_point = DrawerSnapPoint::Full;
167
168 if !self.is_open {
169 let dummy = egui::Area::new(self.id.with("drawer_empty"))
170 .order(egui::Order::Background)
171 .fixed_pos(egui::Pos2::ZERO)
172 .show(ctx, |_| {})
173 .response;
174 return DrawerResponse {
175 response: dummy,
176 closed: false,
177 snap_point,
178 };
179 }
180
181 #[allow(deprecated)]
182 let screen_rect = ctx.screen_rect();
183
184 let drag_state_id = self.id.with("drag_state");
186 let mut drag_offset: f32 = ctx.data_mut(|d| d.get_temp(drag_state_id).unwrap_or(0.0));
187 let velocity_id = self.id.with("velocity");
188 let mut last_drag_delta: f32 = ctx.data_mut(|d| d.get_temp(velocity_id).unwrap_or(0.0));
189
190 let max_height = screen_rect.height() * DRAWER_MAX_HEIGHT_RATIO;
192 let base_height = self.height.unwrap_or(DRAWER_DEFAULT_HEIGHT).min(max_height);
193 let current_height = (base_height - drag_offset).max(0.0);
194
195 if self.show_backdrop {
197 let backdrop_alpha = (BACKDROP_ALPHA * (current_height / base_height)).max(0.0);
198 let backdrop_color = Color32::from_black_alpha((255.0 * backdrop_alpha) as u8);
199
200 egui::Area::new(self.id.with("backdrop"))
201 .order(egui::Order::Middle)
202 .interactable(true)
203 .fixed_pos(screen_rect.min)
204 .show(ctx, |ui| {
205 let backdrop_response =
206 ui.allocate_response(screen_rect.size(), Sense::click());
207 ui.painter().rect_filled(screen_rect, 0.0, backdrop_color);
208
209 if backdrop_response.clicked() {
210 closed = true;
211 }
212 });
213 }
214
215 let drawer_rect = Rect::from_min_size(
217 Pos2::new(screen_rect.left(), screen_rect.bottom() - current_height),
218 vec2(screen_rect.width(), current_height),
219 );
220
221 let area_response = egui::Area::new(self.id)
223 .order(egui::Order::Foreground)
224 .fixed_pos(drawer_rect.min)
225 .show(ctx, |ui| {
226 ui.set_clip_rect(drawer_rect);
227
228 ui.painter()
230 .rect_filled(drawer_rect, BORDER_ROUNDING, theme.background());
231
232 ui.painter().hline(
234 drawer_rect.x_range(),
235 drawer_rect.top(),
236 Stroke::new(1.0, theme.border()),
237 );
238
239 let handle_response = if self.show_handle {
241 let handle_rect = Rect::from_center_size(
242 Pos2::new(
243 drawer_rect.center().x,
244 drawer_rect.top() + HANDLE_TOP_MARGIN + HANDLE_HEIGHT / 2.0,
245 ),
246 vec2(HANDLE_WIDTH, HANDLE_HEIGHT),
247 );
248
249 let drag_area = Rect::from_center_size(
251 handle_rect.center(),
252 vec2(screen_rect.width(), HANDLE_TOP_MARGIN * 2.0 + HANDLE_HEIGHT),
253 );
254
255 let drag_response =
256 ui.interact(drag_area, self.id.with("handle"), Sense::drag());
257
258 ui.painter()
260 .rect_filled(handle_rect, HANDLE_ROUNDING, theme.muted());
261
262 Some(drag_response)
263 } else {
264 None
265 };
266
267 if let Some(drag_resp) = handle_response {
269 if drag_resp.dragged() {
270 let delta = drag_resp.drag_delta().y;
271 drag_offset += delta;
272 drag_offset = drag_offset.max(0.0); last_drag_delta = delta;
274 ctx.request_repaint();
275 }
276
277 if drag_resp.drag_stopped() {
278 let drag_ratio = drag_offset / base_height;
280 let velocity = last_drag_delta * 60.0; if drag_ratio > DRAG_CLOSE_THRESHOLD || velocity > DRAG_VELOCITY_THRESHOLD {
283 closed = true;
284 }
285 drag_offset = 0.0;
287 last_drag_delta = 0.0;
288 }
289 }
290
291 let content_top = if self.show_handle {
293 drawer_rect.top() + HANDLE_TOP_MARGIN * 2.0 + HANDLE_HEIGHT
294 } else {
295 drawer_rect.top()
296 };
297
298 let content_rect =
299 Rect::from_min_max(Pos2::new(drawer_rect.left(), content_top), drawer_rect.max);
300
301 let mut content_ui = ui.new_child(
302 egui::UiBuilder::new()
303 .max_rect(content_rect)
304 .layout(egui::Layout::top_down(egui::Align::Min)),
305 );
306
307 content_ui.set_clip_rect(content_rect);
308
309 if self.title.is_some() || self.description.is_some() {
311 content_ui.horizontal(|ui| {
312 ui.add_space(HEADER_PADDING);
313 ui.vertical(|ui| {
314 ui.set_width(drawer_rect.width() - HEADER_PADDING * 2.0);
315
316 if let Some(title) = &self.title {
317 ui.label(
318 egui::RichText::new(title)
319 .size(theme.typography.xl)
320 .strong()
321 .color(theme.foreground()),
322 );
323 }
324
325 if let Some(desc) = &self.description {
326 ui.add_space(GAP_Y);
327 ui.label(
328 egui::RichText::new(desc)
329 .size(theme.typography.base)
330 .color(theme.muted_foreground()),
331 );
332 }
333 });
334 });
335 content_ui.add_space(HEADER_PADDING);
336 }
337
338 content_ui.horizontal(|ui| {
340 ui.add_space(CONTENT_PADDING);
341 ui.vertical(|ui| {
342 ui.set_width(drawer_rect.width() - CONTENT_PADDING * 2.0);
343 content(ui);
344 });
345 ui.add_space(CONTENT_PADDING);
346 });
347 });
348
349 ctx.data_mut(|d| {
351 d.insert_temp(drag_state_id, drag_offset);
352 d.insert_temp(velocity_id, last_drag_delta);
353 });
354
355 if ctx.input(|i| i.key_pressed(Key::Escape)) {
357 closed = true;
358 }
359
360 if closed {
362 ctx.data_mut(|d| {
363 d.insert_temp(drag_state_id, 0.0f32);
364 d.insert_temp(velocity_id, 0.0f32);
365 });
366 }
367
368 DrawerResponse {
369 response: area_response.response,
370 closed,
371 snap_point,
372 }
373 }
374}
375
376pub struct DrawerResponse {
378 pub response: egui::Response,
380 pub closed: bool,
382 pub snap_point: DrawerSnapPoint,
384}
385
386impl DrawerResponse {
387 #[must_use]
389 pub const fn closed(&self) -> bool {
390 self.closed
391 }
392}