Source code for spacr.qt.shortcuts

"""
Keyboard-first shortcuts for the spaCR Qt GUI.

Registers global :class:`QShortcut` bindings on the main window so
the whole app is usable without a mouse:

    Ctrl+0        Go home (Cmd+H is Hide on macOS)
    Ctrl+1..9     Switch to the Nth app in the sidebar
    Ctrl+K        Open the command palette
    Ctrl+Shift+H  Search spaCR from the field beside the Help menu
    F1  / ?       Show the shortcuts cheat sheet
    Ctrl+P        Open Preferences
    Ctrl+Alt+0    Put GUI scale, font scale and preview scales back to 100 %
    Ctrl+/        Open the AI Console
    Ctrl+End      Jump to the newest console line
    F11           Toggle full screen
    Esc           Close any open dialog / popup

:func:`install` is called once from ``MainWindow.__init__``. Every
binding is documented in :data:`SHORTCUTS` so the cheat-sheet
dialog stays in sync with what's actually wired up.

Every window-wide key can be rebound. The cheat sheet carries a "Change
shortcuts…" button that opens a table of the window-wide actions; a key
typed there replaces the default, a key already taken by another action is
named as a conflict and cannot be saved, and the overrides are kept in the
Preferences store under :data:`_KEYMAP_KEY` in Qt's portable spelling, so a
keymap saved on one platform reads the same on the others. Keys that belong
to a single screen (Annotate, Make Masks, the field browser) have separate
editable rows and saved overrides, applied to both existing and new screens.
"""
from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Callable, List, Optional

from PySide6.QtCore import QEvent, QRectF, Qt
from PySide6.QtGui import (QAction, QColor, QKeySequence, QPainter, QPen,
                           QShortcut, QUndoCommand, QUndoStack)
from PySide6.QtWidgets import (
    QApplication,
    QDialog,
    QGridLayout,
    QHBoxLayout,
    QKeySequenceEdit,
    QLabel,
    QMainWindow,
    QPushButton,
    QScrollArea,
    QTableWidget,
    QVBoxLayout,
    QWidget,
)

LOG = logging.getLogger("spacr.qt.shortcuts")


#: Where a shortcut works. A key that only works on one screen and is listed
#: without saying so sends a user to press it somewhere it does nothing.
EVERYWHERE = "anywhere in spaCR"


@dataclass(frozen=True)
[docs] class ShortcutSpec: """One shortcut declaration. :param keys: the binding, in Qt's portable spelling. It is PRINTED through `QKeySequence.toString(NativeText)`, so `Ctrl` reads as the Command symbol on macOS -- writing "Ctrl+H" into a label would hard-code one platform into the help. :param label: what the key does. :param category: the group it is shown under. :param scope: where it works. The default is the whole window; a per-screen binding names its screen. """ keys: str label: str category: str = "General" scope: str = EVERYWHERE
SHORTCUTS: List[ShortcutSpec] = [ ShortcutSpec("Ctrl+0", "Go to home", "Navigation"), ShortcutSpec("Ctrl+1", "Switch to 1st app", "Navigation"), ShortcutSpec("Ctrl+2", "Switch to 2nd app", "Navigation"), ShortcutSpec("Ctrl+3", "Switch to 3rd app", "Navigation"), ShortcutSpec("Ctrl+4", "Switch to 4th app", "Navigation"), ShortcutSpec("Ctrl+5", "Switch to 5th app", "Navigation"), ShortcutSpec("Ctrl+6", "Switch to 6th app", "Navigation"), ShortcutSpec("Ctrl+7", "Switch to 7th app", "Navigation"), ShortcutSpec("Ctrl+8", "Switch to 8th app", "Navigation"), ShortcutSpec("Ctrl+9", "Switch to 9th app", "Navigation"), ShortcutSpec("Ctrl+K", "Open command palette", "Navigation"), ShortcutSpec("Ctrl+P", "Open preferences", "Navigation"), ShortcutSpec("Ctrl+Shift+A", "Show the full app list", "Navigation"), ShortcutSpec("F11", "Toggle full screen", "Navigation"), ShortcutSpec("Ctrl+/", "Toggle AI Console", "Actions"), ShortcutSpec("Ctrl+End", "Jump to the newest console line", "Console"), ShortcutSpec("Ctrl+F", "Search this module's settings", "Actions"), ShortcutSpec("Ctrl+Shift+R", "Settings templates", "Actions"), ShortcutSpec("Ctrl+T", "Pause or resume the background", "Background"), ShortcutSpec("Ctrl+R", "Restart the background", "Background"), ShortcutSpec("Ctrl+Shift+F", "Show the background full screen", "Background"), ShortcutSpec("Ctrl+B", "Blank the background", "Background"), ShortcutSpec("Z + scroll", "Resize the interface text", "Background"), ShortcutSpec("Ctrl+Alt+0", "Reset GUI scale and font scale to 100%", "Navigation"), ShortcutSpec("F11", "Full screen", "Actions"), ShortcutSpec("Ctrl+Shift+H", "Search spaCR from the Help bar", "Help"), ShortcutSpec("F1", "Show this cheat sheet", "Help"), ShortcutSpec("?", "Show this cheat sheet", "Help") ] #: THE PER-SCREEN BINDINGS, kept apart from :data:`SHORTCUTS` on purpose. #: #: `SHORTCUTS` is what `install()` BINDS on the main window, and a test #: asserts every entry in it is wired there. These keys are bound by the #: screens that own them and do not exist until such a screen is built -- #: so putting them in the same table made "declared" and "wired" stop #: meaning the same thing, and four tests said so at once. #: #: THE MAP IS BOTH (:func:`mapped`). The distinction is real -- one set is #: always live and the other is not -- and it is the same distinction the #: `scope` field states to the reader. SCREEN_SHORTCUTS: List[ShortcutSpec] = [ ShortcutSpec("Left", "Previous image", "Annotate", "the Annotate and Make Masks screens and the QC field " "browser"), ShortcutSpec("Right", "Next image", "Annotate", "the Annotate and Make Masks screens and the QC field " "browser"), ShortcutSpec("PageUp", "Previous image", "Annotate", "the Annotate screen"), ShortcutSpec("PageDown", "Next image", "Annotate", "the Annotate screen"), ShortcutSpec("Alt+Left", "Previous image", "Annotate", "the Annotate screen"), ShortcutSpec("Alt+Right", "Next image", "Annotate", "the Annotate screen"), ShortcutSpec("Y", "Confirm the suggested label", "Annotate", "the Annotate screen"), ShortcutSpec("N", "Reject the suggested label", "Annotate", "the Annotate screen"), ShortcutSpec("Ctrl+Z", "Undo", "Annotate", "the Annotate screen"), ShortcutSpec("Ctrl+Y", "Redo", "Annotate", "the Annotate screen"), ShortcutSpec("Ctrl+Shift+Z", "Redo", "Annotate", "the Annotate screen"), ShortcutSpec("B", "Brush", "Make Masks", "the Make Masks screen"), ShortcutSpec("E", "Erase", "Make Masks", "the Make Masks screen"), ShortcutSpec("X", "Box tool", "Make Masks", "the Make Masks screen"), ShortcutSpec("W", "Magic wand — add", "Make Masks", "the Make Masks screen"), ShortcutSpec("D", "Draw an object", "Make Masks", "the Make Masks screen"), ShortcutSpec("V", "Divide an object", "Make Masks", "the Make Masks screen"), ShortcutSpec("R", "Recrop an object", "Make Masks", "the Make Masks screen"), ShortcutSpec("Z", "Zoom", "Make Masks", "the Make Masks screen"), ShortcutSpec("M", "Live magnifier", "Make Masks", "the Make Masks screen"), ShortcutSpec("Esc", "Reset the zoom", "Make Masks", "the Make Masks screen"), ShortcutSpec("Ctrl+S", "Save the mask", "Make Masks", "the Make Masks screen"), ShortcutSpec("Ctrl+Z", "Undo", "Make Masks", "the Make Masks screen"), ShortcutSpec("Ctrl+Y", "Redo", "Make Masks", "the Make Masks screen"), ShortcutSpec("Ctrl+Shift+Z", "Redo", "Make Masks", "the Make Masks screen"), ShortcutSpec("Q", "Quarantine or restore this field", "Field browser", "the QC field browser"), ] #: Window-wide keys that something OTHER than `install()` binds. They are #: attached to window actions, so they belong on the map and not in #: ``install()``'s count. BOUND_ELSEWHERE = frozenset({ "Ctrl+Shift+A", "Ctrl+B", "Ctrl+T", "Ctrl+R", "Ctrl+Shift+F", "F11", "Ctrl+0", "Ctrl+P", }) #: The keys every editor answers for undo and redo. Ctrl reads as Command on #: macOS; Ctrl+Y is the Windows habit and Ctrl+Shift+Z everyone else's. _UNDO_KEYS = ("Ctrl+Z",) _REDO_KEYS = ("Ctrl+Shift+Z", "Ctrl+Y") class _ValueEdit(QUndoCommand): """One undoable change of a value, replayed through a setter. The change has already happened when it is recorded, so the first ``redo`` that :meth:`QUndoStack.push` makes is skipped. """ def __init__(self, text, apply, old, new): """Remember both values and the setter that writes either back. :param text: what the step is called in an undo menu. :param apply: callable taking a value and writing it back. :param old: the value before the edit. :param new: the value after the edit. """ super().__init__(text) self._apply = apply self.old = old self.new = new self._applied = True def redo(self): """Write the new value back, unless it is already there.""" if self._applied: self._applied = False return self._apply(self.new) def undo(self): """Write the old value back.""" self._apply(self.old) def _same(a, b) -> bool: """Whether two recorded values are equal, tolerating odd comparisons.""" try: return bool(a == b) except Exception: # noqa: BLE001 return False def _record_edit(stack: QUndoStack, text: str, apply, old, new) -> bool: """Push one finished edit onto ``stack`` unless it changed nothing. :param stack: the editor's undo stack. :param text: what the step is called. :param apply: callable writing a value back. :param old: the value before the edit. :param new: the value after it. :returns: whether a step was pushed. """ if _same(old, new): return False stack.push(_ValueEdit(text, apply, old, new)) return True def _bind_undo_keys(widget: QWidget, undo, redo=None) -> List[QShortcut]: """Bind Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y on ``widget`` and its children. A text field that has the focus keeps its own Ctrl+Z, so typing is still undone a character at a time before the editor's steps are reached. :param widget: the editor that owns the keys. :param undo: a :class:`QUndoStack`, or a callable that undoes one step. :param redo: the callable that redoes one step; taken from ``undo`` when that is a stack. :returns: the shortcuts made, parented to ``widget``. """ if isinstance(undo, QUndoStack): stack = undo undo, redo = stack.undo, stack.redo made = [] for keys, slot in ([(k, undo) for k in _UNDO_KEYS] + [(k, redo) for k in _REDO_KEYS]): if slot is None: continue shortcut = QShortcut(QKeySequence(keys), widget) shortcut.setContext(Qt.WidgetWithChildrenShortcut) shortcut.activated.connect(slot) made.append(shortcut) return made def _is_a_gesture(keys: str) -> bool: """Whether ``keys`` describes a hand movement rather than a key sequence. RECOGNISED BY SHAPE, NOT BY A LIST OF EXCEPTIONS. A real accelerator is "Ctrl+Shift+A" with no spaces; a gesture is prose -- "Z + scroll" -- and the spaces around the plus are what say so. A second gesture therefore needs no edit here, and a mistyped sequence still fails rather than being quietly excused as a gesture. The same rule is asserted from the other side in `tests/qt/test_shortcut_overlay.py`, which refuses any gesture it has not been told about. """ return " + " in str(keys)
[docs] def installed() -> List[ShortcutSpec]: """The window-wide keys that :func:`install` is responsible for binding. Gestures are not among them. A gesture is a modifier held while the mouse wheel turns, and it is caught by an event filter rather than by a key sequence, so no shortcut object can express it and none is created for it. Listing one here would promise a binding that cannot be made. Every gesture is still listed on the shortcut map. :func:`mapped` returns what the hands can do, and this returns what the shortcut objects own. """ return [s for s in SHORTCUTS if s.keys not in BOUND_ELSEWHERE and not _is_a_gesture(s.keys)]
[docs] def mapped() -> List[ShortcutSpec]: """Every shortcut the map describes: window-wide, then per-screen.""" local = _load_screen_keymap() return list(SHORTCUTS) + [ ShortcutSpec(local.get(scope, {}).get(spec.keys, spec.keys), spec.label, scope, _SCREEN_SCOPES[scope]) for scope in _SCREEN_SCOPES for spec in _screen_specs(scope)]
[docs] def native(keys: str) -> str: """``keys`` in the spelling the user's own keyboard has. `Ctrl` is the Command symbol on macOS and Qt already knows; writing "Ctrl+H" into a label hard-codes one platform into the help. :param keys: a key sequence in Qt's portable spelling, such as ``'Ctrl+H'``. Returned unchanged when Qt cannot convert it. """ try: return QKeySequence(_portable(keys)).toString(QKeySequence.NativeText) \ or str(keys) except Exception: # noqa: BLE001 return str(keys)
[docs] def discover(window) -> List[ShortcutSpec]: """Every shortcut LIVE on ``window``, whether declared or not. The declared table is what the map is drawn from, because a per-screen binding does not exist until that screen is built and the map has to describe it anyway. This is the other half: a shortcut added at runtime -- a plugin, a menu action -- appears without anyone editing a list. Anything already in :data:`SHORTCUTS` is left to its declaration, which is where the label and the scope live. :param window: the widget whose child :class:`QShortcut` and :class:`QAction` objects are searched; a widget that cannot be searched yields an empty list. """ from PySide6.QtGui import QAction keymap = _load_keymap() known = {native(spec.keys) for spec in mapped()} \ | {native(_effective(spec.keys, keymap)) for spec in mapped()} out: List[ShortcutSpec] = [] seen = set() try: holders = list(window.findChildren(QShortcut)) \ + list(window.findChildren(QAction)) except Exception: # noqa: BLE001 return out for holder in holders: try: sequence = holder.key() if isinstance(holder, QShortcut) \ else holder.shortcut() printed = sequence.toString(QKeySequence.NativeText) except Exception: # noqa: BLE001 continue if not printed or printed in known or printed in seen: continue seen.add(printed) label = "" if isinstance(holder, QAction): label = holder.text().replace("&", "").strip() out.append(ShortcutSpec(printed, label or "(not described)", "Other")) return out
#: The Preferences key the user's keymap overrides are stored under. _KEYMAP_KEY = "shortcuts/keymap" _SCREEN_KEYMAP_KEY = "shortcuts/screens" _SCREEN_SCOPES = { "Annotate": "the Annotate screen", "Make Masks": "the Make Masks screen", "Field browser": "the QC field browser", } _ANNOTATE_SHORTCUTS = [ ShortcutSpec("Up", "Move focus up", "Annotate", "the Annotate screen"), ShortcutSpec("Down", "Move focus down", "Annotate", "the Annotate screen"), ShortcutSpec("H", "Move focus left", "Annotate", "the Annotate screen"), ShortcutSpec("J", "Move focus down", "Annotate", "the Annotate screen"), ShortcutSpec("K", "Move focus up", "Annotate", "the Annotate screen"), ShortcutSpec("L", "Move focus right", "Annotate", "the Annotate screen"), ShortcutSpec("U", "Undo", "Annotate", "the Annotate screen"), ShortcutSpec("Space", "Skip forward one crop", "Annotate", "the Annotate screen"), ShortcutSpec("Backspace", "Step back one crop", "Annotate", "the Annotate screen"), ShortcutSpec("Return", "Save and load the next batch", "Annotate", "the Annotate screen"), ShortcutSpec("Enter", "Save and load the next batch", "Annotate", "the Annotate screen"), ShortcutSpec("?", "Toggle keyboard legend", "Annotate", "the Annotate screen"), ShortcutSpec("Esc", "Close keyboard legend or zoom", "Annotate", "the Annotate screen"), ShortcutSpec("0", "Clear the focused crop", "Annotate", "the Annotate screen"), ShortcutSpec("1", "Assign class 1", "Annotate", "the Annotate screen"), ShortcutSpec("2", "Assign class 2", "Annotate", "the Annotate screen"), ShortcutSpec("3", "Assign class 3", "Annotate", "the Annotate screen"), ShortcutSpec("4", "Assign class 4", "Annotate", "the Annotate screen"), ShortcutSpec("5", "Assign class 5", "Annotate", "the Annotate screen"), ShortcutSpec("6", "Assign class 6", "Annotate", "the Annotate screen"), ShortcutSpec("7", "Assign class 7", "Annotate", "the Annotate screen"), ShortcutSpec("8", "Assign class 8", "Annotate", "the Annotate screen"), ShortcutSpec("9", "Assign class 9", "Annotate", "the Annotate screen"), ] def _screen_specs(scope: str) -> List[ShortcutSpec]: """Declared bindings for one screen, including shared navigation keys.""" return [spec for spec in SCREEN_SHORTCUTS if spec.keys in ("Left", "Right") or spec.scope == _SCREEN_SCOPES.get(scope)] + ( list(_ANNOTATE_SHORTCUTS) if scope == "Annotate" else []) def _screen_label(label: str) -> str: """Translate scoped action captions from their canonical English source.""" from .i18n import tr captions = { 'Move focus up': tr('Move focus up'), 'Move focus down': tr('Move focus down'), 'Move focus left': tr('Move focus left'), 'Move focus right': tr('Move focus right'), 'Skip forward one crop': tr('Skip forward one crop'), 'Step back one crop': tr('Step back one crop'), 'Save and load the next batch': tr('Save and load the next batch'), 'Toggle keyboard legend': tr('Toggle keyboard legend'), 'Close keyboard legend or zoom': tr('Close keyboard legend or zoom'), 'Clear the focused crop': tr('Clear the focused crop'), 'Assign class 1': tr('Assign class 1'), 'Assign class 2': tr('Assign class 2'), 'Assign class 3': tr('Assign class 3'), 'Assign class 4': tr('Assign class 4'), 'Assign class 5': tr('Assign class 5'), 'Assign class 6': tr('Assign class 6'), 'Assign class 7': tr('Assign class 7'), 'Assign class 8': tr('Assign class 8'), 'Assign class 9': tr('Assign class 9'), } return captions.get(label, tr(label)) def _load_screen_keymap() -> dict: """Read known single-key per-screen overrides from the preferences store.""" import json try: from .preferences import _settings data = json.loads(_settings().value(_SCREEN_KEYMAP_KEY, "") or "{}") except Exception: LOG.debug("could not read screen shortcuts", exc_info=True) return {} if not isinstance(data, dict): return {} result = {} for scope in _SCREEN_SCOPES: values = data.get(scope, {}) if not isinstance(values, dict): continue result[scope] = {spec.keys: _portable(values[spec.keys]) for spec in _screen_specs(scope) if isinstance(values.get(spec.keys), str) and QKeySequence(_portable(values[spec.keys])).count() <= 1 and (not values[spec.keys] or _portable(values[spec.keys])) and (not values[spec.keys] or QKeySequence(_portable(values[spec.keys]))[0].key() != Qt.Key_unknown)} return result def _bind_screen_key(widget, scope: str, default: str, callback) -> QShortcut: """Bind a declared screen key with its saved value and retain its identity. :param widget: screen owning the shortcut. :param scope: screen category in the declaration table. :param default: original portable key, identifying this binding. :param callback: action invoked on activation. :returns: the owned, screen-scoped shortcut. """ if getattr(widget, "_spacr_screen_scope", None) != scope: widget._spacr_screen_scope = scope widget._spacr_screen_keymap = _load_screen_keymap().get(scope, {}) shortcut = QShortcut(QKeySequence( _portable(widget._spacr_screen_keymap.get(default, default))), widget) shortcut.setContext(Qt.WidgetWithChildrenShortcut) shortcut.activated.connect(callback) holders = getattr(widget, "_spacr_screen_holders", None) if holders is None: holders = widget._spacr_screen_holders = {} holders[default] = shortcut _refresh_screen_hints(widget) return shortcut def _apply_screen_keymaps() -> None: """Update built screens and independent browser dialogs after a save.""" saved = _load_screen_keymap() app = QApplication.instance() if app is None: return for widget in app.allWidgets(): scope = getattr(widget, "_spacr_screen_scope", None) if scope not in _SCREEN_SCOPES: continue widget._spacr_screen_keymap = saved.get(scope, {}) for default, holder in getattr(widget, "_spacr_screen_holders", {}).items(): chosen = widget._spacr_screen_keymap.get(default, default) try: holder.setKey(QKeySequence(_portable(chosen))) except RuntimeError: continue _refresh_screen_hints(widget) def _refresh_screen_hints(widget) -> None: """Keep inline key legends synchronized without changing mouse gestures.""" from .i18n import tr keymap = getattr(widget, "_spacr_screen_keymap", {}) rows = getattr(widget, "_shortcut_rows", {}) for default in getattr(widget, "_spacr_screen_holders", {}): if default in rows: rows[default][0].setText(native(keymap.get(default, default)) or "—") groups = { "Left / Right arrows": ("Left", "Right"), "Ctrl+Z / Ctrl+Y": ("Ctrl+Z", "Ctrl+Y"), "B E W D V Z R": ("B", "E", "W", "D", "V", "Z", "R"), } for caption, defaults in groups.items(): if caption in rows: text = tr(caption) if not any(key in keymap for key in defaults) else " / ".join( native(keymap.get(key, key)) or "—" for key in defaults) rows[caption][0].setText(text) legend = getattr(widget, "_legend_label", None) if legend is not None: from html import escape text = widget.LEGEND_FULL if getattr(widget, "_legend_expanded", False) else widget.LEGEND_COMPACT def shown(key): """Escape a saved key for the existing rich-text legend.""" return escape(native(keymap.get(key, key)) or "—") if any(key in keymap for key in ("Left", "Up", "Down", "Right")): text = text.replace("← ↑ ↓ →", " ".join( shown(key) for key in ("Left", "Up", "Down", "Right"))) if any(key in keymap for key in ("H", "J", "K", "L")): aliases = " ".join(shown(key) for key in ("H", "J", "K", "L")) text = text.replace("hjkl", aliases).replace("h j k l", aliases) if any(str(index) in keymap for index in range(1, 10)): text = text.replace("<b>1</b>–<b>9</b>", "<b>" + " / ".join( shown(str(index)) for index in range(1, 10)) + "</b>") for key, printed in (("0", "0"), ("Space", "Space"), ("Backspace", "Backspace"), ("U", "u")): if key in keymap: text = text.replace("<b>" + printed + "</b>", "<b>" + shown(key) + "</b>") if "Return" in keymap or "Enter" in keymap: text = text.replace("<b>Enter</b>", "<b>" + shown("Return") + " / " + shown("Enter") + "</b>") legend.setText(text) if getattr(widget, "_spacr_screen_scope", None) == "Field browser": widget._quarantine.setToolTip(tr( "Move this merged .npy to merged_quarantined so later Measure " "runs skip it. Press {key} to quarantine or restore.", key=native(keymap.get("Q", "Q")) or "—")) def _screen_event_key(widget, event) -> Optional[int]: """Resolve a real key event to its declared original screen action key. Rebound and cleared defaults stop routing through fixed event handlers. Unmodified undeclared keys retain the screen's existing keyboard tools; unrelated modified keys remain available to their own shortcuts. """ sequence = QKeySequence(event.keyCombination()).toString(QKeySequence.PortableText) keymap = getattr(widget, "_spacr_screen_keymap", {}) scope = getattr(widget, "_spacr_screen_scope", "") specs = _screen_specs(scope) if scope in _SCREEN_SCOPES else [] for spec in specs: chosen = keymap.get(spec.keys, spec.keys) if chosen and _portable(chosen) == sequence: return QKeySequence(_portable(spec.keys))[0].key() if (_portable(chosen) == _portable(spec.keys) and not event.modifiers() & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier) and QKeySequence(_portable(spec.keys))[0].keyboardModifiers() == Qt.NoModifier and event.key() == QKeySequence(_portable(spec.keys))[0].key()): return event.key() original = QKeySequence(event.key()).toString(QKeySequence.PortableText) if sequence in {_portable(spec.keys) for spec in specs} or ( original in {_portable(spec.keys) for spec in specs} and not event.modifiers() & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)): return None if event.modifiers() & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier): return None text_key = _portable(event.text().upper()) if event.text() else "" for spec in specs: chosen = keymap.get(spec.keys, spec.keys) if text_key and chosen and _portable(chosen) == text_key: return QKeySequence(_portable(spec.keys))[0].key() if text_key and text_key in {_portable(spec.keys) for spec in specs}: return None return event.key() def _portable(keys: str) -> str: """``keys`` in Qt's portable spelling, or ``""`` when Qt cannot read it.""" try: return QKeySequence(str(keys).replace("PageUp", "PgUp").replace( "PageDown", "PgDown")).toString(QKeySequence.PortableText) except Exception: # noqa: BLE001 return "" def _rebindable() -> List[ShortcutSpec]: """The window-wide actions a user may rebind, one row per default key. Gestures are left out, since no key sequence expresses them. """ out: List[ShortcutSpec] = [] seen = set() for spec in SHORTCUTS: if _is_a_gesture(spec.keys) or spec.keys in seen: continue seen.add(spec.keys) out.append(spec) return out def _load_keymap() -> dict: """The saved overrides, default key to chosen key. A chosen key of ``""`` means the action has no key. Entries for keys that are no longer rebindable, and anything unreadable, are dropped. """ import json try: from .preferences import _settings raw = _settings().value(_KEYMAP_KEY, "") data = json.loads(raw) if raw else {} except Exception: # noqa: BLE001 LOG.debug("could not read the keymap", exc_info=True) return {} if not isinstance(data, dict): return {} known = {spec.keys for spec in _rebindable()} return {str(k): str(v) for k, v in data.items() if k in known} def _save_keymap(keymap: dict, screen_keymap: Optional[dict] = None) -> None: """Store ``keymap``, keeping only the entries that differ from the default. :param keymap: default key to chosen key; ``""`` unbinds the action. :param screen_keymap: per-screen overrides; saved ones when omitted. :raises ValueError: when two actions would share one key. """ import json clean = {} for default, chosen in (keymap or {}).items(): chosen = _portable(chosen) if chosen else "" if chosen != _portable(default): clean[str(default)] = chosen screens = _load_screen_keymap() if screen_keymap is None else screen_keymap local = {} for scope in _SCREEN_SCOPES: values = screens.get(scope, {}) chosen = {} for spec in _screen_specs(scope): key = values.get(spec.keys, spec.keys) if (not isinstance(key, str) or QKeySequence(_portable(key)).count() > 1 or (key and (not _portable(key) or QKeySequence(_portable(key))[0].key() == Qt.Key_unknown))): from .i18n import tr raise ValueError(tr("Shortcut {key} is not a single key sequence.", key=str(key))) if _portable(key) != _portable(spec.keys): chosen[spec.keys] = _portable(key) if chosen: local[scope] = chosen clashes = _conflicts(clean, local) if clashes: raise ValueError(_describe_conflicts(clashes)) from .preferences import _settings store = _settings() store.setValue(_KEYMAP_KEY, json.dumps(clean, sort_keys=True)) store.setValue(_SCREEN_KEYMAP_KEY, json.dumps(local, sort_keys=True)) def _effective(default: str, keymap: Optional[dict] = None) -> str: """The key the action whose default is ``default`` answers to now. :param default: the action's default key, which is also its identity. :param keymap: the overrides to read; the saved ones when omitted. """ keymap = _load_keymap() if keymap is None else keymap return keymap.get(default, default) def _conflicts(keymap: dict, screen_keymap: Optional[dict] = None) -> dict: """Conflicts among global actions and within each independently active screen. :param keymap: global default key to chosen key. :param screen_keymap: screen category to default-key overrides. :returns: conflicting portable keys and their action labels. """ screens = _load_screen_keymap() if screen_keymap is None else screen_keymap global_holders = {} for spec in _rebindable(): key = _portable(_effective(spec.keys, keymap)) if key: global_holders.setdefault(key, []).append(spec.label) out = {key: labels for key, labels in global_holders.items() if len(labels) > 1} for scope in _SCREEN_SCOPES: holders = {key: list(labels) for key, labels in global_holders.items()} for spec in _screen_specs(scope): key = _portable(screens.get(scope, {}).get(spec.keys, spec.keys)) if key: if (scope == "Annotate" and spec.keys == "?" and key == "?" and _effective("?", keymap) == "?"): continue holders.setdefault(key, []).append(spec.label) for key, labels in holders.items(): if len(labels) > 1: out[key] = list(dict.fromkeys(out.get(key, []) + labels)) return out def _describe_conflicts(clashes: dict) -> str: """One sentence per shared key, naming the actions that share it.""" from .i18n import tr return "\n".join( tr("{key} is used by: {actions}.", key=native(key), actions=", ".join(_screen_label(label) for label in labels)) for key, labels in clashes.items()) def _holders(window) -> dict: """The shortcut or menu action holding each rebindable key on ``window``. Recorded once, while every key still holds its default, so a later rebind can find the holder again whatever key it now answers to. """ registry = getattr(window, "_spacr_keymap_holders", None) if registry is None: registry = {} try: window._spacr_keymap_holders = registry except Exception: # noqa: BLE001 return registry for spec in _rebindable(): if spec.keys in registry: cached = registry[spec.keys] try: if isinstance(cached, QShortcut): cached.key() else: cached.shortcut() except RuntimeError: registry.pop(spec.keys, None) else: continue sequence = QKeySequence(spec.keys) try: shortcuts = window.findChildren( QShortcut, options=Qt.FindDirectChildrenOnly) actions = window.findChildren(QAction) except Exception: # noqa: BLE001 return registry for holder in list(shortcuts) + list(actions): current = holder.key() if isinstance(holder, QShortcut) \ else holder.shortcut() if not current.isEmpty() and current == sequence: registry[spec.keys] = holder break return registry def _apply_keymap(window, keymap: Optional[dict] = None) -> int: """Put every rebindable key on ``window`` to its chosen value. :param window: the main window whose shortcuts and menu actions change. :param keymap: the overrides; the saved ones when omitted. :returns: how many holders were set. """ keymap = _load_keymap() if keymap is None else keymap changed = 0 for default, holder in list(_holders(window).items()): sequence = QKeySequence(_effective(default, keymap)) try: if isinstance(holder, QShortcut): holder.setKey(sequence) else: holder.setShortcut(sequence) except RuntimeError: continue changed += 1 return changed class _KeymapDialog(QDialog): """Rebind global and screen shortcuts, refusing simultaneously active conflicts. One row per action: what it does, its default key, and an editor holding the key it answers to now. Conflicts are listed under the table as they appear and keep Save disabled until they are resolved. :param window: the main window the new keys are applied to on Save. """ def __init__(self, window): """Build the table from the saved keymap. :param window: the main window; also the dialog's parent. """ from .i18n import tr super().__init__(window) self._window = window self.setObjectName("KeymapDialog") self.setWindowTitle(tr("Change shortcuts")) self._specs = _rebindable() saved = _load_keymap() screen_saved = _load_screen_keymap() self._screen_rows = [(scope, spec) for scope in _SCREEN_SCOPES for spec in _screen_specs(scope)] column = QVBoxLayout(self) intro = QLabel(tr("Click a shortcut and press the new key. Clear it " "to leave the action without a key."), self) intro.setWordWrap(True) column.addWidget(intro) self._table = QTableWidget(len(self._specs) + len(self._screen_rows), 4, self) self._table.setObjectName("KeymapTable") self._table.setHorizontalHeaderLabels( [tr("Action"), tr("Default"), tr("Shortcut"), tr("Scope")]) self._table.verticalHeader().setVisible(False) from .widgets.sortable_table import install_sorting, table_item self._editors: List[QKeySequenceEdit] = [] for row, spec in enumerate(self._specs): self._table.setItem(row, 0, table_item(_screen_label(spec.label))) self._table.setItem(row, 1, table_item(native(spec.keys))) editor = QKeySequenceEdit( QKeySequence(_effective(spec.keys, saved)), self._table) if hasattr(editor, "setMaximumSequenceLength"): editor.setMaximumSequenceLength(1) if hasattr(editor, "setClearButtonEnabled"): editor.setClearButtonEnabled(True) editor.keySequenceChanged.connect(self._refresh) self._table.setCellWidget(row, 2, editor) self._editors.append(editor) self._screen_editors = [] for index, (scope, spec) in enumerate(self._screen_rows): row = len(self._specs) + index self._table.setItem(row, 0, table_item(_screen_label(spec.label))) self._table.setItem(row, 1, table_item(native(spec.keys))) self._table.setItem(row, 3, table_item(tr(_SCREEN_SCOPES[scope]))) editor = QKeySequenceEdit(QKeySequence( _portable(screen_saved.get(scope, {}).get(spec.keys, spec.keys))), self._table) editor.setMaximumSequenceLength(1) editor.setClearButtonEnabled(True) editor.keySequenceChanged.connect(self._refresh) self._table.setCellWidget(row, 2, editor) self._screen_editors.append(editor) for row in range(len(self._specs)): self._table.setItem(row, 3, table_item(tr(EVERYWHERE))) self._table.resizeColumnsToContents() install_sorting(self._table) column.addWidget(self._table, 1) self._conflict_label = QLabel("", self) self._conflict_label.setObjectName("KeymapConflicts") self._conflict_label.setWordWrap(True) column.addWidget(self._conflict_label) buttons = QHBoxLayout() self._btn_defaults = QPushButton(tr("Restore defaults"), self) self._btn_defaults.setObjectName("KeymapRestoreDefaults") self._btn_defaults.clicked.connect(self.restore_defaults) buttons.addWidget(self._btn_defaults) buttons.addStretch(1) cancel = QPushButton(tr("Cancel"), self) cancel.clicked.connect(self.reject) buttons.addWidget(cancel) self._btn_save = QPushButton(tr("Save"), self) self._btn_save.setObjectName("KeymapSave") self._btn_save.setDefault(True) self._btn_save.clicked.connect(self.save) buttons.addWidget(self._btn_save) column.addLayout(buttons) self._refresh() def keymap(self) -> dict: """The keys in the table, default key to chosen key.""" return {spec.keys: editor.keySequence().toString( QKeySequence.PortableText) for spec, editor in zip(self._specs, self._editors)} def set_key(self, default: str, keys: str) -> None: """Put ``keys`` in the row whose default is ``default``. :param default: the action's default key. :param keys: the new key, in portable spelling; ``""`` clears it. """ for spec, editor in zip(self._specs, self._editors): if spec.keys == default: editor.setKeySequence(QKeySequence(keys)) def _screen_keymap(self) -> dict: """The screen overrides currently displayed in the editor.""" result = {} for (scope, spec), editor in zip(self._screen_rows, self._screen_editors): result.setdefault(scope, {})[spec.keys] = editor.keySequence().toString( QKeySequence.PortableText) return result def _set_screen_key(self, scope: str, default: str, keys: str) -> None: """Edit one scoped binding without changing another screen's action.""" for (row_scope, spec), editor in zip(self._screen_rows, self._screen_editors): if row_scope == scope and spec.keys == default: editor.setKeySequence(QKeySequence(keys)) def conflict_text(self) -> str: """The conflict line under the table, as the user reads it.""" return self._conflict_label.text() def restore_defaults(self) -> None: """Put every row back to its default key.""" for spec, editor in zip(self._specs, self._editors): editor.setKeySequence(QKeySequence(_portable(spec.keys))) for (_scope, spec), editor in zip(self._screen_rows, self._screen_editors): editor.setKeySequence(QKeySequence(_portable(spec.keys))) def _refresh(self, *_args) -> None: """Show the current conflicts and allow Save only when there are none.""" clashes = _conflicts(self.keymap(), self._screen_keymap()) self._conflict_label.setText( _describe_conflicts(clashes) if clashes else "") self._btn_save.setEnabled(not clashes) def save(self) -> bool: """Store the keymap, apply it to the window and close. :returns: ``False`` when a conflict kept it from being stored. """ try: _save_keymap(self.keymap(), self._screen_keymap()) except ValueError as exc: self._conflict_label.setText(str(exc)) return False _apply_keymap(self._window) _apply_screen_keymaps() self.accept() return True def _open_keymap(window) -> "_KeymapDialog": """Open the shortcut editor over ``window`` and return it.""" dialog = _KeymapDialog(window) dialog.show() return dialog
[docs] def install(window: QMainWindow) -> None: """Wire every shortcut in :data:`SHORTCUTS` onto ``window``. Idempotent — safe to call from within reload paths. :param window: the main window the application shortcuts are bound to. """ _bind(window, "Ctrl+K", lambda: _open_palette(window)) _bind(window, "Ctrl+/", lambda: _toggle_ai(window)) end = _bind(window, "Ctrl+End", lambda: _jump_to_the_newest_line(window)) if end is not None: end.activatedAmbiguously.connect( lambda: _jump_to_the_newest_line(window)) _watch_the_stack_for_consoles(window) _bind(window, "Ctrl+F", lambda: _focus_settings_search(window)) _bind(window, "Ctrl+Shift+H", lambda: _focus_help_search(window)) _bind(window, "Ctrl+Shift+R", lambda: _open_recipes(window)) _bind(window, "F1", lambda: show_cheat_sheet(window)) _bind(window, "Ctrl+Alt+0", lambda: _reset_every_scale(window)) _bind(window, "?", lambda: _help_key(window)) for i in range(1, 10): _bind(window, f"Ctrl+{i}", lambda idx=i: _nav_by_index(window, idx - 1)) _install_window_hooks(window) try: _apply_keymap(window) except Exception: # noqa: BLE001 LOG.debug("could not apply the saved keymap", exc_info=True)
def _install_window_hooks(window: QMainWindow) -> None: """Let modules that own a menu entry or a global filter wire themselves. This runs once from ``MainWindow.__init__``, after ``_build_menu_bar``, which makes it the first moment a module can reach a live menu bar — the same route :mod:`spacr.qt.first_run` and :mod:`spacr.qt.command_palette` take to find one. Each hook is guarded on its own: an optional help entry must never cost anyone a window. """ try: from .widgets.feature_dictionary import install_window_hooks install_window_hooks(window) except Exception: LOG.debug("Could not install the feature dictionary hooks", exc_info=True) try: from .settings_search import install_window_hooks as _search_hooks _search_hooks(window) except Exception: LOG.debug("Could not install the settings search hooks", exc_info=True) try: from .help_search import install_window_hooks as _help_search_hooks _help_search_hooks(window) except Exception: LOG.debug("Could not install the help search field", exc_info=True) try: from .recipes import install_window_hooks as _recipe_hooks _recipe_hooks(window) except Exception: LOG.debug("Could not install the recipe hooks", exc_info=True) try: from .preview_registry import install_window_hooks as _preview_hooks _preview_hooks(window) except Exception: LOG.debug("Could not install the preview hooks", exc_info=True) try: from .walkthrough import install_window_hooks as _walkthrough_hooks _walkthrough_hooks(window) except Exception: LOG.debug("Could not install the walkthrough hooks", exc_info=True) try: from .screens.map_barcodes import install_window_hooks as _fold_hooks _fold_hooks(window) except Exception: LOG.debug("Could not install the fold-strip hooks", exc_info=True) try: pin = getattr(window, "pin_all_menu_roles", None) if callable(pin): pin() except Exception: LOG.debug("Could not pin the menu roles", exc_info=True) def _bind(window: QMainWindow, keys: str, cb: Callable[[], None]) -> Optional[QShortcut]: """Wire ``keys`` on ``window`` and hand the binding back to the caller. ONCE PER KEY, which is what makes :func:`install` idempotent in the only sense that matters here: a second holder of one key makes it AMBIGUOUS, and an ambiguous shortcut fires neither handler -- so a reload path calling `install` again would silence every key it re-bound. Only the window's OWN shortcuts are consulted. `findChildren` reaches the whole tree, and a console panel holding `Ctrl+End` deeper down would otherwise look like this key was already wired. Returned rather than dropped because a key with a second holder somewhere else needs its ambiguous activation connected too. """ sequence = QKeySequence(keys) registry = getattr(window, "_spacr_keymap_holders", None) or {} owner = registry.get(keys) if isinstance(owner, QShortcut): try: owner.key() return owner except RuntimeError: pass for existing in window.findChildren( QShortcut, options=Qt.FindDirectChildrenOnly): if existing.key() == sequence: return existing for action in window.findChildren(QAction): if not action.shortcut().isEmpty() and action.shortcut() == sequence: return None sc = QShortcut(sequence, window) sc.setContext(Qt.WindowShortcut) sc.activated.connect(cb) return sc def _help_key(window: QMainWindow) -> None: """Handle bare ``?``: let the active screen claim it, else show the sheet. A ``Qt.ApplicationShortcut`` fires before the focused widget's ``keyPressEvent``, so without this the global cheat sheet would preempt any screen that wants ``?`` for itself — the Annotate screen uses it to toggle its inline key legend, and opening a modal sheet over a rapid-labelling session is exactly the wrong response. A screen opts in by exposing ``handle_key`` and returning True from it. ``F1`` remains an unconditional route to the cheat sheet everywhere. """ try: screen = window._stack.currentWidget() except Exception: screen = None handler = getattr(screen, "handle_key", None) if callable(handler): try: if handler("?"): return except Exception: pass show_cheat_sheet(window) def _nav(window: QMainWindow, key: str) -> None: """Navigate the window to a module. :param window: the main window; one without the navigation slot is tolerated, so a shortcut cannot crash a bare dialog. :param key: the module to open. """ if hasattr(window, "_on_nav_selected"): window._on_nav_selected(key) def _nav_by_index(window: QMainWindow, idx: int) -> None: """Navigate to the nth VISIBLE module, for the number-key shortcuts. An index past the end, or one naming a module the user has hidden, does nothing -- a shortcut that jumps somewhere unexpected is worse than one that does not fire. :param window: the main window. :param idx: the module's position in the registry. """ try: from .app import APPS, app_is_visible if 0 <= idx < len(APPS) and app_is_visible(APPS[idx][0]): _nav(window, APPS[idx][0]) except Exception: pass def _open_palette(window: QMainWindow) -> None: """Open the command palette. :param window: the main window. A build without the palette logs and does nothing rather than raising out of a key press. """ try: from .command_palette import CommandPalette CommandPalette(window).exec() except Exception as e: LOG.debug("command palette not available: %s", e) def _open_preferences(window: QMainWindow) -> None: """Open the Preferences dialog. :param window: the main window. """ try: from .preferences import PreferencesDialog PreferencesDialog(window).exec() except Exception as e: LOG.debug("preferences dialog not available: %s", e) def _reset_every_scale(window: QMainWindow) -> None: """Put GUI scale, font scale and every preview scale back to 100 %, now. The backup way out of a scale too small to read (item 471): it needs no reading and asks nothing. Ctrl+Alt rather than Ctrl+Shift, which some Windows keyboard setups take for switching layout, and 0 rather than a letter, because Cmd+Option+0 is not one of the combinations macOS reserves. :param window: the main window. """ try: from .gui_scale import reset_every_scale reset_every_scale(window) except Exception: # noqa: BLE001 LOG.debug("could not reset the scales", exc_info=True) def _toggle_ai(window: QMainWindow) -> None: """Toggle the AI switch on the currently active AppScreen.""" try: from .screens.app_screen import AppScreen current = None for s in window.findChildren(AppScreen): if s.isVisible(): current = s break if current is not None and hasattr(current, "_ai_switch"): current._ai_switch.setChecked( not current._ai_switch.isChecked() ) except Exception: # noqa: BLE001 LOG.debug("could not toggle the AI switch", exc_info=True) def _consoles(window) -> list: """Every console panel living under ``window``, newest screens included.""" try: from .widgets.console_panel import ConsolePanel return list(window.findChildren(ConsolePanel)) except Exception: # noqa: BLE001 LOG.debug("could not look for console panels", exc_info=True) return [] def _hand_ctrl_end_to_the_window(window, panels=None) -> None: """Stand the consoles' own ``Ctrl+End`` down in favour of the window's. Two live bindings for one key are not two chances to be heard: Qt calls that ambiguous and fires NEITHER handler. A console panel binds the key on itself so the gesture still works when the panel is used on its own, and inside a window that binds it too that copy is redundant -- the window's reaches the same panel and reaches it from every screen. :param panels: the consoles to sweep, when the caller has already found them; otherwise they are looked up. """ for panel in (_consoles(window) if panels is None else panels): own = getattr(panel, "_end_shortcut", None) if own is None: continue try: if own.isEnabled(): own.setEnabled(False) except RuntimeError: # noqa: PERF203 continue def _watch_the_stack_for_consoles(window) -> None: """Sweep each screen as it is shown, since screens are built on demand. The console of a module that has never been opened does not exist yet, so the stand-down cannot be done once at start-up and be finished. """ _hand_ctrl_end_to_the_window(window) try: window._stack.currentChanged.connect( lambda _index: _hand_ctrl_end_to_the_window(window)) except Exception: # noqa: BLE001 LOG.debug("no screen stack to watch for consoles", exc_info=True) def _jump_to_the_newest_line(window) -> None: """Send the console on screen to its newest line. A long run writes thousands of lines and the one that matters is the last; getting to it must not be a scroll through everything above it. Screens without a console are left alone rather than swallowing the key. """ panels = _consoles(window) _hand_ctrl_end_to_the_window(window, panels) for panel in panels: try: if not panel.isVisible(): continue panel.jump_to_the_end() return except Exception: # noqa: BLE001 LOG.debug("could not jump the console to its end", exc_info=True) #: objectNames, so the theme can reach the overlay and tests can find it. OVERLAY_NAME = "ShortcutOverlay" OVERLAY_CARD_NAME = "ShortcutOverlayCard" OVERLAY_SCROLL_NAME = "ShortcutOverlayScroll" class _ShortcutCard(QWidget): """Paint the rounded shortcut surface independently of global stylesheet timing.""" def paintEvent(self, event): """Keep the background 80-percent opaque and shortcut text fully opaque.""" from .theme import active_palette palette = active_palette() painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) fill = QColor(palette["surface"]) fill.setAlphaF(.8) painter.setBrush(fill) painter.setPen(QPen(QColor(palette["border"]), 1)) painter.drawRoundedRect(QRectF(self.rect()).adjusted(.5, .5, -.5, -.5), 16, 16)
[docs] class ShortcutOverlay(QWidget): """The ``?`` overlay — every shortcut, over the window, dismissed by any key. A modal dialog was the wrong shape for this. The question a user asks by pressing ``?`` is "what can I press *here*", and the answer is worth about two seconds; a dialog with a title bar and a close button makes them commit to a mode, find the button, and leave it. An overlay dims what is behind, answers, and disappears on the next keystroke or click — including on ``?`` itself, so the key that opened it also closes it. Laid out in columns by category rather than one long list, because fifteen bindings in one column is a scroll and in three is a glance. :param window: the window to cover and to read the bindings from. The overlay is drawn OVER it rather than as a dialog of its own, which is the whole argument above -- so this is not a parent in the ordinary sense but the thing being annotated. """ def __init__(self, window: QWidget): """Build the shortcut cheat sheet as a card over the window. :param window: the window it covers; the card is centred in it and scrolls when the map does not fit. """ from .i18n import tr super().__init__(window) self.setObjectName(OVERLAY_NAME) self._window = window self.setGeometry(window.rect()) self.setAttribute(Qt.WA_TransparentForMouseEvents, False) self.setFocusPolicy(Qt.StrongFocus) self._card = _ShortcutCard(self) self._card.setObjectName(OVERLAY_CARD_NAME) self._card.setStyleSheet( "QWidget#ShortcutOverlayCard, QScrollArea#ShortcutOverlayScroll, " "QScrollArea#ShortcutOverlayScroll QWidget { background: transparent; border: none; }") card_layout = QVBoxLayout(self._card) card_layout.setContentsMargins(0, 0, 0, 0) card_layout.setSpacing(0) self._scroll = QScrollArea(self._card) self._scroll.setObjectName(OVERLAY_SCROLL_NAME) self._scroll.setWidgetResizable(False) self._scroll.setFocusPolicy(Qt.NoFocus) self._scroll.viewport().setAutoFillBackground(False) self._scroll.viewport().installEventFilter(self) card_layout.addWidget(self._scroll) self._card_content = QWidget() self._card_content.setAutoFillBackground(False) grid = QGridLayout(self._card_content) grid.setContentsMargins(28, 24, 28, 24) grid.setHorizontalSpacing(36) grid.setVerticalSpacing(6) title = QLabel(tr("Keyboard shortcuts"), self._card_content) title.setObjectName("ShortcutOverlayTitle") grid.addWidget(title, 0, 0, 1, 2) keymap = _load_keymap() by_cat: dict[str, list[ShortcutSpec]] = {} for spec in mapped() + discover(self.parent()): by_cat.setdefault(spec.category, []).append(spec) room = max(int(self.width() * 0.9), 640) per_pair = 420 pairs = max(1, min(len(by_cat), room // per_pair)) band = 1 column = 0 for index, (category, specs) in enumerate(by_cat.items()): if index and index % pairs == 0: band = grid.rowCount() + 1 column = 0 row = band header = QLabel(tr(category).upper(), self._card_content) header.setObjectName("ShortcutOverlayCategory") grid.addWidget(header, row, column, 1, 2) row += 1 for spec in specs: keys = QLabel(native(_effective(spec.keys, keymap) if spec.scope == EVERYWHERE else spec.keys) or "—", self._card_content) keys.setObjectName("ShortcutOverlayKeys") keys.setAlignment(Qt.AlignRight | Qt.AlignVCenter) grid.addWidget(keys, row, column) said = _screen_label(spec.label) if spec.scope and spec.scope != EVERYWHERE: said = f"{said} — {tr(spec.scope)}" label = QLabel(said, self._card_content) label.setObjectName("ShortcutOverlayLabel") grid.addWidget(label, row, column + 1) row += 1 column += 2 hint = QLabel(tr("Press any key to close."), self._card_content) hint.setObjectName("ShortcutOverlayHint") hint_row = grid.rowCount() grid.addWidget(hint, hint_row, 0, 1, max(column - 1, 1)) self._edit_button = QPushButton(tr("Change shortcuts…"), self._card_content) self._edit_button.setObjectName("ShortcutOverlayEdit") self._edit_button.setFocusPolicy(Qt.NoFocus) self._edit_button.clicked.connect(self._on_edit) grid.addWidget(self._edit_button, hint_row, max(column - 1, 1), Qt.AlignRight) self._scroll.setWidget(self._card_content) grid.activate() self._card_content.adjustSize() self._reposition() window.installEventFilter(self)
[docs] def paintEvent(self, event) -> None: """Leave the main window visible around the translucent shortcut card. :param event: the paint event; ignored, so nothing is painted behind the card. """ pass
[docs] def resizeEvent(self, event) -> None: """Keep the card centred when the window resizes. :param event: the resize event; not read, the card is re-centred for the overlay's current size. """ self._reposition()
def _reposition(self) -> None: """Centre the card and size it to its content, within the window. The scrollbar's width is added only when the content is actually taller than the space -- reserving it unconditionally would leave a gap beside a map that fits. """ hint = self._card_content.sizeHint() max_width = max(1, self.width() - 24) max_height = max(1, self.height() - 24) needs_vertical_scroll = hint.height() > max_height scrollbar_width = ( self._scroll.verticalScrollBar().sizeHint().width() if needs_vertical_scroll else 0 ) width = min(max_width, hint.width() + scrollbar_width) height = min(max_height, hint.height()) self._card_content.resize(hint) self._card.setGeometry( max(0, (self.width() - width) // 2), max(0, (self.height() - height) // 2), width, height, )
[docs] def eventFilter(self, obj, event): """Track the window's size so the overlay stays full-bleed. :param obj: the watched object: the covered window or the card's scroll viewport. :param event: the event delivered to it. A resize of the window resizes the overlay to match; a mouse press on the scroll viewport dismisses the overlay and is consumed. """ window = getattr(self, "_window", None) scroll = getattr(self, "_scroll", None) if window is None or scroll is None: return super().eventFilter(obj, event) if obj is window and event.type() == QEvent.Resize: self.setGeometry(window.rect()) if obj is scroll.viewport() \ and event.type() == QEvent.MouseButtonPress: self.dismiss() return True return super().eventFilter(obj, event)
[docs] def keyPressEvent(self, event) -> None: """Any key closes it — that is the whole interaction. :param event: the key event; which key was pressed is not read. """ self.dismiss()
[docs] def mousePressEvent(self, event) -> None: """A click anywhere closes it too. :param event: the mouse press event; its button and position are not read. """ self.dismiss()
def _on_edit(self) -> None: """Close the sheet and open the shortcut editor in its place.""" window = self._window self.dismiss() _open_keymap(window)
[docs] def dismiss(self) -> None: """Close the overlay and let go of the window.""" try: self._window.removeEventFilter(self) except RuntimeError: pass self.close() self.deleteLater()
def _focus_settings_search(window: QMainWindow) -> None: """Put the caret in the current module's settings search box. Ctrl+F on a settings form should mean "find a setting", which is the only thing on that screen anyone searches. Screens without a strip are left alone rather than swallowing the key. A settings column collapsed to the left is opened first (item 471): the caret cannot go into a box nobody can see. """ try: screen = window._stack.currentWidget() except Exception: return bar = getattr(screen, "_settings_search", None) if bar is None: return reveal = getattr(screen, "reveal_settings", None) if callable(reveal): try: reveal() except Exception: # noqa: BLE001 LOG.debug("could not open the settings column", exc_info=True) try: bar._input.setFocus() bar._input.selectAll() except Exception: LOG.debug("could not focus the settings search box", exc_info=True) def _focus_help_search(window: QMainWindow) -> None: """Put the caret in the search box beside the Help menu. Ctrl+F already means "find a setting on THIS module", so the field that searches the whole program needs its own key rather than a second meaning for that one: a key that does two things depending on what is on screen is a key nobody trusts. """ try: from .help_search import focus_field focus_field(window) except Exception: LOG.debug("could not focus the help search box", exc_info=True) def _open_recipes(window: QMainWindow) -> None: """Open the recipe dialog for the module on screen.""" try: from .recipes import _RecipeMenuHandler _RecipeMenuHandler(window).on_triggered() except Exception as e: LOG.debug("recipes not available: %s", e)
[docs] def show_cheat_sheet(parent) -> None: """Show every registered shortcut, grouped by category. An overlay when ``parent`` is a real window, so ``?`` answers and gets out of the way. A modal dialog remains the fallback for a parentless or zero-sized caller, where an overlay would have nothing to cover. :param parent: the window to cover. A :class:`QWidget` with a non-zero size gets the overlay, replacing any overlay it already has; anything else gets a modal dialog parented to it. """ if isinstance(parent, QWidget) and parent.width() > 0 \ and parent.height() > 0: existing = getattr(parent, "_spacr_shortcut_overlay", None) if existing is not None: try: existing.dismiss() except RuntimeError: pass overlay = ShortcutOverlay(parent) parent._spacr_shortcut_overlay = overlay overlay.show() overlay.raise_() overlay.setFocus() return overlay from .widgets.workflow_diagram import DiagramDialog dlg = DiagramDialog(parent) dlg.setWindowTitle("spaCR — Keyboard shortcuts") from .preferences import scaled_px dlg.setMinimumWidth(scaled_px(420)) layout = QVBoxLayout(dlg) by_cat: dict[str, list[ShortcutSpec]] = {} for s in SHORTCUTS: by_cat.setdefault(s.category, []).append(s) for cat, specs in by_cat.items(): from .theme import font_px hdr = QLabel(f"<b>{cat}</b>") hdr.setStyleSheet( "font-family: 'Open Sans', sans-serif;" f"font-weight: 600; font-size: {font_px(12)}px;" "letter-spacing: 1.5px; margin-top: 8px;" ) layout.addWidget(hdr) for s in specs: row = QLabel( f"<code style='padding:2px 6px; " f"background:#1e1e1e; border-radius:3px;'>{s.keys}</code>" f" &nbsp; {s.label}" ) row.setTextFormat(Qt.RichText) layout.addWidget(row) dlg.exec() return None
def _overlay_qss(palette: dict, opacity) -> str: """QSS for the ``?`` overlay, registered through the theme seam.""" from .theme import block_surface, font_px surface = block_surface("surface", palette["theme"], opacity) return f""" QWidget#{OVERLAY_CARD_NAME} {{ background: {surface}; border: 1px solid {palette["accent"]}; border-radius: 12px; }} QScrollArea#{OVERLAY_SCROLL_NAME} {{ background: transparent; border: none; }} QScrollArea#{OVERLAY_SCROLL_NAME} > QWidget > QWidget {{ background: transparent; }} QLabel#ShortcutOverlayTitle {{ font-size: {font_px(18)}px; color: {palette["fg"]}; padding-bottom: 8px; }} QLabel#ShortcutOverlayCategory {{ font-size: {font_px(10)}px; font-weight: 600; letter-spacing: 2px; color: {palette["accent"]}; padding-top: 10px; }} QLabel#ShortcutOverlayKeys {{ font-family: monospace; color: {palette["fg"]}; }} QLabel#ShortcutOverlayLabel {{ color: {palette["fg"]}; }} QLabel#ShortcutOverlayHint {{ color: {palette["fg_dim"]}; font-size: {font_px(11)}px; padding-top: 12px; }} """ try: from .theme import register_widget_qss as _register_widget_qss _register_widget_qss(OVERLAY_NAME, _overlay_qss, replace=True) except Exception: LOG.debug("could not register the shortcut-overlay QSS", exc_info=True)