diff --git a/.github/workflows/build_appimage.yml b/.github/workflows/build_appimage.yml new file mode 100644 index 0000000..dfc17ba --- /dev/null +++ b/.github/workflows/build_appimage.yml @@ -0,0 +1,25 @@ +name: build_appimage + +on: workflow_dispatch + +jobs: + + build_appimage: + name: build_appimage + + runs-on: 'ubuntu-20.04' + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Build appimage + run: | + python3 tools/build_appimage.py --version=${{ github.ref_name }} --jsonfile=tools/linux_libs.json + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + path: BeeRef*.appimage + retention-days: 5 diff --git a/.gitignore b/.gitignore index 6ff49a6..f0b0c63 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +*.appimage +squashfs-root/ # PyInstaller # Usually these files are written by a python script from a template diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 07efd8d..feacf98 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,8 @@ Added * Added panning via scrollwheel: * Scroll wheel + Shift + Ctrl: pan vertically * Scroll wheel + Shift: pan horizontally +* Make mouse and mouse wheel controls configurable + (Settings -> Keyboard & Mouse) Fixed @@ -24,6 +26,11 @@ Fixed correctly depending on the selected images. * Removed black line under marching ants outline of crop mode, which would scale with the image and get potentially very thick. +* Fixed a crash when importing images with unsupported exif orientation info +* Fixed threading issue when importing images (causing potential + hangs/weird behaviour) +* Fixed an intermittent crash when invoking New Scene +* Fixed bee files hanging on to disk space of deleted images (issue #99) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 2505eb7..310a5b0 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -7,12 +7,12 @@ BeeRef is written in Python and PyQt6. Developing ---------- -Optional step: Use pyenv to create a virtual environment: +Optional step: Use pyenv to create a virtual environment:: pyenv install -v 3.11 pyenv virtualenv 3.11 beeref -Once the vitrual environment is set up, you can enter it with: +Once the vitrual environment is set up, you can enter it with:: pyenv activate beeref diff --git a/beeref/__main__.py b/beeref/__main__.py index 4899c83..333feac 100755 --- a/beeref/__main__.py +++ b/beeref/__main__.py @@ -103,6 +103,7 @@ def main(): logger.info(f'Starting {constants.APPNAME} version {constants.VERSION}') logger.debug('System: %s', ' '.join(platform.uname())) logger.debug('Python: %s', platform.python_version()) + logger.debug('LD_LIBRARY_PATH: %s', os.environ.get('LD_LIBRARY_PATH')) settings = BeeSettings() logger.info(f'Using settings: {settings.fileName()}') logger.info(f'Logging to: {logfile_name()}') diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index 1ce63c5..6e45dd9 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -13,7 +13,6 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -from collections import OrderedDict from functools import cached_property import logging @@ -21,22 +20,39 @@ from PyQt6 import QtGui from beeref.actions.menu_structure import menu_structure from beeref.config import KeyboardSettings, settings_events +from beeref.utils import ActionList logger = logging.getLogger(__name__) -class Action(dict): +class Action: + SETTINGS_GROUP = 'Actions' - def __init__(self, *args, **kwargs): + def __init__(self, id, text, callback=None, shortcuts=None, + checkable=False, checked=False, group=None, settings=None, + enabled=True, menu_item=None, menu_id=None): + self.id = id + self.text = text + self.callback = callback + self.shortcuts = shortcuts or [] + self.checkable = checkable + self.checked = checked + self.group = group + self.settings = settings + self.enabled = enabled + self.menu_item = menu_item + self.menu_id = menu_id 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'] + return self.id == other.id + + def __str__(self): + return self.id def on_restore_defaults(self): if self.qaction: @@ -50,7 +66,7 @@ class Action(dict): if isinstance(menu_item['items'], list): # This is a normal menu for item in menu_item['items']: - if item == self['id']: + if item == self.id: path.append(menu_item['menu']) return True if isinstance(item, dict): @@ -58,7 +74,7 @@ class Action(dict): if _get_path(item): path.append(menu_item['menu']) return True - elif menu_item['items'] == self.get('menu_id'): + elif menu_item['items'] == self.menu_id: # This is a dynamic submenu (e.g. Recent Files) path.append(menu_item['menu']) return True @@ -69,13 +85,13 @@ class Action(dict): return path[::-1] def get_shortcuts(self): - return self.kb_settings.get_shortcuts( - 'Actions', self['id'], self.get('shortcuts')) + return self.kb_settings.get_list( + self.SETTINGS_GROUP, self.id, self.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')) + logger.debug(f'Setting shortcut "{self.id}" to: {value}') + self.kb_settings.set_list( + self.SETTINGS_GROUP, self.id, value, self.shortcuts) if self.qaction: self.qaction.setShortcuts(value) @@ -88,348 +104,335 @@ class Action(dict): def shortcuts_changed(self): """Whether shortcuts have changed from their defaults.""" - return self.get_shortcuts() != self.get('shortcuts', []) + return self.get_shortcuts() != self.shortcuts def get_default_shortcut(self, index): try: - return self.get('shortcuts', [])[index] + return self.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': 'show_color_gamut', - 'text': 'Show &Color Gamut', - 'callback': 'on_action_show_color_gamut', - 'group': 'active_when_single_image', - }), - Action({ - 'id': 'sample_color', - 'text': 'Sample Color', - 'shortcuts': ['S'], - 'callback': 'on_action_sample_color', - 'group': 'active_when_items_in_scene', - }), - 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', - 'callback': 'on_action_open_settings_dir', - }), + 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='show_color_gamut', + text='Show &Color Gamut', + callback='on_action_show_color_gamut', + group='active_when_single_image', + ), + Action( + id='sample_color', + text='Sample Color', + shortcuts=['S'], + callback='on_action_sample_color', + group='active_when_items_in_scene', + ), + 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 && Mouse', + callback='on_action_keyboard_settings', + ), + Action( + id='open_settings_dir', + text='&Open Settings Folder', + callback='on_action_open_settings_dir', + ), ]) diff --git a/beeref/actions/mixin.py b/beeref/actions/mixin.py index e0112cc..7e1e22e 100644 --- a/beeref/actions/mixin.py +++ b/beeref/actions/mixin.py @@ -55,10 +55,10 @@ class ActionsMixin: def _init_action_checkable(self, actiondef, qaction): qaction.setCheckable(True) - callback = getattr(self, actiondef['callback']) + callback = getattr(self, actiondef.callback) qaction.toggled.connect(callback) - settings_key = actiondef.get('settings') - checked = actiondef.get('checked', False) + settings_key = actiondef.settings + checked = actiondef.checked qaction.setChecked(checked) if settings_key: val = self.settings.value(settings_key, checked, type=bool) @@ -69,18 +69,18 @@ class ActionsMixin: def _create_actions(self): for action in actions.values(): - qaction = QtGui.QAction(action['text'], self) + qaction = QtGui.QAction(action.text, self) shortcuts = action.get_shortcuts() if shortcuts: qaction.setShortcuts(shortcuts) - if action.get('checkable', False): + if action.checkable: self._init_action_checkable(action, qaction) else: - qaction.triggered.connect(getattr(self, action['callback'])) + qaction.triggered.connect(getattr(self, action.callback)) self.addAction(qaction) - qaction.setEnabled(action.get('enabled', True)) - if 'group' in action: - self.bee_actiongroups[action['group']].append(qaction) + qaction.setEnabled(action.enabled) + if action.group: + self.bee_actiongroups[action.group].append(qaction) qaction.setEnabled(False) action.qaction = qaction @@ -112,10 +112,10 @@ class ActionsMixin: for i in range(10): action_id = f'recent_files_{i}' key = 0 if i == 9 else i + 1 - action = Action({'id': action_id, - 'menu_id': '_build_recent_files', - 'text': f'File {i + 1}', - 'shortcuts': [f'Ctrl+{key}']}) + action = Action(id=action_id, + menu_id='_build_recent_files', + text=f'File {i + 1}', + shortcuts=[f'Ctrl+{key}']) actions[action_id] = action if i < len(files): diff --git a/beeref/commands.py b/beeref/commands.py index 7c5c94a..6ffaa0e 100644 --- a/beeref/commands.py +++ b/beeref/commands.py @@ -26,10 +26,11 @@ class InsertItems(QtGui.QUndoCommand): self.ignore_first_redo = ignore_first_redo def redo(self): - self.scene.deselect_all_items() if self.ignore_first_redo: self.ignore_first_redo = False return + + self.scene.deselect_all_items() if self.position: self.old_positions = [] rect = self.scene.itemsBoundingRect(items=self.items) diff --git a/beeref/config/__init__.py b/beeref/config/__init__.py new file mode 100644 index 0000000..b2f7080 --- /dev/null +++ b/beeref/config/__init__.py @@ -0,0 +1,91 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + +"""Handling of command line args and Qt settings.""" + +import logging +import logging.config +import os.path + +from PyQt6 import QtCore + +from beeref import constants +from beeref.config.controls import KeyboardSettings # noqa F401 +from beeref.config.settings import ( # noqa F401 + BeeSettings, + CommandlineArgs, + settings_events, +) +from beeref.logging import qt_message_handler + + +logger = logging.getLogger(__name__) + + +def logfile_name(): + return os.path.join( + os.path.dirname(BeeSettings().fileName()), f'{constants.APPNAME}.log') + + +logging_conf = { + 'version': 1, + 'formatters': { + 'verbose': { + 'format': ('{asctime} {name} {process:d} {thread:d} {message}'), + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {name}: {message}', + 'style': '{', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + 'level': CommandlineArgs().loglevel, + }, + 'file': { + 'class': 'beeref.logging.BeeRotatingFileHandler', + 'formatter': 'verbose', + 'filename': logfile_name(), + 'maxBytes': 1024 * 1000, # 1MB + 'backupCount': 1, + 'level': 'DEBUG', + 'delay': True, + } + }, + 'loggers': { + 'beeref': { + 'handlers': ['console', 'file'], + 'level': 'TRACE', + 'propagate': False, + }, + 'Qt': { + 'handlers': ['console', 'file'], + 'level': 'DEBUG', + 'propagate': False, + }, + }, + 'root': { + 'handlers': ['console', 'file'], + 'level': 'DEBUG', + }, +} + +logging.config.dictConfig(logging_conf) + +# Redirect Qt logging to Python logger: +QtCore.qInstallMessageHandler(qt_message_handler) diff --git a/beeref/config/controls.py b/beeref/config/controls.py new file mode 100644 index 0000000..813233f --- /dev/null +++ b/beeref/config/controls.py @@ -0,0 +1,336 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + +"""Handling of keyboard shortcuts and mouse controls.""" + +from collections import OrderedDict +from functools import cached_property +import logging +import logging.config +import os.path + +from beeref.config.settings import BeeSettings, settings_events +from beeref.utils import ActionList + +from PyQt6 import QtCore +from PyQt6.QtCore import Qt + + +logger = logging.getLogger(__name__) + + +class MouseConfigBase: + MODIFIER_MAP = OrderedDict(( + ('No Modifier', Qt.KeyboardModifier.NoModifier), + ('Shift', Qt.KeyboardModifier.ShiftModifier), + ('Ctrl', Qt.KeyboardModifier.ControlModifier), + ('Alt', Qt.KeyboardModifier.AltModifier), + ('Meta', Qt.KeyboardModifier.MetaModifier), + ('Keypad', Qt.KeyboardModifier.KeypadModifier), + )) + + BUTTON_MAP = OrderedDict(( + ('Not Configured', Qt.MouseButton.NoButton), + ('Left', Qt.MouseButton.LeftButton), + ('Middle', Qt.MouseButton.MiddleButton), + )) + + def __eq__(self, other): + return self.id == other.id + + def __str__(self): + return self.id + + @cached_property + def kb_settings(self): + return KeyboardSettings() + + def get_modifiers(self): + return self.kb_settings.get_list( + self.SETTINGS_GROUP, f'{self.id}_modifiers', self.modifiers) + + def set_modifiers(self, value): + logger.debug( + f'Setting {self.SETTINGS_GROUP} modifiers ' + f'for "{self.id}" to: {value}') + self.kb_settings.set_list( + self.SETTINGS_GROUP, f'{self.id}_modifiers', value, self.modifiers) + + def get_inverted(self): + return self.kb_settings.get_value( + self.SETTINGS_GROUP, f'{self.id}_inverted', self.inverted) + + def set_inverted(self, value): + logger.debug( + f'Setting {self.SETTINGS_GROUP} inverted ' + f'for "{self.id}" to: {value}') + self.kb_settings.set_value( + self.SETTINGS_GROUP, f'{self.id}_inverted', value, self.inverted) + + @classmethod + def modifiers_to_qt(cls, modifiers): + combined = cls.MODIFIER_MAP[modifiers[0]] + for mod in modifiers[1:]: + combined = combined | cls.MODIFIER_MAP[mod] + return combined + + +class MouseWheelConfig(MouseConfigBase): + + SETTINGS_GROUP = 'MouseWheel' + + def __init__(self, id, group, text, modifiers, invertible): + self.id = id + self.group = group + self.text = text + self.modifiers = modifiers + self.invertible = invertible + self.inverted = False + + def controls_changed(self): + """Whether controls have changed from their defaults.""" + return (set(self.get_modifiers()) != set(self.modifiers) + or self.get_inverted() != self.inverted) + + def is_configured(self): + """Whether controls have been configured for this action.""" + return bool(self.get_modifiers()) + + def remove_controls(self): + self.set_modifiers([]) + self.set_inverted(False) + + def conflicts_with(self, other): + """Whether controls conflict with `other`. + + For unconfigured controls, always return False.""" + return (self.is_configured() + and other.is_configured() + and set(self.get_modifiers()) == set(other.get_modifiers())) + + def matches_event(self, event): + if not self.is_configured(): + return False + modifiers = self.get_modifiers() + return self.modifiers_to_qt(modifiers) == event.modifiers() + + +class MouseConfig(MouseConfigBase): + + SETTINGS_GROUP = 'Mouse' + + def __init__(self, id, group, text, button, modifiers, invertible): + self.id = id + self.group = group + self.text = text + self.button = button + self.modifiers = modifiers + self.invertible = invertible + self.inverted = False + + def get_button(self): + return self.kb_settings.get_value( + self.SETTINGS_GROUP, f'{self.id}_button', self.button) + + def set_button(self, value): + logger.debug( + f'Setting {self.SETTINGS_GROUP} button ' + f'for "{self.id}" to: {value}') + self.kb_settings.set_value( + self.SETTINGS_GROUP, f'{self.id}_button', value, self.button) + + def conflicts_with(self, other): + """Whether controls conflict with `other`. + + For unconfigured controls, always return False. + """ + return (self.is_configured() + and other.is_configured() + and self.get_button() == other.get_button() + and set(self.get_modifiers()) == set(other.get_modifiers())) + + def controls_changed(self): + """Whether controls have changed from their defaults.""" + return (self.get_button() != self.button + or set(self.get_modifiers()) != set(self.modifiers) + or self.get_inverted() != self.inverted) + + def is_configured(self): + """Whether controls have been configured for this action.""" + return self.get_button() != 'Not Configured' + + def remove_controls(self): + self.set_button('Not Configured') + self.set_modifiers([]) + self.set_inverted(False) + + def matches_event(self, event): + if not self.is_configured(): + return False + modifiers = self.get_modifiers() + return (self.modifiers_to_qt(modifiers) == event.modifiers() + and self.BUTTON_MAP[self.get_button()] == event.button()) + + +class KeyboardSettings(QtCore.QSettings): + + MOUSEWHEEL_ACTIONS = ActionList([ + MouseWheelConfig( + id='zoom1', + group='zoom', + text='Zoom', + modifiers=('No Modifier',), + invertible=True, + ), + MouseWheelConfig( + id='zoom2', + group='zoom', + text='Zoom (alternative)', + modifiers=(), + invertible=True, + ), + MouseWheelConfig( + id='pan_horizontal1', + group='pan_horizontal', + text='Pan horizontally', + modifiers=('Shift',), + invertible=True, + ), + MouseWheelConfig( + id='pan_horizontal2', + group='pan_horizontal', + text='Pan horizontally (alternative)', + modifiers=(), + invertible=True, + ), + MouseWheelConfig( + id='pan_vertical1', + group='pan_vertical', + text='Pan vertically', + modifiers=('Shift', 'Ctrl'), + invertible=True, + ), + MouseWheelConfig( + id='pan_vertical2', + group='pan_vertical', + text='Pan vertically (alternative)', + modifiers=(), + invertible=True, + ), + ]) + + MOUSE_ACTIONS = ActionList([ + MouseConfig( + id='zoom1', + group='zoom', + text='Zoom', + button='Middle', + modifiers=('Ctrl',), + invertible=True, + ), + MouseConfig( + id='zoom2', + group='zoom', + text='Zoom (alternative)', + button='Not Configured', + modifiers=(), + invertible=True, + ), + MouseConfig( + id='pan1', + group='pan', + text='Pan', + button='Middle', + modifiers=('No Modifier',), + invertible=False, + ), + MouseConfig( + id='pan2', + group='pan', + text='Pan (alternative)', + button='Left', + modifiers=('Alt',), + invertible=False, + ), + MouseConfig( + id='movewindow1', + group='movewindow', + text='Move Window', + button='Left', + modifiers=('Ctrl', 'Alt'), + invertible=False, + ), + MouseConfig( + id='movewindow2', + group='movewindow (alternative)', + text='Move Window', + button='Not Configured', + modifiers=(), + invertible=False, + ), + ]) + + def __init__(self): + settings_format = QtCore.QSettings.Format.IniFormat + filename = os.path.join( + os.path.dirname(BeeSettings().fileName()), + 'KeyboardSettings.ini') + super().__init__(filename, settings_format) + + def set_list(self, group, key, values, default=None): + if values == default: + self.remove(f'{group}/{key}') + else: + self.setValue(f'{group}/{key}', ', '.join(values)) + + def get_list(self, group, key, default=None): + values = self.value(f'{group}/{key}') + if values is not None: + values = list(filter(lambda x: x, values.split(', '))) + return values + + return list(default or []) # Always return new instance of default + + def get_value(self, group, key, default=None): + value = self.value(f'{group}/{key}') + return default if value is None else value + + def set_value(self, group, key, value, default=None): + if value == default: + self.remove(f'{group}/{key}') + else: + self.setValue(f'{group}/{key}', value) + + 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 and mouse controls to defaults') + for key in self.allKeys(): + self.remove(key) + settings_events.restore_keyboard_defaults.emit() + + def mousewheel_action_for_event(self, event): + for action in self.MOUSEWHEEL_ACTIONS.values(): + if action.matches_event(event): + return action.group, action.get_inverted() + return None, None + + def mouse_action_for_event(self, event): + for action in self.MOUSE_ACTIONS.values(): + if action.matches_event(event): + return action.group, action.get_inverted() + return None, None diff --git a/beeref/config.py b/beeref/config/settings.py similarity index 69% rename from beeref/config.py rename to beeref/config/settings.py index 9368c33..9f3cb37 100644 --- a/beeref/config.py +++ b/beeref/config/settings.py @@ -13,17 +13,13 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -"""Handling of command line args and Qt settings.""" - import argparse import logging -import logging.config import os.path from PyQt6 import QtCore from beeref import constants -from beeref.logging import qt_message_handler logger = logging.getLogger(__name__) @@ -142,7 +138,6 @@ class BeeSettings(QtCore.QSettings): 'validate' are specified in the FIELDS entry for the given key. The default value will be returned if validation or type casting fails. - """ val = self.value(key) @@ -205,94 +200,3 @@ class BeeSettings(QtCore.QSettings): if existing_only: values = [f for f in values if os.path.exists(f)] return values - - -class KeyboardSettings(QtCore.QSettings): - - def __init__(self): - settings_format = QtCore.QSettings.Format.IniFormat - filename = os.path.join( - os.path.dirname(BeeSettings().fileName()), - 'KeyboardSettings.ini') - super().__init__(filename, settings_format) - - 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(', '))) - return values - - return list(default or []) # Always return new instance of default - - 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(): - return os.path.join( - os.path.dirname(BeeSettings().fileName()), f'{constants.APPNAME}.log') - - -logging_conf = { - 'version': 1, - 'formatters': { - 'verbose': { - 'format': ('{asctime} {name} {process:d} {thread:d} {message}'), - 'style': '{', - }, - 'simple': { - 'format': '{levelname} {name}: {message}', - 'style': '{', - }, - }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'simple', - 'level': CommandlineArgs().loglevel, - }, - 'file': { - 'class': 'beeref.logging.BeeRotatingFileHandler', - 'formatter': 'verbose', - 'filename': logfile_name(), - 'maxBytes': 1024 * 1000, # 1MB - 'backupCount': 1, - 'level': 'DEBUG', - 'delay': True, - } - }, - 'loggers': { - 'beeref': { - 'handlers': ['console', 'file'], - 'level': 'TRACE', - 'propagate': False, - }, - 'Qt': { - 'handlers': ['console', 'file'], - 'level': 'DEBUG', - 'propagate': False, - }, - }, - 'root': { - 'handlers': ['console', 'file'], - 'level': 'DEBUG', - }, -} - -logging.config.dictConfig(logging_conf) - -# Redirect Qt logging to Python logger: -QtCore.qInstallMessageHandler(qt_message_handler) diff --git a/beeref/constants.py b/beeref/constants.py index a3839a3..f7726aa 100644 --- a/beeref/constants.py +++ b/beeref/constants.py @@ -19,6 +19,8 @@ VERSION = '0.3.3-dev' WEBSITE = 'https://github.com/rbreu/beeref' COPYRIGHT = 'Copyright © 2021-2023 Rebecca Breu' +CHANGED_SYMBOL = '✎' + COLORS = { # Qt: 'Active:Base': (60, 60, 60), @@ -31,6 +33,10 @@ COLORS = { 'Active:ButtonText': (200, 200, 200), 'Active:Highlight': (83, 167, 165), 'Active:Link': (90, 181, 179), + + 'Disabled:Base': (40, 40, 40), + 'Disabled:Window': (40, 40, 40, 50), + 'Disabled:WindowText': (120, 120, 120), 'Disabled:Light': (0, 0, 0, 0), 'Disabled:Text': (140, 140, 140), diff --git a/beeref/fileio/image.py b/beeref/fileio/image.py index 642a8b4..9112945 100644 --- a/beeref/fileio/image.py +++ b/beeref/fileio/image.py @@ -45,9 +45,13 @@ def exif_rotated_image(path=None): logger.exception(f'Exif parser failed on image: {path}') return img - if 'orientation' in exifimg.list_all(): - orientation = exifimg.orientation - else: + try: + if 'orientation' in exifimg.list_all(): + orientation = exifimg.orientation + else: + return img + except NotImplementedError: + logger.exception(f'Exif failed reading orientation of image: {path}') return img transform = QtGui.QTransform() diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py index 0905b5e..a8077c6 100644 --- a/beeref/fileio/sql.py +++ b/beeref/fileio/sql.py @@ -261,6 +261,7 @@ class SQLiteIO: if self.worker.canceled: break self.delete_items(to_delete) + self.ex('VACUUM') self.connection.commit() if self.worker: self.worker.finished.emit(self.filename, []) diff --git a/beeref/main_controls.py b/beeref/main_controls.py index 4ab1ef5..f59cc8e 100644 --- a/beeref/main_controls.py +++ b/beeref/main_controls.py @@ -114,9 +114,10 @@ class MainControlsMixin: self.exit_movewin_mode() event.accept() return True - if (event.button() == Qt.MouseButton.LeftButton - and event.modifiers() == (Qt.KeyboardModifier.ControlModifier - | Qt.KeyboardModifier.AltModifier)): + + action, inverted =\ + self.control_target.keyboard_settings.mouse_action_for_event(event) + if action == 'movewindow': self.enter_movewin_mode() event.accept() return True diff --git a/beeref/scene.py b/beeref/scene.py index 4e40b33..91f2f28 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -45,20 +45,22 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): self.max_z = 0 self.min_z = 0 self.Z_STEP = 0.001 - self.multi_select_item = MultiSelectItem() - self.rubberband_item = RubberbandItem() self.selectionChanged.connect(self.on_selection_change) self.changed.connect(self.on_change) self.items_to_add = Queue() - self.internal_clipboard = [] self.edit_item = None self.crop_item = None self.settings = BeeSettings() + self.clear() + self._clear_ongoing = False def clear(self): + self._clear_ongoing = True super().clear() self.internal_clipboard = [] self.rubberband_item = RubberbandItem() + self.multi_select_item = MultiSelectItem() + self._clear_ongoing = False def addItem(self, item): logger.debug(f'Adding item {item}') @@ -452,6 +454,10 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): return (rect.topLeft() + rect.bottomRight()) / 2 def on_selection_change(self): + if self._clear_ongoing: + # Ignore events while clearing the scene since the + # multiselect item will get cleared, too + return if self.has_multi_selection(): self.multi_select_item.fit_selection_area( self.itemsBoundingRect(selection_only=True)) @@ -462,6 +468,10 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): self.removeItem(self.multi_select_item) def on_change(self, region): + if self._clear_ongoing: + # Ignore events while clearing the scene since the + # multiselect item will get cleared, too + return if (self.multi_select_item.scene() and self.multi_select_item.active_mode is None): self.multi_select_item.fit_selection_area( diff --git a/beeref/utils.py b/beeref/utils.py index 46920fd..cbf2268 100644 --- a/beeref/utils.py +++ b/beeref/utils.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . +from collections import OrderedDict import re from PyQt6 import QtCore, QtGui @@ -93,3 +94,16 @@ def qcolor_to_hex(color): rgb = color.name() alpha = hex(color.alpha()).removeprefix('0x') return f'{rgb}{alpha}' + + +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) diff --git a/beeref/view.py b/beeref/view.py index 5366e61..a266b17 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -23,7 +23,7 @@ from PyQt6.QtCore import Qt from beeref.actions import ActionsMixin, actions from beeref import commands -from beeref.config import CommandlineArgs, BeeSettings +from beeref.config import CommandlineArgs, BeeSettings, KeyboardSettings from beeref import constants from beeref import fileio from beeref.fileio.export import exporter_registry @@ -51,6 +51,7 @@ class BeeGraphicsView(MainControlsMixin, self.app = app self.parent = parent self.settings = BeeSettings() + self.keyboard_settings = KeyboardSettings() self.welcome_overlay = widgets.welcome_overlay.WelcomeOverlay(self) self.setBackgroundBrush( @@ -493,7 +494,7 @@ class BeeGraphicsView(MainControlsMixin, widgets.settings.SettingsDialog(self) def on_action_keyboard_settings(self): - widgets.settings.KeyboardSettingsDialog(self) + widgets.controls.ControlsDialog(self) def on_action_help(self): widgets.HelpDialog(self) @@ -540,6 +541,7 @@ class BeeGraphicsView(MainControlsMixin, def do_insert_images(self, filenames, pos=None): if not pos: pos = self.get_view_center() + self.scene.deselect_all_items() self.undo_stack.beginMacro('Insert Images') self.worker = fileio.ThreadedIO( fileio.load_images, @@ -748,17 +750,23 @@ class BeeGraphicsView(MainControlsMixin, self.reset_previous_transform() def wheelEvent(self, event): - if event.modifiers() == Qt.KeyboardModifier.NoModifier: - self.zoom(event.angleDelta().y(), event.position()) + action, inverted\ + = self.keyboard_settings.mousewheel_action_for_event(event) + + delta = event.angleDelta().y() + if inverted: + delta = delta * -1 + + if action == 'zoom': + self.zoom(delta, event.position()) event.accept() return - if event.modifiers() == (Qt.KeyboardModifier.ShiftModifier - | Qt.KeyboardModifier.ControlModifier): - self.pan(QtCore.QPointF(0, 0.5 * event.angleDelta().y())) + if action == 'pan_horizontal': + self.pan(QtCore.QPointF(0, 0.5 * delta)) event.accept() return - if event.modifiers() == Qt.KeyboardModifier.ShiftModifier: - self.pan(QtCore.QPointF(0.5 * event.angleDelta().y(), 0)) + if action == 'pan_vertical': + self.pan(QtCore.QPointF(0.5 * delta, 0)) event.accept() return @@ -784,17 +792,17 @@ class BeeGraphicsView(MainControlsMixin, event.accept() return - if (event.button() == Qt.MouseButton.MiddleButton - and event.modifiers() == Qt.KeyboardModifier.ControlModifier): + action, inverted = self.keyboard_settings.mouse_action_for_event(event) + + if action == 'zoom': self.active_mode = self.ZOOM_MODE self.event_start = event.position() self.event_anchor = event.position() + self.event_inverted = inverted event.accept() return - if (event.button() == Qt.MouseButton.MiddleButton - or (event.button() == Qt.MouseButton.LeftButton - and event.modifiers() == Qt.KeyboardModifier.AltModifier)): + if action == 'pan': logger.trace('Begin pan') self.active_mode = self.PAN_MODE self.event_start = event.position() @@ -820,6 +828,8 @@ class BeeGraphicsView(MainControlsMixin, self.reset_previous_transform() pos = event.position() delta = (self.event_start - pos).y() + if self.event_inverted: + delta *= -1 self.event_start = pos self.zoom(delta * 20, self.event_anchor) event.accept() diff --git a/beeref/widgets/__init__.py b/beeref/widgets/__init__.py index bcdf2ca..9000487 100644 --- a/beeref/widgets/__init__.py +++ b/beeref/widgets/__init__.py @@ -21,7 +21,12 @@ from PyQt6.QtCore import Qt from beeref import constants, commands from beeref.config import logfile_name -from beeref.widgets import settings, welcome_overlay, color_gamut # noqa: F401 +from beeref.widgets import ( # noqa: F401 + controls, + settings, + welcome_overlay, + color_gamut, +) logger = logging.getLogger(__name__) @@ -69,12 +74,12 @@ class HelpDialog(QtWidgets.QDialog): # Controls with open(os.path.join(docdir, 'controls.html')) as f: controls_txt = f.read() - controls = QtWidgets.QLabel(controls_txt) - controls.setTextInteractionFlags( + controls_label = QtWidgets.QLabel(controls_txt) + controls_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse) scroll = QtWidgets.QScrollArea(self) scroll.setWidgetResizable(True) - scroll.setWidget(controls) + scroll.setWidget(controls_label) tabs.addTab(scroll, '&Controls') layout = QtWidgets.QVBoxLayout() diff --git a/beeref/widgets/controls/__init__.py b/beeref/widgets/controls/__init__.py new file mode 100644 index 0000000..fb30cf6 --- /dev/null +++ b/beeref/widgets/controls/__init__.py @@ -0,0 +1,96 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + +import logging + +from PyQt6 import QtWidgets + +from beeref.config import KeyboardSettings +from beeref.widgets.controls.keyboard import KeyboardShortcutsView +from beeref.widgets.controls.mouse import MouseView +from beeref.widgets.controls.mousewheel import MouseWheelView + + +logger = logging.getLogger(__name__) + + +class ControlsDialog(QtWidgets.QDialog): + def __init__(self, parent): + super().__init__(parent) + self.setWindowTitle('Keyboard & Mouse Controls') + 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(table.model().setFilterFixedString) + kb_layout.addWidget(search_input) + kb_layout.addWidget(table) + tabs.addTab(keyboard, '&Keyboard Shortcuts') + + # Mouse controls + mouse = QtWidgets.QWidget(parent) + mouse_layout = QtWidgets.QVBoxLayout() + mouse.setLayout(mouse_layout) + table = MouseView(mouse) + search_input = QtWidgets.QLineEdit() + search_input.setPlaceholderText('Search...') + search_input.textChanged.connect(table.model().setFilterFixedString) + mouse_layout.addWidget(search_input) + mouse_layout.addWidget(table) + tabs.addTab(mouse, '&Mouse') + + # Mouse wheel controls + mousewheel = QtWidgets.QWidget(parent) + wheel_layout = QtWidgets.QVBoxLayout() + mousewheel.setLayout(wheel_layout) + table = MouseWheelView(mousewheel) + search_input = QtWidgets.QLineEdit() + search_input.setPlaceholderText('Search...') + search_input.textChanged.connect(table.model().setFilterFixedString) + wheel_layout.addWidget(search_input) + wheel_layout.addWidget(table) + tabs.addTab(mousewheel, 'Mouse &Wheel') + + 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 keyboard and mouse settings ' + 'to their default values?') + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + KeyboardSettings().restore_defaults() diff --git a/beeref/widgets/controls/common.py b/beeref/widgets/controls/common.py new file mode 100644 index 0000000..3dd35de --- /dev/null +++ b/beeref/widgets/controls/common.py @@ -0,0 +1,262 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + +from functools import partial + +from PyQt6 import QtWidgets, QtCore +from PyQt6.QtCore import Qt + +from beeref.config import KeyboardSettings +from beeref import constants + + +class MouseControlsEditorBase(QtWidgets.QDialog): + """Common code for MouseWheel and Mouse control editors.""" + + saved = QtCore.pyqtSignal() + + def init_dialog(self, parent, index, actions, title): + super().__init__(parent) + self.actions = actions + self.action = self.actions[index.row()] + self.setWindowTitle(f'title {self.action.text}') + self.old_modifiers = self.action.get_modifiers() + self.remove_from_other = None + self.ignore_on_changed = False + self.setAutoFillBackground(True) + self.layout = QtWidgets.QVBoxLayout() + self.setLayout(self.layout) + self.setModal(True) + + def init_modifiers_input(self): + group = QtWidgets.QGroupBox('Modifiers') + group_layout = QtWidgets.QVBoxLayout() + group.setLayout(group_layout) + self.layout.addWidget(group) + self.checkboxes = {} + for mod in self.action.MODIFIER_MAP.keys(): + checkbox = QtWidgets.QCheckBox(mod) + checkbox.setChecked(mod in self.old_modifiers) + checkbox.stateChanged.connect( + partial(self.on_modifiers_changed, mod)) + self.checkboxes[mod] = checkbox + group_layout.addWidget(checkbox) + + def init_button_row(self): + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Cancel + | QtWidgets.QDialogButtonBox.StandardButton.Ok) + buttons.accepted.connect(self.on_save) + buttons.rejected.connect(self.reject) + self.layout.addWidget(buttons) + + def set_modifiers_no_modifier(self): + """Check 'No Modifiers', uncheck everything else.""" + for key, checkbox in self.checkboxes.items(): + checkbox.setChecked(key == 'No Modifier') + + def on_modifiers_changed(self, modifier, value): + """Ensure that when 'No Modifiers' is checked, nothing else is + checked at the same time. + + If everything is unchecked, set 'No Modifiers' automatically. + """ + if self.ignore_on_changed: + return + + checked = value == Qt.CheckState.Checked.value + self.ignore_on_changed = True + + if checked and modifier == 'No Modifier': + self.set_modifiers_no_modifier() + + if checked and modifier != 'No Modifier': + self.checkboxes['No Modifier'].setChecked(False) + + if not checked and not self.get_modifiers(cleaned=False): + self.set_modifiers_no_modifier() + + self.ignore_on_changed = False + + def get_modifiers(self, cleaned=True): + modifiers = [key for key, checkbox in self.checkboxes.items() + if checkbox.isChecked()] + if cleaned and 'No Modifier' in modifiers: + # In this case the list already should only have the one + # entry, but just to make sure... + return ['No Modifier'] + return modifiers + + def set_modifiers(self, modifiers): + for key, checkbox in self.checkboxes.items(): + checkbox.setChecked(key in modifiers) + + def get_temp_action(self): + raise NotImplementedError # pragma: no cover + + def reset_inputs(self): + raise NotImplementedError # pragma: no cover + + def on_save(self): + """Don't let users save the same controls on different actions.""" + + temp = self.get_temp_action() + self.remove_from_other = None + for action in self.actions.values(): + if action == self.action: + continue + if action.conflicts_with(temp): + msg = ('

These controls are already used for:

' + f'

{action.text}

' + '

Do you want to remove the other controls' + ' to save these ones?

') + reply = QtWidgets.QMessageBox.question( + self, 'Save Controls?', msg) + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + self.remove_from_other = action + self.accept() + self.saved.emit() + else: + self.reset_inputs() + return + self.accept() + self.saved.emit() + + +class MouseControlsModelBase(QtCore.QAbstractTableModel): + COLUMNS = None + COL_ACTION = 1 + COL_CHANGED = 2 + COL_BUTTON = 3 + COL_MODIFIERS = 4 + COL_INVERTED = 5 + + HEADERS = { + COL_ACTION: 'Action', + COL_CHANGED: constants.CHANGED_SYMBOL, + COL_BUTTON: 'Button', + COL_MODIFIERS: 'Modifiers', + COL_INVERTED: 'Inverted', + } + + def __init__(self, actions): + super().__init__() + self.settings = KeyboardSettings() + self.actions = actions + + def rowCount(self, parent): + return len(self.actions) + + def columnCount(self, parent): + return len(self.COLUMNS) + + def headerData(self, section, orientation, role): + if (role == QtCore.Qt.ItemDataRole.DisplayRole + and orientation == QtCore.Qt.Orientation.Horizontal): + key = self.COLUMNS[section] + return self.HEADERS[key] + + def flags(self, index): + key = self.COLUMNS[index.column()] + base = (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + if key in (self.COL_ACTION, self.COL_CHANGED): + return base + elif key in (self.COL_BUTTON, self.COL_MODIFIERS): + return (base | QtCore.Qt.ItemFlag.ItemIsEditable) + elif key == self.COL_INVERTED: + action = self.actions[index.row()] + if action.invertible and action.is_configured(): + return (base + | QtCore.Qt.ItemFlag.ItemIsEditable + | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + else: + return base + + def data(self, index, role): + key = self.COLUMNS[index.column()] + action = self.actions[index.row()] + + if role in (QtCore.Qt.ItemDataRole.DisplayRole, + QtCore.Qt.ItemDataRole.EditRole): + if key == self.COL_ACTION: + return action.text + if key == self.COL_CHANGED and action.controls_changed(): + return constants.CHANGED_SYMBOL + if key == self.COL_BUTTON: + return action.get_button() + if key == self.COL_MODIFIERS: + return ' + '.join(action.get_modifiers()) + if key == self.COL_INVERTED: + if not action.is_configured() or not action.invertible: + return None + return 'Yes' if action.get_inverted() else 'No' + + if role == QtCore.Qt.ItemDataRole.ToolTipRole: + changed = action.controls_changed() + if not changed: + return + if key == self.COL_CHANGED: + return 'Changed from default' + if key == self.COL_BUTTON: + if action.button == 'Not Configured': + default = 'Not configured' + else: + default = action.button + return f'Default: {default}' + if key == self.COL_MODIFIERS: + if not action.modifiers: + default = 'Not configured' + else: + default = ' + '.join(action.modifiers) + return f'Default: {default}' + if key == self.COL_INVERTED and action.invertible: + default = 'Yes' if action.inverted else 'No' + return f'Default: {default}' + + if role == QtCore.Qt.ItemDataRole.CheckStateRole: + if (key == self.COL_INVERTED + and action.is_configured() + and action.invertible): + return (Qt.CheckState.Checked if action.get_inverted() + else Qt.CheckState.Unchecked) + + def set_data_on_action(self, action, value): + raise NotImplementedError # pragma: no cover + + def setData(self, index, value, role, remove_from_other=None): + key = self.COLUMNS[index.column()] + action = self.actions[index.row()] + if key == self.COL_INVERTED: + action.set_inverted( + True if value == Qt.CheckState.Checked.value else False) + else: + self.set_data_on_action(action, value) + if remove_from_other: + # These controls has conflicts with another action and the + # user chose to remove the other controls + remove_from_other.remove_controls() + row = list(self.actions.keys()).index(remove_from_other.id) + self.dataChanged.emit( + self.index(row, 0), + self.index(row, self.columnCount(None) - 1)) + + # Whole row might be affected, so excpliclity emit dataChanged + self.dataChanged.emit( + self.index(index.row(), 0), + self.index(index.row(), self.columnCount(None) - 1)) + + return True diff --git a/beeref/widgets/controls/keyboard.py b/beeref/widgets/controls/keyboard.py new file mode 100644 index 0000000..fd8e78f --- /dev/null +++ b/beeref/widgets/controls/keyboard.py @@ -0,0 +1,201 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + + +import logging + +from PyQt6 import QtWidgets, QtCore + +from beeref import constants +from beeref.actions.actions import actions +from beeref.config import KeyboardSettings, settings_events + + +logger = logging.getLogger(__name__) + + +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): + """Don't let users save the same shortcuts on different actions.""" + + 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', constants.CHANGED_SYMBOL, '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 constants.CHANGED_SYMBOL + 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 'Not set' + 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 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) + self.setAlternatingRowColors(True) + settings_events.restore_defaults.connect( + self.on_restore_defaults) + + def on_restore_defaults(self): + self.viewport().update() diff --git a/beeref/widgets/controls/mouse.py b/beeref/widgets/controls/mouse.py new file mode 100644 index 0000000..e36118f --- /dev/null +++ b/beeref/widgets/controls/mouse.py @@ -0,0 +1,170 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + +from functools import partial +import logging + +from PyQt6 import QtWidgets, QtCore + +from beeref.config import KeyboardSettings, settings_events +from beeref.config.controls import MouseConfig +from beeref.widgets.controls.common import ( + MouseControlsEditorBase, + MouseControlsModelBase, +) + + +logger = logging.getLogger(__name__) + + +class MouseControlsEditor(MouseControlsEditorBase): + + def __init__(self, parent, index): + self.init_dialog(parent, index, KeyboardSettings.MOUSE_ACTIONS, + 'Mouse Controls for:') + self.old_button = self.action.get_button() + + self.layout.addWidget(QtWidgets.QLabel('Mouse Button:')) + self.button_input = QtWidgets.QComboBox(parent=parent) + self.button_input.insertItems(0, self.action.BUTTON_MAP.keys()) + values = list(self.action.BUTTON_MAP.keys()) + self.button_input.setCurrentIndex(values.index(self.old_button)) + self.layout.addWidget(self.button_input) + + self.init_modifiers_input() + self.init_button_row() + self.on_button_changed() + self.button_input.currentIndexChanged.connect(self.on_button_changed) + self.show() + + def on_button_changed(self): + """Disable modifier inputs when no button configured; enable + otherwise. + """ + self.ignore_on_changed = True + if self.get_button() == 'Not Configured': + for key, checkbox in self.checkboxes.items(): + checkbox.setChecked(False) + self.set_modifiers_enabled(False) + else: + if not self.get_modifiers(cleaned=False): + self.set_modifiers_no_modifier() + self.set_modifiers_enabled(True) + + self.ignore_on_changed = False + + def set_modifiers_enabled(self, enabled): + for key, checkbox in self.checkboxes.items(): + checkbox.setEnabled(enabled) + + def get_button(self): + values = list(self.action.BUTTON_MAP.keys()) + return values[self.button_input.currentIndex()] + + def set_button(self, value): + values = list(self.action.BUTTON_MAP.keys()) + self.button_input.setCurrentIndex(values.index(value)) + + def get_modifiers(self, cleaned=True): + if cleaned and self.get_button() == 'Not Configured': + # In this case the list should already be empty but just + # to make sure... + return [] + return super().get_modifiers(cleaned=True) + + def get_temp_action(self): + return MouseConfig(button=self.get_button(), + modifiers=self.get_modifiers(), + group=None, text=None, invertible=None, id=None) + + def reset_inputs(self): + self.set_button(self.old_button) + self.set_modifiers(self.old_modifiers) + + +class MouseDelegate(QtWidgets.QStyledItemDelegate): + + def createEditor(self, parent, option, index): + widget = QtWidgets.QWidget(parent) + widget.editor = MouseControlsEditor(widget, index) + widget.editor.saved.connect( + partial(self.setModelData, widget, index.model(), index)) + return widget + + def setModelData(self, editor, model, index): + editor = editor.editor + if editor.result() == QtWidgets.QDialog.DialogCode.Accepted: + model.setData( + index, + {'button': editor.get_button(), + 'modifiers': editor.get_modifiers()}, + QtCore.Qt.ItemDataRole.EditRole, + remove_from_other=editor.remove_from_other) + + +class MouseModel(MouseControlsModelBase): + """An entry in the keyboard shortcuts table.""" + + COLUMNS = (MouseControlsModelBase.COL_ACTION, + MouseControlsModelBase.COL_CHANGED, + MouseControlsModelBase.COL_BUTTON, + MouseControlsModelBase.COL_MODIFIERS, + MouseControlsModelBase.COL_INVERTED) + + def __init__(self): + super().__init__(KeyboardSettings.MOUSE_ACTIONS) + + def set_data_on_action(self, action, value): + action.set_button(value['button']) + action.set_modifiers(value['modifiers']) + + +class MouseProxy(QtCore.QSortFilterProxyModel): + + def __init__(self): + super().__init__() + self.setSourceModel(MouseModel()) + self.setFilterCaseSensitivity( + QtCore.Qt.CaseSensitivity.CaseInsensitive) + + 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 MouseView(QtWidgets.QTableView): + + def __init__(self, parent): + super().__init__(parent) + self.setMinimumSize(QtCore.QSize(400, 200)) + self.setItemDelegate(MouseDelegate()) + self.setShowGrid(False) + self.setModel(MouseProxy()) + self.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.Stretch) + self.horizontalHeader().setSectionResizeMode( + 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + self.setSelectionMode( + QtWidgets.QHeaderView.SelectionMode.SingleSelection) + self.setAlternatingRowColors(True) + settings_events.restore_defaults.connect( + self.on_restore_defaults) + + def on_restore_defaults(self): + self.viewport().update() diff --git a/beeref/widgets/controls/mousewheel.py b/beeref/widgets/controls/mousewheel.py new file mode 100644 index 0000000..0aada0c --- /dev/null +++ b/beeref/widgets/controls/mousewheel.py @@ -0,0 +1,120 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + +from functools import partial +import logging + +from PyQt6 import QtWidgets, QtCore + +from beeref.config import KeyboardSettings, settings_events +from beeref.config.controls import MouseWheelConfig +from beeref.widgets.controls.common import ( + MouseControlsEditorBase, + MouseControlsModelBase, +) + + +logger = logging.getLogger(__name__) + + +class MouseWheelModifiersEditor(MouseControlsEditorBase): + + def __init__(self, parent, index): + self.init_dialog(parent, index, KeyboardSettings.MOUSEWHEEL_ACTIONS, + 'MouseWheel Controls for:') + self.init_modifiers_input() + self.init_button_row() + self.show() + + def get_temp_action(self): + return MouseWheelConfig( + modifiers=self.get_modifiers(), + group=None, text=None, invertible=None, id=None) + + def reset_inputs(self): + self.set_modifiers(self.old_modifiers) + + +class MouseWheelDelegate(QtWidgets.QStyledItemDelegate): + + def createEditor(self, parent, option, index): + widget = QtWidgets.QWidget(parent) + widget.editor = MouseWheelModifiersEditor(widget, index) + widget.editor.saved.connect( + partial(self.setModelData, widget, index.model(), index)) + return widget + + def setModelData(self, editor, model, index): + editor = editor.editor + if editor.result() == QtWidgets.QDialog.DialogCode.Accepted: + model.setData( + index, + editor.get_modifiers(), + QtCore.Qt.ItemDataRole.EditRole, + remove_from_other=editor.remove_from_other) + + +class MouseWheelModel(MouseControlsModelBase): + """An entry in the keyboard shortcuts table.""" + + COLUMNS = (MouseControlsModelBase.COL_ACTION, + MouseControlsModelBase.COL_CHANGED, + MouseControlsModelBase.COL_MODIFIERS, + MouseControlsModelBase.COL_INVERTED) + + def __init__(self): + super().__init__(KeyboardSettings.MOUSEWHEEL_ACTIONS) + + def set_data_on_action(self, action, value): + action.set_modifiers(value) + + +class MouseWheelProxy(QtCore.QSortFilterProxyModel): + + def __init__(self): + super().__init__() + self.setSourceModel(MouseWheelModel()) + self.setFilterCaseSensitivity( + QtCore.Qt.CaseSensitivity.CaseInsensitive) + + 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 MouseWheelView(QtWidgets.QTableView): + + def __init__(self, parent): + super().__init__(parent) + self.setMinimumSize(QtCore.QSize(400, 200)) + self.setItemDelegate(MouseWheelDelegate()) + self.setShowGrid(False) + self.setModel(MouseWheelProxy()) + self.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.Stretch) + self.horizontalHeader().setSectionResizeMode( + 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + self.setSelectionMode( + QtWidgets.QHeaderView.SelectionMode.SingleSelection) + self.setAlternatingRowColors(True) + settings_events.restore_defaults.connect( + self.on_restore_defaults) + + def on_restore_defaults(self): + self.viewport().update() diff --git a/beeref/widgets/settings.py b/beeref/widgets/settings.py index b20dc06..e36f09e 100644 --- a/beeref/widgets/settings.py +++ b/beeref/widgets/settings.py @@ -16,19 +16,15 @@ from functools import partial import logging -from PyQt6 import QtWidgets, QtCore +from PyQt6 import QtWidgets from beeref import constants -from beeref.actions.actions import actions -from beeref.config import BeeSettings, KeyboardSettings, settings_events +from beeref.config import BeeSettings, settings_events logger = logging.getLogger(__name__) -CHANGED_SYMBOL = '✎' - - class GroupBase(QtWidgets.QGroupBox): def __init__(self): @@ -47,7 +43,7 @@ class GroupBase(QtWidgets.QGroupBox): def update_title(self): title = [self.TITLE] if self.settings.value_changed(self.KEY): - title.append(CHANGED_SYMBOL) + title.append(constants.CHANGED_SYMBOL) self.setTitle(' '.join(title)) def on_value_changed(self, value): @@ -178,222 +174,3 @@ 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 CHANGED_SYMBOL - 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 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) - self.setAlternatingRowColors(True) - settings_events.restore_keyboard_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.cfg b/setup.cfg index e008106..0605e76 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,9 @@ +[flake8] +exclude = squashfs-root + [coverage:run] source = beeref [tool:pytest] +norecursedirs = squashfs-root addopts = --cov-report html --cov-config=setup.cfg \ No newline at end of file diff --git a/setup.py b/setup.py index 9951ace..e74cce7 100644 --- a/setup.py +++ b/setup.py @@ -19,9 +19,11 @@ setup( 'beeref', 'beeref.actions', 'beeref.assets', + 'beeref.config', 'beeref.documentation', 'beeref.fileio', 'beeref.widgets', + 'beeref.widgets.controls', ], entry_points={ 'gui_scripts': [ diff --git a/tests/actions/test_actions.py b/tests/actions/test_actions.py index bf0d8ea..e02899a 100644 --- a/tests/actions/test_actions.py +++ b/tests/actions/test_actions.py @@ -2,30 +2,35 @@ from unittest.mock import patch from PyQt6 import QtGui -from beeref.actions.actions import Action, ActionList +from beeref.actions.actions import Action + + +def test_action_str(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+R']) + assert str(action) == 'foo' def test_action_equals_true(): - action1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+R']}) - action2 = Action({'id': 'foo', 'text': 'Bar', 'shortcuts': ['Ctrl+F']}) + 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']}) + action1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+R']) + action2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+R']) assert not action1 == action2 def test_action_on_restore_defaults(kbsettings, view): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+R']}) + action = Action(id='foo', text='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 = Action(id='foo', text='Foo') action.qaction = QtGui.QAction('foo', view) action.qaction.setShortcuts(['Ctrl+R']) action.on_restore_defaults() @@ -33,105 +38,105 @@ def test_action_on_restore_defaults_when_no_defaults(kbsettings, view): def test_action_get_overwritten_shortcuts(kbsettings): - kbsettings.set_shortcuts('Actions', 'foo', ['Alt+O']) - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + kbsettings.set_list('Actions', 'foo', ['Alt+O']) + action = Action(id='foo', text='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']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) assert action.get_shortcuts() == ['Ctrl+F'] def test_action_set_shortcuts_when_no_qaction(kbsettings): - action = Action({'id': 'foo'}) + action = Action(id='foo', text='Foo') action.qaction = None action.set_shortcuts(['Ctrl+F']) - assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F'] + assert kbsettings.get_list('Actions', 'foo') == ['Ctrl+F'] def test_action_set_shortcuts_when_qaction(kbsettings, view): - action = Action({'id': 'foo'}) + action = Action(id='foo', text='Foo') action.qaction = QtGui.QAction('foo', view) action.set_shortcuts(['Ctrl+F']) - assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F'] + assert kbsettings.get_list('Actions', 'foo') == ['Ctrl+F'] assert action.qaction.shortcuts() == ['Ctrl+F'] def test_action_get_qkeysequence_first(kbsettings): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + action = Action(id='foo', text='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']}) + action = Action(id='foo', text='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'}) + action = Action(id='foo', text='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']}) + action = Action(id='foo', text='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']}) + action = Action(id='foo', text='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']) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + kbsettings.set_list('Actions', 'foo', ['Ctrl+B']) assert action.shortcuts_changed() is True def test_action_shortcuts_changed_when_empty(kbsettings): - action = Action({'id': 'foo'}) + action = Action(id='foo', text='Foo') assert action.shortcuts_changed() is False def test_action_get_default_shortcut_first(): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + action = Action(id='foo', text='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']}) + action = Action(id='foo', text='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'}) + action = Action(id='foo', text='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']}) + action = Action(id='foo', text='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'}) + action = Action(id='baz', text='Foo') 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'}) + action = Action(id='baz', text='Foo') 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'}) + action = Action(id='baz', text='Foo', menu_id='_build_recent_files') assert action.menu_path == ['Foo'] @@ -139,21 +144,5 @@ def test_action_menu_path_recent_files(): [{'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'}) + action = Action(id='baz', text='Foo', 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 50b5e54..ef400b6 100644 --- a/tests/actions/test_mixin.py +++ b/tests/actions/test_mixin.py @@ -4,8 +4,9 @@ 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.actions import Action from beeref.actions.menu_structure import MENU_SEPARATOR +from beeref.utils import ActionList class FooWidget(QtWidgets.QWidget, ActionsMixin): @@ -28,13 +29,13 @@ class FooWidget(QtWidgets.QWidget, ActionsMixin): @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') + ActionList([Action( + id='foo', + text='&Foo', + shortcuts=['Ctrl+F'], + callback='on_foo', + )])) +@patch('beeref.config.KeyboardSettings.get_list') def test_create_actions(kb_mock, toggle_mock, trigger_mock, qapp): kb_mock.side_effect = lambda group, key, default: default widget = FooWidget() @@ -56,15 +57,15 @@ def test_create_actions(kb_mock, toggle_mock, trigger_mock, qapp): [{'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'})])): + 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']) + kbsettings.set_list('Actions', 'foo', ['Alt+O']) widget = FooWidget() widget.build_menu_and_actions() qaction = widget.actions()[0] @@ -76,12 +77,12 @@ def test_create_actions_with_shortcut_from_settings(qapp, kbsettings): @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', - })])) + ActionList([Action( + id='foo', + text='&Foo', + checkable=True, + callback='on_foo', + )])) def test_create_actions_checkable(toggle_mock, trigger_mock, qapp): widget = FooWidget() widget.build_menu_and_actions() @@ -100,13 +101,13 @@ def test_create_actions_checkable(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, - 'checked': True, - 'callback': 'on_foo', - })])) + ActionList([Action( + id='foo', + text='&Foo', + checkable=True, + checked=True, + callback='on_foo', + )])) def test_create_actions_checkable_checked_true( toggle_mock, trigger_mock, qapp): widget = FooWidget() @@ -127,13 +128,13 @@ def test_create_actions_checkable_checked_true( @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', - })])) + ActionList([Action( + id='foo', + text='&Foo', + checkable=True, + settings='foo/bar', + callback='on_foo', + )])) def test_create_actions_checkable_with_settings( toggle_mock, settings_mock, callback_mock, qapp): widget = FooWidget() @@ -150,12 +151,12 @@ def test_create_actions_checkable_with_settings( @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', - })])) + ActionList([Action( + id='foo', + text='&Foo', + callback='on_foo', + group='bar', + )])) def test_create_actions_with_group(qapp): widget = FooWidget() widget.build_menu_and_actions() @@ -167,11 +168,11 @@ def test_create_actions_with_group(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', - })])) + ActionList([Action( + id='foo', + text='&Foo', + callback='on_foo', + )])) def test_build_menu_and_actions_with_actions(qapp): widget = FooWidget() with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock: @@ -195,11 +196,11 @@ def test_build_menu_and_actions_with_separator(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', - })])) + ActionList([Action( + id='foo', + text='&Foo', + callback='on_foo', + )])) def test_build_menu_and_actions_with_submenu(qapp): widget = FooWidget() with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock: @@ -216,18 +217,18 @@ def test_build_menu_and_actions_with_submenu(qapp): [{'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', - }), + 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() @@ -241,12 +242,12 @@ def test_actiongroup_set_enabled(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', - })])) + 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 @@ -256,7 +257,7 @@ def test_build_menu_and_actions_disables_actiongroups(qapp): @patch('PyQt6.QtGui.QAction.triggered') -@patch('beeref.config.KeyboardSettings.get_shortcuts') +@patch('beeref.config.KeyboardSettings.get_list') @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': '_build_recent_files'}]) @patch('beeref.actions.mixin.actions', ActionList([])) @@ -294,7 +295,7 @@ def test_create_recent_files_more_than_10_files( @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': '_build_recent_files'}]) @patch('beeref.actions.mixin.actions', ActionList([])) -@patch('beeref.config.KeyboardSettings.get_shortcuts') +@patch('beeref.config.KeyboardSettings.get_list') def test_create_recent_files_fewer_files_than_10_files( kb_mock, triggered_mock, qapp): kb_mock.side_effect = lambda group, key, default: default @@ -329,7 +330,7 @@ def test_create_recent_files_fewer_files_than_10_files( @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': '_build_recent_files'}]) @patch('beeref.actions.mixin.actions', ActionList([])) -@patch('beeref.config.KeyboardSettings.get_shortcuts') +@patch('beeref.config.KeyboardSettings.get_list') def test_create_recent_files_when_no_files(kb_mock, qapp): kb_mock.side_effect = lambda group, key, default: default widget = FooWidget() diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config/test_controls.py b/tests/config/test_controls.py new file mode 100644 index 0000000..c48a5a1 --- /dev/null +++ b/tests/config/test_controls.py @@ -0,0 +1,510 @@ +from unittest.mock import patch, MagicMock + +from PyQt6.QtCore import Qt + +from beeref.config.controls import ( + KeyboardSettings, + MouseConfig, + MouseWheelConfig, +) + + +def test_mousewheelconfig_eq(): + action1 = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + action2 = MouseWheelConfig( + id='foo', group='foobar', text='Baz', modifiers=[], invertible=False) + assert action1 == action2 + + +def test_mousewheelconfig_str(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + assert str(action) == 'foo' + + +def test_mousewheelconfig_kb_settings(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + assert isinstance(action.kb_settings, KeyboardSettings) + + +def test_mousewheelconfig_get_modifiers(kbsettings): + kbsettings.set_list('MouseWheel', 'foo_modifiers', ['Ctrl', 'Shift']) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + assert action.get_modifiers() == ['Ctrl', 'Shift'] + + +def test_mousewheelconfig_get_modifiers_default(kbsettings): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=False) + assert action.get_modifiers() == ['Shift'] + + +def test_mousewheelconfig_set_modifiers(kbsettings): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + action.set_modifiers(['Shift']) + assert kbsettings.get_list('MouseWheel', 'foo_modifiers') == ['Shift'] + + +def test_mousewheelconfig_set_modifiers_default(kbsettings): + kbsettings.set_list('MouseWheel', 'foo_modifiers', ['Alt', 'Ctrl']) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', + modifiers=['Shift'], invertible=False) + action.set_modifiers(['Shift']) + assert kbsettings.value('MouseWheel/foo_modifiers') is None + + +def test_mousewheelconfig_get_inverted(kbsettings): + kbsettings.set_value('MouseWheel', 'foo_inverted', True) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + assert action.get_inverted() is True + + +def test_mousewheelconfig_get_inverted_default(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + assert action.get_inverted() is False + + +def test_mousewheelconfig_set_inverted(kbsettings): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + action.set_inverted(True) + assert kbsettings.get_value('MouseWheel', 'foo_inverted') is True + + +def test_mousewheelconfig_set_inverted_default(kbsettings): + kbsettings.set_value('MouseWheel', 'foo_inverted', True) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + action.set_inverted(False) + assert kbsettings.value('MouseWheel/foo_inverted') is None + + +def test_mousewheelconfig_modifiers_to_qt_multiple(): + assert MouseWheelConfig.modifiers_to_qt(['Shift', 'Ctrl']) == ( + Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier) + + +def test_mousewheelconfig_modifiers_to_single(): + assert MouseWheelConfig.modifiers_to_qt(['Alt']) ==\ + Qt.KeyboardModifier.AltModifier + + +def test_mousewheelconfig_controls_changed_not_changed(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + assert action.controls_changed() is False + + +def test_mousewheelconfig_controls_changed_modifiers_changed(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action.set_modifiers(['Alt']) + assert action.controls_changed() is True + + +def test_mousewheelconfig_controls_changed_inverted_changed(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action.set_inverted(True) + assert action.controls_changed() is True + + +def test_mousewheelconfig_controls_is_configured_true(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['No Modifiers'], + invertible=True) + assert action.is_configured() is True + + +def test_mousewheelconfig_controls_is_configured_false(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + assert action.is_configured() is False + + +def test_mousewheelconfig_controls_remove_controls(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action.set_inverted(True) + action.remove_controls() + assert action.get_modifiers() == [] + assert action.get_inverted() is False + + +def test_mousewheelconfig_conflicts_with_true(): + action1 = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action2 = MouseWheelConfig( + id='bar', group='foobar', text='Bar', modifiers=['Shift'], + invertible=True) + assert action1.conflicts_with(action2) is True + + +def test_mousewheelconfig_conflicts_with_false(): + action1 = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action2 = MouseWheelConfig( + id='bar', group='foobar', text='Bar', modifiers=['Shift', 'Ctrl'], + invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mousewheelconfig_conflicts_false_when_both_not_configured(): + action1 = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], + invertible=True) + action2 = MouseWheelConfig( + id='bar', group='foobar', text='Bar', modifiers=[], + invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mousewheelconfig_matches_event_true(): + event = MagicMock( + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Alt'], + invertible=True) + assert action.matches_event(event) is True + + +def test_mousewheelconfig_matches_event_false(): + event = MagicMock( + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Ctrl'], + invertible=True) + assert action.matches_event(event) is False + + +def test_mousewheelconfig_matches_event_false_when_not_configured(): + event = MagicMock( + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], + invertible=True) + assert action.matches_event(event) is False + + +def test_mouseconfig_get_button(kbsettings): + kbsettings.set_value('Mouse', 'foo_button', 'Left') + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', modifiers=[], + invertible=False) + assert action.get_button() == 'Left' + + +def test_mouseconfig_get_button_default(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', modifiers=[], + invertible=False) + assert action.get_button() == 'Left' + + +def test_mouseconfig_set_button(kbsettings): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', modifiers=[], + invertible=False) + action.set_button('Middle') + assert kbsettings.get_value('Mouse', 'foo_button') == 'Middle' + + +def test_mouseconfig_set_button_default(kbsettings): + kbsettings.set_value('Mouse', 'foo_button', 'Middle') + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', modifiers=[], + invertible=False) + action.set_button('Middle') + assert kbsettings.value('Mouse/foo_button') is None + + +def test_mouseconfig_conflicts_with_true(): + action1 = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', + modifiers=['Shift'], invertible=True) + action2 = MouseConfig( + id='bar', group='foobar', text='Bar', button='Left', + modifiers=['Shift'], invertible=True) + assert action1.conflicts_with(action2) is True + + +def test_mouseconfig_conflicts_with_false_when_diff_buttons(): + action1 = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', + modifiers=['Shift'], invertible=True) + action2 = MouseConfig( + id='bar', group='foobar', text='Bar', button='Middle', + modifiers=['Shift'], invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mouseconfig_conflicts_with_false_when_diff_modifiers(): + action1 = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action2 = MouseConfig( + id='bar', group='foobar', text='Bar', button='Middle', + modifiers=['Shift', 'Ctrl'], invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mouseconfig_conflicts_false_when_both_not_configured(): + action1 = MouseConfig( + id='foo', group='foobar', text='Foo', button='Not Configured', + modifiers=[], invertible=True) + action2 = MouseConfig( + id='bar', group='foobar', text='Bar', button='Not Configured', + modifiers=[], invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mouseconfig_is_configured_false(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=[], invertible=True) + action.set_button('Not Configured') + assert action.conflicts_with(action) is False + + +def test_mouseconfig_is_configured_true(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Not Configured', + modifiers=[], invertible=True) + action.set_button('Left') + assert action.conflicts_with(action) is True + + +def test_mouseconfig_controls_changed_false(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.controls_changed() is False + + +def test_mouseconfig_controls_changed_true_when_button_changed(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.set_button('Left') + action.controls_changed() is True + + +def test_mouseconfig_controls_changed_true_when_modifiers_changed(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.set_modifiers(['Shift', 'Ctrl']) + action.controls_changed() is True + + +def test_mouseconfig_controls_changed_true_when_inverted_changed(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.set_inverted(True) + action.controls_changed() is True + + +def test_mouseconfig_remove_controls(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.set_inverted(True) + + action.remove_controls() + assert action.get_button() == 'Not Configured' + assert action.get_modifiers() == [] + assert action.get_inverted() is False + + +def test_mouseconfig_matches_event_true(): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.LeftButton), + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', + modifiers=['Alt'], invertible=True) + assert action.matches_event(event) is True + + +def test_mouseconfig_matches_event_false_when_diff_button(): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.LeftButton), + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Alt'], invertible=True) + assert action.matches_event(event) is False + + +def test_mouseconfig_matches_event_false_when_diff_modifiers(): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.LeftButton), + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', + modifiers=['Ctrl'], invertible=True) + assert action.matches_event(event) is False + + +def test_mouseconfig_matches_event_false_when_not_configured(): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.LeftButton), + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Not Configured', + modifiers=[], invertible=True) + assert action.matches_event(event) is False + + +def test_keyboardsettings_set_value(kbsettings): + kbsettings.set_value('mygroup', 'foo', 'bar') + assert kbsettings.value('mygroup/foo', 'bar') + + +def test_keyboardsettings_set_value_default_value(kbsettings): + kbsettings.setValue('mygroup/foo', 'bar') + kbsettings.set_value('mygroup', 'foo', 'baz', 'baz') + assert kbsettings.value('mygroup/foo') is None + + +def test_keyboardsettings_get_value_existing(kbsettings): + kbsettings.set_value('mygroup', 'bar', 'foo') + value = kbsettings.get_value('mygroup', 'bar', 'baz') + assert value == 'foo' + + +def test_keyboardsettings_get_value_default(kbsettings): + assert kbsettings.get_value('mygroup', 'bar', 'baz') == 'baz' + + +def test_keyboardsettings_set_list(kbsettings): + kbsettings.set_list('mygroup', 'foo', ['Ctrl+F']) + assert kbsettings.get_list('mygroup', 'foo') == ['Ctrl+F'] + + +def test_keyboardsettings_set_list_multiple(kbsettings): + kbsettings.set_list('mygroup', 'foo', ['Ctrl+F', 'Alt+O']) + assert kbsettings.get_list('mygroup', 'foo') == ['Ctrl+F', 'Alt+O'] + + +def test_keyboardsettings_set_list_default_value(kbsettings): + kbsettings.setValue('mygroup/foo', 'Ctrl+F') + kbsettings.set_list('mygroup', 'foo', 'Ctrl+B', 'Ctrl+B') + assert kbsettings.value('mygroup/foo') is None + + +def test_keyboardsettings_get_list_existing(kbsettings): + kbsettings.set_list('mygroup', 'bar', ['Ctrl+R']) + shortcuts = kbsettings.get_list('mygroup', 'bar', ['Ctrl+B']) + assert shortcuts == ['Ctrl+R'] + + +def test_keyboardsettings_get_list_existing_empty_list(kbsettings): + kbsettings.set_list('mygroup', 'bar', []) + shortcuts = kbsettings.get_list('mygroup', 'bar', ['Ctrl+B']) + assert shortcuts == [] + + +def test_keyboardsettings_get_list_default(kbsettings): + shortcuts = kbsettings.get_list('mygroup', 'bar', ['Ctrl+B']) + assert shortcuts == ['Ctrl+B'] + + +@patch('beeref.config.KeyboardSettings.setValue') +@patch('beeref.config.KeyboardSettings.remove') +def test_keyboardsettings_set_list_other_than_default_saves( + remove_mock, set_mock, kbsettings): + kbsettings.set_list('mygroup', 'bar', ['Ctrl+R'], ['Ctrl+Z']) + set_mock.assert_called_once_with('mygroup/bar', 'Ctrl+R') + remove_mock.assert_not_called() + + +@patch('beeref.config.KeyboardSettings.setValue') +@patch('beeref.config.KeyboardSettings.remove') +def test_keyboardsettings_set_list_with_than_default_doesnt_save( + remove_mock, set_mock, kbsettings): + kbsettings.set_list('mygroup', 'bar', ['Ctrl+R'], ['Ctrl+R']) + set_mock.assert_not_called() + remove_mock.assert_called_once_with('mygroup/bar') + + +@patch('PyQt6.QtGui.QAction.setShortcuts') +def test_keyboardsettings_restore_defaults_restores(shortcut_mock, kbsettings): + kbsettings.setValue('Actions/bar', 'Ctrl+R') + kbsettings.restore_defaults() + assert kbsettings.contains('Actions/bar') is False + + +def test_keyboardsettings_mousewheel_action_for_event_finds(kbsettings): + action = kbsettings.MOUSEWHEEL_ACTIONS['zoom1'] + action.set_modifiers(['Shift', 'Ctrl', 'Alt']) + action.set_inverted(True) + + event = MagicMock( + modifiers=MagicMock( + return_value=(Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier))) + + group, inverted = kbsettings.mousewheel_action_for_event(event) + assert group == 'zoom' + assert inverted is True + + +def test_keyboardsettings_mousewheel_action_for_event_empty(kbsettings): + event = MagicMock( + modifiers=MagicMock( + return_value=(Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier))) + + group, inverted = kbsettings.mousewheel_action_for_event(event) + assert group is None + assert inverted is None + + +def test_keyboardsettings_mouse_action_for_event_finds(kbsettings): + action = kbsettings.MOUSE_ACTIONS['zoom1'] + action.set_button('Middle') + action.set_modifiers(['Shift', 'Ctrl', 'Alt']) + action.set_inverted(True) + + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.MiddleButton), + modifiers=MagicMock( + return_value=(Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier))) + + group, inverted = kbsettings.mouse_action_for_event(event) + assert group == 'zoom' + assert inverted is True + + +def test_keyboardsettings_mouse_action_for_event_empty(kbsettings): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.MiddleButton), + modifiers=MagicMock( + return_value=(Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier))) + + group, inverted = kbsettings.mouse_action_for_event(event) + assert group is None + assert inverted is None diff --git a/tests/test_config.py b/tests/config/test_settings.py similarity index 67% rename from tests/test_config.py rename to tests/config/test_settings.py index d40da3f..a78625b 100644 --- a/tests/test_config.py +++ b/tests/config/test_settings.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from beeref.config import CommandlineArgs +from beeref.config.settings import CommandlineArgs def test_command_line_args_singleton(): @@ -13,7 +13,7 @@ def test_command_line_args_singleton(): CommandlineArgs._instance = None -@patch('beeref.config.parser.parse_args') +@patch('beeref.config.settings.parser.parse_args') def test_command_line_args_with_check_forces_new_parsing(parse_mock): args1 = CommandlineArgs() args2 = CommandlineArgs(with_check=True) @@ -117,48 +117,3 @@ def test_settings_recent_files_update_respects_max_num(settings): assert len(recent) == 10 assert recent[0] == os.path.abspath('14.bee') assert recent[-1] == os.path.abspath('5.bee') - - -def test_keyboardsettings_set_shortcuts(kbsettings): - kbsettings.set_shortcuts('Actions', 'foo', ['Ctrl+F']) - assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F'] - - -def test_keyboardsettings_set_shortcuts_multiple(kbsettings): - kbsettings.set_shortcuts('Actions', 'foo', ['Ctrl+F', 'Alt+O']) - assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F', 'Alt+O'] - - -def test_keyboardsettings_get_shortcuts_existing(kbsettings): - kbsettings.set_shortcuts('Actions', 'bar', ['Ctrl+R']) - shortcuts = kbsettings.get_shortcuts('Actions', 'bar', ['Ctrl+B']) - assert shortcuts == ['Ctrl+R'] - - -def test_keyboardsettings_get_shortcuts_default(kbsettings): - shortcuts = kbsettings.get_shortcuts('Actions', 'bar', ['Ctrl+B']) - assert shortcuts == ['Ctrl+B'] - - -@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() - - -@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/fileio/test_image.py b/tests/fileio/test_image.py index f309217..432a55f 100644 --- a/tests/fileio/test_image.py +++ b/tests/fileio/test_image.py @@ -29,6 +29,13 @@ def test_exif_rotated_image_exif_unpack_error(qapp, imgfilename3x3): assert img.isNull() is False +def test_exif_rotated_image_exif_notimplementederror(qapp, imgfilename3x3): + with patch('beeref.fileio.image.exif.Image.list_all', + side_effect=NotImplementedError()): + img = exif_rotated_image(imgfilename3x3) + assert img.isNull() is False + + @pytest.mark.parametrize('path,expected', [('test3x3.png', 'test3x3.png'), ('test3x3_orientation1.jpg', 'test3x3.jpg'), diff --git a/tests/test_utils.py b/tests/test_utils.py index 4d26161..ebd9492 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,6 +2,7 @@ import pytest from PyQt6 import QtCore, QtGui +from beeref.actions.actions import Action from beeref import utils @@ -78,3 +79,19 @@ def test_get_file_extension_from_format(formatstr, expected): ((255, 0, 0, 100), '#ff000064')]) def test_qcolor_to_hex(rgba, expected): assert utils.qcolor_to_hex(QtGui.QColor(*rgba)) == expected + + +def test_actionlist_inits_dict(): + action1 = Action(id='foo', text='Foo') + action2 = Action(id='bar', text='Bar') + actionlist = utils.ActionList([action1, action2]) + actionlist['foo'] == action1 + actionlist['bar'] == action2 + + +def test_actionlist_acts_as_list(): + action1 = Action(id='foo', text='Foo') + action2 = Action(id='bar', text='Bar') + actionlist = utils.ActionList([action1, action2]) + actionlist[0] == action1 + actionlist[1] == action2 diff --git a/tests/test_view.py b/tests/test_view.py index 1d4434c..3bee979 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -376,7 +376,7 @@ def test_on_action_settings(show_mock, view): show_mock.assert_called_once() -@patch('beeref.widgets.settings.KeyboardSettingsDialog.show') +@patch('beeref.widgets.controls.ControlsDialog.show') def test_on_action_keyboard_settings(show_mock, view): view.on_action_keyboard_settings() show_mock.assert_called_once() @@ -940,6 +940,19 @@ def test_wheel_event_zoom(zoom_mock, view): event.accept.assert_called_once_with() +@patch('beeref.view.BeeGraphicsView.zoom') +def test_wheel_event_zoom_custom_inverted(zoom_mock, view, kbsettings): + kbsettings.MOUSEWHEEL_ACTIONS['zoom2'].set_modifiers(['Alt']) + kbsettings.MOUSEWHEEL_ACTIONS['zoom2'].set_inverted(True) + event = MagicMock() + event.angleDelta.return_value = QtCore.QPointF(0.0, 40.0) + event.position.return_value = QtCore.QPointF(10.0, 20.0) + event.modifiers.return_value = Qt.KeyboardModifier.AltModifier + view.wheelEvent(event) + zoom_mock.assert_called_once_with(-40, QtCore.QPointF(10.0, 20.0)) + event.accept.assert_called_once_with() + + @patch('beeref.view.BeeGraphicsView.pan') def test_wheel_event_pan_vertically(pan_mock, view): event = MagicMock() @@ -948,7 +961,21 @@ def test_wheel_event_pan_vertically(pan_mock, view): event.modifiers.return_value = (Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier) view.wheelEvent(event) - pan_mock.assert_called_once_with(QtCore.QPointF(0, 20)) + pan_mock.assert_called_once_with(QtCore.QPointF(20, 0)) + event.accept.assert_called_once_with() + + +@patch('beeref.view.BeeGraphicsView.pan') +def test_wheel_event_pan_vertically_custom_inverted( + pan_mock, view, kbsettings): + kbsettings.MOUSEWHEEL_ACTIONS['pan_vertical2'].set_modifiers(['Alt']) + kbsettings.MOUSEWHEEL_ACTIONS['pan_vertical2'].set_inverted(True) + event = MagicMock() + event.angleDelta.return_value = QtCore.QPointF(0.0, 40.0) + event.position.return_value = QtCore.QPointF(10.0, 20.0) + event.modifiers.return_value = Qt.KeyboardModifier.AltModifier + view.wheelEvent(event) + pan_mock.assert_called_once_with(QtCore.QPointF(-20, 0)) event.accept.assert_called_once_with() @@ -959,7 +986,21 @@ def test_wheel_event_pan_horizontally(pan_mock, view): event.position.return_value = QtCore.QPointF(10.0, 20.0) event.modifiers.return_value = Qt.KeyboardModifier.ShiftModifier view.wheelEvent(event) - pan_mock.assert_called_once_with(QtCore.QPointF(20, 0)) + pan_mock.assert_called_once_with(QtCore.QPointF(0, 20)) + event.accept.assert_called_once_with() + + +@patch('beeref.view.BeeGraphicsView.pan') +def test_wheel_event_pan_horizontally_custom_inverted( + pan_mock, view, kbsettings): + kbsettings.MOUSEWHEEL_ACTIONS['pan_horizontal2'].set_modifiers(['Alt']) + kbsettings.MOUSEWHEEL_ACTIONS['pan_horizontal2'].set_inverted(True) + event = MagicMock() + event.angleDelta.return_value = QtCore.QPointF(0.0, 40.0) + event.position.return_value = QtCore.QPointF(10.0, 20.0) + event.modifiers.return_value = Qt.KeyboardModifier.AltModifier + view.wheelEvent(event) + pan_mock.assert_called_once_with(QtCore.QPointF(0, -20)) event.accept.assert_called_once_with() @@ -973,6 +1014,26 @@ def test_mouse_press_zoom(mouse_event_mock, view): assert view.active_mode == view.ZOOM_MODE assert view.event_start == QtCore.QPointF(10.0, 20.0) assert view.event_anchor == QtCore.QPointF(10.0, 20.0) + assert view.event_inverted is False + mouse_event_mock.assert_not_called() + event.accept.assert_called_once_with() + + +@patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent') +def test_mouse_press_zoom_custom_inverted(mouse_event_mock, view, kbsettings): + kbsettings.MOUSE_ACTIONS['zoom1'].set_button('Left') + kbsettings.MOUSE_ACTIONS['zoom1'].set_modifiers(['Alt', 'Shift']) + kbsettings.MOUSE_ACTIONS['zoom1'].set_inverted(True) + event = MagicMock() + event.position.return_value = QtCore.QPointF(10.0, 20.0) + event.button.return_value = Qt.MouseButton.LeftButton + event.modifiers.return_value = ( + Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier) + view.mousePressEvent(event) + assert view.active_mode == view.ZOOM_MODE + assert view.event_start == QtCore.QPointF(10.0, 20.0) + assert view.event_anchor == QtCore.QPointF(10.0, 20.0) + assert view.event_inverted is True mouse_event_mock.assert_not_called() event.accept.assert_called_once_with() @@ -982,7 +1043,7 @@ def test_mouse_press_pan_middle_drag(mouse_event_mock, view): event = MagicMock() event.position.return_value = QtCore.QPointF(10.0, 20.0) event.button.return_value = Qt.MouseButton.MiddleButton - event.modifiers.return_value = None + event.modifiers.return_value = Qt.KeyboardModifier.NoModifier view.mousePressEvent(event) assert view.active_mode == view.PAN_MODE assert view.event_start == QtCore.QPointF(10.0, 20.0) @@ -1136,6 +1197,7 @@ def test_mouse_move_zoom(zoom_mock, mouse_event_mock, view): view.active_mode = view.ZOOM_MODE view.event_anchor = QtCore.QPointF(55.0, 66.0) view.event_start = QtCore.QPointF(10.0, 20.0) + view.event_inverted = False event = MagicMock() event.position.return_value = QtCore.QPointF(10.0, 18.0) view.mouseMoveEvent(event) @@ -1144,6 +1206,21 @@ def test_mouse_move_zoom(zoom_mock, mouse_event_mock, view): event.accept.assert_called_once_with() +@patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent') +@patch('beeref.view.BeeGraphicsView.zoom') +def test_mouse_move_zoom_inverted(zoom_mock, mouse_event_mock, view): + view.active_mode = view.ZOOM_MODE + view.event_anchor = QtCore.QPointF(55.0, 66.0) + view.event_start = QtCore.QPointF(10.0, 20.0) + view.event_inverted = True + event = MagicMock() + event.position.return_value = QtCore.QPointF(10.0, 18.0) + view.mouseMoveEvent(event) + zoom_mock.assert_called_once_with(-40, QtCore.QPointF(55.0, 66.0)) + mouse_event_mock.assert_not_called() + event.accept.assert_called_once_with() + + @patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent') def test_mouse_move_sample_color(mouse_event_mock, view): view.active_mode = view.SAMPLE_COLOR_MODE diff --git a/tests/widgets/__init__.py b/tests/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/widgets/controls/test_controls.py b/tests/widgets/controls/test_controls.py new file mode 100644 index 0000000..b93357f --- /dev/null +++ b/tests/widgets/controls/test_controls.py @@ -0,0 +1,16 @@ +from unittest.mock import patch + +from PyQt6 import QtWidgets + +from beeref.widgets.controls import ControlsDialog + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.Yes) +@patch('beeref.config.KeyboardSettings.restore_defaults') +def test_controls_dialog_on_restore_defaults( + restore_mock, msg_mock, kbsettings, view): + dialog = ControlsDialog(view) + dialog.on_restore_defaults() + msg_mock.assert_called_once() + restore_mock.assert_called() diff --git a/tests/widgets/controls/test_keyboard.py b/tests/widgets/controls/test_keyboard.py new file mode 100644 index 0000000..0d016be --- /dev/null +++ b/tests/widgets/controls/test_keyboard.py @@ -0,0 +1,422 @@ +from unittest.mock import patch, MagicMock + +from PyQt6 import QtWidgets, QtCore, QtGui + +from beeref.actions.actions import Action +from beeref.widgets.controls.keyboard import ( + KeyboardShortcutsDelegate, + KeyboardShortcutsEditor, + KeyboardShortcutsModel, + KeyboardShortcutsProxy, +) +from beeref.utils import ActionList + + +def test_keyboard_shortcuts_editor_on_save_no_conflicts(view): + a1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.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_on_save_reenter_existing_shortcut(view): + a1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.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_on_save_conflicts_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.controls.keyboard.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_on_save_conflicts_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.controls.keyboard.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_on_save_conflicts_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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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.controls.keyboard.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: Not set' + + +def test_keyboard_shortcuts_model_setdata_saves(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.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', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.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', text='Foo') + with patch('beeref.widgets.controls.keyboard.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', text='Foo', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.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', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.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.controls.keyboard.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() + 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.controls.keyboard.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') + 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.controls.keyboard.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', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.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() == [] diff --git a/tests/widgets/controls/test_mouse.py b/tests/widgets/controls/test_mouse.py new file mode 100644 index 0000000..87e824b --- /dev/null +++ b/tests/widgets/controls/test_mouse.py @@ -0,0 +1,1257 @@ +from unittest.mock import patch, MagicMock + +from PyQt6 import QtWidgets, QtCore +from PyQt6.QtCore import Qt + +from beeref.config.controls import MouseConfig +from beeref.widgets.controls.mouse import ( + MouseDelegate, + MouseControlsEditor, + MouseModel, + MouseProxy, +) +from beeref.utils import ActionList + + +def test_mouse_editor_inits_buttons_and_modifiers_when_not_configured(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + assert editor.button_input.count() == 3 + assert editor.button_input.currentIndex() == 0 + + assert len(editor.checkboxes) == 6 + for checkbox in editor.checkboxes.values(): + assert checkbox.isChecked() is False + + +def test_mouse_editor_inits_buttons_and_modifiers_when_configured(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + assert editor.button_input.count() == 3 + assert editor.button_input.currentIndex() == 1 + + assert len(editor.checkboxes) == 6 + for key, checkbox in editor.checkboxes.items(): + if key in ('Alt', 'Ctrl'): + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_set_modifiers_no_modifier(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers_no_modifier() + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_on_modifiers_changed_when_no_modifiers_checked(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('No Modifier', Qt.CheckState.Checked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_on_modifiers_changed_when_a_modifier_checked(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['No Modifier'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('Alt', Qt.CheckState.Checked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is False + + +def test_mouse_editor_on_modifiers_changed_when_everything_unchecked(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('Alt', Qt.CheckState.Unchecked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_get_modifiers(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.checkboxes['Alt'].setChecked(True) + editor.get_modifiers() == ['Alt'] + + +def test_mouse_editor_get_modifiers_when_no_modifiers(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['No Modifier'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.get_modifiers() == ['No Modifier'] + + +def test_mouse_editor_get_modifiers_when_not_configured_cleaned(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['No Modifier'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.ignore_on_changed = False + + assert editor.get_modifiers(cleaned=True) == [] + + +def test_mouse_editor_get_modifiers_when_not_configured_cleaned_false(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['Shift'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.ignore_on_changed = False + + assert editor.get_modifiers(cleaned=False) == ['Shift', 'Alt'] + + +def test_mouse_editor_set_modifiers(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Alt', 'Shift']) + for key, checkbox in editor.checkboxes.items(): + if key in ['Alt', 'Shift']: + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_on_button_changed_when_button(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['No Modifier'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + for checkbox in editor.checkboxes.values(): + checkbox.setChecked(False) + checkbox.setEnabled(False) + editor.ignore_on_changed = False + + editor.on_button_changed() + for key, checkbox in editor.checkboxes.items(): + assert checkbox.isEnabled() is True + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + checkbox.isChecked() is False + + +def test_mouse_editor_on_button_changed_when_no_button(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=['No Modifier'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + for checkbox in editor.checkboxes.values(): + checkbox.setChecked(True) + checkbox.setEnabled(True) + editor.ignore_on_changed = False + + editor.on_button_changed() + for checkbox in editor.checkboxes.values(): + assert checkbox.isEnabled() is False + assert checkbox.isChecked() is False + + +def test_mouse_editor_set_modifiers_enabled_true(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + for checkbox in editor.checkboxes.values(): + checkbox.setEnabled(False) + + editor.set_modifiers_enabled(True) + for checkbox in editor.checkboxes.values(): + assert checkbox.isEnabled() is True + + +def test_mouse_editor_set_modifiers_enabled_false(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + for checkbox in editor.checkboxes.values(): + checkbox.setEnabled(True) + + editor.set_modifiers_enabled(False) + for checkbox in editor.checkboxes.values(): + assert checkbox.isEnabled() is False + + +def test_mouse_editor_get_button(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.button_input.setCurrentIndex(1) + assert editor.get_button() == 'Left' + + +def test_mouse_editor_set_button(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Left') + assert editor.button_input.currentIndex() == 1 + + +def test_mouse_editor_get_temp_action(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + tmp = editor.get_temp_action() + assert tmp.get_button() == 'Left' + assert tmp.get_modifiers() == ['Alt'] + + +def test_mouse_editor_reset_inputs(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Middle') + editor.set_modifiers(['Ctrl']) + editor.reset_inputs() + assert editor.get_button() == 'Left' + assert editor.get_modifiers() == ['Alt'] + + +@patch('beeref.widgets.controls.mouse.MouseControlsEditor.accept') +def test_mouse_editor_on_save_no_conflicts(accept_mock, view): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Middle') + editor.set_modifiers(['Shift']) + editor.on_save() + assert editor.get_button() == 'Middle' + assert editor.get_modifiers() == ['Shift'] + assert editor.remove_from_other is None + accept_mock.assert_called_once_with() + + +@patch('beeref.widgets.controls.mouse.MouseControlsEditor.accept') +def test_mouse_editor_on_save_reenter_existing_shortcut(accept_mock, view): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_save() + assert editor.get_button() == 'Left' + assert editor.get_modifiers() == ['Alt'] + assert editor.remove_from_other is None + accept_mock.assert_called_once_with() + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.No) +@patch('beeref.widgets.controls.mouse.MouseControlsEditor.accept') +def test_mouse_editor_on_save_conflicts_cancel(accept_mock, msg_mock, view): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Middle') + editor.set_modifiers(['Ctrl']) + editor.on_save() + assert editor.get_button() == 'Left' + assert editor.get_modifiers() == ['Alt'] + assert editor.remove_from_other is None + accept_mock.assert_not_called() + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.Yes) +@patch('beeref.widgets.controls.mouse.MouseControlsEditor.accept') +def test_mouse_editor_on_save_conflicts_confirm(accept_mock, msg_mock, view): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Middle') + editor.set_modifiers(['Ctrl']) + editor.on_save() + assert editor.get_button() == 'Middle' + assert editor.get_modifiers() == ['Ctrl'] + assert editor.remove_from_other == a2 + accept_mock.assert_called_once_with() + + +def test_mouse_delegate_create_editor(view): + delegate = MouseDelegate() + model = MouseModel() + widget = delegate.createEditor( + view, QtWidgets.QStyleOptionViewItem(), index=model.index(0, 3)) + assert isinstance(widget.editor, MouseControlsEditor) + + +def test_mouse_delegate_setmodeldata(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + delegate = MouseDelegate() + model = MouseModel() + widget = delegate.createEditor( + view, QtWidgets.QStyleOptionViewItem(), index=model.index(0, 3)) + widget.editor.set_button('Middle') + widget.editor.set_modifiers(['Ctrl', 'Shift']) + + with patch.object(widget.editor, 'result', + return_value=QtWidgets.QDialog.DialogCode.Accepted): + delegate.setModelData(widget, model, index=model.index(0, 3)) + assert action.get_button() == 'Middle' + assert action.get_modifiers() == ['Shift', 'Ctrl'] + + +def test_mouse_model_columncount(): + model = MouseModel() + model.columnCount(None) == 4 + + +def test_mouse_model_rowcount(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + model = MouseModel() + model.rowCount(None) == 2 + + +def test_mouse_model_headerdata(): + model = MouseModel() + header = model.headerData( + 0, + QtCore.Qt.Orientation.Horizontal, + QtCore.Qt.ItemDataRole.DisplayRole) + assert header == 'Action' + + +def test_flags_first_column(): + model = MouseModel() + flags = model.flags(model.index(0, 0)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_flags_modifiers_column(): + model = MouseModel() + flags = model.flags(model.index(0, 2)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable) + + +def test_flags_inverted_column_when_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + flags = model.flags(model.index(0, 4)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable + | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + + +def test_flags_inverted_column_when_not_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + flags = model.flags(model.index(0, 4)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_mouse_model_data_gets_text(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=0), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Foo' + + +def test_mouse_model_data_gets_changed_when_not_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + 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_mouse_model_data_gets_changed_when_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == '✎' + + +def test_mouse_model_data_gets_button(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Left' + + +def test_mouse_model_data_gets_modifiers(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Ctrl + Alt' + + +def test_mouse_model_data_gets_inverted_when_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'No' + + +def test_mouse_model_data_gets_inverted_when_not_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value is None + + +def test_mouse_model_data_tooltip_changed_when_not_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + 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_mouse_model_data_tooltip_changed_when_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + 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_mouse_model_data_tooltip_button_when_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Left' + + +def test_mouse_model_data_tooltip_button_when_changed_from_not_configured(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Not configured' + + +def test_mouse_model_data_tooltip_modifiers_when_changed_from_not_configured(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Not configured' + + +def test_mouse_model_data_tooltip_modifiers_when_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Ctrl + Alt' + + +def test_mouse_model_data_tooltip_inverted_when_changed_and_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_inverted(True) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: No' + + +def test_mouse_model_data_tooltip_inverted_when_changed_and_not_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_mouse_model_data_checkstaterole_invertible_invertedcol_inverted(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_inverted(True) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value == Qt.CheckState.Checked + + +def test_mouse_model_data_checkstaterole_invertible_invertedcol_not_inverted(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value == Qt.CheckState.Unchecked + + +def test_mouse_model_data_checkstaterole_other_column(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value is None + + +def test_mouse_model_setdate_saves_inverted(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + value=Qt.CheckState.Checked.value, + role=None) + assert action.get_inverted() is True + + +def test_mouse_model_setdata_saves_controls(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value={'button': 'Middle', 'modifiers': ['Ctrl']}, + role=None) + + assert action.get_button() == 'Middle' + assert action.get_modifiers() == ['Ctrl'] + + +def test_mouse_model_setdata_saves_controls_and_removes_from_other(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + model = MouseModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value={'button': 'Middle', 'modifiers': ['Ctrl']}, + role=None, + remove_from_other=a2) + + assert a1.get_button() == 'Middle' + assert a1.get_modifiers() == ['Ctrl'] + assert a2.get_button() == 'Not Configured' + assert a2.get_modifiers() == [] + + +def test_mouse_proxy_data_unfiltered(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseConfig( + id='baz1', + group='baz', + text='Baz', + button='Middle', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseProxy() + + 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' + + +def test_mouse_proxy_data_filtered(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseConfig( + id='baz1', + group='baz', + text='Baz', + button='Middle', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseProxy() + + proxy.setFilterFixedString('b') + 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_mouse_proxy_setdata_saves_correct_filtered_index(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseConfig( + id='baz1', + group='baz', + text='Baz', + button='Middle', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseProxy() + + proxy.setFilterFixedString('b') + proxy.setData( + index=proxy.index(1, 3), + value={'button': 'Left', 'modifiers': ['Ctrl', 'Alt']}, + role=None) + + a3.get_button() == 'Left' + a3.get_modifiers() == ['Ctrl', 'Alt'] + + +def test_mouse_proxy_setdata_remove_from_other(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a2, a1])): + proxy = MouseProxy() + + proxy.setData( + index=proxy.index(0, 3), + value={'button': 'Left', 'modifiers': ['Ctrl', 'Alt']}, + role=None, + remove_from_other=a2) + + a1.get_button() == 'Left' + a1.get_modifiers() == ['Ctrl', 'Alt'] + a2.get_button() == 'Not Configured' + a2.get_modifiers() == [] diff --git a/tests/widgets/controls/test_mousewheel.py b/tests/widgets/controls/test_mousewheel.py new file mode 100644 index 0000000..f58716c --- /dev/null +++ b/tests/widgets/controls/test_mousewheel.py @@ -0,0 +1,966 @@ +from unittest.mock import patch, MagicMock + +from PyQt6 import QtWidgets, QtCore +from PyQt6.QtCore import Qt + +from beeref.config.controls import MouseWheelConfig +from beeref.widgets.controls.mousewheel import ( + MouseWheelDelegate, + MouseWheelModifiersEditor, + MouseWheelModel, + MouseWheelProxy, +) +from beeref.utils import ActionList + + +def test_mousewheel_editor_inits_modifiers_when_not_configured(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + assert len(editor.checkboxes) == 6 + for checkbox in editor.checkboxes.values(): + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_inits_modifiers_when_configured(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + assert len(editor.checkboxes) == 6 + for key, checkbox in editor.checkboxes.items(): + if key in ('Alt', 'Ctrl'): + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_set_modifiers_no_modifier(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers_no_modifier() + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_on_modifiers_changed_no_modifiers_checked(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('No Modifier', Qt.CheckState.Checked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_on_modifiers_changed_when_a_modifier_checked(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['No Modifier'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('Alt', Qt.CheckState.Checked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_on_modifiers_changed_everything_unchecked(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('Alt', Qt.CheckState.Unchecked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_get_modifiers(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.checkboxes['Alt'].setChecked(True) + editor.get_modifiers() == ['Alt'] + + +def test_mousewheel_editor_get_modifiers_when_no_modifiers(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['No Modifier'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.get_modifiers() == ['No Modifier'] + + +def test_mousewheel_editor_get_modifiers_when_no_modifiers_cleaned_false(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['Shift'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.ignore_on_changed = False + + assert editor.get_modifiers(cleaned=False) == ['Shift', 'Alt'] + + +def test_mousewheel_editor_set_modifiers(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Alt', 'Shift']) + for key, checkbox in editor.checkboxes.items(): + if key in ['Alt', 'Shift']: + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_get_temp_action(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + tmp = editor.get_temp_action() + assert tmp.get_modifiers() == ['Alt'] + + +def test_mousewheel_editor_reset_inputs(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Ctrl']) + editor.reset_inputs() + assert editor.get_modifiers() == ['Alt'] + + +@patch('beeref.widgets.controls.mousewheel.MouseWheelModifiersEditor.accept') +def test_mousewheel_editor_on_save_no_conflicts(accept_mock, view): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Shift']) + editor.on_save() + assert editor.get_modifiers() == ['Shift'] + assert editor.remove_from_other is None + accept_mock.assert_called_once_with() + + +@patch('beeref.widgets.controls.mousewheel.MouseWheelModifiersEditor.accept') +def test_mousewheel_editor_on_save_reenter_existing_shortcut( + accept_mock, view): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_save() + assert editor.get_modifiers() == ['Alt'] + assert editor.remove_from_other is None + accept_mock.assert_called_once_with() + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.No) +@patch('beeref.widgets.controls.mousewheel.MouseWheelModifiersEditor.accept') +def test_mousewheel_editor_on_save_conflicts_cancel( + accept_mock, msg_mock, view): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Ctrl']) + editor.on_save() + assert editor.get_modifiers() == ['Alt'] + assert editor.remove_from_other is None + accept_mock.assert_not_called() + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.Yes) +@patch('beeref.widgets.controls.mousewheel.MouseWheelModifiersEditor.accept') +def test_mousewheel_editor_on_save_conflicts_confirm( + accept_mock, msg_mock, view): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Ctrl']) + editor.on_save() + assert editor.get_modifiers() == ['Ctrl'] + assert editor.remove_from_other == a2 + accept_mock.assert_called_once_with() + + +def test_mousewheel_delegate_create_editor(view): + delegate = MouseWheelDelegate() + model = MouseWheelModel() + widget = delegate.createEditor( + view, QtWidgets.QStyleOptionViewItem(), index=model.index(0, 3)) + assert isinstance(widget.editor, MouseWheelModifiersEditor) + + +def test_mousewheel_delegate_setmodeldata(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + delegate = MouseWheelDelegate() + model = MouseWheelModel() + widget = delegate.createEditor( + view, QtWidgets.QStyleOptionViewItem(), index=model.index(0, 2)) + widget.editor.set_modifiers(['Ctrl', 'Shift']) + + with patch.object(widget.editor, 'result', + return_value=QtWidgets.QDialog.DialogCode.Accepted): + delegate.setModelData(widget, model, index=model.index(0, 2)) + assert action.get_modifiers() == ['Shift', 'Ctrl'] + + +def test_mousewheel_model_columncount(): + model = MouseWheelModel() + model.columnCount(None) == 3 + + +def test_mousewheel_model_rowcount(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + model = MouseWheelModel() + model.rowCount(None) == 2 + + +def test_mousewheel_model_headerdata(): + model = MouseWheelModel() + header = model.headerData( + 0, + QtCore.Qt.Orientation.Horizontal, + QtCore.Qt.ItemDataRole.DisplayRole) + assert header == 'Action' + + +def test_flags_first_column(): + model = MouseWheelModel() + flags = model.flags(model.index(0, 0)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_flags_modifiers_column(): + model = MouseWheelModel() + flags = model.flags(model.index(0, 2)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable) + + +def test_flags_inverted_column_when_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + flags = model.flags(model.index(0, 4)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable + | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + + +def test_flags_inverted_column_when_not_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + flags = model.flags(model.index(0, 4)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_mousewheel_model_data_gets_text(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=0), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Foo' + + +def test_mousewheel_model_data_gets_changed_when_not_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + 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_mousewheel_model_data_gets_changed_when_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == '✎' + + +def test_mousewheel_model_data_gets_modifiers(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Ctrl + Alt' + + +def test_mousewheel_model_data_gets_inverted_when_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'No' + + +def test_mousewheel_model_data_gets_inverted_when_not_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value is None + + +def test_mousewheel_model_data_tooltip_changed_when_not_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + 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_mousewheel_model_data_tooltip_changed_when_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + 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_mousewheel_model_data_tooltip_modifiers_changed_from_not_configured(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Not configured' + + +def test_mousewheel_model_data_tooltip_modifiers_when_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Ctrl + Alt' + + +def test_mousewheel_model_data_tooltip_inverted_when_changed_and_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_inverted(True) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: No' + + +def test_mousewheel_model_data_tooltip_inverted_changed_and_not_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + 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_mousewheel_model_data_checkstaterole_invertible_invertcol_inverted(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_inverted(True) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value == Qt.CheckState.Checked + + +def test_mousewheel_model_data_checkstaterole_invertible_invcol_not_inverted(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value == Qt.CheckState.Unchecked + + +def test_mousewheel_model_data_checkstaterole_other_column(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value is None + + +def test_mousewheel_model_setdate_saves_inverted(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value=Qt.CheckState.Checked.value, + role=None) + assert action.get_inverted() is True + + +def test_mousewheel_model_setdata_saves_controls(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + value=['Ctrl'], + role=None) + + assert action.get_modifiers() == ['Ctrl'] + + +def test_mousewheel_model_setdata_saves_controls_and_removes_from_other(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + model = MouseWheelModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + value=['Ctrl'], + role=None, + remove_from_other=a2) + + assert a1.get_modifiers() == ['Ctrl'] + assert a2.get_modifiers() == [] + + +def test_mousewheel_proxy_data_unfiltered(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseWheelConfig( + id='baz1', + group='baz', + text='Baz', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseWheelProxy() + + 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' + + +def test_mousewheel_proxy_data_filtered(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseWheelConfig( + id='baz1', + group='baz', + text='Baz', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseWheelProxy() + + proxy.setFilterFixedString('b') + 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_mousewheel_proxy_setdata_saves_correct_filtered_index(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseWheelConfig( + id='baz1', + group='baz', + text='Baz', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseWheelProxy() + + proxy.setFilterFixedString('b') + proxy.setData( + index=proxy.index(1, 3), + value={'modifiers': ['Ctrl', 'Alt']}, + role=None) + + a3.get_modifiers() == ['Ctrl', 'Alt'] + + +def test_mousewheel_proxy_setdata_remove_from_other(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a2, a1])): + proxy = MouseWheelProxy() + + proxy.setData( + index=proxy.index(0, 3), + value={'modifiers': ['Ctrl', 'Alt']}, + role=None, + remove_from_other=a2) + + a1.get_modifiers() == ['Ctrl', 'Alt'] + a2.get_modifiers() == [] diff --git a/tests/widgets/test_settings.py b/tests/widgets/test_settings.py index d519703..79c8ce6 100644 --- a/tests/widgets/test_settings.py +++ b/tests/widgets/test_settings.py @@ -1,16 +1,10 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import patch -from PyQt6 import QtWidgets, QtCore, QtGui +from PyQt6 import QtWidgets -from beeref.actions.actions import Action, ActionList from beeref.widgets.settings import ( ArrangeGapWidget, ImageStorageFormatWidget, - KeyboardSettingsDialog, - KeyboardShortcutsDelegate, - KeyboardShortcutsEditor, - KeyboardShortcutsModel, - KeyboardShortcutsProxy, SettingsDialog, ) @@ -100,401 +94,3 @@ 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() - 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') - 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() diff --git a/tools/build_appimage.py b/tools/build_appimage.py new file mode 100755 index 0000000..b4ac0f1 --- /dev/null +++ b/tools/build_appimage.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 + +# Build the BeeRef appimage. Run from the git root directory. +# On github actions: +# ./tools/build_appimage --version=${{ github.ref_name }}\ +# --jsonfile=tools/linux_libs.json +# Locally: +# ./tools/build_appimage --version=0.3.3-dev --jsonfile=tools/linux_libs.json +# --skip-apt + + +import argparse +import json +import logging +import os +import shutil +import subprocess +from urllib.request import urlretrieve + + +parser = argparse.ArgumentParser( + description=('Create an appimage for BeeRef. ' + 'Run from the git root directory.')) +parser.add_argument( + '-v', '--version', + required=True, + help='BeeRef version number/tag for output file') +parser.add_argument( + '-j', '--jsonfile', + required=True, + help='Json with lib files and packages as generated by find_linux_libs') +parser.add_argument( + '--redownload', + default=False, + action='store_true', + help='Re-use downloaded files if present') +parser.add_argument( + '--skip-apt', + default=False, + action='store_true', + help='Skip apt install step') +parser.add_argument( + '-l', '--loglevel', + default='INFO', + choices=list(logging._nameToLevel.keys()), + help='log level for console output') + +args = parser.parse_args() + + +BEEVERSION = args.version.removeprefix('v') +APPIMAGE = 'python3.11.9-cp311-cp311-manylinux2014_x86_64.AppImage' +# ^ Siehe: +# https://python-appimage.readthedocs.io/en/latest/#alternative-site-packages-location +PYVER = '3.11' +logger = logging.getLogger(__name__) +logging.basicConfig(level=getattr(logging, args.loglevel)) + + +def run_command(*args, capture_output=False): + logger.info(f'Running command: {args}') + result = subprocess.run(args, capture_output=capture_output) + assert result.returncode == 0, f'Failed with exit code {result.returncode}' + + +def download_file(url, filename): + if not args.redownload and os.path.exists(filename): + logger.info(f'Found file: {filename}') + else: + logger.info(f'Downloading: {url}') + logger.info(f'Saving as: {filename}') + urlretrieve(url, filename=filename) + os.chmod(filename, 0o755) + + +url = ('https://github.com/niess/python-appimage/releases/download/' + f'python{PYVER}/{APPIMAGE}') +download_file(url, filename='python.appimage') + + +try: + shutil.rmtree('squashfs-root') +except FileNotFoundError: + pass +run_command('./python.appimage', '--appimage-extract', + capture_output=True) + +run_command('squashfs-root/usr/bin/pip', + 'install', + '.', + f'--target=squashfs-root/opt/python{PYVER}/lib/python{PYVER}/') + +logger.info(f'Reading from: {args.jsonfile}') +with open(args.jsonfile, 'r') as f: + data = json.loads(f.read()) +libs = data['libs'] +packages = data['packages'] +excludes = data['excludes'] +paths = set() + +if not args.skip_apt: + run_command('sudo', 'apt', 'install', *packages) + +logger.info('Copying .so files to appimage...') + +existing_files = [] +for root, subdirs, files in os.walk('squashfs-root'): + existing_files.extend(files) + +for lib in libs: + if os.path.basename(lib) in existing_files: + logger.debug(f'Skipping {lib} (already in appimage)') + continue + if os.path.basename(lib) in excludes: + logger.debug(f'Skipping {lib} (excluded)') + continue + paths.add(os.path.dirname(lib)) + if os.path.exists(lib): + filename = lib + else: + filename, _ = os.path.splitext(lib) + dest = f'squashfs-root{filename}' + os.makedirs(os.path.dirname(dest), exist_ok=True) + logger.debug(f'Copying {filename} to {dest}') + shutil.copyfile(filename, f'squashfs-root{filename}') + + +logger.info('Writing run script...') +# Adapted from usr/bin/python3.x in the python appimage +os.remove('squashfs-root/AppRun') +# ^ This is only a symlink to usr/bin/python3.x + +paths = [ + '/usr/lib', # The libs that come with the python appimage ar in /usr/lib +] + list(paths) +ld_paths = ['${APPDIR}' + p for p in paths] + ['${LD_LIBRARY_PATH}'] +ld_paths = ':'.join(ld_paths) +logger.debug(f'LD_LIBRARY_PATH: {ld_paths}') + +content = """#! /bin/bash + +# If running from an extracted image, then export ARGV0 and APPDIR +if [ -z "${APPIMAGE}" ]; then + export ARGV0="$0" + + self=$(readlink -f -- "$0") # Protect spaces (issue 55) + here="${self%/*}" + tmp="${here%/*}" + export APPDIR="${tmp%/*}" +fi + +# Resolve the calling command (preserving symbolic links). +export APPIMAGE_COMMAND=$(command -v -- "$ARGV0") + +# Export SSL certificate +export SSL_CERT_FILE="${APPDIR}/opt/_internal/certs.pem" +""" +content += f'export LD_LIBRARY_PATH="{ld_paths}"\n' +content += f'"$APPDIR/opt/python{PYVER}/bin/python{PYVER}" -I -m beeref "$@"\n' + +with open('squashfs-root/AppRun', 'w') as f: + f.write(content) +os.chmod('squashfs-root/AppRun', 0o755) + +url = ('https://github.com/AppImage/AppImageKit/releases/download/' + 'continuous/appimagetool-x86_64.AppImage') + +download_file(url, filename='appimagetool.appimage') +run_command('./appimagetool.appimage', + 'squashfs-root', + f'BeeRef-{BEEVERSION}.appimage', + '--no-appstream') diff --git a/tools/find_linux_libs.py b/tools/find_linux_libs.py new file mode 100755 index 0000000..8de0eac --- /dev/null +++ b/tools/find_linux_libs.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +# Create JSON with Linux libs needed for BeeRef appimage + +import argparse +import json +import logging +import os +import pathlib +import re +import subprocess +import sys +from urllib import request + + +parser = argparse.ArgumentParser( + description=('Create JSON with Linux libs needed for BeeRef appimage')) +parser.add_argument( + 'pid', + nargs=1, + default=None, + help='PID of running BeeRef process') +parser.add_argument( + '-l', '--loglevel', + default='INFO', + choices=list(logging._nameToLevel.keys()), + help='log level for console output') +parser.add_argument( + '--jsonfile', + default='linux_libs.json', + help='JSON input/output file') +parser.add_argument( + '--check-appimage', + default=False, + action='store_true', + help='Check a running appimage process for missing libraries') + + +args = parser.parse_args() + + +def strip_minor_versions(path): + # foo2.so.2.1.1 -> foo2.so.2 + return re.sub('(.so.[0-9]*)[.0-9]*$', r'\1', path) + + +def what_links_to(path): + links = set() + dirname = os.path.dirname(path) + for filename in os.listdir(dirname): + filename = os.path.join(dirname, filename) + if (os.path.islink(filename) + and str(pathlib.Path(filename).resolve()) == path): + links.add(filename) + return sorted(links, key=len) + + +def is_lib(path): + return ('.so' in path + and os.path.expanduser('~') not in path + and 'python3' not in path + and 'mesa-diverted' not in path) + + +def iter_lsofoutput(output): + for line in output.splitlines(): + line = line.split() + if line[3] == 'mem': + path = line[-1] + if is_lib(path): + yield path + + +PID = args.pid[0] +logger = logging.getLogger(__name__) +logging.basicConfig(level=getattr(logging, args.loglevel)) + + +result = subprocess.run(('lsof', '-p', PID), capture_output=True) +assert result.returncode == 0, result.stderr +output = result.stdout.decode('utf-8') + + +if args.check_appimage: + logger.info('Checking appimage...') + errors = False + for lib in iter_lsofoutput(output): + if 'mount_BeeRef' not in lib: + print(f'Not in appimage: {lib}') + errors = True + if not errors: + print('No missing libs found.') + sys.exit() + + +libs = [] + +if os.path.exists(args.jsonfile): + logger.info(f'Reading from: {args.jsonfile}') + with open(args.jsonfile, 'r') as f: + data = json.loads(f.read()) + known_libs = data['libs'] + packages = set(data['packages']) +else: + logger.info(f'No file {args.jsonfile}; starting from scratch') + known_libs = [] + packages = set() + + +for lib in iter_lsofoutput(output): + links = what_links_to(lib) + if len(links) == 1: + lib = links[0] + else: + logger.warning(f'Double check: {lib} {links}') + lib = links[0] + if lib in known_libs: + logger.debug(f'Found known lib: {lib}') + else: + logger.debug(f'Found unknown lib: {lib}') + libs.append(lib) + + +for lib in libs: + result = subprocess.run(('apt-file', 'search', lib), capture_output=True) + if result.returncode != 0: + logger.warning(f'Fix manually: {lib}') + continue + output = result.stdout.decode('utf-8') + pkgs = set() + for line in output.splitlines(): + pkg = line.split(': ')[0] + if not (pkg.endswith('-dev') or pkg.endswith('-dbg')): + pkgs.add(pkg) + if len(pkgs) == 1: + pkg = pkgs.pop() + logger.debug(f'Found package: {pkg}') + packages.add(pkg) + else: + logger.warning(f'Fix manually: {lib}') + + +# Find the libs we shouldn't include in the appimage +with request.urlopen( + 'https://raw.githubusercontent.com/AppImageCommunity/pkg2appimage/' + 'master/excludelist') as f: + response = f.read().decode() + + +exclude_masterlist = set() +for line in response.splitlines(): + if not line or line.startswith('#'): + continue + line = line.split()[0] + line = strip_minor_versions(line) + exclude_masterlist.add(line) + +excludes = [] +for ex in exclude_masterlist: + for lib in (libs + known_libs): + if lib.endswith(ex): + excludes.append(ex) + continue + + +logger.info(f'Writing to: {args.jsonfile}') +with open(args.jsonfile, 'w') as f: + data = {'libs': sorted(libs + known_libs), + 'packages': sorted(packages), + 'excludes': sorted(excludes)} + f.write(json.dumps(data, indent=4)) diff --git a/tools/linux_libs.json b/tools/linux_libs.json new file mode 100644 index 0000000..2169295 --- /dev/null +++ b/tools/linux_libs.json @@ -0,0 +1,238 @@ +{ + "libs": [ + "/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", + "/lib/x86_64-linux-gnu/libbz2.so.1", + "/lib/x86_64-linux-gnu/libc.so.6", + "/lib/x86_64-linux-gnu/libcom_err.so.2", + "/lib/x86_64-linux-gnu/libdbus-1.so.3", + "/lib/x86_64-linux-gnu/libdl.so.2", + "/lib/x86_64-linux-gnu/libexpat.so.1", + "/lib/x86_64-linux-gnu/libgcc_s.so.1", + "/lib/x86_64-linux-gnu/libgpg-error.so.0", + "/lib/x86_64-linux-gnu/libkeyutils.so.1", + "/lib/x86_64-linux-gnu/liblzma.so.5", + "/lib/x86_64-linux-gnu/libm.so.6", + "/lib/x86_64-linux-gnu/libpcre.so.3", + "/lib/x86_64-linux-gnu/libpthread.so.0", + "/lib/x86_64-linux-gnu/libresolv.so.2", + "/lib/x86_64-linux-gnu/librt.so.1", + "/lib/x86_64-linux-gnu/libselinux.so.1", + "/lib/x86_64-linux-gnu/libutil.so.1", + "/lib/x86_64-linux-gnu/libz.so.1", + "/usr/lib/x86_64-linux-gnu/gio/modules/libgvfsdbus.so", + "/usr/lib/x86_64-linux-gnu/gtk-3.0/modules/libcanberra-gtk-module.so", + "/usr/lib/x86_64-linux-gnu/gvfs/libgvfscommon.so", + "/usr/lib/x86_64-linux-gnu/libGLX.so.0", + "/usr/lib/x86_64-linux-gnu/libGLdispatch.so.0", + "/usr/lib/x86_64-linux-gnu/libX11-xcb.so.1", + "/usr/lib/x86_64-linux-gnu/libX11.so.6", + "/usr/lib/x86_64-linux-gnu/libXau.so.6", + "/usr/lib/x86_64-linux-gnu/libXcomposite.so.1", + "/usr/lib/x86_64-linux-gnu/libXcursor.so.1", + "/usr/lib/x86_64-linux-gnu/libXdamage.so.1", + "/usr/lib/x86_64-linux-gnu/libXdmcp.so.6", + "/usr/lib/x86_64-linux-gnu/libXext.so.6", + "/usr/lib/x86_64-linux-gnu/libXfixes.so.3", + "/usr/lib/x86_64-linux-gnu/libXi.so.6", + "/usr/lib/x86_64-linux-gnu/libXinerama.so.1", + "/usr/lib/x86_64-linux-gnu/libXrandr.so.2", + "/usr/lib/x86_64-linux-gnu/libXrender.so.1", + "/usr/lib/x86_64-linux-gnu/libatk-1.0.so.0", + "/usr/lib/x86_64-linux-gnu/libatk-bridge-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libatspi.so.0", + "/usr/lib/x86_64-linux-gnu/libblkid.so.1", + "/usr/lib/x86_64-linux-gnu/libbrotlicommon.so.1", + "/usr/lib/x86_64-linux-gnu/libbrotlidec.so.1", + "/usr/lib/x86_64-linux-gnu/libbsd.so.0", + "/usr/lib/x86_64-linux-gnu/libcairo-gobject.so.2", + "/usr/lib/x86_64-linux-gnu/libcairo.so.2", + "/usr/lib/x86_64-linux-gnu/libcanberra-gtk3.so.0", + "/usr/lib/x86_64-linux-gnu/libcanberra.so.0", + "/usr/lib/x86_64-linux-gnu/libcrypto.so", + "/usr/lib/x86_64-linux-gnu/libdatrie.so.1", + "/usr/lib/x86_64-linux-gnu/libepoxy.so.0", + "/usr/lib/x86_64-linux-gnu/libffi.so.7", + "/usr/lib/x86_64-linux-gnu/libfontconfig.so.1", + "/usr/lib/x86_64-linux-gnu/libfreetype.so.6", + "/usr/lib/x86_64-linux-gnu/libfribidi.so.0", + "/usr/lib/x86_64-linux-gnu/libgcrypt.so.20", + "/usr/lib/x86_64-linux-gnu/libgdk-3.so.0", + "/usr/lib/x86_64-linux-gnu/libgdk_pixbuf-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgio-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libglib-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgmodule-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgobject-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgraphite2.so.3", + "/usr/lib/x86_64-linux-gnu/libgssapi_krb5.so.2", + "/usr/lib/x86_64-linux-gnu/libgthread-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgtk-3.so.0", + "/usr/lib/x86_64-linux-gnu/libharfbuzz.so.0", + "/usr/lib/x86_64-linux-gnu/libk5crypto.so.3", + "/usr/lib/x86_64-linux-gnu/libkrb5.so.3", + "/usr/lib/x86_64-linux-gnu/libkrb5support.so.0", + "/usr/lib/x86_64-linux-gnu/libltdl.so.7", + "/usr/lib/x86_64-linux-gnu/liblz4.so.1", + "/usr/lib/x86_64-linux-gnu/libmd.so.0", + "/usr/lib/x86_64-linux-gnu/libmount.so.1", + "/usr/lib/x86_64-linux-gnu/libogg.so.0", + "/usr/lib/x86_64-linux-gnu/libpango-1.0.so.0", + "/usr/lib/x86_64-linux-gnu/libpangocairo-1.0.so.0", + "/usr/lib/x86_64-linux-gnu/libpangoft2-1.0.so.0", + "/usr/lib/x86_64-linux-gnu/libpcre2-8.so.0", + "/usr/lib/x86_64-linux-gnu/libpixman-1.so.0", + "/usr/lib/x86_64-linux-gnu/libpng16.so.16", + "/usr/lib/x86_64-linux-gnu/libsqlite3.so", + "/usr/lib/x86_64-linux-gnu/libssl.so", + "/usr/lib/x86_64-linux-gnu/libstdc++.so.6", + "/usr/lib/x86_64-linux-gnu/libsystemd.so.0", + "/usr/lib/x86_64-linux-gnu/libtdb.so.1", + "/usr/lib/x86_64-linux-gnu/libthai.so.0", + "/usr/lib/x86_64-linux-gnu/libuuid.so.1", + "/usr/lib/x86_64-linux-gnu/libvorbis.so.0", + "/usr/lib/x86_64-linux-gnu/libvorbisfile.so.3", + "/usr/lib/x86_64-linux-gnu/libwayland-client.so.0", + "/usr/lib/x86_64-linux-gnu/libwayland-cursor.so.0", + "/usr/lib/x86_64-linux-gnu/libwayland-egl.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb-cursor.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-icccm.so.4", + "/usr/lib/x86_64-linux-gnu/libxcb-image.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-keysyms.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb-randr.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-render-util.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-render.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-shape.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-shm.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-sync.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb-util.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb-xfixes.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-xkb.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb.so.1", + "/usr/lib/x86_64-linux-gnu/libxkbcommon-x11.so.0", + "/usr/lib/x86_64-linux-gnu/libxkbcommon.so.0", + "/usr/lib/x86_64-linux-gnu/libzstd.so.1" + ], + "packages": [ + "gvfs", + "gvfs-libs", + "libatk-bridge2.0-0", + "libatk1.0-0", + "libatspi2.0-0", + "libblkid1", + "libbrotli1", + "libbsd0", + "libbz2-1.0", + "libc6", + "libcairo-gobject2", + "libcairo2", + "libcanberra-gtk3-0", + "libcanberra-gtk3-module", + "libcanberra0", + "libcom-err2", + "libdatrie1", + "libdbus-1-3", + "libepoxy0", + "libexpat1", + "libffi7", + "libfontconfig1", + "libfreetype6", + "libfribidi0", + "libgcc-s1", + "libgcrypt20", + "libgdk-pixbuf2.0-0", + "libglib2.0-0", + "libglvnd0", + "libglx0", + "libgpg-error0", + "libgraphite2-3", + "libgssapi-krb5-2", + "libgtk-3-0", + "libharfbuzz0b", + "libk5crypto3", + "libkeyutils1", + "libkrb5-3", + "libkrb5support0", + "libltdl7", + "liblz4-1", + "liblzma5", + "libmd0", + "libmount1", + "libogg0", + "libpango-1.0-0", + "libpangocairo-1.0-0", + "libpangoft2-1.0-0", + "libpcre2-8-0", + "libpcre3", + "libpixman-1-0", + "libpng16-16", + "libselinux1", + "libsqlite3-0", + "libssl1.1", + "libstdc++6", + "libsystemd0", + "libtdb1", + "libthai0", + "libuuid1", + "libvorbis0a", + "libvorbisfile3", + "libwayland-client0", + "libwayland-cursor0", + "libwayland-egl1", + "libx11-6", + "libx11-xcb1", + "libxau6", + "libxcb-cursor0", + "libxcb-icccm4", + "libxcb-image0", + "libxcb-keysyms1", + "libxcb-randr0", + "libxcb-render-util0", + "libxcb-render0", + "libxcb-shape0", + "libxcb-shm0", + "libxcb-sync1", + "libxcb-util1", + "libxcb-xfixes0", + "libxcb-xkb1", + "libxcb1", + "libxcomposite1", + "libxcursor1", + "libxdamage1", + "libxdmcp6", + "libxext6", + "libxfixes3", + "libxi6", + "libxinerama1", + "libxkbcommon-x11-0", + "libxkbcommon0", + "libxrandr2", + "libxrender1", + "libzstd1", + "zlib1g" + ], + "excludes": [ + "ld-linux-x86-64.so.2", + "libGLX.so.0", + "libGLdispatch.so.0", + "libX11-xcb.so.1", + "libX11.so.6", + "libc.so.6", + "libcom_err.so.2", + "libdl.so.2", + "libexpat.so.1", + "libfontconfig.so.1", + "libfreetype.so.6", + "libfribidi.so.0", + "libgcc_s.so.1", + "libgpg-error.so.0", + "libharfbuzz.so.0", + "libm.so.6", + "libpthread.so.0", + "libresolv.so.2", + "librt.so.1", + "libstdc++.so.6", + "libthai.so.0", + "libutil.so.1", + "libxcb.so.1", + "libz.so.1" + ] +}