diff --git a/beeref/__main__.py b/beeref/__main__.py index 2eea0fe..0a4b0cc 100755 --- a/beeref/__main__.py +++ b/beeref/__main__.py @@ -29,19 +29,16 @@ from beeref.view import BeeGraphicsView logger = logging.getLogger(__name__) -class BeeRefMainWindow(QtWidgets.QWidget): +class BeeRefMainWindow(QtWidgets.QMainWindow): def __init__(self, app): super().__init__() app.setOrganizationName(constants.APPNAME) app.setApplicationName(constants.APPNAME) self.setWindowIcon(BeeAssets().logo) - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(QtCore.QMargins(0, 0, 0, 0)) - self.setLayout(layout) self.resize(500, 300) self.view = BeeGraphicsView(app, self) - layout.addWidget(self.view) + self.setCentralWidget(self.view) self.show() def __del__(self): diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index 7049a54..53ef396 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -40,7 +40,7 @@ actions = [ }, { 'id': 'insert_images', - 'text': '&Insert Images...', + 'text': '&Images...', 'shortcuts': ['Ctrl+I'], 'callback': 'on_action_insert_images', }, @@ -218,6 +218,13 @@ actions = [ 'settings': 'View/show_scrollbars', 'callback': 'on_action_show_scrollbars', }, + { + 'id': 'show_menubar', + 'text': 'Show &Menu Bar', + 'checkable': True, + 'settings': 'View/show_menubar', + 'callback': 'on_action_show_menubar', + }, { 'id': 'fullscreen', 'text': '&Fullscreen', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 8bfd76b..b843049 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -56,9 +56,15 @@ menu_structure = [ 'fullscreen', 'always_on_top', 'show_scrollbars', + 'show_menubar', + ], + }, + { + 'menu': '&Insert', + 'items': [ + 'insert_images', ], }, - 'insert_images', { 'menu': '&Transform', 'items': [ diff --git a/beeref/actions/mixin.py b/beeref/actions/mixin.py index 4a39a71..503b7bf 100644 --- a/beeref/actions/mixin.py +++ b/beeref/actions/mixin.py @@ -31,31 +31,27 @@ class ActionsMixin: for action in self.bee_actiongroups[group]: action.setEnabled(value) - def build_menu_and_actions(self, menu=None): + def build_menu_and_actions(self): """Creates a new menu or rebuilds the given menu.""" - if not menu: - menu = QtWidgets.QMenu(self) - self.clear_actions(menu) - self._create_actions() - menu = self._create_menu(self.bee_actions, menu, menu_structure) - - self.actiongroup_set_enabled( - 'active_when_can_redo', self.undo_stack.canRedo()) - self.actiongroup_set_enabled( - 'active_when_can_undo', self.undo_stack.canUndo()) - self.actiongroup_set_enabled( - 'active_when_selection', self.scene.has_selection()) - - return menu - - 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.context_menu = QtWidgets.QMenu(self) + self.toplevel_menus = [] self.bee_actions = {} self.bee_actiongroups = defaultdict(list) + self._post_create_functions = [] + self._create_actions() + self._create_menu(self.bee_actions, self.context_menu, menu_structure) + for func, arg in self._post_create_functions: + func(arg) + del self._post_create_functions + + def update_menu_and_actions(self): + self._build_recent_files() + + def create_menubar(self): + menu_bar = QtWidgets.QMenuBar() + for menu in self.toplevel_menus: + menu_bar.addMenu(menu) + return menu_bar def _store_checkable_setting(self, key, value): self.settings.setValue(key, value) @@ -68,7 +64,7 @@ class ActionsMixin: if settings_key: val = self.settings.value(settings_key, False, type=bool) qaction.setChecked(val) - callback(val) + self._post_create_functions.append((callback, val)) qaction.toggled.connect( partial(self._store_checkable_setting, settings_key)) @@ -86,10 +82,12 @@ class ActionsMixin: self.bee_actions[action['id']] = qaction if 'group' in action: self.bee_actiongroups[action['group']].append(qaction) + qaction.setEnabled(False) def _create_menu(self, actions, menu, items): if isinstance(items, str): - items = getattr(self, items)() + getattr(self, items)(menu) + return menu for item in items: if isinstance(item, str): menu.addAction(actions[item]) @@ -97,11 +95,17 @@ class ActionsMixin: menu.addSeparator() if isinstance(item, dict): submenu = menu.addMenu(item['menu']) + if menu == self.context_menu: + self.toplevel_menus.append(submenu) self._create_menu(actions, submenu, item['items']) return menu - def _build_recent_files(self): + def _build_recent_files(self, menu=None): + if menu: + self._recent_files_submenu = menu + self._clear_recent_files() + files = config.BeeSettings().get_recent_files(existing_only=True) items = [] for i, filename in enumerate(files): @@ -111,6 +115,14 @@ class ActionsMixin: qaction.setShortcuts([f'Ctrl+{key}']) qaction.triggered.connect(partial(self.open_from_file, filename)) self.addAction(qaction) + self._recent_files_submenu.addAction(qaction) self.bee_actions[f'recent_files_{i}'] = qaction items.append(f'recent_files_{i}') - return items + + def _clear_recent_files(self): + for action in self._recent_files_submenu.actions(): + self.removeAction(action) + self._recent_files_submenu.clear() + for key in list(self.bee_actions.keys()): + if key.startswith('recent_files_'): + self.bee_actions.pop(key) diff --git a/beeref/view.py b/beeref/view.py index c230a2f..ef263ed 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -63,10 +63,10 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin): self.setScene(self.scene) # Context menu and actions + self.build_menu_and_actions() self.setContextMenuPolicy( Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.on_context_menu) - self.context_menu = self.build_menu_and_actions() self.welcome_overlay = gui.WelcomeOverlay(self) @@ -85,7 +85,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin): self.update_window_title() if value: self.settings.update_recent_files(value) - self.build_menu_and_actions(self.context_menu) + self.update_menu_and_actions() def update_window_title(self): clean = self.undo_stack.isClean() @@ -176,7 +176,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin): def on_action_always_on_top(self, checked): self.parent().setWindowFlag( - Qt.WindowFlags.WindowStaysOnTopHint, on=checked) + Qt.WindowType.WindowStaysOnTopHint, on=checked) self.parent().destroy() self.parent().create() self.parent().show() @@ -193,6 +193,12 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin): self.setVerticalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + def on_action_show_menubar(self, checked): + if checked: + self.parent().setMenuBar(self.create_menubar()) + else: + self.parent().setMenuBar(None) + def on_action_undo(self): logger.debug('Undo: %s' % self.undo_stack.undoText()) self.undo_stack.undo() diff --git a/tests/actions/test_mixin.py b/tests/actions/test_mixin.py index fa4ed67..afb9c8b 100644 --- a/tests/actions/test_mixin.py +++ b/tests/actions/test_mixin.py @@ -101,18 +101,6 @@ class ActionsMixinTestCase(BeeTestCase): assert toggle_mock.connect.call_count == 2 callback_mock.assert_called_once_with(True) - def test_create_actions_enabled_false(self): - self.actions_mock.__iter__.return_value = [{ - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'enabled': False, - }] - self.menu_mock.__iter__.return_value = ['foo'] - self.widget.build_menu_and_actions() - qaction = self.widget.actions()[0] - qaction.isEnabled() is False - def test_create_actions_with_group(self): self.actions_mock.__iter__.return_value = [{ 'id': 'foo', @@ -135,15 +123,15 @@ class ActionsMixinTestCase(BeeTestCase): }] self.menu_mock.__iter__.return_value = ['foo'] with patch('PyQt6.QtWidgets.QMenu.addAction') as add_mock: - menu = self.widget.build_menu_and_actions() - assert isinstance(menu, QtWidgets.QMenu) + self.widget.build_menu_and_actions() + assert isinstance(self.widget.context_menu, QtWidgets.QMenu) add_mock.assert_called_once_with(self.widget.bee_actions['foo']) 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.build_menu_and_actions() - assert isinstance(menu, QtWidgets.QMenu) + self.widget.build_menu_and_actions() + assert isinstance(self.widget.context_menu, QtWidgets.QMenu) sep_mock.assert_called_once_with() def test_build_menu_and_actions_with_submenu(self): @@ -158,8 +146,8 @@ 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.build_menu_and_actions() - assert isinstance(menu, QtWidgets.QMenu) + self.widget.build_menu_and_actions() + assert isinstance(self.widget.context_menu, QtWidgets.QMenu) addmenu_mock.assert_called_once_with('&Bar') add_mock.assert_called_once_with( self.widget.bee_actions['foo']) @@ -182,25 +170,9 @@ class ActionsMixinTestCase(BeeTestCase): 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 - - def test_build_menu_and_actions_enables_actiongroups(self): - self.widget.scene.has_selection.return_value = True - self.actions_mock.__iter__.return_value = [ - { - 'id': 'foo', - 'text': '&Foo', - 'callback': 'on_foo', - 'group': 'active_when_selection', - }, - ] - - self.menu_mock.__iter__.return_value = ['foo'] - self.widget.build_menu_and_actions() - qaction = self.widget.actions()[0] - assert qaction.isEnabled() is True + self.widget.actiongroup_set_enabled('g1', True) + assert self.widget.bee_actions['foo'].isEnabled() is True + assert self.widget.bee_actions['bar'].isEnabled() is False def test_build_menu_and_actions_disables_actiongroups(self): self.widget.scene.has_selection.return_value = False @@ -220,10 +192,9 @@ class ActionsMixinTestCase(BeeTestCase): @patch('beeref.config.BeeSettings.get_recent_files') @patch('PyQt6.QtGui.QAction.triggered') - def test_recent_files(self, triggered_mock, files_mock): + def test_create_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', @@ -231,7 +202,6 @@ class ActionsMixinTestCase(BeeTestCase): 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' @@ -249,46 +219,30 @@ class ActionsMixinTestCase(BeeTestCase): 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', + @patch('beeref.config.BeeSettings.get_recent_files') + @patch('PyQt6.QtGui.QAction.triggered') + def test_update_recent_files(self, triggered_mock, files_mock): + files_mock.return_value = [os.path.abspath('foo.bee')] + self.menu_mock.__iter__.return_value = [{ + 'menu': 'Open &Recent', + 'items': '_build_recent_files', }] - 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) + self.widget.build_menu_and_actions() + triggered_mock.connect.reset_mock() 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] + qaction1 = self.widget.actions()[0] + assert qaction1.text() == 'foo.bee' - 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 == {} + files_mock.return_value = [os.path.abspath('bar.bee')] + self.widget.update_menu_and_actions() + triggered_mock.connect.assert_called() + assert len(self.widget.actions()) == 1 + qaction1 = self.widget.actions()[0] + assert qaction1.text() == 'bar.bee' + + def test_create_menubar(self): + self.widget.toplevel_menus = [QtWidgets.QMenu('Foo')] + menubar = self.widget.create_menubar() + assert isinstance(menubar, QtWidgets.QMenuBar) + assert len(menubar.actions()) == 1 diff --git a/tests/test_view.py b/tests/test_view.py index 835ea5e..7b82482 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -21,7 +21,7 @@ class ViewBaseTestCase(BeeTestCase): self.config_mock = config_patcher.start() self.config_mock.filename = None self.addCleanup(config_patcher.stop) - self.parent = QtWidgets.QWidget() + self.parent = QtWidgets.QMainWindow() self.view = BeeGraphicsView(self.app, self.parent) @@ -32,11 +32,11 @@ class BeeGraphicsViewTestCase(ViewBaseTestCase): self.config_mock = config_patcher.start() self.config_mock.filename = None self.addCleanup(config_patcher.stop) - self.parent = QtWidgets.QWidget() + self.parent = QtWidgets.QMainWindow() self.view = BeeGraphicsView(self.app, self.parent) def test_inits_menu(self): - parent = QtWidgets.QWidget() + parent = QtWidgets.QMainWindow() view = BeeGraphicsView(self.app, parent) assert isinstance(view.context_menu, QtWidgets.QMenu) assert len(view.actions()) > 0 @@ -46,7 +46,7 @@ class BeeGraphicsViewTestCase(ViewBaseTestCase): @patch('beeref.view.BeeGraphicsView.open_from_file') def test_init_without_filename(self, open_file_mock): self.config_mock.filename = None - parent = QtWidgets.QWidget() + parent = QtWidgets.QMainWindow() view = BeeGraphicsView(self.app, parent) open_file_mock.assert_not_called() assert parent.windowTitle() == 'BeeRef' @@ -55,7 +55,7 @@ class BeeGraphicsViewTestCase(ViewBaseTestCase): @patch('beeref.view.BeeGraphicsView.open_from_file') def test_init_with_filename(self, open_file_mock): self.config_mock.filename = 'test.bee' - parent = QtWidgets.QWidget() + parent = QtWidgets.QMainWindow() view = BeeGraphicsView(self.app, parent) open_file_mock.assert_called_once_with('test.bee') del view @@ -373,6 +373,13 @@ class BeeGraphicsViewTestCase(ViewBaseTestCase): assert self.view.scene.items() == [] assert self.view.undo_stack.isClean() is False + def test_on_action_show_menubar(self): + self.view.toplevel_menus = [QtWidgets.QMenu('Foo')] + self.view.on_action_show_menubar(True) + assert len(self.view.parent().menuBar().actions()) == 1 + self.view.on_action_show_menubar(False) + assert self.view.parent().menuBar().actions() == [] + def test_on_action_delete_items(self): item = BeePixmapItem(QtGui.QImage()) self.view.scene.addItem(item)