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