diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py
index bb5cd5f..e82bc4d 100644
--- a/beeref/actions/menu_structure.py
+++ b/beeref/actions/menu_structure.py
@@ -21,6 +21,10 @@ menu_structure = [
'items': [
'new_scene',
'open',
+ {
+ 'menu': 'Open &Recent',
+ 'items': '_build_recent_files',
+ },
MENU_SEPARATOR,
'save',
'save_as',
diff --git a/beeref/actions/mixin.py b/beeref/actions/mixin.py
index 01448bc..0d55cfd 100644
--- a/beeref/actions/mixin.py
+++ b/beeref/actions/mixin.py
@@ -14,12 +14,16 @@
# along with BeeRef. If not, see .
from collections import defaultdict
+from functools import partial
+import os.path
from PyQt6 import QtGui, QtWidgets
from .actions import actions
from .menu_structure import menu_structure, MENU_SEPARATOR
+from beeref import config
+
class ActionsMixin:
@@ -27,17 +31,25 @@ class ActionsMixin:
for action in self.bee_actiongroups[group]:
action.setEnabled(value)
- def create_menu_and_actions(self):
+ def build_menu_and_actions(self, menu=None):
+ """Creates a new menu or rebuilds the given menu."""
+ if not menu:
+ menu = QtWidgets.QMenu(self)
+ self.clear_actions(menu)
self._create_actions()
- menu = QtWidgets.QMenu(self)
- menu = self._create_menu(
- self.bee_actions, QtWidgets.QMenu(self), menu_structure)
+ menu = self._create_menu(self.bee_actions, menu, menu_structure)
return menu
- def _create_actions(self):
+ def clear_actions(self, menu):
+ if hasattr(self, 'bee_actions'):
+ for action in self.bee_actions.values():
+ self.removeAction(action)
+ if menu:
+ menu.clear()
self.bee_actions = {}
self.bee_actiongroups = defaultdict(list)
+ def _create_actions(self):
for action in actions:
qaction = QtGui.QAction(action['text'], self)
if 'shortcuts' in action:
@@ -54,6 +66,8 @@ class ActionsMixin:
self.bee_actiongroups[action['group']].append(qaction)
def _create_menu(self, actions, menu, items):
+ if isinstance(items, str):
+ items = getattr(self, items)()
for item in items:
if isinstance(item, str):
menu.addAction(actions[item])
@@ -64,3 +78,17 @@ class ActionsMixin:
self._create_menu(actions, submenu, item['items'])
return menu
+
+ def _build_recent_files(self):
+ files = config.BeeSettings().get_recent_files(existing_only=True)
+ items = []
+ for i, filename in enumerate(files):
+ qaction = QtGui.QAction(os.path.basename(filename), self)
+ key = 0 if i == 9 else i + 1
+ if key < 10:
+ qaction.setShortcuts([f'Ctrl+{key}'])
+ qaction.triggered.connect(partial(self.open_from_file, filename))
+ self.addAction(qaction)
+ self.bee_actions[f'recent_files_{i}'] = qaction
+ items.append(f'recent_files_{i}')
+ return items
diff --git a/beeref/config.py b/beeref/config.py
index a07d82b..bf80051 100644
--- a/beeref/config.py
+++ b/beeref/config.py
@@ -13,16 +13,30 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see .
+"""Handling of command line args and Qt settings."""
+
import argparse
import logging
+import os.path
+
+from PyQt6 import QtCore
+
+from beeref import constants
-parser = argparse.ArgumentParser(description='BeeRef referance image viewer')
+logger = logging.getLogger(constants.APPNAME)
+
+
+parser = argparse.ArgumentParser(
+ description=f'{constants.APPNAME} referance image viewer')
parser.add_argument(
'filename',
nargs='?',
default=None,
help='Bee file to open')
+parser.add_argument(
+ '--settings-dir',
+ help='settings directory to use instead of default location')
parser.add_argument(
'-l', '--loglevel',
default='INFO',
@@ -50,15 +64,17 @@ class CommandlineArgs:
Checking for unknown arugments is configurable so that it can be
deliberately enabled from the main() function while ignored for
- other imports. This is a singleton so that arguments are only
- parsed once.
+ other imports so that unit tests won't fail.
+
+ This is a singleton so that arguments are only parsed once, unless
+ ``with_check`` is ``True``.
"""
_instance = None
def __new__(cls, *args, **kwargs):
- if not cls._instance:
- cls._instance = super().__new__(cls, *args, **kwargs)
+ if not cls._instance or kwargs.get('with_check'):
+ cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, with_check=False):
@@ -73,3 +89,49 @@ class CommandlineArgs:
return super().__getattribute__(name)
else:
return getattr(self._args, name)
+
+
+class BeeSettings(QtCore.QSettings):
+
+ def __init__(self):
+ settings_format = QtCore.QSettings.Format.IniFormat
+ settings_scope = QtCore.QSettings.Scope.UserScope
+ settings_dir = self.get_settings_dir()
+ if settings_dir:
+ QtCore.QSettings.setPath(
+ settings_format, settings_scope, settings_dir)
+ super().__init__(
+ settings_format,
+ settings_scope,
+ constants.APPNAME,
+ constants.APPNAME)
+ logger.info(f'Using settings: {self.fileName()}')
+
+ def get_settings_dir(self): # pragma: no cover
+ args = CommandlineArgs()
+ return args.settings_dir
+
+ def update_recent_files(self, filename):
+ filename = os.path.abspath(filename)
+ values = self.get_recent_files()
+ if filename in values:
+ values.remove(filename)
+ values.insert(0, filename)
+
+ self.beginWriteArray('RecentFiles')
+ for i, filename in enumerate(values[:10]):
+ self.setArrayIndex(i)
+ self.setValue('path', filename)
+ self.endArray()
+
+ def get_recent_files(self, existing_only=False):
+ values = []
+ size = self.beginReadArray('RecentFiles')
+ for i in range(size):
+ self.setArrayIndex(i)
+ values.append(self.value('path'))
+ self.endArray()
+
+ if existing_only:
+ values = [f for f in values if os.path.exists(f)]
+ return values
diff --git a/beeref/view.py b/beeref/view.py
index a42a91a..5491818 100644
--- a/beeref/view.py
+++ b/beeref/view.py
@@ -21,7 +21,7 @@ from PyQt6.QtCore import Qt
from beeref.actions import ActionsMixin
from beeref import commands
-from beeref.config import CommandlineArgs
+from beeref.config import CommandlineArgs, BeeSettings
from beeref import constants
from beeref import fileio
from beeref.gui import BeeProgressDialog, WelcomeOverlay, HelpDialog
@@ -38,6 +38,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
def __init__(self, app, parent=None):
super().__init__(parent)
self.app = app
+ self.settings = BeeSettings()
self.setBackgroundBrush(QtGui.QBrush(QtGui.QColor(60, 60, 60)))
self.setTransformationAnchor(
QtWidgets.QGraphicsView.ViewportAnchor.AnchorUnderMouse)
@@ -69,7 +70,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
self.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.on_context_menu)
- self.context_menu = self.create_menu_and_actions()
+ self.context_menu = self.build_menu_and_actions()
self.welcome_overlay = WelcomeOverlay(self)
@@ -86,6 +87,9 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
def filename(self, value):
self._filename = value
self.update_window_title()
+ if value:
+ self.settings.update_recent_files(value)
+ self.build_menu_and_actions(self.context_menu)
def update_window_title(self):
clean = self.undo_stack.isClean()
diff --git a/tests/actions/test_mixin.py b/tests/actions/test_mixin.py
index f511bdd..8af9cbf 100644
--- a/tests/actions/test_mixin.py
+++ b/tests/actions/test_mixin.py
@@ -1,3 +1,4 @@
+import os.path
from unittest.mock import patch
from PyQt6 import QtWidgets
@@ -11,6 +12,12 @@ class FooWidget(QtWidgets.QWidget, ActionsMixin):
def on_foo(self):
pass
+ def on_bar(self):
+ pass
+
+ def open_from_file(self):
+ pass
+
class ActionsMixinTestCase(BeeTestCase):
@@ -33,15 +40,16 @@ class ActionsMixinTestCase(BeeTestCase):
'callback': 'on_foo',
}]
- self.widget._create_actions()
+ self.menu_mock.__iter__.return_value = ['foo']
+ self.widget.build_menu_and_actions()
trigger_mock.connect.assert_called_once_with(self.widget.on_foo)
toggle_mock.connect.assert_not_called()
assert len(self.widget.actions()) == 1
qaction = self.widget.actions()[0]
- qaction.text() == '&Foo'
- qaction.shortcut() == 'Ctrl+F'
- qaction.isEnabled() is True
+ assert qaction.text() == '&Foo'
+ assert qaction.shortcut() == 'Ctrl+F'
+ assert qaction.isEnabled() is True
assert self.widget.bee_actions['foo'] == qaction
@patch('PyQt6.QtGui.QAction.triggered')
@@ -54,14 +62,15 @@ class ActionsMixinTestCase(BeeTestCase):
'callback': 'on_foo',
}]
- self.widget._create_actions()
+ self.menu_mock.__iter__.return_value = ['foo']
+ self.widget.build_menu_and_actions()
trigger_mock.connect.assert_not_called()
toggle_mock.connect.assert_called_once_with(self.widget.on_foo)
assert len(self.widget.actions()) == 1
qaction = self.widget.actions()[0]
- qaction.text() == '&Foo'
- qaction.isEnabled() is True
+ assert qaction.text() == '&Foo'
+ assert qaction.isEnabled() is True
assert self.widget.bee_actions['foo'] == qaction
def test_create_actions_enabled_false(self):
@@ -71,7 +80,8 @@ class ActionsMixinTestCase(BeeTestCase):
'callback': 'on_foo',
'enabled': False,
}]
- self.widget._create_actions()
+ self.menu_mock.__iter__.return_value = ['foo']
+ self.widget.build_menu_and_actions()
qaction = self.widget.actions()[0]
qaction.isEnabled() is False
@@ -82,12 +92,13 @@ class ActionsMixinTestCase(BeeTestCase):
'callback': 'on_foo',
'group': 'bar',
}]
- self.widget._create_actions()
+ self.menu_mock.__iter__.return_value = ['foo']
+ self.widget.build_menu_and_actions()
qaction = self.widget.actions()[0]
- len(self.widget.bee_actiongroups) == 1
- self.widget.bee_actiongroups['bar'] == [qaction]
+ assert len(self.widget.bee_actiongroups) == 1
+ assert self.widget.bee_actiongroups['bar'] == [qaction]
- def test_create_menu_and_actions_with_actions(self):
+ def test_build_menu_and_actions_with_actions(self):
self.actions_mock.__iter__.return_value = [{
'id': 'foo',
'text': '&Foo',
@@ -96,18 +107,18 @@ class ActionsMixinTestCase(BeeTestCase):
}]
self.menu_mock.__iter__.return_value = ['foo']
with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock:
- menu = self.widget.create_menu_and_actions()
+ menu = self.widget.build_menu_and_actions()
assert isinstance(menu, QtWidgets.QMenu)
add_mock.assert_called_once_with(self.widget.bee_actions['foo'])
- def test_create_menu_and_actions_with_separator(self):
+ def test_build_menu_and_actions_with_separator(self):
self.menu_mock.__iter__.return_value = [MENU_SEPARATOR]
with patch('PyQt6.QtWidgets.QMenu.addSeparator') as sep_mock:
- menu = self.widget.create_menu_and_actions()
+ menu = self.widget.build_menu_and_actions()
assert isinstance(menu, QtWidgets.QMenu)
sep_mock.assert_called_once_with()
- def test_create_menu_and_actions_with_submenu(self):
+ def test_build_menu_and_actions_with_submenu(self):
self.actions_mock.__iter__.return_value = [{
'id': 'foo',
'text': '&Foo',
@@ -119,7 +130,7 @@ class ActionsMixinTestCase(BeeTestCase):
with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock:
with patch('PyQt6.QtWidgets.QMenu.addMenu') as addmenu_mock:
addmenu_mock.return_value = QtWidgets.QMenu()
- menu = self.widget.create_menu_and_actions()
+ menu = self.widget.build_menu_and_actions()
assert isinstance(menu, QtWidgets.QMenu)
addmenu_mock.assert_called_once_with('&Bar')
add_mock.assert_called_once_with(
@@ -141,7 +152,83 @@ class ActionsMixinTestCase(BeeTestCase):
},
]
- self.widget._create_actions()
+ self.menu_mock.__iter__.return_value = ['foo']
+ self.widget.build_menu_and_actions()
self.widget.actiongroup_set_enabled('g1', False)
assert self.widget.bee_actions['foo'].isEnabled() is False
assert self.widget.bee_actions['bar'].isEnabled() is True
+
+ @patch('beeref.config.BeeSettings.get_recent_files')
+ @patch('PyQt6.QtGui.QAction.triggered')
+ def test_recent_files(self, triggered_mock, files_mock):
+ files_mock.return_value = [
+ os.path.abspath(f'{i}.bee') for i in range(15)]
+
+ self.menu_mock.__iter__.return_value = [{
+ 'menu': 'Open &Recent',
+ 'items': '_build_recent_files',
+ }]
+
+ self.widget.build_menu_and_actions()
+ triggered_mock.connect.assert_called()
+
+ assert len(self.widget.actions()) == 15
+ qaction1 = self.widget.actions()[0]
+ assert qaction1.text() == '0.bee'
+ assert qaction1.shortcut() == 'Ctrl+1'
+ assert qaction1.isEnabled() is True
+ assert self.widget.bee_actions['recent_files_0'] == qaction1
+ qaction10 = self.widget.actions()[9]
+ assert qaction10.text() == '9.bee'
+ assert qaction10.shortcut() == 'Ctrl+0'
+ assert qaction10.isEnabled() is True
+ assert self.widget.bee_actions['recent_files_9'] == qaction10
+ qaction15 = self.widget.actions()[-1]
+ assert qaction15.text() == '14.bee'
+ assert qaction15.shortcut() == ''
+ assert qaction15.isEnabled() is True
+ assert self.widget.bee_actions['recent_files_14'] == qaction15
+
+ def test_build_menu_and_actions_updates_given_menu(self):
+ self.actions_mock.__iter__.return_value = [{
+ 'id': 'foo',
+ 'text': '&Foo',
+ 'callback': 'on_foo',
+ 'group': 'foo',
+ }]
+ self.menu_mock.__iter__.return_value = ['foo']
+ menu = self.widget.build_menu_and_actions()
+ qaction = self.widget.actions()[0]
+ assert qaction.text() == '&Foo'
+
+ self.actions_mock.__iter__.return_value = [{
+ 'id': 'bar',
+ 'text': '&Bar',
+ 'callback': 'on_bar',
+ 'group': 'bar',
+ }]
+ self.menu_mock.__iter__.return_value = ['bar']
+ self.widget.build_menu_and_actions(menu)
+ assert len(self.widget.actions()) == 1
+ assert len(menu.actions()) == 1
+ qaction = self.widget.actions()[0]
+ assert qaction.text() == '&Bar'
+ assert self.widget.bee_actions == {'bar': qaction}
+ assert self.widget.bee_actiongroups == {'bar': [qaction]}
+
+ def test_clear_actions(self):
+ self.actions_mock.__iter__.return_value = [{
+ 'id': 'foo',
+ 'text': '&Foo',
+ 'callback': 'on_foo',
+ 'group': 'bar',
+ }]
+ self.menu_mock.__iter__.return_value = ['foo']
+ menu = self.widget.build_menu_and_actions()
+ assert menu.actions()
+ assert self.widget.actions()
+ self.widget.clear_actions(menu)
+ assert menu.actions() == []
+ assert self.widget.actions() == []
+ assert self.widget.bee_actions == {}
+ assert self.widget.bee_actiongroups == {}
diff --git a/tests/base.py b/tests/base.py
index d00d486..9599308 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -1,8 +1,11 @@
import os.path
-from unittest import TestCase
+import tempfile
+from unittest import mock, TestCase
from PyQt6 import QtWidgets
+from beeref.config import BeeSettings
+
root = os.path.dirname(__file__)
imgfilename3x3 = os.path.join(root, 'assets', 'test3x3.png')
@@ -17,16 +20,24 @@ class BeeTestCase(TestCase):
@classmethod
def setUpClass(cls):
+ cls._settings_dir = tempfile.TemporaryDirectory()
+ settings_dir_patcher = mock.patch(
+ 'beeref.config.BeeSettings.get_settings_dir',
+ return_value=cls._settings_dir.name)
+ cls._settings_dir_mock = settings_dir_patcher.start()
inst = QtWidgets.QApplication.instance()
cls.app = inst if inst else QtWidgets.QApplication([])
+ @classmethod
+ def tearDownClass(cls):
+ cls._settings_dir_mock.stop()
+ cls._settings_dir.cleanup()
+
+ def tearDown(self):
+ BeeSettings().clear()
+
def queue2list(self, queue):
qlist = []
while not queue.empty():
qlist.append(queue.get())
return qlist
-
- # @classmethod
- # def tearDownClass(cls):
- # cls.app.quit()
- # del cls.app
diff --git a/tests/test_config.py b/tests/test_config.py
index 5c5fd65..9f93546 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,6 +1,70 @@
-from beeref.config import CommandlineArgs
+import os.path
+import tempfile
+from unittest.mock import patch
+
+import pytest
+
+from beeref.config import CommandlineArgs, BeeSettings
+from .base import BeeTestCase
-def test_singleton():
- assert CommandlineArgs() is CommandlineArgs()
- assert CommandlineArgs()._args is CommandlineArgs()._args
+class CommandlineArgsTestCase(BeeTestCase):
+
+ def test_singleton(self):
+ assert CommandlineArgs() is CommandlineArgs()
+ assert CommandlineArgs()._args is CommandlineArgs()._args
+
+ @patch('beeref.config.parser.parse_args')
+ def test_with_check_forces_new_parsing(self, parse_mock):
+ args1 = CommandlineArgs()
+ args2 = CommandlineArgs(with_check=True)
+ parse_mock.assert_called_once()
+ assert args1 is not args2
+
+ def test_get(self):
+ args = CommandlineArgs()
+ assert args.loglevel == 'INFO'
+
+ def test_get_unknown(self):
+ args = CommandlineArgs()
+ with pytest.raises(AttributeError):
+ args.foo
+
+
+class BeeSettingsRecentFilesTestCase(BeeTestCase):
+
+ def setUp(self):
+ self.settings = BeeSettings()
+
+ def test_get_empty(self):
+ self.settings.get_recent_files() == []
+
+ def test_get_existing_only(self):
+ with tempfile.NamedTemporaryFile() as f:
+ self.settings.update_recent_files('foo.bee')
+ self.settings.update_recent_files(f.name)
+ self.settings.get_recent_files(existing_only=True) == [f.name]
+
+ def test_update(self):
+ self.settings.update_recent_files('foo.bee')
+ self.settings.update_recent_files('bar.bee')
+ assert self.settings.get_recent_files() == [
+ os.path.abspath('bar.bee'),
+ os.path.abspath('foo.bee')]
+
+ def test_update_existing(self):
+ self.settings.update_recent_files('foo.bee')
+ self.settings.update_recent_files('bar.bee')
+ self.settings.update_recent_files('foo.bee')
+ assert self.settings.get_recent_files() == [
+ os.path.abspath('foo.bee'),
+ os.path.abspath('bar.bee')]
+
+ def test_update_respects_max_num(self):
+ for i in range(15):
+ self.settings.update_recent_files(f'{i}.bee')
+
+ recent = self.settings.get_recent_files()
+ assert len(recent) == 10
+ assert recent[0] == os.path.abspath('14.bee')
+ assert recent[-1] == os.path.abspath('5.bee')
diff --git a/tests/test_view.py b/tests/test_view.py
index d3b62e5..e8db80f 100644
--- a/tests/test_view.py
+++ b/tests/test_view.py
@@ -23,9 +23,6 @@ class ViewBaseTestCase(BeeTestCase):
self.parent = QtWidgets.QWidget()
self.view = BeeGraphicsView(self.app, self.parent)
- def tearDown(self):
- del self.view
-
class BeeGraphicsViewTestCase(ViewBaseTestCase):
@@ -37,9 +34,6 @@ class BeeGraphicsViewTestCase(ViewBaseTestCase):
self.parent = QtWidgets.QWidget()
self.view = BeeGraphicsView(self.app, self.parent)
- def tearDown(self):
- del self.view
-
def test_inits_menu(self):
parent = QtWidgets.QWidget()
view = BeeGraphicsView(self.app, parent)