Compare commits

...

60 commits
v0.3.2 ... main

Author SHA1 Message Date
Rebecca Breu
caed5c3801 Only migrate when bee file version is smaller 2024-06-02 16:32:22 +02:00
Rebecca Breu
960337887f Update changelog 2024-06-02 12:23:30 +02:00
DarkDefender
e804766dc4 Fix crash when dragging an image and toggling on crop mode
`crop_mode_event_start` would be set to `None` and thus crash the program. Add a check for this.
2024-06-02 12:22:11 +02:00
Rebecca Breu
ef8189ec72 Add contributor's name to changelog 2024-06-02 12:09:00 +02:00
Rebecca Breu
46f0ae54ae Update changelog 2024-06-02 12:08:12 +02:00
DarkDefender
c545ad6ddc Disable keyrepeat for shortcuts
Currently the keyboard shortcut actions gets repeated when holding down the shortcut keys. I don't think this is intentional as I didn't find any shortcuts that would benefit from this behavior. 

Besides just being a bit annoying, this could also lead to crashes as the program would get into an undefined state.

For example if you held down the crop shortcut key (shift + c) and clicked outside of the crop hot zones, beeref would crash most of the time.
2024-06-02 12:03:25 +02:00
Rebecca Breu
6e3bc35f74 Open images from command line 2024-05-26 15:08:45 +02:00
Rebecca Breu
95b024ee42 Make arrange method for importing images configurable 2024-05-26 12:03:38 +02:00
Rebecca Breu
2b9240693e Arange horizontal/vertical now also sorts by filename 2024-05-25 22:42:29 +02:00
Rebecca Breu
f2fc116bb3 Update OS dependencies for build workflow 2024-05-25 18:22:53 +02:00
Rebecca Breu
b22056cefa Add action 'Arrange by Filename' 2024-05-25 18:21:23 +02:00
Rebecca Breu
aac2d0edfc Add confirmation dialog when discarding an unsaved file 2024-05-20 19:31:06 +02:00
Rebecca Breu
a89027f0ba Fix hang when saving an open bee file that has been removed 2024-05-19 22:39:09 +02:00
Rafael Mardojai CM
bdc3653cd6 appdata: Use screenshot direct raw link
Otherwise appstream validation will fail with screenshot-no-media error
2024-05-19 19:21:40 +02:00
Rebecca Breu
b513af015c Make reading image orientation in exif data more robust (#111) 2024-05-19 19:16:18 +02:00
Rebecca Breu
479665b9f1 Export all images from scene 2024-05-19 19:05:31 +02:00
Rebecca Breu
38aaaf2553 Fix unit tests 2024-05-09 16:01:33 +02:00
Rebecca Breu
c5e23fdf4f Display error messages when images can't be loaded from bee file 2024-05-09 15:41:47 +02:00
Rebecca Breu
7ca33cfc0b Fix unit tests 2024-05-08 23:07:39 +02:00
Rebecca Breu
13192987c6 Add setting to change default image allocation limit. 2024-05-08 21:35:11 +02:00
Rebecca Breu
65942c08cf Fix reading docs with importlib.resources in Python 3.9 2024-05-07 22:03:43 +02:00
Rebecca Breu
5b86ad95d9 Use importlib.resources instead of __file__ 2024-05-07 21:37:38 +02:00
Rebecca Breu
8a19a916a0 Add non-Python assets to pyproj.toml 2024-05-07 21:20:18 +02:00
Rebecca Breu
4648fa9c91 Fix pyproj.toml 2024-05-07 20:11:38 +02:00
Rebecca Breu
656f20f1ca Re-add accidentally removed setup.cfg; remove setup.py 2024-05-07 19:57:12 +02:00
Rebecca Breu
3bd65b644f Upgrade dependencies 2024-05-05 20:14:34 +02:00
Rebecca Breu
0891df8a20 Tweak coverage upload 2024-05-05 16:23:49 +02:00
Rebecca Breu
62272b7a88 Migrate to pyproject.toml 2024-05-05 16:06:05 +02:00
Rebecca Breu
1be96ca4c3 Update github action dependencies 2024-05-05 15:35:47 +02:00
Rebecca Breu
b3bb70ce8a Bump version number to 0.3.4-dev 2024-05-05 12:10:54 +02:00
Rebecca Breu
f53abad9c8 Prepare release 0.3.3 2024-05-05 11:22:21 +02:00
Rebecca Breu
4aa6c2eb30 Appimage: Add metadata, don't include GTK libs, various fixes 2024-05-05 11:05:49 +02:00
Rebecca Breu
9356f53b40 Appimage: Add workaround for older Wayland versions (see #102) 2024-05-04 19:42:26 +02:00
Rebecca Breu
6ddccd875d Fixed pasted items being inserted behind existing items 2024-05-04 17:04:40 +02:00
Rebecca Breu
5f4c5088f0 Fix color sampling while multiple items are selected 2024-05-04 16:40:36 +02:00
Rebecca Breu
2af6a75cc3 Added unit tests for pinterst image download 2024-05-04 15:33:29 +02:00
Randommist
fb8515e8ae
Easy drag and drop from Pinterest feed (#97) 2024-05-04 15:20:01 +02:00
Rebecca Breu
3151dc4993 Fix github appimage build 2024-04-28 11:56:39 +02:00
Rebecca Breu
1119004fe1 Add appimage build script and github action 2024-04-25 19:10:52 +02:00
Rebecca Breu
82787d08aa Vacuum bee files on saving (fixes #99) 2024-04-04 19:38:44 +02:00
Rebecca Breu
c64385b2c0 Configurable mouse and mouswheel controls 2024-04-01 15:21:54 +02:00
Rebecca Breu
a532d1d667 Fixed an intermittent crash when invoking New Scene
Multiselect item will get cleared, too, and can't be accesed. Ignore
scene change events during cleaning, because those want to access the
multi select item.
2024-03-29 23:24:26 +01:00
Rebecca Breu
b025204dfb Fixed threading issue when importing images
Don't deselect items from within the import worker thread.
2024-03-29 22:50:49 +01:00
Rebecca Breu
b2c8b867f2 Fail gracefully when exif orientation usupported 2024-03-22 20:32:21 +01:00
Rebecca Breu
f9590e9f2e Ajdusted modifiers for panning via scroll wheel 2024-03-03 14:14:05 +01:00
Rebecca Breu
53c2ced83e Added panning via scroll wheel 2024-03-03 13:29:52 +01:00
Rebecca Breu
f8b3270e0c Added feature request issue template 2024-03-03 12:30:21 +01:00
Rebecca Breu
113ba39488 Notification when attempt to paste from empty clipboard 2024-03-03 12:18:26 +01:00
Rebecca Breu
7aab1ed38f Add color sampler to copy colors to clipboard in hex format 2024-03-02 20:38:49 +01:00
Rebecca Breu
b1aec4a156 Make missing transparency in grayscale mode less obvious by applying canvas colour as background 2024-03-02 12:46:52 +01:00
Rebecca Breu
14549c1a67 Fix crop outline on zoomed in images 2024-03-01 20:25:14 +01:00
Rebecca Breu
5c308c57b7 Fix grayscale checkbox not updating properly 2024-03-01 19:59:32 +01:00
Rebecca Breu
69dc401d0b Fix unit tests 2024-02-27 21:43:16 +01:00
Rebecca Breu
2a9487a5e9 Use standard palette for alternating table rows 2024-02-27 21:31:46 +01:00
Rebecca Breu
052bfb9fa5 Revert "stylesheet_wip"
This reverts commit a7ac9e8640.
2024-02-27 19:14:54 +01:00
Rebecca Breu
a7ac9e8640 stylesheet_wip 2024-02-27 19:14:05 +01:00
Rebecca Breu
51b9bafbeb Use different cursor for moving the BeeRef window 2024-02-20 22:00:46 +01:00
Rebecca Breu
0e3fdf5c72 uiae 2024-02-20 20:59:20 +01:00
Rebecca Breu
09984faedf Fix crash when pressing Ctrl+N while doing a rubberband selection 2024-02-19 19:07:51 +01:00
Rebecca Breu
40e9c27ca7 Bump version number to 0.3.3-dev 2024-01-21 16:41:20 +01:00
86 changed files with 9489 additions and 2264 deletions

View file

@ -10,10 +10,15 @@ assignees: ''
**Describe the bug**
A clear and concise description of what the bug is.
**System info**
BeeRef version number:
Your operating system:
**To Reproduce**
Steps to reproduce the behavior:
1.
2.
1.
2.
3.
**Expected behavior**

View file

@ -0,0 +1,13 @@
---
name: Feature request
about: Create a report to help us improve
title: ''
labels: enhancement
assignees: ''
---
**Describe the feature**
A clear and concise description of what feature you want.
ONLY ONE FEATURE REQUEST PER REPORT! Even if they are related! You may open as many feature requests as you want.

View file

@ -10,17 +10,15 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: ['ubuntu-20.04',
'ubuntu-22.04',
'macos-11',
'macos-12',
os: ['macos-12',
'macos-13',
'macos-14',
'windows-latest']
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Install dependencies
@ -32,7 +30,7 @@ jobs:
run: |
pyinstaller BeeRef.spec
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.os }}
path: dist/*

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

View file

@ -3,16 +3,16 @@ name: flake8
on: [push, pull_request]
jobs:
build:
flake8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: 3.11
python-version: 3.12
- name: Install dependencies
run: |
python -m pip install --upgrade pip

View file

@ -3,17 +3,16 @@ name: pytest
on: [push, pull_request]
jobs:
build:
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.11']
pyqt-version: ['6.5.0', '6.6.1']
python-version: ["3.9", "3.10", "3.11", "3.12"]
pyqt-version: ["6.7.0"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
@ -34,4 +33,8 @@ jobs:
run: |
xvfb-run --auto-servernum --server-num=1 --server-args="-screen 1 1920x1200x24 -ac +extension GLX" pytest --cov --cov-report=xml
- name: Upload Coverage report to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
verbose: true
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

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

@ -1,3 +1,87 @@
0.3.4-dev (unreleased)
======================
Added
-----
* Added a setting to change the default memory limit for individual
images. If a big image won't load, increase this limit. This
setting can be overridden by Qt's default environment variable
QT_IMAGEIO_MAXALLOC
* Display error messages when images can't be loaded from bee files
* Added option to export all images from scene (File -> Export Images)
* Added a confirmation dialog when attempting to close unsaved files.
The confirmation dialog can be disalbed in:
Settings -> Miscellaneous -> Confirm when closing an unsaved file
* Add option to arrange by filename (Arrange -> Square (by filename))
* Added a setting to choose the default arrange method on importing
images in batch.
(Settings -> Settings -> Images & Items -> Default Arrange Method).
* Added the ability to open image files from command line. If the
first command line arg is a bee file, it will be opened and all
further files will be ignored, as previously. If the first argument
isn't a bee file, all files will be treated as images and inserted
as if opened with "Insert -> Images".
Fixed
-----
* Fixed a case where adding/importing an image would hang when the image
contained unsupported exif data (#111)
* Fixed a hang when saving an open bee file that had been removed
since being opened
* Shortcuts now only trigger once when holding down the key
combination to avoid inconstistend program states and potential
crashes (by DarkDefender)
* Fixed a crash when pressing the crop shortcut while dragging an image
(by DarkDefender)
Changed
-------
* Arrange Horiszontal/Vertical now also sort by filename instead of
the previous seemingly random behaviour
0.3.3 - 2024-05-05
==================
Added
-----
* Moving the window from within BeeRef now changes to a diffent cursor from
the default arrow cursor.
* Added a color sampler which can copy colors from images to the
clipboard in hex format (Images -> Sample Color)
* Added notification when attempting to paste from an empty or
unusable clipboard
* 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
-----
* Fixed a crash when pressing the keyboard shortcut for New Scene
while in the process of doing a rubberband selection.
* The checkmark of the menu entry Images -> Grayscale is now updating
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)
* Fixed Drag @ Drop from pinterest feed (by Randommist)
* Fixed pasted items being inserted behind existing items
0.3.2 - 2024-01-21
==================
@ -23,7 +107,7 @@ Fixed
* Scene Export: Fix output image size and margins when scene had been
scaled or moved.
* Scene Export: Selecting filename without file extension now
automatically appends the extension from the selected filter istead
automatically appends the extension from the selected filter instead
of resulting in a confusing error message.
* The exemption from antialias/smoothing for images displayed at large
zoom now also works on images that are flipped horizontally

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

@ -1,11 +1,11 @@
[Desktop Entry]
Name=BeeRef
Comment=BeeRef 0.3.2
GenericName=Image Viewer
Comment=A simple reference image viewer
Terminal=false
Exec=/home/yourname/path/to/BeeRef-0.3.2-linux
Exec=/home/yourname/path/to/BeeRef-0.3.4-dev.appimage
Type=Application
Icon=/home/yourname/path/to/logo.png
Name[en_US]=BeeRef
MimeType=application/x-beeref;
Categories=Qt;KDE;Graphics;

View file

@ -103,10 +103,13 @@ 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()}')
CommandlineArgs(with_check=True) # Force checking
settings.on_startup()
args = CommandlineArgs(with_check=True) # Force checking
assert not args.debug_raise_error, args.debug_raise_error
os.environ["QT_DEBUG_PLUGINS"] = "1"
app = BeeRefApplication(sys.argv)

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,341 +104,347 @@ 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': '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='export_images',
text='Export &Images...',
callback='on_action_export_images',
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 (by filename)',
callback='on_action_arrange_horizontal',
group='active_when_selection',
),
Action(
id='arrange_vertical',
text='&Vertical (by filename)',
callback='on_action_arrange_vertical',
group='active_when_selection',
),
Action(
id='arrange_square',
text='&Square (by filename)',
callback='on_action_arrange_square',
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='on_action_new_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

@ -29,6 +29,7 @@ menu_structure = [
'save',
'save_as',
'export_scene',
'export_images',
MENU_SEPARATOR,
'quit',
],
@ -101,6 +102,7 @@ menu_structure = [
'arrange_optimal',
'arrange_horizontal',
'arrange_vertical',
'arrange_square',
],
},
{
@ -110,6 +112,7 @@ menu_structure = [
'grayscale',
MENU_SEPARATOR,
'show_color_gamut',
'sample_color',
],
},
{

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,19 @@ class ActionsMixin:
def _create_actions(self):
for action in actions.values():
qaction = QtGui.QAction(action['text'], self)
qaction = QtGui.QAction(action.text, self)
qaction.setAutoRepeat(False)
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 +113,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):
@ -123,7 +124,7 @@ class ActionsMixin:
qaction = QtGui.QAction(os.path.basename(filename), self)
qaction.setShortcuts(action.get_shortcuts())
qaction.triggered.connect(
partial(self.open_from_file, filename))
partial(self.on_action_open_recent_file, filename))
self.addAction(qaction)
action.qaction = qaction
self._recent_files_submenu.addAction(qaction)

View file

@ -15,8 +15,8 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from importlib.resources import files as rsc_files
import logging
import os.path
from PyQt6 import QtGui, QtWidgets
@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
class BeeAssets:
_instance = None
PATH = os.path.dirname(__file__)
PATH = rsc_files('beeref.assets')
def __new__(cls, *args, **kwargs):
if not cls._instance:
@ -37,7 +37,8 @@ class BeeAssets:
def on_new(self):
logger.debug(f'Assets path: {self.PATH}')
self.logo = QtGui.QIcon(os.path.join(self.PATH, 'logo.png'))
self.logo = QtGui.QIcon(str(self.PATH.joinpath('logo.png')))
assert self.logo.isNull() is False
self.cursor_rotate = self.cursor_from_image(
'cursor_rotate.png', (20, 20))
self.cursor_flip_h = self.cursor_from_image(
@ -48,7 +49,8 @@ class BeeAssets:
def cursor_from_image(self, filename, hotspot):
app = QtWidgets.QApplication.instance()
scaling = app.primaryScreen().devicePixelRatio()
img = QtGui.QImage(os.path.join(self.PATH, filename))
img = QtGui.QImage(str(self.PATH.joinpath(filename)))
assert img.isNull() is False
pixmap = QtGui.QPixmap.fromImage(img)
pixmap.setDevicePixelRatio(scaling)
return QtGui.QCursor(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -13,11 +13,11 @@
viewBox="0 0 50.536563 50.536564"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
sodipodi:docname="logo.svg"
inkscape:export-filename="/home/rbreu/code/python/beeref/git/beeref/assets/logo.png"
inkscape:export-xdpi="201.03999"
inkscape:export-ydpi="201.03999">
inkscape:export-xdpi="128.67"
inkscape:export-ydpi="128.67">
<defs
id="defs2">
<inkscape:path-effect
@ -496,10 +496,10 @@
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="2560"
inkscape:window-height="1311"
inkscape:window-width="1280"
inkscape:window-height="656"
inkscape:window-x="0"
inkscape:window-y="55"
inkscape:window-y="27"
inkscape:window-maximized="1"
fit-margin-top="0"
fit-margin-left="0"

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

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)
@ -39,6 +40,7 @@ class InsertItems(QtGui.QUndoCommand):
for item in self.items:
self.scene.addItem(item)
item.setSelected(True)
item.bring_to_front()
def undo(self):
self.scene.deselect_all_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,14 @@
# 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
import os.path
from PyQt6 import QtCore
from PyQt6 import QtCore, QtGui
from beeref import constants
from beeref.logging import qt_message_handler
logger = logging.getLogger(__name__)
@ -32,10 +29,14 @@ logger = logging.getLogger(__name__)
parser = argparse.ArgumentParser(
description=f'{constants.APPNAME_FULL} {constants.VERSION}')
parser.add_argument(
'filename',
nargs='?',
'filenames',
nargs='*',
default=None,
help='Bee file to open')
help=('Bee file or images to open. '
'If the first file is a bee file, it will be opened and all '
'further files will be ignored. If the first argument isn\'t a '
'bee file, all files will be treated as images and inserted as '
'if opened with "Insert -> Images".'))
parser.add_argument(
'--settings-dir',
help='settings directory to use instead of default location')
@ -59,6 +60,10 @@ parser.add_argument(
default=False,
action='store_true',
help='draw item\'s transform handle areas for debugging')
parser.add_argument(
'--debug-raise-error',
default='',
help='immediately exit with given error message')
class CommandlineArgs:
@ -108,6 +113,10 @@ settings_events = BeeSettingsEvents()
class BeeSettings(QtCore.QSettings):
FIELDS = {
'Save/confirm_close_unsaved': {
'default': True,
'cast': bool,
},
'Items/image_storage_format': {
'default': 'best',
'validate': lambda x: x in ('png', 'jpg', 'best'),
@ -117,6 +126,17 @@ class BeeSettings(QtCore.QSettings):
'cast': int,
'validate': lambda x: 0 <= x <= 200,
},
'Items/arrange_default': {
'default': 'optimal',
'validate': lambda x: x in (
'optimal', 'horizontal', 'vertical', 'square'),
},
'Items/image_allocation_limit': {
'default': 256,
'cast': int,
'validate': lambda x: x >= 0,
'post_save_callback': QtGui.QImageReader.setAllocationLimit,
}
}
def __init__(self):
@ -132,6 +152,26 @@ class BeeSettings(QtCore.QSettings):
constants.APPNAME,
constants.APPNAME)
def on_startup(self):
"""Settings to be applied on application startup."""
if os.environ.get('QT_IMAGEIO_MAXALLOC'):
alloc = int(os.environ['QT_IMAGEIO_MAXALLOC'])
else:
alloc = self.valueOrDefault('Items/image_allocation_limit')
QtGui.QImageReader.setAllocationLimit(alloc)
def setValue(self, key, value):
super().setValue(key, value)
if key in self.FIELDS and 'post_save_callback' in self.FIELDS[key]:
self.FIELDS[key]['post_save_callback'](value)
def remove(self, key):
super().remove(key)
if key in self.FIELDS and 'post_save_callback' in self.FIELDS[key]:
value = self.valueOrDefault(key)
self.FIELDS[key]['post_save_callback'](value)
def valueOrDefault(self, key):
"""Get the value for key, or the default value specified in FIELDS.
@ -142,7 +182,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 +244,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

@ -15,13 +15,16 @@
APPNAME = 'BeeRef'
APPNAME_FULL = f'{APPNAME} Reference Image Viewer'
VERSION = '0.3.2'
VERSION = '0.3.4-dev'
WEBSITE = 'https://github.com/rbreu/beeref'
COPYRIGHT = 'Copyright © 2021-2023 Rebecca Breu'
COPYRIGHT = 'Copyright © 2021-2024 Rebecca Breu'
CHANGED_SYMBOL = ''
COLORS = {
# Qt:
'Active:Base': (60, 60, 60),
'Active:AlternateBase': (70, 70, 70),
'Active:Window': (40, 40, 40),
'Active:Button': (40, 40, 40),
'Active:Text': (200, 200, 200),
@ -30,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),
@ -37,6 +44,4 @@ COLORS = {
'Scene:Selection': (116, 234, 231),
'Scene:Canvas': (60, 60, 60),
'Scene:Text': (200, 200, 200),
'Table:AlternativeRow': (70, 70, 70),
}

View file

@ -0,0 +1,3 @@
# Reading files in this directory with importlib.resources.files with
# Python 3.9 requires an __init__.py file here. Later version are fine
# without.

View file

@ -2,13 +2,15 @@
<p>
Middle Click + Drag<br/>
Alt + Left Click + Drag
Alt + Left Click + Drag<br/>
Shift + Ctrl + Mouse Wheel (pan vertically)<br/>
Shift + Mouse wheel (pan horizontally)
</p>
<h4>Zoom Canvas</h4>
<p>
Mouse wheel up/down<br/>
Ctrl + Mouse Wheel<br/>
Ctrl + Middle Click + Drag
</p>
@ -27,7 +29,7 @@
Rubberband Selection: Left Click + Drag
</p>
<h4>Move Selection</h4>
<h4>Move Selected Items</h4>
<p>
Left Click + Drag

View file

@ -49,7 +49,7 @@ def save_bee(filename, scene, create_new=False, worker=None):
logger.debug(f'Create new: {create_new}')
io = SQLiteIO(filename, scene, create_new, worker=worker)
io.write()
logger.info('Saved!')
logger.info('End save')
def load_images(filenames, pos, scene, worker):
@ -87,6 +87,7 @@ class ThreadedIO(QtCore.QThread):
progress = QtCore.pyqtSignal(int)
finished = QtCore.pyqtSignal(str, list)
begin_processing = QtCore.pyqtSignal(int)
user_input_required = QtCore.pyqtSignal(str)
def __init__(self, func, *args, **kwargs):
super().__init__()

View file

@ -13,6 +13,12 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
IMG_LOADING_ERROR_MSG = (
'Unknown format or too big?\n'
'Check Settings -> Images & Items -> Maximum Image Size')
class BeeFileIOError(Exception):
def __init__(self, msg, filename):
self.msg = msg

View file

@ -15,12 +15,14 @@
import base64
import logging
import pathlib
from xml.etree import ElementTree as ET
from PyQt6 import QtCore, QtGui
from .errors import BeeFileIOError
from beeref import constants, widgets
from beeref.items import BeePixmapItem
logger = logging.getLogger(__name__)
@ -46,11 +48,41 @@ def register_exporter(cls):
class ExporterBase:
def emit_begin_processing(self, worker, start):
if worker:
worker.begin_processing.emit(start)
def emit_progress(self, worker, progress):
if worker:
worker.progress.emit(progress)
def emit_finished(self, worker, filename, errors):
filename = str(filename)
if worker:
worker.finished.emit(filename, errors)
def emit_user_input_required(self, worker, msg):
if worker:
worker.user_input_required.emit(msg)
def handle_export_error(self, filename, error, worker):
filename = str(filename)
logger.debug(f'Export failed: {error}')
if worker:
worker.finished.emit(filename, [str(error)])
return
else:
e = error if isinstance(error, Exception) else None
raise BeeFileIOError(msg=str(error), filename=filename) from e
class SceneExporterBase(ExporterBase):
"""For exporting the scene to a single image."""
def __init__(self, scene):
self.scene = scene
self.scene.cancel_crop_mode()
self.scene.cancel_active_modes()
self.scene.deselect_all_items()
# Selection outlines/handles will be rendered to the exported
# image, so deselect first. (Alternatively, pass an attribute
@ -67,7 +99,7 @@ class ExporterBase:
@register_exporter
class SceneToPixmapExporter(ExporterBase):
class SceneToPixmapExporter(SceneExporterBase):
TYPE = ExporterRegistry.DEFAULT_TYPE
@ -108,33 +140,25 @@ class SceneToPixmapExporter(ExporterBase):
def export(self, filename, worker=None):
logger.debug(f'Exporting scene to {filename}')
if worker:
worker.begin_processing.emit(1)
self.emit_begin_processing(worker, 1)
image = self.render_to_image()
if worker and worker.canceled:
logger.debug('Export canceled')
worker.finished.emit(filename, [])
self.emit_finished(worker, filename, [])
return
if not image.save(filename, quality=90):
msg = 'Error writing file'
logger.debug(f'Export failed: {msg}')
if worker:
worker.finished.emit(filename, [msg])
return
else:
raise BeeFileIOError(msg, filename=filename)
self.handle_export_error(filename, 'Error writing file', worker)
return
logger.debug('Export finished')
if worker:
worker.progress.emit(1)
worker.finished.emit(filename, [])
self.emit_progress(worker, 1)
self.emit_finished(worker, filename, [])
@register_exporter
class SceneToSVGExporter(ExporterBase):
class SceneToSVGExporter(SceneExporterBase):
TYPE = 'svg'
@ -222,18 +246,15 @@ class SceneToSVGExporter(ExporterBase):
element.set('opacity', str(item.opacity()))
svg.append(element)
if worker:
worker.progress.emit(i)
if worker.canceled:
return
self.emit_progress(worker, i)
if worker and worker.canceled:
return
return svg
def export(self, filename, worker=None):
logger.debug(f'Exporting scene to {filename}')
if worker:
worker.begin_processing.emit(len(self.scene.items()))
self.emit_begin_processing(worker, len(self.scene.items()))
svg = self.render_to_svg(worker)
if worker and worker.canceled:
@ -248,13 +269,89 @@ class SceneToSVGExporter(ExporterBase):
with open(filename, 'w') as f:
tree.write(f, encoding='unicode', xml_declaration=True)
except OSError as e:
logger.debug(f'Export failed: {e}')
if worker:
worker.finished.emit(filename, [str(e)])
return
else:
raise BeeFileIOError(msg=str(e), filename=filename) from e
self.handle_export_error(filename, e, worker)
return
logger.debug('Export finished')
if worker:
worker.finished.emit(filename, [])
self.emit_finished(worker, filename, [])
class ImagesToDirectoryExporter(ExporterBase):
"""Export all images to a folder.
Not registered in the registry as it is accessed via its own menu entry,
not auto-detected by file extension.
"""
def __init__(self, scene, dirname):
self.scene = scene
self.dirname = dirname
self.items = list(self.scene.items_by_type(BeePixmapItem.TYPE))
self.max_save_id = 0
for item in self.items:
if item.save_id:
self.max_save_id = max(self.max_save_id, item.save_id)
self.num_total = len(self.items)
self.start_from = 0
self.handle_existing = None
def export(self, worker=None):
logger.debug(f'Exporting images to {self.dirname}')
logger.debug(f'Starting at {self.start_from}')
self.emit_begin_processing(worker, self.num_total)
self.emit_progress(worker, self.start_from)
for i, item in enumerate(
self.items[self.start_from:], start=self.start_from):
if worker and worker.canceled:
logger.debug('Export canceled')
worker.finished.emit(self.dirname, [])
return
pixmap, imgformat = item.pixmap_to_bytes()
if item.save_id:
filename = item.get_filename_for_export(imgformat)
else:
self.max_save_id += 1
save_id = self.max_save_id
filename = item.get_filename_for_export(imgformat, save_id)
try:
path = pathlib.Path(self.dirname) / filename
path_exists = path.exists()
except OSError as e:
self.handle_export_error(self.dirname, e, worker)
return
if path_exists:
logger.debug(f'File already exists: {path}')
if self.handle_existing is None:
self.start_from = i
self.emit_user_input_required(worker, str(path))
return
else:
if self.handle_existing == 'skip':
self.handle_existing = None
logger.debug('Skipping file')
continue
elif self.handle_existing == 'skip_all':
logger.debug('Skipping file')
continue
elif self.handle_existing == 'overwrite':
self.handle_existing = None
logger.debug('Overwrite file')
elif self.handle_existing == 'overwrite_all':
logger.debug('Overwrite file')
logger.debug(f'Writing file: {path}')
try:
path.write_bytes(pixmap)
except OSError as e:
self.handle_export_error(path, e, worker)
return
self.emit_progress(worker, i)
self.emit_finished(worker, self.dirname, [])

View file

@ -17,11 +17,12 @@ import logging
import os.path
import tempfile
from urllib.error import URLError
from urllib import request
from urllib import parse, request
from PyQt6 import QtGui
import exif
from lxml import etree
import plum
@ -44,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, ValueError):
logger.exception(f'Exif failed reading orientation of image: {path}')
return img
transform = QtGui.QTransform()
@ -85,7 +90,15 @@ def load_image(path):
return (exif_rotated_image(path), path)
url = bytes(path.toEncoded()).decode()
domain = '.'.join(parse.urlparse(url).netloc.split(".")[-2:])
img = exif_rotated_image()
if domain == 'pinterest.com':
try:
page_data = request.urlopen(url).read()
root = etree.HTML(page_data)
url = root.xpath("//img")[0].get('src')
except Exception as e:
logger.debug(f'Pinterest image download failed: {e}')
try:
imgdata = request.urlopen(url).read()
except URLError as e:

View file

@ -34,8 +34,8 @@ import tempfile
from PyQt6 import QtGui
from beeref import constants
from beeref.items import BeePixmapItem
from .errors import BeeFileIOError
from beeref.items import BeePixmapItem, BeeErrorItem
from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG
from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID
@ -52,7 +52,7 @@ def handle_sqlite_errors(func):
def wrapper(self, *args, **kwargs):
try:
func(self, *args, **kwargs)
except sqlite3.Error as e:
except Exception as e:
logger.exception(f'Error while reading/writing {self.filename}')
try:
# Try to roll back transaction if there is any
@ -80,6 +80,7 @@ class SQLiteIO:
self.filename = filename
self.readonly = readonly
self.worker = worker
self.retry = False
def __del__(self):
self._close_connection()
@ -109,14 +110,20 @@ class SQLiteIO:
self._connection = sqlite3.connect(uri, uri=True)
self._cursor = self.connection.cursor()
if not self.create_new:
self._migrate()
try:
self._migrate()
except Exception:
# Updating a file failed; try creating it from scratch instead
logger.exception('Error migrating bee file')
self.create_new = True
self._establish_connection()
def _migrate(self):
"""Migrate database if necessary."""
version = self.fetchone('PRAGMA user_version')[0]
logger.debug(f'Found bee file version: {version}')
if version == USER_VERSION:
if version >= USER_VERSION:
logger.debug('Version ok; no migrations necessary')
return
@ -210,8 +217,14 @@ class SQLiteIO:
}
if data['type'] == 'pixmap':
data['item'] = BeePixmapItem(QtGui.QImage())
data['item'].pixmap_from_bytes(row[9])
item = BeePixmapItem(QtGui.QImage())
item.pixmap_from_bytes(row[9])
if item.pixmap().isNull():
item = data['data']['text'] = (
f'Image could not be loaded: {item.filename}\n'
+ IMG_LOADING_ERROR_MSG)
data['type'] = BeeErrorItem.TYPE
data['item'] = item
self.scene.add_item_later(data)
@ -230,22 +243,31 @@ class SQLiteIO:
def write(self):
if self.readonly:
raise sqlite3.OperationalError(
'attempt to write a readonly database')
'Attempt to write to a readonly database')
try:
self.create_schema_on_new()
self.write_data()
except sqlite3.Error:
if self.create_new:
# If writing to a new file fails, we can't recover
except Exception:
if self.retry:
# Trying to recover failed
raise
else:
# Updating a file failed; try creating it from scratch instead
self.retry = True
# Try creating file from scratch and save again
logger.exception(
f'Updating to existing file {self.filename} failed')
self.create_new = True
self._close_connection()
self.write()
def write_data(self):
to_delete = self.fetchall('SELECT id from ITEMS')
to_delete = {row[0] for row in self.fetchall('SELECT id from ITEMS')}
# We don't want to touch existing items that are displayed as errors:
keep = {item.original_save_id
for item in self.scene.items_by_type(BeeErrorItem.TYPE)}
logger.debug(f'Not saving error items: {keep}')
to_delete = to_delete - keep
to_save = list(self.scene.items_for_save())
if self.worker:
self.worker.begin_processing.emit(len(to_save))
@ -253,7 +275,7 @@ class SQLiteIO:
logger.debug(f'Saving {item} with id {item.save_id}')
if item.save_id:
self.update_item(item)
to_delete.remove((item.save_id,))
to_delete.remove(item.save_id)
else:
self.insert_item(item)
if self.worker:
@ -261,11 +283,13 @@ 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, [])
def delete_items(self, to_delete):
to_delete = [(pk,) for pk in to_delete]
self.exmany('DELETE FROM items WHERE id=?', to_delete)
self.exmany('DELETE FROM sqlar WHERE item_id=?', to_delete)
self.connection.commit()
@ -282,13 +306,7 @@ class SQLiteIO:
if hasattr(item, 'pixmap_to_bytes'):
pixmap, imgformat = item.pixmap_to_bytes()
if item.filename:
basename = os.path.splitext(os.path.basename(item.filename))[0]
name = f'{item.save_id:04}-{basename}.{imgformat}'
else:
name = f'{item.save_id:04}.{imgformat}'
name = item.get_filename_for_export(imgformat)
self.ex(
'INSERT INTO sqlar (item_id, name, mode, sz, data) '
'VALUES (?, ?, ?, ?, ?)',

View file

@ -20,6 +20,7 @@ text).
from collections import defaultdict
from functools import cached_property
import logging
import os.path
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
@ -40,6 +41,32 @@ def register_item(cls):
return cls
def sort_by_filename(items):
"""Order items by filename.
Items with a filename (ordered by filename) first, then items
without a filename but with a save_id follow (ordered by
save_id), then remaining items in the order that they have
been inserted into the scene.
"""
items_by_filename = []
items_by_save_id = []
items_remaining = []
for item in items:
if getattr(item, 'filename', None):
items_by_filename.append(item)
elif getattr(item, 'save_id', None):
items_by_save_id.append(item)
else:
items_remaining.append(item)
items_by_filename.sort(key=lambda x: x.filename)
items_by_save_id.sort(key=lambda x: x.save_id)
return items_by_filename + items_by_save_id + items_remaining
class BeeItemMixin(SelectableMixin):
"""Base for all items added by the user."""
@ -64,7 +91,7 @@ class BeeItemMixin(SelectableMixin):
def on_selected_change(self, value):
if (value and self.scene()
and not self.scene().has_selection()
and not self.scene().rubberband_active):
and not self.scene().active_mode is None):
self.bring_to_front()
def update_from_data(self, **kwargs):
@ -85,7 +112,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
TYPE = 'pixmap'
CROP_HANDLE_SIZE = 15
def __init__(self, image, filename=None):
def __init__(self, image, filename=None, **kwargs):
super().__init__(QtGui.QPixmap.fromImage(image))
self.save_id = None
self.filename = filename
@ -132,14 +159,56 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
logger.debug('Setting grayscale for {self} to {value}')
self._grayscale = value
if value is True:
img = self.pixmap().toImage()
img = img.convertToFormat(QtGui.QImage.Format.Format_Grayscale8)
# Using the grayscale image format to convert to grayscale
# loses an image's tranparency. So the straightworward
# following method gives us an ugly black replacement:
# img = img.convertToFormat(QtGui.QImage.Format.Format_Grayscale8)
# Instead, we will fill the background with the current
# canvas colour, so the issue is only visible if the image
# overlaps other images. The way we do it here only works
# as long as the canvas colour is itself grayscale,
# though.
img = QtGui.QImage(
self.pixmap().size(), QtGui.QImage.Format.Format_Grayscale8)
img.fill(QtGui.QColor(*COLORS['Scene:Canvas']))
painter = QtGui.QPainter(img)
painter.drawPixmap(0, 0, self.pixmap())
painter.end()
self._grayscale_pixmap = QtGui.QPixmap.fromImage(img)
# Alternative methods that have their own issues:
#
# 1. Use setAlphaChannel of the resulting grayscale
# image. How do we get the original alpha channel? Using
# the whole original image also takes color values into
# account, not just their alpha values.
#
# 2. QtWidgets.QGraphicsColorizeEffect() with black colour
# on the GraphicsItem. This applys to everything the paint
# method does, so the selection outline/handles will also
# be gray. setGraphicsEffect is only available on some
# widgets, so we can't apply it selectively.
#
# 3. Going through every pixel and doing it manually — bad
# performance.
else:
self._grayscale_pixmap = None
self.update()
def sample_color_at(self, pos):
ipos = self.mapFromScene(pos)
if self.grayscale:
pm = self._grayscale_pixmap
else:
pm = self.pixmap()
img = pm.toImage()
color = img.pixelColor(int(ipos.x()), int(ipos.y()))
if color.alpha():
return color
def bounding_rect_unselected(self):
if self.crop_mode:
return QtWidgets.QGraphicsPixmapItem.boundingRect(self)
@ -155,6 +224,16 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
self.crop.width(),
self.crop.height()]}
def get_filename_for_export(self, imgformat, save_id_default=None):
save_id = self.save_id or save_id_default
assert save_id is not None
if self.filename:
basename = os.path.splitext(os.path.basename(self.filename))[0]
return f'{save_id:04}-{basename}.{imgformat}'
else:
return f'{save_id:04}.{imgformat}'
def get_imgformat(self, img):
"""Determines the format for storing this image."""
@ -384,7 +463,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
color = QtGui.QColor(0, 0, 0)
color.setAlpha(100)
painter.setBrush(QtGui.QBrush(color))
painter.setPen(QtGui.QPen())
painter.setPen(Qt.PenStyle.NoPen)
painter.drawPath(path)
painter.setBrush(QtGui.QBrush())
@ -435,13 +514,13 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
for handle in self.crop_handles():
if handle().contains(event.pos()):
self.setCursor(self.get_crop_handle_cursor(handle))
self.set_cursor(self.get_crop_handle_cursor(handle))
return
for edge in self.crop_edges():
if edge().contains(event.pos()):
self.setCursor(self.get_crop_edge_cursor(edge))
self.set_cursor(self.get_crop_edge_cursor(edge))
return
self.setCursor(Qt.CursorShape.ArrowCursor)
self.unset_cursor()
def mousePressEvent(self, event):
if not self.crop_mode:
@ -505,7 +584,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
return point
def mouseMoveEvent(self, event):
if self.crop_mode:
if self.crop_mode and self.crop_mode_event_start:
diff = event.pos() - self.crop_mode_event_start
if self.crop_mode_move == self.crop_handle_topleft:
new = self.ensure_point_within_crop_bounds(
@ -560,7 +639,7 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
TYPE = 'text'
def __init__(self, text=None):
def __init__(self, text=None, **kwargs):
super().__init__(text or "Text")
self.save_id = None
logger.debug(f'Initialized {self}')
@ -650,3 +729,78 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
def copy_to_clipboard(self, clipboard):
clipboard.setText(self.toPlainText())
@register_item
class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
"""Class for displaying error messages when an item can't be loaded
from a bee file.
This item will be displayed instead of the original item. It won't
save to bee files. The original item will be preserved in the bee
file, unless this item gets deleted by the user, or a new bee file
is saved.
"""
TYPE = 'error'
def __init__(self, text=None, **kwargs):
super().__init__(text or "Text")
self.original_save_id = None
logger.debug(f'Initialized {self}')
self.is_image = False
self.init_selectable()
self.is_editable = False
self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text']))
@classmethod
def create_from_data(cls, **kwargs):
data = kwargs.get('data', {})
item = cls(**data)
return item
def __str__(self):
txt = self.toPlainText()[:40]
return (f'Error "{txt}"')
def contains(self, point):
return self.boundingRect().contains(point)
def paint(self, painter, option, widget):
painter.setPen(Qt.PenStyle.NoPen)
color = QtGui.QColor(200, 0, 0)
brush = QtGui.QBrush(color)
painter.setBrush(brush)
painter.drawRect(QtWidgets.QGraphicsTextItem.boundingRect(self))
option.state = QtWidgets.QStyle.StateFlag.State_Enabled
super().paint(painter, option, widget)
self.paint_selectable(painter, option, widget)
def update_from_data(self, **kwargs):
self.original_save_id = kwargs.get('save_id', self.original_save_id)
self.setPos(kwargs.get('x', self.pos().x()),
kwargs.get('y', self.pos().y()))
self.setZValue(kwargs.get('z', self.zValue()))
self.setScale(kwargs.get('scale', self.scale()))
self.setRotation(kwargs.get('rotation', self.rotation()))
def create_copy(self):
item = BeeErrorItem(self.toPlainText())
item.setPos(self.pos())
item.setZValue(self.zValue())
item.setScale(self.scale())
item.setRotation(self.rotation())
return item
def flip(self, *args, **kwargs):
"""Returns the flip value (1 or -1)"""
# Never display error messages flipped
return 1
def do_flip(self, *args, **kwargs):
"""Flips the item."""
# Never flip error messages
pass
def copy_to_clipboard(self, clipboard):
clipboard.setText(self.toPlainText())

View file

@ -18,7 +18,7 @@ import logging
from PyQt6 import QtCore, QtGui
from PyQt6.QtCore import Qt
from beeref import commands
from beeref import commands, widgets
from beeref.items import BeePixmapItem
from beeref import fileio
@ -49,10 +49,17 @@ class MainControlsMixin:
else:
self.enter_movewin_mode()
@property
def viewport_or_self(self):
if hasattr(self, 'viewport'):
return self.viewport()
return self
def enter_movewin_mode(self):
logger.debug('Entering movewin mode')
self.setMouseTracking(True)
self.movewin_active = True
self.viewport_or_self.setCursor(Qt.CursorShape.SizeAllCursor)
self.event_start = QtCore.QPointF(self.cursor().pos())
if hasattr(self, 'disable_mouse_events'):
self.disable_mouse_events()
@ -61,6 +68,7 @@ class MainControlsMixin:
logger.debug('Exiting movewin mode')
self.setMouseTracking(False)
self.movewin_active = False
self.viewport_or_self.unsetCursor()
if hasattr(self, 'enable_mouse_events'):
self.enable_mouse_events()
@ -72,7 +80,9 @@ class MainControlsMixin:
elif mimedata.hasImage():
event.acceptProposedAction()
else:
logger.info('Attempted drop not an image')
msg = 'Attempted drop not an image or image too big'
logger.info(msg)
widgets.BeeNotification(self.control_target, msg)
def dragMoveEvent(self, event):
event.acceptProposedAction()
@ -106,9 +116,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

@ -13,9 +13,10 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from queue import Queue
from functools import partial
import logging
import math
from queue import Queue
from PyQt6 import QtCore, QtWidgets, QtGui
from PyQt6.QtCore import Qt
@ -24,7 +25,7 @@ import rpack
from beeref import commands
from beeref.config import BeeSettings
from beeref.items import item_registry
from beeref.items import item_registry, BeeErrorItem, sort_by_filename
from beeref.selection import MultiSelectItem, RubberbandItem
@ -32,28 +33,35 @@ logger = logging.getLogger(__name__)
class BeeGraphicsScene(QtWidgets.QGraphicsScene):
cursor_changed = QtCore.pyqtSignal(QtGui.QCursor)
cursor_cleared = QtCore.pyqtSignal()
MOVE_MODE = 1
RUBBERBAND_MODE = 2
def __init__(self, undo_stack):
super().__init__()
self.move_active = False
self.rubberband_active = False
self.active_mode = None
self.undo_stack = undo_stack
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}')
@ -63,6 +71,19 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
logger.debug(f'Removing item {item}')
super().removeItem(item)
def cancel_active_modes(self):
"""Cancels ongoing crop modes, rubberband modes etc, if there are
any.
"""
self.cancel_crop_mode()
self.end_rubberband_mode()
def end_rubberband_mode(self):
if self.rubberband_item.scene():
logger.debug('Ending rubberband selection')
self.removeItem(self.rubberband_item)
self.active_mode = None
def cancel_crop_mode(self):
"""Cancels an ongoing crop mode, if there is any."""
if self.crop_item:
@ -81,7 +102,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.undo_stack.push(commands.InsertItems(self, copies, position))
def raise_to_top(self):
self.cancel_crop_mode()
self.cancel_active_modes()
items = self.selectedItems(user_only=True)
z_values = map(lambda i: i.zValue(), items)
delta = self.max_z + self.Z_STEP - min(z_values)
@ -90,7 +111,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
item.setZValue(item.zValue() + delta)
def lower_to_bottom(self):
self.cancel_crop_mode()
self.cancel_active_modes()
items = self.selectedItems(user_only=True)
z_values = map(lambda i: i.zValue(), items)
delta = self.min_z - self.Z_STEP - max(z_values)
@ -106,7 +127,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
:param mode: "width" or "height".
"""
self.cancel_crop_mode()
self.cancel_active_modes()
values = []
items = self.selectedItems(user_only=True)
for item in items:
@ -138,7 +159,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
Size meaning the area = widh * height.
"""
self.cancel_crop_mode()
self.cancel_active_modes()
sizes = []
items = self.selectedItems(user_only=True)
for item in items:
@ -158,12 +179,23 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.undo_stack.push(
commands.NormalizeItems(items, scale_factors))
def arrange_default(self):
default = self.settings.valueOrDefault('Items/arrange_default')
MAPPING = {
'optimal': self.arrange_optimal,
'horizontal': self.arrange,
'vertical': partial(self.arrange, vertical=True),
'square': self.arrange_square,
}
MAPPING[default]()
def arrange(self, vertical=False):
"""Arrange items in a line (horizontally or vertically)."""
self.cancel_crop_mode()
self.cancel_active_modes()
items = self.selectedItems(user_only=True)
items = sort_by_filename(self.selectedItems(user_only=True))
if len(items) < 2:
return
@ -202,14 +234,13 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
positions))
def arrange_optimal(self):
self.cancel_crop_mode()
self.cancel_active_modes()
items = self.selectedItems(user_only=True)
if len(items) < 2:
return
gap = self.settings.valueOrDefault('Items/arrange_gap')
center = self.get_selection_center()
sizes = []
for item in items:
@ -232,15 +263,53 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
# We want the items to center around the selection's center,
# not (0, 0)
center = self.get_selection_center()
bounds = rpack.bbox_size(sizes, positions)
diff = center - QtCore.QPointF(bounds[0]/2, bounds[1]/2)
positions = [QtCore.QPointF(*pos) + diff for pos in positions]
self.undo_stack.push(commands.ArrangeItems(self, items, positions))
def arrange_square(self):
self.cancel_active_modes()
max_width = 0
max_height = 0
gap = self.settings.valueOrDefault('Items/arrange_gap')
items = sort_by_filename(self.selectedItems(user_only=True))
if len(items) < 2:
return
for item in items:
rect = self.itemsBoundingRect(items=[item])
max_width = max(max_width, rect.width() + gap)
max_height = max(max_height, rect.height() + gap)
# We want the items to center around the selection's center,
# not (0, 0)
num_rows = math.ceil(math.sqrt(len(items)))
center = self.get_selection_center()
diff = center - num_rows/2 * QtCore.QPointF(max_width, max_height)
iter_items = iter(items)
positions = []
for j in range(num_rows):
for i in range(num_rows):
try:
item = next(iter_items)
rect = self.itemsBoundingRect(items=[item])
point = QtCore.QPointF(
i * max_width + (max_width - rect.width())/2,
j * max_height + (max_height - rect.height())/2)
positions.append(point + diff)
except StopIteration:
break
self.undo_stack.push(commands.ArrangeItems(self, items, positions))
def flip_items(self, vertical=False):
"""Flip selected items."""
self.cancel_crop_mode()
self.cancel_active_modes()
self.undo_stack.push(
commands.FlipItems(self.selectedItems(user_only=True),
self.get_selection_center(),
@ -256,15 +325,20 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
if item.is_image:
item.enter_crop_mode()
def sample_color_at(self, position):
item_at_pos = self.itemAt(position, self.views()[0].transform())
if item_at_pos:
return item_at_pos.sample_color_at(position)
def select_all_items(self):
self.cancel_crop_mode()
self.cancel_active_modes()
path = QtGui.QPainterPath()
path.addRect(self.itemsBoundingRect())
# This is faster than looping through all items and calling setSelected
self.setSelectionArea(path)
def deselect_all_items(self):
self.cancel_crop_mode()
self.cancel_active_modes()
self.clearSelection()
def has_selection(self):
@ -313,16 +387,16 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
super().mousePressEvent(event)
return
if item_at_pos:
self.move_active = True
self.active_mode = self.MOVE_MODE
elif self.items():
self.rubberband_active = True
self.active_mode = self.RUBBERBAND_MODE
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
self.cancel_active_modes()
item = self.itemAt(event.scenePos(), self.views()[0].transform())
if item:
self.move_active = False
if not item.isSelected():
item.setSelected(True)
if item.is_editable:
@ -336,7 +410,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
super().mouseDoubleClickEvent(event)
def mouseMoveEvent(self, event):
if self.rubberband_active:
if self.active_mode == self.RUBBERBAND_MODE:
if not self.rubberband_item.scene():
logger.debug('Activating rubberband selection')
self.addItem(self.rubberband_item)
@ -347,22 +421,19 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self.rubberband_active:
if self.rubberband_item.scene():
logger.debug('Ending rubberband selection')
self.removeItem(self.rubberband_item)
self.rubberband_active = False
if (self.move_active
if self.active_mode == self.RUBBERBAND_MODE:
self.end_rubberband_mode()
if (self.active_mode == self.MOVE_MODE
and self.has_selection()
and not self.multi_select_item.is_action_active()
and not self.selectedItems()[0].is_action_active()):
and self.multi_select_item.active_mode is None
and self.selectedItems()[0].active_mode is None):
delta = event.scenePos() - self.event_start
if not delta.isNull():
self.undo_stack.push(
commands.MoveItemsBy(self.selectedItems(),
delta,
ignore_first_redo=True))
self.move_active = False
self.active_mode = None
super().mouseReleaseEvent(event)
def selectedItems(self, user_only=False):
@ -377,6 +448,12 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
return list(filter(lambda i: hasattr(i, 'save_id'), items))
return items
def items_by_type(self, itype):
"""Returns all items of the given type."""
return filter(lambda i: getattr(i, 'TYPE', None) == itype,
self.items())
def items_for_save(self):
"""Returns the items that are to be saved.
@ -432,6 +509,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))
@ -442,9 +523,12 @@ 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 not self.multi_select_item.scale_active
and not self.multi_select_item.rotate_active):
and self.multi_select_item.active_mode is None):
self.multi_select_item.fit_selection_area(
self.itemsBoundingRect(selection_only=True))
@ -467,7 +551,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
if not cls:
# Just in case we add new item types in future versions
logger.warning(f'Encountered item of unknown type: {typ}')
cls = item_registry.get('text')
cls = BeeErrorItem
data['data'] = {'text': f'Item of unknown type: {typ}'}
item = cls.create_from_data(**data)
# Set the values common to all item types:

View file

@ -118,6 +118,21 @@ class BaseItemMixin:
"""The item's center in scene coordinates."""
return self.mapToScene(self.center)
def set_cursor(self, cursor):
# Can't use setCursor on the item itself because of bug
# https://bugreports.qt.io/browse/QTBUG-4190
if self.scene():
self.scene().cursor_changed.emit(cursor)
def unset_cursor(self):
# Can't use unsetCursor on the item itself because of bug
# https://bugreports.qt.io/browse/QTBUG-4190
if self.scene():
self.scene().cursor_cleared.emit()
def sample_color_at(self, pos):
return None
class SelectableMixin(BaseItemMixin):
"""Common code for selectable items: Selection outline, handles etc."""
@ -128,6 +143,10 @@ class SelectableMixin(BaseItemMixin):
SELECT_ROTATE_SIZE = 10 # size of hover area for rotating
SELECT_FREE_CENTER = 20 # size of handle-free area in the center
SCALE_MODE = 1
ROTATE_MODE = 2
FLIP_MODE = 3
def init_selectable(self):
self.setAcceptHoverEvents(True)
self.setFlags(
@ -135,19 +154,9 @@ class SelectableMixin(BaseItemMixin):
| QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.viewport_scale = 1
self.reset_actions()
self.active_mode = None
self.is_editable = False
def reset_actions(self):
self.scale_active = False
self.rotate_active = False
self.flip_active = False
def is_action_active(self):
return any((self.scale_active,
self.rotate_active,
self.flip_active))
def fixed_length_for_viewport(self, value):
"""The interactable areas need to stay the same size on the
screen so we need to adjust the values according to the scale
@ -215,6 +224,7 @@ class SelectableMixin(BaseItemMixin):
pen.setWidth(self.SELECT_LINE_WIDTH)
pen.setCosmetic(True)
painter.setPen(pen)
painter.setBrush(QtGui.QBrush())
# Draw the main selection rectangle
painter.drawRect(self.bounding_rect_unselected())
@ -363,31 +373,31 @@ class SelectableMixin(BaseItemMixin):
# This area should always trigger regular move operations,
# even if it is covered by selection scale/flip/... handles.
# This ensures that small items can always still be moved/edited.
self.setCursor(Qt.CursorShape.ArrowCursor)
self.unset_cursor()
return
for corner in self.corners:
# See if we need to change the cursor for interactable areas
if self.get_scale_bounds(corner).contains(event.pos()):
self.setCursor(self.get_corner_scale_cursor(corner))
self.scene().cursor_changed.emit(
self.get_corner_scale_cursor(corner))
self.set_cursor(self.get_corner_scale_cursor(corner))
return
elif self.get_rotate_bounds(corner).contains(event.pos()):
self.setCursor(BeeAssets().cursor_rotate)
self.set_cursor(BeeAssets().cursor_rotate)
return
for edge in self.get_flip_bounds():
if edge['rect'].contains(event.pos()):
if self.get_edge_flips_v(edge):
self.setCursor(BeeAssets().cursor_flip_v)
self.set_cursor(BeeAssets().cursor_flip_v)
else:
self.setCursor(BeeAssets().cursor_flip_h)
self.set_cursor(BeeAssets().cursor_flip_h)
return
self.setCursor(Qt.CursorShape.ArrowCursor)
self.unset_cursor()
def hoverEnterEvent(self, event):
# Always return regular cursor when there aren't any selection handles
if not self.has_selection_handles():
self.setCursor(Qt.CursorShape.ArrowCursor)
def hoverLeaveEvent(self, event):
self.unset_cursor()
def mousePressEvent(self, event):
self.event_start = event.scenePos()
@ -411,7 +421,7 @@ class SelectableMixin(BaseItemMixin):
# Check if we are in one of the corner's scale areas
if self.get_scale_bounds(corner).contains(event.pos()):
# Start scale action for this corner
self.scale_active = True
self.active_mode = self.SCALE_MODE
self.event_direction = self.get_direction_from_center(
event.scenePos())
self.event_anchor = self.mapToScene(
@ -423,7 +433,7 @@ class SelectableMixin(BaseItemMixin):
# Check if we are in one of the corner's rotate areas
if self.get_rotate_bounds(corner).contains(event.pos()):
# Start rotate action
self.rotate_active = True
self.active_mode = self.ROTATE_MODE
self.event_anchor = self.center_scene_coords
self.rotate_start_angle = self.get_rotate_angle(
event.scenePos())
@ -434,7 +444,7 @@ class SelectableMixin(BaseItemMixin):
# Check if we are in one of the flip edges:
for edge in self.get_flip_bounds():
if edge['rect'].contains(event.pos()):
self.flip_active = True
self.active_mode = self.FLIP_MODE
event.accept()
self.scene().undo_stack.push(
commands.FlipItems(
@ -537,14 +547,14 @@ class SelectableMixin(BaseItemMixin):
if (event.scenePos() - self.event_start).manhattanLength() > 5:
self.scene().views()[0].reset_previous_transform()
if self.scale_active:
if self.active_mode == self.SCALE_MODE:
factor = self.get_scale_factor(event)
for item in self.selection_action_items():
item.setScale(item.scale_orig_factor * factor,
item.mapFromScene(self.event_anchor))
event.accept()
return
if self.rotate_active:
if self.active_mode == self.ROTATE_MODE:
snap = (event.modifiers() == Qt.KeyboardModifier.ControlModifier
or event.modifiers() == Qt.KeyboardModifier.ShiftModifier)
delta = self.get_rotate_delta(event.scenePos(), snap)
@ -554,7 +564,7 @@ class SelectableMixin(BaseItemMixin):
item.mapFromScene(self.event_anchor))
event.accept()
return
if self.flip_active:
if self.active_mode == self.FLIP_MODE:
# We have already flipped on MousePress, but we
# still need to accept the event here as to not
# initiate an item move
@ -564,7 +574,7 @@ class SelectableMixin(BaseItemMixin):
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self.scale_active:
if self.active_mode == self.SCALE_MODE:
if self.get_scale_factor(event) != 1:
self.scene().undo_stack.push(
commands.ScaleItemsBy(
@ -573,9 +583,9 @@ class SelectableMixin(BaseItemMixin):
self.event_anchor,
ignore_first_redo=True))
event.accept()
self.reset_actions()
self.active_mode = None
return
elif self.rotate_active:
elif self.active_mode == self.ROTATE_MODE:
self.scene().on_selection_change()
if self.get_rotate_delta(event.scenePos()) != 0:
self.scene().undo_stack.push(
@ -585,18 +595,18 @@ class SelectableMixin(BaseItemMixin):
self.event_anchor,
ignore_first_redo=True))
event.accept()
self.reset_actions()
self.active_mode = None
return
elif self.flip_active:
elif self.active_mode == self.FLIP_MODE:
for edge in self.get_flip_bounds():
if edge['rect'].contains(event.pos()):
# We have already flipped on MousePress, but we
# still need to accept the event here as to not
# initiate an item move
event.accept()
self.reset_actions()
self.active_mode = None
return
self.reset_actions()
self.active_mode = None
super().mouseReleaseEvent(event)
def on_view_scale_change(self):
@ -634,12 +644,20 @@ class MultiSelectItem(SelectableMixin,
def selection_action_items(self):
"""The items affected by selection actions like scaling and rotating.
"""
return list(self.scene().selectedItems())
if self.scene():
return list(self.scene().selectedItems())
return []
def lower_behind_selection(self):
items = self.selection_action_items()
if items:
min_z = min(item.zValue() for item in items)
self.setZValue(min_z - self.scene().Z_STEP)
def fit_selection_area(self, rect):
"""Updates itself to fit the given selection area."""
logger.debug(f'Fit selection area to {rect}')
logger.trace(f'Fit selection area to {rect}')
# Only update when values have changed, otherwise we end up in an
# infinite event loop sceneChange -> itemChange -> sceneChange ...
@ -688,4 +706,4 @@ class RubberbandItem(BaseItemMixin, QtWidgets.QGraphicsRectItem):
"""Updates itself to fit the two given points."""
self.setRect(utils.get_rect_from_points(point1, point2))
logger.debug(f'Updated rubberband {self}')
logger.trace(f'Updated rubberband {self}')

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
@ -78,3 +79,31 @@ def get_file_extension_from_format(formatstr):
extensions = re.match(r'.* \((.*)\)', formatstr).groups()[0]
ext = extensions.split()[0]
return ext.removeprefix('*.')
def qcolor_to_hex(color):
"""Returns the QColor as a hex represenation string:
#RRGGBBAA if the color has transparencey, otherwise #RRGGBB.
"""
if color.alpha() == 255:
return color.name()
# The name method can only do HexRgb and HexArgb, not HexRgba, so
# we have to do this ourselves:
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

@ -21,17 +21,18 @@ import os.path
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from beeref.actions import ActionsMixin
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
from beeref.fileio.errors import IMG_LOADING_ERROR_MSG
from beeref.fileio.export import exporter_registry, ImagesToDirectoryExporter
from beeref import widgets
from beeref.items import BeePixmapItem, BeeTextItem
from beeref.main_controls import MainControlsMixin
from beeref.scene import BeeGraphicsScene
from beeref.utils import get_file_extension_from_format
from beeref.utils import get_file_extension_from_format, qcolor_to_hex
commandline_args = CommandlineArgs()
@ -42,11 +43,16 @@ class BeeGraphicsView(MainControlsMixin,
QtWidgets.QGraphicsView,
ActionsMixin):
PAN_MODE = 1
ZOOM_MODE = 2
SAMPLE_COLOR_MODE = 3
def __init__(self, app, parent=None):
super().__init__(parent)
self.app = app
self.parent = parent
self.settings = BeeSettings()
self.keyboard_settings = KeyboardSettings()
self.welcome_overlay = widgets.welcome_overlay.WelcomeOverlay(self)
self.setBackgroundBrush(
@ -62,12 +68,13 @@ class BeeGraphicsView(MainControlsMixin,
self.filename = None
self.previous_transform = None
self.pan_active = False
self.zoom_active = False
self.active_mode = None
self.scene = BeeGraphicsScene(self.undo_stack)
self.scene.changed.connect(self.on_scene_changed)
self.scene.selectionChanged.connect(self.on_selection_changed)
self.scene.cursor_changed.connect(self.on_cursor_changed)
self.scene.cursor_cleared.connect(self.on_cursor_cleared)
self.setScene(self.scene)
# Context menu and actions
@ -75,9 +82,14 @@ class BeeGraphicsView(MainControlsMixin,
self.control_target = self
self.init_main_controls(main_window=parent)
# Load file given via command line
if commandline_args.filename:
self.open_from_file(commandline_args.filename)
# Load files given via command line
if commandline_args.filenames:
fn = commandline_args.filenames[0]
if os.path.splitext(fn)[1] == '.bee':
self.open_from_file(fn)
else:
self.do_insert_images(commandline_args.filenames)
self.update_window_title()
@property
@ -92,6 +104,21 @@ class BeeGraphicsView(MainControlsMixin,
self.settings.update_recent_files(value)
self.update_menu_and_actions()
def cancel_active_modes(self):
self.scene.cancel_active_modes()
self.cancel_sample_color_mode()
self.active_mode = None
def cancel_sample_color_mode(self):
logger.debug('Cancel sample color mode')
self.active_mode = None
self.viewport().unsetCursor()
if hasattr(self, 'sample_color_widget'):
self.sample_color_widget.hide()
del self.sample_color_widget
if self.scene.has_multi_selection():
self.scene.multi_select_item.bring_to_front()
def update_window_title(self):
clean = self.undo_stack.isClean()
if clean and not self.filename:
@ -143,6 +170,7 @@ class BeeGraphicsView(MainControlsMixin,
def clear_scene(self):
logging.debug('Clearing scene...')
self.cancel_active_modes()
self.scene.clear()
self.undo_stack.clear()
self.filename = None
@ -177,6 +205,26 @@ class BeeGraphicsView(MainControlsMixin,
self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
logger.trace('Fit view done')
def get_confirmation_unsaved_changes(self, msg):
confirm = self.settings.valueOrDefault('Save/confirm_close_unsaved')
if confirm and not self.undo_stack.isClean():
answer = QtWidgets.QMessageBox.question(
self,
'Discard unsaved changes?',
msg,
QtWidgets.QMessageBox.StandardButton.Yes |
QtWidgets.QMessageBox.StandardButton.Cancel)
return answer == QtWidgets.QMessageBox.StandardButton.Yes
return True
def on_action_new_scene(self):
confirm = self.get_confirmation_unsaved_changes(
'There are unsaved changes. '
'Are you sure you want to open a new scene?')
if confirm:
self.clear_scene()
def on_action_fit_scene(self):
self.fit_rect(self.scene.itemsBoundingRect())
@ -229,12 +277,12 @@ class BeeGraphicsView(MainControlsMixin,
def on_action_undo(self):
logger.debug('Undo: %s' % self.undo_stack.undoText())
self.scene.cancel_crop_mode()
self.cancel_active_modes()
self.undo_stack.undo()
def on_action_redo(self):
logger.debug('Redo: %s' % self.undo_stack.redoText())
self.scene.cancel_crop_mode()
self.cancel_active_modes()
self.undo_stack.redo()
def on_action_select_all(self):
@ -245,7 +293,7 @@ class BeeGraphicsView(MainControlsMixin,
def on_action_delete_items(self):
logger.debug('Deleting items...')
self.scene.cancel_crop_mode()
self.cancel_active_modes()
self.undo_stack.push(
commands.DeleteItems(
self.scene, self.scene.selectedItems(user_only=True)))
@ -281,6 +329,9 @@ class BeeGraphicsView(MainControlsMixin,
def on_action_arrange_optimal(self):
self.scene.arrange_optimal()
def on_action_arrange_square(self):
self.scene.arrange_square()
def on_action_change_opacity(self):
images = list(filter(
lambda item: item.is_image,
@ -305,33 +356,50 @@ class BeeGraphicsView(MainControlsMixin,
self.scene.flip_items(vertical=True)
def on_action_reset_scale(self):
self.scene.cancel_crop_mode()
self.cancel_active_modes()
self.undo_stack.push(commands.ResetScale(
self.scene.selectedItems(user_only=True)))
def on_action_reset_rotation(self):
self.scene.cancel_crop_mode()
self.cancel_active_modes()
self.undo_stack.push(commands.ResetRotation(
self.scene.selectedItems(user_only=True)))
def on_action_reset_flip(self):
self.scene.cancel_crop_mode()
self.cancel_active_modes()
self.undo_stack.push(commands.ResetFlip(
self.scene.selectedItems(user_only=True)))
def on_action_reset_crop(self):
self.scene.cancel_crop_mode()
self.cancel_active_modes()
self.undo_stack.push(commands.ResetCrop(
self.scene.selectedItems(user_only=True)))
def on_action_reset_transforms(self):
self.scene.cancel_crop_mode()
self.cancel_active_modes()
self.undo_stack.push(commands.ResetTransforms(
self.scene.selectedItems(user_only=True)))
def on_action_show_color_gamut(self):
widgets.color_gamut.GamutDialog(self, self.scene.selectedItems()[0])
def on_action_sample_color(self):
self.cancel_active_modes()
logger.debug('Entering sample color mode')
self.viewport().setCursor(Qt.CursorShape.CrossCursor)
self.active_mode = self.SAMPLE_COLOR_MODE
if self.scene.has_multi_selection():
# We don't want to sample the multi select item, so
# temporarily send it to the back:
self.scene.multi_select_item.lower_behind_selection()
pos = self.mapFromGlobal(self.cursor().pos())
self.sample_color_widget = widgets.SampleColorWidget(
self,
pos,
self.scene.sample_color_at(self.mapToScene(pos)))
def on_items_loaded(self, value):
logger.debug('On items loaded: add queued items')
self.scene.add_queued_items()
@ -348,6 +416,13 @@ class BeeGraphicsView(MainControlsMixin,
self.scene.add_queued_items()
self.on_action_fit_scene()
def on_action_open_recent_file(self, filename):
confirm = self.get_confirmation_unsaved_changes(
'There are unsaved changes. '
'Are you sure you want to open a new scene?')
if confirm:
self.open_from_file(filename)
def open_from_file(self, filename):
logger.info(f'Opening file {filename}')
self.clear_scene()
@ -356,13 +431,19 @@ class BeeGraphicsView(MainControlsMixin,
self.worker.progress.connect(self.on_items_loaded)
self.worker.finished.connect(self.on_loading_finished)
self.progress = widgets.BeeProgressDialog(
'Loading %s' % filename,
f'Loading {filename}',
worker=self.worker,
parent=self)
self.worker.start()
def on_action_open(self):
self.scene.cancel_crop_mode()
confirm = self.get_confirmation_unsaved_changes(
'There are unsaved changes. '
'Are you sure you want to open a new scene?')
if not confirm:
return
self.cancel_active_modes()
filename, f = QtWidgets.QFileDialog.getOpenFileName(
parent=self,
caption='Open file',
@ -390,13 +471,13 @@ class BeeGraphicsView(MainControlsMixin,
fileio.save_bee, filename, self.scene, create_new=create_new)
self.worker.finished.connect(self.on_saving_finished)
self.progress = widgets.BeeProgressDialog(
'Saving %s' % filename,
f'Saving {filename}',
worker=self.worker,
parent=self)
self.worker.start()
def on_action_save_as(self):
self.scene.cancel_crop_mode()
self.cancel_active_modes()
directory = os.path.dirname(self.filename) if self.filename else None
filename, f = QtWidgets.QFileDialog.getSaveFileName(
parent=self,
@ -407,7 +488,7 @@ class BeeGraphicsView(MainControlsMixin,
self.do_save(filename, create_new=True)
def on_action_save(self):
self.scene.cancel_crop_mode()
self.cancel_active_modes()
if not self.filename:
self.on_action_save_as()
else:
@ -441,7 +522,7 @@ class BeeGraphicsView(MainControlsMixin,
self.worker = fileio.ThreadedIO(exporter.export, filename)
self.worker.finished.connect(self.on_export_finished)
self.progress = widgets.BeeProgressDialog(
'Exporting %s' % filename,
f'Exporting {filename}',
worker=self.worker,
parent=self)
self.worker.start()
@ -454,15 +535,51 @@ class BeeGraphicsView(MainControlsMixin,
'Problem writing file',
f'<p>Problem writing file {filename}</p><p>{err_msg}</p>')
def on_action_export_images(self):
directory = os.path.dirname(self.filename) if self.filename else None
directory = QtWidgets.QFileDialog.getExistingDirectory(
parent=self,
caption='Export Images',
directory=directory)
if not directory:
return
logger.debug(f'Got export directory {directory}')
self.exporter = ImagesToDirectoryExporter(self.scene, directory)
self.worker = fileio.ThreadedIO(self.exporter.export)
self.worker.user_input_required.connect(
self.on_export_images_file_exists)
self.worker.finished.connect(self.on_export_finished)
self.progress = widgets.BeeProgressDialog(
f'Exporting to {directory}',
worker=self.worker,
parent=self)
self.worker.start()
def on_export_images_file_exists(self, filename):
dlg = widgets.ExportImagesFileExistsDialog(self, filename)
if dlg.exec() == QtWidgets.QDialog.DialogCode.Accepted:
self.exporter.handle_existing = dlg.get_answer()
directory = self.exporter.dirname
self.progress = widgets.BeeProgressDialog(
f'Exporting to {directory}',
worker=self.worker,
parent=self)
self.worker.start()
def on_action_quit(self):
logger.info('User quit. Exiting...')
self.app.quit()
confirm = self.get_confirmation_unsaved_changes(
'There are unsaved changes. Are you sure you want to quit?')
if confirm:
logger.info('User quit. Exiting...')
self.app.quit()
def on_action_settings(self):
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)
@ -493,15 +610,14 @@ class BeeGraphicsView(MainControlsMixin,
errornames = [
f'<li>{fn}</li>' for fn in errors]
errornames = '<ul>%s</ul>' % '\n'.join(errornames)
msg = ('{errors} image(s) out of {total} '
'could not be opened:'.format(
errors=len(errors), total=len(errors)))
num = len(errors)
msg = f'{num} image(s) could not be opened.<br/>'
QtWidgets.QMessageBox.warning(
self,
'Problem loading images',
msg + errornames)
msg + IMG_LOADING_ERROR_MSG + errornames)
self.scene.add_queued_items()
self.scene.arrange_optimal()
self.scene.arrange_default()
self.undo_stack.endMacro()
if new_scene:
self.on_action_fit_scene()
@ -509,6 +625,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,
@ -526,6 +643,7 @@ class BeeGraphicsView(MainControlsMixin,
self.worker.start()
def on_action_insert_images(self):
self.cancel_active_modes()
formats = self.get_supported_image_formats(QtGui.QImageReader)
logger.debug(f'Supported image types for reading: {formats}')
filenames, f = QtWidgets.QFileDialog.getOpenFileNames(
@ -535,6 +653,7 @@ class BeeGraphicsView(MainControlsMixin,
self.do_insert_images(filenames)
def on_action_insert_text(self):
self.cancel_active_modes()
item = BeeTextItem()
pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos()))
item.setScale(1 / self.get_scale())
@ -542,7 +661,7 @@ class BeeGraphicsView(MainControlsMixin,
def on_action_copy(self):
logger.debug('Copying to clipboard...')
self.scene.cancel_crop_mode()
self.cancel_active_modes()
clipboard = QtWidgets.QApplication.clipboard()
items = self.scene.selectedItems(user_only=True)
@ -560,6 +679,7 @@ class BeeGraphicsView(MainControlsMixin,
'beeref/items', QtCore.QByteArray.number(len(items)))
def on_action_paste(self):
self.cancel_active_modes()
logger.debug('Pasting from clipboard...')
clipboard = QtWidgets.QApplication.clipboard()
pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos()))
@ -587,7 +707,10 @@ class BeeGraphicsView(MainControlsMixin,
item.setScale(1 / self.get_scale())
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
return
logger.info('No image data or text in clipboard')
msg = 'No image data or text in clipboard or image too big'
logger.info(msg)
widgets.BeeNotification(self, msg)
def on_action_open_settings_dir(self):
dirname = os.path.dirname(self.settings.fileName())
@ -601,8 +724,21 @@ class BeeGraphicsView(MainControlsMixin,
self.scene.has_selection())
self.actiongroup_set_enabled('active_when_single_image',
self.scene.has_single_image_selection())
if self.scene.has_selection():
item = self.scene.selectedItems(user_only=True)[0]
grayscale = getattr(item, 'grayscale', False)
actions.actions['grayscale'].qaction.setChecked(grayscale)
self.viewport().repaint()
def on_cursor_changed(self, cursor):
if self.active_mode is None:
self.viewport().setCursor(cursor)
def on_cursor_cleared(self):
if self.active_mode is None:
self.viewport().unsetCursor()
def recalc_scene_rect(self):
"""Resize the scene rectangle so that it is always one view width
wider than all items' bounding box at each side and one view
@ -698,34 +834,73 @@ class BeeGraphicsView(MainControlsMixin,
self.reset_previous_transform()
def wheelEvent(self, event):
self.zoom(event.angleDelta().y(), event.position())
event.accept()
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 action == 'pan_horizontal':
self.pan(QtCore.QPointF(0, 0.5 * delta))
event.accept()
return
if action == 'pan_vertical':
self.pan(QtCore.QPointF(0.5 * delta, 0))
event.accept()
return
def mousePressEvent(self, event):
if self.mousePressEventMainControls(event):
return
if (event.button() == Qt.MouseButton.MiddleButton
and event.modifiers() == Qt.KeyboardModifier.ControlModifier):
self.zoom_active = True
self.event_start = event.position()
self.event_anchor = event.position()
if self.active_mode == self.SAMPLE_COLOR_MODE:
if (event.button() == Qt.MouseButton.LeftButton):
color = self.scene.sample_color_at(
self.mapToScene(event.pos()))
if color:
name = qcolor_to_hex(color)
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(name)
self.scene.internal_clipboard = []
msg = f'Copied color to clipboard: {name}'
logger.debug(msg)
widgets.BeeNotification(self, msg)
else:
logger.debug('No color found')
self.cancel_sample_color_mode()
event.accept()
return
if (event.button() == Qt.MouseButton.MiddleButton
or (event.button() == Qt.MouseButton.LeftButton
and event.modifiers() == Qt.KeyboardModifier.AltModifier)):
self.pan_active = True
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.setCursor(Qt.CursorShape.ClosedHandCursor)
self.event_anchor = event.position()
self.event_inverted = inverted
event.accept()
return
if action == 'pan':
logger.trace('Begin pan')
self.active_mode = self.PAN_MODE
self.event_start = event.position()
self.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
# ClosedHandCursor and OpenHandCursor don't work, but I
# don't know if that's only on my system or a general
# problem. It works with other cursors.
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.pan_active:
if self.active_mode == self.PAN_MODE:
self.reset_previous_transform()
pos = event.position()
self.pan(self.event_start - pos)
@ -733,27 +908,37 @@ class BeeGraphicsView(MainControlsMixin,
event.accept()
return
if self.zoom_active:
if self.active_mode == self.ZOOM_MODE:
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()
return
if self.active_mode == self.SAMPLE_COLOR_MODE:
self.sample_color_widget.update(
event.position(),
self.scene.sample_color_at(self.mapToScene(event.pos())))
event.accept()
return
if self.mouseMoveEventMainControls(event):
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self.pan_active:
self.setCursor(Qt.CursorShape.ArrowCursor)
self.pan_active = False
if self.active_mode == self.PAN_MODE:
logger.trace('End pan')
self.viewport().unsetCursor()
self.active_mode = None
event.accept()
return
if self.zoom_active:
self.zoom_active = False
if self.active_mode == self.ZOOM_MODE:
self.active_mode = None
event.accept()
return
if self.mouseReleaseEventMainControls(event):
@ -768,4 +953,8 @@ class BeeGraphicsView(MainControlsMixin,
def keyPressEvent(self, event):
if self.keyPressEventMainControls(event):
return
if self.active_mode == self.SAMPLE_COLOR_MODE:
self.cancel_sample_color_mode()
event.accept()
return
super().keyPressEvent(event)

View file

@ -13,15 +13,20 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from importlib.resources import files as rsc_files
import logging
import os.path
from PyQt6 import QtCore, QtWidgets
from PyQt6 import QtCore, QtWidgets, QtGui
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__)
@ -39,6 +44,7 @@ class BeeProgressDialog(QtWidgets.QProgressDialog):
worker.begin_processing.connect(self.on_begin_processing)
worker.progress.connect(self.on_progress)
worker.finished.connect(self.on_finished)
worker.user_input_required.connect(self.on_finished)
self.canceled.connect(worker.on_canceled)
def on_progress(self, value):
@ -49,7 +55,7 @@ class BeeProgressDialog(QtWidgets.QProgressDialog):
logger.debug(f'Beginn progress dialog: {value}')
self.setMaximum(value)
def on_finished(self, filename, errors):
def on_finished(self, *args, **kwargs):
logger.debug('Finished progress dialog')
self.setValue(self.maximum())
self.reset()
@ -61,20 +67,18 @@ class HelpDialog(QtWidgets.QDialog):
def __init__(self, parent):
super().__init__(parent)
self.setWindowTitle(f'{constants.APPNAME} Help')
docdir = os.path.join(os.path.dirname(__file__),
'..',
'documentation')
tabs = QtWidgets.QTabWidget()
# Controls
with open(os.path.join(docdir, 'controls.html')) as f:
controls_txt = f.read()
controls = QtWidgets.QLabel(controls_txt)
controls.setTextInteractionFlags(
controls_txt = rsc_files(
'beeref.documentation').joinpath('controls.html').read_text()
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()
@ -239,3 +243,96 @@ class ChangeOpacityDialog(QtWidgets.QDialog):
def reject(self):
self.command.undo()
return super().reject()
class BeeNotification(QtWidgets.QWidget):
def __init__(self, parent, text):
super().__init__(parent)
self.label = QtWidgets.QLabel(text)
self.setObjectName('BeeNotification')
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self.setAutoFillBackground(True)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.label)
self.setLayout(layout)
color = constants.COLORS['Active:Window']
self.setStyleSheet(
f'background-color: rgba({color[0]}, {color[1]}, {color[2]}, 0.9);'
'padding: 0.7em;'
'border-radius: 5px;')
self.show()
# We only get own width after showing it;
# updateGeometry doesn't work on hidden widgets
x = (parent.width() - self.width()) / 2
self.move(int(x), 10)
QtCore.QTimer.singleShot(1000 * 3, self.deleteLater)
class SampleColorWidget(QtWidgets.QWidget):
OFFSET = 10 # Offset from mouse pointer
SIZE = 50
NONE_COLOR = QtGui.QColor(0, 0, 0, 0)
def __init__(self, parent, pos, color):
super().__init__(parent)
self.color = color
self.set_pos(pos)
self.show()
def set_pos(self, pos):
self.setGeometry(int(pos.x() + self.OFFSET),
int(pos.y() + self.OFFSET),
self.SIZE, self.SIZE)
def paintEvent(self, event):
color = self.color if self.color else self.NONE_COLOR
painter = QtGui.QPainter(self)
painter.setBrush(QtGui.QBrush(color))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRect(0, 0, self.SIZE, self.SIZE)
def update(self, pos, color):
self.set_pos(pos)
self.color = color
self.repaint()
class ExportImagesFileExistsDialog(QtWidgets.QDialog):
def __init__(self, parent, filename):
super().__init__(parent)
self.setWindowTitle('File exists')
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
label = QtWidgets.QLabel(
f'File already exists:\n{filename}')
layout.addWidget(label)
choices = (('skip', 'Skip this file'),
('skip_all', 'Skip all existing files'),
('overwrite', 'Overwrite this file'),
('overwrite_all', 'Overwrite all existing files'))
self.radio_buttons = {}
for (value, label) in choices:
btn = QtWidgets.QRadioButton(label)
self.radio_buttons[value] = btn
layout.addWidget(btn)
self.radio_buttons['skip'].setChecked(True)
# Bottom row of buttons
buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton.Ok
| QtWidgets.QDialogButtonBox.StandardButton.Cancel)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
layout.addWidget(buttons)
def get_answer(self):
for value, btn in self.radio_buttons.items():
if btn.isChecked():
return value

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,20 +16,20 @@
from functools import partial
import logging
from PyQt6 import QtWidgets, QtCore, QtGui
from PyQt6 import QtWidgets
from PyQt6.QtCore import Qt
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):
TITLE = None
HELPTEXT = None
KEY = None
def __init__(self):
super().__init__()
@ -47,23 +47,31 @@ 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):
if self.ignore_value_changed:
return
value = self.convert_value_from_qt(value)
if value != self.settings.valueOrDefault(self.KEY):
logger.debug(f'Setting {self.KEY} changed to: {value}')
self.settings.setValue(self.KEY, value)
self.update_title()
def convert_value_from_qt(self, value):
return value
def on_restore_defaults(self):
new_value = self.settings.valueOrDefault(self.KEY)
self.ignore_value_changed = True
self.set_value(new_value)
self.ignore_value_changed = False
self.update_title()
class RadioGroup(GroupBase):
TITLE = None
HELPTEXT = None
KEY = None
OPTIONS = None
def __init__(self):
@ -83,38 +91,59 @@ class RadioGroup(GroupBase):
self.ignore_value_changed = False
self.layout.addStretch(100)
def on_restore_defaults(self):
new_value = self.settings.valueOrDefault(self.KEY)
self.ignore_value_changed = True
for value, btn in self.buttons.items():
btn.setChecked(value == new_value)
self.ignore_value_changed = False
self.update_title()
def set_value(self, value):
for old_value, btn in self.buttons.items():
btn.setChecked(old_value == value)
class IntegerGroup(GroupBase):
TITLE = None
HELPTEXT = None
KEY = None
MIN = None
MAX = None
def __init__(self):
super().__init__()
self.input = QtWidgets.QSpinBox()
self.input.setValue(self.settings.valueOrDefault(self.KEY))
self.input.setRange(self.MIN, self.MAX)
self.set_value(self.settings.valueOrDefault(self.KEY))
self.input.valueChanged.connect(self.on_value_changed)
self.layout.addWidget(self.input)
self.layout.addStretch(100)
self.ignore_value_changed = False
def on_restore_defaults(self):
new_value = self.settings.valueOrDefault(self.KEY)
self.ignore_value_changed = True
self.input.setValue(new_value)
def set_value(self, value):
self.input.setValue(value)
class SingleCheckboxGroup(GroupBase):
LABEL = None
def __init__(self):
super().__init__()
self.input = QtWidgets.QCheckBox(self.LABEL)
self.set_value(self.settings.valueOrDefault(self.KEY))
self.input.checkStateChanged.connect(self.on_value_changed)
self.layout.addWidget(self.input)
self.layout.addStretch(100)
self.ignore_value_changed = False
self.update_title()
def set_value(self, value):
self.input.setChecked(value)
def convert_value_from_qt(self, value):
return value == Qt.CheckState.Checked
class ArrangeDefaultWidget(RadioGroup):
TITLE = 'Default Arrange Method:'
HELPTEXT = ('How images are arranged when inserted in batch')
KEY = 'Items/arrange_default'
OPTIONS = (
('optimal', 'Optimal', 'Arrange Optimal'),
('horizontal', 'Horizontal (by filename)',
'Arrange Horizontal (by filename)'),
('vertical', 'Vertical (by filename)',
'Arrange Vertical (by filename)'),
('square', 'Square (by filename)', 'Arrannge Square (by filename)'))
class ImageStorageFormatWidget(RadioGroup):
@ -139,6 +168,24 @@ class ArrangeGapWidget(IntegerGroup):
MAX = 200
class AllocationLimitWidget(IntegerGroup):
TITLE = 'Maximum Image Size:'
HELPTEXT = ('The maximum image size that can be loaded (in megabytes). '
'Set to 0 for no limitation.')
KEY = 'Items/image_allocation_limit'
MIN = 0
MAX = 10000
class ConfirmCloseUnsavedWidget(SingleCheckboxGroup):
TITLE = 'Confirm when closing an unsaved file:'
HELPTEXT = (
'When about to close an unsaved file, should BeeRef ask for '
'confirmation?')
LABEL = 'Confirm when closing'
KEY = 'Save/confirm_close_unsaved'
class SettingsDialog(QtWidgets.QDialog):
def __init__(self, parent):
super().__init__(parent)
@ -149,10 +196,19 @@ class SettingsDialog(QtWidgets.QDialog):
misc = QtWidgets.QWidget()
misc_layout = QtWidgets.QGridLayout()
misc.setLayout(misc_layout)
misc_layout.addWidget(ImageStorageFormatWidget(), 0, 0)
misc_layout.addWidget(ArrangeGapWidget(), 0, 1)
misc_layout.addWidget(ConfirmCloseUnsavedWidget(), 0, 0)
tabs.addTab(misc, '&Miscellaneous')
# Images & Items
items = QtWidgets.QWidget()
items_layout = QtWidgets.QGridLayout()
items.setLayout(items_layout)
items_layout.addWidget(ImageStorageFormatWidget(), 0, 0)
items_layout.addWidget(AllocationLimitWidget(), 0, 1)
items_layout.addWidget(ArrangeGapWidget(), 1, 0)
items_layout.addWidget(ArrangeDefaultWidget(), 1, 1)
tabs.addTab(items, '&Images && Items')
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
layout.addWidget(tabs)
@ -178,228 +234,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 data(self, index, role):
if (role == QtCore.Qt.ItemDataRole.BackgroundRole
and index.row() % 2):
return QtGui.QColor(*constants.COLORS['Table:AlternativeRow'])
else:
return super().data(index, role)
def setData(self, index, value, role, remove_from_other=None):
result = self.sourceModel().setData(
self.mapToSource(index),
value,
role,
remove_from_other=remove_from_other)
return result
class KeyboardShortcutsView(QtWidgets.QTableView):
def __init__(self, parent):
super().__init__(parent)
self.setMinimumSize(QtCore.QSize(400, 200))
self.setItemDelegate(KeyboardShortcutsDelegate())
self.setShowGrid(False)
self.setModel(KeyboardShortcutsProxy())
self.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.ResizeMode.Stretch)
self.horizontalHeader().setSectionResizeMode(
1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
self.setSelectionMode(
QtWidgets.QHeaderView.SelectionMode.SingleSelection)
settings_events.restore_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

@ -93,7 +93,7 @@ class WelcomeOverlay(MainControlsMixin, QtWidgets.QWidget):
def __init__(self, parent):
super().__init__(parent)
self.control_target = parent
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
self.setAutoFillBackground(True)
self.init_main_controls(main_window=parent.parent)
# Recent files

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="146.0" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="round"><rect width="146.0" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#round)"><rect width="48.1" height="20" fill="#555"/><rect x="48.1" width="97.9" height="20" fill="#007ec6"/><rect width="146.0" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"><text x="250.5" y="150" fill="#010101" fill-opacity=".3" transform="scale(0.1)" textLength="381.0" lengthAdjust="spacing">Python</text><text x="250.5" y="140" transform="scale(0.1)" textLength="381.0" lengthAdjust="spacing">Python</text><text x="960.5000000000001" y="150" fill="#010101" fill-opacity=".3" transform="scale(0.1)" textLength="879.0" lengthAdjust="spacing">3.9 | 3.10 | 3.11</text><text x="960.5000000000001" y="140" transform="scale(0.1)" textLength="879.0" lengthAdjust="spacing">3.9 | 3.10 | 3.11</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="146.0" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="round"><rect width="146.0" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#round)"><rect width="48.1" height="20" fill="#555"/><rect x="48.1" width="97.9" height="20" fill="#007ec6"/><rect width="146.0" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"><text x="250.5" y="150" fill="#010101" fill-opacity=".3" transform="scale(0.1)" textLength="381.0" lengthAdjust="spacing">Python</text><text x="250.5" y="140" transform="scale(0.1)" textLength="381.0" lengthAdjust="spacing">Python</text><text x="960.5000000000001" y="150" fill="#010101" fill-opacity=".3" transform="scale(0.1)" textLength="879.0" lengthAdjust="spacing">3.9 | 3.10 | 3.11 | 3.12</text><text x="960.5000000000001" y="140" transform="scale(0.1)" textLength="879.0" lengthAdjust="spacing">3.9 | 3.10 | 3.11 | 3.12</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -4,7 +4,7 @@
<launchable type="desktop-id">org.beeref.BeeRef.desktop</launchable>
<name>BeeRef</name>
<developer_name>BeeRef</developer_name>
<summary>A Simple Reference Image Viewer</summary>
<summary>A simple reference image viewer</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0</project_license>
<url type="homepage">https://beeref.org/</url>
@ -14,10 +14,14 @@
</description>
<screenshots>
<screenshot type="default">https://github.com/rbreu/beeref/blob/main/images/screenshot.png?raw=true</screenshot>
<screenshot type="default">
<image>https://raw.githubusercontent.com/rbreu/beeref/main/images/screenshot.png</image>
</screenshot>
</screenshots>
<releases>
<!-- <release version="0.3.4-dev" date="unreleased"></release> -->
<release version="0.3.3" date="2024-05-05"></release>
<release version="0.3.2" date="2024-01-21"></release>
<release version="0.3.1" date="2023-12-10"></release>
<release version="0.3.0" date="2023-11-23"></release>

37
pyproject.toml Normal file
View file

@ -0,0 +1,37 @@
[build-system]
requires = ["setuptools>=61.0.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["beeref*"]
[tool.setuptools.package-data]
"*" = ["*.html", "*.png"]
[project]
name = "BeeRef"
version = "0.3.4-dev"
description = "A simple reference image viewer"
readme = "README.rst"
license = {file = "LICENSE"}
authors = [
{ name = "Rebecca Breu", email = "rebecca@rbreu.de" },
]
requires-python = ">=3.9,<3.13"
dependencies = [
"exif>=1.3.5,<=1.6.0",
"lxml==5.1.0",
"pyQt6-Qt6>=6.7.0,<=6.7.0",
"pyQt6>=6.7.0,<=6.7.0",
"rectangle-packer>=2.0.1,<=2.0.2",
]
[project.gui-scripts]
beeref = "beeref.__main__:main"
[project.urls]
Homepage = "https://beeref.org/"
Repository = "https://github.com/rbreu/beeref"
# Documentation =
Issues = "https://github.com/rbreu/beeref/issues"
Changelog = "https://github.com/rbreu/beeref/blob/main/CHANGELOG.rst"

View file

@ -1,3 +1,3 @@
# For the test action on github
# For the build action on github
pyinstaller==6.3.0
pyinstaller==6.6.0

View file

@ -3,6 +3,6 @@
-r test.txt
-r build.txt
flake8==6.1.0
flake8==7.0.0
pybadges==3.0.1
yamllint==1.33.0
yamllint==1.35.1

View file

@ -1,6 +1,6 @@
# For the test action on github
coverage==7.3.2
coverage==7.5.1
httpretty==1.1.4
pytest==7.4.3
pytest-qt==4.2.0
pytest-cov==4.1.0
pytest==8.2.0
pytest-qt==4.4.0
pytest-cov==5.0.0

View file

@ -1,5 +1,12 @@
[flake8]
exclude =
squashfs-root
build
dist
[coverage:run]
source = beeref
[tool:pytest]
norecursedirs = squashfs-root
addopts = --cov-report html --cov-config=setup.cfg

View file

@ -1,35 +1,3 @@
from setuptools import setup
setup(
name='BeeRef',
version='0.3.2',
author='Rebecca Breu',
author_email='rebecca@rbreu.de',
url='https://github.com/rbreu/beeref',
license='LICENSE',
description='A simple reference image viewer',
install_requires=[
'pyQt6>=6.5.0,<=6.6.1',
'pyQt6-Qt6>=6.5.0,<=6.6.1',
'rectangle-packer>=2.0.1,<=2.0.2',
'exif>=1.3.5,<=1.6.0',
],
packages=[
'beeref',
'beeref.actions',
'beeref.assets',
'beeref.documentation',
'beeref.fileio',
'beeref.widgets',
],
entry_points={
'gui_scripts': [
'beeref = beeref.__main__:main'
]
},
include_package_data=True,
package_data={
'beeref.assets': ['*.png'],
'beeref': ['documentation/*.html'],
},
)
setup()

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):
@ -19,7 +20,7 @@ class FooWidget(QtWidgets.QWidget, ActionsMixin):
def on_bar(self):
pass
def open_from_file(self):
def on_action_open_recent_file(self, filename):
pass
@ -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

@ -1,10 +1,13 @@
import os
import os.path
import tempfile
from unittest.mock import patch
from unittest.mock import patch, MagicMock
import pytest
from beeref.config import CommandlineArgs
from PyQt6 import QtGui
from beeref.config.settings import CommandlineArgs
def test_command_line_args_singleton():
@ -13,7 +16,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)
@ -35,6 +38,52 @@ def test_command_line_args_get_unknown():
CommandlineArgs._instance = None
def test_settings_on_startup_sets_alloc_from_settings(settings):
settings.setValue('Items/image_allocation_limit', 66)
QtGui.QImageReader.setAllocationLimit(100)
settings.on_startup()
assert QtGui.QImageReader.allocationLimit() == 66
def test_settings_on_startup_sets_alloc_from_environment(settings):
settings.setValue('Items/image_allocation_limit', 66)
os.environ['QT_IMAGEIO_MAXALLOC'] = '42'
QtGui.QImageReader.setAllocationLimit(100)
settings.on_startup()
assert QtGui.QImageReader.allocationLimit() == 42
def test_settings_set_value_without_callback(settings):
settings.FIELDS = {'foo/bar': {}}
settings.setValue('foo/bar', 100)
assert settings.value('foo/bar') == 100
def test_settings_set_value_with_callback(settings):
foo_callback = MagicMock()
settings.FIELDS = {'foo/bar': {'post_save_callback': foo_callback}}
settings.setValue('foo/bar', 100)
foo_callback.assert_called_once_with(100)
assert settings.value('foo/bar') == 100
def test_settings_remove_without_callback(settings):
settings.FIELDS = {'foo/bar': {}}
settings.remove('foo/bar')
assert settings.value('foo/bar') is None
def test_settings_remove_with_callback(settings):
foo_callback = MagicMock()
settings.FIELDS = {
'foo/bar': {'default': 66,
'post_save_callback': foo_callback}
}
settings.remove('foo/bar')
foo_callback.assert_called_once_with(66)
assert settings.value('foo/bar') is None
def test_settings_value_or_default_gets_default(settings):
assert settings.valueOrDefault('Items/image_storage_format') == 'best'
@ -117,48 +166,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

@ -30,7 +30,7 @@ def reset_beeref_actions():
def commandline_args():
config_patcher = patch('beeref.view.commandline_args')
config_mock = config_patcher.start()
config_mock.filename = None
config_mock.filenames = []
yield config_mock
config_patcher.stop()

View file

@ -1,13 +1,5 @@
import os
import stat
from unittest.mock import patch, ANY, MagicMock
import pytest
from PyQt6 import QtGui, QtCore
from beeref import constants
from beeref.items import BeePixmapItem, BeeTextItem
from beeref.fileio.errors import BeeFileIOError
from beeref.fileio.export import (
exporter_registry,
SceneToPixmapExporter,
@ -21,446 +13,3 @@ from beeref.fileio.export import (
('svg', SceneToSVGExporter)])
def test_registry(key, expected):
exporter_registry[key] == expected
@patch('beeref.widgets.SceneToPixmapExporterDialog.exec', return_value=True)
@patch('beeref.widgets.SceneToPixmapExporterDialog.value',
return_value=QtCore.QSize(100, 200))
def test_scene_to_pixmap_exporter_get_user_input(value_mock, exec_mock, view):
exporter = SceneToPixmapExporter(view.scene)
value = exporter.get_user_input(None)
assert value is True
assert exporter.size == QtCore.QSize(100, 200)
@patch('beeref.widgets.SceneToPixmapExporterDialog.exec', return_value=False)
@patch('beeref.widgets.SceneToPixmapExporterDialog.value')
def test_scene_to_pixmap_exporter_get_user_input_when_canceled(
value_mock, exec_mock, view):
exporter = SceneToPixmapExporter(view.scene)
value = exporter.get_user_input(None)
assert value is False
def test_scene_to_pixmap_exporter_default_size_and_margin(view):
item1 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(0, 0))
view.scene.addItem(item1)
item2 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(200, 0))
view.scene.addItem(item2)
exporter = SceneToPixmapExporter(view.scene)
assert view.scene.sceneRect().size().toSize() == QtCore.QSize(300, 100)
assert (exporter.margin - 9) < 0.000001
assert exporter.default_size == QtCore.QSize(318, 118)
def test_scene_to_pixmap_exporter_default_size_and_margin_when_selection(view):
item1 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(0, 0))
view.scene.addItem(item1)
item2 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(200, 0))
view.scene.addItem(item2)
item2.setSelected(True)
exporter = SceneToPixmapExporter(view.scene)
assert view.scene.sceneRect().size().toSize() == QtCore.QSize(300, 100)
assert (exporter.margin - 9) < 0.000001
assert exporter.default_size == QtCore.QSize(318, 118)
@patch('beeref.scene.BeeGraphicsScene.render')
def test_scene_to_pixmap_exporter_render_sets_margins(render_mock, view):
item = BeePixmapItem(
QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32))
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
assert exporter.margin == 36
assert exporter.default_size == QtCore.QSize(1072, 1272)
exporter.size = QtCore.QSize(536, 636)
exporter.render_to_image()
render_mock.assert_called_once_with(
ANY,
source=QtCore.QRectF(0, 0, 1000, 1200),
target=QtCore.QRectF(18, 18, 500, 600))
def test_scene_to_pixmap_exporter_render_renders_scene(view):
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item_img.fill(QtGui.QColor(11, 22, 33))
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
assert exporter.margin == 36
assert exporter.default_size == QtCore.QSize(1072, 1272)
exporter.size = QtCore.QSize(536, 636)
image = exporter.render_to_image()
assert image.pixel(1, 1) == QtGui.QColor(*constants.COLORS['Scene:Canvas'])
assert image.pixel(100, 100) == QtGui.QColor(11, 22, 33)
def test_scene_to_pixmap_exporter_export_writes_image(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
exporter.export(filename)
with open(filename, 'rb') as f:
assert f.read().startswith(b'\x89PNG')
def test_scene_to_pixmap_exporter_export_with_worker(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=False)
exporter.export(filename, worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(1)
worker.finished.emit.assert_called_once_with(filename, [])
with open(filename, 'rb') as f:
assert f.read().startswith(b'\x89PNG')
def test_scene_to_pixmap_exporter_export_with_worker_when_canceled(
view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=True)
exporter.export(filename, worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_not_called()
worker.finished.emit.assert_called_once_with(filename, [])
os.path.exists(filename) is False
def test_scene_to_pixmap_exporter_export_when_file_not_writeable(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
with open(filename, 'w') as f:
f.write('foo')
os.chmod(filename, stat.S_IREAD)
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
with pytest.raises(BeeFileIOError) as e:
exporter.export(filename)
assert e.filename == filename
def test_scene_to_pixmap_exporter_export_when_file_not_writeable_with_worker(
view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
with open(filename, 'w') as f:
f.write('foo')
os.chmod(filename, stat.S_IREAD)
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=False)
exporter.export(filename, worker=worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_not_called()
worker.finished.emit.assert_called_once_with(
filename, ['Error writing file'])
def test_scene_to_svg_exporter_get_user_input(view):
item1 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(0, 0))
view.scene.addItem(item1)
item2 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(200, 0))
view.scene.addItem(item2)
assert view.scene.sceneRect().size().toSize() == QtCore.QSize(300, 100)
exporter = SceneToSVGExporter(view.scene)
value = exporter.get_user_input(None)
assert value is True
assert exporter.size == QtCore.QSize(318, 118)
def test_scene_to_svg_exporter_render_pixmap_items(view):
item1 = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(20, 30))
view.scene.addItem(item1)
item2 = BeePixmapItem(
QtGui.QImage(70, 77, QtGui.QImage.Format.Format_RGB32))
item2.setPos(QtCore.QPointF(50, 50))
item2.setZValue(-1)
view.scene.addItem(item2)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert svg.tag == 'svg'
assert svg.get('width') == '200'
assert svg.get('height') == '400'
assert svg.get('xmlns') == 'http://www.w3.org/2000/svg'
assert svg.get('xmlns:xlink') == 'http://www.w3.org/1999/xlink'
assert len(svg) == 2
element = svg[0] # item2
assert element.tag == 'image'
assert element.get('xlink:href').startswith('data:image/png;base64,iVBOR')
assert element.get('width') == '70.0'
assert element.get('height') == '77.0'
assert element.get('image-rendering') == 'optimizeQuality'
assert element.get('transform') == 'rotate(0.0 35.0 25.0)'
assert element.get('x') == '35.0'
assert element.get('y') == '25.0'
assert element.get('opacity') == '1.0'
element = svg[1] # item1
assert element.tag == 'image'
assert element.get('width') == '100.0'
assert element.get('height') == '110.0'
assert element.get('image-rendering') == 'optimizeQuality'
assert element.get('transform') == 'rotate(0.0 5.0 5.0)'
assert element.get('x') == '5.0'
assert element.get('y') == '5.0'
assert element.get('opacity') == '1.0'
def test_scene_to_svg_exporter_render_pixmap_with_crop(view):
item = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item.setPos(QtCore.QPointF(20, 30))
item.crop = QtCore.QRectF(20, 25, 30, 33)
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'image'
assert element.get('width') == '30.0'
assert element.get('height') == '33.0'
assert element.get('transform') == 'rotate(0.0 -15.0 -20.0)'
assert element.get('x') == '5.0'
assert element.get('y') == '5.0'
def test_scene_to_svg_exporter_render_pixmap_with_rotation(view):
item = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item.setPos(QtCore.QPointF(20, 30))
item.setRotation(90)
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'image'
assert element.get('transform') == 'rotate(90.0 115.0 5.0)'
assert element.get('x') == '115.0'
assert element.get('y') == '5.0'
def test_scene_to_svg_exporter_render_pixmap_with_opacity(view):
item = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item.setPos(QtCore.QPointF(20, 30))
item.setOpacity(0.75)
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'image'
assert element.get('opacity') == '0.75'
def test_scene_to_svg_exporter_render_pixmap_with_flip(view):
item = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item.setPos(QtCore.QPointF(20, 30))
item.do_flip()
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'image'
assert element.get('transform') == (
'translate(105.0 5.0) scale(-1.0 1)'
' translate(-105.0 -5.0) rotate(0.0 105.0 5.0)')
assert element.get('x') == '105.0'
assert element.get('y') == '5.0'
def test_scene_to_svg_exporter_render_text(view):
item = BeeTextItem('foo')
item.setPos(QtCore.QPointF(20, 30))
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'text'
assert element.text == 'foo'
assert element.get('dominant-baseline') == 'hanging'
assert 'font-family' in element.get('style')
assert element.get('transform') == 'rotate(0.0 5.0 5.0)'
assert element.get('x') == '5.0'
assert element.get('y') == '5.0'
def test_scene_to_svg_exporter_export_when_file_not_writeable(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
with open(filename, 'w') as f:
f.write('foo')
os.chmod(filename, stat.S_IREAD)
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
with pytest.raises(BeeFileIOError) as e:
exporter.export(filename)
assert e.filename == filename
def test_scene_to_svg_exporter_render_with_worker(view):
item = BeeTextItem('foo')
item.setPos(QtCore.QPointF(20, 30))
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
worker = MagicMock(canceled=False)
svg = exporter.render_to_svg(worker=worker)
assert len(svg) == 1
worker.progress.emit.assert_called_once_with(0)
def test_scene_to_svg_exporter_render_with_worker_canceled(view):
item = BeeTextItem('foo')
item.setPos(QtCore.QPointF(20, 30))
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
worker = MagicMock(canceled=True)
svg = exporter.render_to_svg(worker=worker)
assert svg is None
def test_scene_to_svg_exporter_export_writes_svg(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
exporter.export(filename)
with open(filename, 'rb') as f:
assert f.read().startswith(b'<?xml')
def test_scene_to_svg_exporter_export_with_worker(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=False)
exporter.export(filename, worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once_with(filename, [])
with open(filename, 'rb') as f:
assert f.read().startswith(b'<?xml')
def test_scene_to_svg_exporter_export_with_worker_canceled(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=True)
exporter.export(filename, worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once_with(filename, [])
os.path.exists(filename) is False
def test_scene_to_svg_exporter_export_when_file_not_writeable_with_worker(
view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
with open(filename, 'w') as f:
f.write('foo')
os.chmod(filename, stat.S_IREAD)
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=False)
exporter.export(filename, worker=worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once()
args = worker.finished.emit.call_args.args
assert args[0] == filename
assert len(args[1]) == 1

View file

@ -0,0 +1,287 @@
import os
import stat
from unittest.mock import MagicMock
import pytest
from PyQt6 import QtGui
from beeref.items import BeePixmapItem
from beeref.fileio.errors import BeeFileIOError
from beeref.fileio.export import ImagesToDirectoryExporter
def test_images_to_directory_exporter_export_writes_images(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item1 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
item2.save_id = 3
view.scene.addItem(item2)
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.export()
with open(os.path.join(tmpdir, '0003.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
with open(os.path.join(tmpdir, '0004.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
def test_images_to_directory_exporter_export_file_exists_no_user_input(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item1 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item2)
with open(os.path.join(tmpdir, '0002.png'), 'w') as f:
assert f.write('foo')
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.export()
with open(os.path.join(tmpdir, '0001.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
with open(os.path.join(tmpdir, '0002.png'), 'r') as f:
assert f.read() == 'foo'
assert exporter.start_from == 1
def test_images_to_directory_exporter_export_file_exists_skip(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item1 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item2)
item3 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item3)
with open(os.path.join(tmpdir, '0002.png'), 'w') as f:
assert f.write('foo')
with open(os.path.join(tmpdir, '0003.png'), 'w') as f:
assert f.write('bar')
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.handle_existing = 'skip'
exporter.export()
with open(os.path.join(tmpdir, '0001.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
with open(os.path.join(tmpdir, '0002.png'), 'r') as f:
assert f.read() == 'foo'
with open(os.path.join(tmpdir, '0003.png'), 'r') as f:
assert f.read() == 'bar'
assert exporter.start_from == 2
assert exporter.handle_existing is None
def test_images_to_directory_exporter_export_file_exists_skip_all(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item1 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item2)
item3 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item3)
with open(os.path.join(tmpdir, '0002.png'), 'w') as f:
assert f.write('foo')
with open(os.path.join(tmpdir, '0003.png'), 'w') as f:
assert f.write('bar')
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.handle_existing = 'skip_all'
exporter.export()
with open(os.path.join(tmpdir, '0001.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
with open(os.path.join(tmpdir, '0002.png'), 'r') as f:
assert f.read() == 'foo'
with open(os.path.join(tmpdir, '0003.png'), 'r') as f:
assert f.read() == 'bar'
assert exporter.handle_existing == 'skip_all'
def test_images_to_directory_exporter_export_file_exists_overwrite(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item1 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item2)
item3 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item3)
with open(os.path.join(tmpdir, '0002.png'), 'w') as f:
assert f.write('foo')
with open(os.path.join(tmpdir, '0003.png'), 'w') as f:
assert f.write('bar')
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.handle_existing = 'overwrite'
exporter.export()
with open(os.path.join(tmpdir, '0001.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
with open(os.path.join(tmpdir, '0002.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
with open(os.path.join(tmpdir, '0003.png'), 'r') as f:
assert f.read() == 'bar'
assert exporter.start_from == 2
assert exporter.handle_existing is None
def test_images_to_directory_exporter_export_file_exists_overwrite_all(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item1 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item2)
item3 = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item3)
with open(os.path.join(tmpdir, '0002.png'), 'w') as f:
assert f.write('foo')
with open(os.path.join(tmpdir, '0003.png'), 'w') as f:
assert f.write('bar')
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.handle_existing = 'overwrite_all'
exporter.export()
with open(os.path.join(tmpdir, '0001.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
with open(os.path.join(tmpdir, '0002.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
with open(os.path.join(tmpdir, '0003.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
assert exporter.handle_existing == 'overwrite_all'
def test_images_to_directory_exporter_export_with_worker(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
worker = MagicMock(canceled=False)
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.export(worker)
with open(os.path.join(tmpdir, '0001.png'), 'rb') as f:
assert f.read().startswith(b'\x89PNG')
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_with(0)
worker.finished.emit.assert_called_once_with(tmpdir, [])
def test_images_to_directory_exporter_export_with_worker_when_canceled(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
worker = MagicMock(canceled=True)
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.export(worker)
assert os.path.exists(os.path.join(tmpdir, '0001.png')) is False
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once_with(tmpdir, [])
def test_images_to_directory_exporter_export_with_worker_when_file_exists(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
with open(os.path.join(tmpdir, '0001.png'), 'w') as f:
assert f.write('foo')
worker = MagicMock(canceled=False)
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.export(worker)
imgfilename = os.path.join(tmpdir, '0001.png')
with open(imgfilename, 'r') as f:
assert f.read() == 'foo'
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_with(0)
worker.user_input_required.emit.assert_called_once_with(imgfilename)
def test_images_to_directory_exporter_export_when_dir_not_writeable(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
os.chmod(tmpdir, stat.S_IREAD)
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
with pytest.raises(BeeFileIOError) as e:
exporter.export()
assert e.filename == tmpdir
def test_images_to_directory_exporter_export_when_dir_not_writeable_w_worker(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
os.chmod(tmpdir, stat.S_IREAD)
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
worker = MagicMock(canceled=False)
exporter.export(worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.finished.emit.assert_called_once()
args = worker.finished.emit.call_args.args
assert args[0] == tmpdir
assert len(args[1]) == 1
def test_images_to_directory_exporter_export_when_img_not_writeable(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
imgfilename = os.path.join(tmpdir, '0001.png')
with open(imgfilename, 'w') as f:
assert f.write('foo')
os.chmod(imgfilename, stat.S_IREAD)
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.handle_existing = 'overwrite_all'
with pytest.raises(BeeFileIOError) as e:
exporter.export()
assert e.filename == tmpdir
def test_images_to_directory_exporter_export_when_img_not_writeable_w_worker(
view, tmpdir, imgdata3x3, imgfilename3x3,):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
imgfilename = os.path.join(tmpdir, '0001.png')
with open(imgfilename, 'w') as f:
assert f.write('foo')
os.chmod(imgfilename, stat.S_IREAD)
exporter = ImagesToDirectoryExporter(view.scene, tmpdir)
exporter.handle_existing = 'overwrite_all'
worker = MagicMock(canceled=False)
exporter.export(worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.finished.emit.assert_called_once()
args = worker.finished.emit.call_args.args
assert args[0] == imgfilename
assert len(args[1]) == 1

View file

@ -0,0 +1,181 @@
import os
import stat
from unittest.mock import patch, ANY, MagicMock
import pytest
from PyQt6 import QtGui, QtCore
from beeref import constants
from beeref.items import BeePixmapItem
from beeref.fileio.errors import BeeFileIOError
from beeref.fileio.export import SceneToPixmapExporter
@patch('beeref.widgets.SceneToPixmapExporterDialog.exec', return_value=True)
@patch('beeref.widgets.SceneToPixmapExporterDialog.value',
return_value=QtCore.QSize(100, 200))
def test_scene_to_pixmap_exporter_get_user_input(value_mock, exec_mock, view):
exporter = SceneToPixmapExporter(view.scene)
value = exporter.get_user_input(None)
assert value is True
assert exporter.size == QtCore.QSize(100, 200)
@patch('beeref.widgets.SceneToPixmapExporterDialog.exec', return_value=False)
@patch('beeref.widgets.SceneToPixmapExporterDialog.value')
def test_scene_to_pixmap_exporter_get_user_input_when_canceled(
value_mock, exec_mock, view):
exporter = SceneToPixmapExporter(view.scene)
value = exporter.get_user_input(None)
assert value is False
def test_scene_to_pixmap_exporter_default_size_and_margin(view):
item1 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(0, 0))
view.scene.addItem(item1)
item2 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(200, 0))
view.scene.addItem(item2)
exporter = SceneToPixmapExporter(view.scene)
assert view.scene.sceneRect().size().toSize() == QtCore.QSize(300, 100)
assert (exporter.margin - 9) < 0.000001
assert exporter.default_size == QtCore.QSize(318, 118)
def test_scene_to_pixmap_exporter_default_size_and_margin_when_selection(view):
item1 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(0, 0))
view.scene.addItem(item1)
item2 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(200, 0))
view.scene.addItem(item2)
item2.setSelected(True)
exporter = SceneToPixmapExporter(view.scene)
assert view.scene.sceneRect().size().toSize() == QtCore.QSize(300, 100)
assert (exporter.margin - 9) < 0.000001
assert exporter.default_size == QtCore.QSize(318, 118)
@patch('beeref.scene.BeeGraphicsScene.render')
def test_scene_to_pixmap_exporter_render_sets_margins(render_mock, view):
item = BeePixmapItem(
QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32))
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
assert exporter.margin == 36
assert exporter.default_size == QtCore.QSize(1072, 1272)
exporter.size = QtCore.QSize(536, 636)
exporter.render_to_image()
render_mock.assert_called_once_with(
ANY,
source=QtCore.QRectF(0, 0, 1000, 1200),
target=QtCore.QRectF(18, 18, 500, 600))
def test_scene_to_pixmap_exporter_render_renders_scene(view):
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item_img.fill(QtGui.QColor(11, 22, 33))
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
assert exporter.margin == 36
assert exporter.default_size == QtCore.QSize(1072, 1272)
exporter.size = QtCore.QSize(536, 636)
image = exporter.render_to_image()
assert image.pixel(1, 1) == QtGui.QColor(*constants.COLORS['Scene:Canvas'])
assert image.pixel(100, 100) == QtGui.QColor(11, 22, 33)
def test_scene_to_pixmap_exporter_export_writes_image(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
exporter.export(filename)
with open(filename, 'rb') as f:
assert f.read().startswith(b'\x89PNG')
def test_scene_to_pixmap_exporter_export_with_worker(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=False)
exporter.export(filename, worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(1)
worker.finished.emit.assert_called_once_with(filename, [])
with open(filename, 'rb') as f:
assert f.read().startswith(b'\x89PNG')
def test_scene_to_pixmap_exporter_export_with_worker_when_canceled(
view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=True)
exporter.export(filename, worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_not_called()
worker.finished.emit.assert_called_once_with(filename, [])
os.path.exists(filename) is False
def test_scene_to_pixmap_exporter_export_when_file_not_writeable(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
with open(filename, 'w') as f:
f.write('foo')
os.chmod(filename, stat.S_IREAD)
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
with pytest.raises(BeeFileIOError) as e:
exporter.export(filename)
assert e.filename == filename
def test_scene_to_pixmap_exporter_export_when_file_not_writeable_with_worker(
view, tmpdir):
filename = os.path.join(tmpdir, 'foo.png')
with open(filename, 'w') as f:
f.write('foo')
os.chmod(filename, stat.S_IREAD)
item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)
item = BeePixmapItem(item_img)
view.scene.addItem(item)
exporter = SceneToPixmapExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=False)
exporter.export(filename, worker=worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_not_called()
worker.finished.emit.assert_called_once_with(
filename, ['Error writing file'])

View file

@ -0,0 +1,283 @@
import os
import stat
from unittest.mock import MagicMock
import pytest
from PyQt6 import QtGui, QtCore
from beeref.items import BeePixmapItem, BeeTextItem
from beeref.fileio.errors import BeeFileIOError
from beeref.fileio.export import SceneToSVGExporter
def test_scene_to_svg_exporter_get_user_input(view):
item1 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(0, 0))
view.scene.addItem(item1)
item2 = BeePixmapItem(
QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(200, 0))
view.scene.addItem(item2)
assert view.scene.sceneRect().size().toSize() == QtCore.QSize(300, 100)
exporter = SceneToSVGExporter(view.scene)
value = exporter.get_user_input(None)
assert value is True
assert exporter.size == QtCore.QSize(318, 118)
def test_scene_to_svg_exporter_render_pixmap_items(view):
item1 = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item1.setPos(QtCore.QPointF(20, 30))
view.scene.addItem(item1)
item2 = BeePixmapItem(
QtGui.QImage(70, 77, QtGui.QImage.Format.Format_RGB32))
item2.setPos(QtCore.QPointF(50, 50))
item2.setZValue(-1)
view.scene.addItem(item2)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert svg.tag == 'svg'
assert svg.get('width') == '200'
assert svg.get('height') == '400'
assert svg.get('xmlns') == 'http://www.w3.org/2000/svg'
assert svg.get('xmlns:xlink') == 'http://www.w3.org/1999/xlink'
assert len(svg) == 2
element = svg[0] # item2
assert element.tag == 'image'
assert element.get('xlink:href').startswith('data:image/png;base64,iVBOR')
assert element.get('width') == '70.0'
assert element.get('height') == '77.0'
assert element.get('image-rendering') == 'optimizeQuality'
assert element.get('transform') == 'rotate(0.0 35.0 25.0)'
assert element.get('x') == '35.0'
assert element.get('y') == '25.0'
assert element.get('opacity') == '1.0'
element = svg[1] # item1
assert element.tag == 'image'
assert element.get('width') == '100.0'
assert element.get('height') == '110.0'
assert element.get('image-rendering') == 'optimizeQuality'
assert element.get('transform') == 'rotate(0.0 5.0 5.0)'
assert element.get('x') == '5.0'
assert element.get('y') == '5.0'
assert element.get('opacity') == '1.0'
def test_scene_to_svg_exporter_render_pixmap_with_crop(view):
item = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item.setPos(QtCore.QPointF(20, 30))
item.crop = QtCore.QRectF(20, 25, 30, 33)
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'image'
assert element.get('width') == '30.0'
assert element.get('height') == '33.0'
assert element.get('transform') == 'rotate(0.0 -15.0 -20.0)'
assert element.get('x') == '5.0'
assert element.get('y') == '5.0'
def test_scene_to_svg_exporter_render_pixmap_with_rotation(view):
item = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item.setPos(QtCore.QPointF(20, 30))
item.setRotation(90)
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'image'
assert element.get('transform') == 'rotate(90.0 115.0 5.0)'
assert element.get('x') == '115.0'
assert element.get('y') == '5.0'
def test_scene_to_svg_exporter_render_pixmap_with_opacity(view):
item = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item.setPos(QtCore.QPointF(20, 30))
item.setOpacity(0.75)
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'image'
assert element.get('opacity') == '0.75'
def test_scene_to_svg_exporter_render_pixmap_with_flip(view):
item = BeePixmapItem(
QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32))
item.setPos(QtCore.QPointF(20, 30))
item.do_flip()
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'image'
assert element.get('transform') == (
'translate(105.0 5.0) scale(-1.0 1)'
' translate(-105.0 -5.0) rotate(0.0 105.0 5.0)')
assert element.get('x') == '105.0'
assert element.get('y') == '5.0'
def test_scene_to_svg_exporter_render_text(view):
item = BeeTextItem('foo')
item.setPos(QtCore.QPointF(20, 30))
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
svg = exporter.render_to_svg()
assert len(svg) == 1
element = svg[0]
assert element.tag == 'text'
assert element.text == 'foo'
assert element.get('dominant-baseline') == 'hanging'
assert 'font-family' in element.get('style')
assert element.get('transform') == 'rotate(0.0 5.0 5.0)'
assert element.get('x') == '5.0'
assert element.get('y') == '5.0'
def test_scene_to_svg_exporter_export_when_file_not_writeable(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
with open(filename, 'w') as f:
f.write('foo')
os.chmod(filename, stat.S_IREAD)
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
with pytest.raises(BeeFileIOError) as e:
exporter.export(filename)
assert e.filename == filename
def test_scene_to_svg_exporter_render_with_worker(view):
item = BeeTextItem('foo')
item.setPos(QtCore.QPointF(20, 30))
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
worker = MagicMock(canceled=False)
svg = exporter.render_to_svg(worker=worker)
assert len(svg) == 1
worker.progress.emit.assert_called_once_with(0)
def test_scene_to_svg_exporter_render_with_worker_canceled(view):
item = BeeTextItem('foo')
item.setPos(QtCore.QPointF(20, 30))
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(200, 400)
exporter.margin = 5
worker = MagicMock(canceled=True)
svg = exporter.render_to_svg(worker=worker)
assert svg is None
def test_scene_to_svg_exporter_export_writes_svg(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
exporter.export(filename)
with open(filename, 'rb') as f:
assert f.read().startswith(b'<?xml')
def test_scene_to_svg_exporter_export_with_worker(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=False)
exporter.export(filename, worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once_with(filename, [])
with open(filename, 'rb') as f:
assert f.read().startswith(b'<?xml')
def test_scene_to_svg_exporter_export_with_worker_canceled(view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=True)
exporter.export(filename, worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once_with(filename, [])
os.path.exists(filename) is False
def test_scene_to_svg_exporter_export_when_file_not_writeable_with_worker(
view, tmpdir):
filename = os.path.join(tmpdir, 'foo.svg')
with open(filename, 'w') as f:
f.write('foo')
os.chmod(filename, stat.S_IREAD)
item = BeeTextItem('foo')
view.scene.addItem(item)
exporter = SceneToSVGExporter(view.scene)
exporter.size = QtCore.QSize(100, 120)
worker = MagicMock(canceled=False)
exporter.export(filename, worker=worker)
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once()
args = worker.finished.emit.call_args.args
assert args[0] == filename
assert len(args[1]) == 1

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'),
@ -115,3 +122,65 @@ def test_load_image_loads_from_web_url_errors(view, imgfilename3x3):
img, filename = load_image(QtCore.QUrl(url))
assert img.isNull() is True
assert filename == url
@httpretty.activate
def test_load_image_from_pinterest_finds_image(view, imgdata3x3):
url = 'http://pinterest.com/a1b2c3/'
img_url = 'http://pinterest.com/foo.png'
httpretty.register_uri(
httpretty.GET,
url,
body=f'<html><body><img src="{img_url}"/></body></html>',
)
httpretty.register_uri(
httpretty.GET,
img_url,
body=imgdata3x3,
)
img, filename = load_image(QtCore.QUrl(url))
assert img.isNull() is False
assert filename == img_url
@httpretty.activate
def test_load_image_from_pinterest_when_already_image(view, imgdata3x3):
img_url = 'http://pinterest.com/foo.png'
httpretty.register_uri(
httpretty.GET,
img_url,
body=imgdata3x3,
)
img, filename = load_image(QtCore.QUrl(img_url))
assert img.isNull() is False
assert filename == img_url
@httpretty.activate
def test_load_image_from_pinterest_when_img_url_not_found(view, imgdata3x3):
url = 'http://pinterest.com/a1b2c3/'
img_url = 'http://pinterest.com/foo.png'
httpretty.register_uri(
httpretty.GET,
url,
body='<html><body><p>no image here</p></body></html>',
)
httpretty.register_uri(
httpretty.GET,
img_url,
body=imgdata3x3,
)
img, filename = load_image(QtCore.QUrl(url))
assert img.isNull() is True
@httpretty.activate
def test_load_image_from_pinterest_when_url_errors(view, imgdata3x3):
url = 'http://pinterest.com/a1b2c3/'
httpretty.register_uri(
httpretty.GET,
url,
status=500,
)
img, filename = load_image(QtCore.QUrl(url))
assert img.isNull() is True

View file

@ -10,7 +10,7 @@ import pytest
from beeref.fileio import schema, is_bee_file
from beeref.fileio.errors import BeeFileIOError
from beeref.fileio.sql import SQLiteIO
from beeref.items import BeePixmapItem, BeeTextItem
from beeref.items import BeePixmapItem, BeeTextItem, BeeErrorItem
@pytest.mark.parametrize('filename,expected',
@ -120,21 +120,21 @@ def test_all_migrations(tmpfile):
assert result[3] == b'bla'
def test_sqliteio_rite_meta_application_id(tmpfile):
def test_sqliteio_write_meta_application_id(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA application_id')
assert result[0] == schema.APPLICATION_ID
def test_sqliteio_rite_meta_user_version(tmpfile):
def test_sqliteio_write_meta_user_version(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA user_version')
assert result[0] == schema.USER_VERSION
def test_sqliteio_rite_meta_foreign_keys(tmpfile):
def test_sqliteio_write_meta_foreign_keys(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA foreign_keys')
@ -304,6 +304,8 @@ def test_sqliteio_write_updates_existing_text_item(tmpfile, view):
item.save_id = 1
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.write()
assert io.fetchone('SELECT COUNT(*) from items') == (1,)
item.setScale(0.7)
item.setPos(20, 30)
item.setZValue(0.33)
@ -341,6 +343,8 @@ def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view):
item.pixmap_to_bytes = MagicMock(return_value=(b'abc', 'png'))
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.write()
assert io.fetchone('SELECT COUNT(*) from items') == (1,)
item.setScale(0.7)
item.setPos(20, 30)
item.setZValue(0.33)
@ -374,6 +378,62 @@ def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view):
assert result[7] == b'abc'
def test_sqliteio_write_keeps_pixmap_item_of_error_item(tmpfile, view):
item = BeePixmapItem(QtGui.QImage(), filename='bee.png')
view.scene.addItem(item)
item.setScale(1.3)
item.setPos(44, 55)
item.setZValue(0.22)
item.setRotation(33)
item.setOpacity(0.2)
item.save_id = 1
item.crop = QtCore.QRectF(5, 5, 80, 100)
item.pixmap_to_bytes = MagicMock(return_value=(b'abc', 'png'))
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.write()
view.scene.removeItem(item)
assert io.fetchone('SELECT COUNT(*) from items') == (1,)
err_item = BeeErrorItem('errormsg')
err_item.original_save_id = 1
err_item.setScale(0.7)
err_item.setPos(20, 30)
err_item.setZValue(0.33)
err_item.setRotation(100)
view.scene.addItem(err_item)
io.create_new = False
io.write()
assert io.fetchone('SELECT COUNT(*) from items') == (1,)
result = io.fetchone(
'SELECT x, y, z, scale, rotation, flip, items.data, sqlar.data '
'FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
assert result[0] == 44
assert result[1] == 55
assert result[2] == 0.22
assert result[3] == 1.3
assert result[4] == 33
assert result[5] == 1
assert json.loads(result[6]) == {
'filename': 'bee.png',
'crop': [5, 5, 80, 100],
'opacity': 0.2,
'grayscale': False,
}
assert result[7] == b'abc'
def test_sqliteio_doesnt_write_error_item_to_new_file(tmpfile, view):
err_item = BeeErrorItem('errormsg')
err_item.original_save_id = 1
view.scene.addItem(err_item)
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.create_new = True
io.write()
assert io.fetchone('SELECT COUNT(*) from items') == (0,)
def test_sqliteio_write_removes_nonexisting_text_item(tmpfile, view):
item = BeeTextItem('foo bar')
item.setScale(1.3)
@ -412,6 +472,7 @@ def test_sqliteio_write_removes_nonexisting_pixmap_item(tmpfile, view):
def test_sqliteio_write_update_recovers_from_borked_file(view, tmpfile):
item = BeePixmapItem(QtGui.QImage(), filename='bee.png')
item.save_id = 1
view.scene.addItem(item)
with open(tmpfile, 'w') as f:
@ -423,6 +484,16 @@ def test_sqliteio_write_update_recovers_from_borked_file(view, tmpfile):
assert result[0] == 1
def test_sqliteio_write_update_recovers_from_nonexisting_file(view, tmpfile):
item = BeePixmapItem(QtGui.QImage(), filename='bee.png')
item.save_id = 1
view.scene.addItem(item)
io = SQLiteIO(tmpfile, view.scene, create_new=False)
io.write()
result = io.fetchone('SELECT COUNT(*) FROM items')
assert result[0] == 1
def test_sqliteio_write_updates_progress(tmpfile, view):
worker = MagicMock(canceled=False)
io = SQLiteIO(tmpfile, view.scene, create_new=True, worker=worker)
@ -463,6 +534,7 @@ def test_sqliteio_read_reads_readonly_text_item(tmpfile, view):
view.scene.add_queued_items()
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert isinstance(item, BeeTextItem)
assert item.isSelected() is False
assert item.save_id == 1
assert item.pos().x() == 22.2
@ -493,6 +565,7 @@ def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3):
view.scene.add_queued_items()
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert isinstance(item, BeePixmapItem)
assert item.isSelected() is False
assert item.save_id == 1
assert item.pos().x() == 22.2
@ -510,6 +583,29 @@ def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3):
assert view.scene.items_to_add.empty() is True
def test_sqliteio_read_reads_readonly_pixmap_item_error(tmpfile, view):
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.create_schema_on_new()
io.ex('INSERT INTO items '
'(type, x, y, z, scale, rotation, flip, data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?) ',
('pixmap', 22.2, 33.3, 0.22, 3.4, 45, -1,
json.dumps({'filename': 'bee.png'})))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)',
(1, b'not an image'))
io.connection.commit()
del io
io = SQLiteIO(tmpfile, view.scene, readonly=True)
io.read()
view.scene.add_queued_items()
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert isinstance(item, BeeErrorItem)
item.toPlainText().startswith('Unknown')
assert view.scene.items_to_add.empty() is True
def test_sqliteio_read_updates_progress(tmpfile, view):
worker = MagicMock(canceled=False)
io = SQLiteIO(tmpfile, view.scene, create_new=True,

View file

@ -0,0 +1,114 @@
from unittest.mock import patch, MagicMock
from PyQt6 import QtCore, QtWidgets
from beeref.items import BeeErrorItem, item_registry
def test_in_items_registry():
assert item_registry['error'] == BeeErrorItem
@patch('beeref.selection.SelectableMixin.init_selectable')
def test_init(selectable_mock, qapp):
item = BeeErrorItem('foo bar')
assert hasattr(item, 'save_id') is False
assert item.original_save_id is None
assert item.width
assert item.height
assert item.scale() == 1
assert item.toPlainText() == 'foo bar'
assert item.is_editable is False
assert item.is_image is False
selectable_mock.assert_called_once()
@patch('PyQt6.QtWidgets.QGraphicsTextItem.paint')
def test_paint(paint_mock, qapp):
item = BeeErrorItem('foo bar')
item.paint_selectable = MagicMock()
painter = MagicMock()
option = MagicMock()
item.paint(painter, option, 'widget')
item.paint_selectable.assert_called_once()
painter.drawRect.assert_called_once()
assert option.state == QtWidgets.QStyle.StateFlag.State_Enabled
paint_mock.assert_called_once_with(painter, option, 'widget')
def test_update_from_data(qapp):
item = BeeErrorItem('foo bar')
item.update_from_data(
save_id=3,
x=11,
y=22,
z=1.2,
scale=2.5,
rotation=45,
flip=-1,
data={'opactiy': 0.5})
assert item.original_save_id == 3
assert item.pos() == QtCore.QPointF(11, 22)
assert item.zValue() == 1.2
assert item.rotation() == 45
assert item.flip() == 1
assert hasattr(item, 'save_id') is False
def test_update_from_data_keeps_unset_values(qapp):
item = BeeErrorItem('foo bar')
item.setScale(3)
item.update_from_data(rotation=45)
assert item.scale() == 3
assert item.flip() == 1
def test_create_from_data(qapp):
item = BeeErrorItem.create_from_data(data={'text': 'hello world'})
item.toPlainText() == 'hello world'
assert hasattr(item, 'save_id') is False
def test_create_copy(qapp):
item = BeeErrorItem('foo bar')
item.setPos(20, 30)
item.setRotation(33)
item.setZValue(0.5)
item.setScale(2.2)
copy = item.create_copy()
assert copy.toPlainText() == 'foo bar'
assert copy.pos() == QtCore.QPointF(20, 30)
assert copy.rotation() == 33
assert copy.zValue() == 0.5
assert copy.scale() == 2.2
assert copy.flip() == 1
def test_item_to_clipboard(qapp):
clipboard = QtWidgets.QApplication.clipboard()
item = BeeErrorItem('foo bar')
item.copy_to_clipboard(clipboard)
assert clipboard.text() == 'foo bar'
def test_flip(qapp):
item = BeeErrorItem('foo bar')
item.do_flip()
assert item.flip() == 1
@patch('beeref.items.BeeErrorItem.boundingRect')
def test_contains_when_inside_bounds(brect_mock, qapp):
brect_mock.return_value = QtCore.QRectF(20, 30, 50, 50)
item = BeeErrorItem('foo bar')
item.contains(QtCore.QPointF(33, 45)) is True
brect_mock.assert_called_once_with()
@patch('beeref.items.BeeErrorItem.boundingRect')
def test_contains_when_outside_bounds(brect_mock, qapp):
brect_mock.return_value = QtCore.QRectF(20, 30, 50, 50)
item = BeeErrorItem('foo bar')
item.contains(QtCore.QPointF(19, 29)) is False
brect_mock.assert_called_once_with()

46
tests/items/test_items.py Normal file
View file

@ -0,0 +1,46 @@
from PyQt6 import QtGui
from beeref.items import sort_by_filename, BeePixmapItem, BeeTextItem
def test_sort_by_filename(view):
item1 = BeePixmapItem(QtGui.QImage())
item2 = BeePixmapItem(QtGui.QImage())
item2.filename = 'foo.png'
item2.save_id = 66
item3 = BeePixmapItem(QtGui.QImage())
item3.save_id = 33
item4 = BeePixmapItem(QtGui.QImage())
item4.filename = 'bar.png'
item4.save_id = 77
item5 = BeePixmapItem(QtGui.QImage())
item5.save_id = 22
result = sort_by_filename([item1, item2, item3, item4, item5])
assert result == [item4, item2, item5, item3, item1]
def test_sort_by_filename_when_only_by_filename(view):
item1 = BeePixmapItem(QtGui.QImage())
item1.filename = 'foo.png'
item2 = BeePixmapItem(QtGui.QImage())
item2.filename = 'bar.png'
assert sort_by_filename([item1, item2]) == [item2, item1]
def test_sort_by_filename_when_only_by_save_id(view):
item1 = BeePixmapItem(QtGui.QImage())
item1.save_id = 66
item2 = BeePixmapItem(QtGui.QImage())
item2.save_id = 33
assert sort_by_filename([item1, item2]) == [item2, item1]
def test_sort_by_filename_deals_with_text_items(view):
item1 = BeeTextItem('Foo')
item2 = BeeTextItem('Bar')
assert len(sort_by_filename([item1, item2])) == 2

View file

@ -98,6 +98,43 @@ def test_get_extra_save_data(item):
}
def test_get_filename_for_export_when_save_id_and_filename(item):
item.filename = 'foo.png'
item.save_id = 5
assert item.get_filename_for_export('jpg', 8) == '0005-foo.jpg'
def test_get_filename_for_export_when_save_id_and_no_filename(item):
item.filename = None
item.save_id = 5
assert item.get_filename_for_export('jpg', 8) == '0005.jpg'
def test_get_filename_for_export_when_no_save_id_and_filename(item):
item.filename = 'foo.png'
item.save_id = None
assert item.get_filename_for_export('jpg', 8) == '0008-foo.jpg'
def test_get_filename_for_export_when_no_save_id_and_no_filename(item):
item.filename = None
item.save_id = None
assert item.get_filename_for_export('jpg', 8) == '0008.jpg'
def test_get_filename_for_export_when_save_id_and_no_default(item):
item.filename = 'foo.png'
item.save_id = 5
assert item.get_filename_for_export('jpg') == '0005-foo.jpg'
def test_get_filename_for_export_when_no_save_id_and_no_default(item):
item.filename = 'foo.png'
item.save_id = None
with pytest.raises(AssertionError):
assert item.get_filename_for_export('jpg')
def test_get_imgformat_test_with_real_image(
qapp, imgfilename3x3, item, settings):
settings.setValue('Items/image_storage_format', 'best')
@ -846,3 +883,42 @@ def test_mouse_release_event_when_not_crop_mode(mouse_mock, qapp, item):
assert item.crop_mode is False
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
def test_sample_color_at_returns_color(qapp, view):
color = QtGui.QColor(255, 0, 0, 3)
img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32)
img.fill(color)
item = BeePixmapItem(img, 'foo.png')
view.scene.addItem(item)
assert item.sample_color_at(QtCore.QPointF(2, 2)) == color
def test_sample_color_at_returns_none_when_fully_transparent(qapp, view):
color = QtGui.QColor(255, 0, 0, 0)
img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32)
img.fill(color)
item = BeePixmapItem(img, 'foo.png')
view.scene.addItem(item)
assert item.sample_color_at(QtCore.QPointF(2, 2)) is None
def test_sample_color_in_greyscale_mode(qapp, view):
color = QtGui.QColor(255, 0, 0)
img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32)
img.fill(color)
item = BeePixmapItem(img, 'foo.png')
item.grayscale = True
view.scene.addItem(item)
gray = item.sample_color_at(QtCore.QPointF(2, 2))
print(gray.red(), gray.green(), gray.blue(), gray.alpha())
assert gray == QtGui.QColor(130, 130, 130)
def test_sample_color_at_returns_none_when_transparent(qapp, view):
color = QtGui.QColor(255, 0, 0, 0)
img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32)
img.fill(color)
item = BeePixmapItem(img, 'foo.png')
view.scene.addItem(item)
assert item.sample_color_at(QtCore.QPointF(2, 2)) is None

View file

@ -24,6 +24,12 @@ def test_init(selectable_mock, qapp):
selectable_mock.assert_called_once()
def test_sample_color_at(qapp, view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
assert item.sample_color_at(QtCore.QPointF(2.0, 2.0)) is None
def test_set_pos_center(qapp):
item = BeeTextItem('foo bar')
with patch.object(item, 'bounding_rect_unselected',
@ -196,9 +202,9 @@ def test_create_copy(qapp):
assert copy.toPlainText() == 'foo bar'
assert copy.pos() == QtCore.QPointF(20, 30)
assert copy.rotation() == 33
assert item.flip() == -1
assert item.zValue() == 0.5
assert item.scale() == 2.2
assert copy.flip() == -1
assert copy.zValue() == 0.5
assert copy.scale() == 2.2
def test_enter_edit_mode(view):

View file

@ -47,6 +47,46 @@ def test_selection_action_items(view):
assert action_items == {item1, item2, view.scene.multi_select_item}
def test_lower_behind_selection_when_selection(view):
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
item1.setSelected(True)
item2 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item2)
item2.setSelected(True)
item3 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item3)
item3.setSelected(False)
item1.setZValue(3)
item2.setZValue(4)
item3.setZValue(1)
view.scene.multi_select_item.setZValue(5)
view.scene.multi_select_item.lower_behind_selection()
assert view.scene.multi_select_item.zValue() == 2.999
def test_lower_behind_selection_when_no_selection(view):
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
item1.setSelected(False)
item2 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item2)
item2.setSelected(False)
item3 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item3)
item3.setSelected(False)
item1.setZValue(3)
item2.setZValue(4)
item3.setZValue(1)
view.scene.multi_select_item.setZValue(5)
view.scene.multi_select_item.lower_behind_selection()
assert view.scene.multi_select_item.zValue() == 5
def test_fit_selection_area():
item = MultiSelectItem()
item.setScale(5)

View file

@ -13,18 +13,7 @@ from beeref.items import BeePixmapItem
def test_init_selectable(view):
item = BeePixmapItem(QtGui.QImage())
assert item.viewport_scale == 1
assert item.scale_active is False
assert item.rotate_active is False
assert item.flip_active is False
def test_is_action_active_when_no_action(view, item):
assert item.is_action_active() is False
def test_is_action_active_when_action(view, item):
item.scale_active = True
assert item.is_action_active() is True
assert item.active_mode is None
def test_on_view_scale_change(view, item):
@ -634,11 +623,11 @@ def test_hover_move_event_no_selection(view, item):
view.scene.addItem(item)
event = MagicMock()
event.pos.return_value = QtCore.QPointF(0, 0)
item.setCursor = MagicMock()
item.set_cursor = MagicMock()
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.hoverMoveEvent(event)
item.setCursor.assert_not_called()
item.set_cursor.assert_not_called()
def test_hover_move_event_small_item_inside_handle_free_center(view, item):
@ -646,11 +635,11 @@ def test_hover_move_event_small_item_inside_handle_free_center(view, item):
item.setSelected(True)
event = MagicMock()
event.pos.return_value = QtCore.QPointF(10, 10)
item.setCursor = MagicMock()
item.unset_cursor = MagicMock()
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 20, 20)):
item.hoverMoveEvent(event)
item.setCursor.assert_called_once_with(Qt.CursorShape.ArrowCursor)
item.unset_cursor.assert_called_once_with()
@mark.parametrize('pos,flipped,rotation, expected',
@ -684,7 +673,7 @@ def test_hover_move_event_scale(
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.hoverMoveEvent(event)
assert item.cursor() == getattr(Qt.CursorShape, expected)
assert view.viewport().cursor() == getattr(Qt.CursorShape, expected)
def test_hover_move_event_scale_bottomright_very_wide_item(view, item):
@ -695,7 +684,7 @@ def test_hover_move_event_scale_bottomright_very_wide_item(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 1000, 100)):
item.hoverMoveEvent(event)
assert item.cursor() == Qt.CursorShape.SizeFDiagCursor
assert view.viewport().cursor() == Qt.CursorShape.SizeFDiagCursor
def test_hover_move_event_rotate(view, item):
@ -706,7 +695,7 @@ def test_hover_move_event_rotate(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.hoverMoveEvent(event)
assert item.cursor() == BeeAssets().cursor_rotate
assert view.viewport().cursor() == BeeAssets().cursor_rotate
def test_hover_flip_event_top_edge(view, item):
@ -717,7 +706,7 @@ def test_hover_flip_event_top_edge(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.hoverMoveEvent(event)
assert item.cursor() == BeeAssets().cursor_flip_v
assert view.viewport().cursor() == BeeAssets().cursor_flip_v
def test_hover_flip_event_bottom_edge(view, item):
@ -728,7 +717,7 @@ def test_hover_flip_event_bottom_edge(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.hoverMoveEvent(event)
assert item.cursor() == BeeAssets().cursor_flip_v
assert view.viewport().cursor() == BeeAssets().cursor_flip_v
def test_hover_flip_event_left_edge(view, item):
@ -739,7 +728,7 @@ def test_hover_flip_event_left_edge(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.hoverMoveEvent(event)
assert item.cursor() == BeeAssets().cursor_flip_h
assert view.viewport().cursor() == BeeAssets().cursor_flip_h
def test_hover_flip_event_right_edge(view, item):
@ -750,7 +739,7 @@ def test_hover_flip_event_right_edge(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.hoverMoveEvent(event)
assert item.cursor() == BeeAssets().cursor_flip_h
assert view.viewport().cursor() == BeeAssets().cursor_flip_h
def test_hover_flip_event_top_edge_rotated_90(view, item):
@ -762,7 +751,7 @@ def test_hover_flip_event_top_edge_rotated_90(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.hoverMoveEvent(event)
assert item.cursor() == BeeAssets().cursor_flip_h
assert view.viewport().cursor() == BeeAssets().cursor_flip_h
def test_hover_flip_event_left_edge_when_rotated_90(view, item):
@ -774,7 +763,7 @@ def test_hover_flip_event_left_edge_when_rotated_90(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.hoverMoveEvent(event)
assert item.cursor() == BeeAssets().cursor_flip_v
assert view.viewport().cursor() == BeeAssets().cursor_flip_v
def test_hover_move_event_not_in_handles(view, item):
@ -785,24 +774,16 @@ def test_hover_move_event_not_in_handles(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 1000, 800)):
item.hoverMoveEvent(event)
assert item.cursor() == Qt.CursorShape.ArrowCursor
assert view.viewport().cursor() == Qt.CursorShape.ArrowCursor
def test_hover_enter_event_when_selected(view, item):
def test_hover_leave_event(view, item):
view.scene.addItem(item)
event = MagicMock()
item.setSelected(True)
item.setCursor = MagicMock()
item.hoverEnterEvent(event)
item.setCursor.assert_not_called()
def test_hover_enter_event_when_not_selected(view, item):
view.scene.addItem(item)
event = MagicMock()
item.setSelected(False)
item.hoverEnterEvent(event)
assert item.cursor() == Qt.CursorShape.ArrowCursor
item.unset_cursor = MagicMock()
item.hoverLeaveEvent(event)
item.unset_cursor.assert_called_once_with()
def test_mouse_press_event_just_selected(view, item):
@ -843,7 +824,7 @@ def test_mouse_press_event_topleft_scale(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.mousePressEvent(event)
assert item.scale_active is True
assert item.active_mode == item.SCALE_MODE
assert item.event_start == QtCore.QPointF(-1, -1)
assert item.event_direction.x() < 0
assert item.event_direction.y() < 0
@ -861,7 +842,7 @@ def test_mouse_press_event_bottomright_scale(view, item):
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.mousePressEvent(event)
assert item.scale_active is True
assert item.active_mode == item.SCALE_MODE
assert item.event_start == QtCore.QPointF(101, 81)
assert item.event_direction.x() > 0
assert item.event_direction.y() > 0
@ -880,7 +861,7 @@ def test_mouse_press_event_rotate(view, item):
return_value=QtCore.QRectF(0, 0, 100, 80)):
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent'):
item.mousePressEvent(event)
assert item.rotate_active is True
assert item.active_mode == item.ROTATE_MODE
assert item.event_anchor == QtCore.QPointF(50, 40)
assert item.rotate_orig_degrees == 0
event.accept.assert_called_once_with()
@ -903,7 +884,7 @@ def test_mouse_press_event_flip(view, item):
assert cmd.items == [item]
assert cmd.anchor == QtCore.QPointF(50, 40)
assert cmd.vertical is False
assert item.flip_active is True
assert item.active_mode == item.FLIP_MODE
event.accept.assert_called_once_with()
@ -917,9 +898,7 @@ def test_mouse_press_event_not_in_handles(view, item):
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m:
item.mousePressEvent(event)
m.assert_called_once_with(event)
assert item.scale_active is False
assert item.rotate_active is False
assert item.flip_active is False
assert item.active_mode is None
event.accept.assert_not_called()
@ -953,7 +932,7 @@ def test_mouse_move_event_when_scale_action(view, item):
view.scene.addItem(item)
event = MagicMock()
event.scenePos.return_value = QtCore.QPointF(20, 90)
item.scale_active = True
item.active_mode = item.SCALE_MODE
item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2)
item.event_anchor = QtCore.QPointF(100, 80)
item.event_start = QtCore.QPointF(10, 10)
@ -973,7 +952,7 @@ def test_mouse_move_event_when_rotate_action(view, item):
event = MagicMock()
event.scenePos.return_value = QtCore.QPointF(15, 25)
item.event_start = QtCore.QPointF(10, 10)
item.rotate_active = True
item.active_mode = item.ROTATE_MODE
item.rotate_orig_degrees = 0
item.rotate_start_angle = -3
item.event_anchor = QtCore.QPointF(10, 20)
@ -989,7 +968,7 @@ def test_mouse_move_event_when_flip_action(view, item):
event = MagicMock()
event.scenePos.return_value = QtCore.QPointF(15, 25)
item.event_start = QtCore.QPointF(10, 10)
item.flip_active = True
item.active_mode = item.FLIP_MODE
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mouseMoveEvent') as m:
item.mouseMoveEvent(event)
m.assert_not_called()
@ -999,13 +978,13 @@ def test_mouse_move_event_when_flip_action(view, item):
def test_mouse_release_event_when_no_action(view, item):
view.scene.addItem(item)
event = MagicMock()
item.flip_active = True
item.active_mode = item.FLIP_MODE
event.pos.return_value = QtCore.QPointF(-100, -100)
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem'
'.mouseReleaseEvent') as m:
item.mouseReleaseEvent(event)
m.assert_called_once_with(event)
item.flip_active is False
item.active_mode is None
event.accept.assert_not_called()
@ -1013,7 +992,7 @@ def test_mouse_release_event_when_scale_action(view, item):
view.scene.addItem(item)
event = MagicMock()
event.scenePos.return_value = QtCore.QPointF(20, 90)
item.scale_active = True
item.active_mode = item.SCALE_MODE
item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2)
item.event_anchor = QtCore.QPointF(100, 80)
item.event_start = QtCore.QPointF(10, 10)
@ -1031,7 +1010,7 @@ def test_mouse_release_event_when_scale_action(view, item):
assert cmd.factor == approx(1.5, 0.01)
assert cmd.anchor == QtCore.QPointF(100, 80)
assert cmd.ignore_first_redo is True
assert item.scale_active is False
assert item.active_mode is None
event.accept.assert_called_once_with()
@ -1039,7 +1018,7 @@ def test_mouse_release_event_when_scale_action_zero(view, item):
view.scene.addItem(item)
event = MagicMock()
event.scenePos.return_value = QtCore.QPointF(20, 90)
item.scale_active = True
item.active_mode = item.SCALE_MODE
item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2)
item.event_anchor = QtCore.QPointF(100, 80)
item.event_start = QtCore.QPointF(20, 90)
@ -1050,7 +1029,7 @@ def test_mouse_release_event_when_scale_action_zero(view, item):
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.mouseReleaseEvent(event)
view.scene.undo_stack.push.assert_not_called()
assert item.scale_active is False
assert item.active_mode is None
event.accept.assert_called_once_with()
@ -1058,7 +1037,7 @@ def test_mouse_release_event_when_rotate_action(view, item):
view.scene.addItem(item)
event = MagicMock()
event.scenePos.return_value = QtCore.QPointF(15, 25)
item.rotate_active = True
item.active_mode = item.ROTATE_MODE
item.rotate_orig_degrees = 0
item.rotate_start_angle = -3
item.event_anchor = QtCore.QPointF(10, 20)
@ -1073,7 +1052,7 @@ def test_mouse_release_event_when_rotate_action(view, item):
assert cmd.delta == -42
assert cmd.anchor == QtCore.QPointF(10, 20)
assert cmd.ignore_first_redo is True
assert item.rotate_active is False
assert item.active_mode is None
event.accept.assert_called_once_with()
@ -1081,7 +1060,7 @@ def test_mouse_release_event_when_rotate_action_zero(view, item):
view.scene.addItem(item)
event = MagicMock()
event.scenePos.return_value = QtCore.QPointF(15, 25)
item.rotate_active = True
item.active_mode = item.ROTATE_MODE
item.rotate_orig_degrees = 0
item.rotate_start_angle = -45
item.event_anchor = QtCore.QPointF(10, 20)
@ -1089,7 +1068,7 @@ def test_mouse_release_event_when_rotate_action_zero(view, item):
item.mouseReleaseEvent(event)
view.scene.undo_stack.push.assert_not_called()
assert item.rotate_active is False
assert item.active_mode is None
event.accept.assert_called_once_with()
@ -1097,12 +1076,12 @@ def test_mouse_release_event_when_flip_action(view, item):
view.scene.addItem(item)
event = MagicMock()
event.pos.return_value = QtCore.QPointF(0, 40)
item.flip_active = True
item.active_mode = item.FLIP_MODE
view.scene.undo_stack = MagicMock(push=MagicMock())
with patch.object(item, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
item.mouseReleaseEvent(event)
view.scene.undo_stack.push.assert_not_called()
assert item.flip_active is False
assert item.active_mode is None
event.accept.assert_called_once_with()

View file

@ -29,11 +29,16 @@ def test_beerefapplication_fileopenevent(open_mock, qapp, main_window):
@patch('beeref.__main__.BeeRefApplication')
@patch('beeref.__main__.CommandlineArgs')
def test_main(args_mock, app_mock, qapp):
@patch('beeref.config.BeeSettings.on_startup')
def test_main(startup_mock, args_mock, app_mock, qapp):
app_mock.return_value = qapp
args_mock.return_value.filename = None
args_mock.return_value.loglevel = 'WARN'
args_mock.return_value.debug_raise_error = ''
with patch.object(qapp, 'exec') as exec_mock:
main()
args_mock.assert_called_once_with(with_check=True)
exec_mock.assert_called_once_with()
args_mock.assert_called_once_with(with_check=True)
startup_mock.assert_called()

View file

@ -1,6 +1,7 @@
import math
from unittest.mock import patch, MagicMock
import pytest
from pytest import approx
from PyQt6 import QtCore, QtGui, QtWidgets
@ -249,22 +250,37 @@ def test_normalize_size_when_no_items(view):
view.scene.cancel_crop_mode.assert_called_once_with()
@pytest.mark.parametrize('value,expected_func,expected_kwargs',
[('optimal', 'arrange_optimal', {}),
('horizontal', 'arrange', {}),
('vertical', 'arrange', {'vertical': True}),
('square', 'arrange_square', {})])
def test_arrange_default(
value, expected_func, expected_kwargs, settings, view):
settings.setValue('Items/arrange_default', value)
setattr(view.scene, expected_func, MagicMock())
view.scene.arrange_default()
getattr(view.scene, expected_func).assert_called_once_with(
**expected_kwargs)
def test_arrange_horizontal(view):
item1 = BeePixmapItem(QtGui.QImage())
item1.filename = 'foo.png'
view.scene.addItem(item1)
item1.setSelected(True)
item1.setPos(10, -100)
item1.crop = QtCore.QRectF(0, 0, 100, 80)
item2 = BeePixmapItem(QtGui.QImage())
item2.filename = 'bar.png'
view.scene.addItem(item2)
item2.setSelected(True)
item2.setPos(-10, 40)
view.scene.cancel_crop_mode = MagicMock()
item2.crop = QtCore.QRectF(0, 0, 100, 80)
with patch.object(item1, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
with patch.object(item2, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
view.scene.arrange()
view.scene.cancel_crop_mode = MagicMock()
view.scene.arrange()
assert item2.pos() == QtCore.QPointF(-50, -30)
assert item1.pos() == QtCore.QPointF(50, -30)
@ -273,21 +289,23 @@ def test_arrange_horizontal(view):
def test_arrange_horizontal_with_gap(view, settings):
settings.setValue('Items/arrange_gap', 6)
item1 = BeePixmapItem(QtGui.QImage())
item1.filename = 'foo.png'
view.scene.addItem(item1)
item1.setSelected(True)
item1.setPos(10, -100)
item1.crop = QtCore.QRectF(0, 0, 100, 80)
item2 = BeePixmapItem(QtGui.QImage())
item2.filename = 'bar.png'
view.scene.addItem(item2)
item2.setSelected(True)
item2.setPos(-10, 40)
view.scene.cancel_crop_mode = MagicMock()
item2.crop = QtCore.QRectF(0, 0, 100, 80)
with patch.object(item1, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
with patch.object(item2, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
view.scene.arrange()
view.scene.cancel_crop_mode = MagicMock()
view.scene.arrange()
assert item2.pos() == QtCore.QPointF(-50, -30)
assert item1.pos() == QtCore.QPointF(56, -30)
@ -296,47 +314,50 @@ def test_arrange_horizontal_with_gap(view, settings):
def test_arrange_vertical(view):
item1 = BeePixmapItem(QtGui.QImage())
item1.filename = 'foo.png'
view.scene.addItem(item1)
item1.setSelected(True)
item1.setPos(10, -100)
item1.crop = QtCore.QRectF(0, 0, 100, 80)
item2 = BeePixmapItem(QtGui.QImage())
item2.filename = 'bar.png'
view.scene.addItem(item2)
item2.setSelected(True)
item2.setPos(-10, 40)
view.scene.cancel_crop_mode = MagicMock()
item2.crop = QtCore.QRectF(0, 0, 100, 80)
with patch.object(item1, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
with patch.object(item2, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
view.scene.arrange(vertical=True)
view.scene.cancel_crop_mode = MagicMock()
view.scene.arrange(vertical=True)
assert item1.pos() == QtCore.QPointF(0, -70)
assert item2.pos() == QtCore.QPointF(0, 10)
view.scene.cancel_crop_mode = MagicMock()
view.scene.cancel_crop_mode.assert_called_once_with()
def test_arrange_vertical_with_gap(view, settings):
settings.setValue('Items/arrange_gap', 6)
item1 = BeePixmapItem(QtGui.QImage())
item1.filename = 'foo.png'
view.scene.addItem(item1)
item1.setSelected(True)
item1.setPos(10, -100)
item1.crop = QtCore.QRectF(0, 0, 100, 80)
item2 = BeePixmapItem(QtGui.QImage())
item2.filename = 'bar.png'
view.scene.addItem(item2)
item2.setSelected(True)
item2.setPos(-10, 40)
view.scene.cancel_crop_mode = MagicMock()
item2.crop = QtCore.QRectF(0, 0, 100, 80)
with patch.object(item1, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
with patch.object(item2, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
view.scene.arrange(vertical=True)
view.scene.cancel_crop_mode = MagicMock()
view.scene.arrange(vertical=True)
assert item1.pos() == QtCore.QPointF(0, -70)
assert item2.pos() == QtCore.QPointF(0, 16)
view.scene.cancel_crop_mode = MagicMock()
view.scene.cancel_crop_mode.assert_called_once_with()
def test_arrange_when_rotated(view):
@ -429,6 +450,85 @@ def test_arrange_optimal_when_no_items(view):
view.scene.cancel_crop_mode.assert_called_once_with()
def test_arrange_square(view):
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
item1.setSelected(True)
item1.crop = QtCore.QRectF(0, 0, 100, 80)
item2 = BeePixmapItem(QtGui.QImage())
item2.filename = 'foo.png'
item2.save_id = 66
view.scene.addItem(item2)
item2.setSelected(True)
item2.crop = QtCore.QRectF(0, 0, 80, 60)
item3 = BeePixmapItem(QtGui.QImage())
item3.save_id = 33
view.scene.addItem(item3)
item3.setSelected(True)
item3.crop = QtCore.QRectF(0, 0, 100, 80)
item4 = BeePixmapItem(QtGui.QImage())
item4.filename = 'bar.png'
item4.save_id = 77
view.scene.addItem(item4)
item4.setSelected(True)
item4.crop = QtCore.QRectF(0, 0, 100, 80)
view.scene.cancel_crop_mode = MagicMock()
view.scene.arrange_square()
assert item4.pos() == QtCore.QPointF(-50, -40)
assert item2.pos() == QtCore.QPointF(60, -30)
assert item3.pos() == QtCore.QPointF(-50, 40)
assert item1.pos() == QtCore.QPointF(50, 40)
view.scene.cancel_crop_mode.assert_called_once_with()
def test_arrange_square_with_gap(view, settings):
settings.setValue('Items/arrange_gap', 6)
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
item1.setSelected(True)
item1.crop = QtCore.QRectF(0, 0, 100, 80)
item2 = BeePixmapItem(QtGui.QImage())
item2.filename = 'foo.png'
item2.save_id = 66
view.scene.addItem(item2)
item2.setSelected(True)
item2.crop = QtCore.QRectF(0, 0, 80, 60)
item3 = BeePixmapItem(QtGui.QImage())
item3.save_id = 33
view.scene.addItem(item3)
item3.setSelected(True)
item3.crop = QtCore.QRectF(0, 0, 100, 80)
item4 = BeePixmapItem(QtGui.QImage())
item4.filename = 'bar.png'
item4.save_id = 77
view.scene.addItem(item4)
item4.setSelected(True)
item4.crop = QtCore.QRectF(0, 0, 100, 80)
view.scene.cancel_crop_mode = MagicMock()
view.scene.arrange_square()
assert item4.pos() == QtCore.QPointF(-53, -43)
assert item2.pos() == QtCore.QPointF(63, -33)
assert item3.pos() == QtCore.QPointF(-53, 43)
assert item1.pos() == QtCore.QPointF(53, 43)
view.scene.cancel_crop_mode.assert_called_once_with()
def test_arrange_square_when_no_items(view):
view.scene.cancel_crop_mode = MagicMock()
view.scene.arrange_square()
view.scene.cancel_crop_mode.assert_called_once_with()
def test_flip_items(view, item):
view.scene.addItem(item)
item.setSelected(True)
@ -495,6 +595,25 @@ def test_crop_item_when_not_image(view):
item.enter_crop_mode.assert_not_called()
def test_sample_color_at_when_pixmap_item(view):
color = QtGui.QColor(255, 0, 0, 3)
img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32)
img.fill(color)
item = BeePixmapItem(img, 'foo.png')
view.scene.addItem(item)
assert view.scene.sample_color_at(QtCore.QPointF(2, 2)) == color
def test_sample_color_at_when_text_item(view):
item = BeeTextItem('foo bar baz')
view.scene.addItem(item)
assert view.scene.sample_color_at(QtCore.QPointF(2, 2)) is None
def test_sample_color_at_when_no_item(view):
assert view.scene.sample_color_at(QtCore.QPointF(2, 2)) is None
def test_select_all_items_when_true(view):
item1 = BeeTextItem('foo')
view.scene.addItem(item1)
@ -631,8 +750,7 @@ def test_mouse_press_event_when_left_click_over_item(mouse_mock, view, item):
view.scene.mousePressEvent(event)
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.move_active is True
assert view.scene.rubberband_active is False
assert view.scene.active_mode == view.scene.MOVE_MODE
assert view.scene.event_start == QtCore.QPointF(10, 20)
@ -651,8 +769,7 @@ def test_mouse_press_event_when_left_click_over_item_in_edit_mode(
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
item.exit_edit_mode.assert_not_called()
assert view.scene.move_active is False
assert view.scene.rubberband_active is False
assert view.scene.active_mode is None
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
@ -670,8 +787,7 @@ def test_mouse_press_event_when_left_click_over_diff_item_in_edit_mode(
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
txtitem.exit_edit_mode.assert_called_once_with()
assert view.scene.move_active is True
assert view.scene.rubberband_active is False
assert view.scene.active_mode == view.scene.MOVE_MODE
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
@ -689,8 +805,7 @@ def test_mouse_press_event_when_left_click_over_no_item_in_edit_mode(
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
item.exit_edit_mode.assert_called_once_with()
assert view.scene.move_active is False
assert view.scene.rubberband_active is True
assert view.scene.active_mode == view.scene.RUBBERBAND_MODE
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
@ -707,8 +822,7 @@ def test_mouse_press_event_when_left_click_over_item_in_crop_mode(
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
view.scene.cancel_crop_mode.assert_not_called()
assert view.scene.move_active is False
assert view.scene.rubberband_active is False
assert view.scene.active_mode is None
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
@ -726,8 +840,7 @@ def test_mouse_press_event_when_left_click_over_diff_item_in_crop_mode(
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
view.scene.cancel_crop_mode.assert_called_once_with()
assert view.scene.move_active is True
assert view.scene.rubberband_active is False
assert view.scene.active_mode is view.scene.MOVE_MODE
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
@ -744,8 +857,7 @@ def test_mouse_press_event_when_left_click_over_no_item_in_crop_mode(
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
view.scene.cancel_crop_mode.assert_called_once_with()
assert view.scene.move_active is False
assert view.scene.rubberband_active is True
assert view.scene.active_mode == view.scene.RUBBERBAND_MODE
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
@ -760,8 +872,7 @@ def test_mouse_press_event_when_left_click_not_over_item(
view.scene.mousePressEvent(event)
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.move_active is False
assert view.scene.rubberband_active is True
assert view.scene.active_mode == view.scene.RUBBERBAND_MODE
assert view.scene.event_start == QtCore.QPointF(10, 20)
@ -775,15 +886,14 @@ def test_mouse_press_event_when_no_items(mouse_mock, view):
view.scene.mousePressEvent(event)
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.move_active is False
assert view.scene.rubberband_active is False
assert view.scene.active_mode is None
mouse_mock.assert_called_once_with(event)
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseDoubleClickEvent')
def test_mouse_doubleclick_event_when_over_item(mouse_mock, view, item):
event = MagicMock()
view.scene.move_active = True
view.scene.active_mode = view.scene.MOVE_MODE
view.scene.addItem(item)
item.setPos(30, 40)
item.setSelected(True)
@ -794,7 +904,7 @@ def test_mouse_doubleclick_event_when_over_item(mouse_mock, view, item):
return_value=QtCore.QRectF(0, 0, 100, 100)):
view.scene.mouseDoubleClickEvent(event)
assert view.scene.move_active is False
assert view.scene.active_mode is None
view.fit_rect.assert_called_once_with(
QtCore.QRectF(30, 40, 100, 100), toggle_item=item)
mouse_mock.assert_not_called()
@ -807,7 +917,7 @@ def test_mouse_doubleclick_event_when_over_editable_item(
item = BeeTextItem('foo bar')
item.enter_edit_mode = MagicMock()
event = MagicMock()
view.scene.move_active = True
view.scene.active_mode = view.scene.MOVE_MODE
view.scene.addItem(item)
item.setPos(30, 40)
item.setSelected(True)
@ -818,7 +928,7 @@ def test_mouse_doubleclick_event_when_over_editable_item(
return_value=QtCore.QRectF(0, 0, 100, 100)):
view.scene.mouseDoubleClickEvent(event)
assert view.scene.move_active is False
assert view.scene.active_mode is None
item.enter_edit_mode.assert_called_once_with()
double_mock.assert_not_called()
press_mock.assert_called_once_with(event)
@ -828,7 +938,7 @@ def test_mouse_doubleclick_event_when_over_editable_item(
def test_mouse_doubleclick_event_when_item_not_selected(
mouse_mock, view, item):
event = MagicMock()
view.scene.move_active = True
view.scene.active_mode = view.scene.MOVE_MODE
view.scene.addItem(item)
item.setPos(30, 40)
item.setSelected(False)
@ -839,7 +949,7 @@ def test_mouse_doubleclick_event_when_item_not_selected(
return_value=QtCore.QRectF(0, 0, 100, 100)):
view.scene.mouseDoubleClickEvent(event)
assert view.scene.move_active is False
assert view.scene.active_mode is None
view.fit_rect.assert_called_once_with(
QtCore.QRectF(30, 40, 100, 100), toggle_item=item)
mouse_mock.assert_not_called()
@ -861,7 +971,7 @@ def test_mouse_move_event_when_rubberband_new(
mouse_mock, view, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
view.scene.rubberband_active = True
view.scene.active_mode = view.scene.RUBBERBAND_MODE
view.scene.addItem = MagicMock()
view.scene.event_start = QtCore.QPointF(0, 0)
view.scene.rubberband_item.bring_to_front = MagicMock()
@ -877,7 +987,7 @@ def test_mouse_move_event_when_rubberband_new(
view.scene.rubberband_item.rect().bottomRight().x() == 10
view.scene.rubberband_item.rect().bottomRight().y() == 20
assert item.isSelected() is True
assert mouse_mock.called_once_with(event)
mouse_mock.assert_called_once_with(event)
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseMoveEvent')
@ -885,7 +995,7 @@ def test_mouse_move_event_when_rubberband_not_new(
mouse_mock, view, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
view.scene.rubberband_active = True
view.scene.active_mode = view.scene.RUBBERBAND_MODE
view.scene.event_start = QtCore.QPointF(0, 0)
view.scene.rubberband_item.bring_to_front = MagicMock()
view.scene.addItem(view.scene.rubberband_item)
@ -901,14 +1011,14 @@ def test_mouse_move_event_when_rubberband_not_new(
view.scene.rubberband_item.rect().bottomRight().x() == 10
view.scene.rubberband_item.rect().bottomRight().y() == 20
assert item.isSelected() is True
assert mouse_mock.called_once_with(event)
mouse_mock.assert_called_once_with(event)
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseMoveEvent')
def test_mouse_move_event_when_no_rubberband(mouse_mock, view, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
view.scene.rubberband_active = False
view.scene.active_mode = None
view.scene.event_start = QtCore.QPointF(0, 0)
view.scene.rubberband_item.bring_to_front = MagicMock()
view.scene.addItem = MagicMock()
@ -923,19 +1033,19 @@ def test_mouse_move_event_when_no_rubberband(mouse_mock, view, imgfilename3x3):
view.scene.rubberband_item.rect().bottomRight().x() == 0
view.scene.rubberband_item.rect().bottomRight().y() == 0
assert item.isSelected() is False
assert mouse_mock.called_once_with(event)
mouse_mock.assert_called_once_with(event)
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent')
def test_mouse_release_event_when_rubberband_active(mouse_mock, view):
event = MagicMock()
view.scene.rubberband_active = True
view.scene.active_mode = view.scene.RUBBERBAND_MODE
view.scene.addItem(view.scene.rubberband_item)
view.scene.removeItem = MagicMock()
view.scene.mouseReleaseEvent(event)
view.scene.removeItem.assert_called_once_with(view.scene.rubberband_item)
view.scene.rubberband_active is False
view.scene.active_mode is None
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent')
@ -943,7 +1053,7 @@ def test_mouse_release_event_when_move_active(mouse_mock, view, item):
view.scene.addItem(item)
item.setSelected(True)
event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20)))
view.scene.move_active = True
view.scene.active_mode = view.scene.MOVE_MODE
view.scene.event_start = QtCore.QPoint(0, 0)
view.scene.undo_stack = MagicMock(push=MagicMock())
@ -957,7 +1067,7 @@ def test_mouse_release_event_when_move_active(mouse_mock, view, item):
assert cmd.delta.x() == 10
assert cmd.delta.y() == 20
mouse_mock.assert_called_once_with(event)
assert view.scene.move_active is False
assert view.scene.active_mode is None
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent')
@ -965,13 +1075,13 @@ def test_mouse_release_event_when_move_not_active(mouse_mock, view, item):
view.scene.addItem(item)
item.setSelected(True)
event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20)))
view.scene.move_active = False
view.scene.active_mode = None
view.scene.undo_stack = MagicMock(push=MagicMock())
view.scene.mouseReleaseEvent(event)
view.scene.undo_stack.push.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.move_active is False
assert view.scene.active_mode is None
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent')
@ -979,13 +1089,13 @@ def test_mouse_release_event_when_no_selection(mouse_mock, view, item):
view.scene.addItem(item)
item.setSelected(False)
event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20)))
view.scene.move_active = True
view.scene.active_mode = view.scene.MOVE_MODE
view.scene.undo_stack = MagicMock(push=MagicMock())
view.scene.mouseReleaseEvent(event)
view.scene.undo_stack.push.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.move_active is False
assert view.scene.active_mode is None
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent')
@ -993,14 +1103,14 @@ def test_mouse_release_event_when_item_action_active(mouse_mock, view, item):
view.scene.addItem(item)
item.setSelected(True)
event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20)))
view.scene.move_active = True
item.scale_active = True
item.active_mode = item.SCALE_MODE
view.scene.active_mode = view.scene.MOVE_MODE
view.scene.undo_stack = MagicMock(push=MagicMock())
view.scene.mouseReleaseEvent(event)
view.scene.undo_stack.push.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.move_active is False
assert view.scene.active_mode is None
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent')
@ -1012,14 +1122,14 @@ def test_mouse_release_event_when_multiselect_action_active(mouse_mock, view):
view.scene.addItem(item2)
item2.setSelected(True)
event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20)))
view.scene.move_active = True
view.scene.multi_select_item.scale_active = True
view.scene.active_mode = view.scene.MOVE_MODE
view.scene.multi_select_item.active_mode = BeePixmapItem.SCALE_MODE
view.scene.undo_stack = MagicMock(push=MagicMock())
view.scene.mouseReleaseEvent(event)
view.scene.undo_stack.push.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.move_active is False
assert view.scene.active_mode is None
def test_selected_items(view):
@ -1048,6 +1158,14 @@ def test_selected_items_user_only(view):
assert item2 in selected
def test_items_by_tpe(view):
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
item2 = BeeTextItem('foo')
view.scene.addItem(item2)
assert list(view.scene.items_by_type('text')) == [item2]
def test_items_for_save(view):
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
@ -1229,8 +1347,7 @@ def test_on_selection_change_when_multi_selection_ended(view):
def test_on_change_when_multi_select_when_no_scale_no_rotate(view):
view.scene.addItem(view.scene.multi_select_item)
view.scene.multi_select_item.fit_selection_area = MagicMock()
view.scene.multi_select_item.scale_active = False
view.scene.multi_select_item.rotate_active = False
view.scene.multi_select_item.active_mode = None
view.scene.on_change(None)
view.scene.multi_select_item.fit_selection_area.assert_called_once()
@ -1238,8 +1355,7 @@ def test_on_change_when_multi_select_when_no_scale_no_rotate(view):
def test_on_change_when_multi_select_when_scale_active(view):
view.scene.addItem(view.scene.multi_select_item)
view.scene.multi_select_item.fit_selection_area = MagicMock()
view.scene.multi_select_item.scale_active = True
view.scene.multi_select_item.rotate_active = False
view.scene.multi_select_item.active_mode = BeePixmapItem.SCALE_MODE
view.scene.on_change(None)
view.scene.multi_select_item.fit_selection_area.assert_not_called()
@ -1247,16 +1363,14 @@ def test_on_change_when_multi_select_when_scale_active(view):
def test_on_change_when_multi_select_when_rotate_active(view):
view.scene.addItem(view.scene.multi_select_item)
view.scene.multi_select_item.fit_selection_area = MagicMock()
view.scene.multi_select_item.scale_active = False
view.scene.multi_select_item.rotate_active = True
view.scene.multi_select_item.active_mode = BeePixmapItem.ROTATE_MODE
view.scene.on_change(None)
view.scene.multi_select_item.fit_selection_area.assert_not_called()
def test_on_change_when_no_multi_select(view):
view.scene.multi_select_item.fit_selection_area = MagicMock()
view.scene.multi_select_item.scale_active = True
view.scene.multi_select_item.rotate_active = True
view.scene.multi_select_item.active_mode = BeePixmapItem.SCALE_MODE
view.scene.on_change(None)
view.scene.multi_select_item.fit_selection_area.assert_not_called()

View file

@ -2,6 +2,7 @@ import pytest
from PyQt6 import QtCore, QtGui
from beeref.actions.actions import Action
from beeref import utils
@ -71,3 +72,26 @@ def test_round_to(number, base, expected):
('JPEG (*.jpg *.jpeg)', 'jpg')])
def test_get_file_extension_from_format(formatstr, expected):
assert utils.get_file_extension_from_format(formatstr) == expected
@pytest.mark.parametrize('rgba,expected',
[((255, 0, 0, 255), '#ff0000'),
((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

File diff suppressed because it is too large Load diff

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,11 @@
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,
ConfirmCloseUnsavedWidget,
ImageStorageFormatWidget,
KeyboardSettingsDialog,
KeyboardShortcutsDelegate,
KeyboardShortcutsEditor,
KeyboardShortcutsModel,
KeyboardShortcutsProxy,
SettingsDialog,
)
@ -37,7 +32,7 @@ def test_image_storage_format_selects_radiobox(settings, view):
def test_image_storage_format_saves_change(settings, view):
settings.setValue('Items/image_storage_format', 'best')
widget = ImageStorageFormatWidget()
widget.buttons['jpg'].setChecked(True)
widget.set_value('jpg')
assert widget.buttons['best'].isChecked() is False
assert widget.buttons['png'].isChecked() is False
assert widget.buttons['jpg'].isChecked() is True
@ -47,7 +42,7 @@ def test_image_storage_format_saves_change(settings, view):
def test_image_storage_format_on_restore_defaults(settings, view):
widget = ImageStorageFormatWidget()
widget.buttons['jpg'].setChecked(True)
widget.set_value('jpg')
settings.setValue('Items/image_storage_format', 'best')
widget.on_restore_defaults()
assert widget.buttons['best'].isChecked() is True
@ -76,442 +71,63 @@ def test_arrange_gap_sets_title_when_edited(settings, view):
def test_arrange_gap_saves_change(settings, view):
settings.setValue('Items/arrange_gap', 6)
widget = ArrangeGapWidget()
widget.input.setValue(8)
widget.set_value(8)
assert settings.valueOrDefault('Items/arrange_gap') == 8
assert widget.title() == 'Arrange Gap: ✎'
def test_arrange_gap_on_restore_defaults(settings, view):
widget = ArrangeGapWidget()
widget.input.setValue(7)
widget.set_value(7)
settings.setValue('Items/arrange_gap', 0)
widget.on_restore_defaults()
assert widget.input.value() == 0
assert widget.title() == 'Arrange Gap:'
def test_confirm_closed_initialises_input_from_settings(settings, view):
settings.setValue('Save/confirm_close_unsaved', False)
widget = ConfirmCloseUnsavedWidget()
assert widget.input.isChecked() is False
def test_confirm_closed_sets_title_when_not_edited(settings, view):
widget = ConfirmCloseUnsavedWidget()
assert widget.title() == 'Confirm when closing an unsaved file:'
def test_confirm_closed_sets_title_when_edited(settings, view):
settings.setValue('Save/confirm_close_unsaved', False)
widget = ConfirmCloseUnsavedWidget()
assert widget.title() == 'Confirm when closing an unsaved file: ✎'
def test_confirm_closed_saves_change(settings, view):
settings.setValue('Save/confirm_close_unsaved', True)
widget = ConfirmCloseUnsavedWidget()
widget.set_value(False)
assert settings.valueOrDefault('Save/confirm_close_unsaved') is False
assert widget.title() == 'Confirm when closing an unsaved file: ✎'
def test_confirm_closed_on_restore_defaults(settings, view):
widget = ConfirmCloseUnsavedWidget()
widget.set_value(False)
settings.setValue('Save/confirm_close_unsaved', True)
widget.on_restore_defaults()
assert widget.input.isChecked() is True
assert widget.title() == 'Confirm when closing an unsaved file:'
@patch('PyQt6.QtWidgets.QMessageBox.question',
return_value=QtWidgets.QMessageBox.StandardButton.Yes)
def test_settings_dialog_on_restore_defaults(msg_mock, settings, view):
dialog = SettingsDialog(view)
settings.setValue('Items/image_storage_format', 'jpg')
settings.setValue('Items/arrange_gap', 10)
settings.setValue('Save/confirm_close_unsaved', False)
dialog.on_restore_defaults()
msg_mock.assert_called_once()
assert settings.valueOrDefault('Items/image_storage_format') == 'best'
assert settings.valueOrDefault('Items/arrange_gap') == 0
def test_keyboard_shortcuts_editor_no_conflicts(view):
a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']})
a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']})
with patch('beeref.widgets.settings.actions', ActionList([a1, a2])):
editor = KeyboardShortcutsEditor(
view,
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)))
editor.setKeySequence('Ctrl+A')
editor.on_editing_finished()
assert editor.keySequence().toString() == 'Ctrl+A'
assert editor.remove_from_other is None
def test_keyboard_shortcuts_editor_reenter_existing_shortcut(view):
a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']})
a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']})
with patch('beeref.widgets.settings.actions', ActionList([a1, a2])):
editor = KeyboardShortcutsEditor(
view,
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)))
editor.setKeySequence('Ctrl+F')
editor.on_editing_finished()
assert editor.keySequence().toString() == 'Ctrl+F'
assert editor.remove_from_other is None
@patch('PyQt6.QtWidgets.QMessageBox.question',
return_value=QtWidgets.QMessageBox.StandardButton.No)
def test_keyboard_shortcuts_editor_conflicts_choose_to_cancel(
question_mock, view):
a1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']})
a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']})
with patch('beeref.widgets.settings.actions', ActionList([a1, a2])):
editor = KeyboardShortcutsEditor(
view,
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)))
editor.setKeySequence('Ctrl+B')
editor.on_editing_finished()
assert editor.keySequence().toString() == 'Ctrl+F'
assert editor.remove_from_other is None
@patch('PyQt6.QtWidgets.QMessageBox.question',
return_value=QtWidgets.QMessageBox.StandardButton.Yes)
def test_keyboard_shortcuts_editor_conflicts_choose_to_confirm(
question_mock, view):
a1 = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']})
a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']})
with patch('beeref.widgets.settings.actions', ActionList([a1, a2])):
editor = KeyboardShortcutsEditor(
view,
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)))
editor.setKeySequence('Ctrl+B')
editor.on_editing_finished()
assert editor.keySequence().toString() == 'Ctrl+B'
assert editor.remove_from_other == a2
@patch('PyQt6.QtWidgets.QMessageBox.question',
return_value=QtWidgets.QMessageBox.StandardButton.No)
def test_keyboard_shortcuts_editor_conflicts_choose_to_cancel_when_no_shortcut(
question_mock, view):
a1 = Action({'id': 'foo', 'text': 'Foo'})
a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']})
with patch('beeref.widgets.settings.actions', ActionList([a1, a2])):
editor = KeyboardShortcutsEditor(
view,
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)))
editor.setKeySequence('Ctrl+B')
editor.on_editing_finished()
assert editor.keySequence().toString() == ''
assert a2.get_shortcuts() == ['Ctrl+B']
def test_keyboard_shortcuts_delegate_setmodeldata(view):
a1 = Action({'id': 'foo', 'text': 'Foo'})
a2 = Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']})
with patch('beeref.widgets.settings.actions', ActionList([a1, a2])):
model = KeyboardShortcutsModel()
delegate = KeyboardShortcutsDelegate()
editor = delegate.createEditor(view, None, index=model.index(0, 2))
editor.setKeySequence('Ctrl+F')
delegate.setModelData(
editor, model, index=model.index(0, 2))
assert a1.get_shortcuts() == ['Ctrl+F']
def test_keyboard_shortcuts_model_columncount():
model = KeyboardShortcutsModel()
model.columnCount(None) == 4
@patch('beeref.widgets.settings.actions',
ActionList([
Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']}),
Action({'id': 'bar', 'text': 'Bar', 'shortcuts': ['Ctrl+B']})
]))
def test_keyboard_shortcuts_model_rowcount():
model = KeyboardShortcutsModel()
model.rowCount(None) == 2
def test_keyboard_shortcuts_model_data_gets_text():
action = Action({'id': 'foo', 'text': '&Foo', 'shortcuts': ['Ctrl+F']})
action.menu_path = ['&Bar', 'Ba&z']
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=0),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.DisplayRole)
assert value == 'Bar: Baz: Foo'
def test_keyboard_shortcuts_model_data_gets_changed_when_not_changed():
action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']})
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=1),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.DisplayRole)
assert value is None
def test_keyboard_shortcuts_model_data_gets_changed_when_changed(kbsettings):
action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']})
with patch('beeref.widgets.settings.actions', ActionList([action])):
action.set_shortcuts(['Ctrl+B'])
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=1),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.DisplayRole)
assert value == ''
def test_keyboard_shortcuts_model_data_gets_shortcut():
action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']})
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.DisplayRole)
assert value == 'Ctrl+F'
def test_keyboard_shortcuts_model_data_tooltip_changed_when_not_changed():
action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']})
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=1),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.ToolTipRole)
assert value is None
def test_keyboard_shortcuts_model_data_tooltip_changed_when_changed():
action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']})
action.set_shortcuts(['Ctrl+B'])
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=1),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.ToolTipRole)
assert value == 'Changed from default'
def test_keyboard_shortcuts_model_data_tooltip_shortcut_when_not_changed():
action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']})
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.ToolTipRole)
assert value is None
def test_keyboard_shortcuts_model_data_tooltip_shortcut_not_changed_not_set():
action = Action({'id': 'foo', 'text': 'Foo'})
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=3),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.ToolTipRole)
assert value is None
def test_keyboard_shortcuts_model_data_tooltip_shortcut_when_changed():
action = Action({'id': 'foo', 'text': 'Foo', 'shortcuts': ['Ctrl+F']})
with patch('beeref.widgets.settings.actions', ActionList([action])):
action.set_shortcuts(['Ctrl+B'])
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.ToolTipRole)
assert value == 'Default: Ctrl+F'
def test_keyboard_shortcuts_model_data_tooltip_shortcut_changed_from_none():
action = Action({'id': 'foo', 'text': 'Foo'})
with patch('beeref.widgets.settings.actions', ActionList([action])):
action.set_shortcuts(['Ctrl+B'])
model = KeyboardShortcutsModel()
value = model.data(
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)),
role=QtCore.Qt.ItemDataRole.ToolTipRole)
assert value == 'Default: -'
def test_keyboard_shortcuts_model_setdata_saves():
action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']})
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
model.setData(
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)),
value=QtGui.QKeySequence('Ctrl+B'),
role=None)
assert action.get_shortcuts() == ['Ctrl+B']
def test_keyboard_shortcuts_model_setdata_saves_second_shortcut():
action = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']})
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
model.setData(
index=MagicMock(
column=MagicMock(return_value=3),
row=MagicMock(return_value=0)),
value=QtGui.QKeySequence('Ctrl+B'),
role=None)
assert action.get_shortcuts() == ['Ctrl+F', 'Ctrl+B']
def test_keyboard_shortcuts_model_setdata_saves_second_shortcut_no_first():
action = Action({'id': 'foo'})
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
model.setData(
index=MagicMock(
column=MagicMock(return_value=3),
row=MagicMock(return_value=0)),
value=QtGui.QKeySequence('Ctrl+B'),
role=None)
assert action.get_shortcuts() == ['Ctrl+B']
def test_keyboard_shortcuts_model_setdata_removes_duplicate():
action = Action({'id': 'foo', 'shortcuts': ['Ctrl+B']})
with patch('beeref.widgets.settings.actions', ActionList([action])):
model = KeyboardShortcutsModel()
model.setData(
index=MagicMock(
column=MagicMock(return_value=3),
row=MagicMock(return_value=0)),
value=QtGui.QKeySequence('Ctrl+B'),
role=None)
assert action.get_shortcuts() == ['Ctrl+B']
def test_keyboard_shortcuts_model_setdata_remove_from_other():
a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']})
a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']})
with patch('beeref.widgets.settings.actions', ActionList([a1, a2])):
model = KeyboardShortcutsModel()
model.setData(
index=MagicMock(
column=MagicMock(return_value=2),
row=MagicMock(return_value=0)),
value=QtGui.QKeySequence('Ctrl+B'),
role=None,
remove_from_other=a2)
assert a1.get_shortcuts() == ['Ctrl+B']
assert a2.get_shortcuts() == []
def test_keyboard_shortcuts_model_headerdata():
model = KeyboardShortcutsModel()
header = model.headerData(
0,
QtCore.Qt.Orientation.Horizontal,
QtCore.Qt.ItemDataRole.DisplayRole)
assert header == 'Action'
def test_flags_first_column():
model = KeyboardShortcutsModel()
flags = model.flags(model.index(0, 0))
assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled
| QtCore.Qt.ItemFlag.ItemNeverHasChildren)
def test_flags_shortcut_column():
model = KeyboardShortcutsModel()
flags = model.flags(model.index(0, 2))
assert flags == (QtCore.Qt.ItemFlag.ItemIsEnabled
| QtCore.Qt.ItemFlag.ItemNeverHasChildren
| QtCore.Qt.ItemFlag.ItemIsEditable)
@patch('beeref.widgets.settings.actions',
ActionList([Action({'id': 'bar', 'text': 'Bar'}),
Action({'id': 'foo', 'text': 'Foo'}),
Action({'id': 'baz', 'text': 'Baz'})]))
def test_keyboard_shortcuts_proxy_data_unfiltered():
proxy = KeyboardShortcutsProxy()
color1 = proxy.data(
proxy.index(0, 0), QtCore.Qt.ItemDataRole.BackgroundRole)
color2 = proxy.data(
proxy.index(1, 0), QtCore.Qt.ItemDataRole.BackgroundRole)
color3 = proxy.data(
proxy.index(2, 0), QtCore.Qt.ItemDataRole.BackgroundRole)
assert color1 == color3
assert color1 != color2
assert proxy.data(
proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar'
assert proxy.data(
proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Foo'
assert proxy.data(
proxy.index(2, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz'
@patch('beeref.widgets.settings.actions',
ActionList([Action({'id': 'bar', 'text': 'Bar'}),
Action({'id': 'foo', 'text': 'Foo'}),
Action({'id': 'baz', 'text': 'Baz'})]))
def test_keyboard_shortcuts_proxy_data_filtered():
proxy = KeyboardShortcutsProxy()
proxy.setFilterFixedString('b')
color1 = proxy.data(
proxy.index(0, 0), QtCore.Qt.ItemDataRole.BackgroundRole)
color2 = proxy.data(
proxy.index(1, 0), QtCore.Qt.ItemDataRole.BackgroundRole)
assert color1 != color2
assert proxy.data(
proxy.index(0, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Bar'
assert proxy.data(
proxy.index(1, 0), QtCore.Qt.ItemDataRole.DisplayRole) == 'Baz'
def test_keyboard_shortcuts_proxy_setdata_saves_correct_filtered_index():
a1 = Action({'id': 'bar', 'text': 'Bar'})
a2 = Action({'id': 'foo', 'text': 'Foo'})
a3 = Action({'id': 'baz', 'text': 'Baz'})
with patch('beeref.widgets.settings.actions', ActionList([a1, a2, a3])):
proxy = KeyboardShortcutsProxy()
proxy.setFilterFixedString('b')
proxy.setData(
index=proxy.index(1, 2),
value=QtGui.QKeySequence('Ctrl+B'),
role=None)
assert a1.get_shortcuts() == []
assert a2.get_shortcuts() == []
assert a3.get_shortcuts() == ['Ctrl+B']
def test_keyboard_shortcuts_proxy_setdata_remove_from_other():
a1 = Action({'id': 'foo', 'shortcuts': ['Ctrl+F']})
a2 = Action({'id': 'bar', 'shortcuts': ['Ctrl+B']})
with patch('beeref.widgets.settings.actions', ActionList([a1, a2])):
proxy = KeyboardShortcutsProxy()
proxy.setData(
index=proxy.index(0, 2),
value=QtGui.QKeySequence('Ctrl+B'),
role=None,
remove_from_other=a2)
assert a1.get_shortcuts() == ['Ctrl+B']
assert a2.get_shortcuts() == []
@patch('PyQt6.QtWidgets.QMessageBox.question',
return_value=QtWidgets.QMessageBox.StandardButton.Yes)
@patch('beeref.config.KeyboardSettings.restore_defaults')
def test_keyboard_settings_dialog_on_restore_defaults(
restore_mock, msg_mock, kbsettings, view):
dialog = KeyboardSettingsDialog(view)
dialog.on_restore_defaults()
msg_mock.assert_called_once()
restore_mock.assert_called()
assert settings.valueOrDefault('Save/confirm_close_unsaved') is True

View file

@ -1,10 +1,15 @@
from unittest.mock import patch, MagicMock
from PyQt6 import QtCore, QtWidgets, QtGui
from PyQt6.QtCore import Qt
from beeref.config import logfile_name
from beeref.widgets import (
BeeNotification,
ChangeOpacityDialog,
DebugLogDialog,
ExportImagesFileExistsDialog,
SampleColorWidget,
SceneToPixmapExporterDialog,
)
@ -94,3 +99,56 @@ def test_change_opacity_dialog_reject(view, item):
dlg.reject()
assert item.opacity() == 0.6
assert len(stack) == 0
@patch('PyQt6.QtCore.QTimer.singleShot')
def test_bee_notification(single_shot_mock, view):
widget = BeeNotification(view, 'Hello World')
assert widget.label.text() == 'Hello World'
single_shot_mock.assert_called_once_with(1000 * 3, widget.deleteLater)
def test_sample_color_widget(view):
widget = SampleColorWidget(
view, QtCore.QPoint(2, 5), QtGui.QColor(255, 0, 0))
assert widget.color == QtGui.QColor(255, 0, 0)
assert widget.geometry() == QtCore.QRect(12, 15, 50, 50)
widget.update(QtCore.QPoint(13, 15), QtGui.QColor(0, 255, 0))
assert widget.color == QtGui.QColor(0, 255, 0)
assert widget.geometry() == QtCore.QRect(23, 25, 50, 50)
def test_sample_color_widget_paint_event_when_color(view):
widget = SampleColorWidget(
view, QtCore.QPoint(2, 5), QtGui.QColor(255, 0, 0))
with patch('PyQt6.QtGui.QPainter') as painter_cls_mock:
painter_mock = MagicMock()
painter_cls_mock.return_value = painter_mock
widget.paintEvent(MagicMock())
brush = QtGui.QBrush(QtGui.QColor(255, 0, 0))
painter_mock.setBrush.assert_called_once_with(brush)
painter_mock.drawRect.assert_called_once_with(0, 0, 50, 50)
def test_sample_color_widget_paint_event_when_no_color(view):
widget = SampleColorWidget(view, QtCore.QPoint(2, 5), None)
with patch('PyQt6.QtGui.QPainter') as painter_cls_mock:
painter_mock = MagicMock()
painter_cls_mock.return_value = painter_mock
widget.paintEvent(MagicMock())
brush = QtGui.QBrush(QtGui.QColor(0, 0, 0, 0))
painter_mock.setBrush.assert_called_once_with(brush)
painter_mock.drawRect.assert_called_once_with(0, 0, 50, 50)
def test_export_images_file_exists_dialog(view):
dlg = ExportImagesFileExistsDialog(view, '/tmp/foo.png')
assert len(dlg.radio_buttons) == 4
assert dlg.get_answer() == 'skip'
def test_export_images_file_exists_dialog_get_answer(view):
dlg = ExportImagesFileExistsDialog(view, '/tmp/foo.png')
dlg.radio_buttons['overwrite'].setChecked(True)
assert dlg.get_answer() == 'overwrite'

228
tools/build_appimage.py Executable file
View file

@ -0,0 +1,228 @@
#!/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 glob
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"
"""]
runbee = f'"$APPDIR/opt/python{PYVER}/bin/python{PYVER}" -I -m beeref "$@"'
logfile = '/tmp/BeeRefAppimageLog.txt'
content.extend([
f'export LD_LIBRARY_PATH="{ld_paths}"',
f'{runbee} 2> >(tee {logfile} >&2)',
'if [ $? -ne 0 ]; then',
# Workaround for:
# https://bugreports.qt.io/browse/QTBUG-114635
# See also https://github.com/rbreu/beeref/issues/102
f' if grep -q wl_proxy_marshal_flags {logfile}; then',
' echo "Wayland version error; trying again without Wayland"',
f' QT_QPA_PLATFORM=xcb {runbee}',
'fi; fi;'])
with open('squashfs-root/AppRun', 'w') as f:
f.write('\n'.join(content))
os.chmod('squashfs-root/AppRun', 0o755)
logger.info('Copying appdata.xml...')
for f in glob.glob('squashfs-root/usr/share/metainfo/*'):
os.remove(f)
filename = 'org.beeref.BeeRef.appdata.xml'
shutil.copyfile(filename, f'squashfs-root/usr/share/metainfo/{filename}')
logger.info('Writing .desktop...')
for f in glob.glob('squashfs-root/usr/share/applications/*'):
os.remove(f)
for f in glob.glob('squashfs-root/*.desktop'):
os.remove(f)
content = f"""[Desktop Entry]
Name=BeeRef
GenericName=Image Viewer
Comment=A simple reference image viewer
Terminal=false
Exec=BeeRef-{BEEVERSION}
Type=Application
Icon=logo
MimeType=application/x-beeref;
Categories=Qt;KDE;Graphics;
X-KDE-NativeMimeType=application/x-beeref
X-KDE-ExtraNativeMimeTypes=
X-AppImage-Version={BEEVERSION}
"""
filename = 'squashfs-root/usr/share/applications/org.beeref.BeeRef.desktop'
with open(filename, 'w') as f:
f.write(content)
os.symlink('usr/share/applications/org.beeref.BeeRef.desktop',
'squashfs-root/BeeRef.desktop')
logger.info('Copying logos...')
shutil.copyfile('./beeref/assets/logo.svg', 'squashfs-root/logo.svg')
shutil.copyfile('./beeref/assets/logo.png', 'squashfs-root/.DirIcon')
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')

175
tools/find_linux_libs.py Executable file
View file

@ -0,0 +1,175 @@
#!/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'])
excludes = set(data['excludes'])
else:
logger.info(f'No file {args.jsonfile}; starting from scratch')
known_libs = []
packages = set()
excludes = set()
for lib in iter_lsofoutput(output):
links = what_links_to(lib)
if len(links) == 0:
pass
elif 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 new 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)
for ex in exclude_masterlist:
for lib in (libs + known_libs):
if lib.endswith(ex):
excludes.add(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))

245
tools/linux_libs.json Normal file
View file

@ -0,0 +1,245 @@
{
"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",
"libcanberra-gtk-module.so",
"libcanberra-gtk3.so.0",
"libcom_err.so.2",
"libdl.so.2",
"libexpat.so.1",
"libfontconfig.so.1",
"libfreetype.so.6",
"libfribidi.so.0",
"libgcc_s.so.1",
"libgdk-3.so.0",
"libgdk_pixbuf-2.0.so.0",
"libgio-2.0.so.0",
"libgpg-error.so.0",
"libgvfscommon.so",
"libgvfsdbus.so",
"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"
]
}

147
tools/linux_libs_kde.json Normal file
View file

@ -0,0 +1,147 @@
{
"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/libutil.so.1",
"/lib/x86_64-linux-gnu/libz.so.1",
"/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/libXdmcp.so.6",
"/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/libcrypto.so",
"/usr/lib/x86_64-linux-gnu/libffi.so",
"/usr/lib/x86_64-linux-gnu/libfontconfig.so.1",
"/usr/lib/x86_64-linux-gnu/libfreetype.so.6",
"/usr/lib/x86_64-linux-gnu/libgcrypt.so.20",
"/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/libgssapi_krb5.so.2",
"/usr/lib/x86_64-linux-gnu/libgthread-2.0.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/liblz4.so.1",
"/usr/lib/x86_64-linux-gnu/libmd.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/libuuid.so",
"/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": [
"libbrotli1",
"libbsd0",
"libbz2-1.0",
"libc6",
"libcom-err2",
"libdbus-1-3",
"libexpat1",
"libffi7",
"libfontconfig1",
"libfreetype6",
"libgcc-s1",
"libgcrypt20",
"libglib2.0-0",
"libglvnd0",
"libglx0",
"libgpg-error0",
"libgssapi-krb5-2",
"libk5crypto3",
"libkeyutils1",
"libkrb5-3",
"libkrb5support0",
"liblz4-1",
"liblzma5",
"libmd0",
"libpcre3",
"libpng16-16",
"libsqlite3-0",
"libssl1.1",
"libstdc++6",
"libsystemd0",
"libuuid1",
"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",
"libxdmcp6",
"libxkbcommon-x11-0",
"libxkbcommon0",
"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",
"libgcc_s.so.1",
"libgpg-error.so.0",
"libm.so.6",
"libpthread.so.0",
"libresolv.so.2",
"librt.so.1",
"libstdc++.so.6",
"libutil.so.1",
"libxcb.so.1",
"libz.so.1"
]
}

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",
"/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",
"/usr/lib/x86_64-linux-gnu/libvorbis.so.0",
"/usr/lib/x86_64-linux-gnu/libvorbisfile.so.3",
"/usr/lib/x86_64-linux-gnu/libwayland-client.so.0",
"/usr/lib/x86_64-linux-gnu/libwayland-cursor.so.0",
"/usr/lib/x86_64-linux-gnu/libwayland-egl.so.1",
"/usr/lib/x86_64-linux-gnu/libxcb-cursor.so.0",
"/usr/lib/x86_64-linux-gnu/libxcb-icccm.so.4",
"/usr/lib/x86_64-linux-gnu/libxcb-image.so.0",
"/usr/lib/x86_64-linux-gnu/libxcb-keysyms.so.1",
"/usr/lib/x86_64-linux-gnu/libxcb-randr.so.0",
"/usr/lib/x86_64-linux-gnu/libxcb-render-util.so.0",
"/usr/lib/x86_64-linux-gnu/libxcb-render.so.0",
"/usr/lib/x86_64-linux-gnu/libxcb-shape.so.0",
"/usr/lib/x86_64-linux-gnu/libxcb-shm.so.0",
"/usr/lib/x86_64-linux-gnu/libxcb-sync.so.1",
"/usr/lib/x86_64-linux-gnu/libxcb-util.so.1",
"/usr/lib/x86_64-linux-gnu/libxcb-xfixes.so.0",
"/usr/lib/x86_64-linux-gnu/libxcb-xkb.so.1",
"/usr/lib/x86_64-linux-gnu/libxcb.so.1",
"/usr/lib/x86_64-linux-gnu/libxkbcommon-x11.so.0",
"/usr/lib/x86_64-linux-gnu/libxkbcommon.so.0",
"/usr/lib/x86_64-linux-gnu/libzstd.so.1"
],
"packages": [
"gvfs",
"gvfs-libs",
"libatk-bridge2.0-0",
"libatk1.0-0",
"libatspi2.0-0",
"libblkid1",
"libbrotli1",
"libbsd0",
"libbz2-1.0",
"libc6",
"libcairo-gobject2",
"libcairo2",
"libcanberra-gtk3-0",
"libcanberra-gtk3-module",
"libcanberra0",
"libcom-err2",
"libdatrie1",
"libdbus-1-3",
"libepoxy0",
"libexpat1",
"libffi7",
"libfontconfig1",
"libfreetype6",
"libfribidi0",
"libgcc-s1",
"libgcrypt20",
"libgdk-pixbuf-2.0-0",
"libglib2.0-0",
"libglvnd0",
"libglx0",
"libgpg-error0",
"libgraphite2-3",
"libgssapi-krb5-2",
"libgtk-3-0",
"libharfbuzz0b",
"libk5crypto3",
"libkeyutils1",
"libkrb5-3",
"libkrb5support0",
"libltdl7",
"liblz4-1",
"liblzma5",
"libmd0",
"libmount1",
"libogg0",
"libpango-1.0-0",
"libpangocairo-1.0-0",
"libpangoft2-1.0-0",
"libpcre2-8-0",
"libpcre3",
"libpixman-1-0",
"libpng16-16",
"libselinux1",
"libsqlite3-0",
"libssl1.1",
"libstdc++6",
"libsystemd0",
"libtdb1",
"libthai0",
"libuuid1",
"libvorbis0a",
"libvorbisfile3",
"libwayland-client0",
"libwayland-cursor0",
"libwayland-egl1",
"libx11-6",
"libx11-xcb1",
"libxau6",
"libxcb-cursor0",
"libxcb-icccm4",
"libxcb-image0",
"libxcb-keysyms1",
"libxcb-randr0",
"libxcb-render-util0",
"libxcb-render0",
"libxcb-shape0",
"libxcb-shm0",
"libxcb-sync1",
"libxcb-util1",
"libxcb-xfixes0",
"libxcb-xkb1",
"libxcb1",
"libxcomposite1",
"libxcursor1",
"libxdamage1",
"libxdmcp6",
"libxext6",
"libxfixes3",
"libxi6",
"libxinerama1",
"libxkbcommon-x11-0",
"libxkbcommon0",
"libxrandr2",
"libxrender1",
"libzstd1",
"zlib1g"
],
"excludes": [
"ld-linux-x86-64.so.2",
"libGLX.so.0",
"libGLdispatch.so.0",
"libX11-xcb.so.1",
"libX11.so.6",
"libc.so.6",
"libcom_err.so.2",
"libdl.so.2",
"libexpat.so.1",
"libfontconfig.so.1",
"libfreetype.so.6",
"libfribidi.so.0",
"libgcc_s.so.1",
"libgpg-error.so.0",
"libharfbuzz.so.0",
"libm.so.6",
"libpthread.so.0",
"libresolv.so.2",
"librt.so.1",
"libstdc++.so.6",
"libthai.so.0",
"libutil.so.1",
"libxcb.so.1",
"libz.so.1"
]
}

View file

@ -0,0 +1,20 @@
Appimage : Explanations for including/excluding libraries
=========================================================
GTK/Gnome
---------
By default, in a Gnome session BeeRef pulls a couple of gtk/gdg libs. When opening a native Gnome/GTK file dialog, more gtk/gdl libs are loaded from the system, which might be incompatable.
https://github.com/rbreu/beeref/discussions/103
Current workaround: Exclude the following libs::
"libgvfsdbus.so"
"libcanberra-gtk-module.so"
"libgvfscommon.so"
"libcanberra-gtk3.so.0"
"libgdk-3.so.0"
"libgdk_pixbuf-2.0.so.0"
"libgio-2.0.so.0"