diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 7d8d9c3..a034a30 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -9,7 +9,7 @@ jobs:
strategy:
matrix:
python-version: ['3.9', '3.11']
- pyqt-version: ['6.4.0', '6.6.1']
+ pyqt-version: ['6.5.0', '6.6.1']
steps:
- uses: actions/checkout@v4
- name: Set up Python
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 745aeec..94595a8 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,10 @@ Added
* The opacity of images can be changed (Images -> Change Opacity).
* Images can be set to display as grayscale (Images -> Grayscale).
* The scene can now also be exported as SVG
+* Keyboard shortcuts can now be edited from within BeeRef (Settings ->
+ Keyboard Shortcuts). The KeyboardSettings.ini file will now only
+ store values which are changed from the default, since it's no longer
+ needed as a reference.
Fixed
diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py
index b430962..e2884b8 100644
--- a/beeref/actions/actions.py
+++ b/beeref/actions/actions.py
@@ -13,308 +13,409 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see
This shortcut is already used for:
' + f'{txt}
' + 'Do you want to remove the other shortcut' + ' to save this one?
') + reply = QtWidgets.QMessageBox.question( + self, 'Save Shortcut?', msg) + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + self.remove_from_other = action + else: + self.setKeySequence(self.old_value) + + +class KeyboardShortcutsDelegate(QtWidgets.QStyledItemDelegate): + + def createEditor(self, parent, option, index): + return KeyboardShortcutsEditor(parent, index) + + def setModelData(self, editor, model, index): + model.setData( + index, + editor.keySequence(), + QtCore.Qt.ItemDataRole.EditRole, + remove_from_other=editor.remove_from_other) + + +class KeyboardShortcutsModel(QtCore.QAbstractTableModel): + """An entry in the keyboard shortcuts table.""" + + HEADER = ('Action', '✎', 'Shortcut', 'Alternative') + + def __init__(self): + super().__init__() + self.settings = KeyboardSettings() + + def rowCount(self, parent): + return len(actions) + + def columnCount(self, parent): + return len(self.HEADER) + + def data(self, index, role): + if role in (QtCore.Qt.ItemDataRole.DisplayRole, + QtCore.Qt.ItemDataRole.EditRole): + action = actions[index.row()] + txt = ': '.join(action.menu_path + [action['text']]) + if index.column() == 0: + return txt.replace('&', '').removesuffix('...') + if index.column() == 1 and action.shortcuts_changed(): + return '✎' + if index.column() > 1: + return action.get_qkeysequence(index.column() - 2) + + if role == QtCore.Qt.ItemDataRole.ToolTipRole: + action = actions[index.row()] + changed = action.shortcuts_changed() + if changed and index.column() == 1: + return 'Changed from default' + if changed and index.column() > 1: + default = action.get_default_shortcut(index.column() - 2) + default = default or '-' + return f'Default: {default}' + + def setData(self, index, value, role, remove_from_other=None): + action = actions[index.row()] + shortcuts = action.get_shortcuts() + [None, None] + shortcuts[index.column() - 2] = value.toString() + shortcuts = list(filter(bool, shortcuts)) + if len(shortcuts) != len(set(shortcuts)): + # We got the same shortcut twice + shortcuts = set(shortcuts) + action.set_shortcuts(shortcuts) + # Whole row might be affected, so excpliclity emit dataChanged + self.dataChanged.emit(self.index(index.row(), 1), + self.index(index.row(), 3)) + + if remove_from_other: + # This shortcut has conflicts with another action and the + # user chose to remove the other shortcut + shortcuts = remove_from_other.get_shortcuts() + shortcuts.remove(value.toString()) + remove_from_other.set_shortcuts(shortcuts) + row = list(actions.keys()).index(remove_from_other['id']) + self.dataChanged.emit(self.index(row, 1), + self.index(row, 3)) + + return True + + def headerData(self, section, orientation, role): + if (role == QtCore.Qt.ItemDataRole.DisplayRole + and orientation == QtCore.Qt.Orientation.Horizontal): + return self.HEADER[section] + + def flags(self, index): + base = (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + if index.column() <= 1: + return base + else: + return (base | QtCore.Qt.ItemFlag.ItemIsEditable) + + +class KeyboardShortcutsProxy(QtCore.QSortFilterProxyModel): + + def __init__(self): + super().__init__() + self.setSourceModel(KeyboardShortcutsModel()) + self.setFilterCaseSensitivity( + QtCore.Qt.CaseSensitivity.CaseInsensitive) + + def data(self, index, role): + if (role == QtCore.Qt.ItemDataRole.BackgroundRole + and index.row() % 2): + return QtGui.QColor(*constants.COLORS['Table:AlternativeRow']) + else: + return super().data(index, role) + + def setData(self, index, value, role, remove_from_other=None): + result = self.sourceModel().setData( + self.mapToSource(index), + value, + role, + remove_from_other=remove_from_other) + return result + + +class KeyboardShortcutsView(QtWidgets.QTableView): + + def __init__(self, parent): + super().__init__(parent) + self.setMinimumSize(QtCore.QSize(400, 200)) + self.setItemDelegate(KeyboardShortcutsDelegate()) + self.setShowGrid(False) + self.setModel(KeyboardShortcutsProxy()) + self.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.Stretch) + self.horizontalHeader().setSectionResizeMode( + 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + self.setSelectionMode( + QtWidgets.QHeaderView.SelectionMode.SingleSelection) + settings_events.restore_defaults.connect(self.on_restore_defaults) + + def on_restore_defaults(self): + self.viewport().update() + + +class KeyboardSettingsDialog(QtWidgets.QDialog): + def __init__(self, parent): + super().__init__(parent) + self.setWindowTitle('Keyboard Shortcuts') + tabs = QtWidgets.QTabWidget() + + # Keyboard shortcuts + keyboard = QtWidgets.QWidget(parent) + kb_layout = QtWidgets.QVBoxLayout() + keyboard.setLayout(kb_layout) + table = KeyboardShortcutsView(keyboard) + search_input = QtWidgets.QLineEdit() + search_input.setPlaceholderText('Search...') + search_input.textChanged.connect( + lambda value: table.model().setFilterFixedString(value)) + kb_layout.addWidget(search_input) + kb_layout.addWidget(table) + tabs.addTab(keyboard, '&Keyboard Shortcuts') + + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + layout.addWidget(tabs) + + # Bottom row of buttons + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Close) + buttons.rejected.connect(self.reject) + reset_btn = QtWidgets.QPushButton('&Restore Defaults') + reset_btn.setAutoDefault(False) + reset_btn.clicked.connect(self.on_restore_defaults) + buttons.addButton(reset_btn, + QtWidgets.QDialogButtonBox.ButtonRole.ActionRole) + + layout.addWidget(buttons) + self.show() + + def on_restore_defaults(self, *args, **kwargs): + reply = QtWidgets.QMessageBox.question( + self, + 'Restore defaults?', + 'Do you want to restore all settings to their default values?') + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + KeyboardSettings().restore_defaults() diff --git a/setup.py b/setup.py index dfaa026..4471eaf 100644 --- a/setup.py +++ b/setup.py @@ -9,8 +9,8 @@ setup( license='LICENSE', description='A simple reference image viewer', install_requires=[ - 'pyQt6>=6.4.0,<=6.6.1', - 'pyQt6-Qt6>=6.4.0,<=6.6.1', + 'pyQt6>=6.5.0,<=6.6.1', + 'pyQt6-Qt6>=6.5.0,<=6.6.1', 'rectangle-packer>=2.0.1,<=2.0.2', 'exif>=1.3.5,<=1.6.0', ], diff --git a/tests/actions/test_actions.py b/tests/actions/test_actions.py new file mode 100644 index 0000000..bf0d8ea --- /dev/null +++ b/tests/actions/test_actions.py @@ -0,0 +1,159 @@ +from unittest.mock import patch + +from PyQt6 import QtGui + +from beeref.actions.actions import Action, ActionList + + +def test_action_equals_true(): + action1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+R']}) + action2 = Action({'id': 'foo', 'text': 'Bar', 'shortcuts': ['Ctrl+F']}) + assert action1 == action2 + + +def test_action_equals_false(): + action1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+R']}) + action2 = Action({'id': 'Bar', 'text': 'Foo', 'shortcuts': ['Ctrl+R']}) + assert not action1 == action2 + + +def test_action_on_restore_defaults(kbsettings, view): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+R']}) + action.qaction = QtGui.QAction('foo', view) + action.on_restore_defaults() + assert action.qaction.shortcuts() == ['Ctrl+R'] + + +def test_action_on_restore_defaults_when_no_defaults(kbsettings, view): + action = Action({'id': 'foo'}) + action.qaction = QtGui.QAction('foo', view) + action.qaction.setShortcuts(['Ctrl+R']) + action.on_restore_defaults() + assert action.qaction.shortcuts() == [] + + +def test_action_get_overwritten_shortcuts(kbsettings): + kbsettings.set_shortcuts('Actions', 'foo', ['Alt+O']) + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + assert action.get_shortcuts() == ['Alt+O'] + + +def test_action_get_shortcuts_gets_default(kbsettings): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + assert action.get_shortcuts() == ['Ctrl+F'] + + +def test_action_set_shortcuts_when_no_qaction(kbsettings): + action = Action({'id': 'foo'}) + action.qaction = None + action.set_shortcuts(['Ctrl+F']) + assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F'] + + +def test_action_set_shortcuts_when_qaction(kbsettings, view): + action = Action({'id': 'foo'}) + action.qaction = QtGui.QAction('foo', view) + action.set_shortcuts(['Ctrl+F']) + assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F'] + assert action.qaction.shortcuts() == ['Ctrl+F'] + + +def test_action_get_qkeysequence_first(kbsettings): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + assert action.get_qkeysequence(0) == QtGui.QKeySequence('Ctrl+F') + + +def test_action_get_qkeysequence_second(kbsettings): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F', 'Ctrl+B']}) + assert action.get_qkeysequence(1) == QtGui.QKeySequence('Ctrl+B') + + +def test_action_get_qkeysequence_first_when_not_set(kbsettings): + action = Action({'id': 'foo'}) + assert action.get_qkeysequence(0) == QtGui.QKeySequence() + + +def test_action_get_qkeysequence_second_when_not_set(kbsettings): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + assert action.get_qkeysequence(1) == QtGui.QKeySequence() + + +def test_action_shortcuts_changed_when_not_changed(kbsettings): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + assert action.shortcuts_changed() is False + + +def test_action_shortcuts_changed_when_changed(kbsettings): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + kbsettings.set_shortcuts('Actions', 'foo', ['Ctrl+B']) + assert action.shortcuts_changed() is True + + +def test_action_shortcuts_changed_when_empty(kbsettings): + action = Action({'id': 'foo'}) + assert action.shortcuts_changed() is False + + +def test_action_get_default_shortcut_first(): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + assert action.get_default_shortcut(0) == 'Ctrl+F' + + +def test_action_get_default_shortcut_second(): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F', 'Ctrl+B']}) + assert action.get_default_shortcut(1) == 'Ctrl+B' + + +def test_action_get_default_shortcut_first_when_none_set(): + action = Action({'id': 'foo'}) + assert action.get_default_shortcut(0) is None + + +def test_action_get_default_shortcut_second_when_none_set(): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + assert action.get_default_shortcut(1) is None + + +@patch('beeref.actions.actions.menu_structure', + [{'menu': 'Foo', 'items': ['bar', 'baz']}]) +def test_action_menu_path(): + action = Action({'id': 'baz'}) + assert action.menu_path == ['Foo'] + + +@patch('beeref.actions.actions.menu_structure', + [{'menu': 'Foo', 'items': [{'menu': 'Bar', 'items': ['baz']}]}]) +def test_action_menu_path_with_submenus(): + action = Action({'id': 'baz'}) + assert action.menu_path == ['Foo', 'Bar'] + + +@patch('beeref.actions.actions.menu_structure', + [{'menu': 'Foo', 'items': '_build_recent_files'}]) +def test_action_menu_path_recent_files(): + action = Action({'id': 'baz', 'menu_id': '_build_recent_files'}) + assert action.menu_path == ['Foo'] + + +@patch('beeref.actions.actions.menu_structure', + [{'menu': 'Foo', 'items': [ + {'menu': 'Bar', 'items': '_build_recent_files'}]}]) +def test_action_menu_path_recent_files_in_submenu(): + action = Action({'id': 'baz', 'menu_id': '_build_recent_files'}) + assert action.menu_path == ['Foo', 'Bar'] + + +def test_actionlist_inits_dict(): + action1 = Action({'id': 'foo'}) + action2 = Action({'id': 'bar'}) + actionlist = ActionList([action1, action2]) + actionlist['foo'] == action1 + actionlist['bar'] == action2 + + +def test_actionlist_acts_as_list(): + action1 = Action({'id': 'foo'}) + action2 = Action({'id': 'bar'}) + actionlist = ActionList([action1, action2]) + actionlist[0] == action1 + actionlist[1] == action2 diff --git a/tests/actions/test_mixin.py b/tests/actions/test_mixin.py index ec56a60..50b5e54 100644 --- a/tests/actions/test_mixin.py +++ b/tests/actions/test_mixin.py @@ -4,6 +4,7 @@ from unittest.mock import patch, MagicMock, call from PyQt6 import QtWidgets from beeref.actions import ActionsMixin +from beeref.actions.actions import Action, ActionList from beeref.actions.menu_structure import MENU_SEPARATOR @@ -24,21 +25,19 @@ class FooWidget(QtWidgets.QWidget, ActionsMixin): @patch('PyQt6.QtGui.QAction.triggered') @patch('PyQt6.QtGui.QAction.toggled') -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -@patch('beeref.actions.mixin.KeyboardSettings.get_shortcuts') -def test_create_actions( - kb_mock, actions_mock, menu_mock, toggle_mock, trigger_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': ['foo']}]) +@patch('beeref.actions.mixin.actions', + ActionList([Action({ + 'id': 'foo', + 'text': '&Foo', + 'shortcuts': ['Ctrl+F'], + 'callback': 'on_foo', + })])) +@patch('beeref.config.KeyboardSettings.get_shortcuts') +def test_create_actions(kb_mock, toggle_mock, trigger_mock, qapp): kb_mock.side_effect = lambda group, key, default: default widget = FooWidget() - actions_mock.__iter__.return_value = [{ - 'id': 'foo', - 'text': '&Foo', - 'shortcuts': ['Ctrl+F'], - 'callback': 'on_foo', - }] - - menu_mock.__iter__.return_value = ['foo'] widget.build_menu_and_actions() trigger_mock.connect.assert_called_once_with(widget.on_foo) toggle_mock.connect.assert_not_called() @@ -48,44 +47,43 @@ def test_create_actions( assert qaction.text() == '&Foo' assert qaction.shortcut() == 'Ctrl+F' assert qaction.isEnabled() is True - assert widget.bee_actions['foo'] == qaction + from beeref.actions.mixin import actions + assert actions['foo'].qaction == qaction kb_mock.assert_called_once_with('Actions', 'foo', ['Ctrl+F']) -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -def test_create_actions_with_shortcut_from_settings( - actions_mock, menu_mock, qapp, kbsettings): - kbsettings.set_shortcuts('Actions', 'foo', ['Alt+O']) - widget = FooWidget() - actions_mock.__iter__.return_value = [{ - 'id': 'foo', - 'text': '&Foo', - 'shortcuts': ['Ctrl+F'], - 'callback': 'on_foo', - }] - - menu_mock.__iter__.return_value = ['foo'] - widget.build_menu_and_actions() - qaction = widget.actions()[0] - assert qaction.shortcut() == 'Alt+O' +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': ['foo']}]) +def test_create_actions_with_shortcut_from_settings(qapp, kbsettings): + with patch('beeref.actions.mixin.actions', + ActionList([Action({ + 'id': 'foo', + 'text': '&Foo', + 'shortcuts': ['Ctrl+F'], + 'callback': 'on_foo'})])): + # Create Action inside the test function so that its + # kbsettings get created after the kbsettings fixture changes + # the file path + kbsettings.set_shortcuts('Actions', 'foo', ['Alt+O']) + widget = FooWidget() + widget.build_menu_and_actions() + qaction = widget.actions()[0] + assert qaction.shortcuts() == ['Alt+O'] @patch('PyQt6.QtGui.QAction.triggered') @patch('PyQt6.QtGui.QAction.toggled') -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -def test_create_actions_checkable( - actions_mock, menu_mock, toggle_mock, trigger_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': ['foo']}]) +@patch('beeref.actions.mixin.actions', + ActionList([Action({ + 'id': 'foo', + 'text': '&Foo', + 'checkable': True, + 'callback': 'on_foo', + })])) +def test_create_actions_checkable(toggle_mock, trigger_mock, qapp): widget = FooWidget() - actions_mock.__iter__.return_value = [{ - 'id': 'foo', - 'text': '&Foo', - 'checkable': True, - 'callback': 'on_foo', - }] - - menu_mock.__iter__.return_value = ['foo'] widget.build_menu_and_actions() trigger_mock.connect.assert_not_called() toggle_mock.connect.assert_called_once_with(widget.on_foo) @@ -95,25 +93,23 @@ def test_create_actions_checkable( assert qaction.text() == '&Foo' assert qaction.isEnabled() is True assert qaction.isChecked() is False - assert widget.bee_actions['foo'] == qaction @patch('PyQt6.QtGui.QAction.triggered') @patch('PyQt6.QtGui.QAction.toggled') -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': ['foo']}]) +@patch('beeref.actions.mixin.actions', + ActionList([Action({ + 'id': 'foo', + 'text': '&Foo', + 'checkable': True, + 'checked': True, + 'callback': 'on_foo', + })])) def test_create_actions_checkable_checked_true( - actions_mock, menu_mock, toggle_mock, trigger_mock, qapp): + toggle_mock, trigger_mock, qapp): widget = FooWidget() - actions_mock.__iter__.return_value = [{ - 'id': 'foo', - 'text': '&Foo', - 'checkable': True, - 'checked': True, - 'callback': 'on_foo', - }] - - menu_mock.__iter__.return_value = ['foo'] widget.build_menu_and_actions() trigger_mock.connect.assert_not_called() toggle_mock.connect.assert_called_once_with(widget.on_foo) @@ -123,27 +119,24 @@ def test_create_actions_checkable_checked_true( assert qaction.text() == '&Foo' assert qaction.isEnabled() is True assert qaction.isChecked() is True - assert widget.bee_actions['foo'] == qaction @patch.object(FooWidget, 'on_foo') @patch.object(FooWidget, 'settings') @patch('PyQt6.QtGui.QAction.toggled') -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': ['foo']}]) +@patch('beeref.actions.mixin.actions', + ActionList([Action({ + 'id': 'foo', + 'text': '&Foo', + 'checkable': True, + 'settings': 'foo/bar', + 'callback': 'on_foo', + })])) def test_create_actions_checkable_with_settings( - actions_mock, menu_mock, toggle_mock, settings_mock, callback_mock, - qapp): + toggle_mock, settings_mock, callback_mock, qapp): widget = FooWidget() - actions_mock.__iter__.return_value = [{ - 'id': 'foo', - 'text': '&Foo', - 'checkable': True, - 'callback': 'on_foo', - 'settings': 'foo/bar', - }] - - menu_mock.__iter__.return_value = ['foo'] settings_mock.value.return_value = True widget.build_menu_and_actions() settings_mock.value.assert_called_once_with( @@ -154,152 +147,141 @@ def test_create_actions_checkable_with_settings( callback_mock.assert_called_once_with(True) -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -def test_create_actions_with_group(actions_mock, menu_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': ['foo']}]) +@patch('beeref.actions.mixin.actions', + ActionList([Action({ + 'id': 'foo', + 'text': '&Foo', + 'callback': 'on_foo', + 'group': 'bar', + })])) +def test_create_actions_with_group(qapp): widget = FooWidget() - actions_mock.__iter__.return_value = [{ - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'group': 'bar', - }] - menu_mock.__iter__.return_value = ['foo'] widget.build_menu_and_actions() assert len(widget.actions()) == 1 qaction = widget.actions()[0] assert widget.bee_actiongroups['bar'] == [qaction] -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -def test_build_menu_and_actions_with_actions(actions_mock, menu_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': ['foo']}]) +@patch('beeref.actions.mixin.actions', + ActionList([Action({ + 'id': 'foo', + 'text': '&Foo', + 'callback': 'on_foo', + })])) +def test_build_menu_and_actions_with_actions(qapp): widget = FooWidget() - actions_mock.__iter__.return_value = [{ - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'group': 'bar', - }] - menu_mock.__iter__.return_value = ['foo'] with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock: widget.build_menu_and_actions() assert isinstance(widget.context_menu, QtWidgets.QMenu) - add_mock.assert_called_once_with(widget.bee_actions['foo']) + from beeref.actions.mixin import actions + add_mock.assert_called_once_with(actions['foo'].qaction) -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -def test_build_menu_and_actions_with_separator(actions_mock, menu_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': [MENU_SEPARATOR]}]) +@patch('beeref.actions.mixin.actions', ActionList([])) +def test_build_menu_and_actions_with_separator(qapp): widget = FooWidget() - menu_mock.__iter__.return_value = [MENU_SEPARATOR] with patch('PyQt6.QtWidgets.QMenu.addSeparator') as sep_mock: widget.build_menu_and_actions() assert isinstance(widget.context_menu, QtWidgets.QMenu) sep_mock.assert_called_once_with() -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -def test_build_menu_and_actions_with_submenu(actions_mock, menu_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': [{'menu': 'Bar', 'items': ['foo']}]}]) +@patch('beeref.actions.mixin.actions', + ActionList([Action({ + 'id': 'foo', + 'text': '&Foo', + 'callback': 'on_foo', + })])) +def test_build_menu_and_actions_with_submenu(qapp): widget = FooWidget() - actions_mock.__iter__.return_value = [{ - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'group': 'bar', - }] - 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() widget.build_menu_and_actions() assert isinstance(widget.context_menu, QtWidgets.QMenu) - addmenu_mock.assert_called_once_with('&Bar') - add_mock.assert_called_once_with(widget.bee_actions['foo']) + addmenu_mock.assert_called_with('Bar') + from beeref.actions.mixin import actions + add_mock.assert_called_once_with(actions['foo'].qaction) -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -def test_actiongroup_set_enabled(actions_mock, menu_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': ['foo']}]) +@patch('beeref.actions.mixin.actions', + ActionList([ + Action({ + 'id': 'foo', + 'text': '&Foo', + 'callback': 'on_foo', + 'group': 'g1', + }), + Action({ + 'id': 'bar', + 'text': '&Bar', + 'callback': 'on_foo', + 'group': 'g2', + }), + ])) +def test_actiongroup_set_enabled(qapp): widget = FooWidget() - actions_mock.__iter__.return_value = [ - { - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'group': 'g1', - }, - { - 'id': 'bar', - 'text': '&Bar', - 'callback': 'on_foo', - 'group': 'g2', - }, - ] - - menu_mock.__iter__.return_value = ['foo'] widget.build_menu_and_actions() widget.actiongroup_set_enabled('g1', True) - assert widget.bee_actions['foo'].isEnabled() is True - assert widget.bee_actions['bar'].isEnabled() is False + from beeref.actions.mixin import actions + assert actions['foo'].qaction.isEnabled() is True + assert actions['bar'].qaction.isEnabled() is False -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -def test_build_menu_and_actions_disables_actiongroups( - actions_mock, menu_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': ['foo']}]) +@patch('beeref.actions.mixin.actions', + ActionList([Action({ + 'id': 'foo', + 'text': '&Foo', + 'callback': 'on_foo', + 'group': 'active_when_selection', + })])) +def test_build_menu_and_actions_disables_actiongroups(qapp): widget = FooWidget() widget.scene.has_selection.return_value = False - actions_mock.__iter__.return_value = [ - { - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'group': 'active_when_selection', - }, - ] - - menu_mock.__iter__.return_value = ['foo'] widget.build_menu_and_actions() qaction = widget.actions()[0] assert qaction.isEnabled() is False @patch('PyQt6.QtGui.QAction.triggered') -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -@patch('beeref.actions.mixin.KeyboardSettings.get_shortcuts') -def test_create_recent_files_more_files_than_shortcuts( - kb_mock, actions_mock, menu_mock, triggered_mock, qapp): +@patch('beeref.config.KeyboardSettings.get_shortcuts') +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': '_build_recent_files'}]) +@patch('beeref.actions.mixin.actions', ActionList([])) +def test_create_recent_files_more_than_10_files( + kb_mock, triggered_mock, qapp): kb_mock.side_effect = lambda group, key, default: default widget = FooWidget() widget.settings.get_recent_files.return_value = [ os.path.abspath(f'{i}.bee') for i in range(15)] - menu_mock.__iter__.return_value = [{ - 'menu': 'Open &Recent', - 'items': '_build_recent_files', - }] widget.build_menu_and_actions() triggered_mock.connect.assert_called() - assert len(widget.actions()) == 15 + assert len(widget.actions()) == 10 + + from beeref.actions.mixin import actions qaction1 = widget.actions()[0] assert qaction1.text() == '0.bee' assert qaction1.shortcut() == 'Ctrl+1' assert qaction1.isEnabled() is True - assert widget.bee_actions['recent_files_0'] == qaction1 + assert actions['recent_files_0'].qaction == qaction1 qaction10 = widget.actions()[9] assert qaction10.text() == '9.bee' assert qaction10.shortcut() == 'Ctrl+0' assert qaction10.isEnabled() is True - assert widget.bee_actions['recent_files_9'] == qaction10 - qaction15 = widget.actions()[-1] - assert qaction15.text() == '14.bee' - assert qaction15.shortcut() == '' - assert qaction15.isEnabled() is True - assert widget.bee_actions['recent_files_14'] == qaction15 + assert actions['recent_files_9'].qaction == qaction10 assert kb_mock.call_count == 10 kb_mock.assert_has_calls( @@ -309,73 +291,62 @@ def test_create_recent_files_more_files_than_shortcuts( @patch('PyQt6.QtGui.QAction.triggered') -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -@patch('beeref.actions.mixin.KeyboardSettings.get_shortcuts') -def test_create_recent_files_fewer_files_than_shortcuts( - kb_mock, actions_mock, menu_mock, triggered_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': '_build_recent_files'}]) +@patch('beeref.actions.mixin.actions', ActionList([])) +@patch('beeref.config.KeyboardSettings.get_shortcuts') +def test_create_recent_files_fewer_files_than_10_files( + kb_mock, triggered_mock, qapp): kb_mock.side_effect = lambda group, key, default: default widget = FooWidget() widget.settings.get_recent_files.return_value = [ os.path.abspath(f'{i}.bee') for i in range(5)] - menu_mock.__iter__.return_value = [{ - 'menu': 'Open &Recent', - 'items': '_build_recent_files', - }] widget.build_menu_and_actions() triggered_mock.connect.assert_called() assert len(widget.actions()) == 5 + + from beeref.actions.mixin import actions qaction1 = widget.actions()[0] assert qaction1.text() == '0.bee' assert qaction1.shortcut() == 'Ctrl+1' assert qaction1.isEnabled() is True - assert widget.bee_actions['recent_files_0'] == qaction1 + assert actions['recent_files_0'].qaction == qaction1 qaction5 = widget.actions()[4] assert qaction5.text() == '4.bee' assert qaction5.shortcut() == 'Ctrl+5' assert qaction5.isEnabled() is True - assert widget.bee_actions['recent_files_4'] == qaction5 + assert actions['recent_files_4'].qaction == qaction5 + assert actions['recent_files_5'].qaction is None - assert kb_mock.call_count == 10 + assert kb_mock.call_count == 5 kb_mock.assert_has_calls( [call('Actions', 'recent_files_0', ['Ctrl+1']), - call('Actions', 'recent_files_9', ['Ctrl+0'])], + call('Actions', 'recent_files_4', ['Ctrl+5'])], any_order=True) -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -@patch('beeref.actions.mixin.KeyboardSettings.get_shortcuts') -def test_create_recent_files_when_no_files( - kb_mock, actions_mock, menu_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': '_build_recent_files'}]) +@patch('beeref.actions.mixin.actions', ActionList([])) +@patch('beeref.config.KeyboardSettings.get_shortcuts') +def test_create_recent_files_when_no_files(kb_mock, qapp): kb_mock.side_effect = lambda group, key, default: default widget = FooWidget() widget.settings.get_recent_files.return_value = [] - menu_mock.__iter__.return_value = [{ - 'menu': 'Open &Recent', - 'items': '_build_recent_files', - }] widget.build_menu_and_actions() assert len(widget.actions()) == 0 - assert kb_mock.call_count == 10 - kb_mock.assert_has_calls( - [call('Actions', 'recent_files_0', ['Ctrl+1']), - call('Actions', 'recent_files_9', ['Ctrl+0'])], - any_order=True) + kb_mock.assert_not_called() @patch('PyQt6.QtGui.QAction.triggered') -@patch('beeref.actions.mixin.menu_structure') -@patch('beeref.actions.mixin.actions') -def test_update_recent_files(actions_mock, menu_mock, triggered_mock, qapp): +@patch('beeref.actions.mixin.menu_structure', + [{'menu': 'Foo', 'items': '_build_recent_files'}]) +@patch('beeref.actions.mixin.actions', ActionList([])) +def test_update_recent_files(triggered_mock, qapp): widget = FooWidget() widget.settings.get_recent_files.return_value = [ os.path.abspath('foo.bee')] - menu_mock.__iter__.return_value = [{ - 'menu': 'Open &Recent', - 'items': '_build_recent_files', - }] widget.build_menu_and_actions() triggered_mock.connect.reset_mock() diff --git a/tests/conftest.py b/tests/conftest.py index cb19bf0..29413eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,9 +17,13 @@ def pytest_configure(config): import logging.config logging.config.dictConfig = MagicMock - # Disable creation of KeyboardSettings.ini to speed tests up - from beeref.config import KeyboardSettings - KeyboardSettings.save_unknown_shortcuts = False + +@pytest.fixture(autouse=True) +def reset_beeref_actions(): + from beeref.actions.actions import actions + for key in list(actions.keys()): + if key.startswith('recent_files_'): + actions.pop(key) @pytest.fixture(autouse=True) diff --git a/tests/test_config.py b/tests/test_config.py index c836fcd..f4d27d7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -59,13 +59,13 @@ def test_settings_value_or_default_gets_default_when_cast_error(settings): assert settings.valueOrDefault('Items/arrange_gap') == 0 -def test_restore_defaults_restores(settings): +def test_settings_restore_defaults_restores(settings): settings.setValue('Items/image_storage_format', 'png') settings.restore_defaults() assert settings.contains('Items/image_storage_format') is False -def test_restore_defaults_leaves_other_settings(settings): +def test_settings_restore_defaults_leaves_other_settings(settings): settings.setValue('foo/bar', 'baz') settings.restore_defaults() assert settings.contains('foo/bar') is True @@ -122,34 +122,34 @@ def test_keyboardsettings_set_shortcuts_multiple(kbsettings): def test_keyboardsettings_get_shortcuts_existing(kbsettings): kbsettings.set_shortcuts('Actions', 'bar', ['Ctrl+R']) - with patch.object(kbsettings, 'set_shortcuts') as set_mock: - with patch.object(kbsettings, 'save_unknown_shortcuts', True): - shortcuts = kbsettings.get_shortcuts('Actions', 'bar', ['Ctrl+B']) - assert shortcuts == ['Ctrl+R'] - set_mock.assert_not_called() + shortcuts = kbsettings.get_shortcuts('Actions', 'bar', ['Ctrl+B']) + assert shortcuts == ['Ctrl+R'] def test_keyboardsettings_get_shortcuts_default(kbsettings): - with patch.object(kbsettings, 'set_shortcuts') as set_mock: - with patch.object(kbsettings, 'save_unknown_shortcuts', True): - shortcuts = kbsettings.get_shortcuts('Actions', 'bar', ['Ctrl+B']) - assert shortcuts == ['Ctrl+B'] - set_mock.assert_called_once_with('Actions', 'bar', ['Ctrl+B']) + shortcuts = kbsettings.get_shortcuts('Actions', 'bar', ['Ctrl+B']) + assert shortcuts == ['Ctrl+B'] -def test_keyboardsettings_get_shortcuts_default_doesnt_override_empty( - kbsettings): - kbsettings.set_shortcuts('Actions', 'bar', []) - with patch.object(kbsettings, 'set_shortcuts') as set_mock: - with patch.object(kbsettings, 'save_unknown_shortcuts', True): - shortcuts = kbsettings.get_shortcuts('Actions', 'bar', ['Ctrl+B']) - assert shortcuts == [] - set_mock.assert_not_called() +@patch('beeref.config.KeyboardSettings.setValue') +@patch('beeref.config.KeyboardSettings.remove') +def test_keyboardsettings_set_shortcuts_other_than_default_saves( + remove_mock, set_mock, kbsettings): + kbsettings.set_shortcuts('Actions', 'bar', ['Ctrl+R'], ['Ctrl+Z']) + set_mock.assert_called_once_with('Actions/bar', 'Ctrl+R') + remove_mock.assert_not_called() -def test_keyboardsettings_get_shortcuts_not_set_no_defaults(kbsettings): - with patch('beeref.config.KeyboardSettings.set_shortcuts') as set_mock: - with patch.object(kbsettings, 'save_unknown_shortcuts', True): - shortcuts = kbsettings.get_shortcuts('Actions', 'baz') - assert shortcuts == [] - set_mock.assert_called_once_with('Actions', 'baz', []) +@patch('beeref.config.KeyboardSettings.setValue') +@patch('beeref.config.KeyboardSettings.remove') +def test_keyboardsettings_set_shortcuts_with_than_default_doesnt_save( + remove_mock, set_mock, kbsettings): + kbsettings.set_shortcuts('Actions', 'bar', ['Ctrl+R'], ['Ctrl+R']) + set_mock.assert_not_called() + remove_mock.assert_called_once_with('Actions/bar') + + +def test_keyboardsettings_restore_defaults_restores(kbsettings): + kbsettings.setValue('Actions/bar', 'Ctrl+R') + kbsettings.restore_defaults() + assert kbsettings.contains('Actions/bar') is False diff --git a/tests/test_view.py b/tests/test_view.py index b258cd1..da8c887 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -11,12 +11,12 @@ from beeref.items import BeePixmapItem, BeeTextItem from beeref.view import BeeGraphicsView -def test_inits_menu(view, qapp): +def test_inits_menu(qapp): parent = QtWidgets.QMainWindow() view = BeeGraphicsView(qapp, parent) assert isinstance(view.context_menu, QtWidgets.QMenu) assert len(view.actions()) > 0 - assert view.bee_actions + assert view.actions() assert view.bee_actiongroups @@ -31,7 +31,7 @@ def test_init_without_filename(open_file_mock, qapp, commandline_args): @patch('beeref.view.BeeGraphicsView.open_from_file') -def test_init_with_filename(open_file_mock, view, qapp, commandline_args): +def test_init_with_filename(open_file_mock, qapp, commandline_args): commandline_args.filename = 'test.bee' parent = QtWidgets.QMainWindow() view = BeeGraphicsView(qapp, parent) @@ -374,6 +374,12 @@ def test_on_action_settings(show_mock, view): show_mock.assert_called_once() +@patch('beeref.widgets.settings.KeyboardSettingsDialog.show') +def test_on_action_keyboard_settings(show_mock, view): + view.on_action_keyboard_settings() + show_mock.assert_called_once() + + @patch('beeref.widgets.HelpDialog.show') def test_on_action_help(show_mock, view): view.on_action_help() diff --git a/tests/widgets/test_settings.py b/tests/widgets/test_settings.py index 2b604f9..b593581 100644 --- a/tests/widgets/test_settings.py +++ b/tests/widgets/test_settings.py @@ -1,9 +1,16 @@ -from unittest.mock import patch +from unittest.mock import patch, MagicMock -from PyQt6 import QtWidgets +from PyQt6 import QtWidgets, QtCore, QtGui + +from beeref.actions.actions import Action, ActionList from beeref.widgets.settings import ( ArrangeGapWidget, ImageStorageFormatWidget, + KeyboardSettingsDialog, + KeyboardShortcutsDelegate, + KeyboardShortcutsEditor, + KeyboardShortcutsModel, + KeyboardShortcutsProxy, SettingsDialog, ) @@ -67,3 +74,418 @@ def test_settings_dialog_on_restore_defaults(msg_mock, settings, view): msg_mock.assert_called_once() assert settings.valueOrDefault('Items/image_storage_format') == 'best' assert settings.valueOrDefault('Items/arrange_gap') == 0 + + +def test_keyboard_shortcuts_editor_no_conflicts(view): + a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']}) + with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+A') + editor.on_editing_finished() + assert editor.keySequence().toString() == 'Ctrl+A' + assert editor.remove_from_other is None + + +def test_keyboard_shortcuts_editor_reenter_existing_shortcut(view): + a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']}) + with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+F') + editor.on_editing_finished() + assert editor.keySequence().toString() == 'Ctrl+F' + assert editor.remove_from_other is None + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.No) +def test_keyboard_shortcuts_editor_conflicts_choose_to_cancel( + question_mock, view): + a1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) + a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) + with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+B') + editor.on_editing_finished() + assert editor.keySequence().toString() == 'Ctrl+F' + assert editor.remove_from_other is None + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.Yes) +def test_keyboard_shortcuts_editor_conflicts_choose_to_confirm( + question_mock, view): + a1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) + a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) + with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+B') + editor.on_editing_finished() + assert editor.keySequence().toString() == 'Ctrl+B' + assert editor.remove_from_other == a2 + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.No) +def test_keyboard_shortcuts_editor_conflicts_choose_to_cancel_when_no_shortcut( + question_mock, view): + a1 = Action({'id': 'foo', 'text': 'Foo'}) + a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) + with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+B') + editor.on_editing_finished() + assert editor.keySequence().toString() == '' + assert a2.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_delegate_setmodeldata(view): + a1 = Action({'id': 'foo', 'text': 'Foo'}) + a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) + with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): + model = KeyboardShortcutsModel() + delegate = KeyboardShortcutsDelegate() + editor = delegate.createEditor(view, None, index=model.index(0, 2)) + editor.setKeySequence('Ctrl+F') + delegate.setModelData( + editor, model, index=model.index(0, 2)) + assert a1.get_shortcuts() == ['Ctrl+F'] + + +def test_keyboard_shortcuts_model_columncount(): + model = KeyboardShortcutsModel() + model.columnCount(None) == 4 + + +@patch('beeref.widgets.settings.actions', + ActionList([ + Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}), + Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) + ])) +def test_keyboard_shortcuts_model_rowcount(): + model = KeyboardShortcutsModel() + model.rowCount(None) == 2 + + +def test_keyboard_shortcuts_model_data_gets_text(): + action = Action({'id': 'foo', 'text': '&Foo', 'shortcuts': ['Ctrl+F']}) + action.menu_path = ['&Bar', 'Ba&z'] + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=0), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Bar: Baz: Foo' + + +def test_keyboard_shortcuts_model_data_gets_changed_when_not_changed(): + action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value is None + + +def test_keyboard_shortcuts_model_data_gets_changed_when_changed(kbsettings): + action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + action.set_shortcuts(['Ctrl+B']) + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == '✎' + + +def test_keyboard_shortcuts_model_data_gets_shortcut(): + action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Ctrl+F' + + +def test_keyboard_shortcuts_model_data_tooltip_changed_when_not_changed(): + action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_keyboard_shortcuts_model_data_tooltip_changed_when_changed(): + action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) + action.set_shortcuts(['Ctrl+B']) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Changed from default' + + +def test_keyboard_shortcuts_model_data_tooltip_shortcut_when_not_changed(): + action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_keyboard_shortcuts_model_data_tooltip_shortcut_not_changed_not_set(): + action = Action({'id': 'foo', 'text': 'Foo'}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_keyboard_shortcuts_model_data_tooltip_shortcut_when_changed(): + action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + action.set_shortcuts(['Ctrl+B']) + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Ctrl+F' + + +def test_keyboard_shortcuts_model_data_tooltip_shortcut_changed_from_none(): + action = Action({'id': 'foo', 'text': 'Foo'}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + action.set_shortcuts(['Ctrl+B']) + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: -' + + +def test_keyboard_shortcuts_model_setdata_saves(): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert action.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_model_setdata_saves_second_shortcut(): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert action.get_shortcuts() == ['Ctrl+F', 'Ctrl+B'] + + +def test_keyboard_shortcuts_model_setdata_saves_second_shortcut_no_first(): + action = Action({'id': 'foo'}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert action.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_model_setdata_removes_duplicate(): + action = Action({'id': 'foo', 'shortcuts': ['Ctrl+B']}) + with patch('beeref.widgets.settings.actions', ActionList([action])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert action.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_model_setdata_remove_from_other(): + a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']}) + with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None, + remove_from_other=a2) + assert a1.get_shortcuts() == ['Ctrl+B'] + assert a2.get_shortcuts() == [] + + +def test_keyboard_shortcuts_model_headerdata(): + model = KeyboardShortcutsModel() + header = model.headerData( + 0, + QtCore.Qt.Orientation.Horizontal, + QtCore.Qt.ItemDataRole.DisplayRole) + assert header == 'Action' + + +def test_flags_first_column(): + model = KeyboardShortcutsModel() + flags = model.flags(model.index(0, 0)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_flags_shortcut_column(): + model = KeyboardShortcutsModel() + flags = model.flags(model.index(0, 2)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable) + + +@patch('beeref.widgets.settings.actions', + ActionList([Action({'id': 'bar', 'text': 'Bar'}), + Action({'id': 'foo', 'text': 'Foo'}), + Action({'id': 'baz', 'text': 'Baz'})])) +def test_keyboard_shortcuts_proxy_data_unfiltered(): + proxy = KeyboardShortcutsProxy() + color1 = proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.BackgroundRole) + color2 = proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.BackgroundRole) + color3 = proxy.data( + proxy.index(2, 0), QtCore.Qt.ItemDataRole.BackgroundRole) + + assert color1 == color3 + assert color1 != color2 + + assert proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' + assert proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Foo' + assert proxy.data( + proxy.index(2, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' + + +@patch('beeref.widgets.settings.actions', + ActionList([Action({'id': 'bar', 'text': 'Bar'}), + Action({'id': 'foo', 'text': 'Foo'}), + Action({'id': 'baz', 'text': 'Baz'})])) +def test_keyboard_shortcuts_proxy_data_filtered(): + proxy = KeyboardShortcutsProxy() + proxy.setFilterFixedString('b') + color1 = proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.BackgroundRole) + color2 = proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.BackgroundRole) + + assert color1 != color2 + + assert proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' + assert proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' + + +def test_keyboard_shortcuts_proxy_setdata_saves_correct_filtered_index(): + a1 = Action({'id': 'bar', 'text': 'Bar'}) + a2 = Action({'id': 'foo', 'text': 'Foo'}) + a3 = Action({'id': 'baz', 'text': 'Baz'}) + + with patch('beeref.widgets.settings.actions', ActionList([a1, a2, a3])): + proxy = KeyboardShortcutsProxy() + proxy.setFilterFixedString('b') + proxy.setData( + index=proxy.index(1, 2), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert a1.get_shortcuts() == [] + assert a2.get_shortcuts() == [] + assert a3.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_proxy_setdata_remove_from_other(): + a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']}) + with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): + proxy = KeyboardShortcutsProxy() + proxy.setData( + index=proxy.index(0, 2), + value=QtGui.QKeySequence('Ctrl+B'), + role=None, + remove_from_other=a2) + assert a1.get_shortcuts() == ['Ctrl+B'] + assert a2.get_shortcuts() == [] + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.Yes) +@patch('beeref.config.KeyboardSettings.restore_defaults') +def test_keyboard_settings_dialog_on_restore_defaults( + restore_mock, msg_mock, kbsettings, view): + dialog = KeyboardSettingsDialog(view) + dialog.on_restore_defaults() + msg_mock.assert_called_once() + restore_mock.assert_called()