1use crate::icon;
28use crate::Theme;
29use egui::{vec2, Color32, Key, Pos2, Rect, Sense, Stroke, Ui};
30
31const SHEET_WIDTH_SM: f32 = 320.0; const SHEET_WIDTH_MD: f32 = 420.0; const SHEET_WIDTH_LG: f32 = 540.0; const SHEET_WIDTH_XL: f32 = 672.0; const SHEET_HEIGHT_DEFAULT: f32 = 400.0; const HEADER_PADDING: f32 = 24.0; const CONTENT_PADDING_X: f32 = 24.0; const _FOOTER_PADDING: f32 = 24.0; const GAP_Y: f32 = 8.0; const CLOSE_BUTTON_SIZE: f32 = 16.0; const CLOSE_BUTTON_OFFSET: f32 = 16.0; const CLOSE_BUTTON_ROUNDING: f32 = 4.0; const BACKDROP_ALPHA: f32 = 0.8; const BORDER_WIDTH: f32 = 1.0;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum SheetSide {
53 Top,
55 #[default]
57 Right,
58 Bottom,
60 Left,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Default)]
66pub enum SheetSize {
67 #[default]
69 Small,
70 Medium,
72 Large,
74 XLarge,
76 Full,
78 Custom(f32),
80}
81
82impl SheetSize {
83 const fn to_pixels(self) -> f32 {
84 match self {
85 Self::Small => SHEET_WIDTH_SM,
86 Self::Medium => SHEET_WIDTH_MD,
87 Self::Large => SHEET_WIDTH_LG,
88 Self::XLarge => SHEET_WIDTH_XL,
89 Self::Full => 0.0, Self::Custom(px) => px,
91 }
92 }
93}
94
95pub struct Sheet {
100 id: egui::Id,
101 side: SheetSide,
102 size: SheetSize,
103 title: Option<String>,
104 description: Option<String>,
105 show_close_button: bool,
106 show_backdrop: bool,
107 is_open: bool,
108}
109
110impl Sheet {
111 pub fn new(id: impl Into<egui::Id>) -> Self {
113 Self {
114 id: id.into(),
115 side: SheetSide::Right,
116 size: SheetSize::Small,
117 title: None,
118 description: None,
119 show_close_button: true,
120 show_backdrop: true,
121 is_open: false,
122 }
123 }
124
125 #[must_use]
127 pub const fn side(mut self, side: SheetSide) -> Self {
128 self.side = side;
129 self
130 }
131
132 #[must_use]
134 pub const fn size(mut self, size: SheetSize) -> Self {
135 self.size = size;
136 self
137 }
138
139 #[must_use]
141 pub const fn open(mut self, open: bool) -> Self {
142 self.is_open = open;
143 self
144 }
145
146 #[must_use]
148 pub fn title(mut self, title: impl Into<String>) -> Self {
149 self.title = Some(title.into());
150 self
151 }
152
153 #[must_use]
155 pub fn description(mut self, description: impl Into<String>) -> Self {
156 self.description = Some(description.into());
157 self
158 }
159
160 #[must_use]
162 pub const fn show_close_button(mut self, show: bool) -> Self {
163 self.show_close_button = show;
164 self
165 }
166
167 #[must_use]
169 pub const fn show_backdrop(mut self, show: bool) -> Self {
170 self.show_backdrop = show;
171 self
172 }
173
174 pub fn show(
176 &mut self,
177 ctx: &egui::Context,
178 theme: &Theme,
179 content: impl FnOnce(&mut Ui),
180 ) -> SheetResponse {
181 let mut closed = false;
182
183 if !self.is_open {
184 let dummy = egui::Area::new(self.id.with("sheet_empty"))
185 .order(egui::Order::Background)
186 .fixed_pos(egui::Pos2::ZERO)
187 .show(ctx, |_| {})
188 .response;
189 return SheetResponse {
190 response: dummy,
191 closed: false,
192 };
193 }
194
195 #[allow(deprecated)]
196 let screen_rect = ctx.screen_rect(); let is_horizontal = matches!(self.side, SheetSide::Left | SheetSide::Right);
200 let sheet_size = if self.size == SheetSize::Full {
201 if is_horizontal {
202 screen_rect.width()
203 } else {
204 screen_rect.height()
205 }
206 } else {
207 self.size.to_pixels()
208 };
209
210 let sheet_size = if !is_horizontal && self.size != SheetSize::Full {
212 sheet_size.min(SHEET_HEIGHT_DEFAULT)
213 } else {
214 sheet_size
215 };
216
217 if self.show_backdrop {
219 let backdrop_color = Color32::from_black_alpha((255.0 * BACKDROP_ALPHA) as u8);
220
221 egui::Area::new(self.id.with("backdrop"))
222 .order(egui::Order::Middle)
223 .interactable(true)
224 .fixed_pos(screen_rect.min)
225 .show(ctx, |ui| {
226 let backdrop_response =
227 ui.allocate_response(screen_rect.size(), Sense::click());
228 ui.painter().rect_filled(screen_rect, 0.0, backdrop_color);
229
230 if backdrop_response.clicked() {
231 closed = true;
232 }
233 });
234 }
235
236 let sheet_rect = match self.side {
238 SheetSide::Right => Rect::from_min_size(
239 Pos2::new(screen_rect.right() - sheet_size, screen_rect.top()),
240 vec2(sheet_size, screen_rect.height()),
241 ),
242 SheetSide::Left => Rect::from_min_size(
243 screen_rect.left_top(),
244 vec2(sheet_size, screen_rect.height()),
245 ),
246 SheetSide::Top => Rect::from_min_size(
247 screen_rect.left_top(),
248 vec2(screen_rect.width(), sheet_size),
249 ),
250 SheetSide::Bottom => Rect::from_min_size(
251 Pos2::new(screen_rect.left(), screen_rect.bottom() - sheet_size),
252 vec2(screen_rect.width(), sheet_size),
253 ),
254 };
255
256 let area_response = egui::Area::new(self.id)
258 .order(egui::Order::Foreground)
259 .fixed_pos(sheet_rect.min)
260 .show(ctx, |ui| {
261 ui.set_clip_rect(sheet_rect);
262
263 ui.painter()
265 .rect_filled(sheet_rect, 0.0, theme.background());
266
267 let border_stroke = Stroke::new(BORDER_WIDTH, theme.border());
269 match self.side {
270 SheetSide::Right => {
271 ui.painter()
272 .vline(sheet_rect.left(), sheet_rect.y_range(), border_stroke);
273 }
274 SheetSide::Left => {
275 ui.painter()
276 .vline(sheet_rect.right(), sheet_rect.y_range(), border_stroke);
277 }
278 SheetSide::Top => {
279 ui.painter().hline(
280 sheet_rect.x_range(),
281 sheet_rect.bottom(),
282 border_stroke,
283 );
284 }
285 SheetSide::Bottom => {
286 ui.painter()
287 .hline(sheet_rect.x_range(), sheet_rect.top(), border_stroke);
288 }
289 }
290
291 if self.show_close_button {
293 let close_rect = Rect::from_min_size(
294 Pos2::new(
295 sheet_rect.right() - CLOSE_BUTTON_OFFSET - CLOSE_BUTTON_SIZE,
296 sheet_rect.top() + CLOSE_BUTTON_OFFSET,
297 ),
298 vec2(CLOSE_BUTTON_SIZE, CLOSE_BUTTON_SIZE),
299 );
300
301 let close_response = ui.interact(
302 close_rect.expand(4.0), self.id.with("close"),
304 Sense::click(),
305 );
306
307 if close_response.hovered() {
309 ui.painter().rect_filled(
310 close_rect.expand(4.0),
311 CLOSE_BUTTON_ROUNDING,
312 theme.accent(),
313 );
314 }
315
316 let icon_color = if close_response.hovered() {
318 theme.foreground()
319 } else {
320 theme.muted_foreground()
321 };
322 icon::draw_close(ui.painter(), close_rect, icon_color);
323
324 if close_response.clicked() {
325 closed = true;
326 }
327 }
328
329 let content_rect =
331 Rect::from_min_max(sheet_rect.min + vec2(0.0, 0.0), sheet_rect.max);
332
333 let mut content_ui = ui.new_child(
334 egui::UiBuilder::new()
335 .max_rect(content_rect)
336 .layout(egui::Layout::top_down(egui::Align::Min)),
337 );
338
339 content_ui.set_clip_rect(content_rect);
340
341 if self.title.is_some() || self.description.is_some() {
343 content_ui.add_space(HEADER_PADDING);
344 content_ui.horizontal(|ui| {
345 ui.add_space(HEADER_PADDING);
346 ui.vertical(|ui| {
347 ui.set_width(
348 sheet_rect.width()
349 - HEADER_PADDING * 2.0
350 - CLOSE_BUTTON_SIZE
351 - CLOSE_BUTTON_OFFSET,
352 );
353
354 if let Some(title) = &self.title {
355 ui.label(
356 egui::RichText::new(title)
357 .size(18.0)
358 .strong()
359 .color(theme.foreground()),
360 );
361 }
362
363 if let Some(desc) = &self.description {
364 ui.add_space(GAP_Y);
365 ui.label(
366 egui::RichText::new(desc)
367 .size(14.0)
368 .color(theme.muted_foreground()),
369 );
370 }
371 });
372 });
373 content_ui.add_space(HEADER_PADDING);
374 }
375
376 content_ui.horizontal(|ui| {
378 ui.add_space(CONTENT_PADDING_X);
379 ui.vertical(|ui| {
380 ui.set_width(sheet_rect.width() - CONTENT_PADDING_X * 2.0);
381 content(ui);
382 });
383 ui.add_space(CONTENT_PADDING_X);
384 });
385 });
386
387 if ctx.input(|i| i.key_pressed(Key::Escape)) {
389 closed = true;
390 }
391
392 SheetResponse {
393 response: area_response.response,
394 closed,
395 }
396 }
397}
398
399pub struct SheetResponse {
401 pub response: egui::Response,
403 pub closed: bool,
405}
406
407impl SheetResponse {
408 #[must_use]
410 pub const fn closed(&self) -> bool {
411 self.closed
412 }
413}