diff --git a/docs/HLSD/keyboard-shortcuts.md b/docs/HLSD/keyboard-shortcuts.md index 7cfc72c30f..cf48a13c8d 100644 --- a/docs/HLSD/keyboard-shortcuts.md +++ b/docs/HLSD/keyboard-shortcuts.md @@ -53,6 +53,13 @@ Space counts as typing. The speed dial's default is the one bare Space, and `MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons, combo boxes), so it opens the dial from the canvases and the tab strip only. +`MainFrame::handle_global_shortcut` skips a chord that `KeyChord::edits_text()` reports +while a text field has the focus (a wx text entry, spin control, web view or ImGui +InputText), and skips every chord while an ImGui modal is open, as a wx modal dialog +would. `ImGuiWrapper::owns_key()` reads the active InputText and the popup stack +directly, since `WantCaptureKeyboard` lags a frame, and is called only when the focus is on +a visible canvas from `OpenGLManager::create_wxglcanvas()`. + ## Which event a chord matches Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp index 9ce8d79e4d..080e85ad4e 100644 --- a/src/slic3r/GUI/ImGuiWrapper.cpp +++ b/src/slic3r/GUI/ImGuiWrapper.cpp @@ -62,6 +62,7 @@ #include "Search.hpp" #include "BitmapCache.hpp" #include "GUI_App.hpp" +#include "KeyChord.hpp" #include #include @@ -2134,6 +2135,27 @@ bool ImGuiWrapper::want_any_input() const return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput; } +bool ImGuiWrapper::text_input_active() const +{ + const ImGuiID id = ImGui::GetActiveID(); + return id != 0 && ImGui::GetInputTextState(id) != nullptr; +} + +bool ImGuiWrapper::modal_popup_open() const +{ + // A modal stays on the popup stack while its canvas is hidden, so it counts only if the last finished + // frame drew it, which NewFrame() moves from Active to WasActive. + const ImGuiWindow* modal = ImGui::GetTopMostPopupModal(); + if (modal == nullptr) + return false; + return ImGui::GetCurrentContext()->WithinFrameScope ? modal->WasActive : modal->Active; +} + +bool ImGuiWrapper::owns_key(const KeyChord& chord) const +{ + return modal_popup_open() || (chord.edits_text() && text_input_active()); +} + ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id) { auto item = m_custom_glyph_rects_ids.find(tex_id); diff --git a/src/slic3r/GUI/ImGuiWrapper.hpp b/src/slic3r/GUI/ImGuiWrapper.hpp index 1a6f5e1bb9..6b5487bb5f 100644 --- a/src/slic3r/GUI/ImGuiWrapper.hpp +++ b/src/slic3r/GUI/ImGuiWrapper.hpp @@ -35,6 +35,7 @@ struct ImRect; namespace Slic3r { namespace GUI { +struct KeyChord; bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data); @@ -219,6 +220,12 @@ public: bool want_keyboard() const; bool want_text_input() const; bool want_any_input() const; + // True while an ImGui InputText is active, without the one-frame lag of want_text_input(). + bool text_input_active() const; + // True while an ImGui modal popup is on screen. + bool modal_popup_open() const; + // True for every key while a modal is open, and for text-editing keys while an InputText is active. + bool owns_key(const KeyChord& chord) const; // Optional inputs are used for set up value inside of an optional, with default value // diff --git a/src/slic3r/GUI/KBShortcutsDialog.cpp b/src/slic3r/GUI/KBShortcutsDialog.cpp index 0fbe4f79c4..47fc0631ee 100644 --- a/src/slic3r/GUI/KBShortcutsDialog.cpp +++ b/src/slic3r/GUI/KBShortcutsDialog.cpp @@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages() using Section = ShortcutSection; if (wxGetApp().is_editor()) { - page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, { + page(_L("Global"), _L("Available anywhere in the window."), ShortcutContext::Global, { fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")), fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")), // wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there. @@ -438,17 +438,16 @@ ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut SetBackgroundColour(*wxWHITE); wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL); - // A Global shortcut also runs while a text field has the focus, so its hint names the keys it can use. + // A Global shortcut runs before the focused control gets the key, so its hint names the keys it can use. const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0; const wxString advice = global_key_advice(); - const wxString typing = _L("Global shortcuts also apply while typing."); const wxString rule = _L("A key that types a character cannot be a global shortcut."); - m_hint = global ? typing + "\n" + advice : _L("Esc cancels, Enter confirms."); + m_hint = global ? advice : _L("Esc cancels, Enter confirms."); m_rejection = rule + "\n" + advice; // Wide enough for each sentence on a line of its own where the translation allows, within limits. int width = FromDIP(450); - for (const wxString& sentence : { typing, rule, advice }) { + for (const wxString& sentence : { rule, advice }) { int extent = 0; GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font()); width = std::max(width, extent); diff --git a/src/slic3r/GUI/KeyChord.cpp b/src/slic3r/GUI/KeyChord.cpp index 768e503486..652eda2ae4 100644 --- a/src/slic3r/GUI/KeyChord.cpp +++ b/src/slic3r/GUI/KeyChord.cpp @@ -187,6 +187,40 @@ bool KeyChord::is_menu_accelerator() const return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE)); } +bool KeyChord::edits_text() const +{ + if (!valid()) + return false; + if (!is_menu_accelerator()) + return true; + const int mods = modifiers & ~wxMOD_SHIFT; + switch (key) { + case WXK_LEFT: case WXK_RIGHT: case WXK_UP: case WXK_DOWN: + case WXK_HOME: case WXK_END: case WXK_BACK: case WXK_DELETE: +#ifdef __APPLE__ + return mods == wxMOD_NONE || mods == wxMOD_CONTROL || mods == wxMOD_ALT; // Option moves by word +#else + return mods == wxMOD_NONE || mods == wxMOD_CONTROL; +#endif + case WXK_INSERT: + return mods == wxMOD_NONE || mods == wxMOD_CONTROL; + case WXK_RETURN: case WXK_TAB: case WXK_ESCAPE: case WXK_PAGEUP: case WXK_PAGEDOWN: + return mods == wxMOD_NONE; + default: + break; + } + if (!is_printable(key)) + return false; +#ifdef __APPLE__ + if (mods == wxMOD_ALT || mods == wxMOD_RAW_CONTROL) + return true; // Option types a character, Control runs the Cocoa text key bindings +#else + if (mods == (wxMOD_CONTROL | wxMOD_ALT)) + return true; // AltGr types a character +#endif + return mods == wxMOD_CONTROL && (key == 'A' || key == 'C' || key == 'V' || key == 'X' || key == 'Y' || key == 'Z'); +} + // Only a chord the desktop acts on while still delivering it to the app belongs here. bool KeyChord::is_system_shortcut() const { diff --git a/src/slic3r/GUI/KeyChord.hpp b/src/slic3r/GUI/KeyChord.hpp index 36d1ca8d16..f969cd149e 100644 --- a/src/slic3r/GUI/KeyChord.hpp +++ b/src/slic3r/GUI/KeyChord.hpp @@ -35,6 +35,9 @@ struct KeyChord // True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without // swallowing typing in text fields. bool is_menu_accelerator() const; + // True for a chord a focused text field acts on: typing a character (AltGr and macOS Option + // characters included), moving the caret, selecting, deleting, or the clipboard and undo chords. + bool edits_text() const; // True for a chord the desktop acts on although the app receives it, so a binding would // take it from the system. bool is_system_shortcut() const; diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 66e982ad9f..b1ae832abc 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -107,6 +108,8 @@ #include "../Utils/PrintHost.hpp" #include "GUI_App.hpp" +#include "ImGuiWrapper.hpp" +#include "OpenGLManager.hpp" #include "Shortcuts.hpp" #include "UnsavedChangesDialog.hpp" #include "PublishSettingsDialog.hpp" @@ -184,18 +187,31 @@ enum class ERescaleTarget namespace { +bool focus_is_text_field(wxWindow* focus) +{ + if (dynamic_cast(focus) || dynamic_cast(focus)) + return true; // a web page can have its own text fields + if (dynamic_cast(focus) || dynamic_cast(focus)) + return true; // the native Windows spin control is not a wxTextEntry + return false; +} + +// Only a scene canvas draws ImGui, and a hidden one can keep the wx focus after a tab switch. +const ImGuiWrapper* focused_canvas_imgui(wxWindow* focus) +{ + if (!OpenGLManager::is_scene_canvas(focus) || !focus->IsShownOnScreen()) + return nullptr; + return wxGetApp().imgui(); +} + // Space opens the speed dial, but it is the activation key for buttons, checkboxes and other -// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has -// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and -// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page. +// controls, so only take Space when the focused window has no keyboard-activation meaning of its +// own. Canvases (GLCanvas3D) and panels are not controls and fall through to "open"; the Notebook +// itself does too, so Space still opens the dial on any page. bool focus_keeps_space(wxWindow* focus) { if (!focus) return false; - if (dynamic_cast(focus)) - return true; // typing a space into a text field - if (dynamic_cast(focus)) - return true; // web content scrolls and hosts its own text fields if (dynamic_cast<::Button*>(focus)) return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl) if (dynamic_cast(focus)) @@ -816,16 +832,12 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ bool MainFrame::handle_global_shortcut(const KeyChord& chord) { - // ImGui draws its text fields (the text tool's box, a sketch value field) on the GL canvas, so - // while one of them is typed into the focused wx window is that canvas or an in-canvas frame: - // neither is a wxControl, so focus_keeps_space() cannot see it and CHAR_HOOK takes the chord - // here first -- Space opened the Speed Dial, Ctrl+P the Preferences, and the field never saw - // the key. ImGui raises WantCaptureKeyboard while one of its items owns the keyboard, so leave - // the key to it then. A focused wxControl (text field, list, web view) is untouched: those - // keep their own keys, and ImGui's flag can outlive the focus moving to one of them. + // CHAR_HOOK runs before the focused window, so leave text-editing keys to a focused text field and + // the keys ImGui owns to the focused canvas. wxWindow* const focus = wxWindow::FindFocus(); - ImGuiWrapper* const imgui = wxGetApp().imgui(); - if (focus != nullptr && imgui != nullptr && imgui->want_keyboard() && dynamic_cast(focus) == nullptr) + if (chord.edits_text() && focus_is_text_field(focus)) + return false; + if (const ImGuiWrapper* imgui = focused_canvas_imgui(focus); imgui != nullptr && imgui->owns_key(chord)) return false; const std::optional shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord); diff --git a/src/slic3r/GUI/OpenGLManager.cpp b/src/slic3r/GUI/OpenGLManager.cpp index 971a5282fc..fccfb57a9a 100644 --- a/src/slic3r/GUI/OpenGLManager.cpp +++ b/src/slic3r/GUI/OpenGLManager.cpp @@ -427,6 +427,8 @@ wxGLContext* OpenGLManager::init_glcontext(wxGLCanvas& canvas, const std::pairSetBackgroundStyle(wxBG_STYLE_PAINT); return canvas; } +bool OpenGLManager::is_scene_canvas(const wxWindow* window) +{ + return window != nullptr && window->GetName() == SCENE_CANVAS_NAME; +} + void OpenGLManager::detect_multisample(int* attribList) { int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER; diff --git a/src/slic3r/GUI/OpenGLManager.hpp b/src/slic3r/GUI/OpenGLManager.hpp index 9e82123205..e9de7fc197 100644 --- a/src/slic3r/GUI/OpenGLManager.hpp +++ b/src/slic3r/GUI/OpenGLManager.hpp @@ -101,6 +101,8 @@ public: static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); } static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; } static wxGLCanvas* create_wxglcanvas(wxWindow& parent); + // True for a canvas made by create_wxglcanvas(), the kind a GLCanvas3D draws its scene and ImGui into. + static bool is_scene_canvas(const wxWindow* window); static const GLInfo& get_gl_info() { return s_gl_info; } static bool force_power_of_two_textures() { return s_force_power_of_two_textures; } diff --git a/tests/slic3rutils/test_imgui_wrapper.cpp b/tests/slic3rutils/test_imgui_wrapper.cpp index f24e371cd2..544a3832f5 100644 --- a/tests/slic3rutils/test_imgui_wrapper.cpp +++ b/tests/slic3rutils/test_imgui_wrapper.cpp @@ -1,6 +1,7 @@ -// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is -// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in -// ; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it). +// How ImGuiWrapper turns wx mouse events into ImGui input, and when it reports that ImGui owns a key. +// ImGuiWrapper lives in libslic3r_gui; this is the suite that links it. Same Windows include prologue +// as test_texture_color_palette.cpp (wx pulls in ; keep WIN32_LEAN_AND_MEAN / NOMINMAX +// ahead of it). #ifdef WIN32 #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN @@ -17,12 +18,15 @@ #include #include +#include #include #include "slic3r/GUI/ImGuiWrapper.hpp" +#include "slic3r/GUI/KeyChord.hpp" using Catch::Matchers::WithinAbs; using Slic3r::GUI::ImGuiWrapper; +using Slic3r::GUI::KeyChord; namespace { @@ -41,6 +45,45 @@ float wheel_after(int rotation, wxMouseWheelAxis axis) return ImGui::GetIO().MouseWheel; } +// Runs ImGui frames without OpenGL by building the font atlas on the CPU. +struct ImGuiFrames +{ + ImGuiWrapper imgui; + + ImGuiFrames() + { + imgui.set_display_size(800.f, 600.f); + unsigned char* pixels = nullptr; + int width = 0, height = 0; + ImGui::GetIO().Fonts->GetTexDataAsRGBA32(&pixels, &width, &height); + } + + template void frame(Draw draw) + { + ImGui::NewFrame(); + draw(); + ImGui::EndFrame(); + } + + // SetKeyboardFocusHere() activates the field in the next frame that draws it. + void draw_input(bool focus) + { + ImGui::Begin("window"); + if (focus) + ImGui::SetKeyboardFocusHere(); + ImGui::InputText("##text", text, sizeof(text)); + ImGui::End(); + } + + char text[16] = ""; +}; + +void draw_modal() +{ + if (ImGui::BeginPopupModal("modal")) + ImGui::EndPopup(); +} + } // namespace TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]") @@ -59,3 +102,62 @@ TEST_CASE("a wheel event with no rotation or on the horizontal axis does not ste })); CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0)); } + +TEST_CASE("an InputText counts as active only while it holds the keyboard", "[ImGuiWrapper]") +{ + ImGuiFrames f; + f.frame([&] { f.draw_input(false); }); + CHECK_FALSE(f.imgui.text_input_active()); + + f.frame([&] { f.draw_input(true); }); + f.frame([&] { f.draw_input(false); }); + CHECK(f.imgui.text_input_active()); + + f.frame([] {}); + f.frame([] {}); + CHECK_FALSE(f.imgui.text_input_active()); +} + +TEST_CASE("a modal counts as open from its first frame until a frame no longer draws it", "[ImGuiWrapper]") +{ + ImGuiFrames f; + CHECK_FALSE(f.imgui.modal_popup_open()); + + f.frame([] { + ImGui::OpenPopup("modal"); + draw_modal(); + }); + CHECK(f.imgui.modal_popup_open()); + + // A frame opened by input, before anything is drawn in it. + ImGui::NewFrame(); + CHECK(f.imgui.modal_popup_open()); + draw_modal(); + ImGui::EndFrame(); + CHECK(f.imgui.modal_popup_open()); + + // A frame that no longer draws it, as when its canvas is hidden. + f.frame([] {}); + REQUIRE(ImGui::GetTopMostPopupModal() != nullptr); + CHECK_FALSE(f.imgui.modal_popup_open()); +} + +TEST_CASE("ImGui owns every key under a modal and text-editing keys in an active InputText", "[ImGuiWrapper]") +{ + ImGuiFrames f; + const KeyChord letter{ 'A' }; + const KeyChord save{ 'S', wxMOD_CONTROL }; + CHECK_FALSE(f.imgui.owns_key(letter)); + CHECK_FALSE(f.imgui.owns_key(save)); + + f.frame([&] { f.draw_input(true); }); + f.frame([&] { f.draw_input(false); }); + CHECK(f.imgui.owns_key(letter)); + CHECK_FALSE(f.imgui.owns_key(save)); + + f.frame([] { + ImGui::OpenPopup("modal"); + draw_modal(); + }); + CHECK(f.imgui.owns_key(save)); +} diff --git a/tests/slic3rutils/test_shortcuts.cpp b/tests/slic3rutils/test_shortcuts.cpp index a93dd4c92d..d36c1d3b39 100644 --- a/tests/slic3rutils/test_shortcuts.cpp +++ b/tests/slic3rutils/test_shortcuts.cpp @@ -126,6 +126,55 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators", CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty()); } +TEST_CASE("Typing, caret, deletion and clipboard chords edit text", "[Shortcuts]") +{ + CHECK(KeyChord{ 'A' }.edits_text()); + CHECK(KeyChord{ 'A', wxMOD_SHIFT }.edits_text()); + CHECK(KeyChord{ WXK_SPACE }.edits_text()); + CHECK(KeyChord{ WXK_BACK }.edits_text()); + CHECK(KeyChord{ WXK_DELETE }.edits_text()); + CHECK(KeyChord{ WXK_BACK, wxMOD_CONTROL }.edits_text()); + CHECK(KeyChord{ WXK_LEFT, wxMOD_SHIFT }.edits_text()); + CHECK(KeyChord{ WXK_RIGHT, wxMOD_CONTROL | wxMOD_SHIFT }.edits_text()); + CHECK(KeyChord{ WXK_HOME }.edits_text()); + CHECK(KeyChord{ WXK_END, wxMOD_CONTROL }.edits_text()); + CHECK(KeyChord{ WXK_DELETE, wxMOD_SHIFT }.edits_text()); + CHECK(KeyChord{ WXK_INSERT }.edits_text()); + CHECK(KeyChord{ WXK_INSERT, wxMOD_SHIFT }.edits_text()); + CHECK(KeyChord{ WXK_INSERT, wxMOD_CONTROL }.edits_text()); + CHECK(KeyChord{ WXK_PAGEUP, wxMOD_SHIFT }.edits_text()); + CHECK(KeyChord{ WXK_RETURN }.edits_text()); + CHECK(KeyChord{ WXK_TAB, wxMOD_SHIFT }.edits_text()); + CHECK(KeyChord{ WXK_ESCAPE }.edits_text()); + CHECK(KeyChord{ WXK_PAGEDOWN }.edits_text()); + for (int key : { 'A', 'C', 'V', 'X', 'Y', 'Z' }) + CHECK(KeyChord{ key, wxMOD_CONTROL }.edits_text()); + CHECK(KeyChord{ 'Z', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text()); + + CHECK_FALSE(KeyChord{ WXK_TAB, wxMOD_CONTROL }.edits_text()); + CHECK_FALSE(KeyChord{ WXK_RETURN, wxMOD_CONTROL }.edits_text()); + CHECK_FALSE(KeyChord{ WXK_PAGEDOWN, wxMOD_CONTROL }.edits_text()); + CHECK_FALSE(KeyChord{ WXK_ESCAPE, wxMOD_CONTROL }.edits_text()); + CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL }.edits_text()); + CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text()); + CHECK_FALSE(KeyChord{ '0', wxMOD_CONTROL }.edits_text()); + CHECK_FALSE(KeyChord{ WXK_F5 }.edits_text()); + CHECK_FALSE(KeyChord{}.edits_text()); +#ifdef __APPLE__ + CHECK(KeyChord{ 'E', wxMOD_ALT }.edits_text()); + CHECK(KeyChord{ 'K', wxMOD_RAW_CONTROL }.edits_text()); + CHECK(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text()); + CHECK(KeyChord{ WXK_LEFT, wxMOD_CONTROL }.edits_text()); + CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_RAW_CONTROL }.edits_text()); + CHECK_FALSE(KeyChord{ 'E', wxMOD_CONTROL | wxMOD_ALT }.edits_text()); +#else + CHECK(KeyChord{ 'Q', wxMOD_CONTROL | wxMOD_ALT }.edits_text()); + CHECK_FALSE(KeyChord{ 'E', wxMOD_ALT }.edits_text()); + CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text()); + CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_CONTROL | wxMOD_ALT }.edits_text()); +#endif +} + TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]") { #ifdef _WIN32 @@ -186,6 +235,21 @@ TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]") } } +TEST_CASE("No default Global binding except the Speed Dial's Space edits text", "[Shortcuts]") +{ + ShortcutRegistry registry; + size_t checked = 0; + for (size_t i = 0; i < size_t(Shortcut::Count); ++i) { + const ShortcutInfo& info = shortcut_info(Shortcut(i)); + if ((info.contexts & context_bit(ShortcutContext::Global)) == 0 || info.id == Shortcut::SpeedDial) + continue; + CAPTURE(info.key); + CHECK_FALSE(registry.binding(info.id).edits_text()); + ++checked; + } + CHECK(checked > 0); +} + TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]") { ShortcutRegistry registry;