Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use app-model keybindings internally #5103

Merged
merged 17 commits into from
Oct 27, 2022
25 changes: 24 additions & 1 deletion napari/_qt/dialogs/preferences_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,30 @@ def _get_page_dict(self, field: 'ModelField') -> Tuple[dict, dict]:
"""Provides the schema, set of values for each setting, and the
properties for each setting."""
ftype = cast('BaseModel', field.type_)
schema = json.loads(ftype.schema_json())

# TODO make custom shortcuts dialog to properly capture new
# functionality once we switch to app-model's keybinding system
# then we can remove the below code used for autogeneration
if field.name == 'shortcuts':
# hardcode workaround because pydantic's schema generation
# does not allow you to specify custom JSON serialization
schema = {
"title": "ShortcutsSettings",
"type": "object",
"properties": {
"shortcuts": {
"title": "shortcuts",
"description": "Set keyboard shortcuts for actions.",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {"type": "string"},
},
}
},
}
else:
schema = json.loads(ftype.schema_json())

# find enums:
for name, subfield in ftype.__fields__.items():
Expand Down
12 changes: 7 additions & 5 deletions napari/layers/image/_image_key_bindings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from app_model.types import KeyCode

import napari

from ...layers.utils.interactivity_utils import (
Expand All @@ -15,19 +17,19 @@ def register_image_action(description: str, repeatable: bool = False):
return register_layer_action(Image, description, repeatable)


@Image.bind_key('z')
@Image.bind_key(KeyCode.KeyZ)
@register_image_action(trans._('Orient plane normal along z-axis'))
def orient_plane_normal_along_z(layer: Image):
orient_plane_normal_around_cursor(layer, plane_normal=(1, 0, 0))


@Image.bind_key('y')
@Image.bind_key(KeyCode.KeyY)
@register_image_action(trans._('orient plane normal along y-axis'))
def orient_plane_normal_along_y(layer: Image):
orient_plane_normal_around_cursor(layer, plane_normal=(0, 1, 0))


@Image.bind_key('x')
@Image.bind_key(KeyCode.KeyX)
@register_image_action(trans._('orient plane normal along x-axis'))
def orient_plane_normal_along_x(layer: Image):
orient_plane_normal_around_cursor(layer, plane_normal=(0, 0, 1))
Expand All @@ -43,7 +45,7 @@ def orient_plane_normal_along_view_direction(layer: Image):
)


@Image.bind_key('o')
@Image.bind_key(KeyCode.KeyO)
def synchronise_plane_normal_with_view_direction(layer: Image):
viewer = napari.viewer.current_viewer()
if viewer.dims.ndisplay != 3:
Expand All @@ -65,7 +67,7 @@ def sync_plane_normal_with_view_direction(event=None):
)


@Image.bind_key('Space')
@Image.bind_key(KeyCode.Space)
def hold_to_pan_zoom(layer):
"""Hold to pan and zoom in the viewer."""
if layer._mode != Mode.PAN_ZOOM:
Expand Down
7 changes: 4 additions & 3 deletions napari/layers/labels/_labels_key_bindings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import numpy as np
from app_model.types import KeyCode, KeyMod

from ...layers.utils.layer_utils import (
register_layer_action,
Expand All @@ -12,7 +13,7 @@
MAX_BRUSH_SIZE = 40


@Labels.bind_key('Space')
@Labels.bind_key(KeyCode.Space)
def hold_to_pan_zoom(layer: Labels):
"""Hold to pan and zoom in the viewer."""
if layer._mode != Mode.PAN_ZOOM:
Expand Down Expand Up @@ -126,13 +127,13 @@ def toggle_preserve_labels(layer: Labels):
layer.preserve_labels = not layer.preserve_labels


@Labels.bind_key('Control-Z')
@Labels.bind_key(KeyMod.CtrlCmd | KeyCode.KeyZ)
def undo(layer: Labels):
"""Undo the last paint or fill action since the view slice has changed."""
layer.undo()


@Labels.bind_key('Control-Shift-Z')
@Labels.bind_key(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ)
def redo(layer: Labels):
"""Redo any previously undone actions."""
layer.redo()
8 changes: 5 additions & 3 deletions napari/layers/points/_points_key_bindings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from app_model.types import KeyCode, KeyMod

from napari.utils.notifications import show_info

from ...layers.utils.layer_utils import (
Expand All @@ -19,7 +21,7 @@ def register_points_mode_action(description):
return register_layer_attr_action(Points, description, 'mode')


@Points.bind_key('Space')
@Points.bind_key(KeyCode.Space)
def hold_to_pan_zoom(layer: Points):
"""Hold to pan and zoom in the viewer."""
if layer._mode != Mode.PAN_ZOOM:
Expand Down Expand Up @@ -58,13 +60,13 @@ def activate_points_pan_zoom_mode(layer: Points):
]


@Points.bind_key('Control-C')
@Points.bind_key(KeyMod.CtrlCmd | KeyCode.KeyC)
def copy(layer: Points):
"""Copy any selected points."""
layer._copy_data()


@Points.bind_key('Control-V')
@Points.bind_key(KeyMod.CtrlCmd | KeyCode.KeyV)
def paste(layer: Points):
"""Paste any copied points."""
layer._paste_data()
Expand Down
5 changes: 3 additions & 2 deletions napari/layers/shapes/_shapes_key_bindings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import numpy as np
from app_model.types import KeyCode

from ...layers.utils.layer_utils import (
register_layer_action,
Expand All @@ -10,7 +11,7 @@
from .shapes import Shapes


@Shapes.bind_key('Space')
@Shapes.bind_key(KeyCode.Space)
def hold_to_pan_zoom(layer: Shapes):
"""Hold to pan and zoom in the viewer."""
if layer._mode != Mode.PAN_ZOOM:
Expand All @@ -27,7 +28,7 @@ def hold_to_pan_zoom(layer: Shapes):
layer._set_highlight()


@Shapes.bind_key('Shift')
@Shapes.bind_key(KeyCode.Shift)
def hold_to_lock_aspect_ratio(layer: Shapes):
"""Hold to lock aspect ratio when resizing a shape."""
# on key press
Expand Down
9 changes: 7 additions & 2 deletions napari/settings/_shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
from pydantic import Field, validator

from ..utils.events.evented_model import EventedModel
from ..utils.key_bindings import KeyBinding, coerce_keybinding
from ..utils.shortcuts import default_shortcuts
from ..utils.translations import trans


class ShortcutsSettings(EventedModel):
# FIXME user with modified shortcut will not see new shortcut
shortcuts: Dict[str, List[str]] = Field(
shortcuts: Dict[str, List[KeyBinding]] = Field(
default_shortcuts,
title=trans._("shortcuts"),
description=trans._(
Expand All @@ -26,4 +27,8 @@ def shortcut_validate(cls, v):
for name, value in default_shortcuts.items():
if name not in v:
v[name] = value
return v

return {
name: [coerce_keybinding(kb) for kb in value]
for name, value in v.items()
}
4 changes: 4 additions & 0 deletions napari/settings/_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from enum import Enum
from typing import TYPE_CHECKING, Type

from app_model.types import KeyBinding
from pydantic import BaseModel
from yaml import SafeDumper, dump_all

Expand Down Expand Up @@ -43,6 +44,9 @@ class YamlDumper(SafeDumper):
YamlDumper.add_representer(
Version, lambda dumper, data: dumper.represent_str(str(data))
)
YamlDumper.add_representer(
KeyBinding, lambda dumper, data: dumper.represent_str(str(data))
)


class PydanticYamlMixin(BaseModel):
Expand Down
9 changes: 4 additions & 5 deletions napari/utils/_tests/test_interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
@pytest.mark.parametrize(
'shortcut,reason',
[
('Ctrl-A', 'Ctrl instead of Control'),
('Ctrl+A', '+ instead of -'),
('Ctrl-AA', 'AA make no sens'),
('BB', 'BB make no sens'),
('Atl-A', 'Alt misspelled'),
('Ctrl-AA', 'AA makes no sense'),
nclack marked this conversation as resolved.
Show resolved Hide resolved
('BB', 'BB makes no sense'),
],
)
def test_shortcut_invalid(shortcut, reason):
Expand All @@ -29,4 +28,4 @@ def test_minus_shortcut():

def test_shortcut_qt():

assert Shortcut('Control-A').qt == 'Control+A'
assert Shortcut('Control-A').qt == 'Ctrl+A'
66 changes: 15 additions & 51 deletions napari/utils/_tests/test_key_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from unittest.mock import patch

import pytest
from app_model.types import KeyCode, KeyMod

from .. import key_bindings
from ..key_bindings import (
Expand All @@ -13,56 +14,9 @@
_bind_user_key,
_get_user_keymap,
bind_key,
components_to_key_combo,
normalize_key_combo,
parse_key_combo,
)


def test_parse_key_combo():
assert parse_key_combo('X') == ('X', set())
assert parse_key_combo('Control-X') == ('X', {'Control'})
assert parse_key_combo('Control-Alt-Shift-Meta-X') == (
'X',
{'Control', 'Alt', 'Shift', 'Meta'},
)


def test_components_to_key_combo():
assert components_to_key_combo('X', []) == 'X'
assert components_to_key_combo('X', ['Control']) == 'Control-X'

# test consuming
assert components_to_key_combo('X', []) == 'X'
assert components_to_key_combo('X', ['Shift']) == 'Shift-X'
assert components_to_key_combo('x', []) == 'X'

assert components_to_key_combo('@', ['Shift']) == '@'
assert (
components_to_key_combo('2', ['Control', 'Shift']) == 'Control-Shift-2'
)

# test ordering
assert (
components_to_key_combo('2', ['Control', 'Alt', 'Shift', 'Meta'])
== 'Control-Alt-Shift-Meta-2'
)
assert (
components_to_key_combo('2', ['Alt', 'Shift', 'Control', 'Meta'])
== 'Control-Alt-Shift-Meta-2'
)


def test_normalize_key_combo():
assert normalize_key_combo('x') == 'X'
assert normalize_key_combo('Control-X') == 'Control-X'
assert normalize_key_combo('Meta-Alt-X') == 'Alt-Meta-X'
assert (
normalize_key_combo('Shift-Alt-Control-Meta-2')
== 'Control-Alt-Shift-Meta-2'
)


def test_bind_key():
kb = {}

Expand Down Expand Up @@ -96,9 +50,16 @@ def spam():
bind_key(kb, ..., ...)
assert kb == {'A': ..., ...: ...}

# typecheck
with pytest.raises(TypeError):
bind_key(kb, 'B', 'not a callable')

# app-model representation
kb = {}
bind_key(kb, KeyMod.Shift | KeyCode.KeyA, ...)
(key,) = kb.keys()
assert key == 'Shift-A'


def test_bind_key_decorator():
kb = {}
Expand Down Expand Up @@ -126,9 +87,9 @@ class Bar(Foo):
assert Bar.class_keymap is not Foo.class_keymap

class Baz(KeymapProvider):
class_keymap = {'A', ...}
class_keymap = {'A': ...}

assert Baz.class_keymap == {'A', ...}
assert Baz.class_keymap == {'A': ...}


def test_bind_keymap():
Expand Down Expand Up @@ -337,15 +298,18 @@ def add_then_subtract(x):

class Baz(KeymapProvider):
aliiiens = 0
class_keymap = {'A': make_42, 'Control-Shift-B': add_then_subtract}
class_keymap = {
KeyCode.Shift: make_42,
kne42 marked this conversation as resolved.
Show resolved Hide resolved
'Control-Shift-B': add_then_subtract,
}

baz = Baz()
handler = KeymapHandler()
handler.keymap_providers = [baz]

# one-statement generator function
assert not hasattr(baz, 'SPAM')
handler.press_key('A')
handler.press_key('Shift')
assert baz.SPAM == 42

# two-statement generator function
Expand Down
6 changes: 5 additions & 1 deletion napari/utils/events/evented_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any, Callable, ClassVar, Dict, Set, Union

import numpy as np
from app_model.types import KeyBinding
from pydantic import BaseModel, PrivateAttr, main, utils

from ...utils.misc import pick_equality_operator
Expand All @@ -14,7 +15,10 @@
# encoders for non-napari specific field types. To declare a custom encoder
# for a napari type, add a `_json_encode` method to the class itself.
# it will be added to the model json_encoders in :func:`EventedMetaclass.__new__`
_BASE_JSON_ENCODERS = {np.ndarray: lambda arr: arr.tolist()}
_BASE_JSON_ENCODERS = {
np.ndarray: lambda arr: arr.tolist(),
KeyBinding: lambda v: str(v),
}


@contextmanager
Expand Down