Remember recently opened files

This commit is contained in:
Rebecca Breu 2021-05-13 18:35:26 +02:00
parent 6369bb68ef
commit 93947ac2f6
8 changed files with 300 additions and 46 deletions

View file

@ -21,6 +21,10 @@ menu_structure = [
'items': [
'new_scene',
'open',
{
'menu': 'Open &Recent',
'items': '_build_recent_files',
},
MENU_SEPARATOR,
'save',
'save_as',

View file

@ -14,12 +14,16 @@
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
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

View file

@ -13,16 +13,30 @@
# 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 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

View file

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

View file

@ -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 == {}

View file

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

View file

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

View file

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