diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b598ab7..a2c2790f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Configurable hotkeys**: **Settings -> Keyboard shortcuts** remaps shape tools (Move/Rotate/Scale/Extrude/Chamfer/Fillet/Polar duplicate/Cross-section), sketch tools (inspection, from face, operation axis, node/line/multi-line/arc/square/rectangle/circle/slot, Dimension), boolean commands (Cut/Fuse/Common), Delete, New/Open/Save, and Undo/Redo. Bindings persist as **`gui.hotkeys`** (human-readable chords; missing keys use defaults). Toolbar tooltips follow remaps. **Delete** / **Backspace** stay fixed delete aliases; Ctrl+Shift+Z stays a fixed redo alias. Fixed chords are rejected on assign; per-row **Reset** rejects when the factory chord is already bound elsewhere (same conflict message as assign); hotkey capture clears when Settings closes. +- **Configurable hotkeys**: **Settings -> Keyboard shortcuts** remaps shape tools (Move/Rotate/Scale/Extrude/Chamfer/Fillet/Polar duplicate/Cross-section), sketch tools (inspection, from face, operation axis, node/line/multi-line/arc/square/rectangle/circle/slot, Dimension), boolean commands (Cut/Fuse/Common), Delete, New/Open/Save, and Undo/Redo. Bindings persist as **`gui.hotkeys`** (human-readable chords; missing keys use defaults). Toolbar tooltips follow remaps. **Delete** / **Backspace** stay fixed delete aliases; Ctrl+Shift+Z stays a fixed redo alias. Fixed, unsupported, and conflicting chords are rejected on assign with a status toast (and inline message); remaps accept letters, digits, and Space only (punctuation/numpad rejected); per-row **Reset** rejects when the factory chord is already bound elsewhere (same conflict message as assign); hotkey capture clears when Settings closes. A **?** at the top of the section opens the [Keyboard shortcuts](https://ezycad.readthedocs.io/en/latest/usage-settings.html#keyboard-shortcuts) settings guide. - **Extrude Twist**: Options **Twist** checkbox. Two-phase flow locks height first, then sets twist angle about the face centroid (mouse, or Shift+Tab for degrees). Height length dim is cleared on lock; a temporary angle annotation on the extruded front face shows degrees during twist. With **Both sides**, ends twist symmetrically by +/- half the angle. Geometry uses ruled thru-sections with compatibility off (keeps tooth pairing) and cuts twisted hole solids so face bores survive; straight prism when twist is zero. Dense-face **Extrude fast preview** also applies during Twist (face copies translate and rotate; finalize builds the solid). @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`gui.hotkeys` load**: duplicate-chord cleanup no longer leaves two actions on the same key when the later row's factory chord is the colliding key (e.g. Move and Rotate both `"R"`). Earlier remaps that steal a later action's factory chord are restored to defaults so each binding stays unique. +- **Move / Rotate axis keys**: unmodified X / Y / Z are reserved so Settings remaps cannot steal axis constraints (Move) or rotation-axis pick (Rotate). Existing `"X"` / `"Y"` / `"Z"` bindings in **`gui.hotkeys`** are dropped on load. + +- **Hotkey capture**: punctuation (e.g. , / .) and other non-round-trippable keys are rejected with an unsupported-key message instead of saving as opaque `KeyN` chords that fail to reload. + ## [0.4.0] - 2026-07-25 ### Performance diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 1cf45e1e..a4acf502 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -9,7 +9,8 @@ This guide covers the **Settings** pane (what is on screen), the **View** menu ( 3. [Options panel](#options-panel) 4. [Where settings are stored](#where-settings-are-stored) 5. [Startup project](#startup-project) -6. [Settings file reference](#settings-file-reference) +6. [Keyboard shortcuts](#keyboard-shortcuts) +7. [Settings file reference](#settings-file-reference) ## View menu @@ -77,7 +78,7 @@ Between those, the pane has collapsible sections. Expand a section to see its co **WebAssembly build** — Open CASCADE line-width controls (`SetWidth` / `Prs3d` line width) have no visible effect in the browser (WebGL/GLES). The **Settings** pane hides **Edge thickness**, **Dimension line width**, and **Snap guide line width** on the web build. Saved JSON may still contain those keys from a desktop session; they are not shown as editable rows in the browser UI. -7. **Keyboard shortcuts** — Table of remappable actions: shape tools (Move, Rotate, Scale, Extrude, Chamfer, Fillet, Polar duplicate, Cross-section), sketch tools (inspection, from face, operation axis, node/line/multi-line/arc/square/rectangle/circle/slot, Dimension), boolean commands (Cut, Fuse, Common), Delete, New/Open/Save, and Undo/Redo. Click a shortcut cell, then press the new key combination; **Esc** cancels capture. Closing Settings cancels capture. **Reset** restores that row's factory chord (same conflict rules as assign: rejected if another action already uses that chord). Duplicate chords and fixed/reserved chords (Esc, Enter, Tab, Delete/Backspace, selection digits, view zoom/orbit/roll, Ctrl+Shift+Z) are rejected with an inline message. Stored as **`gui.hotkeys`** (human-readable strings such as `"G"`, `"Ctrl+S"`, `"Shift+L"`). Missing keys merge to built-in defaults. **Defaults** at the bottom of Settings restores factory chords. Toolbar tooltips for remappable modes and boolean commands follow the current bindings. See [Hotkeys](usage.md#hotkeys). +7. **Keyboard shortcuts** — Remappable chords for modeling and sketch tools, booleans, Delete, New/Open/Save, and Undo/Redo. See [Keyboard shortcuts](#keyboard-shortcuts). 8. **Startup project** — **Desktop only:** **Load last opened on startup** (checkbox, with **?**), then **Last opened path:** … or **(No path saved yet.)** Then **Save current as startup project**, **Clear saved startup** (with **?**). **WebAssembly:** no load-last row; only the two buttons and **?**. See [Startup project](#startup-project). @@ -144,6 +145,40 @@ Similar to Blender's startup file: EzyCad can load a **default document** when i - **Next runs** - If a saved startup exists, it is loaded instead of the bundled file. The session starts **untitled** (so **Save** does not overwrite your startup file until you pick a path). - **Clear saved startup** - In **Settings -> Startup project**, click **Clear saved startup**; the next launch uses the bundled `res/default.ezy` again. +## Keyboard shortcuts + +Remap modeling and sketch tool chords, boolean commands, Delete, New/Open/Save, and Undo/Redo in **View -> Settings -> Keyboard shortcuts**. Default key lists live in [usage.md -> Hotkeys](usage.md#hotkeys) (and [usage-sketch.md -> Hotkeys](usage-sketch.md#hotkeys) for sketch-focused summaries). Toolbar tooltips for remappable modes and boolean commands follow the current bindings. + +**How to remap** + +1. Expand **Keyboard shortcuts**. +2. Click the shortcut cell for an action (it shows **Press key...**). +3. Press the new key combination. **Esc** cancels capture without changing the binding. Closing **Settings** also cancels capture. +4. Per-row **Reset** restores that action's factory chord (rejected if another action already uses that chord; same conflict message as assign). +5. **Defaults** at the bottom of Settings restores all factory chords (and other bundled settings). + +A **?** at the top of the section (when [UI verbosity](#settings-pane) is high enough) opens this page. + +**What you can bind** + +- A letter, digit, or Space, with optional Ctrl / Shift / Alt / Super. +- Punctuation (for example , / .) and numpad keys are rejected so stored chords stay human-readable. + +**Reserved (cannot remap)** + +| Keys | Role | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Esc, Enter, Tab / Shift+Tab | Cancel, confirm, distance / angle input | +| Delete, Backspace | Always delete selection (fixed aliases) | +| Selection filter digits (19) | Normal-mode shape filter | +| View zoom / orbit / roll | See [usage.md -> View navigation](usage.md#view-navigation) | +| Unmodified X / Y / Z | Move axis constraints; Rotate axis pick | +| Ctrl+Shift+Z | Fixed redo alias (alongside remappable Ctrl+Y) | + +Two actions cannot share the same chord. Reserved, unsupported, and duplicate chords show an inline message under the table and a status toast with the reason. + +**Persistence** — Bindings are stored as **`gui.hotkeys`**: action id keys to chord strings such as `"G"`, `"Ctrl+S"`, `"Shift+L"`. Missing ids merge to built-in defaults; reserved or invalid chords are dropped on load. See [Settings file reference](#gui) (`hotkeys`). + ## Settings file reference The on-disk (or localStorage) document is JSON with a **version**, optional **ImGui** layout blob, an **`occt_view`** object for the 3D view, and a **`gui`** object. **`gui`** includes pane visibility and other flags that are **not** all exposed as controls in the **Settings** pane (for example **show sketch list** comes from **View**, not from a row inside **Settings**). @@ -232,7 +267,7 @@ If saved layout text has no `[Docking]` section (older installs), a default dock | `default_2d_view_height` | number | Vertical sketch-plane span, stored in **inches** (allowed range **0.1** to **1000**; default **3**). Settings UI shows this in **`default_project_unit`**. | | `load_last_opened_on_startup` | boolean | Desktop: open the last `.ezy` on launch. **Legacy:** `load_last_saved_on_startup` is read as a fallback if the newer key is absent. | | `last_opened_project_path` | string | Path of the last opened project for the option above. **Legacy:** `last_saved_project_path` is accepted if the newer key is missing. | -| `hotkeys` | object | Remappable keyboard shortcuts: action id string keys to human-readable chord strings (for example `"mode.move": "G"`, `"mode.add_edge": "L"`, `"cmd.shape_cut": "Ctrl+Shift+C"`, `"edit.delete": "Shift+D"`). Missing keys merge to built-in defaults. See **Settings -> Keyboard shortcuts** and [usage.md#hotkeys](usage.md#hotkeys). | +| `hotkeys` | object | Remappable keyboard shortcuts: action id string keys to human-readable chord strings (for example `"mode.move": "G"`, `"mode.add_edge": "L"`, `"cmd.shape_cut": "Ctrl+Shift+C"`, `"edit.delete": "Shift+D"`). Missing keys merge to built-in defaults. See [Keyboard shortcuts](#keyboard-shortcuts) and [usage.md#hotkeys](usage.md#hotkeys). | Each **`imgui_style_dark`** / **`imgui_style_light`** object may contain: diff --git a/docs/usage-sketch.md b/docs/usage-sketch.md index 76da6acb..81b83430 100644 --- a/docs/usage-sketch.md +++ b/docs/usage-sketch.md @@ -117,7 +117,7 @@ Common keyboard shortcuts (hotkeys) while working in 2D sketch mode or with sket **Notes:** - Tab / Shift+Tab work even when focus is in the 3D view (they are routed to the active sketch tool for precise entry). - When an angle constraint is active, node snapping is typically relaxed or disabled to preserve the exact angle. -- Global hotkeys (mode switches like G Move, sketch tools, undo, view navigation, selection filters) are in the main [Hotkeys](usage.md#hotkeys) section. Remap sketch and modeling chords in **View -> Settings -> Keyboard shortcuts**. +- Global hotkeys (mode switches like G Move, sketch tools, undo, view navigation, selection filters) are in the main [Hotkeys](usage.md#hotkeys) section. Remap sketch and modeling chords in **View -> Settings -> Keyboard shortcuts** ([usage-settings.md](usage-settings.md#keyboard-shortcuts)). ### Move / rotate / polar axis constraints (when those options are active) diff --git a/docs/usage.md b/docs/usage.md index 30248b73..01d71630 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -691,7 +691,7 @@ For more on 3D solids and the viewer, see [3D viewer (Open CASCADE)](usage-occt- ## Hotkeys -Mode, file, and edit chords in the **General Operations** and **Modeling Shortcuts** tables below are the **defaults**. Remap them in **View -> Settings -> Keyboard shortcuts** (persisted as **`gui.hotkeys`**). Toolbar tooltips for remappable modes show the current chord. Fixed keys (Esc, Enter, Tab / Shift+Tab, selection filter digits, view zoom/orbit/roll, and Ctrl+Shift+Z redo) are reserved and cannot be assigned in Settings. **Delete** and **Backspace** always delete the selection even if **Delete** is remapped off Shift+D. Ctrl+Shift+Z remains a fixed redo alias alongside the remappable Ctrl+Y. +Mode, file, and edit chords in the **General Operations** and **Modeling Shortcuts** tables below are the **defaults**. Remap them in **View -> Settings -> Keyboard shortcuts** — see [usage-settings.md -> Keyboard shortcuts](usage-settings.md#keyboard-shortcuts) for bindable keys, reserved chords, Reset/Defaults, and **`gui.hotkeys`** persistence. Toolbar tooltips for remappable modes show the current chord. **Delete** and **Backspace** always delete the selection even if **Delete** is remapped off Shift+D. Ctrl+Shift+Z remains a fixed redo alias alongside the remappable Ctrl+Y. ### General Operations diff --git a/src/doc/gui.md b/src/doc/gui.md index 02159734..3b0ef7db 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -54,7 +54,7 @@ When adding a `Mode` to [`mode.h`](../mode.h) (`EZY_MODE_LIST`), a toolbar butto | 4 | `gui.hotkeys` entry in [`res/ezycad_settings.json`](../../res/ezycad_settings.json) | | 5 | User docs: [usage.md](../../docs/usage.md#hotkeys) Modeling table; sketch tools also [usage-sketch.md](../../docs/usage-sketch.md#hotkeys); [usage-settings.md](../../docs/usage-settings.md) if labels change; `CHANGELOG.md` | -Pick a default that does not collide with existing `c_actions` chords or fixed keys (Esc, Enter, Tab, digits, unmodified X/Y/Z axis toggles). Also wire parent-mode / Options / doc URL maps as usual for new modes. +Pick a default that does not collide with existing `c_actions` chords or fixed keys (Esc, Enter, Tab, digits, unmodified X/Y/Z axis toggles — reserved via `is_reserved_chord`). Also wire parent-mode / Options / doc URL maps as usual for new modes. `Occt_view::on_mode` also sets `AIS_ViewController::SetAllowHighlight(false)` for `Move` / `Rotate` / `Scale` (and `ClearDetected`) so idle mouse moves do not run dynamic `MoveTo` while transform preview leaves selection BVHs at the pre-transform pose; other modes restore highlight. Orbit/pan still receive `UpdateMousePosition` when buttons are held. When LMB finalizes an active transform (operands loaded), `on_mouse_button` skips `PressMouseButton` / `ReleaseMouseButton` for that click so AIS `SelectDetected` on release cannot replace the restored multi-selection with the single shape under the cursor. @@ -163,7 +163,7 @@ Remappable chords live in `Gui_hotkeys` (`gui_hotkeys.h` / `.cpp`), owned by `GU | Move-mode keys | `Mode::Move` | `on_key_move_mode_` (axis constraints X/Y/Z); hardcoded | | Rotate-mode keys | `Mode::Rotate` | `on_key_rotate_mode_` (axis pick, Tab angle); hardcoded | -Default remappable chords include G/R/S/E/C/F/D shape tools; sketch tools N/L/A/Q/B/O/U/I/P and Shift variants; Shift+P polar, Shift+X cross-section; Ctrl+Shift+C/F/M booleans; Shift+D delete; Ctrl+N/O/S; Ctrl+Z / Ctrl+Y. Avoid binding unmodified X/Y/Z (axis toggles in Move/Rotate). +Default remappable chords include G/R/S/E/C/F/D shape tools; sketch tools N/L/A/Q/B/O/U/I/P and Shift variants; Shift+P polar, Shift+X cross-section; Ctrl+Shift+C/F/M booleans; Shift+D delete; Ctrl+N/O/S; Ctrl+Z / Ctrl+Y. Unmodified X/Y/Z are reserved for Move/Rotate axis toggles (`is_reserved_chord`); Shift+X remains free for cross-section. Remappable keys must pass `is_bindable_key` (letters, digits, Space, and named keys that round-trip in settings JSON); punctuation such as `,` / `.` and numpad keys are rejected. Settings **Keyboard shortcuts** has a `?` to `doc_urls::k_hotkeys` ([usage-settings.md#keyboard-shortcuts](../../docs/usage-settings.md#keyboard-shortcuts)). See also [`src/doc/sketch.md`](sketch.md) and [`src/doc/shape.md`](shape.md) for per-mode mouse routing after `GUI` delegates to `Occt_view`. diff --git a/src/gui.h b/src/gui.h index cf85afab..5f4eebc4 100644 --- a/src/gui.h +++ b/src/gui.h @@ -217,6 +217,7 @@ inline constexpr const char* k_usage_settings_options = "https://ezycad.re inline constexpr const char* k_occt_view = "https://ezycad.readthedocs.io/en/latest/usage-occt-view.html"; inline constexpr const char* k_startup_project = "https://ezycad.readthedocs.io/en/latest/usage-settings.html#startup-project"; inline constexpr const char* k_extrude_sketch_face = "https://ezycad.readthedocs.io/en/latest/usage.html#extrude-sketch-face-tool-e"; +inline constexpr const char* k_hotkeys = "https://ezycad.readthedocs.io/en/latest/usage-settings.html#keyboard-shortcuts"; // clang-format on } // namespace doc_urls diff --git a/src/gui_hotkeys.cpp b/src/gui_hotkeys.cpp index c1e6f6fc..c4bc0498 100644 --- a/src/gui_hotkeys.cpp +++ b/src/gui_hotkeys.cpp @@ -18,7 +18,7 @@ struct Action_meta Key_chord def; }; -// Defaults avoid existing shape letters (G/R/S/E/C/F/D), X/Y/Z (axis toggles), and digits. +// Defaults avoid existing shape letters (G/R/S/E/C/F/D), reserved X/Y/Z (axis toggles), and digits. // Sketch: AutoCAD/FreeCAD-flavored mnemonics; booleans use Ctrl+Shift chords. // clang-format off constexpr Action_meta c_actions[] = { @@ -71,6 +71,7 @@ static_assert(sizeof(c_actions) / sizeof(c_actions[0]) == Gui_hotkeys::k_count); case GLFW_KEY_LEFT_SUPER: case GLFW_KEY_RIGHT_SUPER: return true; + default: return false; } @@ -86,18 +87,14 @@ static_assert(sizeof(c_actions) / sizeof(c_actions[0]) == Gui_hotkeys::k_count); switch (key) { - case GLFW_KEY_DELETE: - return "Delete"; - case GLFW_KEY_BACKSPACE: - return "Backspace"; - case GLFW_KEY_TAB: - return "Tab"; - case GLFW_KEY_ENTER: - return "Enter"; - case GLFW_KEY_ESCAPE: - return "Esc"; - case GLFW_KEY_SPACE: - return "Space"; + // clang-format off + case GLFW_KEY_DELETE: return "Delete"; + case GLFW_KEY_BACKSPACE: return "Backspace"; + case GLFW_KEY_TAB: return "Tab"; + case GLFW_KEY_ENTER: return "Enter"; + case GLFW_KEY_ESCAPE: return "Esc"; + case GLFW_KEY_SPACE: return "Space"; + // clang-format on default: { std::ostringstream oss; @@ -111,13 +108,12 @@ static_assert(sizeof(c_actions) / sizeof(c_actions[0]) == Gui_hotkeys::k_count); { if (tok.size() == 1) { + // clang-format off const char c = tok[0]; - if (c >= 'A' && c <= 'Z') - return GLFW_KEY_A + (c - 'A'); - if (c >= 'a' && c <= 'z') - return GLFW_KEY_A + (c - 'a'); - if (c >= '0' && c <= '9') - return GLFW_KEY_0 + (c - '0'); + if (c >= 'A' && c <= 'Z') return GLFW_KEY_A + (c - 'A'); + if (c >= 'a' && c <= 'z') return GLFW_KEY_A + (c - 'a'); + if (c >= '0' && c <= '9') return GLFW_KEY_0 + (c - '0'); + // clang-format on } auto eq = [&](const char* lit) @@ -125,24 +121,22 @@ static_assert(sizeof(c_actions) / sizeof(c_actions[0]) == Gui_hotkeys::k_count); const std::string_view l(lit); if (tok.size() != l.size()) return false; + for (size_t i = 0; i < tok.size(); ++i) if (std::tolower(static_cast(tok[i])) != std::tolower(static_cast(l[i]))) return false; + return true; }; - if (eq("Delete")) - return GLFW_KEY_DELETE; - if (eq("Backspace")) - return GLFW_KEY_BACKSPACE; - if (eq("Tab")) - return GLFW_KEY_TAB; - if (eq("Enter") || eq("Return")) - return GLFW_KEY_ENTER; - if (eq("Esc") || eq("Escape")) - return GLFW_KEY_ESCAPE; - if (eq("Space")) - return GLFW_KEY_SPACE; + // clang-format off + if (eq("Delete")) return GLFW_KEY_DELETE; + if (eq("Backspace")) return GLFW_KEY_BACKSPACE; + if (eq("Tab")) return GLFW_KEY_TAB; + if (eq("Enter") || eq("Return")) return GLFW_KEY_ENTER; + if (eq("Esc") || eq("Escape")) return GLFW_KEY_ESCAPE; + if (eq("Space")) return GLFW_KEY_SPACE; + // clang-format on return std::nullopt; } } // namespace @@ -165,10 +159,12 @@ bool Gui_hotkeys::reset_action(Gui_action action) const int i = static_cast(action); if (i < 0 || i >= k_count) return false; + // Already factory: succeed without rewriting (avoids a false conflict if a // duplicate somehow already exists on another row). if (m_chords[i] == c_actions[i].def) return true; + return set_chord(action, c_actions[i].def); } @@ -177,6 +173,7 @@ Key_chord Gui_hotkeys::chord_for(Gui_action action) const const int i = static_cast(action); if (i < 0 || i >= k_count) return {}; + return m_chords[i]; } @@ -189,9 +186,33 @@ std::optional Gui_hotkeys::action_for(int key, int mods) const for (int i = 0; i < k_count; ++i) if (m_chords[i] == needle) return static_cast(i); + return std::nullopt; } +bool Gui_hotkeys::is_bindable_key(int key) +{ + if (key >= GLFW_KEY_A && key <= GLFW_KEY_Z) + return true; + + if (key >= GLFW_KEY_0 && key <= GLFW_KEY_9) + return true; + + switch (key) + { + case GLFW_KEY_DELETE: + case GLFW_KEY_BACKSPACE: + case GLFW_KEY_TAB: + case GLFW_KEY_ENTER: + case GLFW_KEY_ESCAPE: + case GLFW_KEY_SPACE: + return true; + + default: + return false; + } +} + bool Gui_hotkeys::is_reserved_chord(Key_chord chord) { chord.mods = normalize_mods(chord.mods); @@ -206,6 +227,7 @@ bool Gui_hotkeys::is_reserved_chord(Key_chord chord) case GLFW_KEY_DELETE: case GLFW_KEY_BACKSPACE: return true; + default: break; } @@ -219,33 +241,34 @@ bool Gui_hotkeys::is_reserved_chord(Key_chord chord) // Selection filter digits (Normal mode); also blocks Shift+digit. if (no_ctrl_alt_super) - { if ((key >= GLFW_KEY_1 && key <= GLFW_KEY_9) || (key >= GLFW_KEY_KP_1 && key <= GLFW_KEY_KP_9)) return true; - } // View zoom (+/-) without Ctrl/Alt. if ((mods & (GLFW_MOD_CONTROL | GLFW_MOD_ALT)) == 0) { if (key == GLFW_KEY_KP_ADD || key == GLFW_KEY_KP_SUBTRACT || key == GLFW_KEY_MINUS) return true; + if (key == GLFW_KEY_EQUAL && (mods & GLFW_MOD_SHIFT) != 0) return true; } // View roll: Shift + KP4/6, 4/6, or Left/Right. if ((mods & GLFW_MOD_SHIFT) != 0 && (mods & (GLFW_MOD_CONTROL | GLFW_MOD_ALT)) == 0) - { if (key == GLFW_KEY_KP_4 || key == GLFW_KEY_KP_6 || key == GLFW_KEY_4 || key == GLFW_KEY_6 || key == GLFW_KEY_LEFT || key == GLFW_KEY_RIGHT) return true; - } // View orbit / snap: unmodified numpad 2/4/5/6/8. + // Move / Rotate axis toggles: unmodified X/Y/Z (mode handlers run after remaps). if (mods == 0) { if (key == GLFW_KEY_KP_2 || key == GLFW_KEY_KP_4 || key == GLFW_KEY_KP_5 || key == GLFW_KEY_KP_6 || key == GLFW_KEY_KP_8) return true; + + if (key == GLFW_KEY_X || key == GLFW_KEY_Y || key == GLFW_KEY_Z) + return true; } return false; @@ -256,7 +279,8 @@ bool Gui_hotkeys::set_chord(Gui_action action, Key_chord chord) const int ai = static_cast(action); if (ai < 0 || ai >= k_count) return false; - if (is_pure_modifier_key(chord.key) || chord.key == 0) + + if (is_pure_modifier_key(chord.key) || chord.key == 0 || !is_bindable_key(chord.key)) return false; chord.mods = normalize_mods(chord.mods); @@ -267,6 +291,7 @@ bool Gui_hotkeys::set_chord(Gui_action action, Key_chord chord) { if (i == ai) continue; + if (m_chords[i] == chord) return false; } @@ -280,6 +305,7 @@ const char* Gui_hotkeys::action_id(Gui_action action) const int i = static_cast(action); if (i < 0 || i >= k_count) return ""; + return c_actions[i].id; } @@ -316,15 +342,12 @@ std::string Gui_hotkeys::format_chord(Key_chord chord) { chord.mods = normalize_mods(chord.mods); std::string out; - if (chord.mods & GLFW_MOD_CONTROL) - out += "Ctrl+"; - if (chord.mods & GLFW_MOD_SHIFT) - out += "Shift+"; - if (chord.mods & GLFW_MOD_ALT) - out += "Alt+"; - if (chord.mods & GLFW_MOD_SUPER) - out += "Super+"; - + // clang-format off + if (chord.mods & GLFW_MOD_CONTROL) out += "Ctrl+"; + if (chord.mods & GLFW_MOD_SHIFT) out += "Shift+"; + if (chord.mods & GLFW_MOD_ALT) out += "Alt+"; + if (chord.mods & GLFW_MOD_SUPER) out += "Super+"; + // clang-format on out += key_token(chord.key); return out; } @@ -344,7 +367,7 @@ std::optional Gui_hotkeys::parse_chord(std::string_view text) // Trim spaces while (!part.empty() && part.front() == ' ') part.remove_prefix(1); - + while (!part.empty() && part.back() == ' ') part.remove_suffix(1); @@ -356,9 +379,11 @@ std::optional Gui_hotkeys::parse_chord(std::string_view text) const std::string_view l(lit); if (part.size() != l.size()) return false; + for (size_t i = 0; i < part.size(); ++i) if (std::tolower(static_cast(part[i])) != std::tolower(static_cast(l[i]))) return false; + return true; }; @@ -398,6 +423,7 @@ nlohmann::json Gui_hotkeys::to_json() const nlohmann::json j = nlohmann::json::object(); for (int i = 0; i < k_count; ++i) j[c_actions[i].id] = format_chord(m_chords[i]); + return j; } @@ -410,12 +436,15 @@ void Gui_hotkeys::merge_from_json(const nlohmann::json& obj) { if (!it.value().is_string()) continue; + const std::optional act = action_from_id(it.key()); if (!act) continue; + const std::optional chord = parse_chord(it.value().get()); - if (!chord || is_reserved_chord(*chord)) + if (!chord || !is_bindable_key(chord->key) || is_reserved_chord(*chord)) continue; + // Apply without conflict reject against other remaps still loading; rebuild unique at end. m_chords[static_cast(*act)] = *chord; } @@ -430,23 +459,22 @@ void Gui_hotkeys::merge_from_json(const nlohmann::json& obj) { changed = false; for (int i = 0; i < k_count; ++i) - { for (int j = 0; j < i; ++j) { if (m_chords[i] != m_chords[j]) continue; + if (m_chords[i] != c_actions[i].def) { m_chords[i] = c_actions[i].def; - changed = true; + changed = true; } else if (m_chords[j] != c_actions[j].def) { m_chords[j] = c_actions[j].def; - changed = true; + changed = true; } break; } - } } } diff --git a/src/gui_hotkeys.h b/src/gui_hotkeys.h index 1751a318..eae01da4 100644 --- a/src/gui_hotkeys.h +++ b/src/gui_hotkeys.h @@ -66,7 +66,7 @@ class Gui_hotkeys [[nodiscard]] Key_chord chord_for(Gui_action action) const; [[nodiscard]] std::optional action_for(int key, int mods) const; - /// Returns false if \a chord is reserved, invalid, or already bound to a different action. + /// Returns false if \a chord is reserved, not bindable, invalid, or already bound to a different action. [[nodiscard]] bool set_chord(Gui_action action, Key_chord chord); [[nodiscard]] static const char* action_id(Gui_action action); @@ -75,7 +75,11 @@ class Gui_hotkeys [[nodiscard]] static std::optional action_from_id(std::string_view id); [[nodiscard]] static int normalize_mods(int mods); - /// Fixed chords handled outside the remappable map (Esc, Tab, digits, view nav, Ctrl+Shift+Z, ...). + /// Letters, digits, and named keys that round-trip through \ref format_chord / \ref parse_chord + /// (punctuation and numpad keys are rejected so settings JSON stays readable). + [[nodiscard]] static bool is_bindable_key(int key); + /// Fixed chords handled outside the remappable map (Esc, Tab, digits, view nav, + /// unmodified X/Y/Z Move/Rotate axes, Ctrl+Shift+Z, ...). [[nodiscard]] static bool is_reserved_chord(Key_chord chord); [[nodiscard]] static std::string format_chord(Key_chord chord); [[nodiscard]] static std::optional parse_chord(std::string_view text); diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index 23593458..908bd951 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -444,14 +444,22 @@ bool GUI::try_capture_hotkey_press_(int key, int mods) } const Key_chord chord{key, Gui_hotkeys::normalize_mods(mods)}; + if (!Gui_hotkeys::is_bindable_key(key)) + { + m_hotkey_capture_error = "Unsupported key. Use a letter, digit, or Space (modifiers allowed)."; + show_message(m_hotkey_capture_error); + return true; + } if (Gui_hotkeys::is_reserved_chord(chord)) { m_hotkey_capture_error = "Reserved: " + Gui_hotkeys::format_chord(chord) + " is a fixed shortcut and cannot be remapped."; + show_message(m_hotkey_capture_error); return true; } if (!m_hotkeys.set_chord(*m_hotkey_capture_action, chord)) { m_hotkey_capture_error = "Conflict: " + Gui_hotkeys::format_chord(chord) + " is already assigned."; + show_message(m_hotkey_capture_error); return true; } diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index 04b625a6..43a6fca2 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -2083,11 +2083,16 @@ void GUI::settings_() if (settings_collapsing_header_("Keyboard shortcuts", m_settings_headers.hotkeys)) { + GUI_DOC_HELP_("How to remap chords, which keys are reserved, and gui.hotkeys persistence. " + "Click ? to open Keyboard shortcuts in the settings guide.", + doc_urls::k_hotkeys); + if (ui_show_contextual_help()) ImGui::TextWrapped("Click a shortcut, then press the new key combination. Esc cancels capture. " - "Two actions cannot share the same chord. Fixed shortcuts (Esc, Enter, Tab, " - "Delete/Backspace, selection digits, view zoom/orbit/roll, Ctrl+Shift+Z redo) " - "cannot be remapped. Delete and Backspace always delete selection."); + "Use a letter, digit, or Space (optional modifiers). Two actions cannot share " + "the same chord. Fixed shortcuts (Esc, Enter, Tab, Delete/Backspace, selection " + "digits, view zoom/orbit/roll, unmodified X/Y/Z, Ctrl+Shift+Z redo) cannot be " + "remapped. Delete and Backspace always delete selection."); if (ImGui::BeginTable("settings_hotkeys", 3, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_RowBg)) { @@ -2119,8 +2124,9 @@ void GUI::settings_() { if (!m_hotkeys.reset_action(action)) { - m_hotkey_capture_error = "Conflict: " + Gui_hotkeys::format_chord(Gui_hotkeys::default_chord(action)) + - " is already assigned."; + m_hotkey_capture_error = + "Conflict: " + Gui_hotkeys::format_chord(Gui_hotkeys::default_chord(action)) + " is already assigned."; + show_message(m_hotkey_capture_error); } else {