1use crate::localization as l10n;
11use crate::theme::ThemeExt;
12use egui::{
13 Align, Align2, Color32, Context, Frame, Layout, Response, RichText, Stroke, Ui, UiBuilder,
14 UiStackInfo, Vec2,
15};
16
17use crate::icons::icons;
18use crate::tokens::DESIGN_TOKENS;
19use crate::ui_kit::buttons::{ButtonSize, IconButton};
20
21const FOOTER_BUTTON_MIN_WIDTH: f32 = 44.0;
23
24const FIXED_HEIGHT_TAG: &str = "egui_charts::dialog_frame::fixed_height";
26
27pub(crate) fn dialog_frame(ctx: &Context) -> Frame {
29 let theme = ctx.active_theme();
30 let mut frame = Frame::window(&ctx.global_style()).fill(ctx.global_style().visuals.panel_fill);
31 if !theme.dark {
32 frame.stroke = Stroke::NONE;
33 frame.shadow.blur = (2.0 * theme.spacing.sm) as u8;
34 frame.shadow.offset = [0, theme.spacing.xs as i8];
35 }
36 let stroke_width = frame.stroke.width;
37 frame.inner_margin(egui::Margin {
38 left: (theme.spacing.xxl + theme.spacing.sm - stroke_width) as i8,
39 right: (theme.spacing.xxl + theme.spacing.sm - DESIGN_TOKENS.stroke.hairline - stroke_width)
40 as i8,
41 top: (theme.spacing.xxl - stroke_width) as i8,
42 bottom: (theme.spacing.xxl - stroke_width) as i8,
43 })
44}
45
46pub struct DialogFrame {
69 id: String,
70 size: Vec2,
71 auto_height: bool,
72 rounding: Option<f32>,
73}
74
75impl DialogFrame {
76 pub fn new(id: impl Into<String>, size: Vec2) -> Self {
78 Self {
79 id: id.into(),
80 size,
81 auto_height: false,
82 rounding: None,
83 }
84 }
85
86 pub fn with_width(id: impl Into<String>, width: f32) -> Self {
88 Self {
89 id: id.into(),
90 size: Vec2::new(width, 0.0),
91 auto_height: true,
92 rounding: None,
93 }
94 }
95
96 pub fn rounding(mut self, r: f32) -> Self {
98 self.rounding = Some(r);
99 self
100 }
101
102 pub fn show(self, ctx: &Context, add_contents: impl FnOnce(&mut Ui)) {
104 let mut frame = dialog_frame(ctx);
105 if let Some(r) = self.rounding {
106 frame = frame.corner_radius(r);
107 }
108 let max = ctx
109 .content_rect()
110 .shrink(ctx.active_theme().spacing.xxxl)
111 .size()
112 - frame.total_margin().sum();
113 let inner = self.size.min(max).max(Vec2::ZERO);
114
115 let window = egui::Window::new(&self.id)
116 .anchor(Align2::CENTER_CENTER, Vec2::ZERO)
117 .title_bar(false)
118 .collapsible(false)
119 .resizable(false)
120 .max_width((max.x + frame.total_margin().sum().x).max(0.0))
121 .frame(frame);
122 let window = if self.auto_height {
123 window
124 .default_width(inner.x)
125 .default_height((max.y + frame.total_margin().sum().y).max(0.0))
126 .max_height((max.y + frame.total_margin().sum().y).max(0.0))
127 } else {
128 window.fixed_size(inner)
129 };
130
131 let input_blocked = ctx.text_edit_focused() || egui::Popup::is_any_open(ctx);
132 let auto_height = self.auto_height;
133 window.show(ctx, |ui| {
134 let translated_label = l10n::text(ui.ctx(), &self.id);
135 ui.response().widget_info(|| {
136 egui::WidgetInfo::labeled(egui::Role::Dialog, ui.is_enabled(), &translated_label)
137 });
138 ui.data_mut(|d| {
139 d.insert_temp(ui.layer_id().id.with("dialog_input_blocked"), input_blocked)
140 });
141 ui.set_min_size(inner);
143 ui.set_max_width(inner.x);
144 if auto_height {
145 ui.set_max_height(max.y.max(0.0));
146 add_contents(ui);
147 } else {
148 let info = UiStackInfo::default().with_tag(FIXED_HEIGHT_TAG);
150 ui.scope_builder(UiBuilder::new().ui_stack_info(info), add_contents);
151 }
152 let focused = ui.ctx().memory(|m| m.focused()).is_some();
153 ui.data_mut(|d| d.insert_temp(ui.layer_id().id.with("dialog_had_focus"), focused));
154 });
155 }
156}
157
158pub fn dialog_header(ui: &mut Ui, title: &str) -> bool {
162 let close = title_row(ui, title, true);
163 let had_focus = ui
164 .data(|d| d.get_temp::<bool>(ui.layer_id().id.with("dialog_had_focus")))
165 .unwrap_or(false);
166 close || (!had_focus && dialog_key(ui, egui::Key::Escape))
167}
168
169fn dialog_key(ui: &Ui, key: egui::Key) -> bool {
170 if ui
171 .data(|d| d.get_temp::<bool>(ui.layer_id().id.with("dialog_input_blocked")))
172 .unwrap_or(false)
173 || egui::Popup::is_any_open(ui.ctx())
174 || ui.ctx().any_popup_open()
175 || ui.ctx().top_layer_id() != Some(ui.layer_id())
176 || ui.ctx().memory(|m| m.focused()).is_some()
177 {
178 return false;
179 }
180 ui.input_mut(|input| {
181 let fresh = input.events.iter().any(|event| {
182 matches!(event,
183 egui::Event::Key { key: pressed, pressed: true, repeat: false, modifiers, .. }
184 if *pressed == key && modifiers.is_none()
185 )
186 });
187 fresh && input.consume_key(egui::Modifiers::NONE, key)
188 })
189}
190
191pub(crate) fn title_row(ui: &mut Ui, title: &str, closable: bool) -> bool {
193 let row = Vec2::new(ui.available_width(), ButtonSize::MD.pixels());
195 let close = ui
198 .allocate_ui_with_layout(row, Layout::right_to_left(Align::Center), |ui| {
199 let close = closable
200 && IconButton::new(&icons::CLOSE)
201 .icon_scale(1.0)
202 .tooltip("Close")
203 .show(ui)
204 .clicked();
205 ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
206 ui.add(
207 egui::Label::new(
208 RichText::new(l10n::text(ui.ctx(), title))
209 .strong()
210 .size(ui.active_theme().text.dialog_title)
211 .variation("wght", 600.0),
212 )
213 .truncate(),
214 );
215 });
216 close
217 })
218 .inner;
219 ui.add_space(ui.active_theme().spacing.sm);
220 close
221}
222
223pub struct DialogFooter {
241 primary_label: String,
242 secondary_label: Option<String>,
243 primary_enabled: bool,
244 danger: bool,
245}
246
247impl DialogFooter {
248 pub fn new(primary_label: impl Into<String>) -> Self {
250 Self {
251 primary_label: primary_label.into(),
252 secondary_label: None,
253 primary_enabled: true,
254 danger: false,
255 }
256 }
257
258 pub fn secondary(mut self, label: impl Into<String>) -> Self {
260 self.secondary_label = Some(label.into());
261 self
262 }
263
264 pub fn primary_enabled(mut self, enabled: bool) -> Self {
266 self.primary_enabled = enabled;
267 self
268 }
269
270 pub fn danger(mut self) -> Self {
272 self.danger = true;
273 self
274 }
275
276 pub fn show(self, ui: &mut Ui) -> (bool, bool) {
278 self.show_with_left(ui, |_| ())
279 }
280
281 pub fn show_with_left<R>(
283 self,
284 ui: &mut Ui,
285 left_content: impl FnOnce(&mut Ui) -> R,
286 ) -> (bool, bool) {
287 let pinned = ui.stack().tags().contains(FIXED_HEIGHT_TAG);
291 let height_id = ui.scope_id().with("dialog_footer_height");
292 if pinned && !ui.is_sizing_pass() {
293 let height = ui.data(|d| d.get_temp::<f32>(height_id)).unwrap_or(0.0);
294 ui.add_space((ui.available_height() - height).max(0.0));
295 }
296 let top = ui.cursor().top();
297
298 ui.add_space(ui.active_theme().spacing.lg);
299 ui.separator();
300 ui.add_space(ui.active_theme().spacing.lg);
301
302 let row = ui.horizontal(|ui| {
303 left_content(ui);
304
305 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
306 let theme = ui.active_theme();
307 let secondary_min_width = DESIGN_TOKENS.sizing.settings_dialog.button_min_width;
308 ui.spacing_mut().item_spacing.x = theme.spacing.xl;
309 ui.spacing_mut().button_padding.x = theme.spacing.xl + theme.spacing.xs;
310 let font = egui::TextStyle::Button.resolve(ui.style());
311 let width = std::iter::once((&self.primary_label, FOOTER_BUTTON_MIN_WIDTH))
312 .chain(
313 self.secondary_label
314 .iter()
315 .map(|label| (label, secondary_min_width)),
316 )
317 .map(|(label, min_width)| {
318 (ui.painter()
319 .layout_no_wrap(
320 l10n::text(ui.ctx(), label.clone()),
321 font.clone(),
322 Color32::PLACEHOLDER,
323 )
324 .size()
325 .x
326 + 2.0 * ui.spacing().button_padding.x)
327 .max(min_width)
328 })
329 .sum::<f32>()
330 + if self.secondary_label.is_some() {
331 theme.spacing.xl
332 } else {
333 0.0
334 };
335 ui.allocate_ui_with_layout(
336 Vec2::new(width, DESIGN_TOKENS.sizing.settings_dialog.button_height),
337 Layout::left_to_right(Align::Center),
338 |ui| {
339 let colors = &theme.ui;
340 let outline = ui.visuals().widgets.inactive.fg_stroke.color;
341 let secondary_clicked =
342 self.secondary_label.as_ref().is_some_and(|label| {
343 let hover = ui.visuals().widgets.hovered.bg_fill;
344 let fill = [Color32::TRANSPARENT, hover, hover];
345 let stroke = Stroke::new(DESIGN_TOKENS.stroke.hairline, outline);
346 footer_button(ui, label, secondary_min_width, fill, outline, stroke)
347 .clicked()
348 });
349 let (fill, text) = if self.danger {
350 let fill = [
351 colors.danger_bg,
352 colors.danger_bg_hover,
353 colors.danger_bg_active,
354 ];
355 (fill, colors.danger_text)
356 } else {
357 let fill = [colors.text, colors.text_secondary, colors.text_muted];
360 (fill, colors.panel_bg)
361 };
362 let primary_clicked = ui
363 .add_enabled_ui(self.primary_enabled, |ui| {
364 footer_button(
365 ui,
366 &self.primary_label,
367 FOOTER_BUTTON_MIN_WIDTH,
368 fill,
369 text,
370 Stroke::NONE,
371 )
372 })
373 .inner
374 .clicked();
375
376 (primary_clicked, secondary_clicked)
377 },
378 )
379 .inner
380 })
381 .inner
382 });
383
384 if pinned {
385 let height = row.response.rect.bottom() - top;
386 ui.data_mut(|d| d.insert_temp(height_id, height));
387 }
388 let (primary, secondary) = row.inner;
389 (
390 primary || (self.primary_enabled && dialog_key(ui, egui::Key::Enter)),
391 secondary,
392 )
393 }
394}
395
396fn footer_button(
398 ui: &mut Ui,
399 label: &str,
400 min_width: f32,
401 fill: [Color32; 3],
402 text: Color32,
403 stroke: Stroke,
404) -> Response {
405 let radius = ui.active_theme().radius.md;
406 ui.scope(|ui| {
407 let widgets = &mut ui.visuals_mut().widgets;
408 for (visuals, fill) in [
409 (&mut widgets.inactive, fill[0]),
410 (&mut widgets.hovered, fill[1]),
411 (&mut widgets.active, fill[2]),
412 ] {
413 visuals.weak_bg_fill = fill;
414 visuals.bg_stroke = stroke;
415 visuals.fg_stroke.color = text;
416 }
417 let response = ui.add(
418 egui::Button::new(l10n::text(ui.ctx(), label))
419 .corner_radius(radius)
420 .min_size(Vec2::new(
421 min_width,
422 DESIGN_TOKENS.sizing.settings_dialog.button_height,
423 )),
424 );
425 crate::styles::focus::draw_focus_ring(ui, &response, radius);
426 response
427 })
428 .inner
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn frame_keyboard_dismisses_and_activates_only_an_enabled_primary() {
437 for (key, enabled, expected) in [
438 (egui::Key::Escape, true, (true, false)),
439 (egui::Key::Enter, true, (false, true)),
440 (egui::Key::Enter, false, (false, false)),
441 ] {
442 let ctx = Context::default();
443 let frame = |events| {
444 let mut result = (false, false);
445 ctx.run_ui(
446 egui::RawInput {
447 events,
448 ..Default::default()
449 },
450 |ui| {
451 DialogFrame::new("keys", Vec2::new(420.0, 240.0)).show(ui.ctx(), |ui| {
452 result.0 = dialog_header(ui, "Keys");
453 result.1 = DialogFooter::new("Apply")
454 .primary_enabled(enabled)
455 .show(ui)
456 .0;
457 });
458 },
459 )
460 .drop_without_applying_deltas();
461 result
462 };
463 frame(vec![]);
464 frame(vec![]);
465 assert_eq!(
466 frame(vec![egui::Event::Key {
467 key,
468 physical_key: None,
469 pressed: true,
470 repeat: false,
471 modifiers: egui::Modifiers::NONE,
472 }]),
473 expected
474 );
475 }
476 }
477
478 #[test]
479 fn footer_tab_order_follows_the_buttons_from_left_to_right() {
480 let ctx = Context::default();
481 crate::theme::apply_to_egui(&ctx, &crate::theme::Theme::light());
482 let frame = |events| {
483 ctx.run_ui(
484 egui::RawInput {
485 events,
486 ..Default::default()
487 },
488 |ui| {
489 DialogFooter::new("Apply").secondary("Cancel").show(ui);
490 },
491 )
492 .drop_without_applying_deltas();
493 ctx.memory(|m| m.focused())
494 .and_then(|id| ctx.read_response(id))
495 .map(|r| r.rect)
496 };
497 frame(vec![]);
498 let tab = || egui::Event::Key {
499 key: egui::Key::Tab,
500 physical_key: None,
501 pressed: true,
502 repeat: false,
503 modifiers: egui::Modifiers::NONE,
504 };
505 let first = frame(vec![tab()]).unwrap();
506 let second = frame(vec![tab()]).unwrap();
507 assert_eq!(first.size(), Vec2::new(72.0, 34.0));
508 assert_eq!(second.height(), 34.0);
509 assert_eq!(second.left() - first.right(), 12.0);
510 }
511
512 #[test]
513 fn footer_sits_at_the_bottom_of_a_fixed_frame_from_the_first_visible_frame() {
514 let ctx = Context::default();
515 let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, Vec2::new(1000.0, 800.0));
516 let frame = || {
517 let mut row = egui::Rect::NOTHING;
518 let mut window = egui::Id::NULL;
519 let input = egui::RawInput {
520 screen_rect: Some(screen),
521 ..Default::default()
522 };
523 ctx.run_ui(input, |ui| {
524 DialogFrame::new("d", Vec2::new(420.0, 240.0)).show(ui.ctx(), |ui| {
525 window = ui.layer_id().id;
526 ui.label("Body");
527 DialogFooter::new("Apply")
528 .secondary("Cancel")
529 .show_with_left(ui, |ui| row = ui.max_rect());
530 });
531 })
532 .drop_without_applying_deltas();
533 (ctx.memory(|m| m.area_rect(window)).unwrap(), row)
534 };
535 frame(); let first = frame();
537 let second = frame();
538 assert_eq!(
539 first.0, second.0,
540 "the window moved after its first visible frame"
541 );
542 let inner_bottom = second.0.bottom() - ctx.active_theme().spacing.xxl;
543 assert!(
544 (second.1.top() + DESIGN_TOKENS.sizing.settings_dialog.button_height - inner_bottom)
545 .abs()
546 <= 1.5
547 );
548 }
549
550 #[test]
551 fn auto_height_scroll_body_reaches_the_screen_cap() {
552 let ctx = Context::default();
553 for (width, height) in [(1440.0, 900.0), (320.0, 450.0), (1440.0, 900.0)] {
554 let mut window = egui::Id::NULL;
555 for _ in 0..3 {
556 ctx.run_ui(
557 egui::RawInput {
558 screen_rect: Some(egui::Rect::from_min_size(
559 egui::Pos2::ZERO,
560 Vec2::new(width, height),
561 )),
562 ..Default::default()
563 },
564 |ui| {
565 DialogFrame::with_width("scroll", 500.0).show(ui.ctx(), |ui| {
566 window = ui.layer_id().id;
567 dialog_header(ui, "Scrolling body");
568 egui::ScrollArea::vertical()
569 .max_height(ui.available_height())
570 .show(ui, |ui| {
571 for i in 0..100 {
572 ui.label(format!("Row {i}"));
573 }
574 });
575 });
576 },
577 )
578 .drop_without_applying_deltas();
579 }
580 let rect = ctx.memory(|m| m.area_rect(window)).unwrap();
581 assert!((rect.height() - (height - 48.0)).abs() <= 2.0, "{rect:?}");
582 assert!(rect.top() >= 23.0 && rect.bottom() <= height - 23.0);
583 assert!(
584 rect.left() >= 23.0 && rect.right() <= width - 23.0,
585 "{rect:?}"
586 );
587 }
588 }
589}