Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/napari_deeplabcut/_tests/config/test_keybinds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 136 additions & 12 deletions src/napari_deeplabcut/config/keybinds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Comment thread
C-Achard marked this conversation as resolved.
from napari.layers import Points
from qtpy.QtCore import QTimer

from .settings import TRACKING_SHORTCUTS_ENABLED

Expand All @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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,
),
)

# --------------------------------
Expand All @@ -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, ...] = (
Expand Down Expand Up @@ -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)

Comment thread
C-Achard marked this conversation as resolved.

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 -------
Expand Down
7 changes: 6 additions & 1 deletion src/napari_deeplabcut/config/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os

from qtpy.QtCore import QSettings
from qtpy.QtCore import QSettings, Qt

from .utils import _get_int_env

Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/napari_deeplabcut/core/layer_lifecycle/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Comment thread
C-Achard marked this conversation as resolved.

Comment thread
C-Achard marked this conversation as resolved.
return resources
Expand Down
29 changes: 28 additions & 1 deletion src/napari_deeplabcut/ui/labels_and_dropdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)
Expand All @@ -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:
Expand Down
Loading