From fb3bc234c7aa428e4d7a5864e929aa524677aac9 Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Mon, 1 Jan 2024 20:03:18 +0100 Subject: [PATCH] Add dialog for changing keyboard settings --- .github/workflows/pytest.yml | 2 +- CHANGELOG.rst | 4 + beeref/actions/actions.py | 295 ++++++++++++++------- beeref/actions/menu_structure.py | 1 + beeref/actions/mixin.py | 58 ++--- beeref/config.py | 25 +- beeref/constants.py | 4 +- beeref/fileio/export.py | 2 +- beeref/view.py | 3 + beeref/widgets/settings.py | 232 ++++++++++++++++- setup.py | 4 +- tests/actions/test_actions.py | 159 ++++++++++++ tests/actions/test_mixin.py | 359 ++++++++++++-------------- tests/conftest.py | 10 +- tests/test_config.py | 52 ++-- tests/test_view.py | 12 +- tests/widgets/test_settings.py | 426 ++++++++++++++++++++++++++++++- 17 files changed, 1275 insertions(+), 373 deletions(-) create mode 100644 tests/actions/test_actions.py 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 . -actions = [ - { +from collections import OrderedDict +from functools import cached_property +import logging + +from PyQt6 import QtGui + +from beeref.actions.menu_structure import menu_structure +from beeref.config import KeyboardSettings, settings_events + + +logger = logging.getLogger(__name__) + + +class Action(dict): + + def __init__(self, *args, **kwargs): + self.qaction = None + self.kb_settings = KeyboardSettings() + super().__init__(*args, **kwargs) + settings_events.restore_keyboard_defaults.connect( + self.on_restore_defaults) + + def __eq__(self, other): + return self['id'] == other['id'] + + def on_restore_defaults(self): + if self.qaction: + self.qaction.setShortcuts(self.get_shortcuts()) + + @cached_property + def menu_path(self): + path = [] + + def _get_path(menu_item): + if isinstance(menu_item['items'], list): + # This is a normal menu + for item in menu_item['items']: + if item == self['id']: + path.append(menu_item['menu']) + return True + if isinstance(item, dict): + # This is a submenu + if _get_path(item): + path.append(menu_item['menu']) + return True + elif menu_item['items'] == self.get('menu_id'): + # This is a dynamic submenu (e.g. Recent Files) + path.append(menu_item['menu']) + return True + + for menu_item in menu_structure: + _get_path(menu_item) + + return path[::-1] + + def get_shortcuts(self): + return self.kb_settings.get_shortcuts( + 'Actions', self['id'], self.get('shortcuts')) + + def set_shortcuts(self, value): + logger.debug(f'Setting shortcut "{self["id"]}" to: {value}') + self.kb_settings.set_shortcuts( + 'Actions', self['id'], value, self.get('shortcuts')) + if self.qaction: + self.qaction.setShortcuts(value) + + def get_qkeysequence(self, index): + """Current shortcuts as QKeySequence""" + try: + return QtGui.QKeySequence(self.get_shortcuts()[index]) + except IndexError: + return QtGui.QKeySequence() + + def shortcuts_changed(self): + return self.get_shortcuts() != self.get('shortcuts', []) + + def get_default_shortcut(self, index): + try: + return self.get('shortcuts', [])[index] + except IndexError: + return None + + +class ActionList(OrderedDict): + + def __init__(self, actions): + super().__init__() + for action in actions: + self[action['id']] = action + + def __getitem__(self, key): + if isinstance(key, int): + key = list(self.keys())[key] + return super().__getitem__(key) + + +actions = ActionList([ + Action({ 'id': 'open', 'text': '&Open', 'shortcuts': ['Ctrl+O'], 'callback': 'on_action_open', - }, - { + }), + Action({ 'id': 'save', 'text': '&Save', 'shortcuts': ['Ctrl+S'], 'callback': 'on_action_save', 'group': 'active_when_items_in_scene', - }, - { + }), + Action({ 'id': 'save_as', 'text': 'Save &As...', 'shortcuts': ['Ctrl+Shift+S'], 'callback': 'on_action_save_as', 'group': 'active_when_items_in_scene', - }, - { + }), + Action({ 'id': 'export_scene', 'text': 'E&xport Scene...', 'shortcuts': ['Ctrl+Shift+E'], 'callback': 'on_action_export_scene', 'group': 'active_when_items_in_scene', - }, - { + }), + Action({ 'id': 'quit', 'text': '&Quit', 'shortcuts': ['Ctrl+Q'], 'callback': 'on_action_quit', - }, - { + }), + Action({ 'id': 'insert_images', 'text': '&Images...', 'shortcuts': ['Ctrl+I'], 'callback': 'on_action_insert_images', - }, - { + }), + Action({ 'id': 'insert_text', 'text': '&Text', 'shortcuts': ['Ctrl+T'], 'callback': 'on_action_insert_text', - }, - { + }), + Action({ 'id': 'undo', 'text': '&Undo', 'shortcuts': ['Ctrl+Z'], 'callback': 'on_action_undo', 'group': 'active_when_can_undo', - }, - { + }), + Action({ 'id': 'redo', 'text': '&Redo', 'shortcuts': ['Ctrl+Shift+Z'], 'callback': 'on_action_redo', 'group': 'active_when_can_redo', - }, - { + }), + Action({ 'id': 'copy', 'text': '&Copy', 'shortcuts': ['Ctrl+C'], 'callback': 'on_action_copy', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'cut', 'text': 'Cu&t', 'shortcuts': ['Ctrl+X'], 'callback': 'on_action_cut', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'paste', 'text': '&Paste', 'shortcuts': ['Ctrl+V'], 'callback': 'on_action_paste', - }, - { + }), + Action({ 'id': 'delete', 'text': '&Delete', 'shortcuts': ['Del'], 'callback': 'on_action_delete_items', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'raise_to_top', 'text': '&Raise to Top', 'shortcuts': ['PgUp'], 'callback': 'on_action_raise_to_top', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'lower_to_bottom', 'text': 'Lower to Bottom', 'shortcuts': ['PgDown'], 'callback': 'on_action_lower_to_bottom', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'normalize_height', 'text': '&Height', 'shortcuts': ['Shift+H'], 'callback': 'on_action_normalize_height', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'normalize_width', 'text': '&Width', 'shortcuts': ['Shift+W'], 'callback': 'on_action_normalize_width', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'normalize_size', 'text': '&Size', 'shortcuts': ['Shift+S'], 'callback': 'on_action_normalize_size', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'arrange_optimal', 'text': '&Optimal', 'shortcuts': ['Shift+O'], 'callback': 'on_action_arrange_optimal', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'arrange_horizontal', 'text': '&Horizontal', 'callback': 'on_action_arrange_horizontal', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'arrange_vertical', 'text': '&Vertical', 'callback': 'on_action_arrange_vertical', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'change_opacity', 'text': 'Change &Opacity...', 'callback': 'on_action_change_opacity', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'grayscale', 'text': '&Grayscale', 'shortcuts': ['G'], 'checkable': True, 'callback': 'on_action_grayscale', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'crop', 'text': '&Crop', 'shortcuts': ['Shift+C'], 'callback': 'on_action_crop', 'group': 'active_when_single_image', - }, - { + }), + Action({ 'id': 'flip_horizontally', 'text': 'Flip &Horizontally', 'shortcuts': ['H'], 'callback': 'on_action_flip_horizontally', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'flip_vertically', 'text': 'Flip &Vertically', 'shortcuts': ['V'], 'callback': 'on_action_flip_vertically', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'new_scene', 'text': '&New Scene', 'shortcuts': ['Ctrl+N'], 'callback': 'clear_scene', - }, - { + }), + Action({ 'id': 'fit_scene', 'text': '&Fit Scene', 'shortcuts': ['1'], 'callback': 'on_action_fit_scene', - }, - { + }), + Action({ 'id': 'fit_selection', 'text': 'Fit &Selection', 'shortcuts': ['2'], 'callback': 'on_action_fit_selection', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'reset_scale', 'text': 'Reset &Scale', 'callback': 'on_action_reset_scale', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'reset_rotation', 'text': 'Reset &Rotation', 'callback': 'on_action_reset_rotation', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'reset_flip', 'text': 'Reset &Flip', 'callback': 'on_action_reset_flip', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'reset_crop', 'text': 'Reset Cro&p', 'callback': 'on_action_reset_crop', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'reset_transforms', 'text': 'Reset &All', 'shortcuts': ['R'], 'callback': 'on_action_reset_transforms', 'group': 'active_when_selection', - }, - { + }), + Action({ 'id': 'select_all', 'text': '&Select All', 'shortcuts': ['Ctrl+A'], 'callback': 'on_action_select_all', - }, - { + }), + Action({ 'id': 'deselect_all', 'text': 'Deselect &All', 'shortcuts': ['Ctrl+Shift+A'], 'callback': 'on_action_deselect_all', - }, - { + }), + Action({ 'id': 'help', 'text': '&Help', 'shortcuts': ['F1', 'Ctrl+H'], 'callback': 'on_action_help', - }, - { + }), + Action({ 'id': 'about', 'text': '&About', 'callback': 'on_action_about', - }, - { + }), + Action({ 'id': 'debuglog', 'text': 'Show &Debug Log', 'callback': 'on_action_debuglog', - }, - { + }), + Action({ 'id': 'show_scrollbars', 'text': 'Show &Scrollbars', 'checkable': True, 'settings': 'View/show_scrollbars', 'callback': 'on_action_show_scrollbars', - }, - { + }), + Action({ 'id': 'show_menubar', 'text': 'Show &Menu Bar', 'checkable': True, 'settings': 'View/show_menubar', 'callback': 'on_action_show_menubar', - }, - { + }), + Action({ 'id': 'show_titlebar', 'text': 'Show &Title Bar', 'checkable': True, 'checked': True, 'callback': 'on_action_show_titlebar', - }, - { + }), + Action({ 'id': 'move_window', 'text': 'Move &Window', 'shortcuts': ['Ctrl+M'], 'callback': 'on_action_move_window', - }, - { + }), + Action({ 'id': 'fullscreen', 'text': '&Fullscreen', 'shortcuts': ['F11'], 'checkable': True, 'callback': 'on_action_fullscreen', - }, - { + }), + Action({ 'id': 'always_on_top', 'text': '&Always On Top', 'checkable': True, 'callback': 'on_action_always_on_top', - }, - { + }), + Action({ 'id': 'settings', 'text': '&Settings', 'callback': 'on_action_settings', - }, - { + }), + Action({ + 'id': 'keyboard_settings', + 'text': '&Keyboard Shortcuts', + 'callback': 'on_action_keyboard_settings', + }), + Action({ 'id': 'open_settings_dir', - 'text': 'Open Settings Folder', + 'text': '&Open Settings Folder', 'callback': 'on_action_open_settings_dir', - }, -] + }), +]) diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 942993c..cec0766 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -114,6 +114,7 @@ menu_structure = [ 'menu': '&Settings', 'items': [ 'settings', + 'keyboard_settings', 'open_settings_dir', ], }, diff --git a/beeref/actions/mixin.py b/beeref/actions/mixin.py index 012dbb0..e0112cc 100644 --- a/beeref/actions/mixin.py +++ b/beeref/actions/mixin.py @@ -19,11 +19,9 @@ import os.path from PyQt6 import QtGui, QtWidgets -from .actions import actions +from .actions import Action, actions from .menu_structure import menu_structure, MENU_SEPARATOR -from beeref.config import KeyboardSettings - class ActionsMixin: @@ -35,11 +33,10 @@ class ActionsMixin: """Creates a new menu or rebuilds the given menu.""" self.context_menu = QtWidgets.QMenu(self) self.toplevel_menus = [] - self.bee_actions = {} self.bee_actiongroups = defaultdict(list) self._post_create_functions = [] self._create_actions() - self._create_menu(self.bee_actions, self.context_menu, menu_structure) + self._create_menu(self.context_menu, menu_structure) for func, arg in self._post_create_functions: func(arg) del self._post_create_functions @@ -71,10 +68,9 @@ class ActionsMixin: partial(self._store_checkable_setting, settings_key)) def _create_actions(self): - for action in actions: + for action in actions.values(): qaction = QtGui.QAction(action['text'], self) - shortcuts = KeyboardSettings().get_shortcuts( - 'Actions', action['id'], action.get('shortcuts')) + shortcuts = action.get_shortcuts() if shortcuts: qaction.setShortcuts(shortcuts) if action.get('checkable', False): @@ -83,25 +79,25 @@ class ActionsMixin: 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) qaction.setEnabled(False) + action.qaction = qaction - def _create_menu(self, actions, menu, items): + def _create_menu(self, menu, items): if isinstance(items, str): getattr(self, items)(menu) return menu for item in items: if isinstance(item, str): - menu.addAction(actions[item]) + menu.addAction(actions[item].qaction) if item == MENU_SEPARATOR: menu.addSeparator() if isinstance(item, dict): submenu = menu.addMenu(item['menu']) if menu == self.context_menu: self.toplevel_menus.append(submenu) - self._create_menu(actions, submenu, item['items']) + self._create_menu(submenu, item['items']) return menu @@ -112,31 +108,31 @@ class ActionsMixin: files = self.settings.get_recent_files(existing_only=True) items = [] - i = -1 - for i, filename in enumerate(files): - qaction = QtGui.QAction(os.path.basename(filename), self) + + for i in range(10): action_id = f'recent_files_{i}' key = 0 if i == 9 else i + 1 - if key < 10: - shortcuts = KeyboardSettings().get_shortcuts( - 'Actions', action_id, [f'Ctrl+{key}']) - qaction.setShortcuts(shortcuts) - qaction.triggered.connect(partial(self.open_from_file, filename)) - self.addAction(qaction) - self._recent_files_submenu.addAction(qaction) - self.bee_actions[action_id] = qaction - items.append(action_id) + action = Action({'id': action_id, + 'menu_id': '_build_recent_files', + 'text': f'File {i + 1}', + 'shortcuts': [f'Ctrl+{key}']}) + actions[action_id] = action - # Set shortcuts in settings file for remaining slots: - for j in range(i + 1, 10): - key = 0 if j == 9 else j + 1 - KeyboardSettings().get_shortcuts( - 'Actions', f'recent_files_{j}', [f'Ctrl+{key}']) + if i < len(files): + filename = files[i] + qaction = QtGui.QAction(os.path.basename(filename), self) + qaction.setShortcuts(action.get_shortcuts()) + qaction.triggered.connect( + partial(self.open_from_file, filename)) + self.addAction(qaction) + action.qaction = qaction + self._recent_files_submenu.addAction(qaction) + items.append(action_id) def _clear_recent_files(self): for action in self._recent_files_submenu.actions(): self.removeAction(action) self._recent_files_submenu.clear() - for key in list(self.bee_actions.keys()): + for key in list(actions.keys()): if key.startswith('recent_files_'): - self.bee_actions.pop(key) + actions[key].qaction = None diff --git a/beeref/config.py b/beeref/config.py index 01f4288..d2e6d4c 100644 --- a/beeref/config.py +++ b/beeref/config.py @@ -95,6 +95,7 @@ class CommandlineArgs: class BeeSettingsEvents(QtCore.QObject): restore_defaults = QtCore.pyqtSignal() + restore_keyboard_defaults = QtCore.pyqtSignal() # We want to send and receive settings events globally, not per @@ -203,8 +204,6 @@ class BeeSettings(QtCore.QSettings): class KeyboardSettings(QtCore.QSettings): - save_unknown_shortcuts = True - def __init__(self): settings_format = QtCore.QSettings.Format.IniFormat filename = os.path.join( @@ -212,21 +211,29 @@ class KeyboardSettings(QtCore.QSettings): 'KeyboardSettings.ini') super().__init__(filename, settings_format) - def set_shortcuts(self, group, key, values): - self.setValue(f'{group}/{key}', ', '.join(values)) + def set_shortcuts(self, group, key, values, default=None): + if values == default: + self.remove(f'{group}/{key}') + else: + self.setValue(f'{group}/{key}', ', '.join(values)) def get_shortcuts(self, group, key, default=None): values = self.value(f'{group}/{key}') if values is not None: values = list(filter(lambda x: x, values.split(', '))) - logger.debug(f'Found custom shortcuts for {group}/{key}: {values}') return values - values = default or [] - if self.save_unknown_shortcuts: - self.set_shortcuts(group, key, values) + return list(default or []) # Always return new instance of default - return values + def restore_defaults(self): + """Restore all the values specified in FILEDS to their default values + by removing them from the settings file. + """ + + logger.debug('Restoring keyboard shortcuts to defaults') + for key in self.allKeys(): + self.remove(key) + settings_events.restore_keyboard_defaults.emit() def logfile_name(): diff --git a/beeref/constants.py b/beeref/constants.py index c01578a..afa595e 100644 --- a/beeref/constants.py +++ b/beeref/constants.py @@ -36,5 +36,7 @@ COLORS = { # BeeRef specific: 'Scene:Selection': (116, 234, 231), 'Scene:Canvas': (60, 60, 60), - 'Scene:Text': (200, 200, 200) + 'Scene:Text': (200, 200, 200), + 'Table:AlternativeRow': (70, 70, 70), + } diff --git a/beeref/fileio/export.py b/beeref/fileio/export.py index c5795c8..30febe1 100644 --- a/beeref/fileio/export.py +++ b/beeref/fileio/export.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) class ExporterRegistry(dict): - DEFAULT_TYPE = 'default exporter' + DEFAULT_TYPE = 0 def __getitem__(self, key): key = key.removeprefix('.') diff --git a/beeref/view.py b/beeref/view.py index 059d49d..b223168 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -458,6 +458,9 @@ class BeeGraphicsView(MainControlsMixin, def on_action_settings(self): widgets.settings.SettingsDialog(self) + def on_action_keyboard_settings(self): + widgets.settings.KeyboardSettingsDialog(self) + def on_action_help(self): widgets.HelpDialog(self) diff --git a/beeref/widgets/settings.py b/beeref/widgets/settings.py index 3e754bb..e4f9890 100644 --- a/beeref/widgets/settings.py +++ b/beeref/widgets/settings.py @@ -16,10 +16,11 @@ from functools import partial import logging -from PyQt6 import QtWidgets +from PyQt6 import QtWidgets, QtCore, QtGui from beeref import constants -from beeref.config import BeeSettings, settings_events +from beeref.actions.actions import actions +from beeref.config import BeeSettings, KeyboardSettings, settings_events logger = logging.getLogger(__name__) @@ -36,7 +37,8 @@ class RadioGroup(QtWidgets.QGroupBox): self.settings = BeeSettings() layout = QtWidgets.QVBoxLayout() self.setLayout(layout) - settings_events.restore_defaults.connect(self.on_restore_defaults) + settings_events.restore_keyboard_defaults.connect( + self.on_restore_defaults) if self.HELPTEXT: helptxt = QtWidgets.QLabel(self.HELPTEXT) @@ -177,3 +179,227 @@ class SettingsDialog(QtWidgets.QDialog): if reply == QtWidgets.QMessageBox.StandardButton.Yes: BeeSettings().restore_defaults() + + +class KeyboardShortcutsEditor(QtWidgets.QKeySequenceEdit): + + def __init__(self, parent, index): + super().__init__(parent) + self.action = actions[index.row()] + try: + self.old_value = self.action.get_shortcuts()[index.column() - 2] + except IndexError: + self.old_value = '' + self.setClearButtonEnabled(True) + self.setMaximumSequenceLength(1) + self.editingFinished.connect(self.on_editing_finished) + self.finished_last_called_with = None + self.remove_from_other = None + + def on_editing_finished(self): + shortcut = self.keySequence().toString() + + if self.finished_last_called_with == shortcut: + # Workaround for bug + # https://bugreports.qt.io/browse/QTBUG-40 + # editingFinished signal is emitted twice because of + # the QMessageBox below + return + + self.remove_from_other = None + self.finished_last_called_with = shortcut + for action in actions.values(): + if action == self.action: + continue + if shortcut in action.get_shortcuts(): + txt = ': '.join(action.menu_path + [action['text']]) + txt = txt.replace('&', '').removesuffix('...') + msg = ('

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()