From b2c8b867f256f35fad9014f7d2c69ddc028d1490 Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Fri, 22 Mar 2024 20:27:11 +0100 Subject: [PATCH 1/7] Fail gracefully when exif orientation usupported --- CHANGELOG.rst | 1 + beeref/fileio/image.py | 10 +++++++--- tests/fileio/test_image.py | 7 +++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 07efd8d..13a36b5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -24,6 +24,7 @@ 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 diff --git a/beeref/fileio/image.py b/beeref/fileio/image.py index d68db80..0a82a11 100644 --- a/beeref/fileio/image.py +++ b/beeref/fileio/image.py @@ -44,9 +44,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/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'), From b025204dfbf2e948cc3525e6cb8af36a02bdd30e Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Fri, 29 Mar 2024 22:50:49 +0100 Subject: [PATCH 2/7] Fixed threading issue when importing images Don't deselect items from within the import worker thread. --- CHANGELOG.rst | 2 ++ beeref/commands.py | 3 ++- beeref/view.py | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 13a36b5..2b1a4a4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,8 @@ Fixed * 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) 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/view.py b/beeref/view.py index 5366e61..62c8219 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -540,6 +540,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, From a532d1d6678e415a062a4e688e5063b937128180 Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Fri, 29 Mar 2024 23:24:26 +0100 Subject: [PATCH 3/7] Fixed an intermittent crash when invoking New Scene Multiselect item will get cleared, too, and can't be accesed. Ignore scene change events during cleaning, because those want to access the multi select item. --- CHANGELOG.rst | 1 + beeref/scene.py | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2b1a4a4..fbca9ad 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -27,6 +27,7 @@ Fixed * 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 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( From c64385b2c0713204cf69551a4686175cccb989c8 Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Sun, 3 Mar 2024 20:23:14 +0100 Subject: [PATCH 4/7] Configurable mouse and mouswheel controls --- CHANGELOG.rst | 2 + beeref/actions/actions.py | 699 ++++----- beeref/actions/mixin.py | 26 +- beeref/config/__init__.py | 91 ++ beeref/config/controls.py | 336 +++++ beeref/{config.py => config/settings.py} | 96 -- beeref/constants.py | 6 + beeref/main_controls.py | 7 +- beeref/utils.py | 14 + beeref/view.py | 37 +- beeref/widgets/__init__.py | 13 +- beeref/widgets/controls/__init__.py | 96 ++ beeref/widgets/controls/common.py | 262 ++++ beeref/widgets/controls/keyboard.py | 201 +++ beeref/widgets/controls/mouse.py | 170 +++ beeref/widgets/controls/mousewheel.py | 120 ++ beeref/widgets/settings.py | 229 +-- setup.py | 2 + tests/actions/test_actions.py | 81 +- tests/actions/test_mixin.py | 143 +- tests/config/__init__.py | 0 tests/config/test_controls.py | 510 +++++++ .../test_settings.py} | 49 +- tests/test_utils.py | 17 + tests/test_view.py | 85 +- tests/widgets/__init__.py | 0 tests/widgets/controls/test_controls.py | 16 + tests/widgets/controls/test_keyboard.py | 422 ++++++ tests/widgets/controls/test_mouse.py | 1257 +++++++++++++++++ tests/widgets/controls/test_mousewheel.py | 966 +++++++++++++ tests/widgets/test_settings.py | 408 +----- 31 files changed, 5083 insertions(+), 1278 deletions(-) create mode 100644 beeref/config/__init__.py create mode 100644 beeref/config/controls.py rename beeref/{config.py => config/settings.py} (69%) create mode 100644 beeref/widgets/controls/__init__.py create mode 100644 beeref/widgets/controls/common.py create mode 100644 beeref/widgets/controls/keyboard.py create mode 100644 beeref/widgets/controls/mouse.py create mode 100644 beeref/widgets/controls/mousewheel.py create mode 100644 tests/config/__init__.py create mode 100644 tests/config/test_controls.py rename tests/{test_config.py => config/test_settings.py} (67%) create mode 100644 tests/widgets/__init__.py create mode 100644 tests/widgets/controls/test_controls.py create mode 100644 tests/widgets/controls/test_keyboard.py create mode 100644 tests/widgets/controls/test_mouse.py create mode 100644 tests/widgets/controls/test_mousewheel.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fbca9ad..59f7c3d 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 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/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/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/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 62c8219..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) @@ -749,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 @@ -785,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() @@ -821,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.py b/setup.py index ddd9ac0..e98fe55 100644 --- a/setup.py +++ b/setup.py @@ -18,9 +18,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/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() From 82787d08aa8a8cbdd3e4dbd5a0a41f4aeaced149 Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Thu, 4 Apr 2024 19:38:44 +0200 Subject: [PATCH 5/7] Vacuum bee files on saving (fixes #99) --- CHANGELOG.rst | 1 + beeref/fileio/sql.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 59f7c3d..feacf98 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -30,6 +30,7 @@ Fixed * 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/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, []) From 1119004fe109e5ba5fd472f445a417f81d3c7eeb Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Tue, 9 Apr 2024 22:04:31 +0200 Subject: [PATCH 6/7] Add appimage build script and github action --- .github/workflows/build_appimage.yml | 26 +++ .gitignore | 2 + beeref/__main__.py | 1 + setup.cfg | 4 + tools/build_appimage.py | 170 +++++++++++++++++++ tools/find_linux_libs.py | 171 +++++++++++++++++++ tools/linux_libs.json | 238 +++++++++++++++++++++++++++ 7 files changed, 612 insertions(+) create mode 100644 .github/workflows/build_appimage.yml create mode 100755 tools/build_appimage.py create mode 100755 tools/find_linux_libs.py create mode 100644 tools/linux_libs.json diff --git a/.github/workflows/build_appimage.yml b/.github/workflows/build_appimage.yml new file mode 100644 index 0000000..d550be6 --- /dev/null +++ b/.github/workflows/build_appimage.yml @@ -0,0 +1,26 @@ +name: build_appimage + +on: workflow_dispatch + +jobs: + + build_appimage: + name: build_appimage + + runs-on: 'ubuntu-20.04' + strategy: + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Build appimage + run: | + bash tools/build_appimage --version=${{ github.ref_name }} --jsonfile=tools/linux_libs.json + - name: Upload artifact + uses: actions/upload-artifact@v3 + 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/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/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/tools/build_appimage.py b/tools/build_appimage.py new file mode 100755 index 0000000..68f6cc1 --- /dev/null +++ b/tools/build_appimage.py @@ -0,0 +1,170 @@ +#!/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.8-cp311-cp311-manylinux_2_28_x86_64.AppImage' +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..492a5df --- /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-pixbuf-2.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" + ] +} From 3151dc4993204f0c1bf4fb5962d7d5ff09ae990f Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Thu, 25 Apr 2024 19:12:41 +0200 Subject: [PATCH 7/7] Fix github appimage build --- .github/workflows/build_appimage.yml | 7 +++---- CONTRIBUTING.rst | 4 ++-- tools/build_appimage.py | 4 +++- tools/linux_libs.json | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build_appimage.yml b/.github/workflows/build_appimage.yml index d550be6..dfc17ba 100644 --- a/.github/workflows/build_appimage.yml +++ b/.github/workflows/build_appimage.yml @@ -8,19 +8,18 @@ jobs: name: build_appimage runs-on: 'ubuntu-20.04' - strategy: steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.11 - name: Build appimage run: | - bash tools/build_appimage --version=${{ github.ref_name }} --jsonfile=tools/linux_libs.json + python3 tools/build_appimage.py --version=${{ github.ref_name }} --jsonfile=tools/linux_libs.json - name: Upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: path: BeeRef*.appimage retention-days: 5 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/tools/build_appimage.py b/tools/build_appimage.py index 68f6cc1..b4ac0f1 100755 --- a/tools/build_appimage.py +++ b/tools/build_appimage.py @@ -49,7 +49,9 @@ args = parser.parse_args() BEEVERSION = args.version.removeprefix('v') -APPIMAGE = 'python3.11.8-cp311-cp311-manylinux_2_28_x86_64.AppImage' +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)) diff --git a/tools/linux_libs.json b/tools/linux_libs.json index 492a5df..2169295 100644 --- a/tools/linux_libs.json +++ b/tools/linux_libs.json @@ -138,7 +138,7 @@ "libfribidi0", "libgcc-s1", "libgcrypt20", - "libgdk-pixbuf-2.0-0", + "libgdk-pixbuf2.0-0", "libglib2.0-0", "libglvnd0", "libglx0",