Skip to main content

dais_ui/widgets/
help_overlay.rs

1//! Keybinding help overlay.
2//!
3//! A dismissible popup that lists the active keybindings grouped by category.
4//! Toggled with `?` (button or key) and dismissed with `?`, Escape, or the
5//! close button.
6
7use dais_core::keybindings::{Action, KeybindingMap};
8
9const OVERLAY_BG: egui::Color32 = egui::Color32::from_rgb(248, 250, 252);
10const OVERLAY_STROKE: egui::Color32 = egui::Color32::from_rgb(150, 160, 174);
11const HEADER_COLOR: egui::Color32 = egui::Color32::from_rgb(24, 82, 151);
12const TEXT_COLOR: egui::Color32 = egui::Color32::from_rgb(20, 24, 31);
13const MUTED_TEXT_COLOR: egui::Color32 = egui::Color32::from_rgb(58, 65, 77);
14const WINDOW_WIDTH: f32 = 560.0;
15const MAX_HEIGHT_FRACTION: f32 = 0.80;
16const SCROLLBAR_GUTTER: f32 = 22.0;
17const ROW_GAP: f32 = 12.0;
18const KEY_COLUMN_WIDTH: f32 = 200.0;
19const HEADER_BUTTON_SIZE: f32 = 28.0;
20
21/// Persistent state for the help overlay.
22#[derive(Default)]
23pub struct HelpOverlay {
24    pub visible: bool,
25}
26
27impl HelpOverlay {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Toggle visibility and return `true` when visible after the toggle.
33    pub fn toggle(&mut self) -> bool {
34        self.visible = !self.visible;
35        self.visible
36    }
37
38    /// Render the overlay. Call once per frame; it will only draw when visible.
39    ///
40    /// Returns `true` if the overlay consumed the `?` key this frame (so the
41    /// caller can suppress further key handling).
42    pub fn show(&mut self, ctx: &egui::Context, keybindings: &KeybindingMap) -> bool {
43        if !self.visible {
44            return false;
45        }
46
47        // Check for dismiss keys before drawing so the overlay can close on
48        // the same frame the key is pressed.
49        let dismiss = ctx.input(|i| {
50            i.events.iter().any(|e| match e {
51                egui::Event::Text(t) => t == "?",
52                egui::Event::Key { key: egui::Key::Escape, pressed: true, .. } => true,
53                _ => false,
54            })
55        });
56
57        if dismiss {
58            self.visible = false;
59            return true;
60        }
61
62        let screen = ctx.content_rect();
63        let max_h = screen.height() * MAX_HEIGHT_FRACTION;
64
65        let mut still_open = true;
66        egui::Window::new("help_overlay")
67            .open(&mut still_open)
68            .enabled(true)
69            .collapsible(false)
70            .resizable(false)
71            .title_bar(false)
72            .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
73            .default_width(WINDOW_WIDTH)
74            .min_width(WINDOW_WIDTH)
75            .max_width(WINDOW_WIDTH)
76            .max_height(max_h)
77            .frame(
78                egui::Frame::window(&ctx.style())
79                    .fill(OVERLAY_BG)
80                    .stroke(egui::Stroke::new(1.0_f32, OVERLAY_STROKE))
81                    .corner_radius(10.0)
82                    .inner_margin(16.0),
83            )
84            .show(ctx, |ui| {
85                let visuals = &mut ui.style_mut().visuals;
86                visuals.override_text_color = Some(TEXT_COLOR);
87                visuals.widgets.noninteractive.fg_stroke.color = TEXT_COLOR;
88                visuals.widgets.inactive.fg_stroke.color = TEXT_COLOR;
89                visuals.widgets.hovered.fg_stroke.color = TEXT_COLOR;
90                visuals.widgets.active.fg_stroke.color = TEXT_COLOR;
91                visuals.widgets.open.fg_stroke.color = TEXT_COLOR;
92                visuals.widgets.inactive.bg_fill = OVERLAY_BG;
93                visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(232, 238, 247);
94                visuals.widgets.active.bg_fill = egui::Color32::from_rgb(218, 229, 244);
95                visuals.widgets.noninteractive.bg_fill = OVERLAY_BG;
96                visuals.widgets.noninteractive.weak_bg_fill =
97                    egui::Color32::from_rgb(238, 242, 248);
98                visuals.widgets.inactive.weak_bg_fill = egui::Color32::from_rgb(238, 242, 248);
99                visuals.widgets.hovered.weak_bg_fill = egui::Color32::from_rgb(232, 238, 247);
100                visuals.widgets.active.weak_bg_fill = egui::Color32::from_rgb(218, 229, 244);
101                visuals.widgets.open.weak_bg_fill = egui::Color32::from_rgb(238, 242, 248);
102
103                Self::render_header(ui, &mut self.visible);
104                ui.add_space(8.0);
105                ui.separator();
106                ui.add_space(10.0);
107
108                Self::render_table(ui, keybindings);
109            });
110
111        if !still_open {
112            self.visible = false;
113        }
114
115        true
116    }
117
118    fn render_table(ui: &mut egui::Ui, keybindings: &KeybindingMap) {
119        let bindings = keybindings.action_bindings();
120        let content_width = (ui.available_width() - SCROLLBAR_GUTTER).max(0.0);
121        let key_width = KEY_COLUMN_WIDTH.min((content_width * 0.42).max(170.0));
122        let action_width = (content_width - key_width - ROW_GAP).max(160.0);
123
124        egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
125            ui.set_width(content_width);
126
127            let mut idx = 0;
128            while idx < bindings.len() {
129                let group = bindings[idx].0.group();
130
131                if idx > 0 {
132                    ui.add_space(6.0);
133                    ui.separator();
134                    ui.add_space(6.0);
135                }
136
137                ui.label(egui::RichText::new(group).size(13.0).color(HEADER_COLOR).strong());
138                ui.add_space(4.0);
139
140                egui::Grid::new(("help_overlay_group", group))
141                    .num_columns(2)
142                    .min_col_width(0.0)
143                    .max_col_width(f32::INFINITY)
144                    .spacing(egui::vec2(ROW_GAP, 6.0))
145                    .show(ui, |ui| {
146                        while idx < bindings.len() && bindings[idx].0.group() == group {
147                            let (action, keys) = &bindings[idx];
148                            Self::render_row(ui, *action, keys, action_width, key_width);
149                            ui.end_row();
150                            idx += 1;
151                        }
152                    });
153            }
154        });
155    }
156
157    fn render_header(ui: &mut egui::Ui, visible: &mut bool) {
158        let total_width = ui.available_width();
159        let center_width = (total_width - HEADER_BUTTON_SIZE * 2.0).max(0.0);
160
161        ui.horizontal(|ui| {
162            ui.allocate_space(egui::vec2(HEADER_BUTTON_SIZE, HEADER_BUTTON_SIZE));
163
164            ui.allocate_ui_with_layout(
165                egui::vec2(center_width, HEADER_BUTTON_SIZE),
166                egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
167                |ui| {
168                    ui.label(
169                        egui::RichText::new("Keyboard Shortcuts")
170                            .size(20.0)
171                            .strong()
172                            .color(TEXT_COLOR),
173                    );
174                },
175            );
176
177            let close = ui.add_sized(
178                egui::vec2(HEADER_BUTTON_SIZE, HEADER_BUTTON_SIZE),
179                egui::Button::new(egui::RichText::new("X").size(18.0).color(TEXT_COLOR))
180                    .frame(false),
181            );
182            if close.clicked() {
183                *visible = false;
184            }
185        });
186    }
187
188    fn render_row(
189        ui: &mut egui::Ui,
190        action: Action,
191        keys: &[String],
192        action_width: f32,
193        key_width: f32,
194    ) {
195        let key_text = if keys.is_empty() { "—".to_string() } else { keys.join("  /  ") };
196
197        ui.allocate_ui_with_layout(
198            egui::vec2(action_width, ui.spacing().interact_size.y),
199            egui::Layout::left_to_right(egui::Align::Center),
200            |ui| {
201                ui.add(
202                    egui::Label::new(
203                        egui::RichText::new(action.description())
204                            .size(13.0)
205                            .strong()
206                            .color(TEXT_COLOR),
207                    )
208                    .sense(egui::Sense::hover()),
209                );
210            },
211        );
212
213        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
214            ui.allocate_ui_with_layout(
215                egui::vec2(key_width, ui.spacing().interact_size.y),
216                egui::Layout::right_to_left(egui::Align::Center),
217                |ui| {
218                    ui.add(
219                        egui::Label::new(
220                            egui::RichText::new(key_text)
221                                .size(12.5)
222                                .strong()
223                                .color(MUTED_TEXT_COLOR)
224                                .family(egui::FontFamily::Monospace),
225                        )
226                        .sense(egui::Sense::hover()),
227                    );
228                },
229            );
230        });
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn toggle_flips_visibility() {
240        let mut overlay = HelpOverlay::new();
241        assert!(!overlay.visible);
242        assert!(overlay.toggle());
243        assert!(overlay.visible);
244        assert!(!overlay.toggle());
245        assert!(!overlay.visible);
246    }
247
248    #[test]
249    fn action_bindings_covers_all_actions() {
250        let map = KeybindingMap::from_config(&std::collections::HashMap::new());
251        let bindings = map.action_bindings();
252        assert_eq!(bindings.len(), Action::all().len());
253    }
254
255    #[test]
256    fn every_action_has_description_and_group() {
257        for action in Action::all() {
258            assert!(!action.description().is_empty(), "{action:?} missing description");
259            assert!(!action.group().is_empty(), "{action:?} missing group");
260        }
261    }
262}