diff --git a/.github/workflows/build_appimage.yml b/.github/workflows/build_appimage.yml
new file mode 100644
index 0000000..dfc17ba
--- /dev/null
+++ b/.github/workflows/build_appimage.yml
@@ -0,0 +1,25 @@
+name: build_appimage
+
+on: workflow_dispatch
+
+jobs:
+
+ build_appimage:
+ name: build_appimage
+
+ runs-on: 'ubuntu-20.04'
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+ - name: Build appimage
+ run: |
+ python3 tools/build_appimage.py --version=${{ github.ref_name }} --jsonfile=tools/linux_libs.json
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ path: BeeRef*.appimage
+ retention-days: 5
diff --git a/.gitignore b/.gitignore
index 6ff49a6..f0b0c63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,8 @@ share/python-wheels/
.installed.cfg
*.egg
MANIFEST
+*.appimage
+squashfs-root/
# PyInstaller
# Usually these files are written by a python script from a template
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 07efd8d..feacf98 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -13,6 +13,8 @@ Added
* Added panning via scrollwheel:
* Scroll wheel + Shift + Ctrl: pan vertically
* Scroll wheel + Shift: pan horizontally
+* Make mouse and mouse wheel controls configurable
+ (Settings -> Keyboard & Mouse)
Fixed
@@ -24,6 +26,11 @@ Fixed
correctly depending on the selected images.
* Removed black line under marching ants outline of crop mode, which
would scale with the image and get potentially very thick.
+* Fixed a crash when importing images with unsupported exif orientation info
+* Fixed threading issue when importing images (causing potential
+ hangs/weird behaviour)
+* Fixed an intermittent crash when invoking New Scene
+* Fixed bee files hanging on to disk space of deleted images (issue #99)
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 2505eb7..310a5b0 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -7,12 +7,12 @@ BeeRef is written in Python and PyQt6.
Developing
----------
-Optional step: Use pyenv to create a virtual environment:
+Optional step: Use pyenv to create a virtual environment::
pyenv install -v 3.11
pyenv virtualenv 3.11 beeref
-Once the vitrual environment is set up, you can enter it with:
+Once the vitrual environment is set up, you can enter it with::
pyenv activate beeref
diff --git a/beeref/__main__.py b/beeref/__main__.py
index 4899c83..333feac 100755
--- a/beeref/__main__.py
+++ b/beeref/__main__.py
@@ -103,6 +103,7 @@ def main():
logger.info(f'Starting {constants.APPNAME} version {constants.VERSION}')
logger.debug('System: %s', ' '.join(platform.uname()))
logger.debug('Python: %s', platform.python_version())
+ logger.debug('LD_LIBRARY_PATH: %s', os.environ.get('LD_LIBRARY_PATH'))
settings = BeeSettings()
logger.info(f'Using settings: {settings.fileName()}')
logger.info(f'Logging to: {logfile_name()}')
diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py
index 1ce63c5..6e45dd9 100644
--- a/beeref/actions/actions.py
+++ b/beeref/actions/actions.py
@@ -13,7 +13,6 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see
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, seeThis 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, seeThis shortcut is already used for:
' - f'{txt}
' - 'Do you want to remove the other shortcut' - ' to save this one?
') - reply = QtWidgets.QMessageBox.question( - self, 'Save Shortcut?', msg) - if reply == QtWidgets.QMessageBox.StandardButton.Yes: - self.remove_from_other = action - else: - self.setKeySequence(self.old_value) - - -class KeyboardShortcutsDelegate(QtWidgets.QStyledItemDelegate): - - def createEditor(self, parent, option, index): - return KeyboardShortcutsEditor(parent, index) - - def setModelData(self, editor, model, index): - model.setData( - index, - editor.keySequence(), - QtCore.Qt.ItemDataRole.EditRole, - remove_from_other=editor.remove_from_other) - - -class KeyboardShortcutsModel(QtCore.QAbstractTableModel): - """An entry in the keyboard shortcuts table.""" - - HEADER = ('Action', '✎', 'Shortcut', 'Alternative') - - def __init__(self): - super().__init__() - self.settings = KeyboardSettings() - - def rowCount(self, parent): - return len(actions) - - def columnCount(self, parent): - return len(self.HEADER) - - def data(self, index, role): - if role in (QtCore.Qt.ItemDataRole.DisplayRole, - QtCore.Qt.ItemDataRole.EditRole): - action = actions[index.row()] - txt = ': '.join(action.menu_path + [action['text']]) - if index.column() == 0: - return txt.replace('&', '').removesuffix('...') - if index.column() == 1 and action.shortcuts_changed(): - return CHANGED_SYMBOL - if index.column() > 1: - return action.get_qkeysequence(index.column() - 2) - - if role == QtCore.Qt.ItemDataRole.ToolTipRole: - action = actions[index.row()] - changed = action.shortcuts_changed() - if changed and index.column() == 1: - return 'Changed from default' - if changed and index.column() > 1: - default = action.get_default_shortcut(index.column() - 2) - default = default or '-' - return f'Default: {default}' - - def setData(self, index, value, role, remove_from_other=None): - action = actions[index.row()] - shortcuts = action.get_shortcuts() + [None, None] - shortcuts[index.column() - 2] = value.toString() - shortcuts = list(filter(bool, shortcuts)) - if len(shortcuts) != len(set(shortcuts)): - # We got the same shortcut twice - shortcuts = set(shortcuts) - action.set_shortcuts(shortcuts) - # Whole row might be affected, so excpliclity emit dataChanged - self.dataChanged.emit(self.index(index.row(), 1), - self.index(index.row(), 3)) - - if remove_from_other: - # This shortcut has conflicts with another action and the - # user chose to remove the other shortcut - shortcuts = remove_from_other.get_shortcuts() - shortcuts.remove(value.toString()) - remove_from_other.set_shortcuts(shortcuts) - row = list(actions.keys()).index(remove_from_other['id']) - self.dataChanged.emit(self.index(row, 1), - self.index(row, 3)) - - return True - - def headerData(self, section, orientation, role): - if (role == QtCore.Qt.ItemDataRole.DisplayRole - and orientation == QtCore.Qt.Orientation.Horizontal): - return self.HEADER[section] - - def flags(self, index): - base = (QtCore.Qt.ItemFlag.ItemIsEnabled - | QtCore.Qt.ItemFlag.ItemNeverHasChildren) - if index.column() <= 1: - return base - else: - return (base | QtCore.Qt.ItemFlag.ItemIsEditable) - - -class KeyboardShortcutsProxy(QtCore.QSortFilterProxyModel): - - def __init__(self): - super().__init__() - self.setSourceModel(KeyboardShortcutsModel()) - self.setFilterCaseSensitivity( - QtCore.Qt.CaseSensitivity.CaseInsensitive) - - def setData(self, index, value, role, remove_from_other=None): - result = self.sourceModel().setData( - self.mapToSource(index), - value, - role, - remove_from_other=remove_from_other) - return result - - -class KeyboardShortcutsView(QtWidgets.QTableView): - - def __init__(self, parent): - super().__init__(parent) - self.setMinimumSize(QtCore.QSize(400, 200)) - self.setItemDelegate(KeyboardShortcutsDelegate()) - self.setShowGrid(False) - self.setModel(KeyboardShortcutsProxy()) - self.horizontalHeader().setSectionResizeMode( - 0, QtWidgets.QHeaderView.ResizeMode.Stretch) - self.horizontalHeader().setSectionResizeMode( - 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - self.setSelectionMode( - QtWidgets.QHeaderView.SelectionMode.SingleSelection) - self.setAlternatingRowColors(True) - settings_events.restore_keyboard_defaults.connect( - self.on_restore_defaults) - - def on_restore_defaults(self): - self.viewport().update() - - -class KeyboardSettingsDialog(QtWidgets.QDialog): - def __init__(self, parent): - super().__init__(parent) - self.setWindowTitle('Keyboard Shortcuts') - tabs = QtWidgets.QTabWidget() - - # Keyboard shortcuts - keyboard = QtWidgets.QWidget(parent) - kb_layout = QtWidgets.QVBoxLayout() - keyboard.setLayout(kb_layout) - table = KeyboardShortcutsView(keyboard) - search_input = QtWidgets.QLineEdit() - search_input.setPlaceholderText('Search...') - search_input.textChanged.connect( - lambda value: table.model().setFilterFixedString(value)) - kb_layout.addWidget(search_input) - kb_layout.addWidget(table) - tabs.addTab(keyboard, '&Keyboard Shortcuts') - - layout = QtWidgets.QVBoxLayout() - self.setLayout(layout) - layout.addWidget(tabs) - - # Bottom row of buttons - buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.StandardButton.Close) - buttons.rejected.connect(self.reject) - reset_btn = QtWidgets.QPushButton('&Restore Defaults') - reset_btn.setAutoDefault(False) - reset_btn.clicked.connect(self.on_restore_defaults) - buttons.addButton(reset_btn, - QtWidgets.QDialogButtonBox.ButtonRole.ActionRole) - - layout.addWidget(buttons) - self.show() - - def on_restore_defaults(self, *args, **kwargs): - reply = QtWidgets.QMessageBox.question( - self, - 'Restore defaults?', - 'Do you want to restore all settings to their default values?') - - if reply == QtWidgets.QMessageBox.StandardButton.Yes: - KeyboardSettings().restore_defaults() diff --git a/setup.cfg b/setup.cfg index e008106..0605e76 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,9 @@ +[flake8] +exclude = squashfs-root + [coverage:run] source = beeref [tool:pytest] +norecursedirs = squashfs-root addopts = --cov-report html --cov-config=setup.cfg \ No newline at end of file diff --git a/setup.py b/setup.py index 9951ace..e74cce7 100644 --- a/setup.py +++ b/setup.py @@ -19,9 +19,11 @@ setup( 'beeref', 'beeref.actions', 'beeref.assets', + 'beeref.config', 'beeref.documentation', 'beeref.fileio', 'beeref.widgets', + 'beeref.widgets.controls', ], entry_points={ 'gui_scripts': [ diff --git a/tests/actions/test_actions.py b/tests/actions/test_actions.py index bf0d8ea..e02899a 100644 --- a/tests/actions/test_actions.py +++ b/tests/actions/test_actions.py @@ -2,30 +2,35 @@ from unittest.mock import patch from PyQt6 import QtGui -from beeref.actions.actions import Action, ActionList +from beeref.actions.actions import Action + + +def test_action_str(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+R']) + assert str(action) == 'foo' def test_action_equals_true(): - action1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+R']}) - action2 = Action({'id': 'foo', 'text': 'Bar', 'shortcuts': ['Ctrl+F']}) + action1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+R']) + action2 = Action(id='foo', text='Bar', shortcuts=['Ctrl+F']) assert action1 == action2 def test_action_equals_false(): - action1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+R']}) - action2 = Action({'id': 'Bar', 'text': 'Foo', 'shortcuts': ['Ctrl+R']}) + action1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+R']) + action2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+R']) assert not action1 == action2 def test_action_on_restore_defaults(kbsettings, view): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+R']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+R']) action.qaction = QtGui.QAction('foo', view) action.on_restore_defaults() assert action.qaction.shortcuts() == ['Ctrl+R'] def test_action_on_restore_defaults_when_no_defaults(kbsettings, view): - action = Action({'id': 'foo'}) + action = Action(id='foo', text='Foo') action.qaction = QtGui.QAction('foo', view) action.qaction.setShortcuts(['Ctrl+R']) action.on_restore_defaults() @@ -33,105 +38,105 @@ def test_action_on_restore_defaults_when_no_defaults(kbsettings, view): def test_action_get_overwritten_shortcuts(kbsettings): - kbsettings.set_shortcuts('Actions', 'foo', ['Alt+O']) - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + kbsettings.set_list('Actions', 'foo', ['Alt+O']) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) assert action.get_shortcuts() == ['Alt+O'] def test_action_get_shortcuts_gets_default(kbsettings): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) assert action.get_shortcuts() == ['Ctrl+F'] def test_action_set_shortcuts_when_no_qaction(kbsettings): - action = Action({'id': 'foo'}) + action = Action(id='foo', text='Foo') action.qaction = None action.set_shortcuts(['Ctrl+F']) - assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F'] + assert kbsettings.get_list('Actions', 'foo') == ['Ctrl+F'] def test_action_set_shortcuts_when_qaction(kbsettings, view): - action = Action({'id': 'foo'}) + action = Action(id='foo', text='Foo') action.qaction = QtGui.QAction('foo', view) action.set_shortcuts(['Ctrl+F']) - assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F'] + assert kbsettings.get_list('Actions', 'foo') == ['Ctrl+F'] assert action.qaction.shortcuts() == ['Ctrl+F'] def test_action_get_qkeysequence_first(kbsettings): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) assert action.get_qkeysequence(0) == QtGui.QKeySequence('Ctrl+F') def test_action_get_qkeysequence_second(kbsettings): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F', 'Ctrl+B']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F', 'Ctrl+B']) assert action.get_qkeysequence(1) == QtGui.QKeySequence('Ctrl+B') def test_action_get_qkeysequence_first_when_not_set(kbsettings): - action = Action({'id': 'foo'}) + action = Action(id='foo', text='Foo') assert action.get_qkeysequence(0) == QtGui.QKeySequence() def test_action_get_qkeysequence_second_when_not_set(kbsettings): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) assert action.get_qkeysequence(1) == QtGui.QKeySequence() def test_action_shortcuts_changed_when_not_changed(kbsettings): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) assert action.shortcuts_changed() is False def test_action_shortcuts_changed_when_changed(kbsettings): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) - kbsettings.set_shortcuts('Actions', 'foo', ['Ctrl+B']) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + kbsettings.set_list('Actions', 'foo', ['Ctrl+B']) assert action.shortcuts_changed() is True def test_action_shortcuts_changed_when_empty(kbsettings): - action = Action({'id': 'foo'}) + action = Action(id='foo', text='Foo') assert action.shortcuts_changed() is False def test_action_get_default_shortcut_first(): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) assert action.get_default_shortcut(0) == 'Ctrl+F' def test_action_get_default_shortcut_second(): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F', 'Ctrl+B']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F', 'Ctrl+B']) assert action.get_default_shortcut(1) == 'Ctrl+B' def test_action_get_default_shortcut_first_when_none_set(): - action = Action({'id': 'foo'}) + action = Action(id='foo', text='Foo') assert action.get_default_shortcut(0) is None def test_action_get_default_shortcut_second_when_none_set(): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) assert action.get_default_shortcut(1) is None @patch('beeref.actions.actions.menu_structure', [{'menu': 'Foo', 'items': ['bar', 'baz']}]) def test_action_menu_path(): - action = Action({'id': 'baz'}) + action = Action(id='baz', text='Foo') assert action.menu_path == ['Foo'] @patch('beeref.actions.actions.menu_structure', [{'menu': 'Foo', 'items': [{'menu': 'Bar', 'items': ['baz']}]}]) def test_action_menu_path_with_submenus(): - action = Action({'id': 'baz'}) + action = Action(id='baz', text='Foo') assert action.menu_path == ['Foo', 'Bar'] @patch('beeref.actions.actions.menu_structure', [{'menu': 'Foo', 'items': '_build_recent_files'}]) def test_action_menu_path_recent_files(): - action = Action({'id': 'baz', 'menu_id': '_build_recent_files'}) + action = Action(id='baz', text='Foo', menu_id='_build_recent_files') assert action.menu_path == ['Foo'] @@ -139,21 +144,5 @@ def test_action_menu_path_recent_files(): [{'menu': 'Foo', 'items': [ {'menu': 'Bar', 'items': '_build_recent_files'}]}]) def test_action_menu_path_recent_files_in_submenu(): - action = Action({'id': 'baz', 'menu_id': '_build_recent_files'}) + action = Action(id='baz', text='Foo', menu_id='_build_recent_files') assert action.menu_path == ['Foo', 'Bar'] - - -def test_actionlist_inits_dict(): - action1 = Action({'id': 'foo'}) - action2 = Action({'id': 'bar'}) - actionlist = ActionList([action1, action2]) - actionlist['foo'] == action1 - actionlist['bar'] == action2 - - -def test_actionlist_acts_as_list(): - action1 = Action({'id': 'foo'}) - action2 = Action({'id': 'bar'}) - actionlist = ActionList([action1, action2]) - actionlist[0] == action1 - actionlist[1] == action2 diff --git a/tests/actions/test_mixin.py b/tests/actions/test_mixin.py index 50b5e54..ef400b6 100644 --- a/tests/actions/test_mixin.py +++ b/tests/actions/test_mixin.py @@ -4,8 +4,9 @@ from unittest.mock import patch, MagicMock, call from PyQt6 import QtWidgets from beeref.actions import ActionsMixin -from beeref.actions.actions import Action, ActionList +from beeref.actions.actions import Action from beeref.actions.menu_structure import MENU_SEPARATOR +from beeref.utils import ActionList class FooWidget(QtWidgets.QWidget, ActionsMixin): @@ -28,13 +29,13 @@ class FooWidget(QtWidgets.QWidget, ActionsMixin): @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': ['foo']}]) @patch('beeref.actions.mixin.actions', - ActionList([Action({ - 'id': 'foo', - 'text': '&Foo', - 'shortcuts': ['Ctrl+F'], - 'callback': 'on_foo', - })])) -@patch('beeref.config.KeyboardSettings.get_shortcuts') + ActionList([Action( + id='foo', + text='&Foo', + shortcuts=['Ctrl+F'], + callback='on_foo', + )])) +@patch('beeref.config.KeyboardSettings.get_list') def test_create_actions(kb_mock, toggle_mock, trigger_mock, qapp): kb_mock.side_effect = lambda group, key, default: default widget = FooWidget() @@ -56,15 +57,15 @@ def test_create_actions(kb_mock, toggle_mock, trigger_mock, qapp): [{'menu': 'Foo', 'items': ['foo']}]) def test_create_actions_with_shortcut_from_settings(qapp, kbsettings): with patch('beeref.actions.mixin.actions', - ActionList([Action({ - 'id': 'foo', - 'text': '&Foo', - 'shortcuts': ['Ctrl+F'], - 'callback': 'on_foo'})])): + ActionList([Action( + id='foo', + text='&Foo', + shortcuts=['Ctrl+F'], + callback='on_foo')])): # Create Action inside the test function so that its # kbsettings get created after the kbsettings fixture changes # the file path - kbsettings.set_shortcuts('Actions', 'foo', ['Alt+O']) + kbsettings.set_list('Actions', 'foo', ['Alt+O']) widget = FooWidget() widget.build_menu_and_actions() qaction = widget.actions()[0] @@ -76,12 +77,12 @@ def test_create_actions_with_shortcut_from_settings(qapp, kbsettings): @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': ['foo']}]) @patch('beeref.actions.mixin.actions', - ActionList([Action({ - 'id': 'foo', - 'text': '&Foo', - 'checkable': True, - 'callback': 'on_foo', - })])) + ActionList([Action( + id='foo', + text='&Foo', + checkable=True, + callback='on_foo', + )])) def test_create_actions_checkable(toggle_mock, trigger_mock, qapp): widget = FooWidget() widget.build_menu_and_actions() @@ -100,13 +101,13 @@ def test_create_actions_checkable(toggle_mock, trigger_mock, qapp): @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': ['foo']}]) @patch('beeref.actions.mixin.actions', - ActionList([Action({ - 'id': 'foo', - 'text': '&Foo', - 'checkable': True, - 'checked': True, - 'callback': 'on_foo', - })])) + ActionList([Action( + id='foo', + text='&Foo', + checkable=True, + checked=True, + callback='on_foo', + )])) def test_create_actions_checkable_checked_true( toggle_mock, trigger_mock, qapp): widget = FooWidget() @@ -127,13 +128,13 @@ def test_create_actions_checkable_checked_true( @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': ['foo']}]) @patch('beeref.actions.mixin.actions', - ActionList([Action({ - 'id': 'foo', - 'text': '&Foo', - 'checkable': True, - 'settings': 'foo/bar', - 'callback': 'on_foo', - })])) + ActionList([Action( + id='foo', + text='&Foo', + checkable=True, + settings='foo/bar', + callback='on_foo', + )])) def test_create_actions_checkable_with_settings( toggle_mock, settings_mock, callback_mock, qapp): widget = FooWidget() @@ -150,12 +151,12 @@ def test_create_actions_checkable_with_settings( @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': ['foo']}]) @patch('beeref.actions.mixin.actions', - ActionList([Action({ - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'group': 'bar', - })])) + ActionList([Action( + id='foo', + text='&Foo', + callback='on_foo', + group='bar', + )])) def test_create_actions_with_group(qapp): widget = FooWidget() widget.build_menu_and_actions() @@ -167,11 +168,11 @@ def test_create_actions_with_group(qapp): @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': ['foo']}]) @patch('beeref.actions.mixin.actions', - ActionList([Action({ - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - })])) + ActionList([Action( + id='foo', + text='&Foo', + callback='on_foo', + )])) def test_build_menu_and_actions_with_actions(qapp): widget = FooWidget() with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock: @@ -195,11 +196,11 @@ def test_build_menu_and_actions_with_separator(qapp): @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': [{'menu': 'Bar', 'items': ['foo']}]}]) @patch('beeref.actions.mixin.actions', - ActionList([Action({ - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - })])) + ActionList([Action( + id='foo', + text='&Foo', + callback='on_foo', + )])) def test_build_menu_and_actions_with_submenu(qapp): widget = FooWidget() with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock: @@ -216,18 +217,18 @@ def test_build_menu_and_actions_with_submenu(qapp): [{'menu': 'Foo', 'items': ['foo']}]) @patch('beeref.actions.mixin.actions', ActionList([ - Action({ - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'group': 'g1', - }), - Action({ - 'id': 'bar', - 'text': '&Bar', - 'callback': 'on_foo', - 'group': 'g2', - }), + Action( + id='foo', + text='&Foo', + callback='on_foo', + group='g1', + ), + Action( + id='bar', + text='&Bar', + callback='on_foo', + group='g2', + ), ])) def test_actiongroup_set_enabled(qapp): widget = FooWidget() @@ -241,12 +242,12 @@ def test_actiongroup_set_enabled(qapp): @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': ['foo']}]) @patch('beeref.actions.mixin.actions', - ActionList([Action({ - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'group': 'active_when_selection', - })])) + ActionList([Action( + id='foo', + text='&Foo', + callback='on_foo', + group='active_when_selection', + )])) def test_build_menu_and_actions_disables_actiongroups(qapp): widget = FooWidget() widget.scene.has_selection.return_value = False @@ -256,7 +257,7 @@ def test_build_menu_and_actions_disables_actiongroups(qapp): @patch('PyQt6.QtGui.QAction.triggered') -@patch('beeref.config.KeyboardSettings.get_shortcuts') +@patch('beeref.config.KeyboardSettings.get_list') @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': '_build_recent_files'}]) @patch('beeref.actions.mixin.actions', ActionList([])) @@ -294,7 +295,7 @@ def test_create_recent_files_more_than_10_files( @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': '_build_recent_files'}]) @patch('beeref.actions.mixin.actions', ActionList([])) -@patch('beeref.config.KeyboardSettings.get_shortcuts') +@patch('beeref.config.KeyboardSettings.get_list') def test_create_recent_files_fewer_files_than_10_files( kb_mock, triggered_mock, qapp): kb_mock.side_effect = lambda group, key, default: default @@ -329,7 +330,7 @@ def test_create_recent_files_fewer_files_than_10_files( @patch('beeref.actions.mixin.menu_structure', [{'menu': 'Foo', 'items': '_build_recent_files'}]) @patch('beeref.actions.mixin.actions', ActionList([])) -@patch('beeref.config.KeyboardSettings.get_shortcuts') +@patch('beeref.config.KeyboardSettings.get_list') def test_create_recent_files_when_no_files(kb_mock, qapp): kb_mock.side_effect = lambda group, key, default: default widget = FooWidget() diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config/test_controls.py b/tests/config/test_controls.py new file mode 100644 index 0000000..c48a5a1 --- /dev/null +++ b/tests/config/test_controls.py @@ -0,0 +1,510 @@ +from unittest.mock import patch, MagicMock + +from PyQt6.QtCore import Qt + +from beeref.config.controls import ( + KeyboardSettings, + MouseConfig, + MouseWheelConfig, +) + + +def test_mousewheelconfig_eq(): + action1 = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + action2 = MouseWheelConfig( + id='foo', group='foobar', text='Baz', modifiers=[], invertible=False) + assert action1 == action2 + + +def test_mousewheelconfig_str(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + assert str(action) == 'foo' + + +def test_mousewheelconfig_kb_settings(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + assert isinstance(action.kb_settings, KeyboardSettings) + + +def test_mousewheelconfig_get_modifiers(kbsettings): + kbsettings.set_list('MouseWheel', 'foo_modifiers', ['Ctrl', 'Shift']) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + assert action.get_modifiers() == ['Ctrl', 'Shift'] + + +def test_mousewheelconfig_get_modifiers_default(kbsettings): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=False) + assert action.get_modifiers() == ['Shift'] + + +def test_mousewheelconfig_set_modifiers(kbsettings): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=False) + action.set_modifiers(['Shift']) + assert kbsettings.get_list('MouseWheel', 'foo_modifiers') == ['Shift'] + + +def test_mousewheelconfig_set_modifiers_default(kbsettings): + kbsettings.set_list('MouseWheel', 'foo_modifiers', ['Alt', 'Ctrl']) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', + modifiers=['Shift'], invertible=False) + action.set_modifiers(['Shift']) + assert kbsettings.value('MouseWheel/foo_modifiers') is None + + +def test_mousewheelconfig_get_inverted(kbsettings): + kbsettings.set_value('MouseWheel', 'foo_inverted', True) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + assert action.get_inverted() is True + + +def test_mousewheelconfig_get_inverted_default(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + assert action.get_inverted() is False + + +def test_mousewheelconfig_set_inverted(kbsettings): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + action.set_inverted(True) + assert kbsettings.get_value('MouseWheel', 'foo_inverted') is True + + +def test_mousewheelconfig_set_inverted_default(kbsettings): + kbsettings.set_value('MouseWheel', 'foo_inverted', True) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + action.set_inverted(False) + assert kbsettings.value('MouseWheel/foo_inverted') is None + + +def test_mousewheelconfig_modifiers_to_qt_multiple(): + assert MouseWheelConfig.modifiers_to_qt(['Shift', 'Ctrl']) == ( + Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier) + + +def test_mousewheelconfig_modifiers_to_single(): + assert MouseWheelConfig.modifiers_to_qt(['Alt']) ==\ + Qt.KeyboardModifier.AltModifier + + +def test_mousewheelconfig_controls_changed_not_changed(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + assert action.controls_changed() is False + + +def test_mousewheelconfig_controls_changed_modifiers_changed(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action.set_modifiers(['Alt']) + assert action.controls_changed() is True + + +def test_mousewheelconfig_controls_changed_inverted_changed(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action.set_inverted(True) + assert action.controls_changed() is True + + +def test_mousewheelconfig_controls_is_configured_true(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['No Modifiers'], + invertible=True) + assert action.is_configured() is True + + +def test_mousewheelconfig_controls_is_configured_false(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], invertible=True) + assert action.is_configured() is False + + +def test_mousewheelconfig_controls_remove_controls(): + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action.set_inverted(True) + action.remove_controls() + assert action.get_modifiers() == [] + assert action.get_inverted() is False + + +def test_mousewheelconfig_conflicts_with_true(): + action1 = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action2 = MouseWheelConfig( + id='bar', group='foobar', text='Bar', modifiers=['Shift'], + invertible=True) + assert action1.conflicts_with(action2) is True + + +def test_mousewheelconfig_conflicts_with_false(): + action1 = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Shift'], + invertible=True) + action2 = MouseWheelConfig( + id='bar', group='foobar', text='Bar', modifiers=['Shift', 'Ctrl'], + invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mousewheelconfig_conflicts_false_when_both_not_configured(): + action1 = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], + invertible=True) + action2 = MouseWheelConfig( + id='bar', group='foobar', text='Bar', modifiers=[], + invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mousewheelconfig_matches_event_true(): + event = MagicMock( + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Alt'], + invertible=True) + assert action.matches_event(event) is True + + +def test_mousewheelconfig_matches_event_false(): + event = MagicMock( + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=['Ctrl'], + invertible=True) + assert action.matches_event(event) is False + + +def test_mousewheelconfig_matches_event_false_when_not_configured(): + event = MagicMock( + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseWheelConfig( + id='foo', group='foobar', text='Foo', modifiers=[], + invertible=True) + assert action.matches_event(event) is False + + +def test_mouseconfig_get_button(kbsettings): + kbsettings.set_value('Mouse', 'foo_button', 'Left') + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', modifiers=[], + invertible=False) + assert action.get_button() == 'Left' + + +def test_mouseconfig_get_button_default(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', modifiers=[], + invertible=False) + assert action.get_button() == 'Left' + + +def test_mouseconfig_set_button(kbsettings): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', modifiers=[], + invertible=False) + action.set_button('Middle') + assert kbsettings.get_value('Mouse', 'foo_button') == 'Middle' + + +def test_mouseconfig_set_button_default(kbsettings): + kbsettings.set_value('Mouse', 'foo_button', 'Middle') + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', modifiers=[], + invertible=False) + action.set_button('Middle') + assert kbsettings.value('Mouse/foo_button') is None + + +def test_mouseconfig_conflicts_with_true(): + action1 = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', + modifiers=['Shift'], invertible=True) + action2 = MouseConfig( + id='bar', group='foobar', text='Bar', button='Left', + modifiers=['Shift'], invertible=True) + assert action1.conflicts_with(action2) is True + + +def test_mouseconfig_conflicts_with_false_when_diff_buttons(): + action1 = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', + modifiers=['Shift'], invertible=True) + action2 = MouseConfig( + id='bar', group='foobar', text='Bar', button='Middle', + modifiers=['Shift'], invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mouseconfig_conflicts_with_false_when_diff_modifiers(): + action1 = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action2 = MouseConfig( + id='bar', group='foobar', text='Bar', button='Middle', + modifiers=['Shift', 'Ctrl'], invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mouseconfig_conflicts_false_when_both_not_configured(): + action1 = MouseConfig( + id='foo', group='foobar', text='Foo', button='Not Configured', + modifiers=[], invertible=True) + action2 = MouseConfig( + id='bar', group='foobar', text='Bar', button='Not Configured', + modifiers=[], invertible=True) + assert action1.conflicts_with(action2) is False + + +def test_mouseconfig_is_configured_false(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=[], invertible=True) + action.set_button('Not Configured') + assert action.conflicts_with(action) is False + + +def test_mouseconfig_is_configured_true(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Not Configured', + modifiers=[], invertible=True) + action.set_button('Left') + assert action.conflicts_with(action) is True + + +def test_mouseconfig_controls_changed_false(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.controls_changed() is False + + +def test_mouseconfig_controls_changed_true_when_button_changed(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.set_button('Left') + action.controls_changed() is True + + +def test_mouseconfig_controls_changed_true_when_modifiers_changed(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.set_modifiers(['Shift', 'Ctrl']) + action.controls_changed() is True + + +def test_mouseconfig_controls_changed_true_when_inverted_changed(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.set_inverted(True) + action.controls_changed() is True + + +def test_mouseconfig_remove_controls(): + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Shift'], invertible=True) + action.set_inverted(True) + + action.remove_controls() + assert action.get_button() == 'Not Configured' + assert action.get_modifiers() == [] + assert action.get_inverted() is False + + +def test_mouseconfig_matches_event_true(): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.LeftButton), + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', + modifiers=['Alt'], invertible=True) + assert action.matches_event(event) is True + + +def test_mouseconfig_matches_event_false_when_diff_button(): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.LeftButton), + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Middle', + modifiers=['Alt'], invertible=True) + assert action.matches_event(event) is False + + +def test_mouseconfig_matches_event_false_when_diff_modifiers(): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.LeftButton), + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Left', + modifiers=['Ctrl'], invertible=True) + assert action.matches_event(event) is False + + +def test_mouseconfig_matches_event_false_when_not_configured(): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.LeftButton), + modifiers=MagicMock(return_value=Qt.KeyboardModifier.AltModifier)) + action = MouseConfig( + id='foo', group='foobar', text='Foo', button='Not Configured', + modifiers=[], invertible=True) + assert action.matches_event(event) is False + + +def test_keyboardsettings_set_value(kbsettings): + kbsettings.set_value('mygroup', 'foo', 'bar') + assert kbsettings.value('mygroup/foo', 'bar') + + +def test_keyboardsettings_set_value_default_value(kbsettings): + kbsettings.setValue('mygroup/foo', 'bar') + kbsettings.set_value('mygroup', 'foo', 'baz', 'baz') + assert kbsettings.value('mygroup/foo') is None + + +def test_keyboardsettings_get_value_existing(kbsettings): + kbsettings.set_value('mygroup', 'bar', 'foo') + value = kbsettings.get_value('mygroup', 'bar', 'baz') + assert value == 'foo' + + +def test_keyboardsettings_get_value_default(kbsettings): + assert kbsettings.get_value('mygroup', 'bar', 'baz') == 'baz' + + +def test_keyboardsettings_set_list(kbsettings): + kbsettings.set_list('mygroup', 'foo', ['Ctrl+F']) + assert kbsettings.get_list('mygroup', 'foo') == ['Ctrl+F'] + + +def test_keyboardsettings_set_list_multiple(kbsettings): + kbsettings.set_list('mygroup', 'foo', ['Ctrl+F', 'Alt+O']) + assert kbsettings.get_list('mygroup', 'foo') == ['Ctrl+F', 'Alt+O'] + + +def test_keyboardsettings_set_list_default_value(kbsettings): + kbsettings.setValue('mygroup/foo', 'Ctrl+F') + kbsettings.set_list('mygroup', 'foo', 'Ctrl+B', 'Ctrl+B') + assert kbsettings.value('mygroup/foo') is None + + +def test_keyboardsettings_get_list_existing(kbsettings): + kbsettings.set_list('mygroup', 'bar', ['Ctrl+R']) + shortcuts = kbsettings.get_list('mygroup', 'bar', ['Ctrl+B']) + assert shortcuts == ['Ctrl+R'] + + +def test_keyboardsettings_get_list_existing_empty_list(kbsettings): + kbsettings.set_list('mygroup', 'bar', []) + shortcuts = kbsettings.get_list('mygroup', 'bar', ['Ctrl+B']) + assert shortcuts == [] + + +def test_keyboardsettings_get_list_default(kbsettings): + shortcuts = kbsettings.get_list('mygroup', 'bar', ['Ctrl+B']) + assert shortcuts == ['Ctrl+B'] + + +@patch('beeref.config.KeyboardSettings.setValue') +@patch('beeref.config.KeyboardSettings.remove') +def test_keyboardsettings_set_list_other_than_default_saves( + remove_mock, set_mock, kbsettings): + kbsettings.set_list('mygroup', 'bar', ['Ctrl+R'], ['Ctrl+Z']) + set_mock.assert_called_once_with('mygroup/bar', 'Ctrl+R') + remove_mock.assert_not_called() + + +@patch('beeref.config.KeyboardSettings.setValue') +@patch('beeref.config.KeyboardSettings.remove') +def test_keyboardsettings_set_list_with_than_default_doesnt_save( + remove_mock, set_mock, kbsettings): + kbsettings.set_list('mygroup', 'bar', ['Ctrl+R'], ['Ctrl+R']) + set_mock.assert_not_called() + remove_mock.assert_called_once_with('mygroup/bar') + + +@patch('PyQt6.QtGui.QAction.setShortcuts') +def test_keyboardsettings_restore_defaults_restores(shortcut_mock, kbsettings): + kbsettings.setValue('Actions/bar', 'Ctrl+R') + kbsettings.restore_defaults() + assert kbsettings.contains('Actions/bar') is False + + +def test_keyboardsettings_mousewheel_action_for_event_finds(kbsettings): + action = kbsettings.MOUSEWHEEL_ACTIONS['zoom1'] + action.set_modifiers(['Shift', 'Ctrl', 'Alt']) + action.set_inverted(True) + + event = MagicMock( + modifiers=MagicMock( + return_value=(Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier))) + + group, inverted = kbsettings.mousewheel_action_for_event(event) + assert group == 'zoom' + assert inverted is True + + +def test_keyboardsettings_mousewheel_action_for_event_empty(kbsettings): + event = MagicMock( + modifiers=MagicMock( + return_value=(Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier))) + + group, inverted = kbsettings.mousewheel_action_for_event(event) + assert group is None + assert inverted is None + + +def test_keyboardsettings_mouse_action_for_event_finds(kbsettings): + action = kbsettings.MOUSE_ACTIONS['zoom1'] + action.set_button('Middle') + action.set_modifiers(['Shift', 'Ctrl', 'Alt']) + action.set_inverted(True) + + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.MiddleButton), + modifiers=MagicMock( + return_value=(Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier))) + + group, inverted = kbsettings.mouse_action_for_event(event) + assert group == 'zoom' + assert inverted is True + + +def test_keyboardsettings_mouse_action_for_event_empty(kbsettings): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButton.MiddleButton), + modifiers=MagicMock( + return_value=(Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier))) + + group, inverted = kbsettings.mouse_action_for_event(event) + assert group is None + assert inverted is None diff --git a/tests/test_config.py b/tests/config/test_settings.py similarity index 67% rename from tests/test_config.py rename to tests/config/test_settings.py index d40da3f..a78625b 100644 --- a/tests/test_config.py +++ b/tests/config/test_settings.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from beeref.config import CommandlineArgs +from beeref.config.settings import CommandlineArgs def test_command_line_args_singleton(): @@ -13,7 +13,7 @@ def test_command_line_args_singleton(): CommandlineArgs._instance = None -@patch('beeref.config.parser.parse_args') +@patch('beeref.config.settings.parser.parse_args') def test_command_line_args_with_check_forces_new_parsing(parse_mock): args1 = CommandlineArgs() args2 = CommandlineArgs(with_check=True) @@ -117,48 +117,3 @@ def test_settings_recent_files_update_respects_max_num(settings): assert len(recent) == 10 assert recent[0] == os.path.abspath('14.bee') assert recent[-1] == os.path.abspath('5.bee') - - -def test_keyboardsettings_set_shortcuts(kbsettings): - kbsettings.set_shortcuts('Actions', 'foo', ['Ctrl+F']) - assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F'] - - -def test_keyboardsettings_set_shortcuts_multiple(kbsettings): - kbsettings.set_shortcuts('Actions', 'foo', ['Ctrl+F', 'Alt+O']) - assert kbsettings.get_shortcuts('Actions', 'foo') == ['Ctrl+F', 'Alt+O'] - - -def test_keyboardsettings_get_shortcuts_existing(kbsettings): - kbsettings.set_shortcuts('Actions', 'bar', ['Ctrl+R']) - shortcuts = kbsettings.get_shortcuts('Actions', 'bar', ['Ctrl+B']) - assert shortcuts == ['Ctrl+R'] - - -def test_keyboardsettings_get_shortcuts_default(kbsettings): - shortcuts = kbsettings.get_shortcuts('Actions', 'bar', ['Ctrl+B']) - assert shortcuts == ['Ctrl+B'] - - -@patch('beeref.config.KeyboardSettings.setValue') -@patch('beeref.config.KeyboardSettings.remove') -def test_keyboardsettings_set_shortcuts_other_than_default_saves( - remove_mock, set_mock, kbsettings): - kbsettings.set_shortcuts('Actions', 'bar', ['Ctrl+R'], ['Ctrl+Z']) - set_mock.assert_called_once_with('Actions/bar', 'Ctrl+R') - remove_mock.assert_not_called() - - -@patch('beeref.config.KeyboardSettings.setValue') -@patch('beeref.config.KeyboardSettings.remove') -def test_keyboardsettings_set_shortcuts_with_than_default_doesnt_save( - remove_mock, set_mock, kbsettings): - kbsettings.set_shortcuts('Actions', 'bar', ['Ctrl+R'], ['Ctrl+R']) - set_mock.assert_not_called() - remove_mock.assert_called_once_with('Actions/bar') - - -def test_keyboardsettings_restore_defaults_restores(kbsettings): - kbsettings.setValue('Actions/bar', 'Ctrl+R') - kbsettings.restore_defaults() - assert kbsettings.contains('Actions/bar') is False diff --git a/tests/fileio/test_image.py b/tests/fileio/test_image.py index f309217..432a55f 100644 --- a/tests/fileio/test_image.py +++ b/tests/fileio/test_image.py @@ -29,6 +29,13 @@ def test_exif_rotated_image_exif_unpack_error(qapp, imgfilename3x3): assert img.isNull() is False +def test_exif_rotated_image_exif_notimplementederror(qapp, imgfilename3x3): + with patch('beeref.fileio.image.exif.Image.list_all', + side_effect=NotImplementedError()): + img = exif_rotated_image(imgfilename3x3) + assert img.isNull() is False + + @pytest.mark.parametrize('path,expected', [('test3x3.png', 'test3x3.png'), ('test3x3_orientation1.jpg', 'test3x3.jpg'), diff --git a/tests/test_utils.py b/tests/test_utils.py index 4d26161..ebd9492 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,6 +2,7 @@ import pytest from PyQt6 import QtCore, QtGui +from beeref.actions.actions import Action from beeref import utils @@ -78,3 +79,19 @@ def test_get_file_extension_from_format(formatstr, expected): ((255, 0, 0, 100), '#ff000064')]) def test_qcolor_to_hex(rgba, expected): assert utils.qcolor_to_hex(QtGui.QColor(*rgba)) == expected + + +def test_actionlist_inits_dict(): + action1 = Action(id='foo', text='Foo') + action2 = Action(id='bar', text='Bar') + actionlist = utils.ActionList([action1, action2]) + actionlist['foo'] == action1 + actionlist['bar'] == action2 + + +def test_actionlist_acts_as_list(): + action1 = Action(id='foo', text='Foo') + action2 = Action(id='bar', text='Bar') + actionlist = utils.ActionList([action1, action2]) + actionlist[0] == action1 + actionlist[1] == action2 diff --git a/tests/test_view.py b/tests/test_view.py index 1d4434c..3bee979 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -376,7 +376,7 @@ def test_on_action_settings(show_mock, view): show_mock.assert_called_once() -@patch('beeref.widgets.settings.KeyboardSettingsDialog.show') +@patch('beeref.widgets.controls.ControlsDialog.show') def test_on_action_keyboard_settings(show_mock, view): view.on_action_keyboard_settings() show_mock.assert_called_once() @@ -940,6 +940,19 @@ def test_wheel_event_zoom(zoom_mock, view): event.accept.assert_called_once_with() +@patch('beeref.view.BeeGraphicsView.zoom') +def test_wheel_event_zoom_custom_inverted(zoom_mock, view, kbsettings): + kbsettings.MOUSEWHEEL_ACTIONS['zoom2'].set_modifiers(['Alt']) + kbsettings.MOUSEWHEEL_ACTIONS['zoom2'].set_inverted(True) + event = MagicMock() + event.angleDelta.return_value = QtCore.QPointF(0.0, 40.0) + event.position.return_value = QtCore.QPointF(10.0, 20.0) + event.modifiers.return_value = Qt.KeyboardModifier.AltModifier + view.wheelEvent(event) + zoom_mock.assert_called_once_with(-40, QtCore.QPointF(10.0, 20.0)) + event.accept.assert_called_once_with() + + @patch('beeref.view.BeeGraphicsView.pan') def test_wheel_event_pan_vertically(pan_mock, view): event = MagicMock() @@ -948,7 +961,21 @@ def test_wheel_event_pan_vertically(pan_mock, view): event.modifiers.return_value = (Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier) view.wheelEvent(event) - pan_mock.assert_called_once_with(QtCore.QPointF(0, 20)) + pan_mock.assert_called_once_with(QtCore.QPointF(20, 0)) + event.accept.assert_called_once_with() + + +@patch('beeref.view.BeeGraphicsView.pan') +def test_wheel_event_pan_vertically_custom_inverted( + pan_mock, view, kbsettings): + kbsettings.MOUSEWHEEL_ACTIONS['pan_vertical2'].set_modifiers(['Alt']) + kbsettings.MOUSEWHEEL_ACTIONS['pan_vertical2'].set_inverted(True) + event = MagicMock() + event.angleDelta.return_value = QtCore.QPointF(0.0, 40.0) + event.position.return_value = QtCore.QPointF(10.0, 20.0) + event.modifiers.return_value = Qt.KeyboardModifier.AltModifier + view.wheelEvent(event) + pan_mock.assert_called_once_with(QtCore.QPointF(-20, 0)) event.accept.assert_called_once_with() @@ -959,7 +986,21 @@ def test_wheel_event_pan_horizontally(pan_mock, view): event.position.return_value = QtCore.QPointF(10.0, 20.0) event.modifiers.return_value = Qt.KeyboardModifier.ShiftModifier view.wheelEvent(event) - pan_mock.assert_called_once_with(QtCore.QPointF(20, 0)) + pan_mock.assert_called_once_with(QtCore.QPointF(0, 20)) + event.accept.assert_called_once_with() + + +@patch('beeref.view.BeeGraphicsView.pan') +def test_wheel_event_pan_horizontally_custom_inverted( + pan_mock, view, kbsettings): + kbsettings.MOUSEWHEEL_ACTIONS['pan_horizontal2'].set_modifiers(['Alt']) + kbsettings.MOUSEWHEEL_ACTIONS['pan_horizontal2'].set_inverted(True) + event = MagicMock() + event.angleDelta.return_value = QtCore.QPointF(0.0, 40.0) + event.position.return_value = QtCore.QPointF(10.0, 20.0) + event.modifiers.return_value = Qt.KeyboardModifier.AltModifier + view.wheelEvent(event) + pan_mock.assert_called_once_with(QtCore.QPointF(0, -20)) event.accept.assert_called_once_with() @@ -973,6 +1014,26 @@ def test_mouse_press_zoom(mouse_event_mock, view): assert view.active_mode == view.ZOOM_MODE assert view.event_start == QtCore.QPointF(10.0, 20.0) assert view.event_anchor == QtCore.QPointF(10.0, 20.0) + assert view.event_inverted is False + mouse_event_mock.assert_not_called() + event.accept.assert_called_once_with() + + +@patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent') +def test_mouse_press_zoom_custom_inverted(mouse_event_mock, view, kbsettings): + kbsettings.MOUSE_ACTIONS['zoom1'].set_button('Left') + kbsettings.MOUSE_ACTIONS['zoom1'].set_modifiers(['Alt', 'Shift']) + kbsettings.MOUSE_ACTIONS['zoom1'].set_inverted(True) + event = MagicMock() + event.position.return_value = QtCore.QPointF(10.0, 20.0) + event.button.return_value = Qt.MouseButton.LeftButton + event.modifiers.return_value = ( + Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier) + view.mousePressEvent(event) + assert view.active_mode == view.ZOOM_MODE + assert view.event_start == QtCore.QPointF(10.0, 20.0) + assert view.event_anchor == QtCore.QPointF(10.0, 20.0) + assert view.event_inverted is True mouse_event_mock.assert_not_called() event.accept.assert_called_once_with() @@ -982,7 +1043,7 @@ def test_mouse_press_pan_middle_drag(mouse_event_mock, view): event = MagicMock() event.position.return_value = QtCore.QPointF(10.0, 20.0) event.button.return_value = Qt.MouseButton.MiddleButton - event.modifiers.return_value = None + event.modifiers.return_value = Qt.KeyboardModifier.NoModifier view.mousePressEvent(event) assert view.active_mode == view.PAN_MODE assert view.event_start == QtCore.QPointF(10.0, 20.0) @@ -1136,6 +1197,7 @@ def test_mouse_move_zoom(zoom_mock, mouse_event_mock, view): view.active_mode = view.ZOOM_MODE view.event_anchor = QtCore.QPointF(55.0, 66.0) view.event_start = QtCore.QPointF(10.0, 20.0) + view.event_inverted = False event = MagicMock() event.position.return_value = QtCore.QPointF(10.0, 18.0) view.mouseMoveEvent(event) @@ -1144,6 +1206,21 @@ def test_mouse_move_zoom(zoom_mock, mouse_event_mock, view): event.accept.assert_called_once_with() +@patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent') +@patch('beeref.view.BeeGraphicsView.zoom') +def test_mouse_move_zoom_inverted(zoom_mock, mouse_event_mock, view): + view.active_mode = view.ZOOM_MODE + view.event_anchor = QtCore.QPointF(55.0, 66.0) + view.event_start = QtCore.QPointF(10.0, 20.0) + view.event_inverted = True + event = MagicMock() + event.position.return_value = QtCore.QPointF(10.0, 18.0) + view.mouseMoveEvent(event) + zoom_mock.assert_called_once_with(-40, QtCore.QPointF(55.0, 66.0)) + mouse_event_mock.assert_not_called() + event.accept.assert_called_once_with() + + @patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent') def test_mouse_move_sample_color(mouse_event_mock, view): view.active_mode = view.SAMPLE_COLOR_MODE diff --git a/tests/widgets/__init__.py b/tests/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/widgets/controls/test_controls.py b/tests/widgets/controls/test_controls.py new file mode 100644 index 0000000..b93357f --- /dev/null +++ b/tests/widgets/controls/test_controls.py @@ -0,0 +1,16 @@ +from unittest.mock import patch + +from PyQt6 import QtWidgets + +from beeref.widgets.controls import ControlsDialog + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.Yes) +@patch('beeref.config.KeyboardSettings.restore_defaults') +def test_controls_dialog_on_restore_defaults( + restore_mock, msg_mock, kbsettings, view): + dialog = ControlsDialog(view) + dialog.on_restore_defaults() + msg_mock.assert_called_once() + restore_mock.assert_called() diff --git a/tests/widgets/controls/test_keyboard.py b/tests/widgets/controls/test_keyboard.py new file mode 100644 index 0000000..0d016be --- /dev/null +++ b/tests/widgets/controls/test_keyboard.py @@ -0,0 +1,422 @@ +from unittest.mock import patch, MagicMock + +from PyQt6 import QtWidgets, QtCore, QtGui + +from beeref.actions.actions import Action +from beeref.widgets.controls.keyboard import ( + KeyboardShortcutsDelegate, + KeyboardShortcutsEditor, + KeyboardShortcutsModel, + KeyboardShortcutsProxy, +) +from beeref.utils import ActionList + + +def test_keyboard_shortcuts_editor_on_save_no_conflicts(view): + a1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+A') + editor.on_editing_finished() + assert editor.keySequence().toString() == 'Ctrl+A' + assert editor.remove_from_other is None + + +def test_keyboard_shortcuts_editor_on_save_reenter_existing_shortcut(view): + a1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+F') + editor.on_editing_finished() + assert editor.keySequence().toString() == 'Ctrl+F' + assert editor.remove_from_other is None + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.No) +def test_keyboard_shortcuts_editor_on_save_conflicts_cancel( + question_mock, view): + a1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+B') + editor.on_editing_finished() + assert editor.keySequence().toString() == 'Ctrl+F' + assert editor.remove_from_other is None + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.Yes) +def test_keyboard_shortcuts_editor_on_save_conflicts_confirm( + question_mock, view): + a1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+B') + editor.on_editing_finished() + assert editor.keySequence().toString() == 'Ctrl+B' + assert editor.remove_from_other == a2 + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.No) +def test_keyboard_shortcuts_editor_on_save_conflicts_cancel_when_no_shortcut( + question_mock, view): + a1 = Action(id='foo', text='Foo') + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([a1, a2])): + editor = KeyboardShortcutsEditor( + view, + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0))) + editor.setKeySequence('Ctrl+B') + editor.on_editing_finished() + assert editor.keySequence().toString() == '' + assert a2.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_delegate_setmodeldata(view): + a1 = Action(id='foo', text='Foo') + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([a1, a2])): + model = KeyboardShortcutsModel() + delegate = KeyboardShortcutsDelegate() + editor = delegate.createEditor(view, None, index=model.index(0, 2)) + editor.setKeySequence('Ctrl+F') + delegate.setModelData( + editor, model, index=model.index(0, 2)) + assert a1.get_shortcuts() == ['Ctrl+F'] + + +def test_keyboard_shortcuts_model_columncount(): + model = KeyboardShortcutsModel() + model.columnCount(None) == 4 + + +@patch('beeref.widgets.controls.keyboard.actions', + ActionList([ + Action(id='foo', text='Foo', shortcuts=['Ctrl+F']), + Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + ])) +def test_keyboard_shortcuts_model_rowcount(): + model = KeyboardShortcutsModel() + model.rowCount(None) == 2 + + +def test_keyboard_shortcuts_model_data_gets_text(): + action = Action(id='foo', text='&Foo', shortcuts=['Ctrl+F']) + action.menu_path = ['&Bar', 'Ba&z'] + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=0), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Bar: Baz: Foo' + + +def test_keyboard_shortcuts_model_data_gets_changed_when_not_changed(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value is None + + +def test_keyboard_shortcuts_model_data_gets_changed_when_changed(kbsettings): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + action.set_shortcuts(['Ctrl+B']) + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == '✎' + + +def test_keyboard_shortcuts_model_data_gets_shortcut(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Ctrl+F' + + +def test_keyboard_shortcuts_model_data_tooltip_changed_when_not_changed(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_keyboard_shortcuts_model_data_tooltip_changed_when_changed(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + action.set_shortcuts(['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Changed from default' + + +def test_keyboard_shortcuts_model_data_tooltip_shortcut_when_not_changed(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_keyboard_shortcuts_model_data_tooltip_shortcut_not_changed_not_set(): + action = Action(id='foo', text='Foo') + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_keyboard_shortcuts_model_data_tooltip_shortcut_when_changed(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + action.set_shortcuts(['Ctrl+B']) + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Ctrl+F' + + +def test_keyboard_shortcuts_model_data_tooltip_shortcut_changed_from_none(): + action = Action(id='foo', text='Foo') + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + action.set_shortcuts(['Ctrl+B']) + model = KeyboardShortcutsModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Not set' + + +def test_keyboard_shortcuts_model_setdata_saves(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert action.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_model_setdata_saves_second_shortcut(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert action.get_shortcuts() == ['Ctrl+F', 'Ctrl+B'] + + +def test_keyboard_shortcuts_model_setdata_saves_second_shortcut_no_first(): + action = Action(id='foo', text='Foo') + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert action.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_model_setdata_removes_duplicate(): + action = Action(id='foo', text='Foo', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([action])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert action.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_model_setdata_remove_from_other(): + a1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([a1, a2])): + model = KeyboardShortcutsModel() + model.setData( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + value=QtGui.QKeySequence('Ctrl+B'), + role=None, + remove_from_other=a2) + assert a1.get_shortcuts() == ['Ctrl+B'] + assert a2.get_shortcuts() == [] + + +def test_keyboard_shortcuts_model_headerdata(): + model = KeyboardShortcutsModel() + header = model.headerData( + 0, + QtCore.Qt.Orientation.Horizontal, + QtCore.Qt.ItemDataRole.DisplayRole) + assert header == 'Action' + + +def test_flags_first_column(): + model = KeyboardShortcutsModel() + flags = model.flags(model.index(0, 0)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_flags_shortcut_column(): + model = KeyboardShortcutsModel() + flags = model.flags(model.index(0, 2)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable) + + +@patch('beeref.widgets.controls.keyboard.actions', + ActionList([Action(id='bar', text='Bar'), + Action(id='foo', text='Foo'), + Action(id='baz', text='Baz')])) +def test_keyboard_shortcuts_proxy_data_unfiltered(): + proxy = KeyboardShortcutsProxy() + assert proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' + assert proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Foo' + assert proxy.data( + proxy.index(2, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' + + +@patch('beeref.widgets.controls.keyboard.actions', + ActionList([Action(id='bar', text='Bar'), + Action(id='foo', text='Foo'), + Action(id='baz', text='Baz')])) +def test_keyboard_shortcuts_proxy_data_filtered(): + proxy = KeyboardShortcutsProxy() + proxy.setFilterFixedString('b') + assert proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' + assert proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' + + +def test_keyboard_shortcuts_proxy_setdata_saves_correct_filtered_index(): + a1 = Action(id='bar', text='Bar') + a2 = Action(id='foo', text='Foo') + a3 = Action(id='baz', text='Baz') + + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([a1, a2, a3])): + proxy = KeyboardShortcutsProxy() + proxy.setFilterFixedString('b') + proxy.setData( + index=proxy.index(1, 2), + value=QtGui.QKeySequence('Ctrl+B'), + role=None) + assert a1.get_shortcuts() == [] + assert a2.get_shortcuts() == [] + assert a3.get_shortcuts() == ['Ctrl+B'] + + +def test_keyboard_shortcuts_proxy_setdata_remove_from_other(): + a1 = Action(id='foo', text='Foo', shortcuts=['Ctrl+F']) + a2 = Action(id='bar', text='Bar', shortcuts=['Ctrl+B']) + with patch('beeref.widgets.controls.keyboard.actions', + ActionList([a1, a2])): + proxy = KeyboardShortcutsProxy() + proxy.setData( + index=proxy.index(0, 2), + value=QtGui.QKeySequence('Ctrl+B'), + role=None, + remove_from_other=a2) + assert a1.get_shortcuts() == ['Ctrl+B'] + assert a2.get_shortcuts() == [] diff --git a/tests/widgets/controls/test_mouse.py b/tests/widgets/controls/test_mouse.py new file mode 100644 index 0000000..87e824b --- /dev/null +++ b/tests/widgets/controls/test_mouse.py @@ -0,0 +1,1257 @@ +from unittest.mock import patch, MagicMock + +from PyQt6 import QtWidgets, QtCore +from PyQt6.QtCore import Qt + +from beeref.config.controls import MouseConfig +from beeref.widgets.controls.mouse import ( + MouseDelegate, + MouseControlsEditor, + MouseModel, + MouseProxy, +) +from beeref.utils import ActionList + + +def test_mouse_editor_inits_buttons_and_modifiers_when_not_configured(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + assert editor.button_input.count() == 3 + assert editor.button_input.currentIndex() == 0 + + assert len(editor.checkboxes) == 6 + for checkbox in editor.checkboxes.values(): + assert checkbox.isChecked() is False + + +def test_mouse_editor_inits_buttons_and_modifiers_when_configured(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + assert editor.button_input.count() == 3 + assert editor.button_input.currentIndex() == 1 + + assert len(editor.checkboxes) == 6 + for key, checkbox in editor.checkboxes.items(): + if key in ('Alt', 'Ctrl'): + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_set_modifiers_no_modifier(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers_no_modifier() + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_on_modifiers_changed_when_no_modifiers_checked(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('No Modifier', Qt.CheckState.Checked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_on_modifiers_changed_when_a_modifier_checked(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['No Modifier'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('Alt', Qt.CheckState.Checked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is False + + +def test_mouse_editor_on_modifiers_changed_when_everything_unchecked(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('Alt', Qt.CheckState.Unchecked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_get_modifiers(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.checkboxes['Alt'].setChecked(True) + editor.get_modifiers() == ['Alt'] + + +def test_mouse_editor_get_modifiers_when_no_modifiers(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['No Modifier'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.get_modifiers() == ['No Modifier'] + + +def test_mouse_editor_get_modifiers_when_not_configured_cleaned(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['No Modifier'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.ignore_on_changed = False + + assert editor.get_modifiers(cleaned=True) == [] + + +def test_mouse_editor_get_modifiers_when_not_configured_cleaned_false(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['Shift'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.ignore_on_changed = False + + assert editor.get_modifiers(cleaned=False) == ['Shift', 'Alt'] + + +def test_mouse_editor_set_modifiers(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Alt', 'Shift']) + for key, checkbox in editor.checkboxes.items(): + if key in ['Alt', 'Shift']: + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mouse_editor_on_button_changed_when_button(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['No Modifier'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + for checkbox in editor.checkboxes.values(): + checkbox.setChecked(False) + checkbox.setEnabled(False) + editor.ignore_on_changed = False + + editor.on_button_changed() + for key, checkbox in editor.checkboxes.items(): + assert checkbox.isEnabled() is True + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + checkbox.isChecked() is False + + +def test_mouse_editor_on_button_changed_when_no_button(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=['No Modifier'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + for checkbox in editor.checkboxes.values(): + checkbox.setChecked(True) + checkbox.setEnabled(True) + editor.ignore_on_changed = False + + editor.on_button_changed() + for checkbox in editor.checkboxes.values(): + assert checkbox.isEnabled() is False + assert checkbox.isChecked() is False + + +def test_mouse_editor_set_modifiers_enabled_true(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + for checkbox in editor.checkboxes.values(): + checkbox.setEnabled(False) + + editor.set_modifiers_enabled(True) + for checkbox in editor.checkboxes.values(): + assert checkbox.isEnabled() is True + + +def test_mouse_editor_set_modifiers_enabled_false(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + for checkbox in editor.checkboxes.values(): + checkbox.setEnabled(True) + + editor.set_modifiers_enabled(False) + for checkbox in editor.checkboxes.values(): + assert checkbox.isEnabled() is False + + +def test_mouse_editor_get_button(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.button_input.setCurrentIndex(1) + assert editor.get_button() == 'Left' + + +def test_mouse_editor_set_button(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Left') + assert editor.button_input.currentIndex() == 1 + + +def test_mouse_editor_get_temp_action(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + tmp = editor.get_temp_action() + assert tmp.get_button() == 'Left' + assert tmp.get_modifiers() == ['Alt'] + + +def test_mouse_editor_reset_inputs(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Middle') + editor.set_modifiers(['Ctrl']) + editor.reset_inputs() + assert editor.get_button() == 'Left' + assert editor.get_modifiers() == ['Alt'] + + +@patch('beeref.widgets.controls.mouse.MouseControlsEditor.accept') +def test_mouse_editor_on_save_no_conflicts(accept_mock, view): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Middle') + editor.set_modifiers(['Shift']) + editor.on_save() + assert editor.get_button() == 'Middle' + assert editor.get_modifiers() == ['Shift'] + assert editor.remove_from_other is None + accept_mock.assert_called_once_with() + + +@patch('beeref.widgets.controls.mouse.MouseControlsEditor.accept') +def test_mouse_editor_on_save_reenter_existing_shortcut(accept_mock, view): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_save() + assert editor.get_button() == 'Left' + assert editor.get_modifiers() == ['Alt'] + assert editor.remove_from_other is None + accept_mock.assert_called_once_with() + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.No) +@patch('beeref.widgets.controls.mouse.MouseControlsEditor.accept') +def test_mouse_editor_on_save_conflicts_cancel(accept_mock, msg_mock, view): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Middle') + editor.set_modifiers(['Ctrl']) + editor.on_save() + assert editor.get_button() == 'Left' + assert editor.get_modifiers() == ['Alt'] + assert editor.remove_from_other is None + accept_mock.assert_not_called() + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.Yes) +@patch('beeref.widgets.controls.mouse.MouseControlsEditor.accept') +def test_mouse_editor_on_save_conflicts_confirm(accept_mock, msg_mock, view): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + editor = MouseControlsEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_button('Middle') + editor.set_modifiers(['Ctrl']) + editor.on_save() + assert editor.get_button() == 'Middle' + assert editor.get_modifiers() == ['Ctrl'] + assert editor.remove_from_other == a2 + accept_mock.assert_called_once_with() + + +def test_mouse_delegate_create_editor(view): + delegate = MouseDelegate() + model = MouseModel() + widget = delegate.createEditor( + view, QtWidgets.QStyleOptionViewItem(), index=model.index(0, 3)) + assert isinstance(widget.editor, MouseControlsEditor) + + +def test_mouse_delegate_setmodeldata(view): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + delegate = MouseDelegate() + model = MouseModel() + widget = delegate.createEditor( + view, QtWidgets.QStyleOptionViewItem(), index=model.index(0, 3)) + widget.editor.set_button('Middle') + widget.editor.set_modifiers(['Ctrl', 'Shift']) + + with patch.object(widget.editor, 'result', + return_value=QtWidgets.QDialog.DialogCode.Accepted): + delegate.setModelData(widget, model, index=model.index(0, 3)) + assert action.get_button() == 'Middle' + assert action.get_modifiers() == ['Shift', 'Ctrl'] + + +def test_mouse_model_columncount(): + model = MouseModel() + model.columnCount(None) == 4 + + +def test_mouse_model_rowcount(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + model = MouseModel() + model.rowCount(None) == 2 + + +def test_mouse_model_headerdata(): + model = MouseModel() + header = model.headerData( + 0, + QtCore.Qt.Orientation.Horizontal, + QtCore.Qt.ItemDataRole.DisplayRole) + assert header == 'Action' + + +def test_flags_first_column(): + model = MouseModel() + flags = model.flags(model.index(0, 0)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_flags_modifiers_column(): + model = MouseModel() + flags = model.flags(model.index(0, 2)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable) + + +def test_flags_inverted_column_when_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + flags = model.flags(model.index(0, 4)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable + | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + + +def test_flags_inverted_column_when_not_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + flags = model.flags(model.index(0, 4)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_mouse_model_data_gets_text(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=0), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Foo' + + +def test_mouse_model_data_gets_changed_when_not_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value is None + + +def test_mouse_model_data_gets_changed_when_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == '✎' + + +def test_mouse_model_data_gets_button(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Left' + + +def test_mouse_model_data_gets_modifiers(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Ctrl + Alt' + + +def test_mouse_model_data_gets_inverted_when_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'No' + + +def test_mouse_model_data_gets_inverted_when_not_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value is None + + +def test_mouse_model_data_tooltip_changed_when_not_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_mouse_model_data_tooltip_changed_when_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Changed from default' + + +def test_mouse_model_data_tooltip_button_when_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Left' + + +def test_mouse_model_data_tooltip_button_when_changed_from_not_configured(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Not configured' + + +def test_mouse_model_data_tooltip_modifiers_when_changed_from_not_configured(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Not Configured', + modifiers=[], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Not configured' + + +def test_mouse_model_data_tooltip_modifiers_when_changed(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Ctrl + Alt' + + +def test_mouse_model_data_tooltip_inverted_when_changed_and_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_inverted(True) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: No' + + +def test_mouse_model_data_tooltip_inverted_when_changed_and_not_invertible(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_button('Middle') + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_mouse_model_data_checkstaterole_invertible_invertedcol_inverted(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + action.set_inverted(True) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value == Qt.CheckState.Checked + + +def test_mouse_model_data_checkstaterole_invertible_invertedcol_not_inverted(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value == Qt.CheckState.Unchecked + + +def test_mouse_model_data_checkstaterole_other_column(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value is None + + +def test_mouse_model_setdate_saves_inverted(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=4), + row=MagicMock(return_value=0)), + value=Qt.CheckState.Checked.value, + role=None) + assert action.get_inverted() is True + + +def test_mouse_model_setdata_saves_controls(): + action = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([action])): + model = MouseModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value={'button': 'Middle', 'modifiers': ['Ctrl']}, + role=None) + + assert action.get_button() == 'Middle' + assert action.get_modifiers() == ['Ctrl'] + + +def test_mouse_model_setdata_saves_controls_and_removes_from_other(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a1, a2])): + model = MouseModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value={'button': 'Middle', 'modifiers': ['Ctrl']}, + role=None, + remove_from_other=a2) + + assert a1.get_button() == 'Middle' + assert a1.get_modifiers() == ['Ctrl'] + assert a2.get_button() == 'Not Configured' + assert a2.get_modifiers() == [] + + +def test_mouse_proxy_data_unfiltered(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseConfig( + id='baz1', + group='baz', + text='Baz', + button='Middle', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseProxy() + + assert proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' + assert proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Foo' + assert proxy.data( + proxy.index(2, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' + + +def test_mouse_proxy_data_filtered(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseConfig( + id='baz1', + group='baz', + text='Baz', + button='Middle', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseProxy() + + proxy.setFilterFixedString('b') + assert proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' + assert proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' + + +def test_mouse_proxy_setdata_saves_correct_filtered_index(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseConfig( + id='baz1', + group='baz', + text='Baz', + button='Middle', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseProxy() + + proxy.setFilterFixedString('b') + proxy.setData( + index=proxy.index(1, 3), + value={'button': 'Left', 'modifiers': ['Ctrl', 'Alt']}, + role=None) + + a3.get_button() == 'Left' + a3.get_modifiers() == ['Ctrl', 'Alt'] + + +def test_mouse_proxy_setdata_remove_from_other(): + a1 = MouseConfig( + id='foo1', + group='foo', + text='Foo', + button='Left', + modifiers=['Alt'], + invertible=True) + a2 = MouseConfig( + id='bar1', + group='bar', + text='Bar', + button='Middle', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSE_ACTIONS', + ActionList([a2, a1])): + proxy = MouseProxy() + + proxy.setData( + index=proxy.index(0, 3), + value={'button': 'Left', 'modifiers': ['Ctrl', 'Alt']}, + role=None, + remove_from_other=a2) + + a1.get_button() == 'Left' + a1.get_modifiers() == ['Ctrl', 'Alt'] + a2.get_button() == 'Not Configured' + a2.get_modifiers() == [] diff --git a/tests/widgets/controls/test_mousewheel.py b/tests/widgets/controls/test_mousewheel.py new file mode 100644 index 0000000..f58716c --- /dev/null +++ b/tests/widgets/controls/test_mousewheel.py @@ -0,0 +1,966 @@ +from unittest.mock import patch, MagicMock + +from PyQt6 import QtWidgets, QtCore +from PyQt6.QtCore import Qt + +from beeref.config.controls import MouseWheelConfig +from beeref.widgets.controls.mousewheel import ( + MouseWheelDelegate, + MouseWheelModifiersEditor, + MouseWheelModel, + MouseWheelProxy, +) +from beeref.utils import ActionList + + +def test_mousewheel_editor_inits_modifiers_when_not_configured(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + assert len(editor.checkboxes) == 6 + for checkbox in editor.checkboxes.values(): + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_inits_modifiers_when_configured(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + assert len(editor.checkboxes) == 6 + for key, checkbox in editor.checkboxes.items(): + if key in ('Alt', 'Ctrl'): + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_set_modifiers_no_modifier(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers_no_modifier() + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_on_modifiers_changed_no_modifiers_checked(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt', 'Ctrl'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('No Modifier', Qt.CheckState.Checked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_on_modifiers_changed_when_a_modifier_checked(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['No Modifier'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('Alt', Qt.CheckState.Checked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_on_modifiers_changed_everything_unchecked(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_modifiers_changed('Alt', Qt.CheckState.Unchecked.value) + for key, checkbox in editor.checkboxes.items(): + if key == 'No Modifier': + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_get_modifiers(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.checkboxes['Alt'].setChecked(True) + editor.get_modifiers() == ['Alt'] + + +def test_mousewheel_editor_get_modifiers_when_no_modifiers(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['No Modifier'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.get_modifiers() == ['No Modifier'] + + +def test_mousewheel_editor_get_modifiers_when_no_modifiers_cleaned_false(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.ignore_on_changed = True + editor.checkboxes['Shift'].setChecked(True) + editor.checkboxes['Alt'].setChecked(True) + editor.ignore_on_changed = False + + assert editor.get_modifiers(cleaned=False) == ['Shift', 'Alt'] + + +def test_mousewheel_editor_set_modifiers(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Alt', 'Shift']) + for key, checkbox in editor.checkboxes.items(): + if key in ['Alt', 'Shift']: + assert checkbox.isChecked() is True + else: + assert checkbox.isChecked() is False + + +def test_mousewheel_editor_get_temp_action(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + tmp = editor.get_temp_action() + assert tmp.get_modifiers() == ['Alt'] + + +def test_mousewheel_editor_reset_inputs(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Ctrl']) + editor.reset_inputs() + assert editor.get_modifiers() == ['Alt'] + + +@patch('beeref.widgets.controls.mousewheel.MouseWheelModifiersEditor.accept') +def test_mousewheel_editor_on_save_no_conflicts(accept_mock, view): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Shift']) + editor.on_save() + assert editor.get_modifiers() == ['Shift'] + assert editor.remove_from_other is None + accept_mock.assert_called_once_with() + + +@patch('beeref.widgets.controls.mousewheel.MouseWheelModifiersEditor.accept') +def test_mousewheel_editor_on_save_reenter_existing_shortcut( + accept_mock, view): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.on_save() + assert editor.get_modifiers() == ['Alt'] + assert editor.remove_from_other is None + accept_mock.assert_called_once_with() + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.No) +@patch('beeref.widgets.controls.mousewheel.MouseWheelModifiersEditor.accept') +def test_mousewheel_editor_on_save_conflicts_cancel( + accept_mock, msg_mock, view): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Ctrl']) + editor.on_save() + assert editor.get_modifiers() == ['Alt'] + assert editor.remove_from_other is None + accept_mock.assert_not_called() + + +@patch('PyQt6.QtWidgets.QMessageBox.question', + return_value=QtWidgets.QMessageBox.StandardButton.Yes) +@patch('beeref.widgets.controls.mousewheel.MouseWheelModifiersEditor.accept') +def test_mousewheel_editor_on_save_conflicts_confirm( + accept_mock, msg_mock, view): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + editor = MouseWheelModifiersEditor( + view, index=MagicMock(row=MagicMock(return_value=0))) + + editor.set_modifiers(['Ctrl']) + editor.on_save() + assert editor.get_modifiers() == ['Ctrl'] + assert editor.remove_from_other == a2 + accept_mock.assert_called_once_with() + + +def test_mousewheel_delegate_create_editor(view): + delegate = MouseWheelDelegate() + model = MouseWheelModel() + widget = delegate.createEditor( + view, QtWidgets.QStyleOptionViewItem(), index=model.index(0, 3)) + assert isinstance(widget.editor, MouseWheelModifiersEditor) + + +def test_mousewheel_delegate_setmodeldata(view): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + delegate = MouseWheelDelegate() + model = MouseWheelModel() + widget = delegate.createEditor( + view, QtWidgets.QStyleOptionViewItem(), index=model.index(0, 2)) + widget.editor.set_modifiers(['Ctrl', 'Shift']) + + with patch.object(widget.editor, 'result', + return_value=QtWidgets.QDialog.DialogCode.Accepted): + delegate.setModelData(widget, model, index=model.index(0, 2)) + assert action.get_modifiers() == ['Shift', 'Ctrl'] + + +def test_mousewheel_model_columncount(): + model = MouseWheelModel() + model.columnCount(None) == 3 + + +def test_mousewheel_model_rowcount(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + model = MouseWheelModel() + model.rowCount(None) == 2 + + +def test_mousewheel_model_headerdata(): + model = MouseWheelModel() + header = model.headerData( + 0, + QtCore.Qt.Orientation.Horizontal, + QtCore.Qt.ItemDataRole.DisplayRole) + assert header == 'Action' + + +def test_flags_first_column(): + model = MouseWheelModel() + flags = model.flags(model.index(0, 0)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_flags_modifiers_column(): + model = MouseWheelModel() + flags = model.flags(model.index(0, 2)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable) + + +def test_flags_inverted_column_when_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + flags = model.flags(model.index(0, 4)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren + | QtCore.Qt.ItemFlag.ItemIsEditable + | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + + +def test_flags_inverted_column_when_not_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + flags = model.flags(model.index(0, 4)) + assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemNeverHasChildren) + + +def test_mousewheel_model_data_gets_text(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=0), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Foo' + + +def test_mousewheel_model_data_gets_changed_when_not_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value is None + + +def test_mousewheel_model_data_gets_changed_when_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == '✎' + + +def test_mousewheel_model_data_gets_modifiers(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'Ctrl + Alt' + + +def test_mousewheel_model_data_gets_inverted_when_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value == 'No' + + +def test_mousewheel_model_data_gets_inverted_when_not_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.DisplayRole) + assert value is None + + +def test_mousewheel_model_data_tooltip_changed_when_not_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_mousewheel_model_data_tooltip_changed_when_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=1), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Changed from default' + + +def test_mousewheel_model_data_tooltip_modifiers_changed_from_not_configured(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=[], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Not configured' + + +def test_mousewheel_model_data_tooltip_modifiers_when_changed(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Ctrl', 'Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: Ctrl + Alt' + + +def test_mousewheel_model_data_tooltip_inverted_when_changed_and_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_inverted(True) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value == 'Default: No' + + +def test_mousewheel_model_data_tooltip_inverted_changed_and_not_invertible(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=False) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_modifiers(['Shift']) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.ToolTipRole) + assert value is None + + +def test_mousewheel_model_data_checkstaterole_invertible_invertcol_inverted(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + action.set_inverted(True) + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value == Qt.CheckState.Checked + + +def test_mousewheel_model_data_checkstaterole_invertible_invcol_not_inverted(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value == Qt.CheckState.Unchecked + + +def test_mousewheel_model_data_checkstaterole_other_column(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + value = model.data( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + role=QtCore.Qt.ItemDataRole.CheckStateRole) + assert value is None + + +def test_mousewheel_model_setdate_saves_inverted(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=3), + row=MagicMock(return_value=0)), + value=Qt.CheckState.Checked.value, + role=None) + assert action.get_inverted() is True + + +def test_mousewheel_model_setdata_saves_controls(): + action = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([action])): + model = MouseWheelModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + value=['Ctrl'], + role=None) + + assert action.get_modifiers() == ['Ctrl'] + + +def test_mousewheel_model_setdata_saves_controls_and_removes_from_other(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a1, a2])): + model = MouseWheelModel() + + model.setData( + index=MagicMock( + column=MagicMock(return_value=2), + row=MagicMock(return_value=0)), + value=['Ctrl'], + role=None, + remove_from_other=a2) + + assert a1.get_modifiers() == ['Ctrl'] + assert a2.get_modifiers() == [] + + +def test_mousewheel_proxy_data_unfiltered(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseWheelConfig( + id='baz1', + group='baz', + text='Baz', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseWheelProxy() + + assert proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' + assert proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Foo' + assert proxy.data( + proxy.index(2, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' + + +def test_mousewheel_proxy_data_filtered(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseWheelConfig( + id='baz1', + group='baz', + text='Baz', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseWheelProxy() + + proxy.setFilterFixedString('b') + assert proxy.data( + proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' + assert proxy.data( + proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' + + +def test_mousewheel_proxy_setdata_saves_correct_filtered_index(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + a3 = MouseWheelConfig( + id='baz1', + group='baz', + text='Baz', + modifiers=['Shift'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a2, a1, a3])): + proxy = MouseWheelProxy() + + proxy.setFilterFixedString('b') + proxy.setData( + index=proxy.index(1, 3), + value={'modifiers': ['Ctrl', 'Alt']}, + role=None) + + a3.get_modifiers() == ['Ctrl', 'Alt'] + + +def test_mousewheel_proxy_setdata_remove_from_other(): + a1 = MouseWheelConfig( + id='foo1', + group='foo', + text='Foo', + modifiers=['Alt'], + invertible=True) + a2 = MouseWheelConfig( + id='bar1', + group='bar', + text='Bar', + modifiers=['Ctrl'], + invertible=True) + + with patch('beeref.config.controls.KeyboardSettings.MOUSEWHEEL_ACTIONS', + ActionList([a2, a1])): + proxy = MouseWheelProxy() + + proxy.setData( + index=proxy.index(0, 3), + value={'modifiers': ['Ctrl', 'Alt']}, + role=None, + remove_from_other=a2) + + a1.get_modifiers() == ['Ctrl', 'Alt'] + a2.get_modifiers() == [] diff --git a/tests/widgets/test_settings.py b/tests/widgets/test_settings.py index d519703..79c8ce6 100644 --- a/tests/widgets/test_settings.py +++ b/tests/widgets/test_settings.py @@ -1,16 +1,10 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import patch -from PyQt6 import QtWidgets, QtCore, QtGui +from PyQt6 import QtWidgets -from beeref.actions.actions import Action, ActionList from beeref.widgets.settings import ( ArrangeGapWidget, ImageStorageFormatWidget, - KeyboardSettingsDialog, - KeyboardShortcutsDelegate, - KeyboardShortcutsEditor, - KeyboardShortcutsModel, - KeyboardShortcutsProxy, SettingsDialog, ) @@ -100,401 +94,3 @@ def test_settings_dialog_on_restore_defaults(msg_mock, settings, view): msg_mock.assert_called_once() assert settings.valueOrDefault('Items/image_storage_format') == 'best' assert settings.valueOrDefault('Items/arrange_gap') == 0 - - -def test_keyboard_shortcuts_editor_no_conflicts(view): - a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) - a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']}) - with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): - editor = KeyboardShortcutsEditor( - view, - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0))) - editor.setKeySequence('Ctrl+A') - editor.on_editing_finished() - assert editor.keySequence().toString() == 'Ctrl+A' - assert editor.remove_from_other is None - - -def test_keyboard_shortcuts_editor_reenter_existing_shortcut(view): - a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) - a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']}) - with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): - editor = KeyboardShortcutsEditor( - view, - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0))) - editor.setKeySequence('Ctrl+F') - editor.on_editing_finished() - assert editor.keySequence().toString() == 'Ctrl+F' - assert editor.remove_from_other is None - - -@patch('PyQt6.QtWidgets.QMessageBox.question', - return_value=QtWidgets.QMessageBox.StandardButton.No) -def test_keyboard_shortcuts_editor_conflicts_choose_to_cancel( - question_mock, view): - a1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) - a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) - with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): - editor = KeyboardShortcutsEditor( - view, - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0))) - editor.setKeySequence('Ctrl+B') - editor.on_editing_finished() - assert editor.keySequence().toString() == 'Ctrl+F' - assert editor.remove_from_other is None - - -@patch('PyQt6.QtWidgets.QMessageBox.question', - return_value=QtWidgets.QMessageBox.StandardButton.Yes) -def test_keyboard_shortcuts_editor_conflicts_choose_to_confirm( - question_mock, view): - a1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) - a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) - with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): - editor = KeyboardShortcutsEditor( - view, - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0))) - editor.setKeySequence('Ctrl+B') - editor.on_editing_finished() - assert editor.keySequence().toString() == 'Ctrl+B' - assert editor.remove_from_other == a2 - - -@patch('PyQt6.QtWidgets.QMessageBox.question', - return_value=QtWidgets.QMessageBox.StandardButton.No) -def test_keyboard_shortcuts_editor_conflicts_choose_to_cancel_when_no_shortcut( - question_mock, view): - a1 = Action({'id': 'foo', 'text': 'Foo'}) - a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) - with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): - editor = KeyboardShortcutsEditor( - view, - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0))) - editor.setKeySequence('Ctrl+B') - editor.on_editing_finished() - assert editor.keySequence().toString() == '' - assert a2.get_shortcuts() == ['Ctrl+B'] - - -def test_keyboard_shortcuts_delegate_setmodeldata(view): - a1 = Action({'id': 'foo', 'text': 'Foo'}) - a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) - with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): - model = KeyboardShortcutsModel() - delegate = KeyboardShortcutsDelegate() - editor = delegate.createEditor(view, None, index=model.index(0, 2)) - editor.setKeySequence('Ctrl+F') - delegate.setModelData( - editor, model, index=model.index(0, 2)) - assert a1.get_shortcuts() == ['Ctrl+F'] - - -def test_keyboard_shortcuts_model_columncount(): - model = KeyboardShortcutsModel() - model.columnCount(None) == 4 - - -@patch('beeref.widgets.settings.actions', - ActionList([ - Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}), - Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']}) - ])) -def test_keyboard_shortcuts_model_rowcount(): - model = KeyboardShortcutsModel() - model.rowCount(None) == 2 - - -def test_keyboard_shortcuts_model_data_gets_text(): - action = Action({'id': 'foo', 'text': '&Foo', 'shortcuts': ['Ctrl+F']}) - action.menu_path = ['&Bar', 'Ba&z'] - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=0), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.DisplayRole) - assert value == 'Bar: Baz: Foo' - - -def test_keyboard_shortcuts_model_data_gets_changed_when_not_changed(): - action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=1), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.DisplayRole) - assert value is None - - -def test_keyboard_shortcuts_model_data_gets_changed_when_changed(kbsettings): - action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - action.set_shortcuts(['Ctrl+B']) - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=1), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.DisplayRole) - assert value == '✎' - - -def test_keyboard_shortcuts_model_data_gets_shortcut(): - action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.DisplayRole) - assert value == 'Ctrl+F' - - -def test_keyboard_shortcuts_model_data_tooltip_changed_when_not_changed(): - action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=1), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.ToolTipRole) - assert value is None - - -def test_keyboard_shortcuts_model_data_tooltip_changed_when_changed(): - action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) - action.set_shortcuts(['Ctrl+B']) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=1), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.ToolTipRole) - assert value == 'Changed from default' - - -def test_keyboard_shortcuts_model_data_tooltip_shortcut_when_not_changed(): - action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.ToolTipRole) - assert value is None - - -def test_keyboard_shortcuts_model_data_tooltip_shortcut_not_changed_not_set(): - action = Action({'id': 'foo', 'text': 'Foo'}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=3), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.ToolTipRole) - assert value is None - - -def test_keyboard_shortcuts_model_data_tooltip_shortcut_when_changed(): - action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - action.set_shortcuts(['Ctrl+B']) - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.ToolTipRole) - assert value == 'Default: Ctrl+F' - - -def test_keyboard_shortcuts_model_data_tooltip_shortcut_changed_from_none(): - action = Action({'id': 'foo', 'text': 'Foo'}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - action.set_shortcuts(['Ctrl+B']) - model = KeyboardShortcutsModel() - value = model.data( - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0)), - role=QtCore.Qt.ItemDataRole.ToolTipRole) - assert value == 'Default: -' - - -def test_keyboard_shortcuts_model_setdata_saves(): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - model.setData( - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0)), - value=QtGui.QKeySequence('Ctrl+B'), - role=None) - assert action.get_shortcuts() == ['Ctrl+B'] - - -def test_keyboard_shortcuts_model_setdata_saves_second_shortcut(): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - model.setData( - index=MagicMock( - column=MagicMock(return_value=3), - row=MagicMock(return_value=0)), - value=QtGui.QKeySequence('Ctrl+B'), - role=None) - assert action.get_shortcuts() == ['Ctrl+F', 'Ctrl+B'] - - -def test_keyboard_shortcuts_model_setdata_saves_second_shortcut_no_first(): - action = Action({'id': 'foo'}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - model.setData( - index=MagicMock( - column=MagicMock(return_value=3), - row=MagicMock(return_value=0)), - value=QtGui.QKeySequence('Ctrl+B'), - role=None) - assert action.get_shortcuts() == ['Ctrl+B'] - - -def test_keyboard_shortcuts_model_setdata_removes_duplicate(): - action = Action({'id': 'foo', 'shortcuts': ['Ctrl+B']}) - with patch('beeref.widgets.settings.actions', ActionList([action])): - model = KeyboardShortcutsModel() - model.setData( - index=MagicMock( - column=MagicMock(return_value=3), - row=MagicMock(return_value=0)), - value=QtGui.QKeySequence('Ctrl+B'), - role=None) - assert action.get_shortcuts() == ['Ctrl+B'] - - -def test_keyboard_shortcuts_model_setdata_remove_from_other(): - a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) - a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']}) - with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): - model = KeyboardShortcutsModel() - model.setData( - index=MagicMock( - column=MagicMock(return_value=2), - row=MagicMock(return_value=0)), - value=QtGui.QKeySequence('Ctrl+B'), - role=None, - remove_from_other=a2) - assert a1.get_shortcuts() == ['Ctrl+B'] - assert a2.get_shortcuts() == [] - - -def test_keyboard_shortcuts_model_headerdata(): - model = KeyboardShortcutsModel() - header = model.headerData( - 0, - QtCore.Qt.Orientation.Horizontal, - QtCore.Qt.ItemDataRole.DisplayRole) - assert header == 'Action' - - -def test_flags_first_column(): - model = KeyboardShortcutsModel() - flags = model.flags(model.index(0, 0)) - assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled - | QtCore.Qt.ItemFlag.ItemNeverHasChildren) - - -def test_flags_shortcut_column(): - model = KeyboardShortcutsModel() - flags = model.flags(model.index(0, 2)) - assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled - | QtCore.Qt.ItemFlag.ItemNeverHasChildren - | QtCore.Qt.ItemFlag.ItemIsEditable) - - -@patch('beeref.widgets.settings.actions', - ActionList([Action({'id': 'bar', 'text': 'Bar'}), - Action({'id': 'foo', 'text': 'Foo'}), - Action({'id': 'baz', 'text': 'Baz'})])) -def test_keyboard_shortcuts_proxy_data_unfiltered(): - proxy = KeyboardShortcutsProxy() - assert proxy.data( - proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' - assert proxy.data( - proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Foo' - assert proxy.data( - proxy.index(2, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' - - -@patch('beeref.widgets.settings.actions', - ActionList([Action({'id': 'bar', 'text': 'Bar'}), - Action({'id': 'foo', 'text': 'Foo'}), - Action({'id': 'baz', 'text': 'Baz'})])) -def test_keyboard_shortcuts_proxy_data_filtered(): - proxy = KeyboardShortcutsProxy() - proxy.setFilterFixedString('b') - assert proxy.data( - proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar' - assert proxy.data( - proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz' - - -def test_keyboard_shortcuts_proxy_setdata_saves_correct_filtered_index(): - a1 = Action({'id': 'bar', 'text': 'Bar'}) - a2 = Action({'id': 'foo', 'text': 'Foo'}) - a3 = Action({'id': 'baz', 'text': 'Baz'}) - - with patch('beeref.widgets.settings.actions', ActionList([a1, a2, a3])): - proxy = KeyboardShortcutsProxy() - proxy.setFilterFixedString('b') - proxy.setData( - index=proxy.index(1, 2), - value=QtGui.QKeySequence('Ctrl+B'), - role=None) - assert a1.get_shortcuts() == [] - assert a2.get_shortcuts() == [] - assert a3.get_shortcuts() == ['Ctrl+B'] - - -def test_keyboard_shortcuts_proxy_setdata_remove_from_other(): - a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']}) - a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']}) - with patch('beeref.widgets.settings.actions', ActionList([a1, a2])): - proxy = KeyboardShortcutsProxy() - proxy.setData( - index=proxy.index(0, 2), - value=QtGui.QKeySequence('Ctrl+B'), - role=None, - remove_from_other=a2) - assert a1.get_shortcuts() == ['Ctrl+B'] - assert a2.get_shortcuts() == [] - - -@patch('PyQt6.QtWidgets.QMessageBox.question', - return_value=QtWidgets.QMessageBox.StandardButton.Yes) -@patch('beeref.config.KeyboardSettings.restore_defaults') -def test_keyboard_settings_dialog_on_restore_defaults( - restore_mock, msg_mock, kbsettings, view): - dialog = KeyboardSettingsDialog(view) - dialog.on_restore_defaults() - msg_mock.assert_called_once() - restore_mock.assert_called() diff --git a/tools/build_appimage.py b/tools/build_appimage.py new file mode 100755 index 0000000..b4ac0f1 --- /dev/null +++ b/tools/build_appimage.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 + +# Build the BeeRef appimage. Run from the git root directory. +# On github actions: +# ./tools/build_appimage --version=${{ github.ref_name }}\ +# --jsonfile=tools/linux_libs.json +# Locally: +# ./tools/build_appimage --version=0.3.3-dev --jsonfile=tools/linux_libs.json +# --skip-apt + + +import argparse +import json +import logging +import os +import shutil +import subprocess +from urllib.request import urlretrieve + + +parser = argparse.ArgumentParser( + description=('Create an appimage for BeeRef. ' + 'Run from the git root directory.')) +parser.add_argument( + '-v', '--version', + required=True, + help='BeeRef version number/tag for output file') +parser.add_argument( + '-j', '--jsonfile', + required=True, + help='Json with lib files and packages as generated by find_linux_libs') +parser.add_argument( + '--redownload', + default=False, + action='store_true', + help='Re-use downloaded files if present') +parser.add_argument( + '--skip-apt', + default=False, + action='store_true', + help='Skip apt install step') +parser.add_argument( + '-l', '--loglevel', + default='INFO', + choices=list(logging._nameToLevel.keys()), + help='log level for console output') + +args = parser.parse_args() + + +BEEVERSION = args.version.removeprefix('v') +APPIMAGE = 'python3.11.9-cp311-cp311-manylinux2014_x86_64.AppImage' +# ^ Siehe: +# https://python-appimage.readthedocs.io/en/latest/#alternative-site-packages-location +PYVER = '3.11' +logger = logging.getLogger(__name__) +logging.basicConfig(level=getattr(logging, args.loglevel)) + + +def run_command(*args, capture_output=False): + logger.info(f'Running command: {args}') + result = subprocess.run(args, capture_output=capture_output) + assert result.returncode == 0, f'Failed with exit code {result.returncode}' + + +def download_file(url, filename): + if not args.redownload and os.path.exists(filename): + logger.info(f'Found file: {filename}') + else: + logger.info(f'Downloading: {url}') + logger.info(f'Saving as: {filename}') + urlretrieve(url, filename=filename) + os.chmod(filename, 0o755) + + +url = ('https://github.com/niess/python-appimage/releases/download/' + f'python{PYVER}/{APPIMAGE}') +download_file(url, filename='python.appimage') + + +try: + shutil.rmtree('squashfs-root') +except FileNotFoundError: + pass +run_command('./python.appimage', '--appimage-extract', + capture_output=True) + +run_command('squashfs-root/usr/bin/pip', + 'install', + '.', + f'--target=squashfs-root/opt/python{PYVER}/lib/python{PYVER}/') + +logger.info(f'Reading from: {args.jsonfile}') +with open(args.jsonfile, 'r') as f: + data = json.loads(f.read()) +libs = data['libs'] +packages = data['packages'] +excludes = data['excludes'] +paths = set() + +if not args.skip_apt: + run_command('sudo', 'apt', 'install', *packages) + +logger.info('Copying .so files to appimage...') + +existing_files = [] +for root, subdirs, files in os.walk('squashfs-root'): + existing_files.extend(files) + +for lib in libs: + if os.path.basename(lib) in existing_files: + logger.debug(f'Skipping {lib} (already in appimage)') + continue + if os.path.basename(lib) in excludes: + logger.debug(f'Skipping {lib} (excluded)') + continue + paths.add(os.path.dirname(lib)) + if os.path.exists(lib): + filename = lib + else: + filename, _ = os.path.splitext(lib) + dest = f'squashfs-root{filename}' + os.makedirs(os.path.dirname(dest), exist_ok=True) + logger.debug(f'Copying {filename} to {dest}') + shutil.copyfile(filename, f'squashfs-root{filename}') + + +logger.info('Writing run script...') +# Adapted from usr/bin/python3.x in the python appimage +os.remove('squashfs-root/AppRun') +# ^ This is only a symlink to usr/bin/python3.x + +paths = [ + '/usr/lib', # The libs that come with the python appimage ar in /usr/lib +] + list(paths) +ld_paths = ['${APPDIR}' + p for p in paths] + ['${LD_LIBRARY_PATH}'] +ld_paths = ':'.join(ld_paths) +logger.debug(f'LD_LIBRARY_PATH: {ld_paths}') + +content = """#! /bin/bash + +# If running from an extracted image, then export ARGV0 and APPDIR +if [ -z "${APPIMAGE}" ]; then + export ARGV0="$0" + + self=$(readlink -f -- "$0") # Protect spaces (issue 55) + here="${self%/*}" + tmp="${here%/*}" + export APPDIR="${tmp%/*}" +fi + +# Resolve the calling command (preserving symbolic links). +export APPIMAGE_COMMAND=$(command -v -- "$ARGV0") + +# Export SSL certificate +export SSL_CERT_FILE="${APPDIR}/opt/_internal/certs.pem" +""" +content += f'export LD_LIBRARY_PATH="{ld_paths}"\n' +content += f'"$APPDIR/opt/python{PYVER}/bin/python{PYVER}" -I -m beeref "$@"\n' + +with open('squashfs-root/AppRun', 'w') as f: + f.write(content) +os.chmod('squashfs-root/AppRun', 0o755) + +url = ('https://github.com/AppImage/AppImageKit/releases/download/' + 'continuous/appimagetool-x86_64.AppImage') + +download_file(url, filename='appimagetool.appimage') +run_command('./appimagetool.appimage', + 'squashfs-root', + f'BeeRef-{BEEVERSION}.appimage', + '--no-appstream') diff --git a/tools/find_linux_libs.py b/tools/find_linux_libs.py new file mode 100755 index 0000000..8de0eac --- /dev/null +++ b/tools/find_linux_libs.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +# Create JSON with Linux libs needed for BeeRef appimage + +import argparse +import json +import logging +import os +import pathlib +import re +import subprocess +import sys +from urllib import request + + +parser = argparse.ArgumentParser( + description=('Create JSON with Linux libs needed for BeeRef appimage')) +parser.add_argument( + 'pid', + nargs=1, + default=None, + help='PID of running BeeRef process') +parser.add_argument( + '-l', '--loglevel', + default='INFO', + choices=list(logging._nameToLevel.keys()), + help='log level for console output') +parser.add_argument( + '--jsonfile', + default='linux_libs.json', + help='JSON input/output file') +parser.add_argument( + '--check-appimage', + default=False, + action='store_true', + help='Check a running appimage process for missing libraries') + + +args = parser.parse_args() + + +def strip_minor_versions(path): + # foo2.so.2.1.1 -> foo2.so.2 + return re.sub('(.so.[0-9]*)[.0-9]*$', r'\1', path) + + +def what_links_to(path): + links = set() + dirname = os.path.dirname(path) + for filename in os.listdir(dirname): + filename = os.path.join(dirname, filename) + if (os.path.islink(filename) + and str(pathlib.Path(filename).resolve()) == path): + links.add(filename) + return sorted(links, key=len) + + +def is_lib(path): + return ('.so' in path + and os.path.expanduser('~') not in path + and 'python3' not in path + and 'mesa-diverted' not in path) + + +def iter_lsofoutput(output): + for line in output.splitlines(): + line = line.split() + if line[3] == 'mem': + path = line[-1] + if is_lib(path): + yield path + + +PID = args.pid[0] +logger = logging.getLogger(__name__) +logging.basicConfig(level=getattr(logging, args.loglevel)) + + +result = subprocess.run(('lsof', '-p', PID), capture_output=True) +assert result.returncode == 0, result.stderr +output = result.stdout.decode('utf-8') + + +if args.check_appimage: + logger.info('Checking appimage...') + errors = False + for lib in iter_lsofoutput(output): + if 'mount_BeeRef' not in lib: + print(f'Not in appimage: {lib}') + errors = True + if not errors: + print('No missing libs found.') + sys.exit() + + +libs = [] + +if os.path.exists(args.jsonfile): + logger.info(f'Reading from: {args.jsonfile}') + with open(args.jsonfile, 'r') as f: + data = json.loads(f.read()) + known_libs = data['libs'] + packages = set(data['packages']) +else: + logger.info(f'No file {args.jsonfile}; starting from scratch') + known_libs = [] + packages = set() + + +for lib in iter_lsofoutput(output): + links = what_links_to(lib) + if len(links) == 1: + lib = links[0] + else: + logger.warning(f'Double check: {lib} {links}') + lib = links[0] + if lib in known_libs: + logger.debug(f'Found known lib: {lib}') + else: + logger.debug(f'Found unknown lib: {lib}') + libs.append(lib) + + +for lib in libs: + result = subprocess.run(('apt-file', 'search', lib), capture_output=True) + if result.returncode != 0: + logger.warning(f'Fix manually: {lib}') + continue + output = result.stdout.decode('utf-8') + pkgs = set() + for line in output.splitlines(): + pkg = line.split(': ')[0] + if not (pkg.endswith('-dev') or pkg.endswith('-dbg')): + pkgs.add(pkg) + if len(pkgs) == 1: + pkg = pkgs.pop() + logger.debug(f'Found package: {pkg}') + packages.add(pkg) + else: + logger.warning(f'Fix manually: {lib}') + + +# Find the libs we shouldn't include in the appimage +with request.urlopen( + 'https://raw.githubusercontent.com/AppImageCommunity/pkg2appimage/' + 'master/excludelist') as f: + response = f.read().decode() + + +exclude_masterlist = set() +for line in response.splitlines(): + if not line or line.startswith('#'): + continue + line = line.split()[0] + line = strip_minor_versions(line) + exclude_masterlist.add(line) + +excludes = [] +for ex in exclude_masterlist: + for lib in (libs + known_libs): + if lib.endswith(ex): + excludes.append(ex) + continue + + +logger.info(f'Writing to: {args.jsonfile}') +with open(args.jsonfile, 'w') as f: + data = {'libs': sorted(libs + known_libs), + 'packages': sorted(packages), + 'excludes': sorted(excludes)} + f.write(json.dumps(data, indent=4)) diff --git a/tools/linux_libs.json b/tools/linux_libs.json new file mode 100644 index 0000000..2169295 --- /dev/null +++ b/tools/linux_libs.json @@ -0,0 +1,238 @@ +{ + "libs": [ + "/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", + "/lib/x86_64-linux-gnu/libbz2.so.1", + "/lib/x86_64-linux-gnu/libc.so.6", + "/lib/x86_64-linux-gnu/libcom_err.so.2", + "/lib/x86_64-linux-gnu/libdbus-1.so.3", + "/lib/x86_64-linux-gnu/libdl.so.2", + "/lib/x86_64-linux-gnu/libexpat.so.1", + "/lib/x86_64-linux-gnu/libgcc_s.so.1", + "/lib/x86_64-linux-gnu/libgpg-error.so.0", + "/lib/x86_64-linux-gnu/libkeyutils.so.1", + "/lib/x86_64-linux-gnu/liblzma.so.5", + "/lib/x86_64-linux-gnu/libm.so.6", + "/lib/x86_64-linux-gnu/libpcre.so.3", + "/lib/x86_64-linux-gnu/libpthread.so.0", + "/lib/x86_64-linux-gnu/libresolv.so.2", + "/lib/x86_64-linux-gnu/librt.so.1", + "/lib/x86_64-linux-gnu/libselinux.so.1", + "/lib/x86_64-linux-gnu/libutil.so.1", + "/lib/x86_64-linux-gnu/libz.so.1", + "/usr/lib/x86_64-linux-gnu/gio/modules/libgvfsdbus.so", + "/usr/lib/x86_64-linux-gnu/gtk-3.0/modules/libcanberra-gtk-module.so", + "/usr/lib/x86_64-linux-gnu/gvfs/libgvfscommon.so", + "/usr/lib/x86_64-linux-gnu/libGLX.so.0", + "/usr/lib/x86_64-linux-gnu/libGLdispatch.so.0", + "/usr/lib/x86_64-linux-gnu/libX11-xcb.so.1", + "/usr/lib/x86_64-linux-gnu/libX11.so.6", + "/usr/lib/x86_64-linux-gnu/libXau.so.6", + "/usr/lib/x86_64-linux-gnu/libXcomposite.so.1", + "/usr/lib/x86_64-linux-gnu/libXcursor.so.1", + "/usr/lib/x86_64-linux-gnu/libXdamage.so.1", + "/usr/lib/x86_64-linux-gnu/libXdmcp.so.6", + "/usr/lib/x86_64-linux-gnu/libXext.so.6", + "/usr/lib/x86_64-linux-gnu/libXfixes.so.3", + "/usr/lib/x86_64-linux-gnu/libXi.so.6", + "/usr/lib/x86_64-linux-gnu/libXinerama.so.1", + "/usr/lib/x86_64-linux-gnu/libXrandr.so.2", + "/usr/lib/x86_64-linux-gnu/libXrender.so.1", + "/usr/lib/x86_64-linux-gnu/libatk-1.0.so.0", + "/usr/lib/x86_64-linux-gnu/libatk-bridge-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libatspi.so.0", + "/usr/lib/x86_64-linux-gnu/libblkid.so.1", + "/usr/lib/x86_64-linux-gnu/libbrotlicommon.so.1", + "/usr/lib/x86_64-linux-gnu/libbrotlidec.so.1", + "/usr/lib/x86_64-linux-gnu/libbsd.so.0", + "/usr/lib/x86_64-linux-gnu/libcairo-gobject.so.2", + "/usr/lib/x86_64-linux-gnu/libcairo.so.2", + "/usr/lib/x86_64-linux-gnu/libcanberra-gtk3.so.0", + "/usr/lib/x86_64-linux-gnu/libcanberra.so.0", + "/usr/lib/x86_64-linux-gnu/libcrypto.so", + "/usr/lib/x86_64-linux-gnu/libdatrie.so.1", + "/usr/lib/x86_64-linux-gnu/libepoxy.so.0", + "/usr/lib/x86_64-linux-gnu/libffi.so.7", + "/usr/lib/x86_64-linux-gnu/libfontconfig.so.1", + "/usr/lib/x86_64-linux-gnu/libfreetype.so.6", + "/usr/lib/x86_64-linux-gnu/libfribidi.so.0", + "/usr/lib/x86_64-linux-gnu/libgcrypt.so.20", + "/usr/lib/x86_64-linux-gnu/libgdk-3.so.0", + "/usr/lib/x86_64-linux-gnu/libgdk_pixbuf-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgio-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libglib-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgmodule-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgobject-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgraphite2.so.3", + "/usr/lib/x86_64-linux-gnu/libgssapi_krb5.so.2", + "/usr/lib/x86_64-linux-gnu/libgthread-2.0.so.0", + "/usr/lib/x86_64-linux-gnu/libgtk-3.so.0", + "/usr/lib/x86_64-linux-gnu/libharfbuzz.so.0", + "/usr/lib/x86_64-linux-gnu/libk5crypto.so.3", + "/usr/lib/x86_64-linux-gnu/libkrb5.so.3", + "/usr/lib/x86_64-linux-gnu/libkrb5support.so.0", + "/usr/lib/x86_64-linux-gnu/libltdl.so.7", + "/usr/lib/x86_64-linux-gnu/liblz4.so.1", + "/usr/lib/x86_64-linux-gnu/libmd.so.0", + "/usr/lib/x86_64-linux-gnu/libmount.so.1", + "/usr/lib/x86_64-linux-gnu/libogg.so.0", + "/usr/lib/x86_64-linux-gnu/libpango-1.0.so.0", + "/usr/lib/x86_64-linux-gnu/libpangocairo-1.0.so.0", + "/usr/lib/x86_64-linux-gnu/libpangoft2-1.0.so.0", + "/usr/lib/x86_64-linux-gnu/libpcre2-8.so.0", + "/usr/lib/x86_64-linux-gnu/libpixman-1.so.0", + "/usr/lib/x86_64-linux-gnu/libpng16.so.16", + "/usr/lib/x86_64-linux-gnu/libsqlite3.so", + "/usr/lib/x86_64-linux-gnu/libssl.so", + "/usr/lib/x86_64-linux-gnu/libstdc++.so.6", + "/usr/lib/x86_64-linux-gnu/libsystemd.so.0", + "/usr/lib/x86_64-linux-gnu/libtdb.so.1", + "/usr/lib/x86_64-linux-gnu/libthai.so.0", + "/usr/lib/x86_64-linux-gnu/libuuid.so.1", + "/usr/lib/x86_64-linux-gnu/libvorbis.so.0", + "/usr/lib/x86_64-linux-gnu/libvorbisfile.so.3", + "/usr/lib/x86_64-linux-gnu/libwayland-client.so.0", + "/usr/lib/x86_64-linux-gnu/libwayland-cursor.so.0", + "/usr/lib/x86_64-linux-gnu/libwayland-egl.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb-cursor.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-icccm.so.4", + "/usr/lib/x86_64-linux-gnu/libxcb-image.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-keysyms.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb-randr.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-render-util.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-render.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-shape.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-shm.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-sync.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb-util.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb-xfixes.so.0", + "/usr/lib/x86_64-linux-gnu/libxcb-xkb.so.1", + "/usr/lib/x86_64-linux-gnu/libxcb.so.1", + "/usr/lib/x86_64-linux-gnu/libxkbcommon-x11.so.0", + "/usr/lib/x86_64-linux-gnu/libxkbcommon.so.0", + "/usr/lib/x86_64-linux-gnu/libzstd.so.1" + ], + "packages": [ + "gvfs", + "gvfs-libs", + "libatk-bridge2.0-0", + "libatk1.0-0", + "libatspi2.0-0", + "libblkid1", + "libbrotli1", + "libbsd0", + "libbz2-1.0", + "libc6", + "libcairo-gobject2", + "libcairo2", + "libcanberra-gtk3-0", + "libcanberra-gtk3-module", + "libcanberra0", + "libcom-err2", + "libdatrie1", + "libdbus-1-3", + "libepoxy0", + "libexpat1", + "libffi7", + "libfontconfig1", + "libfreetype6", + "libfribidi0", + "libgcc-s1", + "libgcrypt20", + "libgdk-pixbuf2.0-0", + "libglib2.0-0", + "libglvnd0", + "libglx0", + "libgpg-error0", + "libgraphite2-3", + "libgssapi-krb5-2", + "libgtk-3-0", + "libharfbuzz0b", + "libk5crypto3", + "libkeyutils1", + "libkrb5-3", + "libkrb5support0", + "libltdl7", + "liblz4-1", + "liblzma5", + "libmd0", + "libmount1", + "libogg0", + "libpango-1.0-0", + "libpangocairo-1.0-0", + "libpangoft2-1.0-0", + "libpcre2-8-0", + "libpcre3", + "libpixman-1-0", + "libpng16-16", + "libselinux1", + "libsqlite3-0", + "libssl1.1", + "libstdc++6", + "libsystemd0", + "libtdb1", + "libthai0", + "libuuid1", + "libvorbis0a", + "libvorbisfile3", + "libwayland-client0", + "libwayland-cursor0", + "libwayland-egl1", + "libx11-6", + "libx11-xcb1", + "libxau6", + "libxcb-cursor0", + "libxcb-icccm4", + "libxcb-image0", + "libxcb-keysyms1", + "libxcb-randr0", + "libxcb-render-util0", + "libxcb-render0", + "libxcb-shape0", + "libxcb-shm0", + "libxcb-sync1", + "libxcb-util1", + "libxcb-xfixes0", + "libxcb-xkb1", + "libxcb1", + "libxcomposite1", + "libxcursor1", + "libxdamage1", + "libxdmcp6", + "libxext6", + "libxfixes3", + "libxi6", + "libxinerama1", + "libxkbcommon-x11-0", + "libxkbcommon0", + "libxrandr2", + "libxrender1", + "libzstd1", + "zlib1g" + ], + "excludes": [ + "ld-linux-x86-64.so.2", + "libGLX.so.0", + "libGLdispatch.so.0", + "libX11-xcb.so.1", + "libX11.so.6", + "libc.so.6", + "libcom_err.so.2", + "libdl.so.2", + "libexpat.so.1", + "libfontconfig.so.1", + "libfreetype.so.6", + "libfribidi.so.0", + "libgcc_s.so.1", + "libgpg-error.so.0", + "libharfbuzz.so.0", + "libm.so.6", + "libpthread.so.0", + "libresolv.so.2", + "librt.so.1", + "libstdc++.so.6", + "libthai.so.0", + "libutil.so.1", + "libxcb.so.1", + "libz.so.1" + ] +}