diff --git a/src/napari_deeplabcut/_tests/config/test_keybinds.py b/src/napari_deeplabcut/_tests/config/test_keybinds.py index 31350339..72b0c44a 100644 --- a/src/napari_deeplabcut/_tests/config/test_keybinds.py +++ b/src/napari_deeplabcut/_tests/config/test_keybinds.py @@ -36,7 +36,7 @@ def test_shortcuts_registry_points_layer_entries_have_callbacks(): assert spec.keys assert spec.description assert spec.group - assert spec.scope in {"points-layer", "global-points"} + assert spec.scope in {"points-layer", "global-points", "viewer"} if spec.scope == "points-layer": assert spec.get_callback is not None diff --git a/src/napari_deeplabcut/config/keybinds.py b/src/napari_deeplabcut/config/keybinds.py index d9433823..ca75b5c1 100644 --- a/src/napari_deeplabcut/config/keybinds.py +++ b/src/napari_deeplabcut/config/keybinds.py @@ -8,7 +8,13 @@ from enum import Enum, auto import numpy as np +from napari import Viewer +from napari.components._viewer_key_bindings import ( + increment_dims_left, + increment_dims_right, +) from napari.layers import Points +from qtpy.QtCore import QTimer from .settings import TRACKING_SHORTCUTS_ENABLED @@ -19,6 +25,11 @@ class BindingContext: controls: object store: object + viewer: object | None = ( + None + # only needed for viewer-scoped keybinds, + # can be left out for points-layer-scoped keybinds + ) @dataclass(frozen=True) @@ -40,11 +51,87 @@ class ShortcutAction(Enum): PREV_KEYPOINT = auto() JUMP_UNLABELED_FRAME = auto() TOGGLE_EDGE_COLOR = auto() + NEXT_FRAME = auto() + PREV_FRAME = auto() # ---------------------------------------- # Functions with associated keybind callbacks # ---------------------------------------- + +_FRAME_REPEAT_INTERVAL_MS = 60 +_frame_repeat_timers: dict[tuple[int, str], QTimer] = {} + + +def _viewer_from_callback_arg(ctx: BindingContext, obj): + """ + Get the viewer either from context or from the callback argument itself. + Requires a napari action that uses viewer as the callback argument. + """ + if ctx.viewer is not None: + return ctx.viewer + + if isinstance(obj, Viewer): + return obj + + return None + + +def _make_repeating_viewer_callback(ctx: BindingContext, action, repeat_id: str): + """ + Call a napari viewer action once, then continue calling it while the key is held. + + This reuses napari's own increment_dims_* functions, but adds hold-to-repeat + for A/D, which napari otherwise filters as non-navigation autorepeat keys. + """ + + def callback(obj): + viewer = _viewer_from_callback_arg(ctx, obj) + if viewer is None: + return + + timer_key = (id(viewer), repeat_id) + + # Avoid duplicate timers if repeat key-press events sneak through. + if timer_key in _frame_repeat_timers: + return + + # Move once immediately. + action(viewer) + + timer = QTimer() + timer.setInterval(_FRAME_REPEAT_INTERVAL_MS) + timer.timeout.connect(lambda: action(viewer)) + + _frame_repeat_timers[timer_key] = timer + timer.start() + + try: + yield + finally: + timer.stop() + timer.deleteLater() + _frame_repeat_timers.pop(timer_key, None) + + return callback + + +def _prev_frame(ctx: BindingContext): + return _make_repeating_viewer_callback( + ctx, + increment_dims_left, + "prev_frame", + ) + + +def _next_frame(ctx: BindingContext): + return _make_repeating_viewer_callback( + ctx, + increment_dims_right, + "next_frame", + ) + + def _cycle_label_mode(ctx: BindingContext): return ctx.controls.cycle_through_label_modes @@ -86,7 +173,7 @@ def _jump_unlabeled_frame(ctx: BindingContext): when="Only cycles beyond bodypart mode for multi-animal layers", ), ShortcutSpec( - keys=("Down",), + keys=("Down", "S"), action=ShortcutAction.NEXT_KEYPOINT, get_callback=_next_keypoint, description="Select next keypoint", @@ -95,7 +182,7 @@ def _jump_unlabeled_frame(ctx: BindingContext): overwrite=True, ), ShortcutSpec( - keys=("Up",), + keys=("Up", "W"), action=ShortcutAction.PREV_KEYPOINT, get_callback=_prev_keypoint, description="Select previous keypoint", @@ -118,6 +205,24 @@ def _jump_unlabeled_frame(ctx: BindingContext): group="Display", scope="global-points", ), + ShortcutSpec( + keys=("A",), + action=ShortcutAction.PREV_FRAME, + get_callback=_prev_frame, + description="Previous frame (fast on hold)", + group="Navigation", + scope="viewer", + overwrite=True, + ), + ShortcutSpec( + keys=("D",), + action=ShortcutAction.NEXT_FRAME, + get_callback=_next_frame, + description="Next frame (fast on hold)", + group="Navigation", + scope="viewer", + overwrite=True, + ), ) # -------------------------------- @@ -137,12 +242,12 @@ def get_display(self) -> str: return txt -TRACK_FORWARD = TrackingKeybindConfig(key="l", tooltip="Track forward") -TRACK_FORWARD_END = TrackingKeybindConfig(key="k", tooltip="Track forward to end") -TRACK_BACKWARD = TrackingKeybindConfig(key="h", tooltip="Track backward") -TRACK_BACKWARD_END = TrackingKeybindConfig(key="j", tooltip="Track backward to start") -MOVE_FORWARD_FRAME = TrackingKeybindConfig(key="i", tooltip="Move forward one frame") -MOVE_BACKWARD_FRAME = TrackingKeybindConfig(key="u", tooltip="Move backward one frame") +TRACK_FORWARD = TrackingKeybindConfig(key="L", tooltip="Track forward") +TRACK_FORWARD_END = TrackingKeybindConfig(key="K", tooltip="Track forward to end") +TRACK_BACKWARD = TrackingKeybindConfig(key="H", tooltip="Track backward") +TRACK_BACKWARD_END = TrackingKeybindConfig(key="J", tooltip="Track backward to start") +MOVE_FORWARD_FRAME = TrackingKeybindConfig(key="I", tooltip="Move forward one frame") +MOVE_BACKWARD_FRAME = TrackingKeybindConfig(key="U", tooltip="Move backward one frame") TRACKING_SHORTCUTS: tuple[ShortcutSpec, ...] = ( @@ -204,15 +309,34 @@ def _bind_each_key(layer: Points, keys: tuple[str, ...], callback, *, overwrite: layer.bind_key(key, callback, overwrite=overwrite) -def install_points_layer_keybindings(layer: Points, controls, store) -> None: - ctx = BindingContext(controls=controls, store=store) +def install_points_layer_keybindings(layer: Points, controls, store, viewer=None) -> None: + ctx = BindingContext(controls=controls, store=store, viewer=viewer) for spec in SHORTCUTS: - if spec.scope != "points-layer" or spec.get_callback is None: + if spec.get_callback is None: + continue + + if spec.scope == "points-layer": + callback = spec.get_callback(ctx) + _bind_each_key(layer, spec.keys, callback, overwrite=spec.overwrite) + + elif spec.scope == "viewer" and viewer is not None: + callback = spec.get_callback(ctx) + _bind_each_key(layer, spec.keys, callback, overwrite=spec.overwrite) + + +def install_viewer_keybindings(viewer, controls=None, store=None) -> None: + # Still needed so A/D work without a Points layer + ctx = BindingContext(controls=controls, store=store, viewer=viewer) + + for spec in SHORTCUTS: + if spec.scope != "viewer" or spec.get_callback is None: continue callback = spec.get_callback(ctx) - _bind_each_key(layer, spec.keys, callback, overwrite=spec.overwrite) + + for key in spec.keys: + viewer.bind_key(key, callback, overwrite=spec.overwrite) # ------- Global keybinds that apply to all points layers, e.g. toggling edge color ------- diff --git a/src/napari_deeplabcut/config/settings.py b/src/napari_deeplabcut/config/settings.py index 10eec836..f7ba468a 100644 --- a/src/napari_deeplabcut/config/settings.py +++ b/src/napari_deeplabcut/config/settings.py @@ -1,6 +1,6 @@ import os -from qtpy.QtCore import QSettings +from qtpy.QtCore import QSettings, Qt from .utils import _get_int_env @@ -11,6 +11,11 @@ # UI settings _OVERWRITE_CONFIRM_ENABLED_KEY = "napari_deeplabcut/overwrite/confirm_enabled" AUTO_OPEN_KEYPOINT_CONTROLS_KEY = "napari_deeplabcut/ui/auto_open_keypoint_controls" +## Shortcuts +### By default, dropdown menus in Qt will jump to any item that starts with the key pressed. +### so that pressing "s" will jump to e.g. "snout" if it is the first item starting with "s". +### Since this conflicts with the "w" and "s" shortcuts for seeking frames, we ignore these keys for dropdown menus. +IGNORED_FOR_DROPDOWN_MENU_SEEKING_KEYS = {Qt.Key_W, Qt.Key_S} ## Trajectory plot _MIN_TRAJ_PLOT_WINDOW = 1 _DEFAULT_TRAJ_PLOT_WINDOW = 50 diff --git a/src/napari_deeplabcut/core/layer_lifecycle/manager.py b/src/napari_deeplabcut/core/layer_lifecycle/manager.py index a48d5f2f..b9db7949 100644 --- a/src/napari_deeplabcut/core/layer_lifecycle/manager.py +++ b/src/napari_deeplabcut/core/layer_lifecycle/manager.py @@ -13,7 +13,7 @@ from napari.utils.history import update_save_history from qtpy.QtCore import QObject, Signal -from ...config.keybinds import install_points_layer_keybindings +from ...config.keybinds import install_points_layer_keybindings, install_viewer_keybindings from ...config.models import AnnotationKind, DLCHeaderModel, ImageMetadata, PointsMetadata from ...core import keypoints from ...core.io import is_video @@ -111,6 +111,7 @@ def __init__(self, viewer: napari.Viewer, *, parent: QObject | None = None) -> N self._project_path: str | None = None self._attached = False + self.viewer_keybinds_installed = False self._init_owned_timers() @@ -1189,7 +1190,10 @@ def attach_points_layer_runtime( pass if not resources.keybindings_installed: - install_points_layer_keybindings(layer, controls, store) + install_points_layer_keybindings(layer, controls, store, self.viewer) + if not self.viewer_keybinds_installed: + install_viewer_keybindings(self.viewer) + self.viewer_keybinds_installed = True resources.keybindings_installed = True return resources diff --git a/src/napari_deeplabcut/ui/labels_and_dropdown.py b/src/napari_deeplabcut/ui/labels_and_dropdown.py index d2e38cf1..ab6633b7 100644 --- a/src/napari_deeplabcut/ui/labels_and_dropdown.py +++ b/src/napari_deeplabcut/ui/labels_and_dropdown.py @@ -12,7 +12,7 @@ from collections import defaultdict from collections.abc import Sequence -from qtpy.QtCore import Qt, Signal +from qtpy.QtCore import QEvent, Qt, Signal from qtpy.QtGui import QCursor from qtpy.QtWidgets import ( QComboBox, @@ -24,6 +24,7 @@ QWidget, ) +from napari_deeplabcut.config.settings import IGNORED_FOR_DROPDOWN_MENU_SEEKING_KEYS from napari_deeplabcut.core import keypoints logger = logging.getLogger(__name__) @@ -34,6 +35,32 @@ def __init__(self, labels: Sequence[str], parent: QWidget | None = None): super().__init__(parent) self.update_items(labels) + # Targets the opened dropdown list (when clicking) + self.view().installEventFilter(self) + + def _do_skip_key(self, event) -> bool: + if event.type() != QEvent.KeyPress: + return False + + if event.modifiers() & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier): + return False + + return event.key() in IGNORED_FOR_DROPDOWN_MENU_SEEKING_KEYS + + def keyPressEvent(self, event): # combo box is focused (clicked on last) + if self._do_skip_key(event): + event.ignore() + return + super().keyPressEvent(event) + + def eventFilter(self, watched, event): # opened dropdown list + # disabled for now as menu behaves correctly when focused + # if watched is self.view() and self._do_skip_key(event): + # event.ignore() + # return True + + return super().eventFilter(watched, event) + def update_to(self, text: str): index = self.findText(text) if index >= 0: