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