Refactor creation of menu and actions

This commit is contained in:
Rebecca Breu 2021-04-11 11:36:49 +02:00
parent 4e31ec8bac
commit 0d4b5da84b
8 changed files with 368 additions and 121 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
from .mixin import ActionsMixin
__all__ = ['ActionsMixin']

101
beeref/actions/actions.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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,
},
]

View file

@ -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 <https://www.gnu.org/licenses/>.
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',
],
},
]

62
beeref/actions/mixin.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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

View file

@ -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):

View file

124
tests/actions/test_mixin.py Normal file
View file

@ -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

View file

@ -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