From 38e3fcb83f13a1ef6143976fda468510494f2aee Mon Sep 17 00:00:00 2001 From: Travonte Date: Mon, 24 Aug 2026 10:12:22 -0400 Subject: [PATCH 1/5] feat(editor): editor keybindings are configurable via [keys] Editor shortcuts move from hardcoded Qt::Key checks to bindings loaded from a [keys] section in omasnap.conf, following the palette/output config pattern. Each action takes one key or a comma-separated list, so the aliases keep working by default (marker=C,M; redo=Ctrl+Shift+Z, Ctrl+Y; zoom +/=/Ctrl+=). Matching is exact on key plus modifiers, and keypad bits never distinguish. Any unparsable value or a key bound to two actions of the same phase rejects the whole section back to defaults with a warning naming the offender; cross-phase reuse stays legal (R restores a region while selecting, draws rectangles while editing). Status lines, tooltips, and the hotkey legend read the primary binding through keyHint(), so hints follow rebinds instead of lying about keys. Pin-window and scrolling-panel keys stay fixed. --- CMakeLists.txt | 4 + README.md | 47 +++++ src/editor.cpp | 352 ++++++++++++++++++++------------- src/editor.hpp | 10 + src/keybind-config.cpp | 256 ++++++++++++++++++++++++ src/keybind-config.hpp | 80 ++++++++ tests/editor-smoke.cpp | 7 + tests/keybind-config-smoke.cpp | 143 ++++++++++++++ tests/keybind-config-smoke.hpp | 6 + 9 files changed, 768 insertions(+), 137 deletions(-) create mode 100644 src/keybind-config.cpp create mode 100644 src/keybind-config.hpp create mode 100644 tests/keybind-config-smoke.cpp create mode 100644 tests/keybind-config-smoke.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2cdac5e2..6bbc90de 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,8 @@ add_library(omasnap-core STATIC src/palette-config.hpp src/background-config.cpp src/background-config.hpp + src/keybind-config.cpp + src/keybind-config.hpp src/recent-snaps.cpp src/recent-snaps.hpp src/output-config.cpp @@ -149,6 +151,8 @@ qt_add_executable(omasnap-smoke tests/cut-mapping-smoke.hpp tests/palette-config-smoke.cpp tests/palette-config-smoke.hpp + tests/keybind-config-smoke.cpp + tests/keybind-config-smoke.hpp src/cli-path.cpp src/recent-snaps.cpp src/recent-snaps.hpp diff --git a/README.md b/README.md index 614733ca..8073c01c 100644 --- a/README.md +++ b/README.md @@ -292,8 +292,55 @@ image = ~/Pictures/backdrops/desk.jpg # automatic window-gray mat; `off` stays transparent. `custom` only takes # effect once `image` above loads successfully. default = custom + +[keys] +# Editor keybindings. Each action takes one key or a comma-separated list +# (any entry fires the action); the first entry is the one shown in hints. +# Values use Qt names: letters and digits as-is, modifiers joined with +, +# e.g. Ctrl+Z, Alt+D, Space, F5. +arrow = W +redo = Ctrl+Shift+Z, Ctrl+Y ``` +Every key is optional — an action missing from `[keys]` keeps its default +(the keys shown in [Controls](#controls)). If any value fails to parse, or a +key ends up bound to two actions of the same phase (editing vs selecting), the +whole `[keys]` section is ignored and a warning names the offender; the same +key may still mean different things per phase, like `R` restoring the last +region while selecting and drawing rectangles while editing. + +Bindable actions and their defaults: + +| Action | Default | Effect | +|---|---|---| +| `select` | `V` | Select/move layers | +| `arrow` | `A` | Arrow | +| `line` | `L` | Straight line | +| `freehand` | `F` | Freehand stroke | +| `highlighter` | `H` | Highlighter | +| `marker` | `C`, `M` | Numbered marker | +| `rectangle` / `ellipse` | `R` / `E` | Shapes; press again to toggle fill | +| `spotlight` | `S` | Spotlight/loupe; again cycles shape | +| `redact` | `D` | Redact; again toggles pixelate/solid | +| `cut` | `X` | Cut out a band | +| `text` | `T` | Text; again toggles the pill | +| `eyedropper` | `I` | Sample a custom color from the image | +| `ocr` | `O` | Copy all text in the image | +| `pin` | `P` | Pin the capture on screen | +| `backdrop` | `B` | Cycle backdrop | +| `duplicate` | `Alt+D` | Duplicate selected layer | +| `undo` / `redo` | `Ctrl+Z` / `Ctrl+Shift+Z`, `Ctrl+Y` | Undo / redo | +| `copy` / `save` | `Ctrl+C` / `Ctrl+S` | Finish with that output only | +| `select-all` | `Ctrl+A` | Select everything (or the full monitor) | +| `zoom-in` / `zoom-out` / `zoom-fit` | `+`, `=`, `Ctrl+=` / `-`, `_`, `Ctrl+-` / `0` | View zoom | +| `color1` … `color8` | `1` … `8` | Palette color slots | +| `restore-region` | `R` | Restore the last region drawn this session (select phase) | +| `scroll-mode` | `S` | Toggle scrolling-region mode (select phase) | +| `cycle-tab` | `Space` | Step through the capture-kind tabs | + +Pin-window keys (`Esc`, `Ctrl+C`) and the scrolling-capture panel keys are not +configurable. + Filename tokens: | Token | Expands to | diff --git a/src/editor.cpp b/src/editor.cpp index faa458a4..0fbb6fc3 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -5,6 +5,7 @@ #include "stitch.hpp" #include "icons.hpp" #include "eyedropper.hpp" +#include "keybind-config.hpp" #include "output-config.hpp" #include "overlay-chrome.hpp" #include "palette-config.hpp" @@ -223,7 +224,7 @@ qreal toolbarScale(qreal availableWidth) { /// at 3× than at 1×), and the two you are not touching should not have to be /// remembered or re-discovered by pressing keys to find out. QString spotlightStatus(SpotlightShape shape, qreal magnification, - qreal border) { + qreal border, const QString &cycleKey) { const QString shapeName = shape == SpotlightShape::Ellipse ? QStringLiteral("ellipse") : shape == SpotlightShape::Rectangle @@ -236,16 +237,17 @@ QString spotlightStatus(SpotlightShape shape, qreal magnification, const QString ring = border <= 0.0 ? QStringLiteral("no border") : QStringLiteral("border %1").arg(qRound(border)); - return QStringLiteral("Spotlight · %1 · %2 · %3 · S cycles shape · wheel " + return QStringLiteral("Spotlight · %1 · %2 · %3 · %4 cycles shape · wheel " "zooms · Alt+wheel border") - .arg(shapeName, zoom, ring); + .arg(shapeName, zoom, ring, cycleKey); } } // namespace QString spotlightStatusForTest(SpotlightShape shape, qreal magnification, qreal border) { - return spotlightStatus(shape, magnification, border); + // The default cycle key reads fine for tests that do not load a config. + return spotlightStatus(shape, magnification, border, QStringLiteral("S")); } namespace { @@ -649,6 +651,7 @@ CaptureEditor::CaptureEditor(CaptureData capture, CaptureMode mode, return image; })); } + keybinds_ = loadKeybindConfig(defaultConfigPath()); if (!log.ops.isEmpty()) { ops_ = std::move(log.ops); opIndex_ = std::clamp(log.index, 0, static_cast(ops_.size())); @@ -1226,12 +1229,12 @@ QString CaptureEditor::highlighterStatus() const { QString CaptureEditor::highlighterTooltip() const { if (highlighterMode_ == HighlighterMode::Snap) { return QStringLiteral("Highlighter · Snap · text height automatic · wheel " - "sets off-text size %1 · H / click again: Normal") - .arg(qRound(annotationSize_)); + "sets off-text size %1 · %2 / click again: Normal") + .arg(qRound(annotationSize_), keyHint(KeyAction::Highlighter)); } return QStringLiteral("Highlighter · Normal · freehand · size %1 · wheel / " - "Alt+wheel · H / click again: Snap") - .arg(qRound(annotationSize_)); + "Alt+wheel · %2 / click again: Snap") + .arg(qRound(annotationSize_), keyHint(KeyAction::Highlighter)); } void CaptureEditor::activateHighlighter() { @@ -1267,13 +1270,14 @@ QString CaptureEditor::toolStatus() const { spotlightBorder_ <= 0.0 ? QStringLiteral("no border") : QStringLiteral("border %1").arg(qRound(spotlightBorder_)); - return QStringLiteral("Spotlight · %1 · %2 · %3 · S cycles shape · wheel " + return QStringLiteral("Spotlight · %1 · %2 · %3 · %4 cycles shape · wheel " "zooms · Alt+wheel border") - .arg(shape, zoom, ring); + .arg(shape, zoom, ring, keyHint(KeyAction::Spotlight)); } case Tool::Redact: - return QStringLiteral("Redact · %1 · D toggles style") - .arg(redactionStyleName(redactionStyle_).toLower()); + return QStringLiteral("Redact · %1 · %2 toggles style") + .arg(redactionStyleName(redactionStyle_).toLower(), + keyHint(KeyAction::Redact)); case Tool::Text: return QStringLiteral("Text · %1 · size %2 · Shift+T cycles font · click " "to type") @@ -1494,7 +1498,8 @@ void CaptureEditor::duplicateSelectedAnnotation() { selectedAnnotation_ = annotations_.size() - 1; selectedAnnotations_ = {selectedAnnotation_}; } - setStatus(QStringLiteral("Duplicated · Alt+D again offsets further")); + setStatus(QStringLiteral("Duplicated · %1 again offsets further") + .arg(keyHint(KeyAction::DuplicateLayer))); } bool CaptureEditor::adjustSelectedAnnotationRing(int step) { @@ -1520,7 +1525,8 @@ bool CaptureEditor::adjustSelectedAnnotationRing(int step) { return false; annotation.size = std::clamp(annotation.size + step * 2.0, 0.0, 12.0); setStatus(spotlightStatus(annotation.spotlightShape, - annotation.magnification, annotation.size)); + annotation.magnification, annotation.size, + keyHint(KeyAction::Spotlight))); commitPatch({selectedAnnotation_}); return true; } @@ -1547,7 +1553,8 @@ void CaptureEditor::adjustSelectedAnnotation(int step) { annotation.magnification = std::clamp(annotation.magnification + step * 0.25, 1.0, 4.0); setStatus(spotlightStatus(annotation.spotlightShape, - annotation.magnification, annotation.size)); + annotation.magnification, annotation.size, + keyHint(KeyAction::Spotlight))); commitPatch({selectedAnnotation_}); return; case Annotation::Kind::Marker: @@ -1606,10 +1613,11 @@ void CaptureEditor::adjustSelectedAnnotation(int step) { annotation.start = center + (annotation.start - center) * scale; annotation.end = center + (annotation.end - center) * scale; const QRectF grown = annotationBounds(annotation); - setStatus(QStringLiteral("Filled shape · %1 × %2 · R unfills it to set " - "a thickness") + setStatus(QStringLiteral("Filled shape · %1 × %2 · %3 unfills it to " + "set a thickness") .arg(qRound(grown.width())) - .arg(qRound(grown.height()))); + .arg(qRound(grown.height())) + .arg(keyHint(KeyAction::Rectangle))); commitPatch({selectedAnnotation_}); return; } @@ -1635,8 +1643,8 @@ void CaptureEditor::toggleShapeFill() { selectedAnnotation_ = -1; setStatus(QStringLiteral("%1 shapes · %2 again toggles fill") .arg(fillName(fillShapes_)) - .arg(tool_ == Tool::Ellipse ? QStringLiteral("E") - : QStringLiteral("R"))); + .arg(tool_ == Tool::Ellipse ? keyHint(KeyAction::Ellipse) + : keyHint(KeyAction::Rectangle))); } void CaptureEditor::toggleTextBackground() { @@ -1644,15 +1652,17 @@ void CaptureEditor::toggleTextBackground() { annotations_.at(selectedAnnotation_).kind == Annotation::Kind::Text) { Annotation &text = annotations_[selectedAnnotation_]; text.textBackground = nextTextBackground(text.textBackground); - setStatus(QStringLiteral("Selected text: %1 · T again cycles") - .arg(textBackgroundName(text.textBackground).toLower())); + setStatus(QStringLiteral("Selected text: %1 · %2 again cycles") + .arg(textBackgroundName(text.textBackground).toLower()) + .arg(keyHint(KeyAction::Text))); commitPatch({selectedAnnotation_}); return; } textBackground_ = nextTextBackground(textBackground_); selectedAnnotation_ = -1; - setStatus(QStringLiteral("Text: %1 · T again cycles") - .arg(textBackgroundName(textBackground_).toLower())); + setStatus(QStringLiteral("Text: %1 · %2 again cycles") + .arg(textBackgroundName(textBackground_).toLower()) + .arg(keyHint(KeyAction::Text))); } void CaptureEditor::cycleTextFont() { @@ -2188,28 +2198,35 @@ CaptureEditor::toolbarButtons(QVector *groupDividers, // Tools: everything that acts on the image via the cursor. add(36, QStringLiteral("tool-select"), {}, - QStringLiteral("Select/move · V · Ctrl+wheel zoom · outer handles crop")); + QStringLiteral("Select/move · %1 · Ctrl+wheel zoom · outer handles crop") + .arg(keyHint(KeyAction::Select))); add(36, QStringLiteral("tool-arrow"), {}, - QStringLiteral("Arrow · A · Shift snaps 45° · Size %1 · Wheel") + QStringLiteral("Arrow · %1 · Shift snaps 45° · Size %2 · Wheel") + .arg(keyHint(KeyAction::Arrow)) .arg(qRound(annotationSize_))); add(36, QStringLiteral("tool-line"), {}, - QStringLiteral("Line · L · Shift snaps 45° · Size %1 · Wheel") + QStringLiteral("Line · %1 · Shift snaps 45° · Size %2 · Wheel") + .arg(keyHint(KeyAction::Line)) .arg(qRound(annotationSize_))); add(36, QStringLiteral("tool-freehand"), {}, - QStringLiteral("Freehand · F · Size %1 · Wheel") + QStringLiteral("Freehand · %1 · Size %2 · Wheel") + .arg(keyHint(KeyAction::Freehand)) .arg(qRound(annotationSize_))); add(36, QStringLiteral("tool-highlighter"), {}, highlighterTooltip()); add(36, QStringLiteral("tool-marker"), {}, - QStringLiteral("Number marker · C · Size %1 · Wheel") + QStringLiteral("Number marker · %1 · Size %2 · Wheel") + .arg(keyHint(KeyAction::Marker)) .arg(qRound(annotationSize_))); const QString fillHint = fillShapes_ ? QStringLiteral("filled") : QString(); const bool ellipseSelected = tool_ == Tool::Ellipse; add(36, ellipseSelected ? QStringLiteral("tool-ellipse") : QStringLiteral("tool-rectangle"), fillHint, - QStringLiteral("Shapes · R rectangle · E ellipse · hover for fill")); + QStringLiteral("Shapes · %1 rectangle · %2 ellipse · hover for fill") + .arg(keyHint(KeyAction::Rectangle), keyHint(KeyAction::Ellipse))); add(36, QStringLiteral("tool-spotlight"), {}, - QStringLiteral("Spotlight · S · %1 · %2× · S cycles shape") + QStringLiteral("Spotlight · %1 · %2 · %3× · %1 cycles shape") + .arg(keyHint(KeyAction::Spotlight)) .arg(spotlightShape_ == SpotlightShape::Ellipse ? QStringLiteral("ellipse") : spotlightShape_ == SpotlightShape::Rectangle @@ -2217,37 +2234,46 @@ CaptureEditor::toolbarButtons(QVector *groupDividers, : QStringLiteral("rounded")) .arg(spotlightMagnification_, 0, 'f', 1)); add(36, QStringLiteral("tool-redact"), {}, - QStringLiteral("Redact · D · %1 · D again toggles") + QStringLiteral("Redact · %1 · %2 · %1 again toggles") + .arg(keyHint(KeyAction::Redact)) .arg(redactionStyleName(redactionStyle_))); add(36, QStringLiteral("tool-cut"), {}, - QStringLiteral("Cut out a band · X · drag across")); + QStringLiteral("Cut out a band · %1 · drag across") + .arg(keyHint(KeyAction::Cut))); add(36, QStringLiteral("tool-text"), {}, - QStringLiteral("%1 text · T · %2 · %3 · T again cycles style · " + QStringLiteral("%1 text · %2 · %3 · %4 · %2 again cycles style · " "Shift+T cycles font · Wheel") - .arg(annotationTextFontName(textFont_)) + .arg(annotationTextFontName(textFont_), + keyHint(KeyAction::Text)) .arg(QString::fromLatin1( kTextSizeNames.at(static_cast(textSizeIndex_)))) .arg(textBackgroundName(textBackground_))); add(36, QStringLiteral("tool-ocr"), {}, - QStringLiteral("Copy all text in the image · O")); + QStringLiteral("Copy all text in the image · %1") + .arg(keyHint(KeyAction::Ocr))); endGroup(); // Actions: pin and finish/exit the capture. add(36, QStringLiteral("pin"), {}, - QStringLiteral("Pin on screen · P · Ctrl+C on the pin copies it")); - add(36, QStringLiteral("copy"), {}, QStringLiteral("Copy only · Ctrl+C")); + QStringLiteral("Pin on screen · %1 · Ctrl+C on the pin copies it") + .arg(keyHint(KeyAction::Pin))); + add(36, QStringLiteral("copy"), {}, + QStringLiteral("Copy only · %1").arg(keyHint(KeyAction::Copy))); add(40, QStringLiteral("both"), {}, QStringLiteral("Copy and save · Enter")); - add(36, QStringLiteral("save"), {}, QStringLiteral("Save only · Ctrl+S")); + add(36, QStringLiteral("save"), {}, + QStringLiteral("Save only · %1").arg(keyHint(KeyAction::Save))); add(36, QStringLiteral("close"), {}, QStringLiteral("Close · Esc twice")); if (includeSubmenus && shapeMenuOpen_) { const QRectF menu = shapeMenuRect(); buttons.push_back({{menu.left() + 4, menu.top() + 4, 32, 28}, QStringLiteral("shape-rectangle"), {}, - QStringLiteral("Rectangle · R"), {}}); + QStringLiteral("Rectangle · %1") + .arg(keyHint(KeyAction::Rectangle)), {}}); buttons.push_back({{menu.left() + 40, menu.top() + 4, 32, 28}, QStringLiteral("shape-ellipse"), {}, - QStringLiteral("Ellipse · E"), {}}); + QStringLiteral("Ellipse · %1") + .arg(keyHint(KeyAction::Ellipse)), {}}); buttons.push_back({{menu.left() + 76, menu.top() + 4, 32, 28}, QStringLiteral("shape-fill"), fillShapes_ ? QStringLiteral("filled") : QString(), @@ -2271,7 +2297,8 @@ CaptureEditor::toolbarButtons(QVector *groupDividers, buttons.push_back({{palette.left() + 4 + (presetCount + 1) * 28, palette.top() + 4, 24, 28}, QStringLiteral("tool-eyedropper"), {}, - QStringLiteral("Sample from image · I"), {}}); + QStringLiteral("Sample from image · %1") + .arg(keyHint(KeyAction::Eyedropper)), {}}); } return buttons; } @@ -2552,13 +2579,15 @@ void CaptureEditor::cycleBackground() { break; } if (next == BackgroundStyle::Off) { - setStatus(QStringLiteral("Backdrop: Off · B cycles")); + setStatus(QStringLiteral("Backdrop: Off · %1 cycles") + .arg(keyHint(KeyAction::Backdrop))); } else { - setStatus(QStringLiteral("Backdrop: %1 · shadow %2 · B cycles · Shift+B " + setStatus(QStringLiteral("Backdrop: %1 · shadow %2 · %3 cycles · Shift+B " "toggles shadow") .arg(backgroundName(next), nextShadow ? QStringLiteral("on") - : QStringLiteral("off"))); + : QStringLiteral("off"), + keyHint(KeyAction::Backdrop))); } commitBackground(next, nextShadow); } @@ -3509,8 +3538,9 @@ void CaptureEditor::handleToolbar(const QString &action) { tool_ = Tool::Redact; } selectedAnnotation_ = -1; - setStatus(QStringLiteral("Redact: %1 · drag sensitive content · D toggles") - .arg(redactionStyleName(redactionStyle_))); + setStatus(QStringLiteral("Redact: %1 · drag sensitive content · %2 toggles") + .arg(redactionStyleName(redactionStyle_)) + .arg(keyHint(KeyAction::Redact))); } else if (action == QStringLiteral("tool-cut")) { tool_ = Tool::Cut; selectedAnnotation_ = -1; @@ -3548,7 +3578,7 @@ void CaptureEditor::handleToolbar(const QString &action) { customColorPickerOpen_ = !customColorPickerOpen_; } else if (action == QStringLiteral("ocr")) runOcr(); - else if (action == QStringLiteral("background")) + } else if (action == QStringLiteral("background")) cycleBackground(); else if (action == QStringLiteral("undo")) { undoEdit(); @@ -3570,6 +3600,36 @@ void CaptureEditor::handleToolbar(const QString &action) { update(); } +bool CaptureEditor::keyMatches(const QKeyEvent *event, KeyAction action) const { + const auto found = keybinds_.bindings.find(action); + if (found == keybinds_.bindings.end()) + return false; + // Keypad and group-switch bits never distinguish a binding: Ctrl+C is + // Ctrl+C wherever the C lives. + const int modifiers = + static_cast(event->modifiers()) & + ~(static_cast(Qt::KeypadModifier) | + static_cast(Qt::GroupSwitchModifier)); + const QKeySequence pressed(event->key() | modifiers); + return std::ranges::any_of(found->second, + [&](const QKeySequence &binding) { + return binding == pressed; + }); +} + +QString CaptureEditor::keyHint(KeyAction action) const { + return primaryKeyHint(keybinds_, action); +} + +int CaptureEditor::matchedColorSlot(const QKeyEvent *event) const { + for (int slot = 0; slot < 8; ++slot) + if (keyMatches( + event, static_cast(static_cast(KeyAction::Color1) + + slot))) + return slot; + return -1; +} + void CaptureEditor::keyPressEvent(QKeyEvent *event) { modifiersSeen_ = true; if (!ocrResultText_.isEmpty()) { @@ -3621,7 +3681,7 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { return; } if (phase_ == Phase::Select) { - if (event->matches(QKeySequence::SelectAll)) { + if (keyMatches(event, KeyAction::SelectAll)) { selectFullscreen(); return; } @@ -3640,8 +3700,8 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { chooseWindow(hoveredWindow_); return; } - if (!windowMode_ && !dragging_ && event->key() == Qt::Key_R && - !event->modifiers()) { + if (!windowMode_ && !dragging_ && + keyMatches(event, KeyAction::RestoreLastRegion)) { // R brings back the last region drawn this session, written for this // monitor at this size; anything else in the file is simply ignored. const QString path = storedCaptureRegionPath(); @@ -3662,11 +3722,11 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { } return; } - if (event->key() == Qt::Key_S && !event->modifiers()) { + if (keyMatches(event, KeyAction::ToggleScrollMode)) { setScrollMode(!scrollMode_); return; } - if (event->key() == Qt::Key_Space) { + if (keyMatches(event, KeyAction::CycleSelectTab)) { // Space steps along the tab strip. Fullscreen is skipped: it captures // on the spot, and a cycle key that fires it on the way past would be // a trap rather than a mode. @@ -3702,46 +3762,41 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { setStatus(QStringLiteral("Cut cancelled")); } - const bool redoShortcut = event->matches(QKeySequence::Redo) || - (event->key() == Qt::Key_Y && - event->modifiers().testFlag(Qt::ControlModifier)); // Zoom keys. The bare keys work in the edit phase too, so zoom never // depends on a modifier reaching us: a remote keyboard bridge may inject // the modifier in a way the compositor never publishes as xkb state. - const bool zoomModifier = - event->modifiers().testFlag(Qt::ControlModifier) || phase_ == Phase::Edit; - if (event->matches(QKeySequence::ZoomIn) || - (zoomModifier && - (event->key() == Qt::Key_Plus || event->key() == Qt::Key_Equal))) { + if (keyMatches(event, KeyAction::ZoomIn)) { setViewZoom(viewZoom_ * 1.25, editImageRect().center()); - setStatus(QStringLiteral("Zoom %1% · + / - zoom · 0 fits · wheel scrolls") + setStatus(QStringLiteral("Zoom %1% · %2 / %3 zoom · %4 fits · wheel scrolls") .arg(qRound(viewZoom_ * (baseImageRect().width() / std::max(canvasRect_.width(), 1)) * - 100))); + 100)) + .arg(keyHint(KeyAction::ZoomIn), keyHint(KeyAction::ZoomOut), + keyHint(KeyAction::ZoomFit))); return; - } else if (event->matches(QKeySequence::ZoomOut) || - (zoomModifier && (event->key() == Qt::Key_Minus || - event->key() == Qt::Key_Underscore))) { + } else if (keyMatches(event, KeyAction::ZoomOut)) { setViewZoom(viewZoom_ / 1.25, editImageRect().center()); - setStatus(QStringLiteral("Zoom %1% · + / - zoom · 0 fits · wheel scrolls") + setStatus(QStringLiteral("Zoom %1% · %2 / %3 zoom · %4 fits · wheel scrolls") .arg(qRound(viewZoom_ * (baseImageRect().width() / std::max(canvasRect_.width(), 1)) * - 100))); + 100)) + .arg(keyHint(KeyAction::ZoomIn), keyHint(KeyAction::ZoomOut), + keyHint(KeyAction::ZoomFit))); return; - } else if (zoomModifier && event->key() == Qt::Key_0) { + } else if (keyMatches(event, KeyAction::ZoomFit)) { resetView(); return; } - if (redoShortcut) { + if (keyMatches(event, KeyAction::Redo)) { redoEdit(); - } else if (event->matches(QKeySequence::Undo)) { + } else if (keyMatches(event, KeyAction::Undo)) { undoEdit(); - } else if (event->matches(QKeySequence::Copy)) { + } else if (keyMatches(event, KeyAction::Copy)) { finish(OutputMode::Copy); return; - } else if (event->matches(QKeySequence::Save)) { + } else if (keyMatches(event, KeyAction::Save)) { finish(OutputMode::Save); return; } else if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { @@ -3758,8 +3813,7 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { } finish(OutputMode::Both); return; - } else if (event->key() == Qt::Key_D && - event->modifiers() == Qt::AltModifier) { + } else if (keyMatches(event, KeyAction::DuplicateLayer)) { duplicateSelectedAnnotation(); } else if (const QPointF nudge = arrowKeyDelta( event->key(), heldModifiers(event->modifiers()) @@ -3793,29 +3847,31 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { : QPointF(0, -step); panView(delta); setStatus(QStringLiteral("Panning · arrows move, Shift jumps · middle-drag " - "pans · Ctrl+0 fits")); + "pans · %1 fits") + .arg(keyHint(KeyAction::ZoomFit))); } else if ((event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace) && !selectedAnnotations_.isEmpty()) { commitDelete(selectedAnnotations_); selectedAnnotations_.clear(); selectedAnnotation_ = -1; - } else if (event->key() == Qt::Key_V) { + } else if (keyMatches(event, KeyAction::Select)) { tool_ = Tool::Select; - } else if (event->matches(QKeySequence::SelectAll)) { + } else if (keyMatches(event, KeyAction::SelectAll)) { selectAllAnnotations(); - } else if (event->key() == Qt::Key_A) { + } else if (keyMatches(event, KeyAction::Arrow)) { tool_ = Tool::Arrow; - } else if (event->key() == Qt::Key_L) { + } else if (keyMatches(event, KeyAction::Line)) { tool_ = Tool::Line; - } else if (event->key() == Qt::Key_F) { + } else if (keyMatches(event, KeyAction::Freehand)) { tool_ = Tool::Freehand; - } else if (event->key() == Qt::Key_H) { + } else if (keyMatches(event, KeyAction::Highlighter)) { activateHighlighter(); - } else if (event->key() == Qt::Key_C || event->key() == Qt::Key_M) { + } else if (keyMatches(event, KeyAction::Marker)) { tool_ = Tool::Marker; - } else if (event->key() == Qt::Key_R || event->key() == Qt::Key_E) { - const bool rectangle = event->key() == Qt::Key_R; + } else if (keyMatches(event, KeyAction::Rectangle) || + keyMatches(event, KeyAction::Ellipse)) { + const bool rectangle = keyMatches(event, KeyAction::Rectangle); const Tool shape = rectangle ? Tool::Rectangle : Tool::Ellipse; if (!dragging_ && selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && @@ -3827,14 +3883,15 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { .arg(rectangle ? QStringLiteral("rectangle") : QStringLiteral("ellipse")) .arg(fillName(selected.filled).toLower()) - .arg(rectangle ? QStringLiteral("R") : QStringLiteral("E"))); + .arg(rectangle ? keyHint(KeyAction::Rectangle) + : keyHint(KeyAction::Ellipse))); commitPatch({selectedAnnotation_}); } else if (tool_ == shape) { toggleShapeFill(); } else { tool_ = shape; } - } else if (event->key() == Qt::Key_S) { + } else if (keyMatches(event, KeyAction::Spotlight)) { if (tool_ == Tool::Spotlight) { spotlightShape_ = spotlightShape_ == SpotlightShape::Ellipse ? SpotlightShape::Rectangle @@ -3846,7 +3903,7 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { tool_ = Tool::Spotlight; } selectedAnnotation_ = -1; - } else if (event->key() == Qt::Key_D) { + } else if (keyMatches(event, KeyAction::Redact)) { if (selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && annotations_.at(selectedAnnotation_).kind == Annotation::Kind::Redaction) { @@ -3855,8 +3912,10 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { redaction.redactionStyle == RedactionStyle::Solid ? RedactionStyle::Pixelate : RedactionStyle::Solid; - setStatus(QStringLiteral("Selected redaction: %1 · D toggles") - .arg(redactionStyleName(redaction.redactionStyle))); + setStatus( + QStringLiteral("Selected redaction: %1 · %2 toggles") + .arg(redactionStyleName(redaction.redactionStyle)) + .arg(keyHint(KeyAction::Redact))); commitPatch({selectedAnnotation_}); } else { if (tool_ == Tool::Redact) { @@ -3867,17 +3926,17 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { tool_ = Tool::Redact; } selectedAnnotation_ = -1; - setStatus( - QStringLiteral("Redact: %1 · drag sensitive content · D toggles") - .arg(redactionStyleName(redactionStyle_))); + setStatus(QStringLiteral("Redact: %1 · drag sensitive content · %2 toggles") + .arg(redactionStyleName(redactionStyle_)) + .arg(keyHint(KeyAction::Redact))); } - } else if (event->key() == Qt::Key_X) { + } else if (keyMatches(event, KeyAction::Cut)) { tool_ = Tool::Cut; setStatus(QStringLiteral("Cut: drag across a band to remove it")); } else if (event->key() == Qt::Key_T && event->modifiers() == Qt::ShiftModifier) { cycleTextFont(); - } else if (event->key() == Qt::Key_T) { + } else if (keyMatches(event, KeyAction::Text)) { const bool textSelected = selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && annotations_.at(selectedAnnotation_).kind == Annotation::Kind::Text; @@ -3885,29 +3944,29 @@ void CaptureEditor::keyPressEvent(QKeyEvent *event) { toggleTextBackground(); else tool_ = Tool::Text; - } else if (event->key() == Qt::Key_I) { + } else if (keyMatches(event, KeyAction::Eyedropper)) { if (tool_ != Tool::Eyedropper) toolBeforeEyedropper_ = tool_; tool_ = Tool::Eyedropper; - } else if (event->key() == Qt::Key_O) { + } else if (keyMatches(event, KeyAction::Ocr)) { runOcr(); return; - } else if (event->key() == Qt::Key_P) { + } else if (keyMatches(event, KeyAction::Pin)) { pinSnapshot(); return; } else if (event->key() == Qt::Key_G) { cycleCanvasBoundary( event->modifiers().testFlag(Qt::ShiftModifier)); - } else if (event->key() == Qt::Key_B) { - if (event->modifiers().testFlag(Qt::ShiftModifier)) { - const bool next = !imageShadow_; - setStatus(QStringLiteral("Drop shadow: %1 · Shift+B toggles") - .arg(next ? QStringLiteral("on") : QStringLiteral("off"))); - commitBackground(backgroundStyle_, next); - } else - cycleBackground(); - } else if (event->key() >= Qt::Key_1 && event->key() <= Qt::Key_8) { - colorIndex_ = event->key() - Qt::Key_1; + } else if (event->key() == Qt::Key_B && + event->modifiers().testFlag(Qt::ShiftModifier)) { + const bool next = !imageShadow_; + setStatus(QStringLiteral("Drop shadow: %1 · Shift+B toggles") + .arg(next ? QStringLiteral("on") : QStringLiteral("off"))); + commitBackground(backgroundStyle_, next); + } else if (keyMatches(event, KeyAction::Backdrop)) { + cycleBackground(); + } else if (const int colorSlot = matchedColorSlot(event); colorSlot >= 0) { + colorIndex_ = colorSlot; usingCustomColor_ = false; if (selectedAnnotation_ >= 0 && selectedAnnotation_ < annotations_.size() && annotations_.at(selectedAnnotation_).kind != @@ -4996,9 +5055,11 @@ void CaptureEditor::wheelEvent(QWheelEvent *event) { setStatus(hoveredRing >= 0 ? spotlightStatus(annotations_.at(hoveredRing).spotlightShape, annotations_.at(hoveredRing).magnification, - nextBorder) + nextBorder, + keyHint(KeyAction::Spotlight)) : spotlightStatus(spotlightShape_, spotlightMagnification_, - nextBorder)); + nextBorder, + keyHint(KeyAction::Spotlight))); } else if (tool_ == Tool::Spotlight) { const qreal delta = step > 0 ? 0.25 : -0.25; const int hovered = hoveredSpotlightAt(event->position()); @@ -5008,13 +5069,15 @@ void CaptureEditor::wheelEvent(QWheelEvent *event) { std::clamp(annotation.magnification + delta, 1.0, 4.0); spotlightMagnification_ = annotation.magnification; setStatus(spotlightStatus(annotation.spotlightShape, - annotation.magnification, annotation.size)); + annotation.magnification, annotation.size, + keyHint(KeyAction::Spotlight))); commitPatch({hovered}); } else { spotlightMagnification_ = std::clamp(spotlightMagnification_ + delta, 1.0, 4.0); setStatus(spotlightStatus(spotlightShape_, spotlightMagnification_, - spotlightBorder_)); + spotlightBorder_, + keyHint(KeyAction::Spotlight))); } } else if (tool_ == Tool::Rectangle && event->modifiers().testFlag(Qt::AltModifier)) { @@ -5740,10 +5803,14 @@ void CaptureEditor::paintSelect(QPainter &painter) { if (!exporting) drawHotkeyLegend(painter, rect(), {{QStringLiteral("Drag"), QStringLiteral("Area")}, - {QStringLiteral("Space"), QStringLiteral("Window")}, - {QStringLiteral("Ctrl+A"), QStringLiteral("Fullscreen")}, - {QStringLiteral("R"), QStringLiteral("Last region")}, - {QStringLiteral("S"), QStringLiteral("Scrolling region")}, + {keyHint(KeyAction::CycleSelectTab), + QStringLiteral("Window")}, + {keyHint(KeyAction::SelectAll), + QStringLiteral("Fullscreen")}, + {keyHint(KeyAction::RestoreLastRegion), + QStringLiteral("Last region")}, + {keyHint(KeyAction::ToggleScrollMode), + QStringLiteral("Scrolling region")}, {QStringLiteral("Esc"), QStringLiteral("Close")}}); const bool haveHole = @@ -5817,24 +5884,33 @@ void CaptureEditor::paintEdit(QPainter &painter) { // image, the toolbar, a popup) simply covers it wherever they overlap. drawHotkeyLegend( painter, rect(), - {{QStringLiteral("V"), QStringLiteral("Select / move layer")}, - {QStringLiteral("A"), QStringLiteral("Arrow")}, - {QStringLiteral("L"), QStringLiteral("Line")}, - {QStringLiteral("F / H"), QStringLiteral("Freehand / Highlighter")}, - {QStringLiteral("C"), QStringLiteral("Marker")}, - {QStringLiteral("R / E"), QStringLiteral("Rectangle / Ellipse")}, - {QStringLiteral("X"), QStringLiteral("Cut out a band")}, - {QStringLiteral("T"), QStringLiteral("Text")}, + {{keyHint(KeyAction::Select), QStringLiteral("Select / move layer")}, + {keyHint(KeyAction::Arrow), QStringLiteral("Arrow")}, + {keyHint(KeyAction::Line), QStringLiteral("Line")}, + {QStringLiteral("%1 / %2") + .arg(keyHint(KeyAction::Freehand), + keyHint(KeyAction::Highlighter)), + QStringLiteral("Freehand / Highlighter")}, + {keyHint(KeyAction::Marker), QStringLiteral("Marker")}, + {QStringLiteral("%1 / %2") + .arg(keyHint(KeyAction::Rectangle), keyHint(KeyAction::Ellipse)), + QStringLiteral("Rectangle / Ellipse")}, + {keyHint(KeyAction::Cut), QStringLiteral("Cut out a band")}, + {keyHint(KeyAction::Text), QStringLiteral("Text")}, {QStringLiteral("Double click"), QStringLiteral("Edit text layer")}, {QStringLiteral("1–8"), QStringLiteral("Color")}, {QStringLiteral("Wheel"), QStringLiteral("Zoom selected / tool size")}, - {QStringLiteral("D / O"), QStringLiteral("Redact / OCR text")}, - {QStringLiteral("B / P"), QStringLiteral("Backdrop / Pin on screen")}, - {QStringLiteral("Ctrl+Z"), QStringLiteral("Undo")}, - {QStringLiteral("Ctrl+Shift+Z"), QStringLiteral("Redo")}, + {QStringLiteral("%1 / %2") + .arg(keyHint(KeyAction::Redact), keyHint(KeyAction::Ocr)), + QStringLiteral("Redact / OCR text")}, + {QStringLiteral("%1 / %2") + .arg(keyHint(KeyAction::Backdrop), keyHint(KeyAction::Pin)), + QStringLiteral("Backdrop / Pin on screen")}, + {keyHint(KeyAction::Undo), QStringLiteral("Undo")}, + {keyHint(KeyAction::Redo), QStringLiteral("Redo")}, {QStringLiteral("Enter"), QStringLiteral("Copy + save")}, - {QStringLiteral("Ctrl+C"), QStringLiteral("Copy only")}, - {QStringLiteral("Ctrl+S"), QStringLiteral("Save only")}, + {keyHint(KeyAction::Copy), QStringLiteral("Copy only")}, + {keyHint(KeyAction::Save), QStringLiteral("Save only")}, {QStringLiteral("Esc"), QStringLiteral("Arrow / twice close")}}); // When zoomed past fit the image is larger than the viewport; clip content // to the band between the toolbar and the status so it cannot overdraw them. @@ -6408,15 +6484,17 @@ void CaptureEditor::paintEdit(QPainter &painter) { QString tooltip; if (tool_ == Tool::Text) { tooltip = QStringLiteral( - "%1 · S M L · current %2 · Scroll wheel · %3 · T " + "%1 · S M L · current %2 · Scroll wheel · %3 · %4 " "again cycles style · Shift+T cycles font") - .arg(annotationTextFontName(textFont_)) - .arg(QString::fromLatin1(kTextSizeNames.at( - static_cast(textSizeIndex_)))) - .arg(textBackgroundName(textBackground_)); + .arg(annotationTextFontName(textFont_), + QString::fromLatin1(kTextSizeNames.at( + static_cast(textSizeIndex_))), + textBackgroundName(textBackground_), + keyHint(KeyAction::Text)); } else if (tool_ == Tool::Redact) { - tooltip = QStringLiteral("Redact · %1 · D toggles style") - .arg(redactionStyleName(redactionStyle_)); + tooltip = QStringLiteral("Redact · %1 · %2 toggles style") + .arg(redactionStyleName(redactionStyle_)) + .arg(keyHint(KeyAction::Redact)); } else if (tool_ == Tool::Highlighter) { tooltip = highlighterTooltip(); } else if (tool_ == Tool::Rectangle) { diff --git a/src/editor.hpp b/src/editor.hpp index 4941f7d6..f4a4c77a 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -3,6 +3,7 @@ #include "background-config.hpp" #include "capture.hpp" #include "cut.hpp" +#include "keybind-config.hpp" #include "overlay-chrome.hpp" #include "palette-config.hpp" #include "recent-snaps.hpp" @@ -376,6 +377,14 @@ class CaptureEditor final : public QWidget { [[nodiscard]] QString highlighterStatus() const; [[nodiscard]] QString highlighterTooltip() const; void activateHighlighter(); + /// Whether the event matches one of `action`'s configured bindings + /// (exact key plus modifiers; keypad bits never distinguish). + [[nodiscard]] bool keyMatches(const QKeyEvent *event, KeyAction action) const; + /// Display form of an action's primary binding ("Alt+D"), for status lines + /// and tooltips: hints must follow rebinds instead of lying about keys. + [[nodiscard]] QString keyHint(KeyAction action) const; + /// Which configured color slot (0-based) the event hits, or -1. + [[nodiscard]] int matchedColorSlot(const QKeyEvent *event) const; [[nodiscard]] int annotationAt(const QPointF &point) const; /// Whether the armed tool picks a layer up rather than working over it. /// Moves a layer to the top of the stack, remapping every index that @@ -682,6 +691,7 @@ class CaptureEditor final : public QWidget { int hoveredWindow_ = -1; int colorIndex_ = 0; PaletteConfig paletteConfig_ = defaultPaletteConfig(); + KeybindConfig keybinds_; QColor customColor_; qreal customHue_ = 0.98; int nextMarker_ = 1; diff --git a/src/keybind-config.cpp b/src/keybind-config.cpp new file mode 100644 index 00000000..196ca094 --- /dev/null +++ b/src/keybind-config.cpp @@ -0,0 +1,256 @@ +/** @fileoverview Editor keybindings from the user's INI config ([keys]). */ +#include "keybind-config.hpp" + +#include +#include +#include + +namespace { +constexpr std::array kAllKeyActions = { + KeyAction::Select, KeyAction::Arrow, + KeyAction::Line, KeyAction::Freehand, + KeyAction::Highlighter, KeyAction::Marker, + KeyAction::Rectangle, KeyAction::Ellipse, + KeyAction::Spotlight, KeyAction::Redact, + KeyAction::Cut, KeyAction::Text, + KeyAction::Eyedropper, KeyAction::Ocr, + KeyAction::Pin, KeyAction::Backdrop, + KeyAction::DuplicateLayer, KeyAction::RestoreLastRegion, + KeyAction::ToggleScrollMode, KeyAction::CycleSelectTab, + KeyAction::Color1, KeyAction::Color2, + KeyAction::Color3, KeyAction::Color4, + KeyAction::Color5, KeyAction::Color6, + KeyAction::Color7, KeyAction::Color8, + KeyAction::Undo, KeyAction::Redo, + KeyAction::Copy, KeyAction::Save, + KeyAction::SelectAll, KeyAction::ZoomIn, + KeyAction::ZoomOut, KeyAction::ZoomFit, + +}; +} // namespace + +KeybindConfig defaultKeybindConfig() { + KeybindConfig config; + const auto put = [&config](KeyAction action, + std::initializer_list keys) { + std::vector sequences; + sequences.reserve(keys.size()); + for (const char *key : keys) + sequences.emplace_back(QLatin1String(key)); + config.bindings.emplace(action, std::move(sequences)); + }; + put(KeyAction::Select, {"V"}); + put(KeyAction::Arrow, {"A"}); + put(KeyAction::Line, {"L"}); + put(KeyAction::Freehand, {"F"}); + put(KeyAction::Highlighter, {"H"}); + put(KeyAction::Marker, {"C", "M"}); + put(KeyAction::Rectangle, {"R"}); + put(KeyAction::Ellipse, {"E"}); + put(KeyAction::Spotlight, {"S"}); + put(KeyAction::Redact, {"D"}); + put(KeyAction::Cut, {"X"}); + put(KeyAction::Text, {"T"}); + put(KeyAction::Eyedropper, {"I"}); + put(KeyAction::Ocr, {"O"}); + put(KeyAction::Pin, {"P"}); + put(KeyAction::Backdrop, {"B"}); + put(KeyAction::DuplicateLayer, {"Alt+D"}); + // Select-phase keys. Sharing R and S with edit-phase tools is fine: the + // scopes never match in the same pass. + put(KeyAction::RestoreLastRegion, {"R"}); + put(KeyAction::ToggleScrollMode, {"S"}); + put(KeyAction::CycleSelectTab, {"Space"}); + put(KeyAction::Color1, {"1"}); + put(KeyAction::Color2, {"2"}); + put(KeyAction::Color3, {"3"}); + put(KeyAction::Color4, {"4"}); + put(KeyAction::Color5, {"5"}); + put(KeyAction::Color6, {"6"}); + put(KeyAction::Color7, {"7"}); + put(KeyAction::Color8, {"8"}); + put(KeyAction::Undo, {"Ctrl+Z"}); + put(KeyAction::Redo, {"Ctrl+Shift+Z", "Ctrl+Y"}); + put(KeyAction::Copy, {"Ctrl+C"}); + put(KeyAction::Save, {"Ctrl+S"}); + put(KeyAction::SelectAll, {"Ctrl+A"}); + // Bare zoom keys stay the defaults on purpose: a remote keyboard bridge may + // inject modifiers in a way the compositor never publishes as xkb state. + put(KeyAction::ZoomIn, {"+", "=", "Ctrl+="}); + put(KeyAction::ZoomOut, {"-", "_", "Ctrl+-"}); + put(KeyAction::ZoomFit, {"0"}); + return config; +} + +QString keyActionName(KeyAction action) { + switch (action) { + case KeyAction::Select: + return QStringLiteral("select"); + case KeyAction::Arrow: + return QStringLiteral("arrow"); + case KeyAction::Line: + return QStringLiteral("line"); + case KeyAction::Freehand: + return QStringLiteral("freehand"); + case KeyAction::Highlighter: + return QStringLiteral("highlighter"); + case KeyAction::Marker: + return QStringLiteral("marker"); + case KeyAction::Rectangle: + return QStringLiteral("rectangle"); + case KeyAction::Ellipse: + return QStringLiteral("ellipse"); + case KeyAction::Spotlight: + return QStringLiteral("spotlight"); + case KeyAction::Redact: + return QStringLiteral("redact"); + case KeyAction::Cut: + return QStringLiteral("cut"); + case KeyAction::Text: + return QStringLiteral("text"); + case KeyAction::Eyedropper: + return QStringLiteral("eyedropper"); + case KeyAction::Ocr: + return QStringLiteral("ocr"); + case KeyAction::Pin: + return QStringLiteral("pin"); + case KeyAction::Backdrop: + return QStringLiteral("backdrop"); + case KeyAction::DuplicateLayer: + return QStringLiteral("duplicate"); + case KeyAction::RestoreLastRegion: + return QStringLiteral("restore-region"); + case KeyAction::ToggleScrollMode: + return QStringLiteral("scroll-mode"); + case KeyAction::CycleSelectTab: + return QStringLiteral("cycle-tab"); + case KeyAction::Color1: + return QStringLiteral("color1"); + case KeyAction::Color2: + return QStringLiteral("color2"); + case KeyAction::Color3: + return QStringLiteral("color3"); + case KeyAction::Color4: + return QStringLiteral("color4"); + case KeyAction::Color5: + return QStringLiteral("color5"); + case KeyAction::Color6: + return QStringLiteral("color6"); + case KeyAction::Color7: + return QStringLiteral("color7"); + case KeyAction::Color8: + return QStringLiteral("color8"); + case KeyAction::Undo: + return QStringLiteral("undo"); + case KeyAction::Redo: + return QStringLiteral("redo"); + case KeyAction::Copy: + return QStringLiteral("copy"); + case KeyAction::Save: + return QStringLiteral("save"); + case KeyAction::SelectAll: + return QStringLiteral("select-all"); + case KeyAction::ZoomIn: + return QStringLiteral("zoom-in"); + case KeyAction::ZoomOut: + return QStringLiteral("zoom-out"); + case KeyAction::ZoomFit: + return QStringLiteral("zoom-fit"); + } + return {}; +} + +KeyScope keyActionScope(KeyAction action) { + switch (action) { + case KeyAction::RestoreLastRegion: + case KeyAction::ToggleScrollMode: + case KeyAction::CycleSelectTab: + return KeyScope::Select; + default: + return KeyScope::Edit; + } +} + +KeybindConfig loadKeybindConfig(const QString &filePath) { + KeybindConfig config = defaultKeybindConfig(); + QSettings settings(filePath, QSettings::IniFormat); + settings.beginGroup(QStringLiteral("keys")); + const QStringList childKeys = settings.childKeys(); + if (childKeys.isEmpty()) + return config; + + // Parse everything before touching the defaults so any bad entry rejects + // the whole section rather than half-applying. + QString offender; + bool ok = true; + std::map> parsed; + for (const QString &child : childKeys) { + const auto action = + std::find_if(kAllKeyActions.begin(), kAllKeyActions.end(), + [&](KeyAction candidate) { + return keyActionName(candidate) == child; + }); + if (action == kAllKeyActions.end()) + continue; // Unknown names are ignored, like unknown keys elsewhere. + std::vector sequences; + const QStringList values = settings.value(child).toStringList(); + for (const QString &value : values) { + const QString trimmed = value.trimmed(); + if (trimmed.isEmpty()) + continue; + const QKeySequence sequence = QKeySequence::fromString(trimmed); + // Qt parses garbage like "not+a+real+key" into an unknown key without + // complaint; only a renderable sequence actually names a key. + if (sequence.isEmpty() || + sequence.toString(QKeySequence::PortableText).isEmpty()) { + offender = trimmed; + ok = false; + break; + } + if (std::find(sequences.begin(), sequences.end(), sequence) == + sequences.end()) + sequences.push_back(sequence); + } + if (!ok) + break; + if (!sequences.empty()) + parsed.emplace(*action, std::move(sequences)); + } + + if (ok) { + // A key bound to two actions of one scope would make the match order + // decide, which is exactly what a config should not depend on. + for (auto it = parsed.begin(); it != parsed.end() && ok; ++it) { + for (auto other = std::next(it); other != parsed.end(); ++other) { + if (keyActionScope(it->first) != keyActionScope(other->first)) + continue; + for (const QKeySequence &sequence : it->second) { + if (std::find(other->second.begin(), other->second.end(), + sequence) != other->second.end()) { + offender = sequence.toString(); + ok = false; + break; + } + } + if (!ok) + break; + } + } + } + + if (!ok) { + qWarning("omasnap: rejecting [keys] section of %s: %s", + qUtf8Printable(filePath), qUtf8Printable(offender)); + return config; + } + for (auto &[action, sequences] : parsed) + config.bindings[action] = std::move(sequences); + return config; +} + +QString primaryKeyHint(const KeybindConfig &config, KeyAction action) { + const auto found = config.bindings.find(action); + if (found == config.bindings.end() || found->second.empty()) + return {}; + return found->second.front().toString(QKeySequence::PortableText); +} diff --git a/src/keybind-config.hpp b/src/keybind-config.hpp new file mode 100644 index 00000000..14f6a69d --- /dev/null +++ b/src/keybind-config.hpp @@ -0,0 +1,80 @@ +/** @fileoverview Editor keybindings from the user's INI config ([keys]). */ +#pragma once + +#include +#include +#include +#include +#include + +/** Every editor action bindable under [keys] in omasnap.conf. */ +enum class KeyAction : std::uint8_t { + Select, + Arrow, + Line, + Freehand, + Highlighter, + Marker, + Rectangle, + Ellipse, + Spotlight, + Redact, + Cut, + Text, + Eyedropper, + Ocr, + Pin, + Backdrop, + DuplicateLayer, + RestoreLastRegion, + ToggleScrollMode, + CycleSelectTab, + Color1, + Color2, + Color3, + Color4, + Color5, + Color6, + Color7, + Color8, + Undo, + Redo, + Copy, + Save, + SelectAll, + ZoomIn, + ZoomOut, + ZoomFit, +}; + +/** Which handler group an action is matched in. A key may repeat across + * scopes — R restores the last region while selecting and draws rectangles + * while editing — but never twice within one scope, which loadKeybindConfig + * treats as a rejected config. */ +enum class KeyScope : std::uint8_t { Edit, Select }; + +struct KeybindConfig { + /** Ordered bindings per action; the first entry drives hints. */ + std::map> bindings; +}; + +/** The bindings previously hardcoded in editor.cpp, aliases included. */ +[[nodiscard]] KeybindConfig defaultKeybindConfig(); + +/** Reads [keys]: `name=key[,key...]` with portable QKeySequence names + * (`arrow=A`, `redo=Ctrl+Shift+Z,Ctrl+Y`). A missing file or section leaves + * defaults untouched. Any unparsable key, or one bound to two actions of the + * same scope, rejects the whole section: defaults come back and a warning + * names the offender. Unknown action names are ignored. */ +[[nodiscard]] KeybindConfig loadKeybindConfig(const QString &filePath); + +/** INI key for `action`, e.g. "restore-region". */ +[[nodiscard]] QString keyActionName(KeyAction action); + +/** Which scope `action` is matched in (see KeyScope). */ +[[nodiscard]] KeyScope keyActionScope(KeyAction action); + +/** Display form of `action`'s first binding ("Alt+D"); empty when unbound. + * Status lines and tooltips use this so hints follow rebinds. */ +[[nodiscard]] QString primaryKeyHint(const KeybindConfig &config, + KeyAction action); diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index 68f5516c..9311dd12 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -9,6 +9,7 @@ #include "editor.hpp" #include "recent-snaps.hpp" #include "instance-lock-smoke.hpp" +#include "keybind-config-smoke.hpp" #include "palette-config-smoke.hpp" #include "pin-layout-smoke.hpp" #include "stitch-smoke.hpp" @@ -8882,6 +8883,12 @@ int main(int argc, char **argv) { return EXIT_FAILURE; } + QString keybindError; + if (!runKeybindConfigSmoke(keybindError)) { + qWarning().noquote() << "keybind config smoke failed:" << keybindError; + return EXIT_FAILURE; + } + QString instanceError; if (!runInstanceLockSmoke(instanceError)) { qWarning().noquote() << instanceError; diff --git a/tests/keybind-config-smoke.cpp b/tests/keybind-config-smoke.cpp new file mode 100644 index 00000000..ed6fecfd --- /dev/null +++ b/tests/keybind-config-smoke.cpp @@ -0,0 +1,143 @@ +/** @fileoverview Tests INI keybind loading: defaults, lists, rejection. */ +#include "keybind-config-smoke.hpp" + +#include "keybind-config.hpp" + +#include +#include + +namespace { +bool writeFile(const QString &path, const QByteArray &contents) { + QFile file(path); + return file.open(QIODevice::WriteOnly) && + file.write(contents) == contents.size(); +} +} // namespace + +bool runKeybindConfigSmoke(QString &error) { + QTemporaryDir dir; + if (!dir.isValid()) { + error = QStringLiteral("could not create temporary directory"); + return false; + } + const KeybindConfig defaults = defaultKeybindConfig(); + if (defaults.bindings.at(KeyAction::Marker).size() != 2 || + defaults.bindings.at(KeyAction::Redo).size() != 2 || + primaryKeyHint(defaults, KeyAction::DuplicateLayer) != + QStringLiteral("Alt+D")) { + error = QStringLiteral("default keybinds lost an alias or hint"); + return false; + } + // Every default binding must actually parse; an empty sequence would + // silently unbind the action. + for (const auto &[action, sequences] : defaults.bindings) { + if (sequences.empty()) { + error = QStringLiteral("action %1 has no default binding") + .arg(keyActionName(action)); + return false; + } + for (const QKeySequence &sequence : sequences) { + if (sequence.isEmpty()) { + error = QStringLiteral("a default binding for %1 did not parse") + .arg(keyActionName(action)); + return false; + } + } + } + + // Missing file -> defaults. + const KeybindConfig missing = + loadKeybindConfig(dir.filePath(QStringLiteral("absent.conf"))); + if (missing.bindings != defaults.bindings) { + error = QStringLiteral("missing file did not fall back to defaults"); + return false; + } + + // A full override replaces the bound list; hints follow the first entry. + const QString full = dir.filePath(QStringLiteral("full.conf")); + if (!writeFile(full, "[keys]\n" + "arrow=W\n" + "marker=N,M\n" + "redo=Ctrl+Shift+Z,Ctrl+Y\n")) { + error = QStringLiteral("could not write full config"); + return false; + } + const KeybindConfig loaded = loadKeybindConfig(full); + const std::vector expectedArrow = {QKeySequence( + QStringLiteral("W"))}; + const std::vector expectedMarker = { + QKeySequence(QStringLiteral("N")), QKeySequence(QStringLiteral("M"))}; + if (loaded.bindings.at(KeyAction::Arrow) != expectedArrow || + loaded.bindings.at(KeyAction::Marker) != expectedMarker || + loaded.bindings.at(KeyAction::Redo) != defaults.bindings.at(KeyAction::Redo) || + primaryKeyHint(loaded, KeyAction::Arrow) != QStringLiteral("W")) { + error = QStringLiteral("full keybind config not applied"); + return false; + } + + // An unparsable key rejects the whole section, valid lines included. + const QString invalid = dir.filePath(QStringLiteral("invalid.conf")); + if (!writeFile(invalid, "[keys]\n" + "arrow=W\n" + "line=not+a+real+key\n")) { + error = QStringLiteral("could not write invalid config"); + return false; + } + qInstallMessageHandler([](QtMsgType, const QMessageLogContext &, + const QString &) {}); + const KeybindConfig rejected = loadKeybindConfig(invalid); + qInstallMessageHandler(nullptr); + if (rejected.bindings != defaults.bindings) { + error = QStringLiteral("unparsable entry did not reject the section"); + return false; + } + + // Two actions of one scope sharing a key is rejected too. + const QString conflict = dir.filePath(QStringLiteral("conflict.conf")); + if (!writeFile(conflict, "[keys]\n" + "select=C\n" + "marker=C\n")) { + error = QStringLiteral("could not write conflicting config"); + return false; + } + qInstallMessageHandler([](QtMsgType, const QMessageLogContext &, + const QString &) {}); + const KeybindConfig conflicted = loadKeybindConfig(conflict); + qInstallMessageHandler(nullptr); + if (conflicted.bindings != defaults.bindings) { + error = QStringLiteral("cross-action conflict did not reject the section"); + return false; + } + + // The same key twice within one action's own list is merely deduped. + const QString repeat = dir.filePath(QStringLiteral("repeat.conf")); + if (!writeFile(repeat, "[keys]\nmarker=C,C,M\n")) { + error = QStringLiteral("could not write repeated config"); + return false; + } + const KeybindConfig deduped = loadKeybindConfig(repeat); + const std::vector expectedDeduped = { + QKeySequence(QStringLiteral("C")), QKeySequence(QStringLiteral("M"))}; + if (deduped.bindings.at(KeyAction::Marker) != expectedDeduped) { + error = QStringLiteral("repeated entries were not deduped"); + return false; + } + + // Cross-scope reuse stays legal: R and S mean different things per phase. + const QString scopes = dir.filePath(QStringLiteral("scopes.conf")); + if (!writeFile(scopes, "[keys]\nrectangle=R\nrestore-region=R\n")) { + error = QStringLiteral("could not write scoped config"); + return false; + } + const KeybindConfig scoped = loadKeybindConfig(scopes); + if (keyActionScope(KeyAction::Rectangle) != KeyScope::Edit || + keyActionScope(KeyAction::RestoreLastRegion) != KeyScope::Select || + scoped.bindings.at(KeyAction::Rectangle) != + std::vector{QKeySequence(QStringLiteral("R"))} || + scoped.bindings.at(KeyAction::RestoreLastRegion) != + std::vector{QKeySequence(QStringLiteral("R"))}) { + error = QStringLiteral("cross-scope reuse was rejected or disturbed"); + return false; + } + return true; +} diff --git a/tests/keybind-config-smoke.hpp b/tests/keybind-config-smoke.hpp new file mode 100644 index 00000000..33b553c6 --- /dev/null +++ b/tests/keybind-config-smoke.hpp @@ -0,0 +1,6 @@ +/** @fileoverview Declares the keybind-config smoke test. */ +#pragma once + +#include + +[[nodiscard]] bool runKeybindConfigSmoke(QString &error); From e3094b78c13fa8cbd64ff84827a1ef8b05e71c40 Mon Sep 17 00:00:00 2001 From: Travonte Date: Mon, 24 Aug 2026 10:30:05 -0400 Subject: [PATCH 2/5] feat(editor): an empty [keys] value unbinds its action "marker =" now removes the marker's keys instead of being treated like an absent line. Missing names still keep their defaults, and unbinding one action leaves the rest alone. --- README.md | 12 +++++++----- src/keybind-config.cpp | 5 +++-- src/keybind-config.hpp | 7 ++++--- tests/keybind-config-smoke.cpp | 20 ++++++++++++++++++++ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8073c01c..20245839 100644 --- a/README.md +++ b/README.md @@ -303,11 +303,13 @@ redo = Ctrl+Shift+Z, Ctrl+Y ``` Every key is optional — an action missing from `[keys]` keeps its default -(the keys shown in [Controls](#controls)). If any value fails to parse, or a -key ends up bound to two actions of the same phase (editing vs selecting), the -whole `[keys]` section is ignored and a warning names the offender; the same -key may still mean different things per phase, like `R` restoring the last -region while selecting and drawing rectangles while editing. +(the keys shown in [Controls](#controls)). Setting an action to an empty +value unbinds it entirely (`marker =` disables the marker keys); its hints +then show no key. If any value fails to parse, or a key ends up bound to two +actions of the same phase (editing vs selecting), the whole `[keys]` section +is ignored and a warning names the offender; the same key may still mean +different things per phase, like `R` restoring the last region while +selecting and drawing rectangles while editing. Bindable actions and their defaults: diff --git a/src/keybind-config.cpp b/src/keybind-config.cpp index 196ca094..b14a3feb 100644 --- a/src/keybind-config.cpp +++ b/src/keybind-config.cpp @@ -213,8 +213,9 @@ KeybindConfig loadKeybindConfig(const QString &filePath) { } if (!ok) break; - if (!sequences.empty()) - parsed.emplace(*action, std::move(sequences)); + // A listed action with no keys (e.g. "marker =") unbinds it; an absent + // one keeps its defaults. + parsed.emplace(*action, std::move(sequences)); } if (ok) { diff --git a/src/keybind-config.hpp b/src/keybind-config.hpp index 14f6a69d..b48df211 100644 --- a/src/keybind-config.hpp +++ b/src/keybind-config.hpp @@ -63,9 +63,10 @@ struct KeybindConfig { /** Reads [keys]: `name=key[,key...]` with portable QKeySequence names * (`arrow=A`, `redo=Ctrl+Shift+Z,Ctrl+Y`). A missing file or section leaves - * defaults untouched. Any unparsable key, or one bound to two actions of the - * same scope, rejects the whole section: defaults come back and a warning - * names the offender. Unknown action names are ignored. */ + * defaults untouched. A listed action with no keys ("marker =") unbinds it. + * Any unparsable key, or one bound to two actions of the same scope, rejects + * the whole section: defaults come back and a warning names the offender. + * Unknown action names are ignored. */ [[nodiscard]] KeybindConfig loadKeybindConfig(const QString &filePath); /** INI key for `action`, e.g. "restore-region". */ diff --git a/tests/keybind-config-smoke.cpp b/tests/keybind-config-smoke.cpp index ed6fecfd..5b2fdd6b 100644 --- a/tests/keybind-config-smoke.cpp +++ b/tests/keybind-config-smoke.cpp @@ -123,6 +123,26 @@ bool runKeybindConfigSmoke(QString &error) { return false; } + // A listed action with no keys unbinds it; absent ones keep defaults. + const QString unbind = dir.filePath(QStringLiteral("unbind.conf")); + if (!writeFile(unbind, "[keys]\n" + "marker=\n" + "redo = \n")) { + error = QStringLiteral("could not write unbind config"); + return false; + } + const KeybindConfig unbound = loadKeybindConfig(unbind); + if (!unbound.bindings.at(KeyAction::Marker).empty() || + !unbound.bindings.at(KeyAction::Redo).empty()) { + error = QStringLiteral("empty values did not unbind the action"); + return false; + } + if (unbound.bindings.at(KeyAction::Arrow) != + defaults.bindings.at(KeyAction::Arrow)) { + error = QStringLiteral("unbinding one action disturbed another"); + return false; + } + // Cross-scope reuse stays legal: R and S mean different things per phase. const QString scopes = dir.filePath(QStringLiteral("scopes.conf")); if (!writeFile(scopes, "[keys]\nrectangle=R\nrestore-region=R\n")) { From 6f57f1a3aaededb64a3690cbbac30760f67a32b7 Mon Sep 17 00:00:00 2001 From: Travonte Date: Mon, 24 Aug 2026 10:55:55 -0400 Subject: [PATCH 3/5] chore: format the keybind files to the project's LLVM base style --- src/keybind-config.cpp | 66 +++++++++++++++++++++------------- tests/keybind-config-smoke.cpp | 15 ++++---- 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/keybind-config.cpp b/src/keybind-config.cpp index b14a3feb..ecd083ea 100644 --- a/src/keybind-config.cpp +++ b/src/keybind-config.cpp @@ -7,24 +7,42 @@ namespace { constexpr std::array kAllKeyActions = { - KeyAction::Select, KeyAction::Arrow, - KeyAction::Line, KeyAction::Freehand, - KeyAction::Highlighter, KeyAction::Marker, - KeyAction::Rectangle, KeyAction::Ellipse, - KeyAction::Spotlight, KeyAction::Redact, - KeyAction::Cut, KeyAction::Text, - KeyAction::Eyedropper, KeyAction::Ocr, - KeyAction::Pin, KeyAction::Backdrop, - KeyAction::DuplicateLayer, KeyAction::RestoreLastRegion, - KeyAction::ToggleScrollMode, KeyAction::CycleSelectTab, - KeyAction::Color1, KeyAction::Color2, - KeyAction::Color3, KeyAction::Color4, - KeyAction::Color5, KeyAction::Color6, - KeyAction::Color7, KeyAction::Color8, - KeyAction::Undo, KeyAction::Redo, - KeyAction::Copy, KeyAction::Save, - KeyAction::SelectAll, KeyAction::ZoomIn, - KeyAction::ZoomOut, KeyAction::ZoomFit, + KeyAction::Select, + KeyAction::Arrow, + KeyAction::Line, + KeyAction::Freehand, + KeyAction::Highlighter, + KeyAction::Marker, + KeyAction::Rectangle, + KeyAction::Ellipse, + KeyAction::Spotlight, + KeyAction::Redact, + KeyAction::Cut, + KeyAction::Text, + KeyAction::Eyedropper, + KeyAction::Ocr, + KeyAction::Pin, + KeyAction::Backdrop, + KeyAction::DuplicateLayer, + KeyAction::RestoreLastRegion, + KeyAction::ToggleScrollMode, + KeyAction::CycleSelectTab, + KeyAction::Color1, + KeyAction::Color2, + KeyAction::Color3, + KeyAction::Color4, + KeyAction::Color5, + KeyAction::Color6, + KeyAction::Color7, + KeyAction::Color8, + KeyAction::Undo, + KeyAction::Redo, + KeyAction::Copy, + KeyAction::Save, + KeyAction::SelectAll, + KeyAction::ZoomIn, + KeyAction::ZoomOut, + KeyAction::ZoomFit, }; } // namespace @@ -185,11 +203,9 @@ KeybindConfig loadKeybindConfig(const QString &filePath) { bool ok = true; std::map> parsed; for (const QString &child : childKeys) { - const auto action = - std::find_if(kAllKeyActions.begin(), kAllKeyActions.end(), - [&](KeyAction candidate) { - return keyActionName(candidate) == child; - }); + const auto action = std::find_if( + kAllKeyActions.begin(), kAllKeyActions.end(), + [&](KeyAction candidate) { return keyActionName(candidate) == child; }); if (action == kAllKeyActions.end()) continue; // Unknown names are ignored, like unknown keys elsewhere. std::vector sequences; @@ -226,8 +242,8 @@ KeybindConfig loadKeybindConfig(const QString &filePath) { if (keyActionScope(it->first) != keyActionScope(other->first)) continue; for (const QKeySequence &sequence : it->second) { - if (std::find(other->second.begin(), other->second.end(), - sequence) != other->second.end()) { + if (std::find(other->second.begin(), other->second.end(), sequence) != + other->second.end()) { offender = sequence.toString(); ok = false; break; diff --git a/tests/keybind-config-smoke.cpp b/tests/keybind-config-smoke.cpp index 5b2fdd6b..9295bcd7 100644 --- a/tests/keybind-config-smoke.cpp +++ b/tests/keybind-config-smoke.cpp @@ -63,13 +63,14 @@ bool runKeybindConfigSmoke(QString &error) { return false; } const KeybindConfig loaded = loadKeybindConfig(full); - const std::vector expectedArrow = {QKeySequence( - QStringLiteral("W"))}; + const std::vector expectedArrow = { + QKeySequence(QStringLiteral("W"))}; const std::vector expectedMarker = { QKeySequence(QStringLiteral("N")), QKeySequence(QStringLiteral("M"))}; if (loaded.bindings.at(KeyAction::Arrow) != expectedArrow || loaded.bindings.at(KeyAction::Marker) != expectedMarker || - loaded.bindings.at(KeyAction::Redo) != defaults.bindings.at(KeyAction::Redo) || + loaded.bindings.at(KeyAction::Redo) != + defaults.bindings.at(KeyAction::Redo) || primaryKeyHint(loaded, KeyAction::Arrow) != QStringLiteral("W")) { error = QStringLiteral("full keybind config not applied"); return false; @@ -83,8 +84,8 @@ bool runKeybindConfigSmoke(QString &error) { error = QStringLiteral("could not write invalid config"); return false; } - qInstallMessageHandler([](QtMsgType, const QMessageLogContext &, - const QString &) {}); + qInstallMessageHandler( + [](QtMsgType, const QMessageLogContext &, const QString &) {}); const KeybindConfig rejected = loadKeybindConfig(invalid); qInstallMessageHandler(nullptr); if (rejected.bindings != defaults.bindings) { @@ -100,8 +101,8 @@ bool runKeybindConfigSmoke(QString &error) { error = QStringLiteral("could not write conflicting config"); return false; } - qInstallMessageHandler([](QtMsgType, const QMessageLogContext &, - const QString &) {}); + qInstallMessageHandler( + [](QtMsgType, const QMessageLogContext &, const QString &) {}); const KeybindConfig conflicted = loadKeybindConfig(conflict); qInstallMessageHandler(nullptr); if (conflicted.bindings != defaults.bindings) { From 47d6d441fa2b8ad998d21160b8ec8a079e514766 Mon Sep 17 00:00:00 2001 From: Travonte Date: Mon, 24 Aug 2026 10:55:55 -0400 Subject: [PATCH 4/5] fix(doc): make it clear the keys section is for all bindings --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 20245839..5a3c7679 100644 --- a/README.md +++ b/README.md @@ -294,8 +294,8 @@ image = ~/Pictures/backdrops/desk.jpg default = custom [keys] -# Editor keybindings. Each action takes one key or a comma-separated list -# (any entry fires the action); the first entry is the one shown in hints. +# Each action takes one key or a comma-separated list (any entry fires the action); +# the first entry is the one shown in hints. # Values use Qt names: letters and digits as-is, modifiers joined with +, # e.g. Ctrl+Z, Alt+D, Space, F5. arrow = W From 2f4dcf11a6ef82ae350afcf06fbc6fe8c3bcf4ae Mon Sep 17 00:00:00 2001 From: Travonte Date: Thu, 27 Aug 2026 09:56:29 -0400 Subject: [PATCH 5/5] fix(test): bind Ctrl+0 to zoom-fit and isolate smoke config The smoke binary loaded keybindings from the developer's real ~/.config/omasnap/omasnap.conf, so a personal [keys] override (e.g. cycle-tab=Tab) silently flipped the bindings the suite asserts on and broke the select-phase undim-hole checks. Point XDG_CONFIG_HOME at a scratch dir so the editor reads its defaults instead of the host's. Also add Ctrl+0 as a ZoomFit alias: the suite asserts Ctrl+0 restores the fitted view and the README documents "0 (also with Ctrl)", matching the existing Ctrl+=/Ctrl+- zoom aliases. Co-Authored-By: OpenCode --- src/editor.cpp | 8 +++++--- src/keybind-config.cpp | 2 +- tests/editor-smoke.cpp | 8 ++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/editor.cpp b/src/editor.cpp index 0fbb6fc3..9659681c 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -1230,11 +1230,13 @@ QString CaptureEditor::highlighterTooltip() const { if (highlighterMode_ == HighlighterMode::Snap) { return QStringLiteral("Highlighter · Snap · text height automatic · wheel " "sets off-text size %1 · %2 / click again: Normal") - .arg(qRound(annotationSize_), keyHint(KeyAction::Highlighter)); + .arg(qRound(annotationSize_)) + .arg(keyHint(KeyAction::Highlighter)); } return QStringLiteral("Highlighter · Normal · freehand · size %1 · wheel / " "Alt+wheel · %2 / click again: Snap") - .arg(qRound(annotationSize_), keyHint(KeyAction::Highlighter)); + .arg(qRound(annotationSize_)) + .arg(keyHint(KeyAction::Highlighter)); } void CaptureEditor::activateHighlighter() { @@ -3578,7 +3580,7 @@ void CaptureEditor::handleToolbar(const QString &action) { customColorPickerOpen_ = !customColorPickerOpen_; } else if (action == QStringLiteral("ocr")) runOcr(); - } else if (action == QStringLiteral("background")) + else if (action == QStringLiteral("background")) cycleBackground(); else if (action == QStringLiteral("undo")) { undoEdit(); diff --git a/src/keybind-config.cpp b/src/keybind-config.cpp index ecd083ea..adb8bff3 100644 --- a/src/keybind-config.cpp +++ b/src/keybind-config.cpp @@ -96,7 +96,7 @@ KeybindConfig defaultKeybindConfig() { // inject modifiers in a way the compositor never publishes as xkb state. put(KeyAction::ZoomIn, {"+", "=", "Ctrl+="}); put(KeyAction::ZoomOut, {"-", "_", "Ctrl+-"}); - put(KeyAction::ZoomFit, {"0"}); + put(KeyAction::ZoomFit, {"0", "Ctrl+0"}); return config; } diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index 9311dd12..e1f85ece 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -7378,6 +7378,14 @@ int main(int argc, char **argv) { if (!heldLockPath.isEmpty()) return runInstanceLockHolder(heldLockPath); + // Keep the editor's keybind/palette/background config hermetic: read defaults + // from a scratch config dir instead of the developer's own ~/.config, so a + // personal [keys] override cannot flip the bindings the suite asserts on. + QTemporaryDir smokeConfig; + if (!smokeConfig.isValid()) + return 19; + qputenv("XDG_CONFIG_HOME", smokeConfig.path().toUtf8()); + QApplication application(argc, argv); if (!loadCaptureFonts()) return 17;