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 (1–9) | 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
{