diff --git a/beeref/actions/__init__.py b/beeref/actions/__init__.py
new file mode 100644
index 0000000..86dcf88
--- /dev/null
+++ b/beeref/actions/__init__.py
@@ -0,0 +1,19 @@
+# This file is part of BeeRef.
+#
+# BeeRef is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# BeeRef is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with BeeRef. If not, see .
+
+from .mixin import ActionsMixin
+
+
+__all__ = ['ActionsMixin']
diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py
new file mode 100644
index 0000000..9b2c88f
--- /dev/null
+++ b/beeref/actions/actions.py
@@ -0,0 +1,101 @@
+# This file is part of BeeRef.
+#
+# BeeRef is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# BeeRef is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with BeeRef. If not, see .
+
+actions = [
+ {
+ 'id': 'open',
+ 'text': '&Open',
+ 'shortcuts': ['Ctrl+O'],
+ 'callback': 'on_action_open',
+ },
+ {
+ 'id': 'save',
+ 'text': '&Save',
+ 'shortcuts': ['Ctrl+S'],
+ 'callback': 'on_action_save',
+ },
+ {
+ 'id': 'save_as',
+ 'text': 'Save &As...',
+ 'shortcuts': ['Ctrl+Shift+S'],
+ 'callback': 'on_action_save_as',
+ },
+ {
+ 'id': 'quit',
+ 'text': '&Quit...',
+ 'shortcuts': ['Ctrl+Q'],
+ 'callback': 'on_action_quit',
+ },
+ {
+ 'id': 'insert_images',
+ 'text': '&Insert Images...',
+ 'shortcuts': ['Ctrl+I'],
+ 'callback': 'on_action_insert_images',
+ },
+ {
+ 'id': 'undo',
+ 'text': '&Undo',
+ 'shortcuts': ['Ctrl+Z'],
+ 'callback': 'on_action_undo',
+ 'group': 'active_when_can_undo',
+ 'enabled': False,
+ },
+ {
+ 'id': 'redo',
+ 'text': '&Redo',
+ 'shortcuts': ['Ctrl+Shift+Z'],
+ 'callback': 'on_action_redo',
+ 'group': 'active_when_can_redo',
+ 'enabled': False,
+ },
+ {
+ 'id': 'paste',
+ 'text': '&Paste',
+ 'shortcuts': ['Ctrl+V'],
+ 'callback': 'on_action_paste',
+ },
+ {
+ 'id': 'delete',
+ 'text': '&Delete',
+ 'shortcuts': ['Del'],
+ 'callback': 'on_action_delete_items',
+ 'group': 'active_when_selection',
+ 'enabled': False,
+ },
+ {
+ 'id': 'normalize_height',
+ 'text': '&Height',
+ 'shortcuts': ['Shift+H'],
+ 'callback': 'on_action_normalize_height',
+ 'group': 'active_when_selection',
+ 'enabled': False,
+ },
+ {
+ 'id': 'normalize_width',
+ 'text': '&Width',
+ 'shortcuts': ['Shift+W'],
+ 'callback': 'on_action_normalize_width',
+ 'group': 'active_when_selection',
+ 'enabled': False,
+ },
+ {
+ 'id': 'normalize_size',
+ 'text': '&Size',
+ 'shortcuts': ['Shift+S'],
+ 'callback': 'on_action_normalize_size',
+ 'group': 'active_when_selection',
+ 'enabled': False,
+ },
+]
diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py
new file mode 100644
index 0000000..671ad8b
--- /dev/null
+++ b/beeref/actions/menu_structure.py
@@ -0,0 +1,47 @@
+# This file is part of BeeRef.
+#
+# BeeRef is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# BeeRef is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with BeeRef. If not, see .
+
+MENU_SEPARATOR = 0
+
+menu_structure = [
+ {
+ 'menu': '&File',
+ 'items': [
+ 'open',
+ 'save',
+ 'save_as',
+ MENU_SEPARATOR,
+ 'quit',
+ ],
+ },
+ 'insert_images',
+ {
+ 'menu': '&Edit',
+ 'items': [
+ 'undo',
+ 'redo',
+ 'paste',
+ 'delete',
+ ],
+ },
+ {
+ 'menu': '&Normalize',
+ 'items': [
+ 'normalize_height',
+ 'normalize_width',
+ 'normalize_size',
+ ],
+ },
+]
diff --git a/beeref/actions/mixin.py b/beeref/actions/mixin.py
new file mode 100644
index 0000000..25c2dc8
--- /dev/null
+++ b/beeref/actions/mixin.py
@@ -0,0 +1,62 @@
+# This file is part of BeeRef.
+#
+# BeeRef is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# BeeRef is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with BeeRef. If not, see .
+
+from collections import defaultdict
+
+from PyQt6 import QtGui, QtWidgets
+
+from .actions import actions
+from .menu_structure import menu_structure, MENU_SEPARATOR
+
+
+class ActionsMixin:
+
+ def actiongroup_set_enabled(self, group, value):
+ for action in self.bee_actiongroups[group]:
+ action.setEnabled(value)
+
+ def create_menu_and_actions(self):
+ self._create_actions()
+ menu = QtWidgets.QMenu(self)
+ menu = self._create_menu(
+ self.bee_actions, QtWidgets.QMenu(self), menu_structure)
+ return menu
+
+ def _create_actions(self):
+ self.bee_actions = {}
+ self.bee_actiongroups = defaultdict(list)
+
+ for action in actions:
+ qaction = QtGui.QAction(action['text'], self)
+ if 'shortcuts' in action:
+ qaction.setShortcuts(action['shortcuts'])
+ qaction.triggered.connect(getattr(self, action['callback']))
+ self.addAction(qaction)
+ qaction.setEnabled(action.get('enabled', True))
+ self.bee_actions[action['id']] = qaction
+ if 'group' in action:
+ self.bee_actiongroups[action['group']].append(qaction)
+
+ def _create_menu(self, actions, menu, items):
+ for item in items:
+ if isinstance(item, str):
+ menu.addAction(actions[item])
+ if item == MENU_SEPARATOR:
+ menu.addSeparator()
+ if isinstance(item, dict):
+ submenu = menu.addMenu(item['menu'])
+ self._create_menu(actions, submenu, item['items'])
+
+ return menu
diff --git a/beeref/view.py b/beeref/view.py
index 7195bfc..f85564d 100644
--- a/beeref/view.py
+++ b/beeref/view.py
@@ -18,6 +18,7 @@ import logging
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
+from beeref.actions import ActionsMixin
from beeref import commands
from beeref.config import CommandlineArgs
from beeref import fileio
@@ -30,7 +31,7 @@ commandline_args = CommandlineArgs()
logger = logging.getLogger('BeeRef')
-class BeeGraphicsView(QtWidgets.QGraphicsView):
+class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
def __init__(self, app, parent=None):
super().__init__(parent)
@@ -67,8 +68,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView):
self.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.on_context_menu)
- self.context_menu = QtWidgets.QMenu(self)
- self.build_actions()
+ self.context_menu = self.create_menu_and_actions()
self.welcome_overlay = WelcomeOverlay(self)
@@ -84,124 +84,11 @@ class BeeGraphicsView(QtWidgets.QGraphicsView):
self.welcome_overlay.hide()
self.recalc_scene_rect()
- def build_actions(self):
-
- self.actions_active_when_selection = []
- self.actions_active_when_can_undo = []
- self.actions_active_when_can_redo = []
-
- def add_to_menu(menu, actions):
- for action in actions:
- qaction = QtGui.QAction(action['text'], self)
- if 'shortcuts' in action:
- qaction.setShortcuts(action['shortcuts'])
- qaction.triggered.connect(action['callback'])
- self.addAction(qaction)
- menu.addAction(qaction)
- if 'group' in action:
- action['group'].append(qaction)
- qaction.setEnabled(action.get('enabled', True))
-
- # File menu
- actions = [
- {
- 'text': '&Open',
- 'shortcuts': ['Ctrl+O'],
- 'callback': self.on_action_open,
- },
- {
- 'text': '&Save',
- 'shortcuts': ['Ctrl+S'],
- 'callback': self.on_action_save,
- },
- {
- 'text': 'Save &As...',
- 'shortcuts': ['Ctrl+Shift+S'],
- 'callback': self.on_action_save_as,
- },
- {
- 'text': '&Quit...',
- 'shortcuts': ['Ctrl+Q'],
- 'callback': self.on_action_quit,
- },
- ]
- add_to_menu(self.context_menu.addMenu('&File'), actions)
-
- # Main menu
- actions = [
- {
- 'text': '&Insert Images...',
- 'shortcuts': ['Ctrl+I'],
- 'callback': self.on_action_insert_images,
- },
-
- ]
- add_to_menu(self.context_menu, actions)
-
- # Edit menu
- actions = [
- {
- 'text': '&Undo',
- 'shortcuts': ['Ctrl+Z'],
- 'callback': self.on_action_undo,
- 'group': self.actions_active_when_can_undo,
- 'enabled': False,
- },
- {
- 'text': '&Redo',
- 'shortcuts': ['Ctrl+Shift+Z'],
- 'callback': self.on_action_redo,
- 'group': self.actions_active_when_can_redo,
- 'enabled': False,
- },
- {
- 'text': '&Paste',
- 'shortcuts': ['Ctrl+V'],
- 'callback': self.on_action_paste,
- },
- {
- 'text': '&Delete',
- 'shortcuts': ['Del'],
- 'callback': self.on_action_delete_items,
- 'group': self.actions_active_when_selection,
- 'enabled': False,
- },
- ]
- add_to_menu(self.context_menu.addMenu('&Edit'), actions)
-
- items_menu = self.context_menu.addMenu('&Items')
- actions = [
- {
- 'text': '&Height',
- 'shortcuts': ['Shift+H'],
- 'callback': self.on_action_normalize_height,
- 'group': self.actions_active_when_selection,
- 'enabled': False,
- },
- {
- 'text': '&Width',
- 'shortcuts': ['Shift+W'],
- 'callback': self.on_action_normalize_width,
- 'group': self.actions_active_when_selection,
- 'enabled': False,
- },
- {
- 'text': '&Size',
- 'shortcuts': ['Shift+S'],
- 'callback': self.on_action_normalize_size,
- 'group': self.actions_active_when_selection,
- 'enabled': False,
- },
- ]
- add_to_menu(items_menu.addMenu('&Normalize'), actions)
-
def on_can_redo_changed(self, can_redo):
- for action in self.actions_active_when_can_redo:
- action.setEnabled(can_redo)
+ self.actiongroup_set_enabled('active_when_can_redo', can_redo)
def on_can_undo_changed(self, can_undo):
- for action in self.actions_active_when_can_undo:
- action.setEnabled(can_undo)
+ self.actiongroup_set_enabled('active_when_can_undo', can_undo)
def on_context_menu(self, point):
self.context_menu.exec(self.mapToGlobal(point))
@@ -361,8 +248,8 @@ class BeeGraphicsView(QtWidgets.QGraphicsView):
def on_selection_changed(self):
logger.debug('Currently selected items: %s',
len(self.scene.selectedItems()))
- for action in self.actions_active_when_selection:
- action.setEnabled(self.scene.has_selection())
+ self.actiongroup_set_enabled('active_when_selection',
+ self.scene.has_selection())
self.viewport().repaint()
def recalc_scene_rect(self):
diff --git a/tests/actions/__init__.py b/tests/actions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/actions/test_mixin.py b/tests/actions/test_mixin.py
new file mode 100644
index 0000000..8417fc2
--- /dev/null
+++ b/tests/actions/test_mixin.py
@@ -0,0 +1,124 @@
+from unittest.mock import patch
+
+from PyQt6 import QtWidgets
+
+from beeref.actions import ActionsMixin
+from beeref.actions.menu_structure import MENU_SEPARATOR
+from ..base import BeeTestCase
+
+
+class FooWidget(QtWidgets.QWidget, ActionsMixin):
+ def on_foo(self):
+ pass
+
+
+class ActionsMixinTestCase(BeeTestCase):
+
+ def setUp(self):
+ menu_patcher = patch('beeref.actions.mixin.menu_structure')
+ self.menu_mock = menu_patcher.start()
+ self.addCleanup(menu_patcher.stop)
+ actions_patcher = patch('beeref.actions.mixin.actions')
+ self.actions_mock = actions_patcher.start()
+ self.addCleanup(actions_patcher.stop)
+ self.widget = FooWidget()
+
+ def test_create_actions(self):
+ self.actions_mock.__iter__.return_value = [{
+ 'id': 'foo',
+ 'text': '&Foo',
+ 'shortcuts': ['Ctrl+F'],
+ 'callback': 'on_foo',
+ }]
+
+ with patch('PyQt6.QtGui.QAction.triggered') as trigger_mock:
+ self.widget._create_actions()
+ trigger_mock.connect.assert_called_once_with(self.widget.on_foo)
+ assert len(self.widget.actions()) == 1
+ qaction = self.widget.actions()[0]
+ qaction.text() == '&Foo'
+ qaction.shortcut() == 'Ctrl+F'
+ qaction.isEnabled() is True
+ assert self.widget.bee_actions['foo'] == qaction
+
+ def test_create_actions_enabled_false(self):
+ self.actions_mock.__iter__.return_value = [{
+ 'id': 'foo',
+ 'text': '&Foo',
+ 'callback': 'on_foo',
+ 'enabled': False,
+ }]
+ self.widget._create_actions()
+ qaction = self.widget.actions()[0]
+ qaction.isEnabled() is False
+
+ def test_create_actions_with_group(self):
+ self.actions_mock.__iter__.return_value = [{
+ 'id': 'foo',
+ 'text': '&Foo',
+ 'callback': 'on_foo',
+ 'group': 'bar',
+ }]
+ self.widget._create_actions()
+ qaction = self.widget.actions()[0]
+ len(self.widget.bee_actiongroups) == 1
+ self.widget.bee_actiongroups['bar'] == [qaction]
+
+ def test_create_menu_and_actions_with_actions(self):
+ self.actions_mock.__iter__.return_value = [{
+ 'id': 'foo',
+ 'text': '&Foo',
+ 'callback': 'on_foo',
+ 'group': 'bar',
+ }]
+ self.menu_mock.__iter__.return_value = ['foo']
+ with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock:
+ menu = self.widget.create_menu_and_actions()
+ assert isinstance(menu, QtWidgets.QMenu)
+ add_mock.assert_called_once_with(self.widget.bee_actions['foo'])
+
+ def test_create_menu_and_actions_with_separator(self):
+ self.menu_mock.__iter__.return_value = [MENU_SEPARATOR]
+ with patch('PyQt6.QtWidgets.QMenu.addSeparator') as sep_mock:
+ menu = self.widget.create_menu_and_actions()
+ assert isinstance(menu, QtWidgets.QMenu)
+ sep_mock.assert_called_once_with()
+
+ def test_create_menu_and_actions_with_submenu(self):
+ self.actions_mock.__iter__.return_value = [{
+ 'id': 'foo',
+ 'text': '&Foo',
+ 'callback': 'on_foo',
+ 'group': 'bar',
+ }]
+ self.menu_mock.__iter__.return_value = [
+ {'menu': '&Bar', 'items': ['foo']}]
+ with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock:
+ with patch('PyQt6.QtWidgets.QMenu.addMenu') as addmenu_mock:
+ addmenu_mock.return_value = QtWidgets.QMenu()
+ menu = self.widget.create_menu_and_actions()
+ assert isinstance(menu, QtWidgets.QMenu)
+ addmenu_mock.assert_called_once_with('&Bar')
+ add_mock.assert_called_once_with(
+ self.widget.bee_actions['foo'])
+
+ def test_actiongroup_set_enabled(self):
+ self.actions_mock.__iter__.return_value = [
+ {
+ 'id': 'foo',
+ 'text': '&Foo',
+ 'callback': 'on_foo',
+ 'group': 'g1',
+ },
+ {
+ 'id': 'bar',
+ 'text': '&Bar',
+ 'callback': 'on_foo',
+ 'group': 'g2',
+ },
+ ]
+
+ self.widget._create_actions()
+ self.widget.actiongroup_set_enabled('g1', False)
+ assert self.widget.bee_actions['foo'].isEnabled() is False
+ assert self.widget.bee_actions['bar'].isEnabled() is True
diff --git a/tests/test_view.py b/tests/test_view.py
index e8acaf0..c29d0e1 100644
--- a/tests/test_view.py
+++ b/tests/test_view.py
@@ -2,7 +2,7 @@ import os.path
import tempfile
from unittest.mock import patch
-from PyQt6 import QtGui
+from PyQt6 import QtGui, QtWidgets
from beeref.items import BeePixmapItem
from beeref import fileio
@@ -22,6 +22,13 @@ class BeeGraphicsViewTestCase(BeeTestCase):
def tearDown(self):
del self.view
+ def test_inits_menu(self):
+ view = BeeGraphicsView(self.app)
+ assert isinstance(view.context_menu, QtWidgets.QMenu)
+ assert len(view.actions()) > 0
+ assert view.bee_actions
+ assert view.bee_actiongroups
+
@patch('beeref.view.BeeGraphicsView.open_from_file')
def test_init_without_filename(self, open_file_mock):
self.config_mock.filename = None