From 93947ac2f6c2bbb7c4518fcf3be2928730818ab5 Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Thu, 13 May 2021 18:35:26 +0200 Subject: [PATCH] Remember recently opened files --- beeref/actions/menu_structure.py | 4 + beeref/actions/mixin.py | 38 ++++++++-- beeref/config.py | 72 ++++++++++++++++-- beeref/view.py | 8 +- tests/actions/test_mixin.py | 123 ++++++++++++++++++++++++++----- tests/base.py | 23 ++++-- tests/test_config.py | 72 +++++++++++++++++- tests/test_view.py | 6 -- 8 files changed, 300 insertions(+), 46 deletions(-) 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)