From 824b216f18232a0e25644e225c286746ee07cb5f Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Mon, 21 Sep 2026 15:36:04 -0500 Subject: [PATCH] feat(gui): assignable keyboard shortcuts (#15706) --- docs/HLSD/keyboard-shortcuts.md | 132 +++ src/slic3r/CMakeLists.txt | 4 + src/slic3r/GUI/GLCanvas3D.cpp | 824 ++++++------------ src/slic3r/GUI/GLCanvas3D.hpp | 27 +- src/slic3r/GUI/GUI_App.cpp | 30 +- src/slic3r/GUI/GUI_App.hpp | 12 +- src/slic3r/GUI/GUI_Factories.cpp | 10 +- src/slic3r/GUI/GUI_ObjectList.cpp | 131 ++- src/slic3r/GUI/GUI_ObjectList.hpp | 9 +- src/slic3r/GUI/GUI_Preview.cpp | 24 - src/slic3r/GUI/GUI_Preview.hpp | 1 - src/slic3r/GUI/Gizmos/GLGizmoAdvancedCut.cpp | 1 - src/slic3r/GUI/Gizmos/GLGizmoAssembly.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoBase.cpp | 9 +- src/slic3r/GUI/Gizmos/GLGizmoBase.hpp | 9 +- src/slic3r/GUI/Gizmos/GLGizmoBrimEars.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoCut.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp | 30 +- src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.hpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoFuzzySkin.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoHollow.cpp | 1 - src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.cpp | 3 +- .../GUI/Gizmos/GLGizmoMmuSegmentation.cpp | 37 +- .../GUI/Gizmos/GLGizmoMmuSegmentation.hpp | 2 +- src/slic3r/GUI/Gizmos/GLGizmoMove.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp | 2 + src/slic3r/GUI/Gizmos/GLGizmoRotate.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoScale.cpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoSeam.cpp | 22 +- src/slic3r/GUI/Gizmos/GLGizmoSeam.hpp | 3 +- src/slic3r/GUI/Gizmos/GLGizmoSlaSupports.cpp | 1 - src/slic3r/GUI/Gizmos/GLGizmoText.cpp | 1 - src/slic3r/GUI/Gizmos/GLGizmosManager.cpp | 86 +- src/slic3r/GUI/Gizmos/GLGizmosManager.hpp | 5 +- src/slic3r/GUI/HintNotification.cpp | 4 +- src/slic3r/GUI/KBShortcutsDialog.cpp | 800 ++++++++++------- src/slic3r/GUI/KBShortcutsDialog.hpp | 123 ++- src/slic3r/GUI/KeyChord.cpp | 314 +++++++ src/slic3r/GUI/KeyChord.hpp | 64 ++ src/slic3r/GUI/MainFrame.cpp | 314 ++++--- src/slic3r/GUI/MainFrame.hpp | 26 + src/slic3r/GUI/NativeCommands.cpp | 3 +- src/slic3r/GUI/Plater.cpp | 12 +- src/slic3r/GUI/Preferences.cpp | 35 +- src/slic3r/GUI/Preferences.hpp | 6 + src/slic3r/GUI/Shortcuts.cpp | 377 ++++++++ src/slic3r/GUI/Shortcuts.hpp | 127 +++ tests/slic3rutils/CMakeLists.txt | 1 + tests/slic3rutils/test_shortcuts.cpp | 329 +++++++ 52 files changed, 2685 insertions(+), 1299 deletions(-) create mode 100644 docs/HLSD/keyboard-shortcuts.md create mode 100644 src/slic3r/GUI/KeyChord.cpp create mode 100644 src/slic3r/GUI/KeyChord.hpp create mode 100644 src/slic3r/GUI/Shortcuts.cpp create mode 100644 src/slic3r/GUI/Shortcuts.hpp create mode 100644 tests/slic3rutils/test_shortcuts.cpp diff --git a/docs/HLSD/keyboard-shortcuts.md b/docs/HLSD/keyboard-shortcuts.md new file mode 100644 index 0000000000..7cfc72c30f --- /dev/null +++ b/docs/HLSD/keyboard-shortcuts.md @@ -0,0 +1,132 @@ +# Keyboard Shortcuts + +## Why it exists + +Key events arrive in several windows (the main frame's char hook, the 3D canvases, the +gizmo manager and the object list), and the same keys are shown again in menu labels, +toolbar tooltips, gizmo names and the shortcuts dialog. The registry is the one table all +of them read. Each binding is defined once; dispatchers look key events up there, labels +are derived from it, and a change the user makes updates all of them. + +## Data model + +`KeyChord` (`src/slic3r/GUI/KeyChord.hpp`) is one key press: the key code as +`wxEVT_KEY_DOWN` reports it, plus the `wxMOD_*` modifiers held with it. It has two +text forms. The canonical one (`Ctrl+Shift+S`) is platform-neutral and doubles as the wx +accelerator string and the config format. The display one uses translated modifier +names and the command and option glyphs on macOS. `KeyChord::from_event()` turns any wx +key event into the same key code and modifiers, so a chord recorded in the dialog is +equal to the chord a dispatcher builds from the key press. + +`Shortcut` is the enum of every user-facing binding. `shortcut_table` in +`src/slic3r/GUI/Shortcuts.cpp` gives each one a config key, a description, a context +mask, a default chord, a `repeatable` flag and a `modifier_variants` flag, in the order +the dialog lists them; a `static_assert` keeps the table and the enum in step. + +`ShortcutRegistry` overlays the user's overrides on the defaults and keeps a +chord-to-shortcut index for lookups. It reads and writes the `shortcuts` section of +`AppConfig`. Only overrides are stored, so a default can change between releases +without touching anyone's config; `none` records a shortcut the user unbound. + +## Contexts + +A key press is looked up in the context of the window that received it. + +| Context | Dispatcher | Examples | +|--------------|-----------------------------------------------------------|-------------------------------| +| `Global` | `MainFrame`'s `wxEVT_CHAR_HOOK`, before any child sees it | New project, camera views | +| `Plater` | `GLCanvas3D` of the 3D and assembly views | Arrange, gizmo activation | +| `Preview` | `GLCanvas3D` of the G-code preview | One-layer mode, jump to layer | +| `ObjectList` | the object list | Copy, delete, auto drop | +| `Painting` | `GLGizmosManager` while a painting gizmo is open | Circle, sphere, fill tools | + +A shortcut can belong to several contexts, which is how copy and paste are a single +binding for the canvas and the object list. Two shortcuts can share a chord when their +contexts do not overlap; `C` is the cut gizmo in the 3D view, the G-code window in the +preview and the circle tool while painting. A Global chord is dispatched before every +other context, so the dialog treats it as conflicting with all of them. + +A Global shortcut has to include Ctrl or Alt or use a key that types nothing, since a +bare printable key in the frame hook would swallow that character in every text field. +The dialog refuses such chords and `ShortcutRegistry::load()` drops them from the config. +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. + +## Which event a chord matches + +Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend +on the keyboard layout: the key labelled `Q` on an AZERTY keyboard and the key in the +same position under a Cyrillic layout both report `Q`. Numpad keys fold onto their main +keyboard equivalents, so `Ctrl+1` and `Ctrl+Numpad 1` are one binding. + +Punctuation matches on `wxEVT_CHAR`, because only the char event knows which character +a key produced under the active layout. `+` is Shift and `=` on a US keyboard and a key +of its own on a German one, and the binding means the character in both cases. The +canvas looks a key up on key-down first and, when nothing matched, once more on the char +event, for punctuation chords only. The dialog records chords the same way: a printable +non-alphanumeric key pressed with nothing but Shift is taken from the char event that +follows. + +wxGTK does not report key auto-repeat, so the canvases share one record of the keys +seen going down and swallow the repeats of every shortcut not marked `repeatable`. Zoom +and undo repeat, for example; a toggle such as Tab does not. The record is shared because +a shortcut can move the focus to another canvas while its key is still held; a key +released while no canvas had the focus is dropped on the next press. + +A few shortcuts have `modifier_variants`: Shift or Ctrl added to their binding selects a +step of the same action (1 mm and camera-space moves of the selection, five-step slider +moves). Only a binding without Shift or Ctrl of its own has steps, so no two bindings +share one. `ShortcutRegistry::match()` looks the exact chord up first and only then, when +nothing is bound to it, looks for such a shortcut whose binding is the chord minus those +modifiers, reporting which were added; a binding on Ctrl+Shift+key therefore wins over +the combined step. The Shift and Ctrl steps themselves are reserved. `step_owner()` names +the shortcut they belong to, the capture dialog refuses to assign them, and +`conflicts()` reports exact chords only. A binding made before its key became a stepping +key keeps its chord and shadows that one step. A move or rotation of the selection +started from the keyboard runs until the key that started it is released, or the +canvas loses focus, so a held key is one undo step. + +## Labels + +Menu labels, toolbar tooltips, gizmo names, the context menu and the shortcuts dialog +read the registry, so a rebinding shows up in all of them. Each tracked menu item keeps +its base label; `MainFrame::update_shortcut_labels()` appends the current binding +again after an edit, which also installs the new wx accelerator. + +A chord that is unsafe as a menu accelerator, meaning a bare printable key, is appended +to the label as plain text so the menu cannot take it away from text fields. The macOS +edit menu shows its clipboard and undo entries that way, because a system-menu key +equivalent for Cmd+C would run instead of the text field's own copy. + +On macOS the object list receives no key events at all, so its bindings are installed as +a `wxAcceleratorTable`, regenerated from the registry after each edit. + +## Editing + +The shortcuts dialog has a page per context, each opening with a line that says when its +keys apply. A page lists the shortcuts under the headings of `section_table`, with the +fixed keys that cannot change (mouse buttons, the step modifiers, Esc, the digit keys +that pick a filament) sorted into the same sections. The mouse drag rows describe the +camera actions chosen in Preferences; their button opens Preferences > Control with that +option scrolled into view and focused, instead of editing a key. +Editing a row opens a capture dialog that records the next chord, names the shortcuts it +would take the chord from, and on confirmation unbinds those and binds this one. +Resetting a row asks the same question when its default is now held by another +shortcut, so a reset cannot leave two shortcuts on one chord. Each change is written to +the config at once and pushed to the menus, tooltips and accelerator tables through +`GUI_App::on_shortcuts_changed()`. The dialog opens from the Help menu and Preferences > +Control on the Global page, and from the `?` key on the page of the view that received it. + +## Adding a shortcut + +1. Add the enum value to `Shortcut` and its row to `shortcut_table`, in the position + the dialog should list it; the row's section heading is the `section_table` entry + above it, so a new section needs an entry there too. Pick a default that does not + collide inside its contexts; the `[Shortcuts]` tests check every default against the + others. +2. Handle it in the dispatcher of its context: `MainFrame::handle_global_shortcut`, + `GLCanvas3D::handle_shortcut`, `ObjectList::dispatch_shortcut`, or a gizmo's + `on_tool_shortcut`. A gizmo that opens on a key sets `m_shortcut` in its constructor. +3. Where the UI shows the key, ask the registry (`display()` for tooltips, + `accelerator()` for menu labels); no label holds a literal key name. diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index d04e38086f..d925ca6333 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -123,6 +123,8 @@ set(SLIC3R_GUI_SOURCES GUI/PluginPickerDialog.hpp GUI/PluginsDialog.cpp GUI/PluginsDialog.hpp + GUI/Shortcuts.cpp + GUI/Shortcuts.hpp GUI/SpeedDialDialog.cpp GUI/SpeedDialDialog.hpp GUI/ActionRegistry.cpp @@ -335,6 +337,8 @@ set(SLIC3R_GUI_SOURCES GUI/Jobs/Worker.hpp GUI/KBShortcutsDialog.cpp GUI/KBShortcutsDialog.hpp + GUI/KeyChord.cpp + GUI/KeyChord.hpp GUI/LibVGCode/LibVGCodeWrapper.hpp GUI/LibVGCode/LibVGCodeWrapper.cpp GUI/LinuxDisplayBackend.cpp diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 19bc6eb98a..190eeedbb4 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -29,6 +29,7 @@ #include "MainFrame.hpp" #include "WipeTowerDialog.hpp" #include "GUI_App.hpp" +#include "Shortcuts.hpp" #include "GUI_ObjectList.hpp" #include "GUI_Colors.hpp" #include "Mouse3DController.hpp" @@ -77,6 +78,7 @@ #include #include #include +#include #ifndef IMGUI_DEFINE_MATH_OPERATORS #define IMGUI_DEFINE_MATH_OPERATORS @@ -1055,7 +1057,6 @@ wxDEFINE_EVENT(EVT_GLCANVAS_UPDATE_BED_SHAPE, SimpleEvent); wxDEFINE_EVENT(EVT_GLCANVAS_TAB, SimpleEvent); wxDEFINE_EVENT(EVT_GLCANVAS_RESETGIZMOS, SimpleEvent); wxDEFINE_EVENT(EVT_GLCANVAS_MOVE_SLIDERS, wxKeyEvent); -wxDEFINE_EVENT(EVT_GLCANVAS_EDIT_COLOR_CHANGE, wxKeyEvent); wxDEFINE_EVENT(EVT_GLCANVAS_JUMP_TO, wxKeyEvent); wxDEFINE_EVENT(EVT_GLCANVAS_UNDO, SimpleEvent); wxDEFINE_EVENT(EVT_GLCANVAS_REDO, SimpleEvent); @@ -1174,7 +1175,6 @@ GLCanvas3D::GLCanvas3D(wxGLCanvas* canvas, Bed3D &bed) , m_dynamic_background_enabled(false) , m_multisample_allowed(false) , m_moving(false) - , m_tab_down(false) , m_camera_movement(false) , m_cursor_type(Standard) , m_color_by("volume") @@ -3229,6 +3229,9 @@ void GLCanvas3D::bind_event_handlers() m_canvas->Bind(wxEVT_PAINT, &GLCanvas3D::on_paint, this); m_canvas->Bind(wxEVT_SET_FOCUS, &GLCanvas3D::on_set_focus, this); m_canvas->Bind(wxEVT_KILL_FOCUS, [this](wxFocusEvent& evt) { + // The key-up that would commit a keyboard edit goes to whatever took the focus. + if (m_selection_edit.kind != SelectionEdit::None) + finish_selection_edit(); ImGui::SetWindowFocus(nullptr); render(); evt.Skip(); @@ -3358,8 +3361,6 @@ void GLCanvas3D::on_char(wxKeyEvent& evt) // see include/wx/defs.h enum wxKeyCode int keyCode = evt.GetKeyCode(); - int ctrlMask = wxMOD_CONTROL; - int shiftMask = wxMOD_SHIFT; auto imgui = wxGetApp().imgui(); if (imgui->update_key_data(evt)) { @@ -3367,6 +3368,11 @@ void GLCanvas3D::on_char(wxKeyEvent& evt) return; } +#ifdef SLIC3R_CAD + const int ctrlMask = wxMOD_CONTROL; + const int shiftMask = wxMOD_SHIFT; +#endif + // Design tab: Delete/Backspace removes the selected sketch entities while a // sketch tool is active and the canvas has focus (dialog text fields are separate // wx controls, so this never eats their editing keys). @@ -3425,12 +3431,6 @@ void GLCanvas3D::on_char(wxKeyEvent& evt) } #endif - bool is_in_painting_mode = false; - GLGizmoPainterBase *current_gizmo_painter = dynamic_cast(get_gizmos_manager().get_current()); - if (current_gizmo_painter != nullptr) { - is_in_painting_mode = true; - } - //BBS: add orient deactivate logic if (keyCode == WXK_ESCAPE && (_deactivate_arrange_menu() || _deactivate_orient_menu())) @@ -3439,361 +3439,271 @@ void GLCanvas3D::on_char(wxKeyEvent& evt) if (m_gizmos.on_char(evt)) return; - if ((evt.GetModifiers() & ctrlMask) != 0) { - // CTRL is pressed - switch (keyCode) { -#ifdef __APPLE__ - case 'a': - case 'A': -#else /* __APPLE__ */ - case WXK_CONTROL_A: -#endif /* __APPLE__ */ - if (!is_in_painting_mode && !m_layers_editing.is_enabled()) { - if (evt.ShiftDown()) - post_event(SimpleEvent(EVT_GLCANVAS_SELECT_ALL)); - else - post_event(SimpleEvent(EVT_GLCANVAS_SELECT_CURR_PLATE_ALL)); - } + if (const KeyChord chord = KeyChord::from_event(evt); chord.is_punctuation() && handle_shortcut(chord)) + return; + + if (evt.HasModifiers()) { + evt.Skip(); + return; + } + + auto obj_list = wxGetApp().obj_list(); + switch (keyCode) + { + case WXK_ESCAPE: { deselect_all(); break; } + + // BBS: use keypad to change extruder + case '1': { + if (!m_timer_set_color.IsRunning()) { + m_timer_set_color.StartOnce(500); + break; + } + } + case '0': //Color logic for material 10 + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': { + if (m_timer_set_color.IsRunning()) { + if (keyCode < '7') keyCode += 10; + m_timer_set_color.Stop(); + } + if (m_gizmos.get_current_type() != GLGizmosManager::MmSegmentation) + obj_list->set_extruder_for_selected_items(keyCode - '0'); break; -#ifdef __APPLE__ - case 'c': - case 'C': -#else /* __APPLE__ */ - case WXK_CONTROL_C: -#endif /* __APPLE__ */ - if (!is_in_painting_mode) - post_event(SimpleEvent(EVT_GLTOOLBAR_COPY)); - break; -#ifdef __APPLE__ - case 'm': - case 'M': -#else /* __APPLE__ */ - case WXK_CONTROL_M: -#endif /* __APPLE__ */ - { -#ifdef _WIN32 - if (wxGetApp().app_config->get("use_legacy_3DConnexion") == "true") { -#endif //_WIN32 -#ifdef __APPLE__ - // On OSX use Cmd+Shift+M to "Show/Hide 3Dconnexion devices settings dialog" - if ((evt.GetModifiers() & shiftMask) != 0) { -#endif // __APPLE__ - Mouse3DController& controller = wxGetApp().plater()->get_mouse3d_controller(); - controller.show_settings_dialog(!controller.is_settings_dialog_shown()); - m_dirty = true; -#ifdef __APPLE__ - } - else - // and Cmd+M to minimize application - wxGetApp().mainframe->Iconize(); -#endif // __APPLE__ -#ifdef _WIN32 - } -#endif //_WIN32 - break; - } -#ifdef __APPLE__ - case 'v': - case 'V': -#else /* __APPLE__ */ - case WXK_CONTROL_V: -#endif /* __APPLE__ */ - if (!is_in_painting_mode) - post_event(SimpleEvent(EVT_GLTOOLBAR_PASTE)); - break; - -#ifdef __APPLE__ - case 'x': - case 'X': -#else /* __APPLE__ */ - case WXK_CONTROL_X: -#endif /* __APPLE__ */ - if (!is_in_painting_mode) - post_event(SimpleEvent(EVT_GLTOOLBAR_CUT)); - break; - -#ifdef __APPLE__ - case 'f': - case 'F': -#else /* __APPLE__ */ - case WXK_CONTROL_F: -#endif /* __APPLE__ */ - break; - - -#ifdef __APPLE__ - case 'y': - case 'Y': -#else /* __APPLE__ */ - case WXK_CONTROL_Y: -#endif /* __APPLE__ */ - if (m_canvas_type == CanvasView3D || m_canvas_type == CanvasAssembleView) { - post_event(SimpleEvent(EVT_GLCANVAS_REDO)); - } - break; -#ifdef __APPLE__ - case 'z': - case 'Z': -#else /* __APPLE__ */ - case WXK_CONTROL_Z: -#endif /* __APPLE__ */ - // only support redu/undo in CanvasView3D - if (m_canvas_type == CanvasView3D || m_canvas_type == CanvasAssembleView) { - post_event(SimpleEvent(EVT_GLCANVAS_UNDO)); - } - break; - - // BBS -#ifdef __APPLE__ - case 'E': - case 'e': -#else /* __APPLE__ */ - case WXK_CONTROL_E: -#endif /* __APPLE__ */ - { m_labels.show(!m_labels.is_shown()); m_dirty = true; break; } - case '0': { - select_view("plate"); - zoom_to_bed(); - break; } - case '1': { select_view("top"); break; } - case '2': { select_view("bottom"); break; } - case '3': { select_view("front"); break; } - case '4': { select_view("rear"); break; } - case '5': { select_view("left"); break; } - case '6': { select_view("right"); break; } - case '7': { select_plate(); break; } - - //case WXK_BACK: - //case WXK_DELETE: -#ifdef __APPLE__ - case 'd': - case 'D': -#else /* __APPLE__ */ - case WXK_CONTROL_D: -#endif /* __APPLE__ */ - post_event(SimpleEvent(EVT_GLTOOLBAR_DELETE_ALL)); - break; -#ifdef __APPLE__ - case 'k': - case 'K': -#else /* __APPLE__ */ - case WXK_CONTROL_K: -#endif /* __APPLE__ */ - post_event(SimpleEvent(EVT_GLTOOLBAR_CLONE)); - break; - default: evt.Skip(); - } - } else { - auto obj_list = wxGetApp().obj_list(); - switch (keyCode) - { - //case WXK_BACK: - case WXK_DELETE: { post_event(SimpleEvent(EVT_GLTOOLBAR_DELETE)); break; } - // BBS -#ifdef __APPLE__ - case WXK_BACK: { post_event(SimpleEvent(EVT_GLTOOLBAR_DELETE)); break; } -#endif - case WXK_ESCAPE: { deselect_all(); break; } - case WXK_F5: { - if (wxGetApp().mainframe->is_printer_view()) - wxGetApp().mainframe->load_printer_url(); - - //if ((wxGetApp().is_editor() && !wxGetApp().plater()->model().objects.empty()) || - // (wxGetApp().is_gcode_viewer() && !wxGetApp().plater()->get_last_loaded_gcode().empty())) - // post_event(SimpleEvent(EVT_GLCANVAS_RELOAD_FROM_DISK)); - break; - } - - // BBS: use keypad to change extruder - case '1': { - if (!m_timer_set_color.IsRunning()) { - m_timer_set_color.StartOnce(500); - break; - } - } - case '0': //Color logic for material 10 - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': { - if (m_timer_set_color.IsRunning()) { - if (keyCode < '7') keyCode += 10; - m_timer_set_color.Stop(); - } - if (m_gizmos.get_current_type() != GLGizmosManager::MmSegmentation) - obj_list->set_extruder_for_selected_items(keyCode - '0'); - break; - } - - case '+': { - if (dynamic_cast(m_canvas->GetParent()) != nullptr) - post_event(wxKeyEvent(EVT_GLCANVAS_EDIT_COLOR_CHANGE, evt)); - else - post_event(Event(EVT_GLCANVAS_INCREASE_INSTANCES, +1)); - break; - } - case '-': { - if (dynamic_cast(m_canvas->GetParent()) != nullptr) - post_event(wxKeyEvent(EVT_GLCANVAS_EDIT_COLOR_CHANGE, evt)); - else - post_event(Event(EVT_GLCANVAS_INCREASE_INSTANCES, -1)); - break; - } - case '?': { post_event(SimpleEvent(EVT_GLCANVAS_QUESTION_MARK)); break; } - case 'A': - case 'a': - { - if ((evt.GetModifiers() & shiftMask) != 0) - post_event(SimpleEvent(EVT_GLCANVAS_ARRANGE_PARTPLATE)); - else - post_event(SimpleEvent(EVT_GLCANVAS_ARRANGE)); - break; - } - //case 'B': - //case 'b': { zoom_to_bed(); break; } - case 'C': - case 'c': { wxGetApp().toggle_show_gcode_window(); m_dirty = true; request_extra_frame(); break; } - //case 'G': - //case 'g': { - // if ((evt.GetModifiers() & shiftMask) != 0) { - // if (dynamic_cast(m_canvas->GetParent()) != nullptr) - // post_event(wxKeyEvent(EVT_GLCANVAS_JUMP_TO, evt)); - // } - // break; - //} - case 'I': - case 'i': { _update_camera_zoom(1.0); break; } - //case 'K': - //case 'k': { wxGetApp().plater()->get_camera().select_next_type(); m_dirty = true; break; } - //case 'L': - //case 'l': { - //if (!m_main_toolbar.is_enabled()) { - // m_gcode_viewer.enable_legend(!m_gcode_viewer.is_legend_enabled()); - // m_dirty = true; - // wxGetApp().plater()->update_preview_bottom_toolbar(); - //} - //break; - //} - case 'O': - case 'o': { _update_camera_zoom(-1.0); break; } - case 'q': - case 'Q': - { - if ((evt.GetModifiers() & shiftMask) != 0) - post_event(SimpleEvent(EVT_GLCANVAS_ORIENT_PARTPLATE)); - else - post_event(SimpleEvent(EVT_GLCANVAS_ORIENT)); - break; - } - //case 'Z': - //case 'z': { - // if (!m_selection.is_empty()) - // zoom_to_selection(); - // else { - // if (!m_volumes.empty()) - // zoom_to_volumes(); - // else - // _zoom_to_box(m_gcode_viewer.get_paths_bounding_box()); - // } - // break; - //} - case 'v': - case 'V': { post_event(SimpleEvent(EVT_GLCANVAS_PRINTABLE)); break; } - default: { evt.Skip(); break; } - } + } + default: { evt.Skip(); break; } } } -class TranslationProcessor +bool GLCanvas3D::handle_shortcut(const KeyChord& chord) { - using UpAction = std::function; - using DownAction = std::function; + const ShortcutContext context = m_canvas_type == CanvasPreview ? ShortcutContext::Preview : ShortcutContext::Plater; + const std::optional match = wxGetApp().shortcuts().match(context, chord); + if (!match.has_value()) + return false; + const Shortcut shortcut = match->shortcut; + const int held = match->step_modifiers; + if (m_key_down.repeat && !shortcut_info(shortcut).repeatable) + return true; - UpAction m_up_action{ nullptr }; - DownAction m_down_action{ nullptr }; + const bool painting = dynamic_cast(m_gizmos.get_current()) != nullptr; + const bool can_edit = m_canvas_type == CanvasView3D || m_canvas_type == CanvasAssembleView; + auto edit_selection = [this](SelectionEdit::Kind kind, const Vec3d& direction) { + if (!m_gizmos.is_enabled() || m_selection.is_empty() || m_canvas_type == CanvasAssembleView) + return false; + m_selection_edit.kind = kind; + m_selection_edit.key = m_key_down.code; + m_selection_edit.direction = direction; + return true; + }; + auto move_selection = [&](const Vec3d& direction) { + if (edit_selection(SelectionEdit::Move, direction)) + apply_selection_move((held & wxMOD_SHIFT) != 0, (held & wxMOD_CONTROL) != 0); + }; + auto rotate_selection = [&](double angle_z_rad) { + if (edit_selection(SelectionEdit::Rotate, Vec3d::UnitZ())) + apply_selection_rotate(angle_z_rad); + }; + auto step_slider = [this, held](auto&& step) { + IMSlider* layers = get_gcode_viewer().get_layers_slider(); + IMSlider* moves = get_gcode_viewer().get_moves_slider(); + step(layers, moves, held != 0 ? 5 : 1); + if (layers->is_dirty() && layers->is_one_layer()) + layers->SetLowerValue(layers->GetHigherValue()); + m_dirty = true; + }; - bool m_running{ false }; - Vec3d m_direction{ Vec3d::UnitX() }; - -public: - TranslationProcessor(UpAction up_action, DownAction down_action) - : m_up_action(up_action), m_down_action(down_action) - { - } - - void process(wxKeyEvent& evt) - { - const int keyCode = evt.GetKeyCode(); - wxEventType type = evt.GetEventType(); - if (type == wxEVT_KEY_UP) { - switch (keyCode) - { - case WXK_NUMPAD_LEFT: case WXK_LEFT: - case WXK_NUMPAD_RIGHT: case WXK_RIGHT: - case WXK_NUMPAD_UP: case WXK_UP: - case WXK_NUMPAD_DOWN: case WXK_DOWN: - { - m_running = false; - m_up_action(); - break; - } - default: { break; } - } - } - else if (type == wxEVT_KEY_DOWN) { - bool apply = false; - - switch (keyCode) - { - case WXK_SHIFT: - { - if (m_running) - apply = true; - - break; - } - case WXK_NUMPAD_LEFT: - case WXK_LEFT: - { - m_direction = -Vec3d::UnitX(); - apply = true; - break; - } - case WXK_NUMPAD_RIGHT: - case WXK_RIGHT: - { - m_direction = Vec3d::UnitX(); - apply = true; - break; - } - case WXK_NUMPAD_UP: - case WXK_UP: - { - m_direction = Vec3d::UnitY(); - apply = true; - break; - } - case WXK_NUMPAD_DOWN: - case WXK_DOWN: - { - m_direction = -Vec3d::UnitY(); - apply = true; - break; - } - default: { break; } - } - - if (apply) { - m_running = true; - m_down_action(m_direction, evt.ShiftDown(), evt.CmdDown()); - } + switch (shortcut) { + case Shortcut::SelectAll: + if (!painting && !m_layers_editing.is_enabled()) + post_event(SimpleEvent(EVT_GLCANVAS_SELECT_CURR_PLATE_ALL)); + break; + case Shortcut::SelectAllPlates: + if (!painting && !m_layers_editing.is_enabled()) + post_event(SimpleEvent(EVT_GLCANVAS_SELECT_ALL)); + break; + case Shortcut::Copy: if (!painting) post_event(SimpleEvent(EVT_GLTOOLBAR_COPY)); break; + case Shortcut::Paste: if (!painting) post_event(SimpleEvent(EVT_GLTOOLBAR_PASTE)); break; + case Shortcut::Cut: if (!painting) post_event(SimpleEvent(EVT_GLTOOLBAR_CUT)); break; + case Shortcut::Undo: if (can_edit) post_event(SimpleEvent(EVT_GLCANVAS_UNDO)); break; + case Shortcut::Redo: if (can_edit) post_event(SimpleEvent(EVT_GLCANVAS_REDO)); break; + case Shortcut::DeleteSelected: + if (!m_gizmos.on_delete_key()) + post_event(SimpleEvent(EVT_GLTOOLBAR_DELETE)); + break; + case Shortcut::DeleteAll: post_event(SimpleEvent(EVT_GLTOOLBAR_DELETE_ALL)); break; + case Shortcut::CloneSelected: post_event(SimpleEvent(EVT_GLTOOLBAR_CLONE)); break; + case Shortcut::AddInstance: post_event(Event(EVT_GLCANVAS_INCREASE_INSTANCES, +1)); break; + case Shortcut::RemoveInstance: post_event(Event(EVT_GLCANVAS_INCREASE_INSTANCES, -1)); break; + case Shortcut::TogglePrintable: post_event(SimpleEvent(EVT_GLCANVAS_PRINTABLE)); break; + case Shortcut::Arrange: + if (!m_gizmos.is_running()) + post_event(SimpleEvent(EVT_GLCANVAS_ARRANGE)); + break; + case Shortcut::ArrangePlate: + if (!m_gizmos.is_running()) + post_event(SimpleEvent(EVT_GLCANVAS_ARRANGE_PARTPLATE)); + break; + case Shortcut::Orient: post_event(SimpleEvent(EVT_GLCANVAS_ORIENT)); break; + case Shortcut::OrientPlate: post_event(SimpleEvent(EVT_GLCANVAS_ORIENT_PARTPLATE)); break; + case Shortcut::RotateSelectionLeft: rotate_selection(0.25 * M_PI); break; + case Shortcut::RotateSelectionRight: rotate_selection(-0.25 * M_PI); break; + case Shortcut::MoveSelectionLeft: move_selection(-Vec3d::UnitX()); break; + case Shortcut::MoveSelectionRight: move_selection(Vec3d::UnitX()); break; + case Shortcut::MoveSelectionUp: move_selection(Vec3d::UnitY()); break; + case Shortcut::MoveSelectionDown: move_selection(-Vec3d::UnitY()); break; + case Shortcut::ZoomIn: _update_camera_zoom(1.0); break; + case Shortcut::ZoomOut: _update_camera_zoom(-1.0); break; + case Shortcut::SwitchView: post_event(SimpleEvent(EVT_GLCANVAS_TAB)); break; + case Shortcut::CollapseSidebar: + if (!wxGetApp().is_gcode_viewer()) + post_event(SimpleEvent(EVT_GLCANVAS_COLLAPSE_SIDEBAR)); + break; + case Shortcut::ShowWireframe: + wxGetApp().plater()->toggle_show_wireframe(); + m_dirty = true; + break; + case Shortcut::Mouse3DSettings: { +#ifdef _WIN32 + if (wxGetApp().app_config->get("use_legacy_3DConnexion") == "true") { +#endif //_WIN32 + Mouse3DController& controller = wxGetApp().plater()->get_mouse3d_controller(); + controller.show_settings_dialog(!controller.is_settings_dialog_shown()); + m_dirty = true; +#ifdef _WIN32 } +#endif //_WIN32 + break; } -}; + case Shortcut::ReloadDevicePage: + if (wxGetApp().mainframe->is_printer_view()) + wxGetApp().mainframe->load_printer_url(); + break; + case Shortcut::KeyboardShortcuts: post_event(SimpleEvent(EVT_GLCANVAS_QUESTION_MARK)); break; + case Shortcut::ToggleGcodeWindow: + wxGetApp().toggle_show_gcode_window(); + m_dirty = true; + request_extra_frame(); + break; + case Shortcut::ToggleOneLayerMode: + get_gcode_viewer().get_layers_slider()->switch_one_layer_mode(); + m_dirty = true; + break; + case Shortcut::GoToLayer: + if (!m_gizmos.is_enabled()) { + get_gcode_viewer().get_layers_slider()->show_go_to_layer(true); + m_dirty = true; + } + break; + case Shortcut::LayerSliderUp: + case Shortcut::LayerSliderDown: + step_slider([up = shortcut == Shortcut::LayerSliderUp](IMSlider* layers, IMSlider* moves, int increment) { + const int delta = up ? increment : -increment; + if (layers->GetSelection() == ssHigher) { + layers->SetHigherValue(layers->GetHigherValue() + delta); + moves->SetHigherValue(moves->GetMaxValue()); + } + else if (layers->GetSelection() == ssLower) + layers->SetLowerValue(layers->GetLowerValue() + delta); + }); + break; + case Shortcut::MovesSliderLeft: + step_slider([](IMSlider* layers, IMSlider* moves, int increment) { + if (moves->GetHigherValue() == moves->GetMinValue() && layers->GetHigherValue() > layers->GetMinValue()) { + layers->SetHigherValue(layers->GetHigherValue() - 1); + moves->SetHigherValue(moves->GetMaxValue()); + } + else + moves->SetHigherValue(moves->GetHigherValue() - increment); + }); + break; + case Shortcut::MovesSliderRight: + step_slider([](IMSlider* layers, IMSlider* moves, int increment) { + if (moves->GetHigherValue() == moves->GetMaxValue() && layers->GetHigherValue() < layers->GetMaxValue()) { + layers->SetHigherValue(layers->GetHigherValue() + 1); + moves->SetHigherValue(moves->GetMinValue()); + } + else + moves->SetHigherValue(moves->GetHigherValue() + increment); + }); + break; + case Shortcut::MovesSliderStart: + case Shortcut::MovesSliderEnd: + step_slider([start = shortcut == Shortcut::MovesSliderStart](IMSlider*, IMSlider* moves, int) { + moves->SetHigherValue(start ? moves->GetMinValue() : moves->GetMaxValue()); + moves->set_as_dirty(); + }); + break; + default: + if (!m_gizmos.open_gizmo_by_shortcut(shortcut)) + return false; + m_dirty = true; + break; + } + return true; +} + +void GLCanvas3D::apply_selection_move(bool slow, bool camera_space) +{ + m_selection.setup_cache(); + const double multiplier = slow ? 1.0 : 10.0; + + Vec3d displacement; + if (camera_space) { + Eigen::Matrix inv_view_3x3 = wxGetApp().plater()->get_camera().get_view_matrix().inverse().matrix().block(0, 0, 3, 3); + displacement = multiplier * (inv_view_3x3 * m_selection_edit.direction); + displacement.z() = 0.0; + } + else + displacement = multiplier * m_selection_edit.direction; + + TransformationType trafo_type; + trafo_type.set_relative(); + m_selection.translate(displacement, trafo_type); + m_dirty = true; +} + +void GLCanvas3D::apply_selection_rotate(double angle_z_rad) +{ + m_selection.setup_cache(); + m_selection.rotate(angle_z_rad * m_selection_edit.direction, TransformationType(TransformationType::World_Relative_Joint)); + m_dirty = true; +} + +void GLCanvas3D::finish_selection_edit() +{ + const SelectionEdit::Kind kind = m_selection_edit.kind; + m_selection_edit.kind = SelectionEdit::None; + if (kind == SelectionEdit::Move) + do_move(L("Tool move")); + else + do_rotate(L("Tool Rotate")); + m_gizmos.update_data(); + // Let the plater know that the dragging finished, so a delayed refresh + // of the scene with the background processing data should be performed. + post_event(SimpleEvent(EVT_GLCANVAS_MOUSE_DRAGGING_FINISHED)); + // updates camera target constraints + refresh_camera_scene_box(); + m_dirty = true; +} + +// Keys held since their last key-up, shared by every canvas because a shortcut can move the +// focus, and with it the key-up, to another one. +static std::set s_keys_down; + +static bool key_repeats(int key) +{ + for (auto it = s_keys_down.begin(); it != s_keys_down.end();) // drops keys released while no canvas had the focus + it = wxGetKeyState(wxKeyCode(*it)) ? std::next(it) : s_keys_down.erase(it); + return !s_keys_down.insert(key).second; +} + +static void key_released(int key) { s_keys_down.erase(key); } void GLCanvas3D::on_key(wxKeyEvent& evt) { @@ -3811,45 +3721,11 @@ void GLCanvas3D::on_key(wxKeyEvent& evt) } #endif - static GLCanvas3D const * thiz = nullptr; - static TranslationProcessor translationProcessor(nullptr, nullptr); - if (thiz != this) { - thiz = this; - translationProcessor = TranslationProcessor( - [this]() { - do_move(L("Tool move")); - m_gizmos.update_data(); - - // BBS - //wxGetApp().obj_manipul()->set_dirty(); - // Let the plater know that the dragging finished, so a delayed refresh - // of the scene with the background processing data should be performed. - post_event(SimpleEvent(EVT_GLCANVAS_MOUSE_DRAGGING_FINISHED)); - // updates camera target constraints - refresh_camera_scene_box(); - m_dirty = true; - }, - [this](const Vec3d& direction, bool slow, bool camera_space) { - m_selection.setup_cache(); - double multiplier = slow ? 1.0 : 10.0; - - Vec3d displacement; - if (camera_space) { - Eigen::Matrix inv_view_3x3 = wxGetApp().plater()->get_camera().get_view_matrix().inverse().matrix().block(0, 0, 3, 3); - displacement = multiplier * (inv_view_3x3 * direction); - displacement.z() = 0.0; - } - else - displacement = multiplier * direction; - - TransformationType trafo_type; - trafo_type.set_relative(); - m_selection.translate(displacement, trafo_type); - m_dirty = true; - } - );} - const int keyCode = evt.GetKeyCode(); + if (evt.GetEventType() == wxEVT_KEY_DOWN) + m_key_down = { keyCode, key_repeats(keyCode) }; + else if (evt.GetEventType() == wxEVT_KEY_UP) + key_released(keyCode); auto imgui = wxGetApp().imgui(); if (imgui->update_key_data(evt)) @@ -3863,23 +3739,8 @@ void GLCanvas3D::on_key(wxKeyEvent& evt) wxGetApp().plater()->toggle_render_statistic_dialog(); m_dirty = true; #endif - } else if ((evt.ShiftDown() && evt.ControlDown() && keyCode == WXK_RETURN) || - (evt.ShiftDown() && evt.AltDown() && keyCode == WXK_RETURN)) { - wxGetApp().plater()->toggle_show_wireframe(); - m_dirty = true; - } - else if (m_tab_down && keyCode == WXK_TAB && !evt.HasAnyModifiers()) { - // Enable switching between 3D and Preview with Tab - // m_canvas->HandleAsNavigationKey(evt); // XXX: Doesn't work in some cases / on Linux - post_event(SimpleEvent(EVT_GLCANVAS_TAB)); - } - else if (keyCode == WXK_TAB && evt.ShiftDown() && !evt.ControlDown() && ! wxGetApp().is_gcode_viewer()) { - // Collapse side-panel with Shift+Tab - post_event(SimpleEvent(EVT_GLCANVAS_COLLAPSE_SIDEBAR)); } else if (keyCode == WXK_SHIFT) { - translationProcessor.process(evt); - if (m_picking_enabled && m_rectangle_selection.is_dragging()) { _update_selection_from_hover(); m_rectangle_selection.stop_dragging(); @@ -3905,70 +3766,15 @@ void GLCanvas3D::on_key(wxKeyEvent& evt) } else if (keyCode == WXK_CONTROL) m_dirty = true; - else if (m_gizmos.is_enabled() && !m_selection.is_empty() && m_canvas_type != CanvasAssembleView) { - translationProcessor.process(evt); - - switch (keyCode) - { - case WXK_NUMPAD_PAGEUP: case WXK_PAGEUP: - case WXK_NUMPAD_PAGEDOWN: case WXK_PAGEDOWN: - { - do_rotate(L("Tool Rotate")); - m_gizmos.update_data(); - - // BBS - //wxGetApp().obj_manipul()->set_dirty(); - // Let the plater know that the dragging finished, so a delayed refresh - // of the scene with the background processing data should be performed. - post_event(SimpleEvent(EVT_GLCANVAS_MOUSE_DRAGGING_FINISHED)); - // updates camera target constraints - refresh_camera_scene_box(); - m_dirty = true; - - break; - } - default: { break; } - } - } - - // BBS: add select view logic - if (evt.ControlDown()) { - switch (keyCode) { - case '0': - case WXK_NUMPAD0: //0 on numpad - { select_view("plate"); - zoom_to_bed(); - break; - } - case '1': - case WXK_NUMPAD1: //1 on numpad - { select_view("top"); break; } - case '2': - case WXK_NUMPAD2: //2 on numpad - { select_view("bottom"); break; } - case '3': - case WXK_NUMPAD3: //3 on numpad - { select_view("front"); break; } - case '4': - case WXK_NUMPAD4: //4 on numpad - { select_view("rear"); break; } - case '5': - case WXK_NUMPAD5: //5 on numpad - { select_view("left"); break; } - case '6': - case WXK_NUMPAD6: //6 on numpad - { select_view("right"); break; } - case '7': - case WXK_NUMPAD7: //7 on numpad - { select_plate(); break; } - default: break; - } - } + else if (m_selection_edit.kind != SelectionEdit::None && keyCode == m_selection_edit.key) + finish_selection_edit(); } else if (evt.GetEventType() == wxEVT_KEY_DOWN) { - m_tab_down = keyCode == WXK_TAB && !evt.HasAnyModifiers(); + if (handle_shortcut(KeyChord::from_event(evt))) + return; if (keyCode == WXK_SHIFT) { - translationProcessor.process(evt); + if (m_selection_edit.kind == SelectionEdit::Move) + apply_selection_move(true, evt.CmdDown()); if (m_picking_enabled /*&& (m_gizmos.get_current_type() != GLGizmosManager::SlaSupports)*/) { @@ -3985,68 +3791,6 @@ void GLCanvas3D::on_key(wxKeyEvent& evt) } else if (keyCode == WXK_CONTROL) m_dirty = true; - else if (m_gizmos.is_enabled() && !m_selection.is_empty() && m_canvas_type != CanvasAssembleView) { - auto _do_rotate = [this](double angle_z_rad) { - m_selection.setup_cache(); - m_selection.rotate(Vec3d(0.0, 0.0, angle_z_rad), TransformationType(TransformationType::World_Relative_Joint)); - m_dirty = true; -// wxGetApp().obj_manipul()->set_dirty(); - }; - - translationProcessor.process(evt); - - switch (keyCode) - { - case WXK_NUMPAD_PAGEUP: case WXK_PAGEUP: { _do_rotate(0.25 * M_PI); break; } - case WXK_NUMPAD_PAGEDOWN: case WXK_PAGEDOWN: { _do_rotate(-0.25 * M_PI); break; } - default: { break; } - } - } else if (!m_gizmos.is_enabled()) { - // DoubleSlider navigation in Preview - if (m_canvas_type == CanvasPreview) { - IMSlider *m_layers_slider = get_gcode_viewer().get_layers_slider(); - IMSlider *m_moves_slider = get_gcode_viewer().get_moves_slider(); - int increment = (evt.CmdDown() || evt.ShiftDown()) ? 5 : 1; - if ((evt.CmdDown() || evt.ShiftDown()) && evt.GetKeyCode() == 'G') { - m_layers_slider->show_go_to_layer(true); - } - else if (keyCode == WXK_UP || keyCode == WXK_DOWN) { - int new_pos; - if (m_layers_slider->GetSelection() == ssHigher) { - new_pos = keyCode == WXK_UP ? m_layers_slider->GetHigherValue() + increment : m_layers_slider->GetHigherValue() - increment; - m_layers_slider->SetHigherValue(new_pos); - m_moves_slider->SetHigherValue(m_moves_slider->GetMaxValue()); - } - else if (m_layers_slider->GetSelection() == ssLower) { - new_pos = keyCode == WXK_UP ? m_layers_slider->GetLowerValue() + increment : m_layers_slider->GetLowerValue() - increment; - m_layers_slider->SetLowerValue(new_pos); - } - } else if (keyCode == WXK_LEFT) { - if (m_moves_slider->GetHigherValue() == m_moves_slider->GetMinValue() && (m_layers_slider->GetHigherValue() > m_layers_slider->GetMinValue())) { - m_layers_slider->SetHigherValue(m_layers_slider->GetHigherValue() - 1); - m_moves_slider->SetHigherValue(m_moves_slider->GetMaxValue()); - } else { - m_moves_slider->SetHigherValue(m_moves_slider->GetHigherValue() - increment); - } - } else if (keyCode == WXK_RIGHT) { - if (m_moves_slider->GetHigherValue() == m_moves_slider->GetMaxValue() && (m_layers_slider->GetHigherValue() < m_layers_slider->GetMaxValue())) { - m_layers_slider->SetHigherValue(m_layers_slider->GetHigherValue() + 1); - m_moves_slider->SetHigherValue(m_moves_slider->GetMinValue()); - } else { - m_moves_slider->SetHigherValue(m_moves_slider->GetHigherValue() + increment); - } - } else if (keyCode == WXK_HOME || keyCode == WXK_END) { - const int new_pos = keyCode == WXK_HOME ? m_moves_slider->GetMinValue() : m_moves_slider->GetMaxValue(); - m_moves_slider->SetHigherValue(new_pos); - m_moves_slider->set_as_dirty(); - } - - if (m_layers_slider->is_dirty() && m_layers_slider->is_one_layer()) - m_layers_slider->SetLowerValue(m_layers_slider->GetHigherValue()); - - m_dirty = true; - } - } } } else return; @@ -7115,7 +6859,6 @@ bool GLCanvas3D::_init_main_toolbar() item.name = "add"; item.icon_filename = m_is_dark ? "toolbar_open_dark.svg" : "toolbar_open.svg"; - item.tooltip = _utf8(L("Add")) + " [" + GUI::shortkey_ctrl_prefix() + "I]"; item.sprite_id = 0; item.left.action_callback = [this]() { if (m_canvas != nullptr) wxPostEvent(m_canvas, SimpleEvent(EVT_GLTOOLBAR_ADD)); }; item.enabling_callback = []()->bool {return wxGetApp().plater()->can_add_model(); }; @@ -7133,7 +6876,6 @@ bool GLCanvas3D::_init_main_toolbar() item.name = "orient"; item.icon_filename = m_is_dark ? "toolbar_orient_dark.svg" : "toolbar_orient.svg"; - item.tooltip = _utf8(L("Auto orient all/selected objects")) + " [Q]\n" + _utf8(L("Auto orient all objects on current plate")) + " [" + _utf8(L("Shift+")) + "Q]"; item.sprite_id++; item.left.render_callback = nullptr; item.enabling_callback = []()->bool { return wxGetApp().plater()->can_arrange(); }; @@ -7155,7 +6897,6 @@ bool GLCanvas3D::_init_main_toolbar() item.name = "arrange"; item.icon_filename = m_is_dark ? "toolbar_arrange_dark.svg" : "toolbar_arrange.svg"; - item.tooltip = _utf8(L("Arrange all objects")) + " [A]\n" + _utf8(L("Arrange objects on selected plates")) + " [" + _utf8(L("Shift+")) + "A]"; item.sprite_id++; item.left.action_callback = []() {}; item.enabling_callback = []()->bool { return wxGetApp().plater()->can_arrange(); }; @@ -7179,7 +6920,6 @@ bool GLCanvas3D::_init_main_toolbar() item.name = "more"; item.icon_filename = m_is_dark ? "instance_add_dark.svg" : "instance_add.svg"; - item.tooltip = _utf8(L("Add instance")) + " [+]"; item.sprite_id++; item.left.render_callback = nullptr; item.left.action_callback = [this]() { if (m_canvas != nullptr) wxPostEvent(m_canvas, SimpleEvent(EVT_GLTOOLBAR_MORE)); }; @@ -7191,7 +6931,6 @@ bool GLCanvas3D::_init_main_toolbar() item.name = "fewer"; item.icon_filename = m_is_dark ? "instance_remove_dark.svg" : "instance_remove.svg"; - item.tooltip = _utf8(L("Remove instance")) + " [-]"; item.sprite_id++; item.left.render_callback = nullptr; item.left.action_callback = [this]() { if (m_canvas != nullptr) wxPostEvent(m_canvas, SimpleEvent(EVT_GLTOOLBAR_FEWER)); }; @@ -7241,9 +6980,20 @@ bool GLCanvas3D::_init_main_toolbar() if (!m_main_toolbar.add_item(item)) return false; + update_shortcut_tooltips(); return true; } +void GLCanvas3D::update_shortcut_tooltips() +{ + const ShortcutRegistry& shortcuts = wxGetApp().shortcuts(); + m_main_toolbar.set_tooltip(m_main_toolbar.get_item_id("add"), shortcuts.with_key(_u8L("Add"), Shortcut::ImportModel)); + m_main_toolbar.set_tooltip(m_main_toolbar.get_item_id("orient"), shortcuts.with_key(_u8L("Auto orient all/selected objects"), Shortcut::Orient) + "\n" + shortcuts.with_key(_u8L("Auto orient all objects on current plate"), Shortcut::OrientPlate)); + m_main_toolbar.set_tooltip(m_main_toolbar.get_item_id("arrange"), shortcuts.with_key(_u8L("Arrange all objects"), Shortcut::Arrange) + "\n" + shortcuts.with_key(_u8L("Arrange objects on selected plates"), Shortcut::ArrangePlate)); + m_main_toolbar.set_tooltip(m_main_toolbar.get_item_id("more"), shortcuts.with_key(_u8L("Add instance"), Shortcut::AddInstance)); + m_main_toolbar.set_tooltip(m_main_toolbar.get_item_id("fewer"), shortcuts.with_key(_u8L("Remove instance"), Shortcut::RemoveInstance)); +} + //BBS: GUI refactor: GLToolbar bool GLCanvas3D::_init_select_plate_toolbar() { diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 9c78581f69..ad5188f2f3 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -63,6 +63,7 @@ class PartPlateList; #ifdef SLIC3R_CAD class DesignSketchTool; // Design tab: interactive 2D sketch tool #endif +struct KeyChord; #if ENABLE_RETINA_GL class RetinaHelper; @@ -185,7 +186,6 @@ wxDECLARE_EVENT(EVT_GLCANVAS_UPDATE_BED_SHAPE, SimpleEvent); wxDECLARE_EVENT(EVT_GLCANVAS_TAB, SimpleEvent); wxDECLARE_EVENT(EVT_GLCANVAS_RESETGIZMOS, SimpleEvent); wxDECLARE_EVENT(EVT_GLCANVAS_MOVE_SLIDERS, wxKeyEvent); -wxDECLARE_EVENT(EVT_GLCANVAS_EDIT_COLOR_CHANGE, wxKeyEvent); wxDECLARE_EVENT(EVT_GLCANVAS_JUMP_TO, wxKeyEvent); wxDECLARE_EVENT(EVT_GLCANVAS_UNDO, SimpleEvent); wxDECLARE_EVENT(EVT_GLCANVAS_REDO, SimpleEvent); @@ -626,7 +626,23 @@ private: bool m_dynamic_background_enabled; bool m_multisample_allowed; bool m_moving; - bool m_tab_down; + // The key-down being dispatched, kept for the char event that may follow it. + struct KeyDown + { + int code = WXK_NONE; + bool repeat = false; + }; + KeyDown m_key_down; + // A keyboard move or rotation of the selection runs from the key-down that started it to + // that key's release, so a held key becomes one undo step. + struct SelectionEdit + { + enum Kind { None, Move, Rotate }; + Kind kind = None; + int key = WXK_NONE; // raw key code of the key-down, matched against the key-up + Vec3d direction{ Vec3d::UnitX() }; + }; + SelectionEdit m_selection_edit; bool m_camera_movement; //BBS: add toolpath outside bool m_toolpath_outside{ false }; @@ -1094,6 +1110,13 @@ public: void on_idle(wxIdleEvent& evt); void on_char(wxKeyEvent& evt); void on_key(wxKeyEvent& evt); + // Runs the Plater/Preview shortcut bound to chord, swallowing auto-repeats of one-shot + // shortcuts; false when nothing is bound. + bool handle_shortcut(const KeyChord& chord); + void apply_selection_move(bool slow, bool camera_space); + void apply_selection_rotate(double angle_z_rad); + void finish_selection_edit(); + void update_shortcut_tooltips(); void on_mouse_wheel(wxMouseEvent& evt); void on_timer(wxTimerEvent& evt); void on_render_timer(wxTimerEvent& evt); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index ab53b773a2..48bd58b1a5 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3,6 +3,7 @@ #include "libslic3r/Technologies.hpp" #include "libslic3r/Platform.hpp" #include "GUI_App.hpp" +#include "Shortcuts.hpp" #include "BindDialog.hpp" #include "DeviceManager.hpp" #include "HMS.hpp" @@ -1120,6 +1121,8 @@ GUI_App::GUI_App() { //app config initializes early becasuse it is used in instance checking in OrcaSlicer.cpp this->init_app_config(); + m_shortcuts = std::make_unique(); + m_shortcuts->load(*app_config); this->init_download_path(); // Note: the WebView2 runtime check (init_webview_runtime) used to run here, but // the constructor executes before wxWidgets is fully initialized and before the @@ -4677,12 +4680,28 @@ void GUI_App::system_info() //dlg.ShowModal(); } -void GUI_App::keyboard_shortcuts() +void GUI_App::keyboard_shortcuts(ShortcutContext page, wxWindow* parent) { - KBShortcutsDialog dlg; + KBShortcutsDialog dlg(parent != nullptr ? parent : mainframe, page); dlg.ShowModal(); } +void GUI_App::on_shortcuts_changed() +{ + m_shortcuts->save(*app_config); + app_config->save(); + if (mainframe == nullptr) + return; + mainframe->update_shortcut_labels(); + if (Plater* plater = this->plater(); plater != nullptr) { + if (GLCanvas3D* canvas = plater->get_view3D_canvas3D(); canvas != nullptr) + canvas->update_shortcut_tooltips(); +#ifdef __WXOSX__ + obj_list()->update_shortcut_accelerators(); +#endif + } +} + void GUI_App::troubleshoot() { TroubleshootDialog dlg; @@ -8465,7 +8484,9 @@ void GUI_App::open_exportpresetbundledialog(size_t open_on_tab, const std::strin } } -void GUI_App::open_preferences(size_t open_on_tab, const std::string& highlight_option) +void GUI_App::open_preferences() { open_preferences(PreferencesTab::General); } + +void GUI_App::open_preferences(PreferencesTab tab, const std::string& highlight_option) { // Render settings the canvas reads every frame; a change needs one redraw to show. static constexpr const char* opengl_render_setting_keys[] = { @@ -8482,7 +8503,8 @@ void GUI_App::open_preferences(size_t open_on_tab, const std::string& highlight_ // the dialog needs to be destroyed before the call to recreate_GUI() // or sometimes the application crashes into wxDialogBase() destructor // so we put it into an inner scope - PreferencesDialog dlg(mainframe, open_on_tab, highlight_option); + PreferencesDialog dlg(mainframe); + dlg.select_tab(tab, highlight_option); dlg.ShowModal(); need_recreate_gui = dlg.recreate_GUI(); pending_language = dlg.pending_language(); diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 97213d170b..2d55c5e96c 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -70,6 +70,9 @@ namespace GUI{ class RemovableDriveManager; class OtherInstanceMessageHandler; +class ShortcutRegistry; +enum class ShortcutContext : uint8_t; +enum class PreferencesTab; class MainFrame; class Sidebar; class ObjectSettings; @@ -286,6 +289,7 @@ private: std::unique_ptr m_removable_drive_manager; std::unique_ptr m_imgui; + std::unique_ptr m_shortcuts; std::unique_ptr m_printhost_job_queue; std::unique_ptr m_other_instance_message_handler; std::unique_ptr m_single_instance_checker; @@ -483,7 +487,7 @@ public: void recreate_GUI(const wxString& message); void system_info(); - void keyboard_shortcuts(); + void keyboard_shortcuts(ShortcutContext page, wxWindow* parent = nullptr); // the main frame when null void troubleshoot(); void load_project(wxWindow *parent, wxString& input_file) const; void import_model(wxWindow *parent, wxArrayString& input_files) const; @@ -639,7 +643,8 @@ public: wxString current_language_code_safe() const; bool is_localized() const { return m_wxLocale->GetLocale() != "English"; } - void open_preferences(size_t open_on_tab = 0, const std::string& highlight_option = std::string()); + void open_preferences(); // on the General tab + void open_preferences(PreferencesTab tab, const std::string& highlight_option = std::string()); void open_presetbundledialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string()); void open_plugins_dialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string()); // Dialog-free plugin actions used by the speed dial: they never require the Plugins dialog to be open. @@ -737,6 +742,9 @@ public: size_t get_instance_hash_int () { return m_instance_hash_int; } ImGuiWrapper* imgui() { return m_imgui.get(); } + ShortcutRegistry& shortcuts() { return *m_shortcuts; } + // Saves the bindings and refreshes every menu label, tooltip and accelerator table that shows one. + void on_shortcuts_changed(); PrintHostJobQueue& printhost_job_queue() { return *m_printhost_job_queue.get(); } diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index d02922497e..ecca540aa9 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -6,6 +6,7 @@ #include "GUI_Factories.hpp" #include "GUI_ObjectList.hpp" #include "GUI_App.hpp" +#include "Shortcuts.hpp" #include "I18N.hpp" #include "Plater.hpp" #include "ObjectDataViewModel.hpp" @@ -2083,13 +2084,8 @@ wxMenu* MenuFactory::assemble_part_menu() void MenuFactory::append_menu_item_clone(wxMenu* menu) { -#ifdef __APPLE__ - static const wxString ctrl = ("Ctrl+"); -#else - // FIXME: maybe should be using GUI::shortkey_ctrl_prefix() or equivalent? - static const wxString ctrl = _L("Ctrl+"); -#endif - append_menu_item(menu, wxID_ANY, _L("Clone") + "\t" + ctrl + "K", "", + const std::string accel = wxGetApp().shortcuts().accelerator(Shortcut::CloneSelected); + append_menu_item(menu, wxID_ANY, _L("Clone") + (accel.empty() ? wxString() : "\t" + from_u8(accel)), "", [](wxCommandEvent&) { plater()->clone_selection(); }, "", nullptr, diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 8067afd8df..2d77200eef 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -4,6 +4,7 @@ #include "GUI_Factories.hpp" //#include "GUI_ObjectLayers.hpp" #include "GUI_App.hpp" +#include "Shortcuts.hpp" #include "I18N.hpp" #include "Plater.hpp" #include "BitmapComboBox.hpp" @@ -245,56 +246,15 @@ ObjectList::ObjectList(wxWindow* parent) : // Key events are not correctly processed by the wxDataViewCtrl on OSX. // Our patched wxWidgets process the keyboard accelerators. // On the other hand, using accelerators will break in-place editing on Windows & Linux/GTK (there is no in-place editing working on OSX for wxDataViewCtrl for now). -// Bind(wxEVT_KEY_DOWN, &ObjectList::OnChar, this); - { - // Accelerators - // wxAcceleratorEntry entries[25]; - wxAcceleratorEntry entries[26]; - int index = 0; - entries[index++].Set(wxACCEL_CTRL, (int)'C', wxID_COPY); - entries[index++].Set(wxACCEL_CTRL, (int)'X', wxID_CUT); - entries[index++].Set(wxACCEL_CTRL, (int)'V', wxID_PASTE); - entries[index++].Set(wxACCEL_CTRL, (int)'M', wxID_DUPLICATE); - entries[index++].Set(wxACCEL_CTRL, (int)'A', wxID_SELECTALL); - entries[index++].Set(wxACCEL_CTRL, (int)'Z', wxID_UNDO); - entries[index++].Set(wxACCEL_CTRL, (int)'Y', wxID_REDO); - entries[index++].Set(wxACCEL_NORMAL, WXK_BACK, wxID_DELETE); - //entries[index++].Set(wxACCEL_NORMAL, int('+'), wxID_ADD); - //entries[index++].Set(wxACCEL_NORMAL, WXK_NUMPAD_ADD, wxID_ADD); - //entries[index++].Set(wxACCEL_NORMAL, int('-'), wxID_REMOVE); - //entries[index++].Set(wxACCEL_NORMAL, WXK_NUMPAD_SUBTRACT, wxID_REMOVE); - //entries[index++].Set(wxACCEL_NORMAL, int('p'), wxID_PRINT); - - int numbers_cnt = 0; - for (auto char_number : { '1', '2', '3', '4', '5', '6', '7', '8', '9' }) { - entries[index + numbers_cnt].Set(wxACCEL_NORMAL, int(char_number), wxID_LAST + numbers_cnt+1); - entries[index + 9 + numbers_cnt].Set(wxACCEL_NORMAL, WXK_NUMPAD0 + numbers_cnt - 1, wxID_LAST + numbers_cnt+1); - numbers_cnt++; - // index++; - } - wxAcceleratorTable accel(26, entries); - SetAcceleratorTable(accel); - - this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->copy(); }, wxID_COPY); - this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->paste(); }, wxID_PASTE); - this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->select_item_all_children(); }, wxID_SELECTALL); - this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->remove(); }, wxID_DELETE); - this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->undo(); }, wxID_UNDO); - this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->redo(); }, wxID_REDO); - this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->cut(); }, wxID_CUT); - this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->clone(); }, wxID_DUPLICATE); - //this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->increase_instances(); }, wxID_ADD); - //this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->decrease_instances(); }, wxID_REMOVE); - //this->Bind(wxEVT_MENU, [this](wxCommandEvent &evt) { this->toggle_printable_state(); }, wxID_PRINT); - - for (int i = 1; i < 10; i++) - this->Bind(wxEVT_MENU, [this, i](wxCommandEvent &evt) { - if (filaments_count() > 1 && i <= filaments_count()) - this->set_extruder_for_selected_items(i); - }, wxID_LAST+i); - - m_accel = accel; - } + m_shortcut_id_base = wxWindow::NewControlId(int(Shortcut::Count)); + for (size_t i = 0; i < size_t(Shortcut::Count); ++i) + this->Bind(wxEVT_MENU, [this, shortcut = Shortcut(i)](wxCommandEvent&) { dispatch_shortcut(shortcut); }, m_shortcut_id_base + int(i)); + for (int i = 1; i < 10; i++) + this->Bind(wxEVT_MENU, [this, i](wxCommandEvent &evt) { + if (filaments_count() > 1 && i <= filaments_count()) + this->set_extruder_for_selected_items(i); + }, wxID_LAST+i); + update_shortcut_accelerators(); #else //__WXOSX__ Bind(wxEVT_CHAR, [this](wxKeyEvent& event) { key_event(event); }); // doesn't work on OSX #endif @@ -1796,36 +1756,10 @@ void ObjectList::decrease_instances() #ifndef __WXOSX__ void ObjectList::key_event(wxKeyEvent& event) { - //if (event.GetKeyCode() == WXK_TAB) - // Navigate(event.ShiftDown() ? wxNavigationKeyEvent::IsBackward : wxNavigationKeyEvent::IsForward); - //else - if (event.GetKeyCode() == WXK_DELETE /*|| event.GetKeyCode() == WXK_BACK*/ ) - remove(); - //else if (event.GetKeyCode() == WXK_F5) - // wxGetApp().plater()->reload_all_from_disk(); - else if (wxGetKeyState(wxKeyCode('A')) && wxGetKeyState(WXK_CONTROL/*WXK_SHIFT*/)) - select_item_all_children(); - else if (wxGetKeyState(wxKeyCode('C')) && wxGetKeyState(WXK_CONTROL)) - copy(); - else if (wxGetKeyState(wxKeyCode('V')) && wxGetKeyState(WXK_CONTROL)) - paste(); - else if (wxGetKeyState(wxKeyCode('Y')) && wxGetKeyState(WXK_CONTROL)) - redo(); - else if (wxGetKeyState(wxKeyCode('Z')) && wxGetKeyState(WXK_CONTROL)) - undo(); - else if (wxGetKeyState(wxKeyCode('X')) && wxGetKeyState(WXK_CONTROL)) - cut(); - else if (wxGetKeyState(wxKeyCode('K')) && wxGetKeyState(WXK_CONTROL)) - clone(); - else if (event.GetUnicodeKey() == '+') - increase_instances(); - else if (event.GetUnicodeKey() == '-') - decrease_instances(); - else if (event.GetUnicodeKey() == 'p') - toggle_printable_state(); - else if (event.GetUnicodeKey() == 'd') - toggle_auto_drop(); - else if (filaments_count() > 1) { + const std::optional shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::ObjectList, KeyChord::from_event(event)); + if (shortcut.has_value() && dispatch_shortcut(*shortcut)) + return; + if (filaments_count() > 1) { std::vector numbers = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; wxChar key_char = event.GetUnicodeKey(); if (std::find(numbers.begin(), numbers.end(), key_char) != numbers.end()) { @@ -1842,6 +1776,43 @@ void ObjectList::key_event(wxKeyEvent& event) } #endif /* __WXOSX__ */ +#ifdef __WXOSX__ +void ObjectList::update_shortcut_accelerators() +{ + std::vector entries; + const ShortcutRegistry& shortcuts = wxGetApp().shortcuts(); + for (Shortcut shortcut : shortcuts_in(ShortcutContext::ObjectList)) + if (const KeyChord chord = shortcuts.binding(shortcut); chord.valid()) + entries.push_back(chord.to_accelerator_entry(m_shortcut_id_base + int(shortcut))); + for (int i = 1; i < 10; ++i) { + entries.emplace_back(wxACCEL_NORMAL, '0' + i, wxID_LAST + i); + entries.emplace_back(wxACCEL_NORMAL, WXK_NUMPAD0 + i, wxID_LAST + i); + } + m_accel = wxAcceleratorTable(int(entries.size()), entries.data()); + SetAcceleratorTable(m_accel); +} +#endif /* __WXOSX__ */ + +bool ObjectList::dispatch_shortcut(Shortcut shortcut) +{ + switch (shortcut) { + case Shortcut::DeleteSelected: remove(); break; + case Shortcut::SelectAll: select_item_all_children(); break; + case Shortcut::Copy: copy(); break; + case Shortcut::Paste: paste(); break; + case Shortcut::Cut: cut(); break; + case Shortcut::Undo: undo(); break; + case Shortcut::Redo: redo(); break; + case Shortcut::CloneSelected: clone(); break; + case Shortcut::AddInstance: increase_instances(); break; + case Shortcut::RemoveInstance: decrease_instances(); break; + case Shortcut::TogglePrintable: toggle_printable_state(); break; + case Shortcut::ToggleAutoDrop: toggle_auto_drop(); break; + default: return false; + } + return true; +} + void ObjectList::OnBeginDrag(wxDataViewEvent &event) { const bool mult_sel = multiple_selection(); diff --git a/src/slic3r/GUI/GUI_ObjectList.hpp b/src/slic3r/GUI/GUI_ObjectList.hpp index cab78b6adc..5705aa2c8b 100644 --- a/src/slic3r/GUI/GUI_ObjectList.hpp +++ b/src/slic3r/GUI/GUI_ObjectList.hpp @@ -40,6 +40,9 @@ typedef std::map t_layer_config_ranges; #define FIX_THROUGH_CGAL_ALWAYS 1 namespace GUI { + +enum class Shortcut : uint8_t; + struct ObjectVolumeID { ModelObject* object{ nullptr }; ModelVolume* volume{ nullptr }; @@ -270,7 +273,11 @@ public: void extruder_editing(); #ifndef __WXOSX__ void key_event(wxKeyEvent& event); +#else + // wxDataViewCtrl never sees key events on macOS, so the bindings are installed as accelerators. + void update_shortcut_accelerators(); #endif /* __WXOSX__ */ + bool dispatch_shortcut(Shortcut shortcut); void copy(); void paste(); @@ -482,8 +489,8 @@ public: private: #ifdef __WXOSX__ -// void OnChar(wxKeyEvent& event); wxAcceleratorTable m_accel; + wxWindowID m_shortcut_id_base; #endif /* __WXOSX__ */ void OnContextMenu(wxDataViewEvent &event); void list_manipulation(const wxPoint& mouse_pos, bool evt_context_menu = false); diff --git a/src/slic3r/GUI/GUI_Preview.cpp b/src/slic3r/GUI/GUI_Preview.cpp index ea3ae5d21d..2c44202876 100644 --- a/src/slic3r/GUI/GUI_Preview.cpp +++ b/src/slic3r/GUI/GUI_Preview.cpp @@ -280,8 +280,6 @@ bool Preview::init(wxWindow* parent, Bed3D& bed, Model* model) m_canvas->enable_assemble_view_toolbar(false); // sizer, m_canvas_widget - m_canvas_widget->Bind(wxEVT_KEY_DOWN, &Preview::update_layers_slider_from_canvas, this); - wxBoxSizer *main_sizer = new wxBoxSizer(wxVERTICAL); main_sizer->Add(m_canvas_widget, 1, wxALL | wxEXPAND, 0); @@ -505,28 +503,6 @@ void Preview::update_layers_slider_mode() m_layers_slider->SetModeAndOnlyExtruder(one_extruder_printed_model, only_extruder, can_change_color); } -void Preview::update_layers_slider_from_canvas(wxKeyEvent &event) -{ - if (event.HasModifiers()) { - event.Skip(); - return; - } - - const auto key = event.GetKeyCode(); - - IMSlider *m_layers_slider = m_canvas->get_gcode_viewer().get_layers_slider(); - IMSlider *m_moves_slider = m_canvas->get_gcode_viewer().get_moves_slider(); - if (key == 'L') { - if(!m_layers_slider->switch_one_layer_mode()) - event.Skip(); - m_canvas->set_as_dirty(); - } - /*else if (key == WXK_SHIFT) - m_layers_slider->UseDefaultColors(false);*/ - else - event.Skip(); -} - void Preview::update_layers_slider(const std::vector& layers_z, bool keep_z_range) { IMSlider *m_layers_slider = m_canvas->get_gcode_viewer().get_layers_slider(); diff --git a/src/slic3r/GUI/GUI_Preview.hpp b/src/slic3r/GUI/GUI_Preview.hpp index 5469e32b71..fdfe364fb8 100644 --- a/src/slic3r/GUI/GUI_Preview.hpp +++ b/src/slic3r/GUI/GUI_Preview.hpp @@ -171,7 +171,6 @@ private: void update_layers_slider(const std::vector& layers_z, bool keep_z_range = false); void update_layers_slider_mode(); - void update_layers_slider_from_canvas(wxKeyEvent &event); //BBS: add only gcode mode void load_print_as_fff(bool keep_z_range = false, bool only_gcode = false); }; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoAdvancedCut.cpp b/src/slic3r/GUI/Gizmos/GLGizmoAdvancedCut.cpp index 99ad00fe55..09bb92dddf 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoAdvancedCut.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoAdvancedCut.cpp @@ -354,7 +354,6 @@ bool GLGizmoAdvancedCut::on_init() if (!GLGizmoRotate3D::on_init()) return false; - m_shortcut_key = WXK_CONTROL_C; // initiate info shortcuts const wxString ctrl = GUI::shortkey_ctrl_prefix(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoAssembly.cpp b/src/slic3r/GUI/Gizmos/GLGizmoAssembly.cpp index e686d5bb8f..9560078cb2 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoAssembly.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoAssembly.cpp @@ -1,6 +1,7 @@ #include "GLGizmoAssembly.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/Gizmos/GizmoObjectManipulation.hpp" #include "slic3r/Utils/UndoRedo.hpp" @@ -46,7 +47,7 @@ bool GLGizmoAssembly::on_init() { GLGizmoMeasure::on_init(); - m_shortcut_key = WXK_CONTROL_Y; + m_shortcut = Shortcut::GizmoAssembly; return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoBase.cpp b/src/slic3r/GUI/Gizmos/GLGizmoBase.cpp index 1aaac99d40..6ff2c8213d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoBase.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoBase.cpp @@ -4,6 +4,7 @@ #include #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/GUI_Colors.hpp" @@ -299,7 +300,6 @@ GLGizmoBase::GLGizmoBase(GLCanvas3D &parent, const std::string &icon_filename, u : m_parent(parent) , m_group_id(-1) , m_state(Off) - , m_shortcut_key(NO_SHORTCUT_KEY_VALUE) , m_icon_filename(icon_filename) , m_sprite_id(sprite_id) , m_imgui(wxGetApp().imgui()) @@ -515,11 +515,8 @@ void GLGizmoBase::render_input_window(float x, float y, float bottom_limit) std::string GLGizmoBase::get_name(bool include_shortcut) const { - int key = get_shortcut_key(); - std::string out = on_get_name(); - if (include_shortcut && key >= WXK_CONTROL_A && key <= WXK_CONTROL_Z) - out += std::string(" [") + char(int('A') + key - int(WXK_CONTROL_A)) + "]"; - return out; + const std::string name = on_get_name(); + return include_shortcut && m_shortcut.has_value() ? wxGetApp().shortcuts().with_key(name, *m_shortcut) : name; } } // namespace GUI diff --git a/src/slic3r/GUI/Gizmos/GLGizmoBase.hpp b/src/slic3r/GUI/Gizmos/GLGizmoBase.hpp index 92491d0d44..47e8f832a6 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoBase.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoBase.hpp @@ -11,6 +11,7 @@ #include "slic3r/GUI/3DScene.hpp" #include +#include #include @@ -29,6 +30,7 @@ namespace GUI { class ImGuiWrapper; +enum class Shortcut : uint8_t; class GLCanvas3D; enum class CommonGizmosDataID; class CommonGizmosDataPool; @@ -72,9 +74,6 @@ public: NegZ = 1 << 5, }; - // Represents NO key(button on keyboard) value - static const int NO_SHORTCUT_KEY_VALUE = 0; - protected: struct Grabber { @@ -138,7 +137,7 @@ protected: int m_group_id; // TODO: remove only for rotate EState m_state; - int m_shortcut_key; + std::optional m_shortcut; // the registry entry that opens this gizmo std::string m_icon_filename; unsigned int m_sprite_id; int m_hover_id{ -1 }; @@ -169,7 +168,7 @@ public: EState get_state() const { return m_state; } void set_state(EState state) { m_state = state; on_set_state(); } - int get_shortcut_key() const { return m_shortcut_key; } + std::optional shortcut() const { return m_shortcut; } const std::string& get_icon_filename() const { return m_icon_filename; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.cpp b/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.cpp index 6d9e810815..750e5fe6cb 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.cpp @@ -5,6 +5,7 @@ #include "slic3r/GUI/Camera.hpp" #include "slic3r/GUI/Gizmos/GLGizmosCommon.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/Plater.hpp" #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/ExPolygon.hpp" @@ -46,7 +47,7 @@ bool GLGizmoBrimEars::on_init() { m_new_point_head_radius = get_brim_default_radius(); - m_shortcut_key = WXK_CONTROL_E; + m_shortcut = Shortcut::GizmoBrimEars; const wxString ctrl = GUI::shortkey_ctrl_prefix(); const wxString alt = GUI::shortkey_alt_prefix(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoCut.cpp b/src/slic3r/GUI/Gizmos/GLGizmoCut.cpp index 273bb94b90..2100b1fca7 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoCut.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoCut.cpp @@ -6,6 +6,7 @@ #include #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/Gizmos/GizmoObjectManipulation.hpp" #include "slic3r/GUI/format.hpp" @@ -1310,7 +1311,7 @@ void GLGizmoCut3D::render_cut_line() bool GLGizmoCut3D::on_init() { m_grabbers.emplace_back(); - m_shortcut_key = WXK_CONTROL_C; + m_shortcut = Shortcut::GizmoCut; // initiate info shortcuts const wxString ctrl = GUI::shortkey_ctrl_prefix(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp b/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp index ecf465afe7..1009a6e89b 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp @@ -1,6 +1,7 @@ #include "GLGizmoEmboss.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" #include "slic3r/GUI/Gizmos/GizmoObjectManipulation.hpp" #include "slic3r/GUI/MainFrame.hpp" // to update title when add text @@ -727,7 +728,7 @@ bool GLGizmoEmboss::on_init() m_rotate_gizmo.set_highlight_color(gray_color); // NOTE: It has special handling in GLGizmosManager::handle_shortcut - m_shortcut_key = WXK_CONTROL_T; + m_shortcut = Shortcut::GizmoEmboss; m_shortcuts = { {_L("Drag"), _L("Position on surface")} diff --git a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp index 035fb895aa..06d69c3e82 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp @@ -8,6 +8,7 @@ //#include "slic3r/GUI/3DScene.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" @@ -78,7 +79,7 @@ std::string GLGizmoFdmSupports::on_get_name() const bool GLGizmoFdmSupports::on_init() { // BBS - m_shortcut_key = WXK_CONTROL_L; + m_shortcut = Shortcut::GizmoFdmSupports; m_desc["perform"] = _L("Apply"); m_desc["on_overhangs_only"] = _L("On highlighted overhangs only"); @@ -149,25 +150,14 @@ void GLGizmoFdmSupports::render_painter_gizmo() glsafe(::glDisable(GL_BLEND)); } -// BBS -bool GLGizmoFdmSupports::on_key_down_select_tool_type(int keyCode) { - switch (keyCode) - { - case 'F': - m_current_tool = ImGui::FillButtonIcon; - break; - case 'S': - m_current_tool = ImGui::SphereButtonIcon; - break; - case 'C': - m_current_tool = ImGui::CircleButtonIcon; - break; - case 'G': - m_current_tool = ImGui::GapFillIcon; - break; - default: - return false; - break; +bool GLGizmoFdmSupports::on_tool_shortcut(Shortcut shortcut) +{ + switch (shortcut) { + case Shortcut::PaintToolFill: m_current_tool = ImGui::FillButtonIcon; break; + case Shortcut::PaintToolSphere: m_current_tool = ImGui::SphereButtonIcon; break; + case Shortcut::PaintToolCircle: m_current_tool = ImGui::CircleButtonIcon; break; + case Shortcut::PaintToolGapFill: m_current_tool = ImGui::GapFillIcon; break; + default: return false; } return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.hpp b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.hpp index 57a6cc1981..d282a06b65 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.hpp @@ -26,8 +26,7 @@ public: state_ready }; - //BBS - bool on_key_down_select_tool_type(int keyCode); + bool on_tool_shortcut(Shortcut shortcut) override; protected: void on_render_input_window(float x, float y, float bottom_limit) override; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp b/src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp index e9d8598423..54dbd96ba7 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp @@ -1,6 +1,7 @@ #include "GLGizmoFlatten.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/Gizmos/GLGizmosCommon.hpp" @@ -54,7 +55,7 @@ void GLGizmoFlatten::data_changed(bool is_serializing) bool GLGizmoFlatten::on_init() { - m_shortcut_key = WXK_CONTROL_F; + m_shortcut = Shortcut::GizmoFlatten; return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoFuzzySkin.cpp b/src/slic3r/GUI/Gizmos/GLGizmoFuzzySkin.cpp index d807e466c8..8938e8a264 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoFuzzySkin.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoFuzzySkin.cpp @@ -6,6 +6,7 @@ #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/MsgDialog.hpp" @@ -31,7 +32,7 @@ std::string GLGizmoFuzzySkin::on_get_name() const bool GLGizmoFuzzySkin::on_init() { - m_shortcut_key = WXK_CONTROL_H; + m_shortcut = Shortcut::GizmoFuzzySkin; const wxString ctrl = GUI::shortkey_ctrl_prefix(); const wxString alt = GUI::shortkey_alt_prefix(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoHollow.cpp b/src/slic3r/GUI/Gizmos/GLGizmoHollow.cpp index 843a4b45fe..e5c6502452 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoHollow.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoHollow.cpp @@ -25,7 +25,6 @@ GLGizmoHollow::GLGizmoHollow(GLCanvas3D& parent, const std::string& icon_filenam bool GLGizmoHollow::on_init() { - m_shortcut_key = WXK_CONTROL_H; m_desc["enable"] = _(L("Hollow this object")); m_desc["preview"] = _(L("Preview hollowed and drilled model")); m_desc["offset"] = _(L("Offset")) + ": "; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp index ba2f54d5c5..8ec564e5b4 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp @@ -2,6 +2,7 @@ #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/Gizmos/GizmoObjectManipulation.hpp" #include "slic3r/Utils/UndoRedo.hpp" @@ -449,7 +450,7 @@ bool GLGizmoMeasure::gizmo_event(SLAGizmoEventType action, const Vec2d& mouse_po bool GLGizmoMeasure::on_init() { - m_shortcut_key = WXK_CONTROL_U; + m_shortcut = Shortcut::GizmoMeasure; const wxString shift = GUI::shortkey_shift_prefix(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.cpp index 8b12da14f9..b91f0a9468 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.cpp @@ -1,5 +1,6 @@ #include "GLGizmoMeshBoolean.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/GUI.hpp" #include "libslic3r/MeshBoolean.hpp" @@ -104,7 +105,7 @@ bool GLGizmoMeshBoolean::on_mouse(const wxMouseEvent &mouse_event) bool GLGizmoMeshBoolean::on_init() { - m_shortcut_key = WXK_CONTROL_B; + m_shortcut = Shortcut::GizmoMeshBoolean; return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp index 3d4af75cde..2e741fa5e5 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp @@ -2,6 +2,7 @@ #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/Camera.hpp" #include "slic3r/GUI/Plater.hpp" @@ -90,7 +91,7 @@ void GLGizmoMmuSegmentation::init_extruders_data() bool GLGizmoMmuSegmentation::on_init() { // BBS - m_shortcut_key = WXK_CONTROL_N; + m_shortcut = Shortcut::GizmoMmuSegmentation; const wxString ctrl = GUI::shortkey_ctrl_prefix(); const wxString alt = GUI::shortkey_alt_prefix(); @@ -209,30 +210,16 @@ bool GLGizmoMmuSegmentation::on_number_key_down(int number) return true; } -bool GLGizmoMmuSegmentation::on_key_down_select_tool_type(int keyCode) { - switch (keyCode) - { - case 'F': - m_current_tool = ImGui::FillButtonIcon; - break; - case 'T': - m_current_tool = ImGui::TriangleButtonIcon; - break; - case 'S': - m_current_tool = ImGui::SphereButtonIcon; - break; - case 'C': - m_current_tool = ImGui::CircleButtonIcon; - break; - case 'H': - m_current_tool = ImGui::HeightRangeIcon; - break; - case 'G': - m_current_tool = ImGui::GapFillIcon; - break; - default: - return false; - break; +bool GLGizmoMmuSegmentation::on_tool_shortcut(Shortcut shortcut) +{ + switch (shortcut) { + case Shortcut::PaintToolFill: m_current_tool = ImGui::FillButtonIcon; break; + case Shortcut::PaintToolTriangle: m_current_tool = ImGui::TriangleButtonIcon; break; + case Shortcut::PaintToolSphere: m_current_tool = ImGui::SphereButtonIcon; break; + case Shortcut::PaintToolCircle: m_current_tool = ImGui::CircleButtonIcon; break; + case Shortcut::PaintToolHeightRange: m_current_tool = ImGui::HeightRangeIcon; break; + case Shortcut::PaintToolGapFill: m_current_tool = ImGui::GapFillIcon; break; + default: return false; } return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp index 70cfde5aed..b2b04acdbe 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp @@ -82,7 +82,7 @@ public: // BBS bool on_number_key_down(int number); - bool on_key_down_select_tool_type(int keyCode); + bool on_tool_shortcut(Shortcut shortcut) override; protected: // BBS diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMove.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMove.cpp index 2f3195620a..4bee1a6cfc 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMove.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMove.cpp @@ -1,6 +1,7 @@ #include "GLGizmoMove.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" //BBS: GUI refactor #include "slic3r/GUI/Plater.hpp" #include "libslic3r/AppConfig.hpp" @@ -61,7 +62,7 @@ bool GLGizmoMove3D::on_init() m_grabbers[0].angles = { 0.0, 0.5 * double(PI), 0.0 }; m_grabbers[1].angles = { -0.5 * double(PI), 0.0, 0.0 }; - m_shortcut_key = WXK_CONTROL_M; + m_shortcut = Shortcut::GizmoMove; return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp b/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp index 63e9d3f436..9ba6065458 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp @@ -192,6 +192,8 @@ public: ~GLGizmoPainterBase() override; void data_changed(bool is_serializing) override; virtual bool gizmo_event(SLAGizmoEventType action, const Vec2d& mouse_position, bool shift_down, bool alt_down, bool control_down); + // Switches the painting tool a Painting-context shortcut names; false when this gizmo has no such tool. + virtual bool on_tool_shortcut(Shortcut shortcut) { return false; } // Following function renders the triangles and cursor. Having this separated // from usual on_render method allows to render them before transparent diff --git a/src/slic3r/GUI/Gizmos/GLGizmoRotate.cpp b/src/slic3r/GUI/Gizmos/GLGizmoRotate.cpp index 5521640eee..835b97f072 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoRotate.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoRotate.cpp @@ -3,6 +3,7 @@ #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/Jobs/RotoptimizeJob.hpp" @@ -555,7 +556,7 @@ bool GLGizmoRotate3D::on_init() for (unsigned int i = 0; i < 3; ++i) m_gizmos[i].set_highlight_color(AXES_COLOR[i]); - m_shortcut_key = WXK_CONTROL_R; + m_shortcut = Shortcut::GizmoRotate; return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoScale.cpp b/src/slic3r/GUI/Gizmos/GLGizmoScale.cpp index 88e744c2b8..c30acba386 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoScale.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoScale.cpp @@ -1,6 +1,7 @@ #include "GLGizmoScale.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/Plater.hpp" #include @@ -135,7 +136,7 @@ bool GLGizmoScale3D::on_init() // BBS m_grabbers[4].enabled = false; - m_shortcut_key = WXK_CONTROL_S; + m_shortcut = Shortcut::GizmoScale; return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSeam.cpp b/src/slic3r/GUI/Gizmos/GLGizmoSeam.cpp index b211a18fb2..f36a47c8ec 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoSeam.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoSeam.cpp @@ -5,6 +5,7 @@ //#include "slic3r/GUI/3DScene.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" @@ -28,7 +29,7 @@ void GLGizmoSeam::on_shutdown() bool GLGizmoSeam::on_init() { - m_shortcut_key = WXK_CONTROL_P; + m_shortcut = Shortcut::GizmoSeam; const wxString ctrl = GUI::shortkey_ctrl_prefix(); const wxString alt = GUI::shortkey_alt_prefix(); @@ -86,19 +87,12 @@ void GLGizmoSeam::render_painter_gizmo() glsafe(::glDisable(GL_BLEND)); } -// BBS -bool GLGizmoSeam::on_key_down_select_tool_type(int keyCode) { - switch (keyCode) - { - case 'S': - m_current_tool = ImGui::SphereButtonIcon; - break; - case 'C': - m_current_tool = ImGui::CircleButtonIcon; - break; - default: - return false; - break; +bool GLGizmoSeam::on_tool_shortcut(Shortcut shortcut) +{ + switch (shortcut) { + case Shortcut::PaintToolSphere: m_current_tool = ImGui::SphereButtonIcon; break; + case Shortcut::PaintToolCircle: m_current_tool = ImGui::CircleButtonIcon; break; + default: return false; } return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSeam.hpp b/src/slic3r/GUI/Gizmos/GLGizmoSeam.hpp index 5b143dc32c..f1c48a12d6 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoSeam.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoSeam.hpp @@ -12,8 +12,7 @@ public: void render_painter_gizmo() override; - //BBS - bool on_key_down_select_tool_type(int keyCode); + bool on_tool_shortcut(Shortcut shortcut) override; protected: // BBS diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSlaSupports.cpp b/src/slic3r/GUI/Gizmos/GLGizmoSlaSupports.cpp index ca22b14d99..49818e8e11 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoSlaSupports.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoSlaSupports.cpp @@ -34,7 +34,6 @@ GLGizmoSlaSupports::GLGizmoSlaSupports(GLCanvas3D& parent, const std::string& ic bool GLGizmoSlaSupports::on_init() { - m_shortcut_key = WXK_CONTROL_L; m_desc["head_diameter"] = _L("Head diameter") + ": "; m_desc["lock_supports"] = _L("Lock supports under new islands"); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoText.cpp b/src/slic3r/GUI/Gizmos/GLGizmoText.cpp index df3e9be584..9ae979eeef 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoText.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoText.cpp @@ -261,7 +261,6 @@ bool GLGizmoText::on_init() //m_avail_font_names = init_occt_fonts(); update_font_texture(); m_scale = m_imgui->get_font_size(); - m_shortcut_key = WXK_CONTROL_T; m_grabbers.push_back(Grabber()); diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp index 17a7b769c3..dc51518acc 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp @@ -4,6 +4,7 @@ #include "slic3r/GUI/3DScene.hpp" #include "slic3r/GUI/Camera.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/Utils/UndoRedo.hpp" @@ -486,15 +487,13 @@ bool GLGizmosManager::is_running() const return m_current != Undefined; } -bool GLGizmosManager::handle_shortcut(int key) +bool GLGizmosManager::open_gizmo_by_shortcut(Shortcut shortcut) { if (!m_enabled) return false; - auto is_key = [pressed_key = key](int gizmo_key) { return (gizmo_key == pressed_key - 64) || (gizmo_key == pressed_key - 96); }; - // allowe open shortcut even when selection is empty - if (GLGizmoBase* gizmo_emboss = m_gizmos[Emboss].get(); - is_key(gizmo_emboss->get_shortcut_key())) { + // The text tool opens without a selection because it creates its own object. + if (GLGizmoBase* gizmo_emboss = m_gizmos[Emboss].get(); gizmo_emboss->shortcut() == shortcut) { dynamic_cast(gizmo_emboss)->on_shortcut_key(); return true; } @@ -502,16 +501,21 @@ bool GLGizmosManager::handle_shortcut(int key) if (m_parent.get_selection().is_empty()) return false; - auto is_gizmo = [is_key](const std::unique_ptr &gizmo) { - return gizmo->is_activable() && is_key(gizmo->get_shortcut_key()); - }; - auto it = std::find_if(m_gizmos.begin(), m_gizmos.end(), is_gizmo); - + auto it = std::find_if(m_gizmos.begin(), m_gizmos.end(), [shortcut](const std::unique_ptr &gizmo) { + return gizmo->is_activable() && gizmo->shortcut() == shortcut; + }); if (it == m_gizmos.end()) return false; - EType gizmo_type = EType(it - m_gizmos.begin()); - return open_gizmo(gizmo_type); + return open_gizmo(EType(it - m_gizmos.begin())); +} + +bool GLGizmosManager::on_delete_key() +{ + const bool processed = (m_current == Cut || m_current == Measure || m_current == Assembly) && gizmo_event(SLAGizmoEventType::Delete); + if (processed) + m_parent.set_as_dirty(); + return processed; } bool GLGizmosManager::is_dragging() const @@ -856,15 +860,6 @@ bool GLGizmosManager::on_char(wxKeyEvent& evt) } break; } - //skip some keys when gizmo - case 'A': - case 'a': - { - if (is_running()) { - processed = true; - } - break; - } //case WXK_RETURN: //{ // if ((m_current == SlaSupports) && gizmo_event(SLAGizmoEventType::ApplyChanges)) @@ -883,12 +878,6 @@ bool GLGizmosManager::on_char(wxKeyEvent& evt) //} - case WXK_BACK: - case WXK_DELETE: { - if ((m_current == Cut || m_current == Measure || m_current == Assembly) && gizmo_event(SLAGizmoEventType::Delete)) - processed = true; - break; - } //case 'A': //case 'a': //{ @@ -932,11 +921,6 @@ bool GLGizmosManager::on_char(wxKeyEvent& evt) } } - if (!processed && !evt.HasModifiers()) { - if (handle_shortcut(keyCode)) - processed = true; - } - if (processed) m_parent.set_as_dirty(); @@ -1077,40 +1061,24 @@ bool GLGizmosManager::on_key(wxKeyEvent& evt) processed = select(digit); } } - else if (keyCode == 'F' || keyCode == 'T' || keyCode == 'S' || keyCode == 'C' || keyCode == 'H' || keyCode == 'G') { - processed = mmu_seg->on_key_down_select_tool_type(keyCode); - if (processed) { - // force extra frame to automatically update window size - wxGetApp().imgui()->set_requires_extra_frame(); - } - } } } - else if (m_current == FdmSupports) { - GLGizmoFdmSupports* fdm_support = dynamic_cast(get_current()); - if (fdm_support != nullptr && (keyCode == 'F' || keyCode == 'S' || keyCode == 'C' || keyCode == 'G')) { - processed = fdm_support->on_key_down_select_tool_type(keyCode); - } - if (processed) { - // force extra frame to automatically update window size - wxGetApp().imgui()->set_requires_extra_frame(); - } - } - else if (m_current == Seam) { - GLGizmoSeam* seam = dynamic_cast(get_current()); - if (seam != nullptr && (keyCode == 'S' || keyCode == 'C')) { - processed = seam->on_key_down_select_tool_type(keyCode); - } - if (processed) { - // force extra frame to automatically update window size - wxGetApp().imgui()->set_requires_extra_frame(); - } - } else if (m_current == Measure || m_current == Assembly) { + else if (m_current == Measure || m_current == Assembly) { if (keyCode == WXK_CONTROL) gizmo_event(SLAGizmoEventType::CtrlDown, Vec2d::Zero(), evt.ShiftDown(), evt.AltDown(), evt.CmdDown()); else if (keyCode == WXK_SHIFT) gizmo_event(SLAGizmoEventType::ShiftDown, Vec2d::Zero(), evt.ShiftDown(), evt.AltDown(), evt.CmdDown()); } + + if (!processed) { + if (auto painter = dynamic_cast(get_current()); painter != nullptr) { + const std::optional shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Painting, KeyChord::from_event(evt)); + processed = shortcut.has_value() && painter->on_tool_shortcut(*shortcut); + if (processed) + // force extra frame to automatically update window size + wxGetApp().imgui()->set_requires_extra_frame(); + } + } } if (processed) diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp index 5f249d2ef7..2a4afac16d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp @@ -263,7 +263,10 @@ public: EType get_gizmo_from_name(const std::string& gizmo_name) const; bool is_running() const; - bool handle_shortcut(int key); + // Opens the gizmo bound to a Plater-context shortcut; false when no gizmo has it or it cannot open now. + bool open_gizmo_by_shortcut(Shortcut shortcut); + // Lets the current gizmo consume the delete key; false when it did not. + bool on_delete_key(); bool is_dragging() const; diff --git a/src/slic3r/GUI/HintNotification.cpp b/src/slic3r/GUI/HintNotification.cpp index c08a447e1e..de5f12e07e 100644 --- a/src/slic3r/GUI/HintNotification.cpp +++ b/src/slic3r/GUI/HintNotification.cpp @@ -5,6 +5,7 @@ #include "GUI_ObjectList.hpp" #include "GLCanvas3D.hpp" #include "MainFrame.hpp" +#include "Preferences.hpp" #include "Tab.hpp" #include "libslic3r/AppConfig.hpp" #include "libslic3r/Utils.hpp" @@ -444,9 +445,8 @@ void HintDatabase::load_hints_from_file(const boost::filesystem::path& path) // open preferences } else if (dict["hypertext_type"] == "preferences") { - std::string page = dict["hypertext_preferences_page"]; std::string item = dict["hypertext_preferences_item"]; - HintData hint_data{ id_string, text1, weight, was_displayed, hypertext_text, follow_text, disabled_tags, enabled_tags, false, documentation_link, img_url, [page, item]() { wxGetApp().open_preferences(1, page); } };// 1 is to modify + HintData hint_data{ id_string, text1, weight, was_displayed, hypertext_text, follow_text, disabled_tags, enabled_tags, false, documentation_link, img_url, [item]() { wxGetApp().open_preferences(PreferencesTab::Control, item); } }; m_loaded_hints.emplace_back(hint_data); } else if (dict["hypertext_type"] == "plater") { diff --git a/src/slic3r/GUI/KBShortcutsDialog.cpp b/src/slic3r/GUI/KBShortcutsDialog.cpp index be1f53ef97..c03d331627 100644 --- a/src/slic3r/GUI/KBShortcutsDialog.cpp +++ b/src/slic3r/GUI/KBShortcutsDialog.cpp @@ -6,359 +6,319 @@ #include "Notebook.hpp" #include #include +#include +#include #include "GUI_App.hpp" #include "wxExtensions.hpp" #include "MainFrame.hpp" +#include "MsgDialog.hpp" +#include "Preferences.hpp" +#include "Widgets/Button.hpp" +#include "Widgets/DialogButtons.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/StaticBox.hpp" +#include "Widgets/StaticLine.hpp" +#include "Widgets/TabCtrl.hpp" #include namespace Slic3r { namespace GUI { -wxDEFINE_EVENT(EVT_PREFERENCES_SELECT_TAB, wxCommandEvent); +namespace { -KBShortcutsDialog::KBShortcutsDialog() - : DPIDialog(static_cast(wxGetApp().mainframe), wxID_ANY,_L("Keyboard Shortcuts"), - wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE) +wxString shortcut_names(const std::vector& shortcuts) { - // fonts - const wxFont& font = wxGetApp().normal_font(); - const wxFont& bold_font = wxGetApp().bold_font(); - SetFont(font); - - this->SetSizeHints(wxDefaultSize, wxDefaultSize); - this->SetBackgroundColour(wxColour(255, 255, 255)); - - wxBoxSizer *m_sizer_top = new wxBoxSizer(wxVERTICAL); - - auto m_top_line = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, 1), wxTAB_TRAVERSAL); - m_top_line->SetBackgroundColour(wxColour(166, 169, 170)); - - m_sizer_top->Add(m_top_line, 0, wxEXPAND, 0); - m_sizer_body = new wxBoxSizer(wxHORIZONTAL); - - m_panel_selects = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL); - m_panel_selects->SetBackgroundColour(wxColour(248, 248, 248)); - wxBoxSizer *m_sizer_left = new wxBoxSizer(wxVERTICAL); - - m_sizer_left->Add(0, 0, 0, wxEXPAND | wxTOP, FromDIP(20)); - - m_sizer_left->Add(create_button(0, _L("Global")), 0, wxEXPAND, 0); - m_sizer_left->Add(create_button(1, _L("Prepare")), 0, wxEXPAND, 0); - m_sizer_left->Add(create_button(2, _L("Toolbar")), 0, wxEXPAND, 0); - m_sizer_left->Add(create_button(3, _L("Objects list")), 0, wxEXPAND, 0); - m_sizer_left->Add(create_button(4, _L("Preview")), 0, wxEXPAND, 0); - - m_panel_selects->SetSizer(m_sizer_left); - m_panel_selects->Layout(); - m_sizer_left->Fit(m_panel_selects); - m_sizer_body->Add(m_panel_selects, 0, wxEXPAND, 0); - - m_sizer_right = new wxBoxSizer(wxHORIZONTAL); - - m_sizer_right->Add(0, 0, 0, wxEXPAND | wxLEFT, FromDIP(12)); - - m_simplebook = new wxSimplebook(this, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(870), FromDIP(500)), 0); - - m_sizer_right->Add(m_simplebook, 1, wxEXPAND, 0); - m_sizer_body->Add(m_sizer_right, 1, wxEXPAND, 0); - m_sizer_top->Add(m_sizer_body, 1, wxEXPAND, 0); - - fill_shortcuts(); - for (size_t i = 0; i < m_full_shortcuts.size(); ++i) { - wxPanel *page = create_page(m_simplebook, m_full_shortcuts[i], font, bold_font); - m_pages.push_back(page); - m_simplebook->AddPage(page, m_full_shortcuts[i].first.first, i == 0); + wxString names; + for (Shortcut shortcut : shortcuts) { + if (!names.empty()) + names += ", "; + names += _(shortcut_info(shortcut).name); } + return names; +} - Bind(EVT_PREFERENCES_SELECT_TAB, &KBShortcutsDialog::OnSelectTabel, this); +const wxColour ERROR_COLOUR("#D01B1B"); - SetSizer(m_sizer_top); - Layout(); - Fit(); +// The camera action a mouse button drags, as set in Preferences > Control. +const char* mouse_action(const char* preference) +{ + const std::string action = wxGetApp().app_config->get(preference); + return action == "1" ? L("Pan View") : action == "2" ? L("Rotate View") : L("None"); +} + +// Page layout in DIPs; titles and rows are indented as in the Preferences dialog. +constexpr int PAGE_WIDTH = 640; +constexpr int TITLE_MARGIN = DESIGN_LEFT_MARGIN - 10; +constexpr int ROW_MARGIN = DESIGN_LEFT_MARGIN; +constexpr int ROW_GAP = 16; + +template struct overloaded : Ts... { using Ts::operator()...; }; +template overloaded(Ts...) -> overloaded; + +std::vector to_wx(const std::vector& parts) +{ + std::vector out; + for (const std::string& part : parts) + out.push_back(from_u8(part)); + return out; +} + +// The keys a Global shortcut can use, the second line of its hint and of a rejection. +wxString global_key_advice() +{ + return wxString::Format(_L("Use %s or %s, or a key that does not type a character."), + from_u8(KeyChord::modifier_name(wxMOD_CONTROL)), from_u8(KeyChord::modifier_name(wxMOD_ALT))); +} + +// The pieces of a chord, spaced out for the dialog: "Ctrl + Shift + A". +wxString join_keys(const std::vector& parts) +{ + wxString out; + for (const wxString& part : parts) + out += (out.empty() ? "" : " + ") + part; + return out; +} + +} // namespace + +KBShortcutsDialog::KBShortcutsDialog(wxWindow* parent, ShortcutContext page) + : DPIDialog(parent, wxID_ANY, _L("Keyboard Shortcuts"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE) +{ + SetFont(wxGetApp().normal_font()); + SetBackgroundColour(*wxWHITE); + + fill_pages(); + + ScalableButton* probe = new ScalableButton(this, wxID_ANY, "edit"); + m_edit_size = probe->GetBestSize(); + probe->Destroy(); + m_buttons_width = 2 * m_edit_size.x + FromDIP(6); + m_row_text_width = FromDIP(PAGE_WIDTH) - FromDIP(ROW_MARGIN) - FromDIP(TITLE_MARGIN) - 2 * FromDIP(ROW_GAP) - m_buttons_width; + GetTextExtent("W", &m_key_slot, nullptr, nullptr, nullptr, &Label::Head_14); + + // The page tabs follow the Preferences dialog. + m_tabs = new TabCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTR_NO_BUTTONS | wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | wxBORDER_NONE | wxWANTS_CHARS | wxTR_FULL_ROW_HIGHLIGHT); + m_tabs->Bind(wxEVT_RIGHT_DOWN, [](auto&) {}); + m_tabs->SetFont(Label::Body_14); + m_simplebook = new wxSimplebook(this, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(660), FromDIP(500))); + for (const Page& page : m_pages) { + m_tabs->AppendItem(page.title); + m_simplebook->AddPage(create_page(m_simplebook, page), page.title); + } + const StateColor tab_colour(std::make_pair(wxColour("#6B6B6C"), (int) StateColor::NotChecked), std::make_pair(wxColour("#363636"), (int) StateColor::Normal)); + for (size_t i = 0; i < m_tabs->GetCount(); ++i) + m_tabs->SetItemTextColour(i, tab_colour); + m_tabs->Bind(wxEVT_TAB_SEL_CHANGED, [this](wxCommandEvent& e) { + for (size_t i = 0; i < m_tabs->GetCount(); ++i) + m_tabs->SetItemBold(i, int(i) == e.GetSelection()); + m_simplebook->SetSelection(e.GetSelection()); + }); + const auto shown = std::find_if(m_pages.begin(), m_pages.end(), [page](const Page& entry) { return entry.context == page; }); + m_tabs->SelectItem(shown == m_pages.end() ? 0 : int(shown - m_pages.begin())); + + wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(m_tabs, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(5)); + sizer->Add(m_simplebook, 1, wxEXPAND); + SetSizerAndFit(sizer); CenterOnParent(); - - // select first - auto event = wxCommandEvent(EVT_PREFERENCES_SELECT_TAB); - event.SetInt(0); - event.SetEventObject(this); - wxPostEvent(this, event); wxGetApp().UpdateDlgDarkUI(this); } -void KBShortcutsDialog::OnSelectTabel(wxCommandEvent &event) -{ - auto id = event.GetInt(); - SelectHash::iterator i = m_hash_selector.begin(); - while (i != m_hash_selector.end()) { - Select *sel = i->second; - if (id == sel->m_index) { - sel->m_tab_button->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#BFE1DE"))); // ORCA color for selected tab background - sel->m_tab_text->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#BFE1DE"))); // ORCA color for selected tab background - sel->m_tab_text->SetFont(::Label::Head_13); - sel->m_tab_button->Refresh(); - sel->m_tab_text->Refresh(); - - m_simplebook->SetSelection(id); - } else { - sel->m_tab_button->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F8F8F8"))); - sel->m_tab_text->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F8F8F8"))); - sel->m_tab_text->SetFont(::Label::Body_13); - sel->m_tab_button->Refresh(); - sel->m_tab_text->Refresh(); - } - i++; - } - wxGetApp().UpdateDlgDarkUI(this); -} - -wxWindow *KBShortcutsDialog::create_button(int id, wxString text) -{ - auto tab_button = new wxWindow(m_panel_selects, wxID_ANY, wxDefaultPosition, wxSize( FromDIP(150), FromDIP(28)), wxTAB_TRAVERSAL); - - wxBoxSizer *sizer = new wxBoxSizer(wxHORIZONTAL); - - sizer->Add(0, 0, 0, wxEXPAND | wxLEFT, FromDIP(22)); - - auto stext = new wxStaticText(tab_button, wxID_ANY, text, wxDefaultPosition, wxDefaultSize, 0); - stext->SetFont(::Label::Body_13); - stext->SetForegroundColour(wxColour(38, 46, 48)); - stext->Wrap(-1); - sizer->Add(stext, 1, wxALIGN_CENTER, 0); - - tab_button->Bind(wxEVT_LEFT_DOWN, [this, id](auto &e) { - auto event = wxCommandEvent(EVT_PREFERENCES_SELECT_TAB); - event.SetInt(id); - event.SetEventObject(this); - wxPostEvent(this, event); - }); - - stext->Bind(wxEVT_LEFT_DOWN, [this, id](wxMouseEvent &e) { - auto event = wxCommandEvent(EVT_PREFERENCES_SELECT_TAB); - event.SetInt(id); - event.SetEventObject(this); - wxPostEvent(this, event); - }); - - Select *sel = new Select; - sel->m_index = id; - sel->m_tab_button = tab_button; - sel->m_tab_text = stext; - m_hash_selector[sel->m_index] = sel; - - tab_button->SetSizer(sizer); - tab_button->Layout(); - return tab_button; -} - void KBShortcutsDialog::on_dpi_changed(const wxRect& suggested_rect) { - m_logo_bmp.msw_rescale(); - m_header_bitmap->SetBitmap(m_logo_bmp.bmp()); - msw_buttons_rescale(this, em_unit(), { wxID_OK }); - + m_tabs->Rescale(); Layout(); Fit(); Refresh(); } -void KBShortcutsDialog::fill_shortcuts() +void KBShortcutsDialog::fill_pages() { - const std::string ctrl = GUI::shortkey_ctrl_prefix(); - const std::string alt = GUI::shortkey_alt_prefix(); - const std::string shift = L("Shift+"); + // A fixed row is listed in the section of the shortcuts it belongs with. + auto fixed = [](ShortcutSection section, std::vector keys, const char* description) { return Row{ FixedKey{ std::move(keys), description }, section }; }; + auto mouse = [](ShortcutSection section, const wxString& button, const char* preference) { return Row{ MouseAction{ button, preference }, section }; }; + auto key = [](const std::string& key) { return _L_CONTEXT(key, "Keyboard Shortcut"); }; + auto page = [this](const wxString& title, const wxString& caption, ShortcutContext context, std::vector fixed_rows) { + Page entry{ title, caption, context, {} }; + for (Shortcut shortcut : shortcuts_in(context)) + entry.rows.push_back({ shortcut, shortcut_section(shortcut) }); + entry.rows.insert(entry.rows.end(), fixed_rows.begin(), fixed_rows.end()); + std::stable_sort(entry.rows.begin(), entry.rows.end(), [](const Row& a, const Row& b) { return a.section < b.section; }); + m_pages.push_back(std::move(entry)); + }; + + const wxString ctrl = from_u8(KeyChord::modifier_name(wxMOD_CONTROL)); + const wxString alt = from_u8(KeyChord::modifier_name(wxMOD_ALT)); + const wxString shift = from_u8(KeyChord::modifier_name(wxMOD_SHIFT)); + const wxString shift_ctrl = shift + "/" + ctrl; // either one + const wxString any_key = key(L_CONTEXT("Key", "Keyboard Shortcut")); // the key the row's shortcut is bound to + const wxString esc = key(L_CONTEXT("Esc", "Keyboard Shortcut")); + const wxString left_button = _L("Left mouse"); + const wxString wheel = _L("Mouse wheel"); + using Section = ShortcutSection; if (wxGetApp().is_editor()) { - Shortcuts global_shortcuts = { - // File - { ctrl + "N", L("New Project") }, - { ctrl + "O", L("Open Project") }, - { ctrl + "S", L("Save Project") }, - { ctrl + shift + "S", L("Save Project as")}, - { ctrl + shift + "E", L("Publish 3MF") }, - // File>Import - { ctrl + "I", L("Import geometry data from STL/STEP/3MF/OBJ/AMF files") }, - // File>Export - { ctrl + "G", L("Export plate sliced file")}, - // Slice plate - { ctrl + "R", L("Slice plate")}, - // Send to Print - { ctrl + shift + "G", L("Print plate")}, - // Edit - { ctrl + "X", L("Cut") }, - { ctrl + "C", L("Copy to clipboard") }, - { ctrl + "V", L("Paste from clipboard") }, - // Configuration - { ctrl + "P", L("Preferences") }, - //3D control -#ifdef __APPLE__ - { ctrl + shift + "M", L("Show/Hide 3Dconnexion devices settings dialog") }, -#else - { ctrl + "M", L("Show/Hide 3Dconnexion devices settings dialog") }, -#endif // __APPLE + page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, { + fixed(Section::Application, { alt, "1-9, 0" }, L("Run a speed dial favorite while the dial is open")), + fixed(Section::Application, { ctrl, key(L_CONTEXT("Tab", "Keyboard Shortcut")) }, L("Switch to the next main tab")), + }); - // Switch table page - { ctrl + L("Tab"), L("Switch table page")}, - // Open speed dial - { L_CONTEXT("Space", "Keyboard Shortcut"), L("Open speed dial") }, - { alt + "1..9,0", L("Run a Speed Dial favourite (while the Speed Dial is open)") }, - //DEL - #ifdef __APPLE__ - {"fn+⌫", L("Delete Selected")}, - #else - {L_CONTEXT("Del", "Keyboard Shortcut"), L("Delete Selected")}, - #endif - // Help - { "?", L("Show keyboard shortcuts list") } - }; - m_full_shortcuts.push_back({{_L("Global shortcuts"), ""}, global_shortcuts}); + page(_L("Prepare"), _L("Available while the 3D view on the Prepare tab has focus."), ShortcutContext::Plater, { + fixed(Section::Selection, { alt, left_button }, L("Select a part")), + fixed(Section::Selection, { ctrl, left_button }, L("Select multiple objects")), + fixed(Section::Selection, { shift, left_button }, L("Select objects by rectangle")), + fixed(Section::Selection, { esc }, L("Deselect All")), + fixed(Section::Objects, { "1-9" }, L("Keyboard 1-9: set filament for object/part")), + fixed(Section::Placement, { shift, any_key }, L("Movement step set to 1mm")), + fixed(Section::Placement, { ctrl, any_key }, L("Movement in camera space")), + mouse(Section::Camera, left_button, "left_mouse_drag_action"), + mouse(Section::Camera, _L("Middle mouse"), "middle_mouse_drag_action"), + mouse(Section::Camera, _L("Right mouse"), "right_mouse_drag_action"), + fixed(Section::Camera, { wheel }, L("Zoom View")), + }); - - // Retrieve mouse actions from config and map to MouseAction - std::map mouse_actions; - mouse_actions["0"] = L("None"); - mouse_actions["1"] = L("Pan View"); - mouse_actions["2"] = L("Rotate View"); + page(_L("Painting"), _L("Available while a painting gizmo is open: supports, seam, fuzzy skin or color painting."), ShortcutContext::Painting, { + fixed(Section::Gizmos, { esc }, L("Deselect All")), + fixed(Section::Gizmos, { shift, left_button }, L("Move: press to snap by 1mm")), + fixed(Section::PaintingTools, { ctrl, wheel }, L("Support/Color Painting: adjust pen radius")), + fixed(Section::PaintingTools, { alt, wheel }, L("Support/Color Painting: adjust section position")), + }); - Shortcuts plater_shortcuts = { - { L("Left mouse button"), mouse_actions[wxGetApp().app_config->get("left_mouse_drag_action").c_str()]}, - { L("Middle mouse button"), mouse_actions[wxGetApp().app_config->get("middle_mouse_drag_action").c_str()]}, - { L("Right mouse button"), mouse_actions[wxGetApp().app_config->get("right_mouse_drag_action").c_str()]}, - { L("Mouse wheel"), L("Zoom View") }, - { "A", L("Arrange all objects") }, - { shift + "A", L("Arrange objects on selected plates") }, - - { "Q", L("Auto orients selected objects or all objects. If there are selected objects, it just orients the selected ones. Otherwise, it will orient all objects in the current project.") }, - { shift + "Q", L("Auto orients all objects on the active plate.") }, - - {shift + L("Tab"), L("Collapse/Expand the sidebar")}, - {ctrl + L("Any arrow"), L("Movement in camera space")}, - {alt + L("Left mouse button"), L("Select a part")}, - {ctrl + L("Left mouse button"), L("Select multiple objects")}, - {shift + L("Left mouse button"), L("Select objects by rectangle")}, - {L_CONTEXT("Arrow Up", "Keyboard Shortcut"), L("Move selection 10mm in positive Y direction")}, - {L_CONTEXT("Arrow Down", "Keyboard Shortcut"), L("Move selection 10mm in negative Y direction")}, - {L_CONTEXT("Arrow Left", "Keyboard Shortcut"), L("Move selection 10mm in negative X direction")}, - {L_CONTEXT("Arrow Right", "Keyboard Shortcut"), L("Move selection 10mm in positive X direction")}, - {shift + L("Any arrow"), L("Movement step set to 1mm")}, - {L_CONTEXT("Esc", "Keyboard Shortcut"), L("Deselect All")}, - {"1-9", L("Keyboard 1-9: set filament for object/part")}, - {ctrl + "0", L("Camera view - Default")}, - {ctrl + "1", L("Camera view - Top")}, - {ctrl + "2", L("Camera view - Bottom")}, - {ctrl + "3", L("Camera view - Front")}, - {ctrl + "4", L("Camera view - Behind")}, - {ctrl + "5", L("Camera Angle - Left side")}, - {ctrl + "6", L("Camera Angle - Right side")}, - - {ctrl + "A", L("Select all objects")}, - {ctrl + "D", L("Delete All")}, - {ctrl + "Z", L("Undo")}, - {ctrl + "Y", L("Redo")}, - { "M", L("Gizmo move") }, - { "R", L("Gizmo rotate") }, - { "S", L("Gizmo scale") }, - { "F", L("Gizmo place face on bed") }, - { "C", L("Gizmo cut") }, - { "B", L("Gizmo mesh boolean") }, - { "H", L("Gizmo FDM paint-on fuzzy skin") }, - { "L", L("Gizmo SLA support points") }, - { "P", L("Gizmo FDM paint-on seam") }, - { "T", L("Gizmo text emboss/engrave") }, - { "U", L("Gizmo measure") }, - { "Y", L("Gizmo assemble") }, - { "E", L("Gizmo brim ears") }, - { "I", L("Zoom in") }, - { "O", L("Zoom out") }, - { "V", L("Toggle printable for object/part") }, - { L_CONTEXT("Tab", "Keyboard Shortcut"), L("Switch between Prepare/Preview") }, - }; - m_full_shortcuts.push_back({ { _L("Plater"), "" }, plater_shortcuts }); - - Shortcuts gizmos_shortcuts = { - {L_CONTEXT("Esc", "Keyboard Shortcut"), L("Deselect All")}, - {shift, L("Move: press to snap by 1mm")}, - {ctrl + L("Mouse wheel"), L("Support/Color Painting: adjust pen radius")}, - {alt + L("Mouse wheel"), L("Support/Color Painting: adjust section position")}, - }; - m_full_shortcuts.push_back({{_L("Gizmo"), ""}, gizmos_shortcuts}); - - Shortcuts object_list_shortcuts = { - {"1-9", L("Set extruder number for the objects and parts") }, - {L_CONTEXT("Del", "Keyboard Shortcut"), L("Delete objects, parts, modifiers")}, - {L_CONTEXT("Esc", "Keyboard Shortcut"), L("Deselect All")}, - {ctrl + "C", L("Copy to clipboard")}, - {ctrl + "V", L("Paste from clipboard")}, - {ctrl + "X", L("Cut")}, - {ctrl + "A", L("Select all objects")}, - {ctrl + "K", L("Clone Selected")}, - {ctrl + "Z", L("Undo")}, - {ctrl + "Y", L("Redo")}, - {L_CONTEXT("Space", "Keyboard Shortcut"), L("Select the object/part and press space to change the name")}, - {L("Mouse click"), L("Select the object/part and mouse click to change the name")}, - }; - m_full_shortcuts.push_back({ { _L("Objects List"), "" }, object_list_shortcuts }); + page(_L("Objects list"), _L("Available while the object list has focus."), ShortcutContext::ObjectList, { + fixed(Section::Selection, { esc }, L("Deselect All")), + fixed(Section::Objects, { "1-9" }, L("Set extruder number for the objects and parts")), + fixed(Section::Objects, { key(L_CONTEXT("Space", "Keyboard Shortcut")) }, L("Select the object/part and press space to change the name")), + fixed(Section::Objects, { _L("Mouse click") }, L("Select the object/part and mouse click to change the name")), + }); } - Shortcuts preview_shortcuts = { - { L_CONTEXT("Arrow Up", "Keyboard Shortcut"), L("Vertical slider - Move active thumb Up")}, - { L_CONTEXT("Arrow Down", "Keyboard Shortcut"), L("Vertical slider - Move active thumb Down")}, - { L_CONTEXT("Arrow Left", "Keyboard Shortcut"), L("Horizontal slider - Move active thumb Left")}, - { L_CONTEXT("Arrow Right", "Keyboard Shortcut"), L("Horizontal slider - Move active thumb Right")}, - { "L", L("On/Off one layer mode of the vertical slider")}, - { "C", L("On/Off G-code window")}, - { L_CONTEXT("Tab", "Keyboard Shortcut"), L("Switch between Prepare/Preview")}, - {shift + L("Any arrow"), L("Move slider 5x faster")}, - {shift + L("Mouse wheel"), L("Move slider 5x faster")}, - {ctrl + L("Any arrow"), L("Move slider 5x faster")}, - {ctrl + L("Mouse wheel"), L("Move slider 5x faster")}, - { L_CONTEXT("Home", "Keyboard Shortcut"), L("Horizontal slider - Move to start position")}, - { L_CONTEXT("End", "Keyboard Shortcut"), L("Horizontal slider - Move to last position")}, - }; - m_full_shortcuts.push_back({ { _L("Preview"), "" }, preview_shortcuts }); + page(_L("Preview"), _L("Available while the 3D view on the Preview tab has focus."), ShortcutContext::Preview, { + fixed(Section::Sliders, { shift_ctrl, any_key }, L("Move slider 5x faster")), + fixed(Section::Sliders, { shift_ctrl, wheel }, L("Scroll slider 5x faster")), + }); } -wxPanel* KBShortcutsDialog::create_page(wxWindow* parent, const ShortcutsItem& shortcuts, const wxFont& font, const wxFont& bold_font) +wxPanel* KBShortcutsDialog::create_page(wxWindow* parent, const Page& page) { wxPanel* main_page = new wxPanel(parent); wxBoxSizer* main_sizer = new wxBoxSizer(wxVERTICAL); - if (!shortcuts.first.second.empty()) { - main_sizer->AddSpacer(FromDIP(10)); - wxBoxSizer* info_sizer = new wxBoxSizer(wxHORIZONTAL); - info_sizer->AddStretchSpacer(); - info_sizer->Add(new wxStaticText(main_page, wxID_ANY, shortcuts.first.second), 0); - info_sizer->AddStretchSpacer(); - main_sizer->Add(info_sizer, 0, wxEXPAND); - main_sizer->AddSpacer(FromDIP(10)); - } - - int items_count = (int) shortcuts.second.size(); wxScrolledWindow *scrollable_panel = new wxScrolledWindow(main_page); wxGetApp().UpdateDarkUI(scrollable_panel); - scrollable_panel->SetScrollbars(20, 20, 50, 50); - scrollable_panel->SetInitialSize(wxSize(FromDIP(850), FromDIP(450))); + const wxColour page_colour = StateColor::darkModeColorFor(*wxWHITE); + scrollable_panel->SetBackgroundColour(page_colour); + scrollable_panel->SetScrollRate(0, 20); + const int page_width = FromDIP(PAGE_WIDTH); + scrollable_panel->SetInitialSize(wxSize(page_width, FromDIP(450))); - wxBoxSizer * scrollable_panel_sizer = new wxBoxSizer(wxVERTICAL); - wxFlexGridSizer *grid_sizer = new wxFlexGridSizer(items_count, 2, FromDIP(10), FromDIP(20)); + const int title_margin = FromDIP(TITLE_MARGIN); + const int row_margin = FromDIP(ROW_MARGIN); + const int gap = FromDIP(ROW_GAP); - for (int i = 0; i < items_count; ++i) { - const auto &[shortcut, description] = shortcuts.second[i]; - // Keyboard keys carry a "Keyboard Shortcut" context so translators keep them in English; - // mouse-input labels are ordinary phrases and use the plain lookup. - const bool is_mouse = shortcut.find("Mouse") != std::string::npos || shortcut.find("mouse") != std::string::npos; - auto key = new wxStaticText(scrollable_panel, wxID_ANY, is_mouse ? _(shortcut) : _L_CONTEXT(shortcut, "Keyboard Shortcut")); - key->SetForegroundColour(wxColour(50, 58, 61)); - key->SetFont(bold_font); - grid_sizer->Add(key, 0, wxALIGN_CENTRE_VERTICAL); + wxBoxSizer* scrollable_panel_sizer = new wxBoxSizer(wxVERTICAL); - auto desc = new wxStaticText(scrollable_panel, wxID_ANY, _(description)); - desc->SetFont(font); - desc->SetForegroundColour(wxColour(50, 58, 61)); - desc->Wrap(FromDIP(600)); - grid_sizer->Add(desc, 0, wxALIGN_CENTRE_VERTICAL); + const wxColour note_colour = StateColor::darkModeColorFor(wxColour("#F8F8F8")); + const wxColour note_text = StateColor::darkModeColorFor(wxColour("#6B6B6A")); + StaticBox* note = new StaticBox(scrollable_panel); + note->SetCornerRadius(FromDIP(4)); + note->SetBorderWidth(0); + note->SetBackgroundColor(note_colour); + note->SetBackgroundColour(note_colour); + auto note_icon = new wxStaticBitmap(note, wxID_ANY, ScalableBitmap(note, "help", 16).bmp()); + auto note_text_ctrl = new wxStaticText(note, wxID_ANY, page.caption); + note_text_ctrl->SetFont(Label::Body_13); + note_text_ctrl->SetForegroundColour(note_text); + note_text_ctrl->SetBackgroundColour(note_colour); + note_text_ctrl->Wrap(page_width - 2 * title_margin - FromDIP(10 + 16 + 8 + 10)); + wxBoxSizer* note_sizer = new wxBoxSizer(wxHORIZONTAL); + note_sizer->Add(note_icon, 0, wxALIGN_CENTRE_VERTICAL | wxLEFT, FromDIP(10)); + note_sizer->Add(note_text_ctrl, 1, wxALIGN_CENTRE_VERTICAL | wxALL, FromDIP(8)); + note->SetSizer(note_sizer); + scrollable_panel_sizer->Add(note, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, title_margin); + + auto key_parts = [](const Row& row) { + return std::visit(overloaded{ + [](Shortcut shortcut) { return to_wx(wxGetApp().shortcuts().binding(shortcut).display_parts()); }, + [](const FixedKey& fixed) { return fixed.keys; }, + [](const MouseAction& mouse) { return std::vector{ mouse.button }; }, + }, row.content); + }; + auto description = [](const Row& row) { + return std::visit(overloaded{ + [](Shortcut shortcut) { return _(shortcut_info(shortcut).name); }, + [](const FixedKey& fixed) { return _(fixed.description); }, + [](const MouseAction& mouse) { return _(mouse_action(mouse.preference)); }, + }, row.content); + }; + auto icon_button = [&](const char* icon, const wxString& tooltip) { + auto button = new ScalableButton(scrollable_panel, wxID_ANY, icon); + button->SetBackgroundColour(page_colour); + button->SetToolTip(tooltip); + return button; + }; + + std::optional section; + for (const Row& row : page.rows) { + if (section != row.section) { + auto heading = new StaticLine(scrollable_panel, false, _(section_name(row.section))); + heading->SetFont(Label::Head_14); + heading->SetForegroundColour(DESIGN_GRAY900_COLOR); + wxBoxSizer* heading_sizer = new wxBoxSizer(wxHORIZONTAL); + heading_sizer->AddSpacer(title_margin); + heading_sizer->Add(heading, 1, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(6)); + heading_sizer->AddSpacer(title_margin); + scrollable_panel_sizer->Add(heading_sizer, 0, wxEXPAND | wxTOP, FromDIP(section.has_value() ? 10 : 6)); + section = row.section; + } + auto desc = new wxStaticText(scrollable_panel, wxID_ANY, description(row)); + desc->SetFont(Label::Body_14); + desc->SetForegroundColour(DESIGN_GRAY900_COLOR); + auto chord_label = [&](long style) { + auto label = new wxStaticText(scrollable_panel, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, style); + label->SetFont(Label::Head_14); + label->SetForegroundColour(DESIGN_GRAY900_COLOR); + return label; + }; + wxStaticText* modifiers = chord_label(0); + wxStaticText* key = chord_label(wxALIGN_CENTRE_HORIZONTAL); // a single key is centred in its column + desc->Wrap(m_row_text_width - set_chord_labels(modifiers, key, key_parts(row))); + + wxBoxSizer* buttons = new wxBoxSizer(wxHORIZONTAL); + if (const MouseAction* mouse = std::get_if(&row.content)) { + auto settings = icon_button("settings", _L("Preferences")); + settings->Bind(wxEVT_BUTTON, [this, preference = mouse->preference](wxCommandEvent&) { open_mouse_preferences(preference); }); + buttons->Add(settings, 0, wxALIGN_CENTRE_VERTICAL); + m_preference_rows.push_back({ mouse->preference, desc }); + } else if (const Shortcut* editable = std::get_if(&row.content)) { + const Shortcut shortcut = *editable; + auto change = icon_button("edit", _L("Edit")); + change->Bind(wxEVT_BUTTON, [this, shortcut](wxCommandEvent&) { edit_shortcut(shortcut); }); + auto reset = icon_button("undo", _L("Reset")); + reset->Bind(wxEVT_BUTTON, [this, shortcut](wxCommandEvent&) { reset_shortcut(shortcut); }); + reset->Show(wxGetApp().shortcuts().is_customized(shortcut)); + buttons->Add(change, 0, wxALIGN_CENTRE_VERTICAL | wxRIGHT, FromDIP(6)); + buttons->Add(reset, 0, wxALIGN_CENTRE_VERTICAL | wxRESERVE_SPACE_EVEN_IF_HIDDEN); + m_editable_rows.push_back({ shortcut, desc, modifiers, key, reset }); + } else { + auto lock = new wxStaticBitmap(scrollable_panel, wxID_ANY, ScalableBitmap(scrollable_panel, "printer_status_lock", 16).bmp()); + lock->SetToolTip(_L("Not customizable")); + buttons->Add((m_edit_size.x - lock->GetBestSize().x) / 2, m_edit_size.y); // centred under the edit icons, at their height + buttons->Add(lock, 0, wxALIGN_CENTRE_VERTICAL); + } + if (const int used = buttons->GetMinSize().x; used < m_buttons_width) // a box sizer recomputes its own min size, so pad it + buttons->AddSpacer(m_buttons_width - used); + + wxBoxSizer* row_sizer = new wxBoxSizer(wxHORIZONTAL); + row_sizer->AddSpacer(row_margin); + row_sizer->Add(desc, 1, wxALIGN_CENTRE_VERTICAL); + row_sizer->AddSpacer(gap); + row_sizer->Add(modifiers, 0, wxALIGN_CENTRE_VERTICAL); + row_sizer->Add(key, 0, wxALIGN_CENTRE_VERTICAL); + row_sizer->Add(buttons, 0, wxALIGN_CENTRE_VERTICAL | wxLEFT, gap); + row_sizer->AddSpacer(title_margin); + scrollable_panel_sizer->Add(row_sizer, 0, wxEXPAND | wxTOP, FromDIP(4)); } - - scrollable_panel_sizer->Add(grid_sizer, 1, wxEXPAND | wxALL, FromDIP(20)); + scrollable_panel_sizer->AddSpacer(title_margin); scrollable_panel->SetSizer(scrollable_panel_sizer); main_sizer->Add(scrollable_panel, 1, wxEXPAND); @@ -367,5 +327,227 @@ wxPanel* KBShortcutsDialog::create_page(wxWindow* parent, const ShortcutsItem& s return main_page; } +void KBShortcutsDialog::edit_shortcut(Shortcut shortcut) +{ + ShortcutCaptureDialog dlg(this, shortcut); + if (dlg.ShowModal() != wxID_OK) + return; + const wxString question = wxString::Format(_L("%s is assigned to %s. Reassign it to %s?"), + join_keys(to_wx(dlg.chord().display_parts())), shortcut_names(dlg.conflicts()), _(shortcut_info(shortcut).name)); + if (!take_chord_from(shortcut, dlg.conflicts(), question)) + return; + wxGetApp().shortcuts().bind(shortcut, dlg.chord()); + apply_bindings(); +} + +void KBShortcutsDialog::reset_shortcut(Shortcut shortcut) +{ + const std::vector conflicts = wxGetApp().shortcuts().conflicts(shortcut, shortcut_info(shortcut).default_chord); + const wxString question = wxString::Format(_L("The default %s is assigned to %s. Reassign it to %s?"), + join_keys(to_wx(shortcut_info(shortcut).default_chord.display_parts())), shortcut_names(conflicts), _(shortcut_info(shortcut).name)); + if (!take_chord_from(shortcut, conflicts, question)) + return; + wxGetApp().shortcuts().reset(shortcut); + apply_bindings(); +} + +bool KBShortcutsDialog::take_chord_from(Shortcut shortcut, const std::vector& conflicts, const wxString& question) +{ + if (conflicts.empty()) + return true; + MessageDialog confirm(this, question, _(shortcut_info(shortcut).name), wxICON_QUESTION | wxOK | wxCANCEL); + if (confirm.ShowModal() != wxID_OK) + return false; + for (Shortcut other : conflicts) + wxGetApp().shortcuts().bind(other, KeyChord{}); + return true; +} + +void KBShortcutsDialog::apply_bindings() +{ + const ShortcutRegistry& shortcuts = wxGetApp().shortcuts(); + std::set pages; + for (const EditableRow& row : m_editable_rows) { + const int chord_width = set_chord_labels(row.modifiers, row.key, to_wx(shortcuts.binding(row.shortcut).display_parts())); + row.description->SetLabel(_(shortcut_info(row.shortcut).name)); + row.description->Wrap(m_row_text_width - chord_width); + row.reset->Show(shortcuts.is_customized(row.shortcut)); + pages.insert(row.key->GetParent()); + } + for (wxWindow* page : pages) + page->Layout(); + wxGetApp().on_shortcuts_changed(); +} + +int KBShortcutsDialog::set_chord_labels(wxStaticText* modifiers, wxStaticText* key, std::vector parts) +{ + const wxString last = parts.empty() ? wxString() : parts.back(); + if (!parts.empty()) + parts.pop_back(); + modifiers->SetLabel(parts.empty() ? wxString() : join_keys(parts) + " + "); + modifiers->Show(!parts.empty()); + key->SetLabel(last); + const int key_width = last.length() == 1 ? m_key_slot : key->GetBestSize().x; + key->SetMinSize(wxSize(key_width, -1)); + return (parts.empty() ? 0 : modifiers->GetBestSize().x) + key_width; +} + +void KBShortcutsDialog::open_mouse_preferences(const char* preference) +{ + // Opened from Preferences > Control, the settings are right behind this dialog. + if (auto preferences = dynamic_cast(GetParent()); preferences != nullptr) { + // Runs once this dialog has closed and the focus is back in Preferences. + preferences->CallAfter([preferences, preference] { preferences->select_tab(PreferencesTab::Control, preference); }); + EndModal(wxID_OK); + return; + } + wxGetApp().open_preferences(PreferencesTab::Control, preference); + // A language change rebuilds the main frame, taking this dialog with it. + if (GetParent() != wxGetApp().mainframe) { + EndModal(wxID_CANCEL); + return; + } + for (const PreferenceRow& row : m_preference_rows) + row.description->SetLabel(_(mouse_action(row.preference))); +} + +ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut) + : DPIDialog(parent, wxID_ANY, _(shortcut_info(shortcut).name), wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE) + , m_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. + 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_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 }) { + int extent = 0; + GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font()); + width = std::max(width, extent); + } + width = std::min(width, FromDIP(550)); + + auto prompt = new Label(this, wxGetApp().normal_font(), wxString::Format(_L("Press the new shortcut for\n\"%s\""), _(shortcut_info(shortcut).name)), LB_AUTO_WRAP); + prompt->SetMinSize(wxSize(width, -1)); + sizer->Add(prompt, 0, wxALL, FromDIP(20)); + + // Keyboard focus stays on this box so the buttons never receive the key presses. + const wxColour box_colour = StateColor::darkModeColorFor(*wxWHITE); + StaticBox* capture = new StaticBox(this, wxID_ANY, wxDefaultPosition, wxSize(width, FromDIP(60)), wxWANTS_CHARS); + capture->SetCornerRadius(FromDIP(4)); + capture->SetBorderColorNormal(StateColor::darkModeColorFor(wxColour("#009688"))); // the focused-input colour, since the box always has the focus + capture->SetBackgroundColorNormal(box_colour); + capture->SetBackgroundColour(box_colour); + wxBoxSizer* capture_sizer = new wxBoxSizer(wxVERTICAL); + m_chord_label = new wxStaticText(capture, wxID_ANY, join_keys(to_wx(wxGetApp().shortcuts().binding(shortcut).display_parts()))); + m_chord_label->SetFont(::Label::Head_14); + m_chord_label->SetBackgroundColour(box_colour); + capture_sizer->AddStretchSpacer(); + capture_sizer->Add(m_chord_label, 0, wxALIGN_CENTER); + capture_sizer->AddStretchSpacer(); + capture->SetSizer(capture_sizer); + capture->Bind(wxEVT_KEY_DOWN, &ShortcutCaptureDialog::on_key, this); + capture->Bind(wxEVT_CHAR, &ShortcutCaptureDialog::on_char, this); + capture->Bind(wxEVT_LEFT_DOWN, [capture](wxMouseEvent&) { capture->SetFocus(); }); + sizer->Add(capture, 0, wxLEFT | wxRIGHT | wxEXPAND, FromDIP(20)); + + m_status = new Label(this, wxGetApp().normal_font(), m_hint, LB_AUTO_WRAP); + m_status->SetMinSize(wxSize(width, 3 * m_status->GetCharHeight())); // room for three lines, so the dialog keeps its size while keys are tried + m_status_colour = m_status->GetForegroundColour(); + sizer->Add(m_status, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(20)); + + auto dlg_btns = new DialogButtons(this, {"Unbind", "OK", "Cancel"}, "", 1 /*left_aligned*/); + dlg_btns->GetFIRST()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + m_chord = KeyChord{}; + m_conflicts.clear(); + EndModal(wxID_OK); + }); + dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); + m_ok = dlg_btns->GetOK(); + m_ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_OK); }); + m_ok->Enable(false); + sizer->Add(dlg_btns, 0, wxEXPAND | wxTOP, FromDIP(10)); + + SetSizerAndFit(sizer); + CenterOnParent(); + wxGetApp().UpdateDlgDarkUI(this); + capture->CallAfter([capture]() { capture->SetFocus(); }); +} + +void ShortcutCaptureDialog::on_dpi_changed(const wxRect& suggested_rect) +{ + Layout(); + Fit(); +} + +void ShortcutCaptureDialog::on_key(wxKeyEvent& evt) +{ + if (!evt.HasAnyModifiers()) { + if (evt.GetKeyCode() == WXK_ESCAPE) { + EndModal(wxID_CANCEL); + return; + } + if (evt.GetKeyCode() == WXK_RETURN || evt.GetKeyCode() == WXK_NUMPAD_ENTER) { + if (m_ok->IsEnabled()) + EndModal(wxID_OK); + return; + } + } + const KeyChord chord = KeyChord::from_event(evt); + if (!chord.valid()) + return; + if (chord.needs_char_event()) { + evt.Skip(); + return; + } + record(chord); +} + +void ShortcutCaptureDialog::on_char(wxKeyEvent& evt) +{ + const KeyChord chord = KeyChord::from_event(evt); + if (chord.is_punctuation()) + record(chord); +} + +void ShortcutCaptureDialog::record(const KeyChord& chord) +{ + m_chord = chord; + m_chord_label->SetLabel(join_keys(to_wx(chord.display_parts()))); + m_chord_label->GetParent()->Layout(); + + auto reject = [this](const wxString& reason) { + m_status->SetForegroundColour(ERROR_COLOUR); + m_status->SetLabel(reason); + m_conflicts.clear(); + m_ok->Enable(false); + }; + const bool global = (shortcut_info(m_shortcut).contexts & context_bit(ShortcutContext::Global)) != 0; + if (global && !chord.is_menu_accelerator()) { + reject(m_rejection); + } else if (const std::optional owner = wxGetApp().shortcuts().step_owner(m_shortcut, chord); owner.has_value()) { + reject(wxString::Format(_L("Already used as a step of %s."), _(shortcut_info(*owner).name))); + } else { + m_conflicts = wxGetApp().shortcuts().conflicts(m_shortcut, chord); + m_status->SetForegroundColour(m_status_colour); + if (m_conflicts.empty()) + m_status->SetLabel(m_hint); + else + m_status->SetLabel(wxString::Format(_L("Already assigned to %s. Press OK to reassign it."), shortcut_names(m_conflicts))); + m_ok->Enable(true); + } + m_status->Refresh(); // a colour change alone does not repaint + Layout(); + Fit(); +} + } // namespace GUI } // namespace Slic3r diff --git a/src/slic3r/GUI/KBShortcutsDialog.hpp b/src/slic3r/GUI/KBShortcutsDialog.hpp index 9d49cd35c5..3091726147 100644 --- a/src/slic3r/GUI/KBShortcutsDialog.hpp +++ b/src/slic3r/GUI/KBShortcutsDialog.hpp @@ -3,53 +3,120 @@ #include #include +#include #include #include "GUI_Utils.hpp" +#include "Shortcuts.hpp" #include "wxExtensions.hpp" #include +class Button; +class Label; +class TabCtrl; + namespace Slic3r { namespace GUI { -class Select -{ -public: - int m_index; - wxWindow *m_tab_button; - wxWindow *m_tab_text; -}; -WX_DECLARE_HASH_MAP(int, Select *, wxIntegerHash, wxIntegerEqual, SelectHash); - +// Lists every shortcut per context and lets the user rebind the assignable ones. class KBShortcutsDialog : public DPIDialog { - typedef std::pair Shortcut; - typedef std::vector Shortcuts; - typedef std::pair, Shortcuts> ShortcutsItem; - typedef std::vector ShortcutsVec; + // A key the user cannot rebind. + struct FixedKey + { + std::vector keys; // modifier names and the key, shown joined with "+" + const char* description; // untranslated + }; + // A mouse button whose camera action is chosen in Preferences. + struct MouseAction + { + wxString button; + const char* preference; // AppConfig key of the action + }; + struct Row + { + std::variant content; + ShortcutSection section; + }; + struct Page + { + wxString title; + wxString caption; // when the page's keys apply + ShortcutContext context; + std::vector rows; + }; + struct EditableRow + { + Shortcut shortcut; + wxStaticText* description; + wxStaticText* modifiers; + wxStaticText* key; + ScalableButton* reset; + }; + struct PreferenceRow + { + const char* preference; + wxStaticText* description; + }; - ShortcutsVec m_full_shortcuts; - ScalableBitmap m_logo_bmp; - wxStaticBitmap* m_header_bitmap; - std::vector m_pages; + std::vector m_pages; + std::vector m_editable_rows; + std::vector m_preference_rows; + // Row geometry, measured once and shared by every page. + wxSize m_edit_size; // an edit or reset icon + int m_buttons_width = 0; // every row's buttons column, so the right-aligned keys share an edge + int m_row_text_width = 0; // what a description and its chord share; the description wraps at the rest + int m_key_slot = 0; // width of the widest single key, the column single keys line up in + + TabCtrl* m_tabs; + wxSimplebook* m_simplebook; public: - KBShortcutsDialog(); - wxWindow* create_button(int id, wxString text); - void OnSelectTabel(wxCommandEvent &event); - wxPanel *m_panel_selects; - wxBoxSizer *m_sizer_right; - wxSimplebook *m_simplebook; - wxBoxSizer * m_sizer_body; - SelectHash m_hash_selector; + KBShortcutsDialog(wxWindow* parent, ShortcutContext page); // opens on the page of that context protected: void on_dpi_changed(const wxRect &suggested_rect) override; private: - void fill_shortcuts(); - wxPanel* create_header(wxWindow* parent, const wxFont& bold_font); - wxPanel* create_page(wxWindow* parent, const ShortcutsItem& shortcuts, const wxFont& font, const wxFont& bold_font); + void fill_pages(); + wxPanel* create_page(wxWindow* parent, const Page& page); + void edit_shortcut(Shortcut shortcut); + void reset_shortcut(Shortcut shortcut); + // Asks question before unbinding conflicts; false when the user declined. + bool take_chord_from(Shortcut shortcut, const std::vector& conflicts, const wxString& question); + void apply_bindings(); // refreshes the rows and pushes the change to the rest of the app + // Puts a chord on a row's two labels, a single key in the shared column, and returns the width the chord takes. + int set_chord_labels(wxStaticText* modifiers, wxStaticText* key, std::vector parts); + void open_mouse_preferences(const char* preference); +}; + +// Records one key chord for a shortcut, warning about the shortcuts it would take the chord from. +class ShortcutCaptureDialog : public DPIDialog +{ +public: + ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut); + + // Valid after ShowModal() returned wxID_OK; an invalid chord means "unbind". + const KeyChord& chord() const { return m_chord; } + const std::vector& conflicts() const { return m_conflicts; } + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + +private: + void on_key(wxKeyEvent& evt); + void on_char(wxKeyEvent& evt); + void record(const KeyChord& chord); + + Shortcut m_shortcut; + KeyChord m_chord; + std::vector m_conflicts; + wxStaticText* m_chord_label; + wxString m_hint; // what m_status shows while there is nothing to warn about + wxString m_rejection; // what it shows for a key a Global shortcut cannot use + Label* m_status; + wxColour m_status_colour; + Button* m_ok; }; } // namespace GUI diff --git a/src/slic3r/GUI/KeyChord.cpp b/src/slic3r/GUI/KeyChord.cpp new file mode 100644 index 0000000000..cd746d2f92 --- /dev/null +++ b/src/slic3r/GUI/KeyChord.cpp @@ -0,0 +1,314 @@ +#include "KeyChord.hpp" + +#include "GUI.hpp" +#include "I18N.hpp" + +#include + +#include +#include +#include + +namespace Slic3r { namespace GUI { + +namespace { + +constexpr int BINDABLE_MODIFIERS = wxMOD_CONTROL | wxMOD_SHIFT | wxMOD_ALT | wxMOD_RAW_CONTROL; + +struct KeyName +{ + int key; + const char* name; // canonical name, as wx parses it + const char* alias; // accepted when parsing; nullptr when there is none + const char* label; // translation key for KeyChord::display(); nullptr when name is it +}; + +constexpr std::array special_keys{{ + { WXK_BACK, L_CONTEXT("Backspace", "Keyboard Shortcut"), "Back", nullptr }, + { WXK_TAB, L_CONTEXT("Tab", "Keyboard Shortcut"), nullptr, nullptr }, + { WXK_RETURN, L_CONTEXT("Enter", "Keyboard Shortcut"), "Return", nullptr }, + { WXK_ESCAPE, L_CONTEXT("Esc", "Keyboard Shortcut"), "Escape", nullptr }, + { WXK_SPACE, L_CONTEXT("Space", "Keyboard Shortcut"), nullptr, nullptr }, + { WXK_DELETE, L_CONTEXT("Del", "Keyboard Shortcut"), "Delete", nullptr }, + { WXK_INSERT, L_CONTEXT("Ins", "Keyboard Shortcut"), "Insert", nullptr }, + { WXK_HOME, L_CONTEXT("Home", "Keyboard Shortcut"), nullptr, nullptr }, + { WXK_END, L_CONTEXT("End", "Keyboard Shortcut"), nullptr, nullptr }, + { WXK_PAGEUP, L_CONTEXT("PgUp", "Keyboard Shortcut"), "PageUp", nullptr }, + { WXK_PAGEDOWN, L_CONTEXT("PgDn", "Keyboard Shortcut"), "PageDown", nullptr }, + // Displayed as "Arrow Left" and so on, which is what the catalogs translate. + { WXK_LEFT, "Left", nullptr, L_CONTEXT("Arrow Left", "Keyboard Shortcut") }, + { WXK_RIGHT, "Right", nullptr, L_CONTEXT("Arrow Right", "Keyboard Shortcut") }, + { WXK_UP, "Up", nullptr, L_CONTEXT("Arrow Up", "Keyboard Shortcut") }, + { WXK_DOWN, "Down", nullptr, L_CONTEXT("Arrow Down", "Keyboard Shortcut") }, +}}; + +bool equals_ignoring_case(const std::string& a, const char* b) +{ + if (b == nullptr) + return false; + size_t i = 0; + for (; i < a.size() && b[i] != '\0'; ++i) + if (std::tolower(static_cast(a[i])) != std::tolower(static_cast(b[i]))) + return false; + return i == a.size() && b[i] == '\0'; +} + +bool is_letter(int key) { return key >= 'A' && key <= 'Z'; } +bool is_digit(int key) { return key >= '0' && key <= '9'; } +bool is_printable(int key) { return key > ' ' && key < 127; } +bool is_symbol(int key) { return is_printable(key) && !std::isalnum(key); } +bool is_function_key(int key) { return key >= WXK_F1 && key <= WXK_F24; } + +bool is_special(int key) +{ + if (is_function_key(key)) + return true; + return std::any_of(special_keys.begin(), special_keys.end(), [key](const KeyName& k) { return k.key == key; }); +} + +std::string special_key_name(int key) +{ + if (is_function_key(key)) + return "F" + std::to_string(key - WXK_F1 + 1); + for (const KeyName& k : special_keys) + if (k.key == key) + return k.name; + return {}; +} + +std::string special_key_label(int key) +{ + for (const KeyName& k : special_keys) + if (k.key == key) + return _u8L_CONTEXT(k.label != nullptr ? k.label : k.name, "Keyboard Shortcut"); + return special_key_name(key); +} + +int parse_key(const std::string& text) +{ + if (text.size() == 1) { + const int key = static_cast(text[0]); + return is_printable(key) ? std::toupper(key) : WXK_NONE; + } + for (const KeyName& k : special_keys) + if (equals_ignoring_case(text, k.name) || equals_ignoring_case(text, k.alias)) + return k.key; + if ((text[0] == 'F' || text[0] == 'f') && text.size() <= 3 && std::all_of(text.begin() + 1, text.end(), [](char c) { return std::isdigit(static_cast(c)); })) { + const int n = std::stoi(text.substr(1)); + if (n >= 1 && n <= 24) + return WXK_F1 + n - 1; + } + return WXK_NONE; +} + +int parse_modifier(const std::string& text) +{ + if (equals_ignoring_case(text, "Ctrl") || equals_ignoring_case(text, "Control") || equals_ignoring_case(text, "Cmd") || equals_ignoring_case(text, "Command")) + return wxMOD_CONTROL; + if (equals_ignoring_case(text, "Shift")) + return wxMOD_SHIFT; + if (equals_ignoring_case(text, "Alt") || equals_ignoring_case(text, "Option")) + return wxMOD_ALT; + if (equals_ignoring_case(text, "RawCtrl")) + return wxMOD_RAW_CONTROL; + return wxMOD_NONE; +} + +// Numpad keys act as their main-keyboard counterparts, so one binding covers both. +int fold_numpad(int key) +{ + if (key >= WXK_NUMPAD0 && key <= WXK_NUMPAD9) + return '0' + (key - WXK_NUMPAD0); + switch (key) { + case WXK_NUMPAD_ENTER: return WXK_RETURN; + case WXK_NUMPAD_SPACE: return WXK_SPACE; + case WXK_NUMPAD_TAB: return WXK_TAB; + case WXK_NUMPAD_HOME: return WXK_HOME; + case WXK_NUMPAD_END: return WXK_END; + case WXK_NUMPAD_PAGEUP: return WXK_PAGEUP; + case WXK_NUMPAD_PAGEDOWN: return WXK_PAGEDOWN; + case WXK_NUMPAD_LEFT: return WXK_LEFT; + case WXK_NUMPAD_RIGHT: return WXK_RIGHT; + case WXK_NUMPAD_UP: return WXK_UP; + case WXK_NUMPAD_DOWN: return WXK_DOWN; + case WXK_NUMPAD_INSERT: return WXK_INSERT; + case WXK_NUMPAD_DELETE: return WXK_DELETE; + case WXK_NUMPAD_ADD: return '+'; + case WXK_NUMPAD_SUBTRACT: return '-'; + case WXK_NUMPAD_MULTIPLY: return '*'; + case WXK_NUMPAD_DIVIDE: return '/'; + case WXK_NUMPAD_DECIMAL: return '.'; + case WXK_NUMPAD_EQUAL: return '='; + default: return key; + } +} + +// Modifiers in the order the text forms list them; wxMOD_RAW_CONTROL is wxMOD_CONTROL off macOS. +#ifdef __APPLE__ +constexpr std::array MODIFIER_ORDER{ wxMOD_CONTROL, wxMOD_SHIFT, wxMOD_ALT, wxMOD_RAW_CONTROL }; +#else +constexpr std::array MODIFIER_ORDER{ wxMOD_CONTROL, wxMOD_SHIFT, wxMOD_ALT }; +#endif + +const char* canonical_modifier_prefix(int modifier) +{ + if (modifier == wxMOD_CONTROL) + return "Ctrl+"; + if (modifier == wxMOD_SHIFT) + return "Shift+"; + if (modifier == wxMOD_ALT) + return "Alt+"; + return "RawCtrl+"; +} + +template +std::string join_modifiers(int modifiers, Prefix prefix) +{ + std::string out; + for (int modifier : MODIFIER_ORDER) + if (modifiers & modifier) + out += prefix(modifier); + return out; +} + +} // namespace + +bool KeyChord::is_punctuation() const { return is_symbol(key) && modifiers == wxMOD_NONE; } + +bool KeyChord::needs_char_event() const { return is_symbol(key) && (modifiers & ~wxMOD_SHIFT) == 0; } + +bool KeyChord::is_menu_accelerator() const +{ + return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE)); +} + +std::string KeyChord::to_string() const +{ + if (!valid()) + return {}; + std::string out = join_modifiers(modifiers, canonical_modifier_prefix); + if (is_printable(key)) + out += char(key); + else + out += special_key_name(key); + return out; +} + +std::optional KeyChord::parse(const std::string& text) +{ + if (text.empty()) + return std::nullopt; + + // The key is whatever follows the last separator; a trailing '+' is the '+' key itself. + size_t key_start = text.size() - 1; + if (text.back() != '+') { + const size_t sep = text.rfind('+'); + key_start = sep == std::string::npos ? 0 : sep + 1; + } + KeyChord chord; + chord.key = parse_key(text.substr(key_start)); + if (chord.key == WXK_NONE) + return std::nullopt; + + const std::string prefix = key_start == 0 ? std::string() : text.substr(0, key_start - 1); + size_t begin = 0; + while (begin < prefix.size()) { + size_t end = prefix.find('+', begin); + if (end == std::string::npos) + end = prefix.size(); + const int modifier = parse_modifier(prefix.substr(begin, end - begin)); + if (modifier == wxMOD_NONE) + return std::nullopt; + chord.modifiers |= modifier; + begin = end + 1; + } + if (is_letter(chord.key) || is_digit(chord.key) || !is_printable(chord.key) || chord.modifiers == wxMOD_NONE) + return chord; + // Shift is folded into the character for punctuation, so "Shift+/" is not accepted. + return (chord.modifiers & wxMOD_SHIFT) ? std::nullopt : std::optional(chord); +} + +std::string KeyChord::display() const +{ + std::string out; + for (const std::string& part : display_parts()) + out += (out.empty() ? "" : "+") + part; + return out; +} + +std::vector KeyChord::display_parts() const +{ + std::vector parts; + if (!valid()) + return parts; + for (int modifier : MODIFIER_ORDER) + if (modifiers & modifier) + parts.push_back(modifier_name(modifier)); + parts.push_back(is_printable(key) ? std::string(1, char(key)) : special_key_label(key)); + return parts; +} + +std::string KeyChord::modifier_prefix(int modifier) +{ + if (modifier == wxMOD_CONTROL) + return shortkey_ctrl_prefix(); + if (modifier == wxMOD_SHIFT) + return _u8L("Shift+"); + if (modifier == wxMOD_ALT) + return shortkey_alt_prefix(); +#ifdef __APPLE__ + if (modifier == wxMOD_RAW_CONTROL) + return u8"⌃+"; +#endif + return {}; +} + +// The catalogue holds the "Ctrl+" prefixes, so the bare name is the prefix without its "+" +// and any space before it ("Strg +" in German). +std::string KeyChord::modifier_name(int modifier) +{ + std::string name = modifier_prefix(modifier); + if (!name.empty() && name.back() == '+') + name.pop_back(); + while (!name.empty() && name.back() == ' ') + name.pop_back(); + return name; +} + +wxAcceleratorEntry KeyChord::to_accelerator_entry(int command) const +{ + int flags = wxACCEL_NORMAL; + if (modifiers & wxMOD_CONTROL) + flags |= wxACCEL_CTRL; + if (modifiers & wxMOD_SHIFT) + flags |= wxACCEL_SHIFT; + if (modifiers & wxMOD_ALT) + flags |= wxACCEL_ALT; +#ifdef __APPLE__ + if (modifiers & wxMOD_RAW_CONTROL) + flags |= wxACCEL_RAW_CTRL; +#endif + return wxAcceleratorEntry(flags, key, command); +} + +KeyChord KeyChord::from_event(const wxKeyEvent& evt) +{ + KeyChord chord; + chord.modifiers = evt.GetModifiers() & BINDABLE_MODIFIERS; + int key = fold_numpad(evt.GetKeyCode()); + + if (evt.GetEventType() == wxEVT_CHAR) { + if (key >= 1 && key <= 26 && evt.ControlDown()) + key = 'A' + key - 1; // Ctrl+letter arrives as the control character + else if (is_symbol(key)) + chord.modifiers &= ~wxMOD_SHIFT; // the character already reflects Shift + } + if (key >= 'a' && key <= 'z') + key -= 'a' - 'A'; + + if (is_printable(key) || is_special(key)) + chord.key = key; + return chord; +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/KeyChord.hpp b/src/slic3r/GUI/KeyChord.hpp new file mode 100644 index 0000000000..c7884409eb --- /dev/null +++ b/src/slic3r/GUI/KeyChord.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +class wxKeyEvent; + +namespace Slic3r { namespace GUI { + +// One key press: a key code as wxEVT_KEY_DOWN reports it (letters upper-case, numpad keys +// folded onto their main-keyboard equivalents) plus the wxMOD_* modifiers held with it. +// Printable punctuation is stored as the character it produces, so "+" means the key that +// types "+" on the user's layout. +struct KeyChord +{ + int key = WXK_NONE; + int modifiers = wxMOD_NONE; + + bool valid() const { return key != WXK_NONE; } + bool operator==(const KeyChord& other) const { return key == other.key && modifiers == other.modifiers; } + bool operator!=(const KeyChord& other) const { return !(*this == other); } + + // Bare printable keys other than letters and digits are matched on wxEVT_CHAR, because + // only the char event knows which character a key produces under the active layout. + bool is_punctuation() const; + // True for a printable non-alphanumeric key pressed with nothing but Shift, which only the + // char event that follows can resolve. + bool needs_char_event() const; + // 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; + + // Platform-neutral text ("Ctrl+Shift+S") for persistence and wx accelerator strings. + std::string to_string() const; + static std::optional parse(const std::string& text); + + // Text for menus, tooltips and the shortcuts dialog, with translated modifier names and the + // command and option glyphs on macOS. + std::string display() const; + // The pieces display() joins with "+": the modifier names, then the key name. + std::vector display_parts() const; + // Translated text of one wxMOD_* modifier, as a "Ctrl+" prefix or the bare "Ctrl" name. + static std::string modifier_prefix(int modifier); + static std::string modifier_name(int modifier); + + wxAcceleratorEntry to_accelerator_entry(int command) const; + + // Builds the chord a key event describes, or an invalid chord for pure modifier presses and + // keys outside the bindable set. wxEVT_CHAR events are normalized to the key codes + // wxEVT_KEY_DOWN reports for letters, digits and special keys. + static KeyChord from_event(const wxKeyEvent& evt); +}; + +struct KeyChordHash +{ + size_t operator()(const KeyChord& chord) const { return std::hash()((static_cast(chord.modifiers) << 32) | unsigned(chord.key)); } +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 288a666901..38ef3141d6 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -56,6 +56,7 @@ #include "../Utils/PrintHost.hpp" #include "GUI_App.hpp" +#include "Shortcuts.hpp" #include "UnsavedChangesDialog.hpp" #include "PublishSettingsDialog.hpp" #include "MsgDialog.hpp" @@ -310,17 +311,6 @@ static wxIcon main_frame_icon(GUI_App::EAppMode app_mode) wxDEFINE_EVENT(EVT_SYNC_CLOUD_PRESET, SimpleEvent); -#ifdef __APPLE__ -static const wxString ctrl = ("Ctrl+"); -// FIXME: maybe should be using GUI::shortkey_ctrl_prefix() or equivalent? -static const std::string ctrl_t = u8"\u2318+"; // "⌘" (Mac Command) -#else -static const wxString ctrl = _L("Ctrl+"); -// FIXME: maybe should be using GUI::shortkey_ctrl_prefix() or equivalent? -static const wxString ctrl_t = ctrl; -#endif -static const wxString shift = _L("Shift+"); - MainFrame::MainFrame() : DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_STYLE, "mainframe") , m_printhost_queue_dlg(new PrintHostQueueDialog(this)) @@ -730,69 +720,8 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ } return;} #endif - // Orca: open the speed dial from any page with a bare Space. Only when no modifier is held (so - // editing shortcuts like Ctrl+Shift+Space in the canvas still reach it) and the focused window - // doesn't use Space to activate itself (buttons, checkboxes, list/choice controls, text fields), - // so a bare Space there still clicks/toggles instead of being hijacked. Gated by a preference - // (default on) so users can hand Space back to the focused control entirely. - if (wxGetApp().app_config->get_bool("enable_speed_dial") && !evt.CmdDown() && !evt.ShiftDown() && - !evt.AltDown() && evt.GetKeyCode() == WXK_SPACE) { - if (focus_keeps_space(wxWindow::FindFocus())) { - evt.Skip(); // let the focused control keep Space - return; - } - // Defer out of the native key-event stack: open_speed_dial() may create a WebView and - // run script, the same window work the codebase avoids doing on native callbacks. - this->CallAfter([] { wxGetApp().open_speed_dial(); }); - return; - } - if (evt.CmdDown() && evt.GetKeyCode() == 'R') { if (m_slice_enable) { wxGetApp().plater()->update(true, true); wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); this->m_tabpanel->SelectPageByName(TAB_ID_PREVIEW); } return; } - if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'G') { - m_plater->apply_background_progress(); - m_print_enable = get_enable_print_status(); - m_print_btn->Enable(m_print_enable); - if (m_print_enable) { - if (wxGetApp().preset_bundle->use_bbl_network() || wxGetApp().app_config->get_bool("use_printer_agents")) - wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_PRINT_PLATE)); - else - wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SEND_GCODE)); - } + if (!handle_global_shortcut(KeyChord::from_event(evt))) evt.Skip(); - return; - } - else if (evt.CmdDown() && evt.GetKeyCode() == 'G') { if (can_export_gcode()) { wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_EXPORT_SLICED_FILE)); } evt.Skip(); return; } - if (evt.CmdDown() && evt.GetKeyCode() == 'J') { m_printhost_queue_dlg->Show(); return; } - if (evt.CmdDown() && evt.GetKeyCode() == 'N') { m_plater->new_project(); return;} - if (evt.CmdDown() && evt.GetKeyCode() == 'O') { m_plater->load_project(); return;} - if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'S') { if (can_save_as()) m_plater->save_project(true); return;} - else if (evt.CmdDown() && evt.GetKeyCode() == 'S') { if (can_save()) m_plater->save_project(); return;} - if (evt.CmdDown() && evt.GetKeyCode() == 'F') { - if (m_plater && is_prepare_or_preview_tab()) { - m_plater->sidebar().can_search(); - } - } -#ifdef __APPLE__ - if (evt.CmdDown() && evt.GetKeyCode() == ',') -#else - if (evt.CmdDown() && evt.GetKeyCode() == 'P') -#endif - { - // Orca: Use GUI_App::open_preferences instead of direct call so windows associations are updated on exit - wxGetApp().open_preferences(); - plater()->get_current_canvas3D()->force_set_focus(); - return; - } - - if (evt.CmdDown() && evt.GetKeyCode() == 'I' && !evt.ShiftDown()) { - if (!can_add_models()) return; - if (m_plater) { m_plater->add_file(); } - return; - } - if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'E') { - if (can_export_model()) publish_project(); - return; - } - evt.Skip(); }); Bind(wxEVT_SHOW, [](wxShowEvent &evt) { @@ -813,6 +742,96 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ bind_diff_dialog(); } +bool MainFrame::handle_global_shortcut(const KeyChord& chord) +{ + const std::optional shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord); + if (!shortcut.has_value()) + return false; + + switch (*shortcut) { + case Shortcut::SlicePlate: + if (m_slice_enable) { + wxGetApp().plater()->update(true, true); + wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); + m_tabpanel->SelectPageByName(TAB_ID_PREVIEW); + } + break; + case Shortcut::PrintPlate: + m_plater->apply_background_progress(); + m_print_enable = get_enable_print_status(); + m_print_btn->Enable(m_print_enable); + if (m_print_enable) { + if (wxGetApp().preset_bundle->use_bbl_network() || wxGetApp().app_config->get_bool("use_printer_agents")) + wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_PRINT_PLATE)); + else + wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SEND_GCODE)); + } + return false; + case Shortcut::ExportSlicedFile: + if (can_export_gcode()) + wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_EXPORT_SLICED_FILE)); + return false; + case Shortcut::PrintHostQueue: m_printhost_queue_dlg->Show(); break; + case Shortcut::SpeedDial: + if (!wxGetApp().app_config->get_bool("enable_speed_dial") || (chord == KeyChord{ WXK_SPACE } && focus_keeps_space(wxWindow::FindFocus()))) + return false; + // Deferred out of the native key-event stack: open_speed_dial() may create a WebView and run script. + CallAfter([] { wxGetApp().open_speed_dial(); }); + break; + case Shortcut::NewProject: m_plater->new_project(); break; + case Shortcut::OpenProject: m_plater->load_project(); break; + case Shortcut::SaveProjectAs: + if (can_save_as()) + m_plater->save_project(true); + break; + case Shortcut::SaveProject: + if (can_save()) + m_plater->save_project(); + break; + case Shortcut::Search: + if (m_plater && is_prepare_or_preview_tab()) + m_plater->sidebar().can_search(); + return false; + case Shortcut::Preferences: + // Orca: Use GUI_App::open_preferences instead of direct call so windows associations are updated on exit + wxGetApp().open_preferences(); + plater()->get_current_canvas3D()->force_set_focus(); + break; + case Shortcut::ImportModel: + if (can_add_models() && m_plater) + m_plater->add_file(); + break; + case Shortcut::Publish3mf: + if (can_export_model()) + publish_project(); + break; + case Shortcut::ShowLabels: + if (m_plater && m_plater->is_view3D_shown()) { + m_plater->show_view3D_labels(!m_plater->are_view3D_labels_shown()); + m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); + } + break; + case Shortcut::ViewDefault: + if (m_plater) { + select_view("plate"); + m_plater->get_current_canvas3D()->zoom_to_bed(); + } + break; + case Shortcut::ViewTop: select_view("top"); break; + case Shortcut::ViewBottom: select_view("bottom"); break; + case Shortcut::ViewFront: select_view("front"); break; + case Shortcut::ViewRear: select_view("rear"); break; + case Shortcut::ViewLeft: select_view("left"); break; + case Shortcut::ViewRight: select_view("right"); break; + case Shortcut::ViewPlate: + if (m_plater) + m_plater->get_current_canvas3D()->select_plate(); + break; + default: return false; + } + return true; +} + void MainFrame::bind_diff_dialog() { auto get_tab = [](Preset::Type type) { @@ -2766,13 +2785,31 @@ static const wxString sep = " - "; static const wxString sep = "\t"; #endif -static wxMenu* generate_help_menu() +wxString MainFrame::shortcut_label(const wxString& label, Shortcut shortcut, bool accelerator) +{ + const ShortcutRegistry& shortcuts = wxGetApp().shortcuts(); + if (accelerator) { + const std::string accel = shortcuts.accelerator(shortcut); + if (!accel.empty()) + return label + " " + from_u8(accel); + } + const std::string text = shortcuts.display(shortcut); + return text.empty() ? label : label + sep + from_u8(text); +} + +void MainFrame::update_shortcut_labels() +{ + for (const ShortcutMenuItem& entry : m_shortcut_menu_items) + entry.item->SetItemLabel(shortcut_label(entry.label, entry.shortcut, entry.accelerator)); +} + +wxMenu* MainFrame::generate_help_menu() { wxMenu* helpMenu = new wxMenu(); // shortcut key - append_menu_item(helpMenu, wxID_ANY, _L("Keyboard Shortcuts") + sep + "&?", _L("Show the list of keyboard shortcuts"), - [](wxCommandEvent&) { wxGetApp().keyboard_shortcuts(); }); + append_shortcut_item(helpMenu, Shortcut::KeyboardShortcuts, false, _L("Keyboard Shortcuts"), _L("Show the list of keyboard shortcuts"), + [](wxCommandEvent&) { wxGetApp().keyboard_shortcuts(ShortcutContext::Global); }); // Show Beginner's Tutorial append_menu_item(helpMenu, wxID_ANY, _L("Setup Wizard"), _L("Setup Wizard"), [](wxCommandEvent &) {wxGetApp().ShowUserGuide();}); @@ -2854,29 +2891,28 @@ static void add_common_publish_menu_items(wxMenu* publish_menu, MainFrame* mainF #endif } -static void add_common_view_menu_items(wxMenu* view_menu, MainFrame* mainFrame, std::function can_change_view) +void MainFrame::add_common_view_menu_items(wxMenu* view_menu, std::function can_change_view) { - // The camera control accelerators are captured by GLCanvas3D::on_char(). - append_menu_item(view_menu, wxID_ANY, _L("Default View") + "\t" + ctrl + "0", _L("Default View"), [mainFrame](wxCommandEvent&) { - mainFrame->select_view("plate"); - mainFrame->plater()->get_current_canvas3D()->zoom_to_bed(); + append_shortcut_item(view_menu, Shortcut::ViewDefault, true, _L("Default View"), _L("Default View"), [this](wxCommandEvent&) { + select_view("plate"); + plater()->get_current_canvas3D()->zoom_to_bed(); }, - "", nullptr, [can_change_view]() { return can_change_view(); }, mainFrame); + "", nullptr, [can_change_view]() { return can_change_view(); }, this); //view_menu->AppendSeparator(); //TRN To be shown in the main menu View->Top - append_menu_item(view_menu, wxID_ANY, _L_CONTEXT("Top", "Camera View") + "\t" + ctrl + "1", _L("Top View"), [mainFrame](wxCommandEvent&) { mainFrame->select_view("top"); }, - "", nullptr, [can_change_view]() { return can_change_view(); }, mainFrame); + append_shortcut_item(view_menu, Shortcut::ViewTop, true, _L_CONTEXT("Top", "Camera View"), _L("Top View"), [this](wxCommandEvent&) { select_view("top"); }, + "", nullptr, [can_change_view]() { return can_change_view(); }, this); //TRN To be shown in the main menu View->Bottom - append_menu_item(view_menu, wxID_ANY, _L_CONTEXT("Bottom", "Camera View") + "\t" + ctrl + "2", _L("Bottom View"), [mainFrame](wxCommandEvent&) { mainFrame->select_view("bottom"); }, - "", nullptr, [can_change_view]() { return can_change_view(); }, mainFrame); - append_menu_item(view_menu, wxID_ANY, _L_CONTEXT("Front", "Camera View") + "\t" + ctrl + "3", _L("Front View"), [mainFrame](wxCommandEvent&) { mainFrame->select_view("front"); }, - "", nullptr, [can_change_view]() { return can_change_view(); }, mainFrame); - append_menu_item(view_menu, wxID_ANY, _L_CONTEXT("Rear", "Camera View") + "\t" + ctrl + "4", _L("Rear View"), [mainFrame](wxCommandEvent&) { mainFrame->select_view("rear"); }, - "", nullptr, [can_change_view]() { return can_change_view(); }, mainFrame); - append_menu_item(view_menu, wxID_ANY, _L_CONTEXT("Left", "Camera View") + "\t" + ctrl + "5", _L("Left View"),[mainFrame](wxCommandEvent &) {mainFrame->select_view("left"); }, - "", nullptr, [can_change_view]() { return can_change_view(); }, mainFrame); - append_menu_item(view_menu, wxID_ANY, _L_CONTEXT("Right", "Camera View") + "\t" + ctrl + "6", _L("Right View"),[mainFrame](wxCommandEvent &) { mainFrame->select_view("right"); }, - "", nullptr, [can_change_view]() { return can_change_view(); }, mainFrame); + append_shortcut_item(view_menu, Shortcut::ViewBottom, true, _L_CONTEXT("Bottom", "Camera View"), _L("Bottom View"), [this](wxCommandEvent&) { select_view("bottom"); }, + "", nullptr, [can_change_view]() { return can_change_view(); }, this); + append_shortcut_item(view_menu, Shortcut::ViewFront, true, _L_CONTEXT("Front", "Camera View"), _L("Front View"), [this](wxCommandEvent&) { select_view("front"); }, + "", nullptr, [can_change_view]() { return can_change_view(); }, this); + append_shortcut_item(view_menu, Shortcut::ViewRear, true, _L_CONTEXT("Rear", "Camera View"), _L("Rear View"), [this](wxCommandEvent&) { select_view("rear"); }, + "", nullptr, [can_change_view]() { return can_change_view(); }, this); + append_shortcut_item(view_menu, Shortcut::ViewLeft, true, _L_CONTEXT("Left", "Camera View"), _L("Left View"), [this](wxCommandEvent &) { select_view("left"); }, + "", nullptr, [can_change_view]() { return can_change_view(); }, this); + append_shortcut_item(view_menu, Shortcut::ViewRight, true, _L_CONTEXT("Right", "Camera View"), _L("Right View"), [this](wxCommandEvent &) { select_view("right"); }, + "", nullptr, [can_change_view]() { return can_change_view(); }, this); } void MainFrame::init_menubar_as_editor() @@ -2895,17 +2931,17 @@ void MainFrame::init_menubar_as_editor() [this] { return m_plater != nullptr && wxGetApp().app_config->get("app", "single_instance") == "false"; }, this); #endif // New Project - append_menu_item(fileMenu, wxID_ANY, _L("New Project") + "\t" + ctrl + "N", _L("Start a new project"), + append_shortcut_item(fileMenu, Shortcut::NewProject, true, _L("New Project"), _L("Start a new project"), [this](wxCommandEvent&) { if (m_plater) m_plater->new_project(); }, "", nullptr, [this](){return can_start_new_project(); }, this); // Open Project #ifndef __APPLE__ - append_menu_item(fileMenu, wxID_ANY, _L("Open Project") + dots + "\t" + ctrl + "O", _L("Open a project file"), + append_shortcut_item(fileMenu, Shortcut::OpenProject, true, _L("Open Project") + dots, _L("Open a project file"), [this](wxCommandEvent&) { if (m_plater) m_plater->load_project(); }, "menu_open", nullptr, [this](){return can_open_project(); }, this); #else - append_menu_item(fileMenu, wxID_ANY, _L("Open Project") + dots + "\t" + ctrl + "O", _L("Open a project file"), + append_shortcut_item(fileMenu, Shortcut::OpenProject, true, _L("Open Project") + dots, _L("Open a project file"), [this](wxCommandEvent&) { if (m_plater) m_plater->load_project(); }, "", nullptr, [this](){return can_open_project(); }, this); #endif @@ -2932,21 +2968,21 @@ void MainFrame::init_menubar_as_editor() // BBS: close save project #ifndef __APPLE__ - append_menu_item(fileMenu, wxID_ANY, _L("Save Project") + "\t" + ctrl + "S", _L("Save current project to file"), + append_shortcut_item(fileMenu, Shortcut::SaveProject, true, _L("Save Project"), _L("Save current project to file"), [this](wxCommandEvent&) { if (m_plater) m_plater->save_project(); }, "menu_save", nullptr, [this](){return m_plater != nullptr && can_save(); }, this); #else - append_menu_item(fileMenu, wxID_ANY, _L("Save Project") + "\t" + ctrl + "S", _L("Save current project to file"), + append_shortcut_item(fileMenu, Shortcut::SaveProject, true, _L("Save Project"), _L("Save current project to file"), [this](wxCommandEvent&) { if (m_plater) m_plater->save_project(); }, "", nullptr, [this](){return m_plater != nullptr && can_save(); }, this); #endif #ifndef __APPLE__ - append_menu_item(fileMenu, wxID_ANY, _L("Save Project as") + dots + "\t" + ctrl + shift + "S", _L("Save current project as"), + append_shortcut_item(fileMenu, Shortcut::SaveProjectAs, true, _L("Save Project as") + dots, _L("Save current project as"), [this](wxCommandEvent&) { if (m_plater) m_plater->save_project(true); }, "menu_save", nullptr, [this](){return m_plater != nullptr && can_save_as(); }, this); #else - append_menu_item(fileMenu, wxID_ANY, _L("Save Project as") + dots + "\t" + ctrl + shift + "S", _L("Save current project as"), + append_shortcut_item(fileMenu, Shortcut::SaveProjectAs, true, _L("Save Project as") + dots, _L("Save current project as"), [this](wxCommandEvent&) { if (m_plater) m_plater->save_project(true); }, "", nullptr, [this](){return m_plater != nullptr && can_save_as(); }, this); #endif @@ -2956,11 +2992,11 @@ void MainFrame::init_menubar_as_editor() auto publish_handler = [this](wxCommandEvent&) { publish_project(); }; #ifndef __APPLE__ - append_menu_item(fileMenu, wxID_ANY, _L("Publish 3MF") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), + append_shortcut_item(fileMenu, Shortcut::Publish3mf, true, _L("Publish 3MF") + dots, _L("Export a 3MF file with the selected settings embedded"), publish_handler, "menu_publish", nullptr, [this](){return can_export_model(); }, this); #else - append_menu_item(fileMenu, wxID_ANY, _L("Publish 3MF") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), + append_shortcut_item(fileMenu, Shortcut::Publish3mf, true, _L("Publish 3MF") + dots, _L("Export a 3MF file with the selected settings embedded"), publish_handler, "", nullptr, [this](){return can_export_model(); }, this); #endif @@ -2971,13 +3007,13 @@ void MainFrame::init_menubar_as_editor() // BBS wxMenu *import_menu = new wxMenu(); #ifndef __APPLE__ - append_menu_item(import_menu, wxID_ANY, _L("Import 3MF/STL/STEP/SVG/OBJ/AMF") + dots + "\t" + ctrl + "I", _L("Load a model"), + append_shortcut_item(import_menu, Shortcut::ImportModel, true, _L("Import 3MF/STL/STEP/SVG/OBJ/AMF") + dots, _L("Load a model"), [this](wxCommandEvent&) { if (m_plater) { m_plater->add_file(); } }, "menu_import", nullptr, [this](){return can_add_models(); }, this); #else - append_menu_item(import_menu, wxID_ANY, _L("Import 3MF/STL/STEP/SVG/OBJ/AMF") + dots + "\t" + ctrl + "I", _L("Load a model"), + append_shortcut_item(import_menu, Shortcut::ImportModel, true, _L("Import 3MF/STL/STEP/SVG/OBJ/AMF") + dots, _L("Load a model"), [this](wxCommandEvent&) { if (m_plater) { m_plater->add_model(); } }, "", nullptr, [this](){return can_add_models(); }, this); #endif @@ -3009,7 +3045,7 @@ void MainFrame::init_menubar_as_editor() [this](wxCommandEvent&) { if (m_plater) m_plater->export_core_3mf(); }, "menu_export_sliced_file", nullptr, [this](){return can_export_model(); }, this); // BBS export .gcode.3mf - append_menu_item(export_menu, wxID_ANY, _L("Export plate sliced file") + dots + "\t" + ctrl + "G", _L("Export current sliced file"), + append_shortcut_item(export_menu, Shortcut::ExportSlicedFile, true, _L("Export plate sliced file") + dots, _L("Export current sliced file"), [this](wxCommandEvent&) { if (m_plater) wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_EXPORT_SLICED_FILE)); }, "menu_export_sliced_file", nullptr, [this](){return can_export_gcode(); }, this); @@ -3059,37 +3095,37 @@ void MainFrame::init_menubar_as_editor() }; #ifndef __APPLE__ // BBS undo - append_menu_item(editMenu, wxID_ANY, _L("Undo") + "\t" + ctrl + "Z", + append_shortcut_item(editMenu, Shortcut::Undo, true, _L("Undo"), _L("Undo"), [this](wxCommandEvent&) { m_plater->undo(); }, "menu_undo", nullptr, [this](){return m_plater->can_undo(); }, this); // BBS redo - append_menu_item(editMenu, wxID_ANY, _L("Redo") + "\t" + ctrl + "Y", + append_shortcut_item(editMenu, Shortcut::Redo, true, _L("Redo"), _L("Redo"), [this](wxCommandEvent&) { m_plater->redo(); }, "menu_redo", nullptr, [this](){return m_plater->can_redo(); }, this); editMenu->AppendSeparator(); // BBS Cut TODO - append_menu_item(editMenu, wxID_ANY, _L("Cut") + "\t" + ctrl + "X", + append_shortcut_item(editMenu, Shortcut::Cut, true, _L("Cut"), _L("Cut selection to clipboard"), [this](wxCommandEvent&) {m_plater->cut_selection_to_clipboard(); }, "menu_cut", nullptr, [this]() {return m_plater->can_copy_to_clipboard(); }, this); // BBS Copy - append_menu_item(editMenu, wxID_ANY, _L("Copy") + "\t" + ctrl + "C", + append_shortcut_item(editMenu, Shortcut::Copy, true, _L("Copy"), _L("Copy selection to clipboard"), [this](wxCommandEvent&) { m_plater->copy_selection_to_clipboard(); }, "menu_copy", nullptr, [this](){return m_plater->can_copy_to_clipboard(); }, this); // BBS Paste - append_menu_item(editMenu, wxID_ANY, _L("Paste") + "\t" + ctrl + "V", + append_shortcut_item(editMenu, Shortcut::Paste, true, _L("Paste"), _L("Paste clipboard"), [this](wxCommandEvent&) { m_plater->paste_from_clipboard(); }, "menu_paste", nullptr, [this](){return m_plater->can_paste_from_clipboard(); }, this); // BBS Delete selected - append_menu_item(editMenu, wxID_ANY, _L("Delete Selected") + "\t" + _L_CONTEXT("Del", "Keyboard Shortcut"), + append_shortcut_item(editMenu, Shortcut::DeleteSelected, true, _L("Delete Selected"), _L("Deletes the current selection"),[this](wxCommandEvent&) { m_plater->remove_selected(); }, "menu_remove", nullptr, [this](){return can_delete(); }, this); //BBS: delete all - append_menu_item(editMenu, wxID_ANY, _L("Delete All") + "\t" + ctrl + "D", + append_shortcut_item(editMenu, Shortcut::DeleteAll, true, _L("Delete All"), _L("Deletes all objects"),[this](wxCommandEvent&) { m_plater->delete_all_objects_from_model(); }, "menu_remove", nullptr, [this](){return can_delete_all(); }, this); editMenu->AppendSeparator(); // BBS Clone Selected - append_menu_item(editMenu, wxID_ANY, _L("Clone Selected") /*+ "\t" + ctrl + "M"*/, + append_shortcut_item(editMenu, Shortcut::CloneSelected, true, _L("Clone Selected"), _L("Clone copies of selections"),[this](wxCommandEvent&) { m_plater->clone_selection(); }, @@ -3103,7 +3139,7 @@ void MainFrame::init_menubar_as_editor() editMenu->AppendSeparator(); #else // BBS undo - append_menu_item(editMenu, wxID_ANY, _L("Undo") + sep + ctrl_t + "Z", + append_shortcut_item(editMenu, Shortcut::Undo, false, _L("Undo"), _L("Undo"), [this, handle_key_event](wxCommandEvent&) { wxKeyEvent e; e.SetEventType(wxEVT_KEY_DOWN); @@ -3115,7 +3151,7 @@ void MainFrame::init_menubar_as_editor() m_plater->undo(); }, "", nullptr, [this](){return m_plater->can_undo(); }, this); // BBS redo - append_menu_item(editMenu, wxID_ANY, _L("Redo") + sep + ctrl_t + "Y", + append_shortcut_item(editMenu, Shortcut::Redo, false, _L("Redo"), _L("Redo"), [this, handle_key_event](wxCommandEvent&) { wxKeyEvent e; e.SetEventType(wxEVT_KEY_DOWN); @@ -3128,7 +3164,7 @@ void MainFrame::init_menubar_as_editor() "", nullptr, [this](){return m_plater->can_redo(); }, this); editMenu->AppendSeparator(); // BBS Cut TODO - append_menu_item(editMenu, wxID_ANY, _L("Cut") + sep + ctrl_t + "X", + append_shortcut_item(editMenu, Shortcut::Cut, false, _L("Cut"), _L("Cut selection to clipboard"), [this, handle_key_event](wxCommandEvent&) { wxKeyEvent e; e.SetEventType(wxEVT_KEY_DOWN); @@ -3140,7 +3176,7 @@ void MainFrame::init_menubar_as_editor() m_plater->cut_selection_to_clipboard(); }, "", nullptr, [this]() {return m_plater->can_copy_to_clipboard(); }, this); // BBS Copy - append_menu_item(editMenu, wxID_ANY, _L("Copy") + sep + ctrl_t + "C", + append_shortcut_item(editMenu, Shortcut::Copy, false, _L("Copy"), _L("Copy selection to clipboard"), [this, handle_key_event](wxCommandEvent&) { wxKeyEvent e; e.SetEventType(wxEVT_KEY_DOWN); @@ -3152,7 +3188,7 @@ void MainFrame::init_menubar_as_editor() m_plater->copy_selection_to_clipboard(); }, "", nullptr, [this](){return m_plater->can_copy_to_clipboard(); }, this); // BBS Paste - append_menu_item(editMenu, wxID_ANY, _L("Paste") + sep + ctrl_t + "V", + append_shortcut_item(editMenu, Shortcut::Paste, false, _L("Paste"), _L("Paste clipboard"), [this, handle_key_event](wxCommandEvent&) { wxKeyEvent e; e.SetEventType(wxEVT_KEY_DOWN); @@ -3165,14 +3201,14 @@ void MainFrame::init_menubar_as_editor() "", nullptr, [this](){return m_plater->can_paste_from_clipboard(); }, this); #if 0 // BBS Delete selected - append_menu_item(editMenu, wxID_ANY, _L("Delete Selected") + "\t" + _L_CONTEXT("Backspace", "Keyboard Shortcut"), + append_shortcut_item(editMenu, Shortcut::DeleteSelected, true, _L("Delete Selected"), _L("Deletes the current selection"),[this](wxCommandEvent&) { m_plater->remove_selected(); }, "", nullptr, [this](){return can_delete(); }, this); #endif //BBS: delete all - append_menu_item(editMenu, wxID_ANY, _L("Delete All") + "\t" + ctrl + "D", + append_shortcut_item(editMenu, Shortcut::DeleteAll, true, _L("Delete All"), _L("Deletes all objects"),[this, handle_key_event](wxCommandEvent&) { wxKeyEvent e; e.SetEventType(wxEVT_KEY_DOWN); @@ -3185,7 +3221,7 @@ void MainFrame::init_menubar_as_editor() "", nullptr, [this](){return can_delete_all(); }, this); editMenu->AppendSeparator(); // BBS Clone Selected - append_menu_item(editMenu, wxID_ANY, _L("Clone Selected") + "\t" + ctrl + "K", + append_shortcut_item(editMenu, Shortcut::CloneSelected, true, _L("Clone Selected"), _L("Clone copies of selections"),[this, handle_key_event](wxCommandEvent&) { wxKeyEvent e; e.SetEventType(wxEVT_KEY_DOWN); @@ -3208,7 +3244,7 @@ void MainFrame::init_menubar_as_editor() #endif // BBS Select All - append_menu_item(editMenu, wxID_ANY, _L("Select All") + sep + ctrl_t + "A", + append_shortcut_item(editMenu, Shortcut::SelectAll, false, _L("Select All"), _L("Selects all objects"), [this, handle_key_event](wxCommandEvent&) { wxKeyEvent e; e.SetEventType(wxEVT_KEY_DOWN); @@ -3260,7 +3296,7 @@ void MainFrame::init_menubar_as_editor() wxMenu* viewMenu = nullptr; if (m_plater) { viewMenu = new wxMenu(); - add_common_view_menu_items(viewMenu, this, std::bind(&MainFrame::can_change_view, this)); + add_common_view_menu_items(viewMenu, std::bind(&MainFrame::can_change_view, this)); viewMenu->AppendSeparator(); //BBS perspective view @@ -3291,13 +3327,14 @@ void MainFrame::init_menubar_as_editor() []() { return wxGetApp().app_config->get_bool("auto_perspective"); }, this); viewMenu->AppendSeparator(); - append_menu_check_item(viewMenu, wxID_ANY, _L("Show &G-code Window") + sep + "C", _L("Show G-code window in Preview scene."), + wxMenuItem* gcode_window = append_menu_check_item(viewMenu, wxID_ANY, shortcut_label(_L("Show &G-code Window"), Shortcut::ToggleGcodeWindow, true), _L("Show G-code window in Preview scene."), [this](wxCommandEvent &) { wxGetApp().toggle_show_gcode_window(); m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); }, this, [this]() { return m_tabpanel->GetSelectedPageName() == TAB_ID_PREVIEW; }, []() { return wxGetApp().show_gcode_window(); }, this); + m_shortcut_menu_items.push_back({ gcode_window, Shortcut::ToggleGcodeWindow, _L("Show &G-code Window"), true }); append_menu_check_item( viewMenu, wxID_ANY, _L("Show 3D Navigator"), _L("Show 3D navigator in Prepare and Preview scene."), @@ -3325,9 +3362,10 @@ void MainFrame::init_menubar_as_editor() this); viewMenu->AppendSeparator(); - append_menu_check_item(viewMenu, wxID_ANY, _L("Show &Labels") + "\t" + ctrl + "E", _L("Show object labels in 3D scene."), + wxMenuItem* show_labels = append_menu_check_item(viewMenu, wxID_ANY, shortcut_label(_L("Show &Labels"), Shortcut::ShowLabels, true), _L("Show object labels in 3D scene."), [this](wxCommandEvent&) { m_plater->show_view3D_labels(!m_plater->are_view3D_labels_shown()); m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); }, this, [this]() { return m_plater->is_view3D_shown(); }, [this]() { return m_plater->are_view3D_labels_shown(); }, this); + m_shortcut_menu_items.push_back({ show_labels, Shortcut::ShowLabels, _L("Show &Labels"), true }); append_menu_check_item(viewMenu, wxID_ANY, _L("Show &Overhang"), _L("Show object overhang highlight in 3D scene."), [this](wxCommandEvent &) { @@ -3372,8 +3410,6 @@ void MainFrame::init_menubar_as_editor() //auto preference_item = new wxMenuItem(parent_menu, OrcaSlicerMenuPreferences + bambu_studio_id_base, _L("Preferences") + "\t" + ctrl + ",", ""); #else wxMenu* parent_menu = m_topbar->GetTopMenu(); - auto preference_item = new wxMenuItem(parent_menu, ConfigMenuPreferences + config_id_base, _L("Preferences") + "\t" + ctrl + "P", ""); - #endif #ifdef __APPLE__ @@ -3382,15 +3418,15 @@ void MainFrame::init_menubar_as_editor() parent_menu, wxID_ANY, _L(about_title), "", [](wxCommandEvent &) { Slic3r::GUI::about();}, "", nullptr, []() { return true; }, this, 0); - append_menu_item( - parent_menu, wxID_ANY, _L("Preferences") + "\t" + ctrl + ",", "", + append_shortcut_item( + parent_menu, Shortcut::Preferences, true, _L("Preferences"), "", [](wxCommandEvent &) { wxGetApp().open_preferences(); }, "", nullptr, []() { return true; }, this, 1); parent_menu->AppendSeparator(); - append_menu_item( - parent_menu, wxID_ANY, _L("Open speed dial...") + sep + "Space", "", + append_shortcut_item( + parent_menu, Shortcut::SpeedDial, false, _L("Open speed dial..."), "", [](wxCommandEvent &) { wxGetApp().open_speed_dial(); }, "", nullptr, []() { return true; }, this); //parent_menu->Insert(1, preference_item); @@ -3406,8 +3442,8 @@ void MainFrame::init_menubar_as_editor() m_topbar->AddDropDownSubMenu(viewMenu, _L("View")); //BBS add Preference - append_menu_item( - m_topbar->GetTopMenu(), wxID_ANY, _L("Preferences") + "\t" + ctrl + "P", "", + append_shortcut_item( + m_topbar->GetTopMenu(), Shortcut::Preferences, true, _L("Preferences"), "", [](wxCommandEvent &) { // Orca: Use GUI_App::open_preferences instead of direct call so windows associations are updated on exit wxGetApp().open_preferences(); @@ -3417,8 +3453,8 @@ void MainFrame::init_menubar_as_editor() auto top_menu = m_topbar->GetTopMenu(); top_menu->AppendSeparator(); - append_menu_item( - top_menu, wxID_ANY, _L("Open speed dial...") + "\t" + "Space", "", + append_shortcut_item( + top_menu, Shortcut::SpeedDial, false, _L("Open speed dial..."), "", [](wxCommandEvent &) { wxGetApp().open_speed_dial(); }, "", nullptr, []() { return true; }, this); top_menu->AppendSeparator(); @@ -3524,8 +3560,8 @@ void MainFrame::init_menubar_as_editor() #else // On Mac, the Apple menu ignores non-standard custom items, so add Preset Bundle to the File menu fileMenu->AppendSeparator(); - append_menu_item( - fileMenu, wxID_ANY, _L("Open speed dial...") + sep + "Space", "", + append_shortcut_item( + fileMenu, Shortcut::SpeedDial, false, _L("Open speed dial..."), "", [](wxCommandEvent&) { wxGetApp().open_speed_dial(); }, "", nullptr, []() { return true; }, this); append_menu_item( @@ -3728,7 +3764,7 @@ void MainFrame::init_menubar_as_gcodeviewer() wxMenu* viewMenu = nullptr; if (m_plater != nullptr) { viewMenu = new wxMenu(); - add_common_view_menu_items(viewMenu, this, std::bind(&MainFrame::can_change_view, this)); + add_common_view_menu_items(viewMenu, std::bind(&MainFrame::can_change_view, this)); } // helpmenu diff --git a/src/slic3r/GUI/MainFrame.hpp b/src/slic3r/GUI/MainFrame.hpp index 6851935059..66f3b31263 100644 --- a/src/slic3r/GUI/MainFrame.hpp +++ b/src/slic3r/GUI/MainFrame.hpp @@ -74,6 +74,8 @@ class DesignPanel; class MainFrame; class WebViewPanel; class ParamsDialog; +enum class Shortcut : uint8_t; +struct KeyChord; #ifdef __WXGTK__ class ResizeEdgePanel; #endif @@ -195,6 +197,29 @@ class MainFrame : public DPIFrame // vector of a MenuBar items changeable in respect to printer technology std::vector m_changeable_menu_items; + // Menu items whose label shows a key binding; update_shortcut_labels() rewrites them. + struct ShortcutMenuItem + { + wxMenuItem* item; + Shortcut shortcut; + wxString label; + bool accelerator; // false keeps the binding display-only on macOS, where the menu bar's accelerators are live + }; + std::vector m_shortcut_menu_items; + + wxString shortcut_label(const wxString& label, Shortcut shortcut, bool accelerator); + template + wxMenuItem* append_shortcut_item(wxMenu* menu, Shortcut shortcut, bool accelerator, const wxString& label, Args&&... args) + { + wxMenuItem* item = append_menu_item(menu, wxID_ANY, shortcut_label(label, shortcut, accelerator), std::forward(args)...); + m_shortcut_menu_items.push_back({ item, shortcut, label, accelerator }); + return item; + } + // Runs the Global shortcut bound to chord; false when the focused control should see the key as well. + bool handle_global_shortcut(const KeyChord& chord); + void add_common_view_menu_items(wxMenu* view_menu, std::function can_change_view); + wxMenu* generate_help_menu(); + struct FileHistory : wxFileHistory { FileHistory(int max) : wxFileHistory(max) {} @@ -354,6 +379,7 @@ public: void request_select_tab(const wxString& id); int get_calibration_curr_tab(); void select_view(const std::string& direction); + void update_shortcut_labels(); // Propagate changed configuration from the Tab to the Plater and save changes to the AppConfig void on_config_changed(DynamicPrintConfig* cfg) const ; void set_print_button_to_default(PrintSelectType select_type); diff --git a/src/slic3r/GUI/NativeCommands.cpp b/src/slic3r/GUI/NativeCommands.cpp index 2e24f14326..1297cd4a24 100644 --- a/src/slic3r/GUI/NativeCommands.cpp +++ b/src/slic3r/GUI/NativeCommands.cpp @@ -10,6 +10,7 @@ #include "GUI_Factories.hpp" #include "GUI_ObjectList.hpp" #include "I18N.hpp" +#include "Shortcuts.hpp" #include "IMSlider.hpp" #include "MainFrame.hpp" #include "NetworkTestDialog.hpp" @@ -563,7 +564,7 @@ std::vector build_command_catalog() // ---- Help ---- (mirrors the top-bar Help menu, plus the wiki/YouTube links) add("help_keyboard_shortcuts", _u8L("Keyboard Shortcuts"), _u8L("Help"), [](const std::string&) { - wxGetApp().keyboard_shortcuts(); + wxGetApp().keyboard_shortcuts(ShortcutContext::Global); return AppActionRunResult{AppActionRunResult::Level::Success}; }); add("help_setup_wizard", _u8L("Setup Wizard"), _u8L("Help"), [](const std::string&) { diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index d2196fefe6..6258cf19af 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -82,6 +82,7 @@ #include "GUI.hpp" #include "GUI_App.hpp" +#include "Shortcuts.hpp" #include "GUI_ObjectList.hpp" #ifdef __WXGTK__ #include "LinuxDisplayBackend.hpp" @@ -7595,7 +7596,9 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) view3D_canvas->Bind(EVT_GLCANVAS_PRINTABLE, [this](SimpleEvent& evt) { this->sidebar->obj_list()->toggle_printable_state(); }); view3D_canvas->Bind(EVT_GLCANVAS_SELECT_ALL, [this](SimpleEvent&) { this->q->select_all(); }); - view3D_canvas->Bind(EVT_GLCANVAS_QUESTION_MARK, [](SimpleEvent&) { wxGetApp().keyboard_shortcuts(); }); + view3D_canvas->Bind(EVT_GLCANVAS_QUESTION_MARK, [this](SimpleEvent&) { + wxGetApp().keyboard_shortcuts(view3D->get_canvas3d()->get_gizmos_manager().is_paint_gizmo() ? ShortcutContext::Painting : ShortcutContext::Plater); + }); view3D_canvas->Bind(EVT_GLCANVAS_INCREASE_INSTANCES, [this](Event& evt) { if (evt.data == 1) this->q->increase_instances(); else if (this->can_decrease_instances()) this->q->decrease_instances(); }); view3D_canvas->Bind(EVT_GLCANVAS_INSTANCE_MOVED, [this](SimpleEvent&) { update(); }); @@ -7672,7 +7675,7 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) view3D_canvas->Bind(EVT_GLCANVAS_UPDATE_BED_SHAPE, [q](SimpleEvent&) { q->set_bed_shape(); }); // Preview events: - preview->get_wxglcanvas()->Bind(EVT_GLCANVAS_QUESTION_MARK, [](SimpleEvent&) { wxGetApp().keyboard_shortcuts(); }); + preview->get_wxglcanvas()->Bind(EVT_GLCANVAS_QUESTION_MARK, [](SimpleEvent&) { wxGetApp().keyboard_shortcuts(ShortcutContext::Preview); }); preview->get_wxglcanvas()->Bind(EVT_GLCANVAS_UPDATE_BED_SHAPE, [q](SimpleEvent&) { q->set_bed_shape(); }); preview->get_wxglcanvas()->Bind(EVT_GLCANVAS_UPDATE, [this](SimpleEvent &) { preview->get_canvas3d()->set_as_dirty(); @@ -8133,10 +8136,7 @@ void Plater::priv::collapse_sidebar(bool collapse) sidebar_layout.is_collapsed = collapse; // Now update the tooltip in the toolbar. - std::string new_tooltip = collapse - ? _u8L("Expand sidebar") - : _u8L("Collapse sidebar"); - new_tooltip += " [" + _u8L("Shift+") + _u8L("Tab") + "]"; + const std::string new_tooltip = wxGetApp().shortcuts().with_key(collapse ? _u8L("Expand sidebar") : _u8L("Collapse sidebar"), Shortcut::CollapseSidebar); int id = collapse_toolbar.get_item_id("collapse_sidebar"); collapse_toolbar.set_tooltip(id, new_tooltip); diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index adda0edd74..be4bfcfa63 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -18,6 +18,7 @@ #include "NetworkTestDialog.hpp" #include "Widgets/StaticLine.hpp" #include "Widgets/RadioGroup.hpp" +#include "Shortcuts.hpp" #include "slic3r/Utils/bambu_networking.hpp" #include "slic3r/Utils/NetworkAgent.hpp" #include "NetworkPluginDialog.hpp" @@ -285,6 +286,7 @@ std::tuple PreferencesDialog::create_item_combobox_base( auto combobox = new ::ComboBox(m_parent, wxID_ANY, wxEmptyString, wxDefaultPosition, DESIGN_LARGE_COMBOBOX_SIZE, 0, nullptr, wxCB_READONLY); combobox->GetDropDown().SetUseContentWidth(true); combobox->SetToolTip(tip); + combobox->SetName(param); // select_tab() finds the row by this name std::vector::iterator iter; for (iter = vlist.begin(); iter != vlist.end(); iter++) { @@ -1522,6 +1524,19 @@ PreferencesDialog::~PreferencesDialog() { } +void PreferencesDialog::select_tab(PreferencesTab tab, const std::string& option) +{ + if (const auto index = m_tab_index.find(tab); index != m_tab_index.end()) + m_pref_tabs->SelectItem(index->second); + wxWindow* control = option.empty() ? nullptr : m_parent->FindWindow(wxString(option)); + if (control == nullptr) + return; + int unit = 1; + m_parent->GetScrollPixelsPerUnit(nullptr, &unit); + m_parent->Scroll(wxDefaultCoord, (m_parent->CalcUnscrolledPosition(control->GetPosition()).y - FromDIP(10)) / unit); + control->SetFocus(); // the focused tint marks the row +} + void PreferencesDialog::on_dpi_changed(const wxRect &suggested_rect) { m_pref_tabs->Rescale(); @@ -1599,7 +1614,7 @@ void PreferencesDialog::create_items() ////////////////////////// //// GENERAL TAB ///////////////////////////////////// - m_pref_tabs->AppendItem(_L("General")); + m_tab_index[PreferencesTab::General] = m_pref_tabs->AppendItem(_L("General")); f_sizers.push_back(new wxFlexGridSizer(1, 1, v_gap, 0)); g_sizer = f_sizers.back(); g_sizer->AddGrowableCol(0, 1); @@ -1730,8 +1745,8 @@ void PreferencesDialog::create_items() auto item_multi_machine = create_item_checkbox(_L("Multi device management"), _L("With this option enabled, you can send a task to multiple devices at the same time and manage multiple devices."), "enable_multi_machine", _L("(Requires restart)")); g_sizer->Add(item_multi_machine); - auto item_speed_dial = create_item_checkbox(_L("Open the Speed Dial with the Space key"), - _L("When enabled, pressing Space (with no other key held) opens the Speed Dial action search from any page."), + auto item_speed_dial = create_item_checkbox(_L("Open the Speed Dial from the keyboard"), + _L("When enabled, the Speed Dial keyboard shortcut (Space by default) opens the action search from any page."), "enable_speed_dial"); g_sizer->Add(item_speed_dial); @@ -1789,7 +1804,7 @@ void PreferencesDialog::create_items() ////////////////////////// //// CONTROL TAB ///////////////////////////////////// - m_pref_tabs->AppendItem(_L("Control")); + m_tab_index[PreferencesTab::Control] = m_pref_tabs->AppendItem(_L("Control")); f_sizers.push_back(new wxFlexGridSizer(1, 1, v_gap, 0)); g_sizer = f_sizers.back(); g_sizer->AddGrowableCol(0, 1); @@ -1859,6 +1874,14 @@ void PreferencesDialog::create_items() auto item_right_mouse_drag = create_item_combobox(_L("Right Mouse Drag"), _L("Set the action that dragging the right mouse button should perform."), "right_mouse_drag_action", ButtonDragActions); g_sizer->Add(item_right_mouse_drag); + //// CONTROL > Keyboard + g_sizer->Add(create_item_title(_L("Keyboard")), 1, wxEXPAND); + + auto item_shortcuts = create_item_button(_L("Keyboard shortcuts"), _L("Edit") + dots, "", _L("Choose the key for each action."), [this]() { + wxGetApp().keyboard_shortcuts(ShortcutContext::Global, this); + }); + g_sizer->Add(item_shortcuts); + //// CONTROL > Clear my choice on ... g_sizer->Add(create_item_title(_L("Clear my choice on...")), 1, wxEXPAND); @@ -1883,7 +1906,7 @@ void PreferencesDialog::create_items() ////////////////////////// //// GRAPHICS TAB ///////////////////////////////////// - m_pref_tabs->AppendItem(_L("Graphics")); + m_tab_index[PreferencesTab::Graphics] = m_pref_tabs->AppendItem(_L("Graphics")); f_sizers.push_back(new wxFlexGridSizer(1, 1, v_gap, 0)); g_sizer = f_sizers.back(); g_sizer->AddGrowableCol(0, 1); @@ -2031,7 +2054,7 @@ void PreferencesDialog::create_items() ////////////////////////// //// ONLINE TAB ///////////////////////////////////// - m_pref_tabs->AppendItem(_L("Online")); + m_tab_index[PreferencesTab::Online] = m_pref_tabs->AppendItem(_L("Online")); f_sizers.push_back(new wxFlexGridSizer(1, 1, v_gap, 0)); g_sizer = f_sizers.back(); g_sizer->AddGrowableCol(0, 1); diff --git a/src/slic3r/GUI/Preferences.hpp b/src/slic3r/GUI/Preferences.hpp index 5ab7fb1919..e3b6ffdc7d 100644 --- a/src/slic3r/GUI/Preferences.hpp +++ b/src/slic3r/GUI/Preferences.hpp @@ -29,6 +29,9 @@ namespace Slic3r { namespace GUI { #define DESIGN_INPUT_SIZE wxSize(FromDIP(120), -1) #define DESIGN_LEFT_MARGIN 25 +// The tabs other dialogs open Preferences on. +enum class PreferencesTab { General, Control, Graphics, Online }; + class PreferencesDialog : public DPIDialog { private: @@ -38,6 +41,7 @@ protected: wxBoxSizer * m_sizer_body; wxScrolledWindow* m_parent; TabCtrl* m_pref_tabs; + std::map m_tab_index; // position of each tab in m_pref_tabs // bool m_settings_layout_changed {false}; bool m_seq_top_layer_only_changed{false}; @@ -60,6 +64,8 @@ public: ~PreferencesDialog(); + void select_tab(PreferencesTab tab, const std::string& option = {}); // and scrolls a combobox option's row into view, focused + wxString m_backup_interval_time; wxTimer m_filament_height_timer; diff --git a/src/slic3r/GUI/Shortcuts.cpp b/src/slic3r/GUI/Shortcuts.cpp new file mode 100644 index 0000000000..0bcf0ccef9 --- /dev/null +++ b/src/slic3r/GUI/Shortcuts.cpp @@ -0,0 +1,377 @@ +#include "Shortcuts.hpp" + +#include "I18N.hpp" +#include "libslic3r/AppConfig.hpp" + +#include +#include +#include + +namespace Slic3r { namespace GUI { + +namespace { + +constexpr uint8_t GLOBAL = context_bit(ShortcutContext::Global); +constexpr uint8_t PLATER = context_bit(ShortcutContext::Plater); +constexpr uint8_t PREVIEW = context_bit(ShortcutContext::Preview); +constexpr uint8_t OBJECT_LIST = context_bit(ShortcutContext::ObjectList); +constexpr uint8_t PAINTING = context_bit(ShortcutContext::Painting); +constexpr uint8_t CANVAS = PLATER | PREVIEW; + +constexpr int CTRL = wxMOD_CONTROL; +constexpr int SHIFT = wxMOD_SHIFT; +constexpr int CTRL_SHIFT = wxMOD_CONTROL | wxMOD_SHIFT; + +#ifdef __APPLE__ +constexpr KeyChord PREFERENCES_CHORD{ ',', CTRL }; +constexpr KeyChord DELETE_CHORD{ WXK_BACK }; +constexpr KeyChord MOUSE3D_CHORD{ 'M', CTRL_SHIFT }; +#else +constexpr KeyChord PREFERENCES_CHORD{ 'P', CTRL }; +constexpr KeyChord DELETE_CHORD{ WXK_DELETE }; +constexpr KeyChord MOUSE3D_CHORD{ 'M', CTRL }; +#endif + +// Each row: enum value, AppConfig key, description shown in the dialog, contexts the key is +// looked up in, and the default chord. Rows are in the order the dialog lists them, within +// the sections of the Shortcut enum. +// SHORTCUT runs once per key press. +// REPEATING runs again on every auto-repeat of a held key. +// STEPPING repeats, and Shift or Ctrl held with its key select a step variant of it instead +// of another shortcut (ShortcutInfo::modifier_variants). +#define SHORTCUT(id, key, name, contexts, ...) ShortcutInfo{ Shortcut::id, key, name, contexts, __VA_ARGS__, false, false } +#define REPEATING(id, key, name, contexts, ...) ShortcutInfo{ Shortcut::id, key, name, contexts, __VA_ARGS__, true, false } +#define STEPPING(id, key, name, contexts, ...) ShortcutInfo{ Shortcut::id, key, name, contexts, __VA_ARGS__, true, true } + +constexpr std::array shortcut_table = {{ + // Project + SHORTCUT(NewProject, "new_project", L("New Project"), GLOBAL, { 'N', CTRL }), + SHORTCUT(OpenProject, "open_project", L("Open Project"), GLOBAL, { 'O', CTRL }), + SHORTCUT(SaveProject, "save_project", L("Save Project"), GLOBAL, { 'S', CTRL }), + SHORTCUT(SaveProjectAs, "save_project_as", L("Save Project as"), GLOBAL, { 'S', CTRL_SHIFT }), + SHORTCUT(ImportModel, "import_model", L("Import geometry data from STL/STEP/3MF/OBJ/AMF files"), GLOBAL, { 'I', CTRL }), + SHORTCUT(Publish3mf, "publish_3mf", L("Publish 3MF"), GLOBAL, { 'E', CTRL_SHIFT }), + + // Slicing and printing + SHORTCUT(SlicePlate, "slice_plate", L("Slice plate"), GLOBAL, { 'R', CTRL }), + SHORTCUT(ExportSlicedFile, "export_sliced_file", L("Export plate sliced file"), GLOBAL, { 'G', CTRL }), + SHORTCUT(PrintPlate, "print_plate", L("Print plate"), GLOBAL, { 'G', CTRL_SHIFT }), + SHORTCUT(PrintHostQueue, "print_host_queue", L("Print host upload queue"), GLOBAL, { 'J', CTRL }), + + // Selection + SHORTCUT(SelectAll, "select_all", L("Select all objects"), PLATER | OBJECT_LIST, { 'A', CTRL }), + SHORTCUT(SelectAllPlates, "select_all_plates", L("Select all objects on all plates"), PLATER, { 'A', CTRL_SHIFT }), + + // Editing + REPEATING(Undo, "undo", L("Undo"), PLATER | OBJECT_LIST, { 'Z', CTRL }), + REPEATING(Redo, "redo", L("Redo"), PLATER | OBJECT_LIST, { 'Y', CTRL }), + SHORTCUT(Cut, "cut", L("Cut"), PLATER | OBJECT_LIST, { 'X', CTRL }), + SHORTCUT(Copy, "copy", L("Copy to clipboard"), PLATER | OBJECT_LIST, { 'C', CTRL }), + SHORTCUT(Paste, "paste", L("Paste from clipboard"), PLATER | OBJECT_LIST, { 'V', CTRL }), + SHORTCUT(DeleteSelected, "delete_selected", L("Delete Selected"), PLATER | OBJECT_LIST, DELETE_CHORD), + SHORTCUT(DeleteAll, "delete_all", L("Delete All"), PLATER, { 'D', CTRL }), + SHORTCUT(CloneSelected, "clone_selected", L("Clone Selected"), PLATER | OBJECT_LIST, { 'K', CTRL }), + + // Objects + REPEATING(AddInstance, "add_instance", L("Add instance"), PLATER | OBJECT_LIST, { '+' }), + REPEATING(RemoveInstance, "remove_instance", L("Remove instance"), PLATER | OBJECT_LIST, { '-' }), + SHORTCUT(TogglePrintable, "toggle_printable", L("Toggle printable for object/part"), PLATER | OBJECT_LIST, { 'V' }), + SHORTCUT(ToggleAutoDrop, "toggle_auto_drop", L("Auto Drop"), OBJECT_LIST, { 'D' }), + + // Placement + SHORTCUT(Arrange, "arrange", L("Arrange all objects"), PLATER, { 'A' }), + SHORTCUT(ArrangePlate, "arrange_plate", L("Arrange objects on selected plates"), PLATER, { 'A', SHIFT }), + SHORTCUT(Orient, "orient", L("Auto orient all/selected objects"), PLATER, { 'Q' }), + SHORTCUT(OrientPlate, "orient_plate", L("Auto orient all objects on current plate"), PLATER, { 'Q', SHIFT }), + STEPPING(MoveSelectionLeft, "move_selection_left", L("Move selection 10mm in negative X direction"), PLATER, { WXK_LEFT }), + STEPPING(MoveSelectionRight, "move_selection_right", L("Move selection 10mm in positive X direction"), PLATER, { WXK_RIGHT }), + STEPPING(MoveSelectionUp, "move_selection_up", L("Move selection 10mm in positive Y direction"), PLATER, { WXK_UP }), + STEPPING(MoveSelectionDown, "move_selection_down", L("Move selection 10mm in negative Y direction"), PLATER, { WXK_DOWN }), + REPEATING(RotateSelectionLeft, "rotate_selection_left", L("Rotate selection 45 degrees counterclockwise"), PLATER, { WXK_PAGEUP }), + REPEATING(RotateSelectionRight, "rotate_selection_right", L("Rotate selection 45 degrees clockwise"), PLATER, { WXK_PAGEDOWN }), + + // Gizmos + SHORTCUT(GizmoMove, "gizmo_move", L("Gizmo move"), PLATER, { 'M' }), + SHORTCUT(GizmoRotate, "gizmo_rotate", L("Gizmo rotate"), PLATER, { 'R' }), + SHORTCUT(GizmoScale, "gizmo_scale", L("Gizmo scale"), PLATER, { 'S' }), + SHORTCUT(GizmoFlatten, "gizmo_flatten", L("Gizmo place face on bed"), PLATER, { 'F' }), + SHORTCUT(GizmoCut, "gizmo_cut", L("Gizmo cut"), PLATER, { 'C' }), + SHORTCUT(GizmoMeshBoolean, "gizmo_mesh_boolean", L("Gizmo mesh boolean"), PLATER, { 'B' }), + SHORTCUT(GizmoFdmSupports, "gizmo_fdm_supports", L("Gizmo FDM paint-on supports"), PLATER, { 'L' }), + SHORTCUT(GizmoSeam, "gizmo_seam", L("Gizmo FDM paint-on seam"), PLATER, { 'P' }), + SHORTCUT(GizmoFuzzySkin, "gizmo_fuzzy_skin", L("Gizmo FDM paint-on fuzzy skin"), PLATER, { 'H' }), + SHORTCUT(GizmoMmuSegmentation, "gizmo_mmu_segmentation", L("Gizmo multi-material painting"), PLATER, { 'N' }), + SHORTCUT(GizmoEmboss, "gizmo_emboss", L("Gizmo text emboss/engrave"), PLATER, { 'T' }), + SHORTCUT(GizmoMeasure, "gizmo_measure", L("Gizmo measure"), PLATER, { 'U' }), + SHORTCUT(GizmoAssembly, "gizmo_assembly", L("Gizmo assemble"), PLATER, { 'Y' }), + SHORTCUT(GizmoBrimEars, "gizmo_brim_ears", L("Gizmo brim ears"), PLATER, { 'E' }), + + // Sliders + SHORTCUT(GoToLayer, "go_to_layer", L("Jump to layer"), PREVIEW, { 'G', SHIFT }), + STEPPING(LayerSliderUp, "layer_slider_up", L("Vertical slider - Move active thumb Up"), PREVIEW, { WXK_UP }), + STEPPING(LayerSliderDown, "layer_slider_down", L("Vertical slider - Move active thumb Down"), PREVIEW, { WXK_DOWN }), + STEPPING(MovesSliderLeft, "moves_slider_left", L("Horizontal slider - Move active thumb Left"), PREVIEW, { WXK_LEFT }), + STEPPING(MovesSliderRight, "moves_slider_right", L("Horizontal slider - Move active thumb Right"), PREVIEW, { WXK_RIGHT }), + REPEATING(MovesSliderStart, "moves_slider_start", L("Horizontal slider - Move to start position"), PREVIEW, { WXK_HOME }), + REPEATING(MovesSliderEnd, "moves_slider_end", L("Horizontal slider - Move to last position"), PREVIEW, { WXK_END }), + + // Painting tools + SHORTCUT(PaintToolCircle, "paint_tool_circle", L("Circle"), PAINTING, { 'C' }), + SHORTCUT(PaintToolSphere, "paint_tool_sphere", L("Sphere"), PAINTING, { 'S' }), + SHORTCUT(PaintToolFill, "paint_tool_fill", L("Fill"), PAINTING, { 'F' }), + SHORTCUT(PaintToolGapFill, "paint_tool_gap_fill", L("Gap Fill"), PAINTING, { 'G' }), + SHORTCUT(PaintToolTriangle, "paint_tool_triangle", L("Triangle"), PAINTING, { 'T' }), + SHORTCUT(PaintToolHeightRange, "paint_tool_height_range", L("Height Range"), PAINTING, { 'H' }), + + // Camera + SHORTCUT(ViewDefault, "view_default", L("Camera view - Default"), GLOBAL, { '0', CTRL }), + SHORTCUT(ViewTop, "view_top", L("Camera view - Top"), GLOBAL, { '1', CTRL }), + SHORTCUT(ViewBottom, "view_bottom", L("Camera view - Bottom"), GLOBAL, { '2', CTRL }), + SHORTCUT(ViewFront, "view_front", L("Camera view - Front"), GLOBAL, { '3', CTRL }), + SHORTCUT(ViewRear, "view_rear", L("Camera view - Behind"), GLOBAL, { '4', CTRL }), + SHORTCUT(ViewLeft, "view_left", L("Camera Angle - Left side"), GLOBAL, { '5', CTRL }), + SHORTCUT(ViewRight, "view_right", L("Camera Angle - Right side"), GLOBAL, { '6', CTRL }), + SHORTCUT(ViewPlate, "view_plate", L("Camera view - Current plate"), GLOBAL, { '7', CTRL }), + REPEATING(ZoomIn, "zoom_in", L("Zoom in"), CANVAS, { 'I' }), + REPEATING(ZoomOut, "zoom_out", L("Zoom out"), CANVAS, { 'O' }), + SHORTCUT(Mouse3DSettings, "mouse3d_settings", L("Show/Hide 3Dconnexion devices settings dialog"), CANVAS, MOUSE3D_CHORD), + + // Display + SHORTCUT(ShowLabels, "show_labels", L("Show object labels in 3D scene."), GLOBAL, { 'E', CTRL }), + SHORTCUT(ShowWireframe, "show_wireframe", L("Show/Hide wireframe"), CANVAS, { WXK_RETURN, CTRL_SHIFT }), + SHORTCUT(ToggleGcodeWindow, "toggle_gcode_window", L("On/Off G-code window"), PREVIEW, { 'C' }), + SHORTCUT(ToggleOneLayerMode, "toggle_one_layer_mode", L("On/Off one layer mode of the vertical slider"), PREVIEW, { 'L' }), + + // Application + SHORTCUT(Preferences, "preferences", L("Preferences"), GLOBAL, PREFERENCES_CHORD), + SHORTCUT(Search, "search", L("Search"), GLOBAL, { 'F', CTRL }), + SHORTCUT(SwitchView, "switch_view", L("Switch between Prepare/Preview"), CANVAS, { WXK_TAB }), + SHORTCUT(CollapseSidebar, "collapse_sidebar", L("Collapse/Expand the sidebar"), CANVAS, { WXK_TAB, SHIFT }), + SHORTCUT(SpeedDial, "speed_dial", L("Open the speed dial"), GLOBAL, { WXK_SPACE }), + SHORTCUT(ReloadDevicePage, "reload_device_page", L("Reload the device page"), CANVAS, { WXK_F5 }), + SHORTCUT(KeyboardShortcuts, "keyboard_shortcuts", L("Show keyboard shortcuts list"), CANVAS, { '?' }), +}}; + +#undef SHORTCUT +#undef REPEATING +#undef STEPPING + +// The first shortcut of each section's run in shortcut_table and the section's heading, indexed +// by ShortcutSection. +struct SectionInfo +{ + Shortcut first; + const char* name; +}; + +constexpr std::array section_table = {{ + { Shortcut::NewProject, L("Project") }, + { Shortcut::SlicePlate, L("Slicing and printing") }, + { Shortcut::SelectAll, L("Selection") }, + { Shortcut::Undo, L("Editing") }, + { Shortcut::AddInstance, L("Objects") }, + { Shortcut::Arrange, L("Placement") }, + { Shortcut::GizmoMove, L("Gizmos") }, + { Shortcut::GoToLayer, L("Sliders") }, + { Shortcut::PaintToolCircle, L("Painting tools") }, + { Shortcut::ViewDefault, L("Camera") }, + { Shortcut::ShowLabels, L("Display") }, + { Shortcut::Preferences, L("Application") }, +}}; + +constexpr bool sections_follow_table_order() +{ + if (section_table.front().first != Shortcut(0)) + return false; + for (size_t i = 1; i < section_table.size(); ++i) + if (section_table[i].first <= section_table[i - 1].first) + return false; + return true; +} +static_assert(sections_follow_table_order(), "section_table must begin at the first shortcut and ascend"); + +constexpr bool table_is_in_enum_order() +{ + for (size_t i = 0; i < shortcut_table.size(); ++i) + if (shortcut_table[i].id != Shortcut(i)) + return false; + return true; +} +static_assert(table_is_in_enum_order(), "shortcut_table must list every Shortcut in declaration order"); + +const char* CONFIG_SECTION = "shortcuts"; +const char* UNBOUND = "none"; + +bool share_context(uint8_t a, uint8_t b) { return (a & b) != 0 || (a & GLOBAL) != 0 || (b & GLOBAL) != 0; } + +// The modifiers a modifier_variants shortcut accepts on top of its binding. +constexpr int STEP_MODIFIERS = wxMOD_SHIFT | wxMOD_CONTROL; + +// The step modifiers chord adds to binding, 0 when chord is not a step of it; a binding with +// Shift or Ctrl of its own has no steps, so no two bindings share one. +int step_modifiers(const KeyChord& chord, const KeyChord& binding) +{ + if (!binding.valid() || (binding.modifiers & STEP_MODIFIERS) != 0 || chord.key != binding.key || + (chord.modifiers & ~STEP_MODIFIERS) != binding.modifiers) + return 0; + return chord.modifiers & STEP_MODIFIERS; +} + +} // namespace + +const ShortcutInfo& shortcut_info(Shortcut shortcut) { return shortcut_table[size_t(shortcut)]; } + +ShortcutSection shortcut_section(Shortcut shortcut) +{ + size_t section = 0; + for (size_t i = 1; i < section_table.size(); ++i) + if (section_table[i].first <= shortcut) + section = i; + return ShortcutSection(section); +} + +const char* section_name(ShortcutSection section) { return section_table[size_t(section)].name; } + +std::vector shortcuts_in(ShortcutContext context) +{ + std::vector out; + for (const ShortcutInfo& info : shortcut_table) + if (info.contexts & context_bit(context)) + out.push_back(info.id); + return out; +} + +ShortcutRegistry::ShortcutRegistry() { rebuild_index(); } + +KeyChord ShortcutRegistry::binding(Shortcut shortcut) const +{ + const std::optional& override = m_overrides[size_t(shortcut)]; + return override.has_value() ? *override : shortcut_info(shortcut).default_chord; +} + +bool ShortcutRegistry::is_customized(Shortcut shortcut) const { return m_overrides[size_t(shortcut)].has_value(); } + +std::string ShortcutRegistry::display(Shortcut shortcut) const { return binding(shortcut).display(); } + +std::string ShortcutRegistry::with_key(const std::string& text, Shortcut shortcut) const +{ + const std::string key = display(shortcut); + return key.empty() ? text : text + " [" + key + "]"; +} + +std::string ShortcutRegistry::accelerator(Shortcut shortcut) const +{ + const KeyChord chord = binding(shortcut); + return chord.is_menu_accelerator() ? chord.to_string() : std::string(); +} + +std::optional ShortcutRegistry::lookup(ShortcutContext context, const KeyChord& chord) const +{ + if (!chord.valid()) + return std::nullopt; + auto it = m_index.find(chord); + if (it == m_index.end()) + return std::nullopt; + for (Shortcut shortcut : it->second) + if (shortcut_info(shortcut).contexts & context_bit(context)) + return shortcut; + return std::nullopt; +} + +std::optional ShortcutRegistry::match(ShortcutContext context, const KeyChord& chord) const +{ + if (const std::optional exact = lookup(context, chord); exact.has_value()) + return Match{ *exact, 0 }; + if ((chord.modifiers & STEP_MODIFIERS) == 0) + return std::nullopt; + for (const ShortcutInfo& info : shortcut_table) + if (info.modifier_variants && (info.contexts & context_bit(context))) + if (const int step = step_modifiers(chord, binding(info.id)); step != 0) + return Match{ info.id, step }; + return std::nullopt; +} + +std::vector ShortcutRegistry::conflicts(Shortcut shortcut, const KeyChord& chord) const +{ + std::vector out; + if (!chord.valid()) + return out; + const uint8_t contexts = shortcut_info(shortcut).contexts; + for (const ShortcutInfo& other : shortcut_table) + if (other.id != shortcut && share_context(contexts, other.contexts) && binding(other.id) == chord) + out.push_back(other.id); + return out; +} + +std::optional ShortcutRegistry::step_owner(Shortcut shortcut, const KeyChord& chord) const +{ + const uint8_t contexts = shortcut_info(shortcut).contexts; + for (const ShortcutInfo& other : shortcut_table) + if (other.modifier_variants && other.id != shortcut && share_context(contexts, other.contexts)) + if (const int step = step_modifiers(chord, binding(other.id)); step == wxMOD_SHIFT || step == wxMOD_CONTROL) // the combined step stays assignable + return other.id; + return std::nullopt; +} + +void ShortcutRegistry::bind(Shortcut shortcut, const KeyChord& chord) +{ + const ShortcutInfo& info = shortcut_info(shortcut); + if (chord == info.default_chord) + m_overrides[size_t(shortcut)].reset(); + else + m_overrides[size_t(shortcut)] = chord; + rebuild_index(); +} + +void ShortcutRegistry::reset(Shortcut shortcut) +{ + m_overrides[size_t(shortcut)].reset(); + rebuild_index(); +} + +void ShortcutRegistry::reset_all() +{ + m_overrides.fill(std::nullopt); + rebuild_index(); +} + +void ShortcutRegistry::load(const AppConfig& config) +{ + m_overrides.fill(std::nullopt); + if (config.has_section(CONFIG_SECTION)) { + const std::map& section = config.get_section(CONFIG_SECTION); + for (const ShortcutInfo& info : shortcut_table) { + auto it = section.find(info.key); + if (it == section.end()) + continue; + if (it->second == UNBOUND) + m_overrides[size_t(info.id)] = KeyChord{}; + else if (std::optional chord = KeyChord::parse(it->second); chord.has_value()) + // A Global chord is seen before any text field, so it needs a modifier or a non-typing key. + if ((info.contexts & GLOBAL) == 0 || chord->is_menu_accelerator()) + m_overrides[size_t(info.id)] = *chord; + } + } + rebuild_index(); +} + +void ShortcutRegistry::save(AppConfig& config) const +{ + for (const ShortcutInfo& info : shortcut_table) { + const std::optional& override = m_overrides[size_t(info.id)]; + if (override.has_value()) + config.set(CONFIG_SECTION, info.key, override->valid() ? override->to_string() : UNBOUND); + else + config.erase(CONFIG_SECTION, info.key); + } +} + +void ShortcutRegistry::rebuild_index() +{ + m_index.clear(); + for (const ShortcutInfo& info : shortcut_table) + if (const KeyChord chord = binding(info.id); chord.valid()) + m_index[chord].push_back(info.id); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Shortcuts.hpp b/src/slic3r/GUI/Shortcuts.hpp new file mode 100644 index 0000000000..ba002556ca --- /dev/null +++ b/src/slic3r/GUI/Shortcuts.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include "KeyChord.hpp" + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +class AppConfig; + +namespace GUI { + +// Where a key press is looked up. A shortcut may belong to several contexts; Global ones are +// dispatched by the main frame before any child window sees the key. +enum class ShortcutContext : uint8_t { Global, Plater, Preview, ObjectList, Painting, Count }; + +constexpr uint8_t context_bit(ShortcutContext context) { return uint8_t(1u << unsigned(context)); } + +enum class Shortcut : uint8_t { + // Project + NewProject, OpenProject, SaveProject, SaveProjectAs, ImportModel, Publish3mf, + // Slicing and printing + SlicePlate, ExportSlicedFile, PrintPlate, PrintHostQueue, + // Selection + SelectAll, SelectAllPlates, + // Editing + Undo, Redo, Cut, Copy, Paste, DeleteSelected, DeleteAll, CloneSelected, + // Objects + AddInstance, RemoveInstance, TogglePrintable, ToggleAutoDrop, + // Placement + Arrange, ArrangePlate, Orient, OrientPlate, + MoveSelectionLeft, MoveSelectionRight, MoveSelectionUp, MoveSelectionDown, RotateSelectionLeft, RotateSelectionRight, + // Gizmos + GizmoMove, GizmoRotate, GizmoScale, GizmoFlatten, GizmoCut, GizmoMeshBoolean, GizmoFdmSupports, GizmoSeam, GizmoFuzzySkin, + GizmoMmuSegmentation, GizmoEmboss, GizmoMeasure, GizmoAssembly, GizmoBrimEars, + // Sliders + GoToLayer, LayerSliderUp, LayerSliderDown, MovesSliderLeft, MovesSliderRight, MovesSliderStart, MovesSliderEnd, + // Painting tools + PaintToolCircle, PaintToolSphere, PaintToolFill, PaintToolGapFill, PaintToolTriangle, PaintToolHeightRange, + // Camera + ViewDefault, ViewTop, ViewBottom, ViewFront, ViewRear, ViewLeft, ViewRight, ViewPlate, ZoomIn, ZoomOut, Mouse3DSettings, + // Display + ShowLabels, ShowWireframe, ToggleGcodeWindow, ToggleOneLayerMode, + // Application + Preferences, Search, SwitchView, CollapseSidebar, SpeedDial, ReloadDevicePage, KeyboardShortcuts, + Count +}; + +struct ShortcutInfo +{ + Shortcut id; + const char* key; // AppConfig key + const char* name; // untranslated description + uint8_t contexts; // context_bit() mask + KeyChord default_chord; + bool repeatable; // runs on key auto-repeat as well + // Shift or Ctrl held with the bound key select a variant of this action, such as the finer + // move step or the faster slider step. + bool modifier_variants; +}; + +// Headings of the shortcuts dialog, in listing order. +enum class ShortcutSection : uint8_t { + Project, SlicingAndPrinting, Selection, Editing, Objects, Placement, Gizmos, Sliders, PaintingTools, Camera, Display, Application, + Count +}; + +const ShortcutInfo& shortcut_info(Shortcut shortcut); +ShortcutSection shortcut_section(Shortcut shortcut); +const char* section_name(ShortcutSection section); // untranslated heading +std::vector shortcuts_in(ShortcutContext context); // in table order + +// Effective key bindings: the built-in defaults overlaid with the user's own. Owns the +// chord -> shortcut index every dispatcher queries. +class ShortcutRegistry +{ +public: + ShortcutRegistry(); + + KeyChord binding(Shortcut shortcut) const; // invalid when unbound + bool is_customized(Shortcut shortcut) const; + + std::string display(Shortcut shortcut) const; // "Ctrl+N" for tooltips and the shortcuts dialog + std::string with_key(const std::string& text, Shortcut shortcut) const; // "text [Ctrl+N]", or text alone when unbound + std::string accelerator(Shortcut shortcut) const; // wx accelerator text for menu labels; empty when unbound or not menu-safe + + // Matches a key event in one context. Global shortcuts are only found through the Global context. + std::optional lookup(ShortcutContext context, const KeyChord& chord) const; + + // lookup(), then the modifier_variants shortcuts, which also match with Shift or Ctrl added + // to their binding; step_modifiers holds whichever of the two were added. + struct Match + { + Shortcut shortcut; + int step_modifiers; + }; + std::optional match(ShortcutContext context, const KeyChord& chord) const; + + // Shortcuts other than shortcut bound to chord in a context it shares; Global shortcuts + // share every context. + std::vector conflicts(Shortcut shortcut, const KeyChord& chord) const; + + // The modifier_variants shortcut in a shared context whose Shift or Ctrl step is chord; + // such a chord is reserved for that step. + std::optional step_owner(Shortcut shortcut, const KeyChord& chord) const; + + void bind(Shortcut shortcut, const KeyChord& chord); // an invalid chord unbinds + void reset(Shortcut shortcut); + void reset_all(); + + void load(const AppConfig& config); + void save(AppConfig& config) const; + +private: + void rebuild_index(); + + std::array, size_t(Shortcut::Count)> m_overrides; + std::unordered_map, KeyChordHash> m_index; +}; + +} // namespace GUI +} // namespace Slic3r diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 3ddacc5a1b..f86f40c3e1 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -18,6 +18,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_sort.cpp test_plugin_cloud_metadata.cpp test_plugin_audit.cpp + test_shortcuts.cpp ../fff_print/test_helpers.cpp ) diff --git a/tests/slic3rutils/test_shortcuts.cpp b/tests/slic3rutils/test_shortcuts.cpp new file mode 100644 index 0000000000..7826a0610a --- /dev/null +++ b/tests/slic3rutils/test_shortcuts.cpp @@ -0,0 +1,329 @@ +#include +#include + +#include "libslic3r/AppConfig.hpp" +#include "slic3r/GUI/KeyChord.hpp" +#include "slic3r/GUI/Shortcuts.hpp" + +#include + +using namespace Slic3r; +using namespace Slic3r::GUI; + +namespace { + +wxKeyEvent key_event(wxEventType type, int key_code, int modifiers = wxMOD_NONE) +{ + wxKeyEvent evt(type); + evt.m_keyCode = key_code; + evt.SetControlDown(modifiers & wxMOD_CONTROL); + evt.SetShiftDown(modifiers & wxMOD_SHIFT); + evt.SetAltDown(modifiers & wxMOD_ALT); + return evt; +} + +} // namespace + +TEST_CASE("KeyChord round-trips through its canonical text", "[Shortcuts]") +{ + const auto [chord, text] = GENERATE(table({ + { { 'N', wxMOD_CONTROL }, "Ctrl+N" }, + { { 'S', wxMOD_CONTROL | wxMOD_SHIFT }, "Ctrl+Shift+S" }, + { { WXK_RETURN, wxMOD_SHIFT | wxMOD_ALT }, "Shift+Alt+Enter" }, + { { WXK_TAB, wxMOD_SHIFT }, "Shift+Tab" }, + { { WXK_DELETE }, "Del" }, + { { WXK_F5 }, "F5" }, + { { WXK_F12, wxMOD_CONTROL }, "Ctrl+F12" }, + { { '+' }, "+" }, + { { '-', wxMOD_CONTROL }, "Ctrl+-" }, + { { '?' }, "?" }, + { { ',', wxMOD_CONTROL }, "Ctrl+," }, + })); + CAPTURE(text); + CHECK(chord.to_string() == text); + REQUIRE(KeyChord::parse(text).has_value()); + CHECK(*KeyChord::parse(text) == chord); +} + +TEST_CASE("KeyChord::parse accepts aliases and rejects malformed text", "[Shortcuts]") +{ + CHECK(KeyChord::parse("control+n") == KeyChord{ 'N', wxMOD_CONTROL }); + CHECK(KeyChord::parse("Cmd+Shift+Delete") == KeyChord{ WXK_DELETE, wxMOD_CONTROL | wxMOD_SHIFT }); + CHECK(KeyChord::parse("PageUp") == KeyChord{ WXK_PAGEUP }); + CHECK(KeyChord::parse("f3") == KeyChord{ WXK_F3 }); + + CHECK_FALSE(KeyChord::parse("").has_value()); + CHECK_FALSE(KeyChord::parse("Ctrl+").has_value()); + CHECK_FALSE(KeyChord::parse("Meta+A").has_value()); + CHECK_FALSE(KeyChord::parse("F25").has_value()); + CHECK_FALSE(KeyChord::parse("Shift+/").has_value()); // Shift is part of the punctuation character +} + +TEST_CASE("Key events normalize to the key-down key codes", "[Shortcuts]") +{ + CHECK(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, 'A', wxMOD_CONTROL)) == KeyChord{ 'A', wxMOD_CONTROL }); + CHECK(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_NUMPAD5, wxMOD_CONTROL)) == KeyChord{ '5', wxMOD_CONTROL }); + CHECK(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_NUMPAD_ADD)) == KeyChord{ '+' }); + CHECK(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_NUMPAD_PAGEUP)) == KeyChord{ WXK_PAGEUP }); + CHECK_FALSE(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_SHIFT, wxMOD_SHIFT)).valid()); + CHECK_FALSE(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_CONTROL, wxMOD_CONTROL)).valid()); + + CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, 'a')) == KeyChord{ 'A' }); + CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, 'A', wxMOD_SHIFT)) == KeyChord{ 'A', wxMOD_SHIFT }); + CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, WXK_CONTROL_C, wxMOD_CONTROL)) == KeyChord{ 'C', wxMOD_CONTROL }); + CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, '+', wxMOD_SHIFT)) == KeyChord{ '+' }); + CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, WXK_DELETE)) == KeyChord{ WXK_DELETE }); + CHECK_FALSE(KeyChord::from_event(key_event(wxEVT_CHAR, 0x444)).valid()); // a Cyrillic letter is not bindable +} + +TEST_CASE("Punctuation chords are the ones matched on char events", "[Shortcuts]") +{ + CHECK(KeyChord{ '+' }.is_punctuation()); + CHECK(KeyChord{ '?' }.is_punctuation()); + CHECK_FALSE(KeyChord{ 'A' }.is_punctuation()); + CHECK_FALSE(KeyChord{ '1' }.is_punctuation()); + CHECK_FALSE(KeyChord{ '=', wxMOD_CONTROL }.is_punctuation()); + CHECK_FALSE(KeyChord{ WXK_DELETE }.is_punctuation()); +} + +TEST_CASE("Chords that only the char event can resolve are recognized", "[Shortcuts]") +{ + CHECK(KeyChord{ '/', wxMOD_SHIFT }.needs_char_event()); + CHECK(KeyChord{ '-' }.needs_char_event()); + CHECK_FALSE(KeyChord{ '=', wxMOD_CONTROL }.needs_char_event()); + CHECK_FALSE(KeyChord{ 'A', wxMOD_SHIFT }.needs_char_event()); + CHECK_FALSE(KeyChord{ '1' }.needs_char_event()); + CHECK_FALSE(KeyChord{ WXK_F5 }.needs_char_event()); +} + +TEST_CASE("Only modified or non-printable chords qualify as menu accelerators", "[Shortcuts]") +{ + CHECK(KeyChord{ 'N', wxMOD_CONTROL }.is_menu_accelerator()); + CHECK(KeyChord{ 'N', wxMOD_ALT }.is_menu_accelerator()); + CHECK(KeyChord{ WXK_DELETE }.is_menu_accelerator()); + CHECK(KeyChord{ WXK_F5 }.is_menu_accelerator()); + CHECK_FALSE(KeyChord{ 'A' }.is_menu_accelerator()); + CHECK_FALSE(KeyChord{ 'A', wxMOD_SHIFT }.is_menu_accelerator()); + CHECK_FALSE(KeyChord{ '?' }.is_menu_accelerator()); + CHECK_FALSE(KeyChord{ WXK_SPACE }.is_menu_accelerator()); + CHECK_FALSE(KeyChord{}.is_menu_accelerator()); + + ShortcutRegistry registry; + CHECK(registry.accelerator(Shortcut::NewProject) == "Ctrl+N"); +#ifdef __APPLE__ + CHECK(registry.accelerator(Shortcut::DeleteSelected) == "Backspace"); +#else + CHECK(registry.accelerator(Shortcut::DeleteSelected) == "Del"); +#endif + CHECK(registry.accelerator(Shortcut::Arrange).empty()); + CHECK(registry.accelerator(Shortcut::ArrangePlate).empty()); + CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty()); +} + +TEST_CASE("Chords convert to wx accelerator entries", "[Shortcuts]") +{ + const wxAcceleratorEntry entry = KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.to_accelerator_entry(42); + CHECK(entry.GetFlags() == (wxACCEL_CTRL | wxACCEL_SHIFT)); + CHECK(entry.GetKeyCode() == 'S'); + CHECK(entry.GetCommand() == 42); + + const wxAcceleratorEntry bare = KeyChord{ WXK_BACK }.to_accelerator_entry(7); + CHECK(bare.GetFlags() == wxACCEL_NORMAL); + CHECK(bare.GetKeyCode() == WXK_BACK); +} + +#ifndef __APPLE__ +TEST_CASE("Display text matches the canonical text without translations", "[Shortcuts]") +{ + CHECK(KeyChord{ WXK_DELETE, wxMOD_CONTROL | wxMOD_SHIFT }.display() == "Ctrl+Shift+Del"); + CHECK(KeyChord{ WXK_DELETE, wxMOD_CONTROL | wxMOD_SHIFT }.display_parts() == std::vector{ "Ctrl", "Shift", "Del" }); + CHECK(KeyChord{ '+' }.display() == "+"); + CHECK(KeyChord{ WXK_UP, wxMOD_SHIFT }.display() == "Shift+Arrow Up"); // the arrows keep the old dialog's names + CHECK(KeyChord{ WXK_UP, wxMOD_SHIFT }.to_string() == "Shift+Up"); + CHECK(KeyChord{}.display().empty()); +} +#endif + +TEST_CASE("Every shortcut is listed under the section of its table row", "[Shortcuts]") +{ + CHECK(shortcut_section(Shortcut::NewProject) == ShortcutSection::Project); + CHECK(shortcut_section(Shortcut::Publish3mf) == ShortcutSection::Project); + CHECK(shortcut_section(Shortcut::SlicePlate) == ShortcutSection::SlicingAndPrinting); + CHECK(shortcut_section(Shortcut::GizmoBrimEars) == ShortcutSection::Gizmos); + CHECK(shortcut_section(Shortcut::MovesSliderEnd) == ShortcutSection::Sliders); + CHECK(shortcut_section(Shortcut::ViewDefault) == ShortcutSection::Camera); + CHECK(shortcut_section(Shortcut::KeyboardShortcuts) == ShortcutSection::Application); + CHECK(std::string(section_name(ShortcutSection::SlicingAndPrinting)) == "Slicing and printing"); +} + +TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]") +{ + ShortcutRegistry registry; + for (size_t i = 0; i < size_t(Shortcut::Count); ++i) { + const Shortcut shortcut = Shortcut(i); + CAPTURE(shortcut_info(shortcut).key); + CHECK(registry.conflicts(shortcut, registry.binding(shortcut)).empty()); + } +} + +TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]") +{ + ShortcutRegistry registry; + for (size_t i = 0; i < size_t(Shortcut::Count); ++i) { + const ShortcutInfo& info = shortcut_info(Shortcut(i)); + if (!info.modifier_variants) + continue; + CAPTURE(info.key); + const KeyChord chord = registry.binding(info.id); + for (int modifier : { int(wxMOD_SHIFT), int(wxMOD_CONTROL), int(wxMOD_SHIFT | wxMOD_CONTROL) }) + for (size_t c = 0; c < size_t(ShortcutContext::Count); ++c) + if (info.contexts & context_bit(ShortcutContext(c))) + CHECK_FALSE(registry.lookup(ShortcutContext(c), KeyChord{ chord.key, chord.modifiers | modifier }).has_value()); + } +} + +TEST_CASE("Lookups are scoped to the context of the key press", "[Shortcuts]") +{ + ShortcutRegistry registry; + const KeyChord ctrl_n{ 'N', wxMOD_CONTROL }; + const KeyChord ctrl_c{ 'C', wxMOD_CONTROL }; + const KeyChord a{ 'A' }; + const KeyChord c{ 'C' }; + + CHECK(registry.lookup(ShortcutContext::Global, ctrl_n) == Shortcut::NewProject); + CHECK_FALSE(registry.lookup(ShortcutContext::Plater, ctrl_n).has_value()); + + CHECK(registry.lookup(ShortcutContext::Plater, ctrl_c) == Shortcut::Copy); + CHECK(registry.lookup(ShortcutContext::ObjectList, ctrl_c) == Shortcut::Copy); + CHECK_FALSE(registry.lookup(ShortcutContext::Global, ctrl_c).has_value()); + + CHECK(registry.lookup(ShortcutContext::Plater, a) == Shortcut::Arrange); + CHECK_FALSE(registry.lookup(ShortcutContext::Preview, a).has_value()); + + CHECK(registry.lookup(ShortcutContext::Plater, c) == Shortcut::GizmoCut); + CHECK(registry.lookup(ShortcutContext::Preview, c) == Shortcut::ToggleGcodeWindow); + CHECK(registry.lookup(ShortcutContext::Painting, c) == Shortcut::PaintToolCircle); +} + +TEST_CASE("Stepping shortcuts match with Shift or Ctrl added to their binding", "[Shortcuts]") +{ + ShortcutRegistry registry; + using Match = ShortcutRegistry::Match; + auto same = [](const std::optional& match, Shortcut shortcut, int step_modifiers) { + return match.has_value() && match->shortcut == shortcut && match->step_modifiers == step_modifiers; + }; + CHECK(same(registry.match(ShortcutContext::Preview, { WXK_UP }), Shortcut::LayerSliderUp, 0)); + CHECK(same(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_SHIFT }), Shortcut::LayerSliderUp, wxMOD_SHIFT)); + CHECK(same(registry.match(ShortcutContext::Plater, { WXK_LEFT, wxMOD_CONTROL | wxMOD_SHIFT }), Shortcut::MoveSelectionLeft, wxMOD_CONTROL | wxMOD_SHIFT)); + CHECK(same(registry.match(ShortcutContext::Plater, { 'A', wxMOD_SHIFT }), Shortcut::ArrangePlate, 0)); // an exact binding wins + CHECK_FALSE(registry.match(ShortcutContext::Plater, { 'Q', wxMOD_CONTROL }).has_value()); // Orient has no variants + CHECK_FALSE(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_ALT }).has_value()); // Alt is not a step modifier + + CHECK_FALSE(registry.match(ShortcutContext::Preview, { WXK_HOME, wxMOD_SHIFT }).has_value()); // Home has no variants + + // A binding with Shift or Ctrl of its own has no steps. + registry.bind(Shortcut::LayerSliderUp, { WXK_UP, wxMOD_CONTROL }); + CHECK(same(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_CONTROL }), Shortcut::LayerSliderUp, 0)); + CHECK_FALSE(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_CONTROL | wxMOD_SHIFT }).has_value()); + CHECK_FALSE(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_SHIFT }).has_value()); + + // An exact binding on the combined step wins over it. + registry.bind(Shortcut::Arrange, { WXK_LEFT, wxMOD_CONTROL | wxMOD_SHIFT }); + CHECK(same(registry.match(ShortcutContext::Plater, { WXK_LEFT, wxMOD_CONTROL | wxMOD_SHIFT }), Shortcut::Arrange, 0)); + CHECK(same(registry.match(ShortcutContext::Plater, { WXK_LEFT, wxMOD_SHIFT }), Shortcut::MoveSelectionLeft, wxMOD_SHIFT)); +} + +TEST_CASE("Conflicts cover shared contexts and every Global shortcut", "[Shortcuts]") +{ + ShortcutRegistry registry; + CHECK(registry.conflicts(Shortcut::Arrange, { 'N', wxMOD_CONTROL }) == std::vector{ Shortcut::NewProject }); + CHECK(registry.conflicts(Shortcut::NewProject, { 'A' }) == std::vector{ Shortcut::Arrange }); + CHECK(registry.conflicts(Shortcut::ToggleGcodeWindow, { 'C' }).empty()); + CHECK(registry.conflicts(Shortcut::ZoomIn, { 'C' }) == std::vector{ Shortcut::GizmoCut, Shortcut::ToggleGcodeWindow }); + CHECK(registry.conflicts(Shortcut::Arrange, { 'A' }).empty()); // a shortcut never conflicts with itself + + // Only the exact chord conflicts; the steps of a stepping shortcut are reserved instead. + CHECK(registry.conflicts(Shortcut::GoToLayer, { WXK_UP, wxMOD_SHIFT }).empty()); + CHECK(registry.conflicts(Shortcut::LayerSliderUp, { 'G', wxMOD_SHIFT }) == std::vector{ Shortcut::GoToLayer }); +} + +TEST_CASE("Shift and Ctrl with a stepping shortcut's key are reserved for its steps", "[Shortcuts]") +{ + ShortcutRegistry registry; + CHECK(registry.step_owner(Shortcut::GoToLayer, { WXK_UP, wxMOD_SHIFT }) == Shortcut::LayerSliderUp); + CHECK(registry.step_owner(Shortcut::NewProject, { WXK_LEFT, wxMOD_CONTROL }) == Shortcut::MoveSelectionLeft); // Global shares every context + CHECK_FALSE(registry.step_owner(Shortcut::GoToLayer, { WXK_UP, wxMOD_CONTROL | wxMOD_SHIFT }).has_value()); // the combined step is free + CHECK_FALSE(registry.step_owner(Shortcut::MoveSelectionLeft, { WXK_LEFT, wxMOD_SHIFT }).has_value()); // its own step + CHECK_FALSE(registry.step_owner(Shortcut::PaintToolCircle, { WXK_UP, wxMOD_SHIFT }).has_value()); // Painting shares no context + registry.bind(Shortcut::LayerSliderUp, { WXK_UP, wxMOD_CONTROL }); + CHECK_FALSE(registry.step_owner(Shortcut::GoToLayer, { WXK_UP, wxMOD_CONTROL | wxMOD_SHIFT }).has_value()); // a modified binding has no steps +} + +TEST_CASE("Custom bindings replace the default and survive a config round trip", "[Shortcuts]") +{ + ShortcutRegistry registry; + const KeyChord w{ 'W' }; + registry.bind(Shortcut::Arrange, w); + + CHECK(registry.is_customized(Shortcut::Arrange)); + CHECK(registry.lookup(ShortcutContext::Plater, w) == Shortcut::Arrange); + CHECK_FALSE(registry.lookup(ShortcutContext::Plater, { 'A' }).has_value()); + + AppConfig config; + registry.save(config); + CHECK(config.get("shortcuts", "arrange") == "W"); + CHECK_FALSE(config.has("shortcuts", "orient")); + + ShortcutRegistry loaded; + loaded.load(config); + CHECK(loaded.lookup(ShortcutContext::Plater, w) == Shortcut::Arrange); + CHECK(loaded.binding(Shortcut::Orient) == KeyChord{ 'Q' }); + + SECTION("rebinding to the default clears the override") + { + registry.bind(Shortcut::Arrange, { 'A' }); + CHECK_FALSE(registry.is_customized(Shortcut::Arrange)); + registry.save(config); + CHECK_FALSE(config.has("shortcuts", "arrange")); + } + SECTION("an invalid chord unbinds and persists as none") + { + registry.bind(Shortcut::Arrange, KeyChord{}); + CHECK_FALSE(registry.binding(Shortcut::Arrange).valid()); + registry.save(config); + CHECK(config.get("shortcuts", "arrange") == "none"); + loaded.load(config); + CHECK_FALSE(loaded.lookup(ShortcutContext::Plater, { 'A' }).has_value()); + CHECK_FALSE(loaded.lookup(ShortcutContext::Plater, w).has_value()); + } + SECTION("reset_all restores every default") + { + registry.reset_all(); + CHECK(registry.lookup(ShortcutContext::Plater, { 'A' }) == Shortcut::Arrange); + CHECK_FALSE(registry.is_customized(Shortcut::Arrange)); + } +} + +TEST_CASE("A Global shortcut refuses a config binding that would swallow typing", "[Shortcuts]") +{ + AppConfig config; + // A string literal would pick AppConfig::set's bool overload. + config.set("shortcuts", "save_project", std::string("S")); + config.set("shortcuts", "new_project", std::string("F9")); + ShortcutRegistry registry; + registry.load(config); + CHECK(registry.binding(Shortcut::SaveProject) == KeyChord{ 'S', wxMOD_CONTROL }); + CHECK(registry.binding(Shortcut::NewProject) == KeyChord{ WXK_F9 }); +} + +TEST_CASE("Unreadable config entries fall back to the default binding", "[Shortcuts]") +{ + AppConfig config; + config.set("shortcuts", "arrange", std::string("Hyper+Q")); + config.set("shortcuts", "no_such_shortcut", std::string("Ctrl+Q")); + + ShortcutRegistry registry; + registry.load(config); + CHECK_FALSE(registry.is_customized(Shortcut::Arrange)); + CHECK(registry.lookup(ShortcutContext::Plater, { 'A' }) == Shortcut::Arrange); +}