Skip to content

Commit

Permalink
Merge branch 'main' into use-app-model-kb-internally
Browse files Browse the repository at this point in the history
  • Loading branch information
nclack committed Oct 20, 2022
2 parents 7b60f46 + ec69e69 commit 6adf0c5
Show file tree
Hide file tree
Showing 19 changed files with 448 additions and 114 deletions.
8 changes: 5 additions & 3 deletions napari/_app_model/_app.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,37 @@
from __future__ import annotations

from functools import lru_cache
from itertools import chain
from typing import Dict

from app_model import Application

from ._submenus import SUBMENUS
from .actions._layer_actions import LAYER_ACTIONS
from .actions._view_actions import VIEW_ACTIONS
from .injection._processors import PROCESSORS
from .injection._providers import PROVIDERS

APP_NAME = 'napari'


class NapariApplication(Application):
def __init__(self) -> None:
def __init__(self, app_name=APP_NAME) -> None:
# raise_synchronous_exceptions means that commands triggered via
# ``execute_command`` will immediately raise exceptions. Normally,
# `execute_command` returns a Future object (which by definition does not
# raise exceptions until requested). While we could use that future to raise
# exceptions with `.result()`, for now, raising immediately should
# prevent any unexpected silent errors. We can turn it off later if we
# adopt asynchronous command execution.
super().__init__(APP_NAME, raise_synchronous_exceptions=True)
super().__init__(app_name, raise_synchronous_exceptions=True)

self.injection_store.namespace = _napari_names # type: ignore [assignment]
self.injection_store.register(
providers=PROVIDERS, processors=PROCESSORS
)

for action in LAYER_ACTIONS:
for action in chain(LAYER_ACTIONS, VIEW_ACTIONS):
self.register_action(action)

self.menus.append_menu_items(SUBMENUS)
Expand Down
8 changes: 8 additions & 0 deletions napari/_app_model/_submenus.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,12 @@
order=None,
),
),
(
MenuId.MENUBAR_VIEW,
SubmenuItem(submenu=MenuId.VIEW_AXES, title=trans._('Axes')),
),
(
MenuId.MENUBAR_VIEW,
SubmenuItem(submenu=MenuId.VIEW_SCALEBAR, title=trans._('Scale Bar')),
),
]
2 changes: 1 addition & 1 deletion napari/_app_model/_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
def test_app():
"""just make sure our app model is registering menus and commands"""
app = get_app()
assert app.name == 'napari'
assert app.name == 'test_app'
assert list(app.menus)
assert list(app.commands)
# assert list(app.keybindings) # don't have any yet
Expand Down
12 changes: 12 additions & 0 deletions napari/_app_model/_tests/test_misc_callbacks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""For testing one off action callbacks"""
from napari._app_model.actions._view_actions import _tooltip_visibility_toggle
from napari.settings import get_settings


def test_tooltip_visibility_toggle():
settings = get_settings().appearance
assert settings.layer_tooltip_visibility is False
_tooltip_visibility_toggle()
assert settings.layer_tooltip_visibility is True
_tooltip_visibility_toggle()
assert settings.layer_tooltip_visibility is False
23 changes: 23 additions & 0 deletions napari/_app_model/_tests/test_viewer_toggler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from napari import Viewer
from napari._app_model._app import get_app
from napari._app_model.actions._toggle_action import ViewerToggleAction
from napari.components import ViewerModel


def test_viewer_toggler():
viewer = ViewerModel()
action = ViewerToggleAction(
id='some.command.id',
title='Toggle Axis Visibility',
viewer_attribute='axes',
sub_attribute='visible',
)
app = get_app()
app.register_action(action)

with app.injection_store.register(providers={Viewer: lambda: viewer}):
assert viewer.axes.visible is False
app.commands.execute_command('some.command.id')
assert viewer.axes.visible is True
app.commands.execute_command('some.command.id')
assert viewer.axes.visible is False
62 changes: 62 additions & 0 deletions napari/_app_model/actions/_toggle_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from app_model.types import Action, ToggleRule

if TYPE_CHECKING:
from ...viewer import Viewer


class ViewerToggleAction(Action):
"""Action subclass that toggles a boolean viewer (sub)attribute on trigger.
Parameters
----------
id : str
The command id of the action.
title : str
The title of the action. Prefer capital case.
viewer_attribute : str
The attribute of the viewer to toggle. (e.g. 'axes')
sub_attribute : str
The attribute of the viewer attribute to toggle. (e.g. 'visible')
**kwargs
Additional keyword arguments to pass to the Action constructor.
Examples
--------
>>> action = ViewerToggleAction(
... id='some.command.id',
... title='Toggle Axis Visibility',
... viewer_attribute='axes',
... sub_attribute='visible',
... )
"""

def __init__(
self,
*,
id: str,
title: str,
viewer_attribute: str,
sub_attribute: str,
**kwargs,
):
def get_current(viewer: Viewer):
"""return the current value of the viewer attribute"""
attr = getattr(viewer, viewer_attribute)
return getattr(attr, sub_attribute)

def toggle(viewer: Viewer):
"""toggle the viewer attribute"""
attr = getattr(viewer, viewer_attribute)
setattr(attr, sub_attribute, not getattr(attr, sub_attribute))

super().__init__(
id=id,
title=title,
toggled=ToggleRule(get_current=get_current),
callback=toggle,
**kwargs,
)
63 changes: 63 additions & 0 deletions napari/_app_model/actions/_view_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Actions related to the view that require Qt.
View actions that do not require Qt should go in
napari/_app_model/actions/_view_actions.py.
"""

from typing import List

from app_model.types import Action, ToggleRule

from ...settings import get_settings
from ..constants import CommandId, MenuId
from ._toggle_action import ViewerToggleAction

VIEW_ACTIONS: List[Action] = []

for cmd, viewer_attr, sub_attr in (
(CommandId.TOGGLE_VIEWER_AXES, 'axes', 'visible'),
(CommandId.TOGGLE_VIEWER_AXES_COLORED, 'axes', 'colored'),
(CommandId.TOGGLE_VIEWER_AXES_LABELS, 'axes', 'labels'),
(CommandId.TOGGLE_VIEWER_AXES_DASHED, 'axes', 'dashed'),
(CommandId.TOGGLE_VIEWER_AXES_ARROWS, 'axes', 'arrows'),
(CommandId.TOGGLE_VIEWER_SCALE_BAR, 'scale_bar', 'visible'),
(CommandId.TOGGLE_VIEWER_SCALE_BAR_COLORED, 'scale_bar', 'colored'),
(CommandId.TOGGLE_VIEWER_SCALE_BAR_TICKS, 'scale_bar', 'ticks'),
):
menu = MenuId.VIEW_AXES if viewer_attr == 'axes' else MenuId.VIEW_SCALEBAR
VIEW_ACTIONS.append(
ViewerToggleAction(
id=cmd,
title=cmd.title,
viewer_attribute=viewer_attr,
sub_attribute=sub_attr,
menus=[{'id': menu}],
)
)


def _tooltip_visibility_toggle():
settings = get_settings().appearance
settings.layer_tooltip_visibility = not settings.layer_tooltip_visibility


# this can be generalised for all boolean settings, similar to `ViewerToggleAction`
def _get_current_tooltip_visibility():
return get_settings().appearance.layer_tooltip_visibility


VIEW_ACTIONS.extend(
[
# TODO: this could be made into a toggle setting Action subclass
# using a similar pattern to the above ViewerToggleAction classes
Action(
id=CommandId.TOGGLE_LAYER_TOOLTIPS,
title=CommandId.TOGGLE_LAYER_TOOLTIPS.title,
menus=[
{'id': MenuId.MENUBAR_VIEW, 'group': '1_render', 'order': 10}
],
callback=_tooltip_visibility_toggle,
toggled=ToggleRule(get_current=_get_current_tooltip_visibility),
),
]
)
33 changes: 32 additions & 1 deletion napari/_app_model/constants/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,26 @@
from ...utils.translations import trans


# fmt: off
class CommandId(str, Enum):
"""Id representing a napari command."""

TOGGLE_FULLSCREEN = 'napari:window:view:toggle_fullscreen'
TOGGLE_MENUBAR = 'napari:window:view:toggle_menubar'
TOGGLE_PLAY = 'napari:window:view:toggle_play'
TOGGLE_OCTREE_CHUNK_OUTLINES = 'napari:window:view:toggle_octree_chunk_outlines'
TOGGLE_LAYER_TOOLTIPS = 'napari:window:view:toggle_layer_tooltips'
TOGGLE_ACTIVITY_DOCK = 'napari:window:view:toggle_activity_dock'

TOGGLE_VIEWER_AXES = 'napari:window:view:toggle_viewer_axes'
TOGGLE_VIEWER_AXES_COLORED = 'napari:window:view:toggle_viewer_axes_colored'
TOGGLE_VIEWER_AXES_LABELS = 'napari:window:view:toggle_viewer_axes_labels'
TOGGLE_VIEWER_AXES_DASHED = 'napari:window:view:toggle_viewer_axesdashed'
TOGGLE_VIEWER_AXES_ARROWS = 'napari:window:view:toggle_viewer_axes_arrows'
TOGGLE_VIEWER_SCALE_BAR = 'napari:window:view:toggle_viewer_scale_bar'
TOGGLE_VIEWER_SCALE_BAR_COLORED = 'napari:window:view:toggle_viewer_scale_bar_colored'
TOGGLE_VIEWER_SCALE_BAR_TICKS = 'napari:window:view:toggle_viewer_scale_bar_ticks'

LAYER_DUPLICATE = 'napari:layer:duplicate'
LAYER_SPLIT_STACK = 'napari:layer:split_stack'
LAYER_SPLIT_RGB = 'napari:layer:split_rgb'
Expand Down Expand Up @@ -62,8 +79,22 @@ class _i(NamedTuple):
description: Optional[str] = None


# fmt: off
_COMMAND_INFO = {
CommandId.TOGGLE_FULLSCREEN: _i(trans._('Toggle Full Screen'),),
CommandId.TOGGLE_MENUBAR: _i(trans._('Toggle Menubar Visibility'),),
CommandId.TOGGLE_PLAY: _i(trans._('Toggle Play'),),
CommandId.TOGGLE_OCTREE_CHUNK_OUTLINES: _i(trans._('Toggle Chunk Outlines'),),
CommandId.TOGGLE_LAYER_TOOLTIPS: _i(trans._('Toggle Layer Tooltips'),),
CommandId.TOGGLE_ACTIVITY_DOCK: _i(trans._('Toggle Activity Dock'),),
CommandId.TOGGLE_VIEWER_AXES: _i(trans._('Axes Visible')),
CommandId.TOGGLE_VIEWER_AXES_COLORED: _i(trans._('Axes Colored')),
CommandId.TOGGLE_VIEWER_AXES_LABELS: _i(trans._('Axes Labels')),
CommandId.TOGGLE_VIEWER_AXES_DASHED: _i(trans._('Axes Dashed')),
CommandId.TOGGLE_VIEWER_AXES_ARROWS: _i(trans._('Axes Arrows')),
CommandId.TOGGLE_VIEWER_SCALE_BAR: _i(trans._('Scale Bar Visible')),
CommandId.TOGGLE_VIEWER_SCALE_BAR_COLORED: _i(trans._('Scale Bar Colored')),
CommandId.TOGGLE_VIEWER_SCALE_BAR_TICKS: _i(trans._('Scale Bar Ticks')),

CommandId.LAYER_DUPLICATE: _i(trans._('Duplicate Layer'),),
CommandId.LAYER_SPLIT_STACK: _i(trans._('Split Stack'),),
CommandId.LAYER_SPLIT_RGB: _i(trans._('Split RGB'),),
Expand Down
5 changes: 5 additions & 0 deletions napari/_app_model/constants/_menus.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
class MenuId(str, Enum):
"""Id representing a menu somewhere in napari."""

MENUBAR_VIEW = 'napari/view'
VIEW_AXES = 'napari/view/axes'
VIEW_SCALEBAR = 'napari/view/scalebar'

LAYERLIST_CONTEXT = 'napari/layers/context'
LAYERS_CONVERT_DTYPE = 'napari/layers/convert_dtype'
LAYERS_PROJECT = 'napari/layers/project'
Expand All @@ -28,6 +32,7 @@ def __str__(self) -> str:
# XXX: the structure/usage pattern of this class may change in the future
class MenuGroup:
NAVIGATION = 'navigation' # always the first group in any menu
RENDER = '1_render'

class LAYERLIST_CONTEXT:
CONVERSION = '1_conversion'
Expand Down
19 changes: 17 additions & 2 deletions napari/_qt/_qapp_model/_menus.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
from typing import TYPE_CHECKING, Optional

from app_model.backends.qt import QModelMenu

if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget


def build_qmodel_menu(menu_id: str) -> QModelMenu:
def build_qmodel_menu(
menu_id: str,
title: Optional[str] = None,
parent: Optional['QWidget'] = None,
) -> QModelMenu:
"""Build a QModelMenu from the napari app model
Parameters
----------
menu_id : str
ID of a menu registered with napari._app_model.get_app().menus
title : Optional[str]
Title of the menu
parent : Optional[QWidget]
Parent of the menu
Returns
-------
Expand All @@ -16,4 +29,6 @@ def build_qmodel_menu(menu_id: str) -> QModelMenu:
"""
from ..._app_model import get_app

return QModelMenu(menu_id=menu_id, app=get_app())
return QModelMenu(
menu_id=menu_id, app=get_app(), title=title, parent=parent
)
18 changes: 15 additions & 3 deletions napari/_qt/_qapp_model/_tests/test_qapp_model_menus.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
from unittest.mock import MagicMock

import pytest

from napari import viewer
from napari._app_model import constants, get_app
from napari._qt._qapp_model import build_qmodel_menu
from napari._qt._qapp_model.qactions import init_qactions
from napari._qt.qt_main_window import Window


@pytest.mark.parametrize('menu_id', list(constants.MenuId))
def test_build_qmodel_menu(qtbot, menu_id):
"""Test that we can build qmenus for all registered menu IDs"""
app = get_app()
menu = build_qmodel_menu(menu_id)
qtbot.addWidget(menu)
assert len(menu.actions()) >= len(app.menus.get_menu(menu_id))

mock = MagicMock()
with app.injection_store.register(
providers={viewer.Viewer: lambda: mock, Window: lambda: mock}
):
init_qactions()
menu = build_qmodel_menu(menu_id)
qtbot.addWidget(menu)
# `>=` because separator bars count as actions
assert len(menu.actions()) >= len(app.menus.get_menu(menu_id))

0 comments on commit 6adf0c5

Please sign in to comment.