yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
shortcut_manager.cc
Go to the documentation of this file.
1#include "shortcut_manager.h"
2
3#include <algorithm>
4#include <cctype>
5#include <cstddef>
6#include <functional>
7#include <optional>
8#include <string>
9#include <utility>
10#include <vector>
11
12#include "absl/strings/match.h"
13#include "absl/strings/str_split.h"
14#include "absl/strings/string_view.h"
15#include "app/gui/core/input.h"
17#include "imgui/imgui.h"
18
19namespace yaze {
20namespace editor {
21
22namespace {
24 int required_mods = 0; // ImGuiMod_* mask
25 std::vector<ImGuiKey> main_keys; // Non-modifier keys
26};
27
28ParsedChord DecomposeChord(const std::vector<ImGuiKey>& keys) {
29 ParsedChord out;
30 out.main_keys.reserve(keys.size());
31
32 for (ImGuiKey key : keys) {
33 const int key_value = static_cast<int>(key);
34 if (key_value & ImGuiMod_Mask_) {
35 out.required_mods |= key_value & ImGuiMod_Mask_;
36 continue;
37 }
38 out.main_keys.push_back(key);
39 }
40
41 return out;
42}
43
44int CountMods(int mods) {
45 int count = 0;
46 if (mods & ImGuiMod_Ctrl)
47 ++count;
48 if (mods & ImGuiMod_Shift)
49 ++count;
50 if (mods & ImGuiMod_Alt)
51 ++count;
52 if (mods & ImGuiMod_Super)
53 ++count;
54 return count;
55}
56
58 // Higher wins: the more specific scope takes the chord.
59 switch (scope) {
61 return 3;
63 return 2;
65 return 1;
66 }
67 return 0;
68}
69
70// Chords that an ImGui text field consumes itself (clipboard, undo/redo,
71// select-all, word navigation/deletion). Shortcuts bound to them must yield
72// while io.WantTextInput is set, or the field and the editor both act.
73bool IsTextEditingChord(const ParsedChord& chord) {
74 if (chord.main_keys.size() != 1) {
75 return false;
76 }
77 const int primary = chord.required_mods & (ImGuiMod_Ctrl | ImGuiMod_Super);
78 if (primary == 0 || (chord.required_mods & ImGuiMod_Alt) != 0) {
79 return false;
80 }
81 switch (chord.main_keys.front()) {
82 case ImGuiKey_A:
83 case ImGuiKey_C:
84 case ImGuiKey_V:
85 case ImGuiKey_X:
86 case ImGuiKey_Y:
87 case ImGuiKey_Z:
88 case ImGuiKey_LeftArrow:
89 case ImGuiKey_RightArrow:
90 case ImGuiKey_UpArrow:
91 case ImGuiKey_DownArrow:
92 case ImGuiKey_Home:
93 case ImGuiKey_End:
94 case ImGuiKey_Backspace:
95 case ImGuiKey_Delete:
96 return true;
97 default:
98 return false;
99 }
100}
101
103 int mods = 0;
104 std::vector<int> main_keys;
105 bool operator==(const NormalizedChord& other) const {
106 return mods == other.mods && main_keys == other.main_keys;
107 }
108};
109
110NormalizedChord Normalize(const std::vector<ImGuiKey>& keys) {
111 ParsedChord parsed = DecomposeChord(keys);
112 NormalizedChord out;
113 out.mods = parsed.required_mods;
114 // ModsSatisfied() treats Ctrl and Super as the same requirement on macOS,
115 // so they collide there.
116 if (gui::IsMacPlatform() && (out.mods & (ImGuiMod_Ctrl | ImGuiMod_Super))) {
117 out.mods = (out.mods & ~ImGuiMod_Super) | ImGuiMod_Ctrl;
118 }
119 for (ImGuiKey key : parsed.main_keys) {
120 out.main_keys.push_back(static_cast<int>(key));
121 }
122 std::sort(out.main_keys.begin(), out.main_keys.end());
123 return out;
124}
125
126// Reverse lookup for key names produced by gui::GetKeyName ("0", "=", "[",
127// "Space", "PageDown", ...), case-insensitive, so PrintShortcut() output
128// parses back to the same keys.
129ImGuiKey LookupNamedKey(const std::string& lower) {
130 static const std::pair<const char*, ImGuiKey> kAliases[] = {
131 {"escape", ImGuiKey_Escape},
132 {"return", ImGuiKey_Enter},
133 {"del", ImGuiKey_Delete},
134 {"keypadadd", ImGuiKey_KeypadAdd},
135 {"keypadsubtract", ImGuiKey_KeypadSubtract},
136 {"equal", ImGuiKey_Equal},
137 {"minus", ImGuiKey_Minus},
138 {"comma", ImGuiKey_Comma},
139 };
140 for (const auto& [alias, key] : kAliases) {
141 if (lower == alias) {
142 return key;
143 }
144 }
145 for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k) {
146 const ImGuiKey key = static_cast<ImGuiKey>(k);
147 std::string name = gui::GetKeyName(key);
148 if (name == "?") {
149 continue;
150 }
151 for (char& c : name) {
152 c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
153 }
154 if (name == lower) {
155 return key;
156 }
157 }
158 return ImGuiKey_None;
159}
160
161bool ModsSatisfied(int pressed_mods, int required_mods) {
162 if (required_mods == 0) {
163 // A plain-key command must not shadow a modified chord that is handled by
164 // the active panel (for example D vs Ctrl/Cmd+D in the dungeon editor).
165 constexpr int kRelevantMods =
166 ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiMod_Alt | ImGuiMod_Super;
167 return (pressed_mods & kRelevantMods) == 0;
168 }
169
170 auto has = [&](int mod) -> bool {
171 return (pressed_mods & mod) != 0;
172 };
173
174 // macOS: ImGui may swap Cmd(Super) and Ctrl at the io.AddKeyEvent() layer
175 // (ConfigMacOSXBehaviors). Treat Ctrl and Super as equivalent requirements so
176 // shortcuts work regardless of that swap and for users who press either key.
177 const bool mac = gui::IsMacPlatform();
178
179 if (required_mods & ImGuiMod_Shift) {
180 if (!has(ImGuiMod_Shift))
181 return false;
182 }
183 if (required_mods & ImGuiMod_Alt) {
184 if (!has(ImGuiMod_Alt))
185 return false;
186 }
187
188 if (required_mods & ImGuiMod_Ctrl) {
189 if (mac) {
190 if (!(has(ImGuiMod_Ctrl) || has(ImGuiMod_Super)))
191 return false;
192 } else {
193 if (!has(ImGuiMod_Ctrl))
194 return false;
195 }
196 }
197 if (required_mods & ImGuiMod_Super) {
198 if (mac) {
199 if (!(has(ImGuiMod_Super) || has(ImGuiMod_Ctrl)))
200 return false;
201 } else {
202 if (!has(ImGuiMod_Super))
203 return false;
204 }
205 }
206
207 return true;
208}
209} // namespace
210
211std::string PrintShortcut(const std::vector<ImGuiKey>& keys) {
212 // Use the platform-aware FormatShortcut from platform_keys.h
213 // This handles Ctrl→Cmd and Alt→Opt conversions for macOS/WASM
214 return gui::FormatShortcut(keys);
215}
216
217std::string InferShortcutGroup(absl::string_view name) {
218 using absl::StartsWith;
219 using absl::StrContains;
220
221 if (StartsWith(name, "File")) {
222 return "File";
223 }
224 if (StartsWith(name, "Edit")) {
225 return "Edit";
226 }
227 if (StartsWith(name, "Help")) {
228 return "Help";
229 }
230 if (StartsWith(name, "Tools") || name == "Command Palette" ||
231 name == "Global Search" || name == "Load Last ROM" ||
232 name == "Show About") {
233 return "Tools";
234 }
235 if (StartsWith(name, "Layout") || StartsWith(name, "Apply Layout") ||
236 StartsWith(name, "Apply Profile") || StartsWith(name, "Apply:") ||
237 StartsWith(name, "layout:")) {
238 return "Layout";
239 }
240 if (StartsWith(name, "drawer:") ||
241 (StartsWith(name, "View: Toggle") &&
242 (StrContains(name, "Drawer") || StrContains(name, "Panel") ||
243 StrContains(name, "Notifications") || StrContains(name, "Agent") ||
244 StrContains(name, "Proposals") || StrContains(name, "Settings") ||
245 StrContains(name, "Help") || StrContains(name, "Project") ||
246 StrContains(name, "Properties"))) ||
247 StartsWith(name, "View: Next Right") ||
248 StartsWith(name, "View: Previous Right")) {
249 return "Drawers";
250 }
251 if (StartsWith(name, "window:") || StartsWith(name, "Window") ||
252 StartsWith(name, "Panel Browser") || StartsWith(name, "Window Browser") ||
253 StartsWith(name, "Window Finder") || StartsWith(name, "View: Show")) {
254 return "Windows";
255 }
256 if (StartsWith(name, "View") || StartsWith(name, "Sidebar")) {
257 return "View";
258 }
259 if (StrContains(name, ".")) {
260 return "Editor";
261 }
262 return "Other";
263}
264
265std::vector<ImGuiKey> ParseShortcut(const std::string& shortcut) {
266 std::vector<ImGuiKey> keys;
267 if (shortcut.empty()) {
268 return keys;
269 }
270
271 // Split on '+' and trim whitespace
272 std::vector<std::string> parts = absl::StrSplit(shortcut, '+');
273 for (auto& part : parts) {
274 // Trim leading/trailing spaces
275 while (!part.empty() && (part.front() == ' ' || part.front() == '\t')) {
276 part.erase(part.begin());
277 }
278 while (!part.empty() && (part.back() == ' ' || part.back() == '\t')) {
279 part.pop_back();
280 }
281 if (part.empty())
282 continue;
283
284 std::string lower;
285 lower.reserve(part.size());
286 for (char c : part)
287 lower.push_back(static_cast<char>(std::tolower(c)));
288
289 // Modifiers (support platform aliases)
290 if (lower == "ctrl" || lower == "control") {
291 keys.push_back(ImGuiMod_Ctrl);
292 continue;
293 }
294 if (lower == "cmd" || lower == "command") {
295 // ImGui's macOS behaviors swap Cmd/Super into ImGuiMod_Ctrl at the
296 // time of io.AddKeyEvent(), so using ImGuiMod_Ctrl here makes "Cmd"
297 // bindings work as expected and round-trip with PrintShortcut().
298 keys.push_back(gui::IsMacPlatform() ? ImGuiMod_Ctrl : ImGuiMod_Super);
299 continue;
300 }
301 if (lower == "win" || lower == "super") {
302 keys.push_back(ImGuiMod_Super);
303 continue;
304 }
305 if (lower == "alt" || lower == "opt" || lower == "option") {
306 keys.push_back(ImGuiMod_Alt);
307 continue;
308 }
309 if (lower == "shift") {
310 keys.push_back(ImGuiMod_Shift);
311 continue;
312 }
313
314 // Function keys
315 if (lower.size() >= 2 && lower[0] == 'f') {
316 int fnum = 0;
317 try {
318 fnum = std::stoi(lower.substr(1));
319 } catch (...) {
320 fnum = 0;
321 }
322 if (fnum >= 1 && fnum <= 24) {
323 keys.push_back(static_cast<ImGuiKey>(ImGuiKey_F1 + (fnum - 1)));
324 continue;
325 }
326 }
327
328 // Single character keys
329 if (part.size() == 1) {
330 ImGuiKey mapped = gui::MapKeyToImGuiKey(part[0]);
331 if (mapped != ImGuiKey_COUNT) {
332 keys.push_back(mapped);
333 continue;
334 }
335 }
336
337 // Digits, punctuation, lowercase letters and named keys (Space, Esc, ...)
338 const ImGuiKey named = LookupNamedKey(lower);
339 if (named != ImGuiKey_None) {
340 keys.push_back(named);
341 continue;
342 }
343 }
344
345 return keys;
346}
347
348void ExecuteShortcuts(const ShortcutManager& shortcut_manager) {
349 // Check for keyboard shortcuts using the shortcut manager.
350 //
351 // Note: we intentionally do NOT gate on io.WantCaptureKeyboard here. In an
352 // ImGui-first app it is frequently true (focused windows, menus, etc) and
353 // would incorrectly disable shortcuts globally.
354 const ImGuiIO& io = ImGui::GetIO();
355
356 struct Candidate {
357 const Shortcut* shortcut = nullptr;
358 int scope_priority = 0;
359 bool editor_owned = false;
360 int key_count = 0;
361 int mod_count = 0;
362 std::string name;
363 };
364
365 auto better = [](const Candidate& a, const Candidate& b) -> bool {
366 if (a.scope_priority != b.scope_priority)
367 return a.scope_priority > b.scope_priority;
368 if (a.editor_owned != b.editor_owned)
369 return a.editor_owned;
370 if (a.key_count != b.key_count)
371 return a.key_count > b.key_count;
372 if (a.mod_count != b.mod_count)
373 return a.mod_count > b.mod_count;
374 return a.name < b.name;
375 };
376
377 Candidate best;
378 bool have_best = false;
379
380 for (const auto& [name, shortcut] : shortcut_manager.GetShortcuts()) {
381 if (!shortcut.callback) {
382 continue;
383 }
384 if (shortcut.keys.empty()) {
385 continue; // command palette only
386 }
387
388 const ParsedChord chord = DecomposeChord(shortcut.keys);
389 if (chord.main_keys.empty()) {
390 continue;
391 }
392
393 // When typing in an InputText, don't steal plain keys (Space, letters,
394 // etc) or the chords the text field implements itself (Cmd/Ctrl+Z/C/V...).
395 if (io.WantTextInput &&
396 (chord.required_mods == 0 || IsTextEditingChord(chord))) {
397 continue;
398 }
399
400 // Modifier satisfaction (macOS Cmd/Ctrl handling is normalized by
401 // ModsSatisfied()).
402 if (!ModsSatisfied(io.KeyMods, chord.required_mods)) {
403 continue;
404 }
405
406 // Require all non-mod keys, with the last key triggering on press.
407 bool chord_pressed = true;
408 for (size_t i = 0; i + 1 < chord.main_keys.size(); ++i) {
409 if (!ImGui::IsKeyDown(chord.main_keys[i])) {
410 chord_pressed = false;
411 break;
412 }
413 }
414 if (!chord_pressed) {
415 continue;
416 }
417 if (!ImGui::IsKeyPressed(chord.main_keys.back(), false /* repeat */)) {
418 continue;
419 }
420
421 // Only now evaluate applicability: the chord matched, so the predicate
422 // cost is paid for at most a handful of shortcuts per key press.
423 if (!shortcut_manager.IsShortcutApplicable(shortcut)) {
424 continue;
425 }
426
427 Candidate cand;
428 cand.shortcut = &shortcut;
429 cand.scope_priority = ScopePriority(shortcut.scope);
430 cand.editor_owned = shortcut.editor_type.has_value();
431 cand.key_count = static_cast<int>(chord.main_keys.size());
432 cand.mod_count = CountMods(chord.required_mods);
433 cand.name = name;
434
435 if (!have_best || better(cand, best)) {
436 best = std::move(cand);
437 have_best = true;
438 }
439 }
440
441 if (have_best && best.shortcut && best.shortcut->callback) {
442 best.shortcut->callback();
443 }
444}
445
446bool SameChord(const std::vector<ImGuiKey>& a, const std::vector<ImGuiKey>& b) {
447 if (a.empty() || b.empty()) {
448 return false;
449 }
450 return Normalize(a) == Normalize(b);
451}
452
453bool ShortcutManager::UpdateShortcutKeys(const std::string& name,
454 const std::vector<ImGuiKey>& keys) {
455 auto it = shortcuts_.find(name);
456 if (it == shortcuts_.end()) {
457 return false;
458 }
459 it->second.keys = keys;
460 return true;
461}
462
463bool ShortcutManager::ResetShortcutKeys(const std::string& name) {
464 auto it = shortcuts_.find(name);
465 if (it == shortcuts_.end()) {
466 return false;
467 }
468 it->second.keys = it->second.default_keys;
469 return true;
470}
471
472bool ShortcutManager::SetShortcutEditor(const std::string& name,
473 std::optional<EditorType> editor_type) {
474 auto it = shortcuts_.find(name);
475 if (it == shortcuts_.end()) {
476 return false;
477 }
478 it->second.editor_type = editor_type;
479 return true;
480}
481
482bool ShortcutManager::SetShortcutEnabled(const std::string& name,
483 std::function<bool()> enabled) {
484 auto it = shortcuts_.find(name);
485 if (it == shortcuts_.end()) {
486 return false;
487 }
488 it->second.enabled = std::move(enabled);
489 return true;
490}
491
493 if (shortcut.editor_type.has_value() && active_editor_provider_) {
494 const std::optional<EditorType> active = active_editor_provider_();
495 if (!active.has_value() || *active != *shortcut.editor_type) {
496 return false;
497 }
498 }
499 if (shortcut.enabled && !shortcut.enabled()) {
500 return false;
501 }
502 return true;
503}
504
505std::string ShortcutManager::GetDisplayString(const std::string& name) const {
506 auto it = shortcuts_.find(name);
507 if (it == shortcuts_.end() || it->second.keys.empty()) {
508 return "";
509 }
510 return PrintShortcut(it->second.keys);
511}
512
513std::vector<std::string> ShortcutManager::FindConflicts(
514 const std::string& name) const {
515 auto it = shortcuts_.find(name);
516 if (it == shortcuts_.end()) {
517 return {};
518 }
519 return FindConflicts(it->second.keys, it->second.editor_type, name);
520}
521
522std::vector<std::string> ShortcutManager::FindConflicts(
523 const std::vector<ImGuiKey>& keys, std::optional<EditorType> editor_type,
524 const std::string& exclude_name) const {
525 std::vector<std::string> conflicts;
526 if (keys.empty()) {
527 return conflicts;
528 }
529 for (const auto& [other_name, other] : shortcuts_) {
530 if (other_name == exclude_name || other.keys.empty()) {
531 continue;
532 }
533 // Shortcuts owned by two different editors are never live together.
534 if (editor_type.has_value() && other.editor_type.has_value() &&
535 *editor_type != *other.editor_type) {
536 continue;
537 }
538 if (SameChord(keys, other.keys)) {
539 conflicts.push_back(other_name);
540 }
541 }
542 std::sort(conflicts.begin(), conflicts.end());
543 return conflicts;
544}
545
546} // namespace editor
547} // namespace yaze
548
549// Implementation in header file (inline methods)
550namespace yaze {
551namespace editor {
552
554 std::function<void()> save_callback, std::function<void()> open_callback,
555 std::function<void()> close_callback, std::function<void()> find_callback,
556 std::function<void()> settings_callback) {
557 // Ctrl+S - Save
558 if (save_callback) {
559 RegisterShortcut("save", {ImGuiMod_Ctrl, ImGuiKey_S}, save_callback);
560 }
561
562 // Ctrl+O - Open
563 if (open_callback) {
564 RegisterShortcut("open", {ImGuiMod_Ctrl, ImGuiKey_O}, open_callback);
565 }
566
567 // Ctrl+W - Close
568 if (close_callback) {
569 RegisterShortcut("close", {ImGuiMod_Ctrl, ImGuiKey_W}, close_callback);
570 }
571
572 // Ctrl+F - Find
573 if (find_callback) {
574 RegisterShortcut("find", {ImGuiMod_Ctrl, ImGuiKey_F}, find_callback);
575 }
576
577 // Ctrl+, - Settings
578 if (settings_callback) {
579 RegisterShortcut("settings", {ImGuiMod_Ctrl, ImGuiKey_Comma},
580 settings_callback);
581 }
582
583 // Ctrl+Tab - Next tab (placeholder for now)
584 // Ctrl+Shift+Tab - Previous tab (placeholder for now)
585}
586
588 std::function<void()> focus_left, std::function<void()> focus_right,
589 std::function<void()> focus_up, std::function<void()> focus_down,
590 std::function<void()> close_window, std::function<void()> split_horizontal,
591 std::function<void()> split_vertical) {
592 // Ctrl/Cmd+Alt+Arrow keys for global pane navigation. Ctrl/Cmd+Arrow is
593 // reserved for editor-local spatial navigation (for example dungeon rooms).
594 if (focus_left) {
595 RegisterShortcut("focus_left",
596 {ImGuiMod_Ctrl, ImGuiMod_Alt, ImGuiKey_LeftArrow},
597 focus_left);
598 }
599
600 if (focus_right) {
601 RegisterShortcut("focus_right",
602 {ImGuiMod_Ctrl, ImGuiMod_Alt, ImGuiKey_RightArrow},
603 focus_right);
604 }
605
606 if (focus_up) {
607 RegisterShortcut("focus_up",
608 {ImGuiMod_Ctrl, ImGuiMod_Alt, ImGuiKey_UpArrow}, focus_up);
609 }
610
611 if (focus_down) {
612 RegisterShortcut("focus_down",
613 {ImGuiMod_Ctrl, ImGuiMod_Alt, ImGuiKey_DownArrow},
614 focus_down);
615 }
616
617 // Ctrl+W, C - Close current window
618 if (close_window) {
619 RegisterShortcut("close_window", {ImGuiMod_Ctrl, ImGuiKey_W, ImGuiKey_C},
620 close_window);
621 }
622
623 // Ctrl+W, S - Split horizontal
624 if (split_horizontal) {
625 RegisterShortcut("split_horizontal",
626 {ImGuiMod_Ctrl, ImGuiKey_W, ImGuiKey_S}, split_horizontal);
627 }
628
629 // Ctrl+W, V - Split vertical
630 if (split_vertical) {
631 RegisterShortcut("split_vertical", {ImGuiMod_Ctrl, ImGuiKey_W, ImGuiKey_V},
632 split_vertical);
633 }
634}
635
636} // namespace editor
637} // namespace yaze
bool SetShortcutEditor(const std::string &name, std::optional< EditorType > editor_type)
Restrict an existing shortcut to one editor. Returns false if unknown.
ActiveEditorProvider active_editor_provider_
void RegisterShortcut(const std::string &name, const std::vector< ImGuiKey > &keys, Shortcut::Scope scope=Shortcut::Scope::kGlobal)
bool IsShortcutApplicable(const Shortcut &shortcut) const
True when the shortcut may fire now (editor owner + enabled predicate).
void RegisterStandardShortcuts(std::function< void()> save_callback, std::function< void()> open_callback, std::function< void()> close_callback, std::function< void()> find_callback, std::function< void()> settings_callback)
bool SetShortcutEnabled(const std::string &name, std::function< bool()> enabled)
Attach an applicability predicate. Returns false if unknown.
void RegisterWindowNavigationShortcuts(std::function< void()> focus_left, std::function< void()> focus_right, std::function< void()> focus_up, std::function< void()> focus_down, std::function< void()> close_window, std::function< void()> split_horizontal, std::function< void()> split_vertical)
std::string GetDisplayString(const std::string &name) const
Live, platform-formatted binding for a shortcut name.
bool ResetShortcutKeys(const std::string &name)
Restore the keys the shortcut was registered with.
std::unordered_map< std::string, Shortcut > shortcuts_
const std::unordered_map< std::string, Shortcut > & GetShortcuts() const
std::vector< std::string > FindConflicts(const std::string &name) const
Names of other shortcuts bound to the same chord that can be active at the same time (same editor own...
bool UpdateShortcutKeys(const std::string &name, const std::vector< ImGuiKey > &keys)
ParsedChord DecomposeChord(const std::vector< ImGuiKey > &keys)
bool ModsSatisfied(int pressed_mods, int required_mods)
NormalizedChord Normalize(const std::vector< ImGuiKey > &keys)
std::string InferShortcutGroup(absl::string_view name)
Menu-IA group for a shortcut/command name (File, View, Drawers, …).
bool SameChord(const std::vector< ImGuiKey > &a, const std::vector< ImGuiKey > &b)
True when two key lists describe the same chord (order-insensitive).
std::vector< ImGuiKey > ParseShortcut(const std::string &shortcut)
std::string PrintShortcut(const std::vector< ImGuiKey > &keys)
void ExecuteShortcuts(const ShortcutManager &shortcut_manager)
Fire at most one shortcut for this frame's key press.
const char * GetKeyName(ImGuiKey key)
Get the ImGui key name as a string.
std::string FormatShortcut(const std::vector< ImGuiKey > &keys)
Format a list of ImGui keys into a human-readable shortcut string.
bool IsMacPlatform()
Check if running on macOS (native or web)
ImGuiKey MapKeyToImGuiKey(char key)
Definition input.cc:708
std::function< bool()> enabled
std::optional< EditorType > editor_type