Merge branch 'rbreu:main' into easy_drag_and_drop

This commit is contained in:
Randommist 2024-05-03 11:48:57 +03:00 committed by GitHub
commit 3b00e730f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 5734 additions and 1287 deletions

25
.github/workflows/build_appimage.yml vendored Normal file
View file

@ -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

2
.gitignore vendored
View file

@ -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

View file

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

View file

@ -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

View file

@ -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()}')

View file

@ -13,7 +13,6 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from collections import OrderedDict
from functools import cached_property
import logging
@ -21,22 +20,39 @@ from PyQt6 import QtGui
from beeref.actions.menu_structure import menu_structure
from beeref.config import KeyboardSettings, settings_events
from beeref.utils import ActionList
logger = logging.getLogger(__name__)
class Action(dict):
class Action:
SETTINGS_GROUP = 'Actions'
def __init__(self, *args, **kwargs):
def __init__(self, id, text, callback=None, shortcuts=None,
checkable=False, checked=False, group=None, settings=None,
enabled=True, menu_item=None, menu_id=None):
self.id = id
self.text = text
self.callback = callback
self.shortcuts = shortcuts or []
self.checkable = checkable
self.checked = checked
self.group = group
self.settings = settings
self.enabled = enabled
self.menu_item = menu_item
self.menu_id = menu_id
self.qaction = None
self.kb_settings = KeyboardSettings()
super().__init__(*args, **kwargs)
settings_events.restore_keyboard_defaults.connect(
self.on_restore_defaults)
def __eq__(self, other):
return self['id'] == other['id']
return self.id == other.id
def __str__(self):
return self.id
def on_restore_defaults(self):
if self.qaction:
@ -50,7 +66,7 @@ class Action(dict):
if isinstance(menu_item['items'], list):
# This is a normal menu
for item in menu_item['items']:
if item == self['id']:
if item == self.id:
path.append(menu_item['menu'])
return True
if isinstance(item, dict):
@ -58,7 +74,7 @@ class Action(dict):
if _get_path(item):
path.append(menu_item['menu'])
return True
elif menu_item['items'] == self.get('menu_id'):
elif menu_item['items'] == self.menu_id:
# This is a dynamic submenu (e.g. Recent Files)
path.append(menu_item['menu'])
return True
@ -69,13 +85,13 @@ class Action(dict):
return path[::-1]
def get_shortcuts(self):
return self.kb_settings.get_shortcuts(
'Actions', self['id'], self.get('shortcuts'))
return self.kb_settings.get_list(
self.SETTINGS_GROUP, self.id, self.shortcuts)
def set_shortcuts(self, value):
logger.debug(f'Setting shortcut "{self["id"]}" to: {value}')
self.kb_settings.set_shortcuts(
'Actions', self['id'], value, self.get('shortcuts'))
logger.debug(f'Setting shortcut "{self.id}" to: {value}')
self.kb_settings.set_list(
self.SETTINGS_GROUP, self.id, value, self.shortcuts)
if self.qaction:
self.qaction.setShortcuts(value)
@ -88,348 +104,335 @@ class Action(dict):
def shortcuts_changed(self):
"""Whether shortcuts have changed from their defaults."""
return self.get_shortcuts() != self.get('shortcuts', [])
return self.get_shortcuts() != self.shortcuts
def get_default_shortcut(self, index):
try:
return self.get('shortcuts', [])[index]
return self.shortcuts[index]
except IndexError:
return None
class ActionList(OrderedDict):
def __init__(self, actions):
super().__init__()
for action in actions:
self[action['id']] = action
def __getitem__(self, key):
if isinstance(key, int):
key = list(self.keys())[key]
return super().__getitem__(key)
actions = ActionList([
Action({
'id': 'open',
'text': '&Open',
'shortcuts': ['Ctrl+O'],
'callback': 'on_action_open',
}),
Action({
'id': 'save',
'text': '&Save',
'shortcuts': ['Ctrl+S'],
'callback': 'on_action_save',
'group': 'active_when_items_in_scene',
}),
Action({
'id': 'save_as',
'text': 'Save &As...',
'shortcuts': ['Ctrl+Shift+S'],
'callback': 'on_action_save_as',
'group': 'active_when_items_in_scene',
}),
Action({
'id': 'export_scene',
'text': 'E&xport Scene...',
'shortcuts': ['Ctrl+Shift+E'],
'callback': 'on_action_export_scene',
'group': 'active_when_items_in_scene',
}),
Action({
'id': 'quit',
'text': '&Quit',
'shortcuts': ['Ctrl+Q'],
'callback': 'on_action_quit',
}),
Action({
'id': 'insert_images',
'text': '&Images...',
'shortcuts': ['Ctrl+I'],
'callback': 'on_action_insert_images',
}),
Action({
'id': 'insert_text',
'text': '&Text',
'shortcuts': ['Ctrl+T'],
'callback': 'on_action_insert_text',
}),
Action({
'id': 'undo',
'text': '&Undo',
'shortcuts': ['Ctrl+Z'],
'callback': 'on_action_undo',
'group': 'active_when_can_undo',
}),
Action({
'id': 'redo',
'text': '&Redo',
'shortcuts': ['Ctrl+Shift+Z'],
'callback': 'on_action_redo',
'group': 'active_when_can_redo',
}),
Action({
'id': 'copy',
'text': '&Copy',
'shortcuts': ['Ctrl+C'],
'callback': 'on_action_copy',
'group': 'active_when_selection',
}),
Action({
'id': 'cut',
'text': 'Cu&t',
'shortcuts': ['Ctrl+X'],
'callback': 'on_action_cut',
'group': 'active_when_selection',
}),
Action({
'id': 'paste',
'text': '&Paste',
'shortcuts': ['Ctrl+V'],
'callback': 'on_action_paste',
}),
Action({
'id': 'delete',
'text': '&Delete',
'shortcuts': ['Del'],
'callback': 'on_action_delete_items',
'group': 'active_when_selection',
}),
Action({
'id': 'raise_to_top',
'text': '&Raise to Top',
'shortcuts': ['PgUp'],
'callback': 'on_action_raise_to_top',
'group': 'active_when_selection',
}),
Action({
'id': 'lower_to_bottom',
'text': 'Lower to Bottom',
'shortcuts': ['PgDown'],
'callback': 'on_action_lower_to_bottom',
'group': 'active_when_selection',
}),
Action({
'id': 'normalize_height',
'text': '&Height',
'shortcuts': ['Shift+H'],
'callback': 'on_action_normalize_height',
'group': 'active_when_selection',
}),
Action({
'id': 'normalize_width',
'text': '&Width',
'shortcuts': ['Shift+W'],
'callback': 'on_action_normalize_width',
'group': 'active_when_selection',
}),
Action({
'id': 'normalize_size',
'text': '&Size',
'shortcuts': ['Shift+S'],
'callback': 'on_action_normalize_size',
'group': 'active_when_selection',
}),
Action({
'id': 'arrange_optimal',
'text': '&Optimal',
'shortcuts': ['Shift+O'],
'callback': 'on_action_arrange_optimal',
'group': 'active_when_selection',
}),
Action({
'id': 'arrange_horizontal',
'text': '&Horizontal',
'callback': 'on_action_arrange_horizontal',
'group': 'active_when_selection',
}),
Action({
'id': 'arrange_vertical',
'text': '&Vertical',
'callback': 'on_action_arrange_vertical',
'group': 'active_when_selection',
}),
Action({
'id': 'change_opacity',
'text': 'Change &Opacity...',
'callback': 'on_action_change_opacity',
'group': 'active_when_selection',
}),
Action({
'id': 'grayscale',
'text': '&Grayscale',
'shortcuts': ['G'],
'checkable': True,
'callback': 'on_action_grayscale',
'group': 'active_when_selection',
}),
Action({
'id': 'show_color_gamut',
'text': 'Show &Color Gamut',
'callback': 'on_action_show_color_gamut',
'group': 'active_when_single_image',
}),
Action({
'id': 'sample_color',
'text': 'Sample Color',
'shortcuts': ['S'],
'callback': 'on_action_sample_color',
'group': 'active_when_items_in_scene',
}),
Action({
'id': 'crop',
'text': '&Crop',
'shortcuts': ['Shift+C'],
'callback': 'on_action_crop',
'group': 'active_when_single_image',
}),
Action({
'id': 'flip_horizontally',
'text': 'Flip &Horizontally',
'shortcuts': ['H'],
'callback': 'on_action_flip_horizontally',
'group': 'active_when_selection',
}),
Action({
'id': 'flip_vertically',
'text': 'Flip &Vertically',
'shortcuts': ['V'],
'callback': 'on_action_flip_vertically',
'group': 'active_when_selection',
}),
Action({
'id': 'new_scene',
'text': '&New Scene',
'shortcuts': ['Ctrl+N'],
'callback': 'clear_scene',
}),
Action({
'id': 'fit_scene',
'text': '&Fit Scene',
'shortcuts': ['1'],
'callback': 'on_action_fit_scene',
}),
Action({
'id': 'fit_selection',
'text': 'Fit &Selection',
'shortcuts': ['2'],
'callback': 'on_action_fit_selection',
'group': 'active_when_selection',
}),
Action({
'id': 'reset_scale',
'text': 'Reset &Scale',
'callback': 'on_action_reset_scale',
'group': 'active_when_selection',
}),
Action({
'id': 'reset_rotation',
'text': 'Reset &Rotation',
'callback': 'on_action_reset_rotation',
'group': 'active_when_selection',
}),
Action({
'id': 'reset_flip',
'text': 'Reset &Flip',
'callback': 'on_action_reset_flip',
'group': 'active_when_selection',
}),
Action({
'id': 'reset_crop',
'text': 'Reset Cro&p',
'callback': 'on_action_reset_crop',
'group': 'active_when_selection',
}),
Action({
'id': 'reset_transforms',
'text': 'Reset &All',
'shortcuts': ['R'],
'callback': 'on_action_reset_transforms',
'group': 'active_when_selection',
}),
Action({
'id': 'select_all',
'text': '&Select All',
'shortcuts': ['Ctrl+A'],
'callback': 'on_action_select_all',
}),
Action({
'id': 'deselect_all',
'text': 'Deselect &All',
'shortcuts': ['Ctrl+Shift+A'],
'callback': 'on_action_deselect_all',
}),
Action({
'id': 'help',
'text': '&Help',
'shortcuts': ['F1', 'Ctrl+H'],
'callback': 'on_action_help',
}),
Action({
'id': 'about',
'text': '&About',
'callback': 'on_action_about',
}),
Action({
'id': 'debuglog',
'text': 'Show &Debug Log',
'callback': 'on_action_debuglog',
}),
Action({
'id': 'show_scrollbars',
'text': 'Show &Scrollbars',
'checkable': True,
'settings': 'View/show_scrollbars',
'callback': 'on_action_show_scrollbars',
}),
Action({
'id': 'show_menubar',
'text': 'Show &Menu Bar',
'checkable': True,
'settings': 'View/show_menubar',
'callback': 'on_action_show_menubar',
}),
Action({
'id': 'show_titlebar',
'text': 'Show &Title Bar',
'checkable': True,
'checked': True,
'callback': 'on_action_show_titlebar',
}),
Action({
'id': 'move_window',
'text': 'Move &Window',
'shortcuts': ['Ctrl+M'],
'callback': 'on_action_move_window',
}),
Action({
'id': 'fullscreen',
'text': '&Fullscreen',
'shortcuts': ['F11'],
'checkable': True,
'callback': 'on_action_fullscreen',
}),
Action({
'id': 'always_on_top',
'text': '&Always On Top',
'checkable': True,
'callback': 'on_action_always_on_top',
}),
Action({
'id': 'settings',
'text': '&Settings',
'callback': 'on_action_settings',
}),
Action({
'id': 'keyboard_settings',
'text': '&Keyboard Shortcuts',
'callback': 'on_action_keyboard_settings',
}),
Action({
'id': 'open_settings_dir',
'text': '&Open Settings Folder',
'callback': 'on_action_open_settings_dir',
}),
Action(
id='open',
text='&Open',
shortcuts=['Ctrl+O'],
callback='on_action_open',
),
Action(
id='save',
text='&Save',
shortcuts=['Ctrl+S'],
callback='on_action_save',
group='active_when_items_in_scene',
),
Action(
id='save_as',
text='Save &As...',
shortcuts=['Ctrl+Shift+S'],
callback='on_action_save_as',
group='active_when_items_in_scene',
),
Action(
id='export_scene',
text='E&xport Scene...',
shortcuts=['Ctrl+Shift+E'],
callback='on_action_export_scene',
group='active_when_items_in_scene',
),
Action(
id='quit',
text='&Quit',
shortcuts=['Ctrl+Q'],
callback='on_action_quit',
),
Action(
id='insert_images',
text='&Images...',
shortcuts=['Ctrl+I'],
callback='on_action_insert_images',
),
Action(
id='insert_text',
text='&Text',
shortcuts=['Ctrl+T'],
callback='on_action_insert_text',
),
Action(
id='undo',
text='&Undo',
shortcuts=['Ctrl+Z'],
callback='on_action_undo',
group='active_when_can_undo',
),
Action(
id='redo',
text='&Redo',
shortcuts=['Ctrl+Shift+Z'],
callback='on_action_redo',
group='active_when_can_redo',
),
Action(
id='copy',
text='&Copy',
shortcuts=['Ctrl+C'],
callback='on_action_copy',
group='active_when_selection',
),
Action(
id='cut',
text='Cu&t',
shortcuts=['Ctrl+X'],
callback='on_action_cut',
group='active_when_selection',
),
Action(
id='paste',
text='&Paste',
shortcuts=['Ctrl+V'],
callback='on_action_paste',
),
Action(
id='delete',
text='&Delete',
shortcuts=['Del'],
callback='on_action_delete_items',
group='active_when_selection',
),
Action(
id='raise_to_top',
text='&Raise to Top',
shortcuts=['PgUp'],
callback='on_action_raise_to_top',
group='active_when_selection',
),
Action(
id='lower_to_bottom',
text='Lower to Bottom',
shortcuts=['PgDown'],
callback='on_action_lower_to_bottom',
group='active_when_selection',
),
Action(
id='normalize_height',
text='&Height',
shortcuts=['Shift+H'],
callback='on_action_normalize_height',
group='active_when_selection',
),
Action(
id='normalize_width',
text='&Width',
shortcuts=['Shift+W'],
callback='on_action_normalize_width',
group='active_when_selection',
),
Action(
id='normalize_size',
text='&Size',
shortcuts=['Shift+S'],
callback='on_action_normalize_size',
group='active_when_selection',
),
Action(
id='arrange_optimal',
text='&Optimal',
shortcuts=['Shift+O'],
callback='on_action_arrange_optimal',
group='active_when_selection',
),
Action(
id='arrange_horizontal',
text='&Horizontal',
callback='on_action_arrange_horizontal',
group='active_when_selection',
),
Action(
id='arrange_vertical',
text='&Vertical',
callback='on_action_arrange_vertical',
group='active_when_selection',
),
Action(
id='change_opacity',
text='Change &Opacity...',
callback='on_action_change_opacity',
group='active_when_selection',
),
Action(
id='grayscale',
text='&Grayscale',
shortcuts=['G'],
checkable=True,
callback='on_action_grayscale',
group='active_when_selection',
),
Action(
id='show_color_gamut',
text='Show &Color Gamut',
callback='on_action_show_color_gamut',
group='active_when_single_image',
),
Action(
id='sample_color',
text='Sample Color',
shortcuts=['S'],
callback='on_action_sample_color',
group='active_when_items_in_scene',
),
Action(
id='crop',
text='&Crop',
shortcuts=['Shift+C'],
callback='on_action_crop',
group='active_when_single_image',
),
Action(
id='flip_horizontally',
text='Flip &Horizontally',
shortcuts=['H'],
callback='on_action_flip_horizontally',
group='active_when_selection',
),
Action(
id='flip_vertically',
text='Flip &Vertically',
shortcuts=['V'],
callback='on_action_flip_vertically',
group='active_when_selection',
),
Action(
id='new_scene',
text='&New Scene',
shortcuts=['Ctrl+N'],
callback='clear_scene',
),
Action(
id='fit_scene',
text='&Fit Scene',
shortcuts=['1'],
callback='on_action_fit_scene',
),
Action(
id='fit_selection',
text='Fit &Selection',
shortcuts=['2'],
callback='on_action_fit_selection',
group='active_when_selection',
),
Action(
id='reset_scale',
text='Reset &Scale',
callback='on_action_reset_scale',
group='active_when_selection',
),
Action(
id='reset_rotation',
text='Reset &Rotation',
callback='on_action_reset_rotation',
group='active_when_selection',
),
Action(
id='reset_flip',
text='Reset &Flip',
callback='on_action_reset_flip',
group='active_when_selection',
),
Action(
id='reset_crop',
text='Reset Cro&p',
callback='on_action_reset_crop',
group='active_when_selection',
),
Action(
id='reset_transforms',
text='Reset &All',
shortcuts=['R'],
callback='on_action_reset_transforms',
group='active_when_selection',
),
Action(
id='select_all',
text='&Select All',
shortcuts=['Ctrl+A'],
callback='on_action_select_all',
),
Action(
id='deselect_all',
text='Deselect &All',
shortcuts=['Ctrl+Shift+A'],
callback='on_action_deselect_all',
),
Action(
id='help',
text='&Help',
shortcuts=['F1', 'Ctrl+H'],
callback='on_action_help',
),
Action(
id='about',
text='&About',
callback='on_action_about',
),
Action(
id='debuglog',
text='Show &Debug Log',
callback='on_action_debuglog',
),
Action(
id='show_scrollbars',
text='Show &Scrollbars',
checkable=True,
settings='View/show_scrollbars',
callback='on_action_show_scrollbars',
),
Action(
id='show_menubar',
text='Show &Menu Bar',
checkable=True,
settings='View/show_menubar',
callback='on_action_show_menubar',
),
Action(
id='show_titlebar',
text='Show &Title Bar',
checkable=True,
checked=True,
callback='on_action_show_titlebar',
),
Action(
id='move_window',
text='Move &Window',
shortcuts=['Ctrl+M'],
callback='on_action_move_window',
),
Action(
id='fullscreen',
text='&Fullscreen',
shortcuts=['F11'],
checkable=True,
callback='on_action_fullscreen',
),
Action(
id='always_on_top',
text='&Always On Top',
checkable=True,
callback='on_action_always_on_top',
),
Action(
id='settings',
text='&Settings',
callback='on_action_settings',
),
Action(
id='keyboard_settings',
text='&Keyboard && Mouse',
callback='on_action_keyboard_settings',
),
Action(
id='open_settings_dir',
text='&Open Settings Folder',
callback='on_action_open_settings_dir',
),
])

View file

@ -55,10 +55,10 @@ class ActionsMixin:
def _init_action_checkable(self, actiondef, qaction):
qaction.setCheckable(True)
callback = getattr(self, actiondef['callback'])
callback = getattr(self, actiondef.callback)
qaction.toggled.connect(callback)
settings_key = actiondef.get('settings')
checked = actiondef.get('checked', False)
settings_key = actiondef.settings
checked = actiondef.checked
qaction.setChecked(checked)
if settings_key:
val = self.settings.value(settings_key, checked, type=bool)
@ -69,18 +69,18 @@ class ActionsMixin:
def _create_actions(self):
for action in actions.values():
qaction = QtGui.QAction(action['text'], self)
qaction = QtGui.QAction(action.text, self)
shortcuts = action.get_shortcuts()
if shortcuts:
qaction.setShortcuts(shortcuts)
if action.get('checkable', False):
if action.checkable:
self._init_action_checkable(action, qaction)
else:
qaction.triggered.connect(getattr(self, action['callback']))
qaction.triggered.connect(getattr(self, action.callback))
self.addAction(qaction)
qaction.setEnabled(action.get('enabled', True))
if 'group' in action:
self.bee_actiongroups[action['group']].append(qaction)
qaction.setEnabled(action.enabled)
if action.group:
self.bee_actiongroups[action.group].append(qaction)
qaction.setEnabled(False)
action.qaction = qaction
@ -112,10 +112,10 @@ class ActionsMixin:
for i in range(10):
action_id = f'recent_files_{i}'
key = 0 if i == 9 else i + 1
action = Action({'id': action_id,
'menu_id': '_build_recent_files',
'text': f'File {i + 1}',
'shortcuts': [f'Ctrl+{key}']})
action = Action(id=action_id,
menu_id='_build_recent_files',
text=f'File {i + 1}',
shortcuts=[f'Ctrl+{key}'])
actions[action_id] = action
if i < len(files):

View file

@ -26,10 +26,11 @@ class InsertItems(QtGui.QUndoCommand):
self.ignore_first_redo = ignore_first_redo
def redo(self):
self.scene.deselect_all_items()
if self.ignore_first_redo:
self.ignore_first_redo = False
return
self.scene.deselect_all_items()
if self.position:
self.old_positions = []
rect = self.scene.itemsBoundingRect(items=self.items)

91
beeref/config/__init__.py Normal file
View file

@ -0,0 +1,91 @@
# This file is part of BeeRef.
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
"""Handling of command line args and Qt settings."""
import logging
import logging.config
import os.path
from PyQt6 import QtCore
from beeref import constants
from beeref.config.controls import KeyboardSettings # noqa F401
from beeref.config.settings import ( # noqa F401
BeeSettings,
CommandlineArgs,
settings_events,
)
from beeref.logging import qt_message_handler
logger = logging.getLogger(__name__)
def logfile_name():
return os.path.join(
os.path.dirname(BeeSettings().fileName()), f'{constants.APPNAME}.log')
logging_conf = {
'version': 1,
'formatters': {
'verbose': {
'format': ('{asctime} {name} {process:d} {thread:d} {message}'),
'style': '{',
},
'simple': {
'format': '{levelname} {name}: {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'simple',
'level': CommandlineArgs().loglevel,
},
'file': {
'class': 'beeref.logging.BeeRotatingFileHandler',
'formatter': 'verbose',
'filename': logfile_name(),
'maxBytes': 1024 * 1000, # 1MB
'backupCount': 1,
'level': 'DEBUG',
'delay': True,
}
},
'loggers': {
'beeref': {
'handlers': ['console', 'file'],
'level': 'TRACE',
'propagate': False,
},
'Qt': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': False,
},
},
'root': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
},
}
logging.config.dictConfig(logging_conf)
# Redirect Qt logging to Python logger:
QtCore.qInstallMessageHandler(qt_message_handler)

336
beeref/config/controls.py Normal file
View file

@ -0,0 +1,336 @@
# This file is part of BeeRef.
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
"""Handling of keyboard shortcuts and mouse controls."""
from collections import OrderedDict
from functools import cached_property
import logging
import logging.config
import os.path
from beeref.config.settings import BeeSettings, settings_events
from beeref.utils import ActionList
from PyQt6 import QtCore
from PyQt6.QtCore import Qt
logger = logging.getLogger(__name__)
class MouseConfigBase:
MODIFIER_MAP = OrderedDict((
('No Modifier', Qt.KeyboardModifier.NoModifier),
('Shift', Qt.KeyboardModifier.ShiftModifier),
('Ctrl', Qt.KeyboardModifier.ControlModifier),
('Alt', Qt.KeyboardModifier.AltModifier),
('Meta', Qt.KeyboardModifier.MetaModifier),
('Keypad', Qt.KeyboardModifier.KeypadModifier),
))
BUTTON_MAP = OrderedDict((
('Not Configured', Qt.MouseButton.NoButton),
('Left', Qt.MouseButton.LeftButton),
('Middle', Qt.MouseButton.MiddleButton),
))
def __eq__(self, other):
return self.id == other.id
def __str__(self):
return self.id
@cached_property
def kb_settings(self):
return KeyboardSettings()
def get_modifiers(self):
return self.kb_settings.get_list(
self.SETTINGS_GROUP, f'{self.id}_modifiers', self.modifiers)
def set_modifiers(self, value):
logger.debug(
f'Setting {self.SETTINGS_GROUP} modifiers '
f'for "{self.id}" to: {value}')
self.kb_settings.set_list(
self.SETTINGS_GROUP, f'{self.id}_modifiers', value, self.modifiers)
def get_inverted(self):
return self.kb_settings.get_value(
self.SETTINGS_GROUP, f'{self.id}_inverted', self.inverted)
def set_inverted(self, value):
logger.debug(
f'Setting {self.SETTINGS_GROUP} inverted '
f'for "{self.id}" to: {value}')
self.kb_settings.set_value(
self.SETTINGS_GROUP, f'{self.id}_inverted', value, self.inverted)
@classmethod
def modifiers_to_qt(cls, modifiers):
combined = cls.MODIFIER_MAP[modifiers[0]]
for mod in modifiers[1:]:
combined = combined | cls.MODIFIER_MAP[mod]
return combined
class MouseWheelConfig(MouseConfigBase):
SETTINGS_GROUP = 'MouseWheel'
def __init__(self, id, group, text, modifiers, invertible):
self.id = id
self.group = group
self.text = text
self.modifiers = modifiers
self.invertible = invertible
self.inverted = False
def controls_changed(self):
"""Whether controls have changed from their defaults."""
return (set(self.get_modifiers()) != set(self.modifiers)
or self.get_inverted() != self.inverted)
def is_configured(self):
"""Whether controls have been configured for this action."""
return bool(self.get_modifiers())
def remove_controls(self):
self.set_modifiers([])
self.set_inverted(False)
def conflicts_with(self, other):
"""Whether controls conflict with `other`.
For unconfigured controls, always return False."""
return (self.is_configured()
and other.is_configured()
and set(self.get_modifiers()) == set(other.get_modifiers()))
def matches_event(self, event):
if not self.is_configured():
return False
modifiers = self.get_modifiers()
return self.modifiers_to_qt(modifiers) == event.modifiers()
class MouseConfig(MouseConfigBase):
SETTINGS_GROUP = 'Mouse'
def __init__(self, id, group, text, button, modifiers, invertible):
self.id = id
self.group = group
self.text = text
self.button = button
self.modifiers = modifiers
self.invertible = invertible
self.inverted = False
def get_button(self):
return self.kb_settings.get_value(
self.SETTINGS_GROUP, f'{self.id}_button', self.button)
def set_button(self, value):
logger.debug(
f'Setting {self.SETTINGS_GROUP} button '
f'for "{self.id}" to: {value}')
self.kb_settings.set_value(
self.SETTINGS_GROUP, f'{self.id}_button', value, self.button)
def conflicts_with(self, other):
"""Whether controls conflict with `other`.
For unconfigured controls, always return False.
"""
return (self.is_configured()
and other.is_configured()
and self.get_button() == other.get_button()
and set(self.get_modifiers()) == set(other.get_modifiers()))
def controls_changed(self):
"""Whether controls have changed from their defaults."""
return (self.get_button() != self.button
or set(self.get_modifiers()) != set(self.modifiers)
or self.get_inverted() != self.inverted)
def is_configured(self):
"""Whether controls have been configured for this action."""
return self.get_button() != 'Not Configured'
def remove_controls(self):
self.set_button('Not Configured')
self.set_modifiers([])
self.set_inverted(False)
def matches_event(self, event):
if not self.is_configured():
return False
modifiers = self.get_modifiers()
return (self.modifiers_to_qt(modifiers) == event.modifiers()
and self.BUTTON_MAP[self.get_button()] == event.button())
class KeyboardSettings(QtCore.QSettings):
MOUSEWHEEL_ACTIONS = ActionList([
MouseWheelConfig(
id='zoom1',
group='zoom',
text='Zoom',
modifiers=('No Modifier',),
invertible=True,
),
MouseWheelConfig(
id='zoom2',
group='zoom',
text='Zoom (alternative)',
modifiers=(),
invertible=True,
),
MouseWheelConfig(
id='pan_horizontal1',
group='pan_horizontal',
text='Pan horizontally',
modifiers=('Shift',),
invertible=True,
),
MouseWheelConfig(
id='pan_horizontal2',
group='pan_horizontal',
text='Pan horizontally (alternative)',
modifiers=(),
invertible=True,
),
MouseWheelConfig(
id='pan_vertical1',
group='pan_vertical',
text='Pan vertically',
modifiers=('Shift', 'Ctrl'),
invertible=True,
),
MouseWheelConfig(
id='pan_vertical2',
group='pan_vertical',
text='Pan vertically (alternative)',
modifiers=(),
invertible=True,
),
])
MOUSE_ACTIONS = ActionList([
MouseConfig(
id='zoom1',
group='zoom',
text='Zoom',
button='Middle',
modifiers=('Ctrl',),
invertible=True,
),
MouseConfig(
id='zoom2',
group='zoom',
text='Zoom (alternative)',
button='Not Configured',
modifiers=(),
invertible=True,
),
MouseConfig(
id='pan1',
group='pan',
text='Pan',
button='Middle',
modifiers=('No Modifier',),
invertible=False,
),
MouseConfig(
id='pan2',
group='pan',
text='Pan (alternative)',
button='Left',
modifiers=('Alt',),
invertible=False,
),
MouseConfig(
id='movewindow1',
group='movewindow',
text='Move Window',
button='Left',
modifiers=('Ctrl', 'Alt'),
invertible=False,
),
MouseConfig(
id='movewindow2',
group='movewindow (alternative)',
text='Move Window',
button='Not Configured',
modifiers=(),
invertible=False,
),
])
def __init__(self):
settings_format = QtCore.QSettings.Format.IniFormat
filename = os.path.join(
os.path.dirname(BeeSettings().fileName()),
'KeyboardSettings.ini')
super().__init__(filename, settings_format)
def set_list(self, group, key, values, default=None):
if values == default:
self.remove(f'{group}/{key}')
else:
self.setValue(f'{group}/{key}', ', '.join(values))
def get_list(self, group, key, default=None):
values = self.value(f'{group}/{key}')
if values is not None:
values = list(filter(lambda x: x, values.split(', ')))
return values
return list(default or []) # Always return new instance of default
def get_value(self, group, key, default=None):
value = self.value(f'{group}/{key}')
return default if value is None else value
def set_value(self, group, key, value, default=None):
if value == default:
self.remove(f'{group}/{key}')
else:
self.setValue(f'{group}/{key}', value)
def restore_defaults(self):
"""Restore all the values specified in FILEDS to their default values
by removing them from the settings file.
"""
logger.debug('Restoring keyboard and mouse controls to defaults')
for key in self.allKeys():
self.remove(key)
settings_events.restore_keyboard_defaults.emit()
def mousewheel_action_for_event(self, event):
for action in self.MOUSEWHEEL_ACTIONS.values():
if action.matches_event(event):
return action.group, action.get_inverted()
return None, None
def mouse_action_for_event(self, event):
for action in self.MOUSE_ACTIONS.values():
if action.matches_event(event):
return action.group, action.get_inverted()
return None, None

View file

@ -13,17 +13,13 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
"""Handling of command line args and Qt settings."""
import argparse
import logging
import logging.config
import os.path
from PyQt6 import QtCore
from beeref import constants
from beeref.logging import qt_message_handler
logger = logging.getLogger(__name__)
@ -142,7 +138,6 @@ class BeeSettings(QtCore.QSettings):
'validate' are specified in the FIELDS entry for the given
key. The default value will be returned if validation or type
casting fails.
"""
val = self.value(key)
@ -205,94 +200,3 @@ class BeeSettings(QtCore.QSettings):
if existing_only:
values = [f for f in values if os.path.exists(f)]
return values
class KeyboardSettings(QtCore.QSettings):
def __init__(self):
settings_format = QtCore.QSettings.Format.IniFormat
filename = os.path.join(
os.path.dirname(BeeSettings().fileName()),
'KeyboardSettings.ini')
super().__init__(filename, settings_format)
def set_shortcuts(self, group, key, values, default=None):
if values == default:
self.remove(f'{group}/{key}')
else:
self.setValue(f'{group}/{key}', ', '.join(values))
def get_shortcuts(self, group, key, default=None):
values = self.value(f'{group}/{key}')
if values is not None:
values = list(filter(lambda x: x, values.split(', ')))
return values
return list(default or []) # Always return new instance of default
def restore_defaults(self):
"""Restore all the values specified in FILEDS to their default values
by removing them from the settings file.
"""
logger.debug('Restoring keyboard shortcuts to defaults')
for key in self.allKeys():
self.remove(key)
settings_events.restore_keyboard_defaults.emit()
def logfile_name():
return os.path.join(
os.path.dirname(BeeSettings().fileName()), f'{constants.APPNAME}.log')
logging_conf = {
'version': 1,
'formatters': {
'verbose': {
'format': ('{asctime} {name} {process:d} {thread:d} {message}'),
'style': '{',
},
'simple': {
'format': '{levelname} {name}: {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'simple',
'level': CommandlineArgs().loglevel,
},
'file': {
'class': 'beeref.logging.BeeRotatingFileHandler',
'formatter': 'verbose',
'filename': logfile_name(),
'maxBytes': 1024 * 1000, # 1MB
'backupCount': 1,
'level': 'DEBUG',
'delay': True,
}
},
'loggers': {
'beeref': {
'handlers': ['console', 'file'],
'level': 'TRACE',
'propagate': False,
},
'Qt': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': False,
},
},
'root': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
},
}
logging.config.dictConfig(logging_conf)
# Redirect Qt logging to Python logger:
QtCore.qInstallMessageHandler(qt_message_handler)

View file

@ -19,6 +19,8 @@ VERSION = '0.3.3-dev'
WEBSITE = 'https://github.com/rbreu/beeref'
COPYRIGHT = 'Copyright © 2021-2023 Rebecca Breu'
CHANGED_SYMBOL = ''
COLORS = {
# Qt:
'Active:Base': (60, 60, 60),
@ -31,6 +33,10 @@ COLORS = {
'Active:ButtonText': (200, 200, 200),
'Active:Highlight': (83, 167, 165),
'Active:Link': (90, 181, 179),
'Disabled:Base': (40, 40, 40),
'Disabled:Window': (40, 40, 40, 50),
'Disabled:WindowText': (120, 120, 120),
'Disabled:Light': (0, 0, 0, 0),
'Disabled:Text': (140, 140, 140),

View file

@ -45,9 +45,13 @@ def exif_rotated_image(path=None):
logger.exception(f'Exif parser failed on image: {path}')
return img
if 'orientation' in exifimg.list_all():
orientation = exifimg.orientation
else:
try:
if 'orientation' in exifimg.list_all():
orientation = exifimg.orientation
else:
return img
except NotImplementedError:
logger.exception(f'Exif failed reading orientation of image: {path}')
return img
transform = QtGui.QTransform()

View file

@ -261,6 +261,7 @@ class SQLiteIO:
if self.worker.canceled:
break
self.delete_items(to_delete)
self.ex('VACUUM')
self.connection.commit()
if self.worker:
self.worker.finished.emit(self.filename, [])

View file

@ -114,9 +114,10 @@ class MainControlsMixin:
self.exit_movewin_mode()
event.accept()
return True
if (event.button() == Qt.MouseButton.LeftButton
and event.modifiers() == (Qt.KeyboardModifier.ControlModifier
| Qt.KeyboardModifier.AltModifier)):
action, inverted =\
self.control_target.keyboard_settings.mouse_action_for_event(event)
if action == 'movewindow':
self.enter_movewin_mode()
event.accept()
return True

View file

@ -45,20 +45,22 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.max_z = 0
self.min_z = 0
self.Z_STEP = 0.001
self.multi_select_item = MultiSelectItem()
self.rubberband_item = RubberbandItem()
self.selectionChanged.connect(self.on_selection_change)
self.changed.connect(self.on_change)
self.items_to_add = Queue()
self.internal_clipboard = []
self.edit_item = None
self.crop_item = None
self.settings = BeeSettings()
self.clear()
self._clear_ongoing = False
def clear(self):
self._clear_ongoing = True
super().clear()
self.internal_clipboard = []
self.rubberband_item = RubberbandItem()
self.multi_select_item = MultiSelectItem()
self._clear_ongoing = False
def addItem(self, item):
logger.debug(f'Adding item {item}')
@ -452,6 +454,10 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
return (rect.topLeft() + rect.bottomRight()) / 2
def on_selection_change(self):
if self._clear_ongoing:
# Ignore events while clearing the scene since the
# multiselect item will get cleared, too
return
if self.has_multi_selection():
self.multi_select_item.fit_selection_area(
self.itemsBoundingRect(selection_only=True))
@ -462,6 +468,10 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.removeItem(self.multi_select_item)
def on_change(self, region):
if self._clear_ongoing:
# Ignore events while clearing the scene since the
# multiselect item will get cleared, too
return
if (self.multi_select_item.scene()
and self.multi_select_item.active_mode is None):
self.multi_select_item.fit_selection_area(

View file

@ -13,6 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from collections import OrderedDict
import re
from PyQt6 import QtCore, QtGui
@ -93,3 +94,16 @@ def qcolor_to_hex(color):
rgb = color.name()
alpha = hex(color.alpha()).removeprefix('0x')
return f'{rgb}{alpha}'
class ActionList(OrderedDict):
def __init__(self, actions):
super().__init__()
for action in actions:
self[action.id] = action
def __getitem__(self, key):
if isinstance(key, int):
key = list(self.keys())[key]
return super().__getitem__(key)

View file

@ -23,7 +23,7 @@ from PyQt6.QtCore import Qt
from beeref.actions import ActionsMixin, actions
from beeref import commands
from beeref.config import CommandlineArgs, BeeSettings
from beeref.config import CommandlineArgs, BeeSettings, KeyboardSettings
from beeref import constants
from beeref import fileio
from beeref.fileio.export import exporter_registry
@ -51,6 +51,7 @@ class BeeGraphicsView(MainControlsMixin,
self.app = app
self.parent = parent
self.settings = BeeSettings()
self.keyboard_settings = KeyboardSettings()
self.welcome_overlay = widgets.welcome_overlay.WelcomeOverlay(self)
self.setBackgroundBrush(
@ -493,7 +494,7 @@ class BeeGraphicsView(MainControlsMixin,
widgets.settings.SettingsDialog(self)
def on_action_keyboard_settings(self):
widgets.settings.KeyboardSettingsDialog(self)
widgets.controls.ControlsDialog(self)
def on_action_help(self):
widgets.HelpDialog(self)
@ -540,6 +541,7 @@ class BeeGraphicsView(MainControlsMixin,
def do_insert_images(self, filenames, pos=None):
if not pos:
pos = self.get_view_center()
self.scene.deselect_all_items()
self.undo_stack.beginMacro('Insert Images')
self.worker = fileio.ThreadedIO(
fileio.load_images,
@ -748,17 +750,23 @@ class BeeGraphicsView(MainControlsMixin,
self.reset_previous_transform()
def wheelEvent(self, event):
if event.modifiers() == Qt.KeyboardModifier.NoModifier:
self.zoom(event.angleDelta().y(), event.position())
action, inverted\
= self.keyboard_settings.mousewheel_action_for_event(event)
delta = event.angleDelta().y()
if inverted:
delta = delta * -1
if action == 'zoom':
self.zoom(delta, event.position())
event.accept()
return
if event.modifiers() == (Qt.KeyboardModifier.ShiftModifier
| Qt.KeyboardModifier.ControlModifier):
self.pan(QtCore.QPointF(0, 0.5 * event.angleDelta().y()))
if action == 'pan_horizontal':
self.pan(QtCore.QPointF(0, 0.5 * delta))
event.accept()
return
if event.modifiers() == Qt.KeyboardModifier.ShiftModifier:
self.pan(QtCore.QPointF(0.5 * event.angleDelta().y(), 0))
if action == 'pan_vertical':
self.pan(QtCore.QPointF(0.5 * delta, 0))
event.accept()
return
@ -784,17 +792,17 @@ class BeeGraphicsView(MainControlsMixin,
event.accept()
return
if (event.button() == Qt.MouseButton.MiddleButton
and event.modifiers() == Qt.KeyboardModifier.ControlModifier):
action, inverted = self.keyboard_settings.mouse_action_for_event(event)
if action == 'zoom':
self.active_mode = self.ZOOM_MODE
self.event_start = event.position()
self.event_anchor = event.position()
self.event_inverted = inverted
event.accept()
return
if (event.button() == Qt.MouseButton.MiddleButton
or (event.button() == Qt.MouseButton.LeftButton
and event.modifiers() == Qt.KeyboardModifier.AltModifier)):
if action == 'pan':
logger.trace('Begin pan')
self.active_mode = self.PAN_MODE
self.event_start = event.position()
@ -820,6 +828,8 @@ class BeeGraphicsView(MainControlsMixin,
self.reset_previous_transform()
pos = event.position()
delta = (self.event_start - pos).y()
if self.event_inverted:
delta *= -1
self.event_start = pos
self.zoom(delta * 20, self.event_anchor)
event.accept()

View file

@ -21,7 +21,12 @@ from PyQt6.QtCore import Qt
from beeref import constants, commands
from beeref.config import logfile_name
from beeref.widgets import settings, welcome_overlay, color_gamut # noqa: F401
from beeref.widgets import ( # noqa: F401
controls,
settings,
welcome_overlay,
color_gamut,
)
logger = logging.getLogger(__name__)
@ -69,12 +74,12 @@ class HelpDialog(QtWidgets.QDialog):
# Controls
with open(os.path.join(docdir, 'controls.html')) as f:
controls_txt = f.read()
controls = QtWidgets.QLabel(controls_txt)
controls.setTextInteractionFlags(
controls_label = QtWidgets.QLabel(controls_txt)
controls_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
scroll = QtWidgets.QScrollArea(self)
scroll.setWidgetResizable(True)
scroll.setWidget(controls)
scroll.setWidget(controls_label)
tabs.addTab(scroll, '&Controls')
layout = QtWidgets.QVBoxLayout()

View file

@ -0,0 +1,96 @@
# This file is part of BeeRef.
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
import logging
from PyQt6 import QtWidgets
from beeref.config import KeyboardSettings
from beeref.widgets.controls.keyboard import KeyboardShortcutsView
from beeref.widgets.controls.mouse import MouseView
from beeref.widgets.controls.mousewheel import MouseWheelView
logger = logging.getLogger(__name__)
class ControlsDialog(QtWidgets.QDialog):
def __init__(self, parent):
super().__init__(parent)
self.setWindowTitle('Keyboard & Mouse Controls')
tabs = QtWidgets.QTabWidget()
# Keyboard shortcuts
keyboard = QtWidgets.QWidget(parent)
kb_layout = QtWidgets.QVBoxLayout()
keyboard.setLayout(kb_layout)
table = KeyboardShortcutsView(keyboard)
search_input = QtWidgets.QLineEdit()
search_input.setPlaceholderText('Search...')
search_input.textChanged.connect(table.model().setFilterFixedString)
kb_layout.addWidget(search_input)
kb_layout.addWidget(table)
tabs.addTab(keyboard, '&Keyboard Shortcuts')
# Mouse controls
mouse = QtWidgets.QWidget(parent)
mouse_layout = QtWidgets.QVBoxLayout()
mouse.setLayout(mouse_layout)
table = MouseView(mouse)
search_input = QtWidgets.QLineEdit()
search_input.setPlaceholderText('Search...')
search_input.textChanged.connect(table.model().setFilterFixedString)
mouse_layout.addWidget(search_input)
mouse_layout.addWidget(table)
tabs.addTab(mouse, '&Mouse')
# Mouse wheel controls
mousewheel = QtWidgets.QWidget(parent)
wheel_layout = QtWidgets.QVBoxLayout()
mousewheel.setLayout(wheel_layout)
table = MouseWheelView(mousewheel)
search_input = QtWidgets.QLineEdit()
search_input.setPlaceholderText('Search...')
search_input.textChanged.connect(table.model().setFilterFixedString)
wheel_layout.addWidget(search_input)
wheel_layout.addWidget(table)
tabs.addTab(mousewheel, 'Mouse &Wheel')
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
layout.addWidget(tabs)
# Bottom row of buttons
buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
reset_btn = QtWidgets.QPushButton('&Restore Defaults')
reset_btn.setAutoDefault(False)
reset_btn.clicked.connect(self.on_restore_defaults)
buttons.addButton(reset_btn,
QtWidgets.QDialogButtonBox.ButtonRole.ActionRole)
layout.addWidget(buttons)
self.show()
def on_restore_defaults(self, *args, **kwargs):
reply = QtWidgets.QMessageBox.question(
self,
'Restore defaults?',
'Do you want to restore all keyboard and mouse settings '
'to their default values?')
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
KeyboardSettings().restore_defaults()

View file

@ -0,0 +1,262 @@
# This file is part of BeeRef.
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from functools import partial
from PyQt6 import QtWidgets, QtCore
from PyQt6.QtCore import Qt
from beeref.config import KeyboardSettings
from beeref import constants
class MouseControlsEditorBase(QtWidgets.QDialog):
"""Common code for MouseWheel and Mouse control editors."""
saved = QtCore.pyqtSignal()
def init_dialog(self, parent, index, actions, title):
super().__init__(parent)
self.actions = actions
self.action = self.actions[index.row()]
self.setWindowTitle(f'title {self.action.text}')
self.old_modifiers = self.action.get_modifiers()
self.remove_from_other = None
self.ignore_on_changed = False
self.setAutoFillBackground(True)
self.layout = QtWidgets.QVBoxLayout()
self.setLayout(self.layout)
self.setModal(True)
def init_modifiers_input(self):
group = QtWidgets.QGroupBox('Modifiers')
group_layout = QtWidgets.QVBoxLayout()
group.setLayout(group_layout)
self.layout.addWidget(group)
self.checkboxes = {}
for mod in self.action.MODIFIER_MAP.keys():
checkbox = QtWidgets.QCheckBox(mod)
checkbox.setChecked(mod in self.old_modifiers)
checkbox.stateChanged.connect(
partial(self.on_modifiers_changed, mod))
self.checkboxes[mod] = checkbox
group_layout.addWidget(checkbox)
def init_button_row(self):
buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton.Cancel
| QtWidgets.QDialogButtonBox.StandardButton.Ok)
buttons.accepted.connect(self.on_save)
buttons.rejected.connect(self.reject)
self.layout.addWidget(buttons)
def set_modifiers_no_modifier(self):
"""Check 'No Modifiers', uncheck everything else."""
for key, checkbox in self.checkboxes.items():
checkbox.setChecked(key == 'No Modifier')
def on_modifiers_changed(self, modifier, value):
"""Ensure that when 'No Modifiers' is checked, nothing else is
checked at the same time.
If everything is unchecked, set 'No Modifiers' automatically.
"""
if self.ignore_on_changed:
return
checked = value == Qt.CheckState.Checked.value
self.ignore_on_changed = True
if checked and modifier == 'No Modifier':
self.set_modifiers_no_modifier()
if checked and modifier != 'No Modifier':
self.checkboxes['No Modifier'].setChecked(False)
if not checked and not self.get_modifiers(cleaned=False):
self.set_modifiers_no_modifier()
self.ignore_on_changed = False
def get_modifiers(self, cleaned=True):
modifiers = [key for key, checkbox in self.checkboxes.items()
if checkbox.isChecked()]
if cleaned and 'No Modifier' in modifiers:
# In this case the list already should only have the one
# entry, but just to make sure...
return ['No Modifier']
return modifiers
def set_modifiers(self, modifiers):
for key, checkbox in self.checkboxes.items():
checkbox.setChecked(key in modifiers)
def get_temp_action(self):
raise NotImplementedError # pragma: no cover
def reset_inputs(self):
raise NotImplementedError # pragma: no cover
def on_save(self):
"""Don't let users save the same controls on different actions."""
temp = self.get_temp_action()
self.remove_from_other = None
for action in self.actions.values():
if action == self.action:
continue
if action.conflicts_with(temp):
msg = ('<p>These controls are already used for:</p>'
f'<p>{action.text}</p>'
'<p>Do you want to remove the other controls'
' to save these ones?</p>')
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

View file

@ -0,0 +1,201 @@
# This file is part of BeeRef.
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
import logging
from PyQt6 import QtWidgets, QtCore
from beeref import constants
from beeref.actions.actions import actions
from beeref.config import KeyboardSettings, settings_events
logger = logging.getLogger(__name__)
class KeyboardShortcutsEditor(QtWidgets.QKeySequenceEdit):
def __init__(self, parent, index):
super().__init__(parent)
self.action = actions[index.row()]
try:
self.old_value = self.action.get_shortcuts()[index.column() - 2]
except IndexError:
self.old_value = ''
self.setClearButtonEnabled(True)
self.setMaximumSequenceLength(1)
self.editingFinished.connect(self.on_editing_finished)
self.finished_last_called_with = None
self.remove_from_other = None
def on_editing_finished(self):
"""Don't let users save the same shortcuts on different actions."""
shortcut = self.keySequence().toString()
if self.finished_last_called_with == shortcut:
# Workaround for bug
# https://bugreports.qt.io/browse/QTBUG-40
# editingFinished signal is emitted twice because of
# the QMessageBox below
return
self.remove_from_other = None
self.finished_last_called_with = shortcut
for action in actions.values():
if action == self.action:
continue
if shortcut in action.get_shortcuts():
txt = ': '.join(action.menu_path + [action.text])
txt = txt.replace('&', '').removesuffix('...')
msg = ('<p>This shortcut is already used for:</p>'
f'<p>{txt}</p>'
'<p>Do you want to remove the other shortcut'
' to save this one?</p>')
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()

View file

@ -0,0 +1,170 @@
# This file is part of BeeRef.
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from functools import partial
import logging
from PyQt6 import QtWidgets, QtCore
from beeref.config import KeyboardSettings, settings_events
from beeref.config.controls import MouseConfig
from beeref.widgets.controls.common import (
MouseControlsEditorBase,
MouseControlsModelBase,
)
logger = logging.getLogger(__name__)
class MouseControlsEditor(MouseControlsEditorBase):
def __init__(self, parent, index):
self.init_dialog(parent, index, KeyboardSettings.MOUSE_ACTIONS,
'Mouse Controls for:')
self.old_button = self.action.get_button()
self.layout.addWidget(QtWidgets.QLabel('Mouse Button:'))
self.button_input = QtWidgets.QComboBox(parent=parent)
self.button_input.insertItems(0, self.action.BUTTON_MAP.keys())
values = list(self.action.BUTTON_MAP.keys())
self.button_input.setCurrentIndex(values.index(self.old_button))
self.layout.addWidget(self.button_input)
self.init_modifiers_input()
self.init_button_row()
self.on_button_changed()
self.button_input.currentIndexChanged.connect(self.on_button_changed)
self.show()
def on_button_changed(self):
"""Disable modifier inputs when no button configured; enable
otherwise.
"""
self.ignore_on_changed = True
if self.get_button() == 'Not Configured':
for key, checkbox in self.checkboxes.items():
checkbox.setChecked(False)
self.set_modifiers_enabled(False)
else:
if not self.get_modifiers(cleaned=False):
self.set_modifiers_no_modifier()
self.set_modifiers_enabled(True)
self.ignore_on_changed = False
def set_modifiers_enabled(self, enabled):
for key, checkbox in self.checkboxes.items():
checkbox.setEnabled(enabled)
def get_button(self):
values = list(self.action.BUTTON_MAP.keys())
return values[self.button_input.currentIndex()]
def set_button(self, value):
values = list(self.action.BUTTON_MAP.keys())
self.button_input.setCurrentIndex(values.index(value))
def get_modifiers(self, cleaned=True):
if cleaned and self.get_button() == 'Not Configured':
# In this case the list should already be empty but just
# to make sure...
return []
return super().get_modifiers(cleaned=True)
def get_temp_action(self):
return MouseConfig(button=self.get_button(),
modifiers=self.get_modifiers(),
group=None, text=None, invertible=None, id=None)
def reset_inputs(self):
self.set_button(self.old_button)
self.set_modifiers(self.old_modifiers)
class MouseDelegate(QtWidgets.QStyledItemDelegate):
def createEditor(self, parent, option, index):
widget = QtWidgets.QWidget(parent)
widget.editor = MouseControlsEditor(widget, index)
widget.editor.saved.connect(
partial(self.setModelData, widget, index.model(), index))
return widget
def setModelData(self, editor, model, index):
editor = editor.editor
if editor.result() == QtWidgets.QDialog.DialogCode.Accepted:
model.setData(
index,
{'button': editor.get_button(),
'modifiers': editor.get_modifiers()},
QtCore.Qt.ItemDataRole.EditRole,
remove_from_other=editor.remove_from_other)
class MouseModel(MouseControlsModelBase):
"""An entry in the keyboard shortcuts table."""
COLUMNS = (MouseControlsModelBase.COL_ACTION,
MouseControlsModelBase.COL_CHANGED,
MouseControlsModelBase.COL_BUTTON,
MouseControlsModelBase.COL_MODIFIERS,
MouseControlsModelBase.COL_INVERTED)
def __init__(self):
super().__init__(KeyboardSettings.MOUSE_ACTIONS)
def set_data_on_action(self, action, value):
action.set_button(value['button'])
action.set_modifiers(value['modifiers'])
class MouseProxy(QtCore.QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.setSourceModel(MouseModel())
self.setFilterCaseSensitivity(
QtCore.Qt.CaseSensitivity.CaseInsensitive)
def setData(self, index, value, role, remove_from_other=None):
result = self.sourceModel().setData(
self.mapToSource(index),
value,
role,
remove_from_other=remove_from_other)
return result
class MouseView(QtWidgets.QTableView):
def __init__(self, parent):
super().__init__(parent)
self.setMinimumSize(QtCore.QSize(400, 200))
self.setItemDelegate(MouseDelegate())
self.setShowGrid(False)
self.setModel(MouseProxy())
self.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.ResizeMode.Stretch)
self.horizontalHeader().setSectionResizeMode(
1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
self.setSelectionMode(
QtWidgets.QHeaderView.SelectionMode.SingleSelection)
self.setAlternatingRowColors(True)
settings_events.restore_defaults.connect(
self.on_restore_defaults)
def on_restore_defaults(self):
self.viewport().update()

View file

@ -0,0 +1,120 @@
# This file is part of BeeRef.
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from functools import partial
import logging
from PyQt6 import QtWidgets, QtCore
from beeref.config import KeyboardSettings, settings_events
from beeref.config.controls import MouseWheelConfig
from beeref.widgets.controls.common import (
MouseControlsEditorBase,
MouseControlsModelBase,
)
logger = logging.getLogger(__name__)
class MouseWheelModifiersEditor(MouseControlsEditorBase):
def __init__(self, parent, index):
self.init_dialog(parent, index, KeyboardSettings.MOUSEWHEEL_ACTIONS,
'MouseWheel Controls for:')
self.init_modifiers_input()
self.init_button_row()
self.show()
def get_temp_action(self):
return MouseWheelConfig(
modifiers=self.get_modifiers(),
group=None, text=None, invertible=None, id=None)
def reset_inputs(self):
self.set_modifiers(self.old_modifiers)
class MouseWheelDelegate(QtWidgets.QStyledItemDelegate):
def createEditor(self, parent, option, index):
widget = QtWidgets.QWidget(parent)
widget.editor = MouseWheelModifiersEditor(widget, index)
widget.editor.saved.connect(
partial(self.setModelData, widget, index.model(), index))
return widget
def setModelData(self, editor, model, index):
editor = editor.editor
if editor.result() == QtWidgets.QDialog.DialogCode.Accepted:
model.setData(
index,
editor.get_modifiers(),
QtCore.Qt.ItemDataRole.EditRole,
remove_from_other=editor.remove_from_other)
class MouseWheelModel(MouseControlsModelBase):
"""An entry in the keyboard shortcuts table."""
COLUMNS = (MouseControlsModelBase.COL_ACTION,
MouseControlsModelBase.COL_CHANGED,
MouseControlsModelBase.COL_MODIFIERS,
MouseControlsModelBase.COL_INVERTED)
def __init__(self):
super().__init__(KeyboardSettings.MOUSEWHEEL_ACTIONS)
def set_data_on_action(self, action, value):
action.set_modifiers(value)
class MouseWheelProxy(QtCore.QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.setSourceModel(MouseWheelModel())
self.setFilterCaseSensitivity(
QtCore.Qt.CaseSensitivity.CaseInsensitive)
def setData(self, index, value, role, remove_from_other=None):
result = self.sourceModel().setData(
self.mapToSource(index),
value,
role,
remove_from_other=remove_from_other)
return result
class MouseWheelView(QtWidgets.QTableView):
def __init__(self, parent):
super().__init__(parent)
self.setMinimumSize(QtCore.QSize(400, 200))
self.setItemDelegate(MouseWheelDelegate())
self.setShowGrid(False)
self.setModel(MouseWheelProxy())
self.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.ResizeMode.Stretch)
self.horizontalHeader().setSectionResizeMode(
1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
self.setSelectionMode(
QtWidgets.QHeaderView.SelectionMode.SingleSelection)
self.setAlternatingRowColors(True)
settings_events.restore_defaults.connect(
self.on_restore_defaults)
def on_restore_defaults(self):
self.viewport().update()

View file

@ -16,19 +16,15 @@
from functools import partial
import logging
from PyQt6 import QtWidgets, QtCore
from PyQt6 import QtWidgets
from beeref import constants
from beeref.actions.actions import actions
from beeref.config import BeeSettings, KeyboardSettings, settings_events
from beeref.config import BeeSettings, settings_events
logger = logging.getLogger(__name__)
CHANGED_SYMBOL = ''
class GroupBase(QtWidgets.QGroupBox):
def __init__(self):
@ -47,7 +43,7 @@ class GroupBase(QtWidgets.QGroupBox):
def update_title(self):
title = [self.TITLE]
if self.settings.value_changed(self.KEY):
title.append(CHANGED_SYMBOL)
title.append(constants.CHANGED_SYMBOL)
self.setTitle(' '.join(title))
def on_value_changed(self, value):
@ -178,222 +174,3 @@ class SettingsDialog(QtWidgets.QDialog):
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
BeeSettings().restore_defaults()
class KeyboardShortcutsEditor(QtWidgets.QKeySequenceEdit):
def __init__(self, parent, index):
super().__init__(parent)
self.action = actions[index.row()]
try:
self.old_value = self.action.get_shortcuts()[index.column() - 2]
except IndexError:
self.old_value = ''
self.setClearButtonEnabled(True)
self.setMaximumSequenceLength(1)
self.editingFinished.connect(self.on_editing_finished)
self.finished_last_called_with = None
self.remove_from_other = None
def on_editing_finished(self):
shortcut = self.keySequence().toString()
if self.finished_last_called_with == shortcut:
# Workaround for bug
# https://bugreports.qt.io/browse/QTBUG-40
# editingFinished signal is emitted twice because of
# the QMessageBox below
return
self.remove_from_other = None
self.finished_last_called_with = shortcut
for action in actions.values():
if action == self.action:
continue
if shortcut in action.get_shortcuts():
txt = ': '.join(action.menu_path + [action['text']])
txt = txt.replace('&', '').removesuffix('...')
msg = ('<p>This shortcut is already used for:</p>'
f'<p>{txt}</p>'
'<p>Do you want to remove the other shortcut'
' to save this one?</p>')
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()

View file

@ -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

View file

@ -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': [

View file

@ -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

View file

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

0
tests/config/__init__.py Normal file
View file

View file

@ -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

View file

@ -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

View file

@ -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'),

View file

@ -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

View file

@ -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

View file

View file

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

View file

@ -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() == []

File diff suppressed because it is too large Load diff

View file

@ -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() == []

View file

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

172
tools/build_appimage.py Executable file
View file

@ -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')

171
tools/find_linux_libs.py Executable file
View file

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

238
tools/linux_libs.json Normal file
View file

@ -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"
]
}