dais_ui/widgets/
help_overlay.rs1use 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#[derive(Default)]
21pub struct HelpOverlay {
22 pub visible: bool,
23}
24
25impl HelpOverlay {
26 pub fn new() -> Self {
27 Self::default()
28 }
29
30 pub fn toggle(&mut self) -> bool {
32 self.visible = !self.visible;
33 self.visible
34 }
35
36 pub fn show(&mut self, ctx: &egui::Context, keybindings: &KeybindingMap) -> bool {
41 if !self.visible {
42 return false;
43 }
44
45 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}