From 4d3c61f682d5db07438c0cd901098933d7ba017b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 10 Jul 2022 10:09:45 -0400 Subject: [PATCH 01/22] wip: working on app --- demo2/model_app.py | 114 ++++++++ demo2/qapplication.py | 273 ++++++++++++++++++ src/app_model/types/__init__.py | 3 +- src/app_model/types/_command_rule.py | 5 + src/app_model/types/_keybinding_rule.py | 6 +- src/app_model/types/_keys/__init__.py | 2 + .../types/_keys/_standard_bindings.py | 168 +++++++++++ 7 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 demo2/model_app.py create mode 100644 demo2/qapplication.py create mode 100644 src/app_model/types/_keys/_standard_bindings.py diff --git a/demo2/model_app.py b/demo2/model_app.py new file mode 100644 index 0000000..a255b42 --- /dev/null +++ b/demo2/model_app.py @@ -0,0 +1,114 @@ +from app_model import types +from fonticon_fa6 import FA6S + + +class MenuId: + FILE = "file" + EDIT = "edit" + HELP = "help" + + +ACTIONS = [ + types.Action( + icon=FA6S.file_circle_plus, + title="New", + shortcut=types.StandardKeyBinding.New, + statusTip="Create a new file", + menus=[{'id': MenuId.FILE, 'group': '1_loadsave'}], + callback=new_file, + ), + types.Action( + icon=FA6S.folder_open, + title="Open...", + shortcut=types.StandardKeyBinding.Open, + statusTip="Open an existing file", + menus=[{'id': MenuId.FILE, 'group': '1_loadsave'}], + callback=open_file, + ), + types.Action( + icon=FA6S.floppy_disk, + title="Save", + shortcut=types.StandardKeyBinding.Save, + statusTip="Save the document to disk", + menus=[{'id': MenuId.FILE, 'group': '1_loadsave'}], + callback=save, + ), + types.Action( + title="Save As...", + shortcut=types.StandardKeyBinding.SaveAs, + statusTip="Save the document under a new name", + menus=[{'id': MenuId.FILE, 'group': '1_loadsave'}], + callback=save_as, + ), + types.Action( + title="Exit", + shortcut="Ctrl+Q", + statusTip="Exit the application", + menus=[{'id': MenuId.FILE, 'group': '3_launchexit'}], + callback=close, + ), + types.Action( + icon=FA6S.scissors, + title="Cut", + shortcut=types.StandardKeyBinding.Cut, + statusTip="Cut the current selection's contents to the clipboard", + menus=[{'id': MenuId.EDIT}], + callback=cut, + ), + types.Action( + icon=FA6S.copy, + title="Copy", + shortcut=types.StandardKeyBinding.Copy, + statusTip="Copy the current selection's contents to the clipboard", + menus=[{'id': MenuId.EDIT}], + callback=copy, + ), + types.Action( + icon=FA6S.paste, + title="Paste", + shortcut=types.StandardKeyBinding.Paste, + statusTip="Paste the clipboard's contents into the current selection", + menus=[{'id': MenuId.EDIT}], + callback=paste, + ), + types.Action( + title="About", + statusTip="Show the application's About box", + menus=[{'id': MenuId.HELP}], + callback=about, + ), +] + +# QT specific stuff + +from qtpy.QtWidgets import QMainWindow, QTextEdit +from qtpy.QtCore import QFileInfo + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + + self._cur_file: str = "" + self._text_edit = QTextEdit() + self.setCentralWidget(self._text_edit) + self._text_edit.document().contentsChanged.connect(self.document_was_modified) + + self.set_current_file("") + + def document_was_modified(self): + self.setWindowModified(self._text_edit.document().isModified()) + + def set_current_file(self, fileName: str) -> None: + self._cur_file = fileName + self._text_edit.document().setModified(False) + self.setWindowModified(False) + + if self._cur_file: + shown_name = self.stripped_name(self._cur_file) + else: + shown_name = "untitled.txt" + + self.setWindowTitle(f"{shown_name}[*] - Application") + + def stripped_name(self, fullFileName: str): + return QFileInfo(fullFileName).fileName() \ No newline at end of file diff --git a/demo2/qapplication.py b/demo2/qapplication.py new file mode 100644 index 0000000..d41433f --- /dev/null +++ b/demo2/qapplication.py @@ -0,0 +1,273 @@ +# Copyright (C) 2013 Riverbank Computing Limited. +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from argparse import ArgumentParser, RawTextHelpFormatter +import sys +from fonticon_fa6 import FA6S + +from qtpy.QtCore import QFile, QFileInfo, QSaveFile, QTextStream, Qt +from qtpy.QtGui import QKeySequence +from qtpy.QtWidgets import ( + QApplication, + QFileDialog, + QMainWindow, + QAction, + QMessageBox, + QTextEdit, +) +from superqt import fonticon + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + + self._cur_file = "" + + self._text_edit = QTextEdit() + self.setCentralWidget(self._text_edit) + + self.create_actions() + self.create_menus() + self.create_tool_bars() + self.create_status_bar() + + self._text_edit.document().contentsChanged.connect(self.document_was_modified) + + self.set_current_file("") + + def new_file(self): + if self.maybe_save(): + self._text_edit.clear() + self.set_current_file("") + + def open(self): + if self.maybe_save(): + fileName, filtr = QFileDialog.getOpenFileName(self) + if fileName: + self.load_file(fileName) + + def save(self): + return self.save_file(self._cur_file) if self._cur_file else self.save_as() + + def save_as(self): + fileName, filtr = QFileDialog.getSaveFileName(self) + if fileName: + return self.save_file(fileName) + + return False + + def about(self): + QMessageBox.about( + self, + "About Application", + "The Application example demonstrates how to write " + "modern GUI applications using Qt, with a menu bar, " + "toolbars, and a status bar.", + ) + + def document_was_modified(self): + self.setWindowModified(self._text_edit.document().isModified()) + + def create_actions(self): + self._new_act = QAction( + fonticon.icon(FA6S.file_circle_plus), + "&New", + self, + shortcut=QKeySequence.StandardKey.New, + statusTip="Create a new file", + triggered=self.new_file, + ) + + self._open_act = QAction( + fonticon.icon(FA6S.folder_open), + "&Open...", + self, + shortcut=QKeySequence.StandardKey.Open, + statusTip="Open an existing file", + triggered=self.open, + ) + + self._save_act = QAction( + fonticon.icon(FA6S.floppy_disk), + "&Save", + self, + shortcut=QKeySequence.StandardKey.Save, + statusTip="Save the document to disk", + triggered=self.save, + ) + + self._save_as_act = QAction( + "Save &As...", + self, + shortcut=QKeySequence.StandardKey.SaveAs, + statusTip="Save the document under a new name", + triggered=self.save_as, + ) + + self._exit_act = QAction( + "E&xit", + self, + shortcut="Ctrl+Q", + statusTip="Exit the application", + triggered=self.close, + ) + + self._cut_act = QAction( + fonticon.icon(FA6S.scissors), + "Cu&t", + self, + shortcut=QKeySequence.StandardKey.Cut, + statusTip="Cut the current selection's contents to the clipboard", + triggered=self._text_edit.cut, + ) + + self._copy_act = QAction( + fonticon.icon(FA6S.copy), + "&Copy", + self, + shortcut=QKeySequence.StandardKey.Copy, + statusTip="Copy the current selection's contents to the clipboard", + triggered=self._text_edit.copy, + ) + + self._paste_act = QAction( + fonticon.icon(FA6S.paste), + "&Paste", + self, + shortcut=QKeySequence.StandardKey.Paste, + statusTip="Paste the clipboard's contents into the current selection", + triggered=self._text_edit.paste, + ) + + self._about_act = QAction( + "&About", + self, + statusTip="Show the application's About box", + triggered=self.about, + ) + + self._cut_act.setEnabled(False) + self._copy_act.setEnabled(False) + self._text_edit.copyAvailable.connect(self._cut_act.setEnabled) + self._text_edit.copyAvailable.connect(self._copy_act.setEnabled) + + def create_menus(self): + self._file_menu = self.menuBar().addMenu("&File") + self._file_menu.addAction(self._new_act) + self._file_menu.addAction(self._open_act) + self._file_menu.addAction(self._save_act) + self._file_menu.addAction(self._save_as_act) + self._file_menu.addSeparator() + self._file_menu.addAction(self._exit_act) + + self._edit_menu = self.menuBar().addMenu("&Edit") + self._edit_menu.addAction(self._cut_act) + self._edit_menu.addAction(self._copy_act) + self._edit_menu.addAction(self._paste_act) + + self.menuBar().addSeparator() + + self._help_menu = self.menuBar().addMenu("&Help") + self._help_menu.addAction(self._about_act) + + def create_tool_bars(self): + self._file_tool_bar = self.addToolBar("File") + self._file_tool_bar.addAction(self._new_act) + self._file_tool_bar.addAction(self._open_act) + self._file_tool_bar.addAction(self._save_act) + + self._edit_tool_bar = self.addToolBar("Edit") + self._edit_tool_bar.addAction(self._cut_act) + self._edit_tool_bar.addAction(self._copy_act) + self._edit_tool_bar.addAction(self._paste_act) + + def create_status_bar(self): + self.statusBar().showMessage("Ready") + + def maybe_save(self): + if self._text_edit.document().isModified(): + ret = QMessageBox.warning( + self, + "Application", + "The document has been modified.\nDo you want to save " "your changes?", + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + ) + if ret == QMessageBox.StandardButton.Save: + return self.save() + elif ret == QMessageBox.StandardButton.Cancel: + return False + return True + + def load_file(self, fileName): + file = QFile(fileName) + if not file.open(QFile.OpenModeFlag.ReadOnly | QFile.OpenModeFlag.Text): + reason = file.errorString() + QMessageBox.warning( + self, "Application", f"Cannot read file {fileName}:\n{reason}." + ) + return + + inf = QTextStream(file) + QApplication.setOverrideCursor(Qt.WaitCursor) + self._text_edit.setPlainText(inf.readAll()) + QApplication.restoreOverrideCursor() + + self.set_current_file(fileName) + self.statusBar().showMessage("File loaded", 2000) + + def save_file(self, fileName): + error = None + QApplication.setOverrideCursor(Qt.WaitCursor) + file = QSaveFile(fileName) + if file.open(QFile.OpenModeFlag.WriteOnly | QFile.OpenModeFlag.Text): + outf = QTextStream(file) + outf << self._text_edit.toPlainText() + if not file.commit(): + reason = file.errorString() + error = f"Cannot write file {fileName}:\n{reason}." + else: + reason = file.errorString() + error = f"Cannot open file {fileName}:\n{reason}." + QApplication.restoreOverrideCursor() + + if error: + QMessageBox.warning(self, "Application", error) + return False + + self.set_current_file(fileName) + self.statusBar().showMessage("File saved", 2000) + return True + + def set_current_file(self, fileName: str): + self._cur_file = fileName + self._text_edit.document().setModified(False) + self.setWindowModified(False) + + if self._cur_file: + shown_name = self.stripped_name(self._cur_file) + else: + shown_name = "untitled.txt" + + self.setWindowTitle(f"{shown_name}[*] - Application") + + def stripped_name(self, fullFileName: str): + return QFileInfo(fullFileName).fileName() + + +if __name__ == "__main__": + argument_parser = ArgumentParser( + description="Application Example", formatter_class=RawTextHelpFormatter + ) + argument_parser.add_argument("file", help="File", nargs="?", type=str) + options = argument_parser.parse_args() + + app = QApplication(sys.argv) + main_win = MainWindow() + if options.file: + main_win.load_file(options.file) + main_win.show() + sys.exit(app.exec()) diff --git a/src/app_model/types/__init__.py b/src/app_model/types/__init__.py index 08a1e20..3ffa88e 100644 --- a/src/app_model/types/__init__.py +++ b/src/app_model/types/__init__.py @@ -3,7 +3,7 @@ from ._command_rule import CommandRule from ._icon import Icon, IconOrDict from ._keybinding_rule import KeyBindingRule, KeyBindingRuleDict, KeyBindingRuleOrDict -from ._keys import KeyBinding, KeyChord, KeyCode, KeyMod, SimpleKeyBinding +from ._keys import KeyBinding, KeyChord, KeyCode, KeyMod, SimpleKeyBinding, StandardKeyBinding from ._menu_rule import ( MenuItem, MenuOrSubmenu, @@ -33,4 +33,5 @@ "ScanCode", "SimpleKeyBinding", "SubmenuItem", + "StandardKeyBinding", ] diff --git a/src/app_model/types/_command_rule.py b/src/app_model/types/_command_rule.py index 4e7011c..b319b69 100644 --- a/src/app_model/types/_command_rule.py +++ b/src/app_model/types/_command_rule.py @@ -30,6 +30,11 @@ class CommandRule(_StrictModel): tooltip: Optional[str] = Field( None, description="(Optional) Tooltip to show when hovered." ) + status_tip: Optional[str] = Field( + None, + description="(Optional) Help message to show in the status bar when a " + "button representing this command is hovered (For backends that support it).", + ) icon: Optional[Icon] = Field( None, description="(Optional) Icon used to represent this command, e.g. on buttons " diff --git a/src/app_model/types/_keybinding_rule.py b/src/app_model/types/_keybinding_rule.py index abe4303..da3a1e1 100644 --- a/src/app_model/types/_keybinding_rule.py +++ b/src/app_model/types/_keybinding_rule.py @@ -27,12 +27,12 @@ class KeyBindingRule(_StrictModel): win: Optional[KeyEncoding] = Field( None, description="(Optional) Windows specific key combo." ) - linux: Optional[KeyEncoding] = Field( - None, description="(Optional) Linux specific key combo." - ) mac: Optional[KeyEncoding] = Field( None, description="(Optional) MacOS specific key combo." ) + linux: Optional[KeyEncoding] = Field( + None, description="(Optional) Linux specific key combo." + ) when: Optional[expressions.Expr] = Field( None, description="(Optional) Condition when the keybingding is active.", diff --git a/src/app_model/types/_keys/__init__.py b/src/app_model/types/_keys/__init__.py index 9572b1c..a0ab624 100644 --- a/src/app_model/types/_keys/__init__.py +++ b/src/app_model/types/_keys/__init__.py @@ -1,5 +1,6 @@ from ._key_codes import KeyChord, KeyCode, KeyCombo, KeyMod, ScanCode from ._keybindings import KeyBinding, SimpleKeyBinding +from ._standard_bindings import StandardKeyBinding __all__ = [ "KeyBinding", @@ -8,5 +9,6 @@ "KeyCombo", "KeyMod", "ScanCode", + "StandardKeyBinding", "SimpleKeyBinding", ] diff --git a/src/app_model/types/_keys/_standard_bindings.py b/src/app_model/types/_keys/_standard_bindings.py new file mode 100644 index 0000000..e83bdc8 --- /dev/null +++ b/src/app_model/types/_keys/_standard_bindings.py @@ -0,0 +1,168 @@ +from enum import Enum, auto +from collections import namedtuple +from typing import TYPE_CHECKING, Dict + +if TYPE_CHECKING: + from .._keybinding_rule import KeyBindingRule + + +class StandardKeyBinding(Enum): + AddTab = auto() + Back = auto() + Backspace = auto() + Bold = auto() + Cancel = auto() + Close = auto() + Copy = auto() + Cut = auto() + Delete = auto() + DeleteCompleteLine = auto() + DeleteEndOfLine = auto() + DeleteEndOfWord = auto() + DeleteStartOfWord = auto() + Deselect = auto() + Find = auto() + FindNext = auto() + FindPrevious = auto() + Forward = auto() + FullScreen = auto() + HelpContents = auto() + InsertLineSeparator = auto() + InsertParagraphSeparator = auto() + Italic = auto() + MoveToEndOfBlock = auto() + MoveToEndOfDocument = auto() + MoveToEndOfLine = auto() + MoveToNextChar = auto() + MoveToNextLine = auto() + MoveToNextPage = auto() + MoveToNextWord = auto() + MoveToPreviousChar = auto() + MoveToPreviousLine = auto() + MoveToPreviousPage = auto() + MoveToPreviousWord = auto() + MoveToStartOfBlock = auto() + MoveToStartOfDocument = auto() + MoveToStartOfLine = auto() + New = auto() + NextChild = auto() + Open = auto() + Paste = auto() + Preferences = auto() + PreviousChild = auto() + Print = auto() + Quit = auto() + Redo = auto() + Refresh = auto() + Replace = auto() + Save = auto() + SaveAs = auto() + SelectAll = auto() + SelectEndOfBlock = auto() + SelectEndOfDocument = auto() + SelectEndOfLine = auto() + SelectNextChar = auto() + SelectNextLine = auto() + SelectNextPage = auto() + SelectNextWord = auto() + SelectPreviousChar = auto() + SelectPreviousLine = auto() + SelectPreviousPage = auto() + SelectPreviousWord = auto() + SelectStartOfBlock = auto() + SelectStartOfDocument = auto() + SelectStartOfLine = auto() + Underline = auto() + Undo = auto() + WhatsThis = auto() + ZoomIn = auto() + ZoomOut = auto() + + def to_keybinding_rule(self) -> "KeyBindingRule": + """Return KeyBindingRule for this StandardKeyBinding.""" + from .._keybinding_rule import KeyBindingRule + + return KeyBindingRule(**_STANDARD_KEY_MAP[self]) + + +_ = "" +SK = namedtuple("SK", "sk, win, mac, gnome") + +# fmt: off +_STANDARD_KEYS = [ + SK(StandardKeyBinding.AddTab, "Ctrl+T", "Ctrl+T", "Ctrl+T"), + SK(StandardKeyBinding.Back, "Alt+Left, Backspace", "Ctrl+[", "Alt+Left"), + SK(StandardKeyBinding.Backspace, _, "Meta+H", _), + SK(StandardKeyBinding.Bold, "Ctrl+B", "Ctrl+B", "Ctrl+B"), + SK(StandardKeyBinding.Cancel, "Escape", "Escape, Ctrl+.", "Escape"), + SK(StandardKeyBinding.Close, "Ctrl+F4, Ctrl+W", "Ctrl+W, Ctrl+F4", "Ctrl+W"), + SK(StandardKeyBinding.Copy, "Ctrl+C, Ctrl+Ins", "Ctrl+C", "Ctrl+C, F16, Ctrl+Ins"), + SK(StandardKeyBinding.Cut, "Ctrl+X Shift+Del", "Ctrl+X Meta+K", "Ctrl+X F20 Shift+Del"), + SK(StandardKeyBinding.Delete, "Del", "Del, Meta+D", "Del, Ctrl+D"), + SK(StandardKeyBinding.DeleteCompleteLine, _, _, "Ctrl+U"), + SK(StandardKeyBinding.DeleteEndOfLine, _, _, "Ctrl+K"), + SK(StandardKeyBinding.DeleteEndOfWord, "Ctrl+Del", _, "Ctrl+Del"), + SK(StandardKeyBinding.DeleteStartOfWord, "Ctrl+Backspace", "Alt+Backspace", "Ctrl+Backspace"), + SK(StandardKeyBinding.Deselect, _, _, "Ctrl+Shift+A"), + SK(StandardKeyBinding.Find, "Ctrl+F", "Ctrl+F", "Ctrl+F"), + SK(StandardKeyBinding.FindNext, "F3, Ctrl+G", "Ctrl+G", "Ctrl+G, F3"), + SK(StandardKeyBinding.FindPrevious, "Shift+F3 Ctrl+Shift+G", "Ctrl+Shift+G", "Ctrl+Shift+G Shift+F3"), + SK(StandardKeyBinding.Forward, "Alt+Right, Shift+Backspace", "Ctrl+]", "Alt+Right"), + SK(StandardKeyBinding.FullScreen, "F11, Alt+Enter", "Ctrl+Meta+F", "Ctrl+F11"), + SK(StandardKeyBinding.HelpContents, "F1", "Ctrl+?", "F1"), + SK(StandardKeyBinding.InsertLineSeparator, "Shift+Enter", "Meta+Enter Meta+O", "Shift+Enter"), + SK(StandardKeyBinding.InsertParagraphSeparator, "Enter", "Enter", "Enter"), + SK(StandardKeyBinding.Italic, "Ctrl+I", "Ctrl+I", "Ctrl+I"), + SK(StandardKeyBinding.MoveToEndOfBlock, _, "Alt+Down, Meta+E", _), + SK(StandardKeyBinding.MoveToEndOfDocument, "Ctrl+End", "Ctrl+Down, End", "Ctrl+End"), + SK(StandardKeyBinding.MoveToEndOfLine, "End", "Ctrl+Right Meta+Right", "End Ctrl+E"), + SK(StandardKeyBinding.MoveToNextChar, "Right", "Right, Meta+F", "Right"), + SK(StandardKeyBinding.MoveToNextLine, "Down", "Down, Meta+N", "Down"), + SK(StandardKeyBinding.MoveToNextPage, "PgDown", "PgDown Alt+PgDown Meta+Down Meta+PgDown Meta+V", "PgDown"), + SK(StandardKeyBinding.MoveToNextWord, "Ctrl+Right", "Alt+Right", "Ctrl+Right"), + SK(StandardKeyBinding.MoveToPreviousChar, "Left", "Left, Meta+B", "Left"), + SK(StandardKeyBinding.MoveToPreviousLine, "Up", "Up, Meta+P", "Up"), + SK(StandardKeyBinding.MoveToPreviousPage, "PgUp", "PgUp Alt+PgUp Meta+Up Meta+PgUp", "PgUp"), + SK(StandardKeyBinding.MoveToPreviousWord, "Ctrl+Left", "Alt+Left", "Ctrl+Left"), + SK(StandardKeyBinding.MoveToStartOfBlock, _, "Alt+Up, Meta+A", _), + SK(StandardKeyBinding.MoveToStartOfDocument, "Ctrl+Home", "Ctrl+Up Home", "Ctrl+Home"), + SK(StandardKeyBinding.MoveToStartOfLine, "Home", "Ctrl+Left, Meta+Left", "Home"), + SK(StandardKeyBinding.New, "Ctrl+N", "Ctrl+N", "Ctrl+N"), + SK(StandardKeyBinding.NextChild, "Ctrl+Tab Forward Ctrl+F6", "Ctrl+} Forward Ctrl+Tab", "Ctrl+Tab Forward"), + SK(StandardKeyBinding.Open, "Ctrl+O", "Ctrl+O", "Ctrl+O"), + SK(StandardKeyBinding.Paste, "Ctrl+V Shift+Ins", "Ctrl+V Meta+Y", "Ctrl+V F18 Shift+Ins"), + SK(StandardKeyBinding.Preferences, _, "Ctrl+,", _), + SK(StandardKeyBinding.PreviousChild, "Ctrl+Shift+Tab Back Ctrl+Shift+F6", "Ctrl+{ Back Ctrl+Shift+Tab", "Ctrl+Shift+Tab Back"), + SK(StandardKeyBinding.Print, "Ctrl+P", "Ctrl+P", "Ctrl+P"), + SK(StandardKeyBinding.Quit, _, "Ctrl+Q", "Ctrl+Q"), + SK(StandardKeyBinding.Redo, "Ctrl+Y Shift+Ctrl+Z Alt+Shift+Backspace", "Ctrl+Shift+Z", "Ctrl+Shift+Z"), + SK(StandardKeyBinding.Refresh, "F5", "F5", "Ctrl+R, F5"), + SK(StandardKeyBinding.Replace, "Ctrl+H", _, "Ctrl+H"), + SK(StandardKeyBinding.Save, "Ctrl+S", "Ctrl+S", "Ctrl+S"), + SK(StandardKeyBinding.SaveAs, _, "Ctrl+Shift+S", "Ctrl+Shift+S"), + SK(StandardKeyBinding.SelectAll, "Ctrl+A", "Ctrl+A", "Ctrl+A"), + SK(StandardKeyBinding.SelectEndOfBlock, _, "Alt+Shift+Down Meta+Shift+E", _), + SK(StandardKeyBinding.SelectEndOfDocument, "Ctrl+Shift+End", "Ctrl+Shift+Down Shift+End", "Ctrl+Shift+End"), + SK(StandardKeyBinding.SelectEndOfLine, "Shift+End", "Ctrl+Shift+Right", "Shift+End"), + SK(StandardKeyBinding.SelectNextChar, "Shift+Right", "Shift+Right", "Shift+Right"), + SK(StandardKeyBinding.SelectNextLine, "Shift+Down", "Shift+Down", "Shift+Down"), + SK(StandardKeyBinding.SelectNextPage, "Shift+PgDown", "Shift+PgDown", "Shift+PgDown"), + SK(StandardKeyBinding.SelectNextWord, "Ctrl+Shift+Right", "Alt+Shift+Right", "Ctrl+Shift+Right"), + SK(StandardKeyBinding.SelectPreviousChar, "Shift+Left", "Shift+Left", "Shift+Left"), + SK(StandardKeyBinding.SelectPreviousLine, "Shift+Up", "Shift+Up", "Shift+Up"), + SK(StandardKeyBinding.SelectPreviousPage, "Shift+PgUp", "Shift+PgUp", "Shift+PgUp"), + SK(StandardKeyBinding.SelectPreviousWord, "Ctrl+Shift+Left", "Alt+Shift+Left", "Ctrl+Shift+Left"), + SK(StandardKeyBinding.SelectStartOfBlock, _, "Alt+Shift+Up Meta+Shift+A", _), + SK(StandardKeyBinding.SelectStartOfDocument, "Ctrl+Shift+Home", "Ctrl+Shift+Up Shift+Home", "Ctrl+Shift+Home"), + SK(StandardKeyBinding.SelectStartOfLine, "Shift+Home", "Ctrl+Shift+Left", "Shift+Home"), + SK(StandardKeyBinding.Underline, "Ctrl+U", "Ctrl+U", "Ctrl+U"), + SK(StandardKeyBinding.Undo, "Ctrl+Z, Alt+Backspace", "Ctrl+Z", "Ctrl+Z, F14"), + SK(StandardKeyBinding.WhatsThis, "Shift+F1", "Shift+F1", "Shift+F1"), + SK(StandardKeyBinding.ZoomIn, "Ctrl+Plus", "Ctrl+Plus", "Ctrl+Plus"), + SK(StandardKeyBinding.ZoomOut, "Ctrl+Minus", "Ctrl+Minus", "Ctrl+Minus"), +] + +# fmt: on +_STANDARD_KEY_MAP: Dict[StandardKeyBinding, Dict[str, str]] = { + nt.sk: {"win": nt.win, "mac": nt.mac, "linux": nt.gnome} for nt in _STANDARD_KEYS +} From b2825c8d98e49b109c9ccc56e0d934bcd4057e41 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 10 Jul 2022 16:55:32 -0400 Subject: [PATCH 02/22] wip --- demo2/model_app.py | 255 ++++++++++++++---- demo2/qapplication.py | 19 +- src/app_model/backends/qt/_qaction.py | 2 - src/app_model/registries/_commands_reg.py | 2 + src/app_model/types/_keybinding_rule.py | 10 +- .../types/_keys/_standard_bindings.py | 150 +++++------ 6 files changed, 280 insertions(+), 158 deletions(-) diff --git a/demo2/model_app.py b/demo2/model_app.py index a255b42..ed00ee2 100644 --- a/demo2/model_app.py +++ b/demo2/model_app.py @@ -1,6 +1,10 @@ -from app_model import types +from app_model import types, Application from fonticon_fa6 import FA6S +from app_model.backends.qt import QModelMainWindow +from qtpy.QtWidgets import QTextEdit, QApplication, QMessageBox, QFileDialog +from qtpy.QtCore import QFile, QFileInfo, QSaveFile, QTextStream, Qt + class MenuId: FILE = "file" @@ -8,107 +12,242 @@ class MenuId: HELP = "help" +def new_file(win: "MainWindow"): + win.new_file() + + +def open_file(win: "MainWindow"): + win.open_file() + + +def save(win: "MainWindow"): + win.save() + + +def save_as(win: "MainWindow"): + win.save_as() + + +def close(win: "MainWindow"): + win.close() + + +def cut(win: "MainWindow"): + win._text_edit.cut() + + +def copy(win: "MainWindow"): + win._text_edit.copy() + + +def paste(win: "MainWindow"): + win._text_edit.paste() + + +def about(win: "MainWindow"): + win.about() + + + +class MainWindow(QModelMainWindow): + def __init__(self, app: Application): + super().__init__(app) + + self._cur_file: str = "" + self._text_edit = QTextEdit() + self.setCentralWidget(self._text_edit) + self.setModelMenuBar([MenuId.FILE, MenuId.EDIT, MenuId.HELP]) + + self.set_current_file("") + + def set_current_file(self, fileName: str) -> None: + self._cur_file = fileName + self._text_edit.document().setModified(False) + self.setWindowModified(False) + + if self._cur_file: + shown_name = QFileInfo(self._cur_file).fileName() + else: + shown_name = "untitled.txt" + + self.setWindowTitle(f"{shown_name}[*] - Application") + + def save(self): + return self.save_file(self._cur_file) if self._cur_file else self.save_as() + + def save_as(self): + fileName, filtr = QFileDialog.getSaveFileName(self) + if fileName: + return self.save_file(fileName) + + return False + + def save_file(self, fileName): + error = None + QApplication.setOverrideCursor(Qt.WaitCursor) + file = QSaveFile(fileName) + if file.open(QFile.OpenModeFlag.WriteOnly | QFile.OpenModeFlag.Text): + outf = QTextStream(file) + outf << self._text_edit.toPlainText() + if not file.commit(): + reason = file.errorString() + error = f"Cannot write file {fileName}:\n{reason}." + else: + reason = file.errorString() + error = f"Cannot open file {fileName}:\n{reason}." + QApplication.restoreOverrideCursor() + + if error: + QMessageBox.warning(self, "Application", error) + return False + + def maybe_save(self): + if self._text_edit.document().isModified(): + ret = QMessageBox.warning( + self, + "Application", + "The document has been modified.\nDo you want to save " "your changes?", + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + ) + if ret == QMessageBox.StandardButton.Save: + return self.save() + elif ret == QMessageBox.StandardButton.Cancel: + return False + return True + + def new_file(self): + if self.maybe_save(): + self._text_edit.clear() + self.set_current_file("") + + def open_file(self): + if self.maybe_save(): + fileName, _ = QFileDialog.getOpenFileName(self) + if fileName: + self.load_file(fileName) + + def load_file(self, fileName): + file = QFile(fileName) + if not file.open(QFile.OpenModeFlag.ReadOnly | QFile.OpenModeFlag.Text): + reason = file.errorString() + QMessageBox.warning( + self, "Application", f"Cannot read file {fileName}:\n{reason}." + ) + return + + inf = QTextStream(file) + QApplication.setOverrideCursor(Qt.WaitCursor) + self._text_edit.setPlainText(inf.readAll()) + QApplication.restoreOverrideCursor() + + self.set_current_file(fileName) + self.statusBar().showMessage("File loaded", 2000) + + def about(self): + QMessageBox.about( + self, + "About Application", + "The Application example demonstrates how to write " + "modern GUI applications using Qt, with a menu bar, " + "toolbars, and a status bar.", + ) + + ACTIONS = [ types.Action( + id="new_file", icon=FA6S.file_circle_plus, title="New", - shortcut=types.StandardKeyBinding.New, - statusTip="Create a new file", - menus=[{'id': MenuId.FILE, 'group': '1_loadsave'}], + keybindings=[types.StandardKeyBinding.New], + status_tip="Create a new file", + menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=new_file, ), types.Action( + id="open_file", icon=FA6S.folder_open, title="Open...", - shortcut=types.StandardKeyBinding.Open, - statusTip="Open an existing file", - menus=[{'id': MenuId.FILE, 'group': '1_loadsave'}], - callback=open_file, + keybindings=[types.StandardKeyBinding.Open], + status_tip="Open an existing file", + menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], + callback=MainWindow.open_file, ), types.Action( + id="save_file", icon=FA6S.floppy_disk, title="Save", - shortcut=types.StandardKeyBinding.Save, - statusTip="Save the document to disk", - menus=[{'id': MenuId.FILE, 'group': '1_loadsave'}], + keybindings=[types.StandardKeyBinding.Save], + status_tip="Save the document to disk", + menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=save, ), types.Action( + id="save_file_as", title="Save As...", - shortcut=types.StandardKeyBinding.SaveAs, - statusTip="Save the document under a new name", - menus=[{'id': MenuId.FILE, 'group': '1_loadsave'}], + keybindings=[types.StandardKeyBinding.SaveAs], + status_tip="Save the document under a new name", + menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=save_as, ), types.Action( + id="close", title="Exit", - shortcut="Ctrl+Q", - statusTip="Exit the application", - menus=[{'id': MenuId.FILE, 'group': '3_launchexit'}], + keybindings=[types.StandardKeyBinding.Quit], + status_tip="Exit the application", + menus=[{"id": MenuId.FILE, "group": "3_launchexit"}], callback=close, ), types.Action( + id="cut", icon=FA6S.scissors, title="Cut", - shortcut=types.StandardKeyBinding.Cut, - statusTip="Cut the current selection's contents to the clipboard", - menus=[{'id': MenuId.EDIT}], + keybindings=[types.StandardKeyBinding.Cut], + enablement="copyAvailable", + status_tip="Cut the current selection's contents to the clipboard", + menus=[{"id": MenuId.EDIT}], callback=cut, ), types.Action( + id="copy", icon=FA6S.copy, title="Copy", - shortcut=types.StandardKeyBinding.Copy, - statusTip="Copy the current selection's contents to the clipboard", - menus=[{'id': MenuId.EDIT}], + keybindings=[types.StandardKeyBinding.Copy], + enablement="copyAvailable", + status_tip="Copy the current selection's contents to the clipboard", + menus=[{"id": MenuId.EDIT}], callback=copy, ), types.Action( + id="paste", icon=FA6S.paste, title="Paste", - shortcut=types.StandardKeyBinding.Paste, - statusTip="Paste the clipboard's contents into the current selection", - menus=[{'id': MenuId.EDIT}], + keybindings=[types.StandardKeyBinding.Paste], + status_tip="Paste the clipboard's contents into the current selection", + menus=[{"id": MenuId.EDIT}], callback=paste, ), types.Action( + id="about", title="About", - statusTip="Show the application's About box", - menus=[{'id': MenuId.HELP}], + status_tip="Show the application's About box", + menus=[{"id": MenuId.HELP}], callback=about, ), ] -# QT specific stuff - -from qtpy.QtWidgets import QMainWindow, QTextEdit -from qtpy.QtCore import QFileInfo -class MainWindow(QMainWindow): - def __init__(self): - super().__init__() +# Main setup - self._cur_file: str = "" - self._text_edit = QTextEdit() - self.setCentralWidget(self._text_edit) - self._text_edit.document().contentsChanged.connect(self.document_was_modified) - - self.set_current_file("") - - def document_was_modified(self): - self.setWindowModified(self._text_edit.document().isModified()) - - def set_current_file(self, fileName: str) -> None: - self._cur_file = fileName - self._text_edit.document().setModified(False) - self.setWindowModified(False) - - if self._cur_file: - shown_name = self.stripped_name(self._cur_file) - else: - shown_name = "untitled.txt" - - self.setWindowTitle(f"{shown_name}[*] - Application") +if __name__ == "__main__": + app = Application(name="my_app") + for action in ACTIONS: + app.register_action(action) + qapp = QApplication([]) + qapp.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) + main_win = MainWindow(app=app) - def stripped_name(self, fullFileName: str): - return QFileInfo(fullFileName).fileName() \ No newline at end of file + app.injection_store.register_provider(lambda: main_win, MainWindow) + main_win.show() + qapp.exec_() diff --git a/demo2/qapplication.py b/demo2/qapplication.py index d41433f..d47be55 100644 --- a/demo2/qapplication.py +++ b/demo2/qapplication.py @@ -2,8 +2,6 @@ # Copyright (C) 2022 The Qt Company Ltd. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause -from argparse import ArgumentParser, RawTextHelpFormatter -import sys from fonticon_fa6 import FA6S from qtpy.QtCore import QFile, QFileInfo, QSaveFile, QTextStream, Qt @@ -33,8 +31,6 @@ def __init__(self): self.create_tool_bars() self.create_status_bar() - self._text_edit.document().contentsChanged.connect(self.document_was_modified) - self.set_current_file("") def new_file(self): @@ -67,9 +63,6 @@ def about(self): "toolbars, and a status bar.", ) - def document_was_modified(self): - self.setWindowModified(self._text_edit.document().isModified()) - def create_actions(self): self._new_act = QAction( fonticon.icon(FA6S.file_circle_plus), @@ -259,15 +252,9 @@ def stripped_name(self, fullFileName: str): if __name__ == "__main__": - argument_parser = ArgumentParser( - description="Application Example", formatter_class=RawTextHelpFormatter - ) - argument_parser.add_argument("file", help="File", nargs="?", type=str) - options = argument_parser.parse_args() - app = QApplication(sys.argv) + qapp = QApplication([]) + qapp.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) main_win = MainWindow() - if options.file: - main_win.load_file(options.file) main_win.show() - sys.exit(app.exec()) + qapp.exec_() diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index 35d1ee3..09257bd 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -78,8 +78,6 @@ def __init__( if command_rule.tooltip: self.setToolTip(command_rule.tooltip) - self.setIconVisibleInMenu(False) - def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled state of this menu item from `ctx`.""" self.setEnabled(expr.eval(ctx) if (expr := self._cmd_rule.enablement) else True) diff --git a/src/app_model/registries/_commands_reg.py b/src/app_model/registries/_commands_reg.py index f319d3a..f62396e 100644 --- a/src/app_model/registries/_commands_reg.py +++ b/src/app_model/registries/_commands_reg.py @@ -161,6 +161,7 @@ def execute_command( raise KeyError( f'Command "{id}" has no registered callbacks' ) # pragma: no cover + if execute_asychronously: with ThreadPoolExecutor() as executor: return executor.submit(cmd, *args, **kwargs) @@ -169,6 +170,7 @@ def execute_command( try: future.set_result(cmd(*args, **kwargs)) except Exception as e: + print(e) future.set_exception(e) return future diff --git a/src/app_model/types/_keybinding_rule.py b/src/app_model/types/_keybinding_rule.py index da3a1e1..aca7c0f 100644 --- a/src/app_model/types/_keybinding_rule.py +++ b/src/app_model/types/_keybinding_rule.py @@ -1,11 +1,11 @@ -from typing import Optional, TypedDict, Union +from typing import Any, Optional, TypedDict, Union from pydantic import Field from .. import expressions from ._base import _StrictModel from ._constants import OperatingSystem - +from ._keys import StandardKeyBinding KeyEncoding = Union[int, str] _OS = OperatingSystem.current() @@ -52,6 +52,12 @@ def _bind_to_current_platform(self) -> Optional[KeyEncoding]: return self.linux return self.primary + @classmethod + def validate(cls, value: Any) -> 'KeyBindingRule': + if isinstance(value, StandardKeyBinding): + return value.to_keybinding_rule() + return super().validate(value) + class KeyBindingRuleDict(TypedDict, total=False): """Typed dict for KeyBindingRule kwargs.""" diff --git a/src/app_model/types/_keys/_standard_bindings.py b/src/app_model/types/_keys/_standard_bindings.py index e83bdc8..abc1f82 100644 --- a/src/app_model/types/_keys/_standard_bindings.py +++ b/src/app_model/types/_keys/_standard_bindings.py @@ -1,6 +1,7 @@ from enum import Enum, auto from collections import namedtuple from typing import TYPE_CHECKING, Dict +from ._key_codes import KeyCode, KeyMod if TYPE_CHECKING: from .._keybinding_rule import KeyBindingRule @@ -9,7 +10,6 @@ class StandardKeyBinding(Enum): AddTab = auto() Back = auto() - Backspace = auto() Bold = auto() Cancel = auto() Close = auto() @@ -27,10 +27,7 @@ class StandardKeyBinding(Enum): Forward = auto() FullScreen = auto() HelpContents = auto() - InsertLineSeparator = auto() - InsertParagraphSeparator = auto() Italic = auto() - MoveToEndOfBlock = auto() MoveToEndOfDocument = auto() MoveToEndOfLine = auto() MoveToNextChar = auto() @@ -41,7 +38,6 @@ class StandardKeyBinding(Enum): MoveToPreviousLine = auto() MoveToPreviousPage = auto() MoveToPreviousWord = auto() - MoveToStartOfBlock = auto() MoveToStartOfDocument = auto() MoveToStartOfLine = auto() New = auto() @@ -58,7 +54,6 @@ class StandardKeyBinding(Enum): Save = auto() SaveAs = auto() SelectAll = auto() - SelectEndOfBlock = auto() SelectEndOfDocument = auto() SelectEndOfLine = auto() SelectNextChar = auto() @@ -69,7 +64,6 @@ class StandardKeyBinding(Enum): SelectPreviousLine = auto() SelectPreviousPage = auto() SelectPreviousWord = auto() - SelectStartOfBlock = auto() SelectStartOfDocument = auto() SelectStartOfLine = auto() Underline = auto() @@ -85,84 +79,80 @@ def to_keybinding_rule(self) -> "KeyBindingRule": return KeyBindingRule(**_STANDARD_KEY_MAP[self]) -_ = "" -SK = namedtuple("SK", "sk, win, mac, gnome") +_ = None +SK = namedtuple("SK", "sk, primary, win, mac, gnome", defaults=(_, _, _, _, _)) # fmt: off +# flake8: noqa + _STANDARD_KEYS = [ - SK(StandardKeyBinding.AddTab, "Ctrl+T", "Ctrl+T", "Ctrl+T"), - SK(StandardKeyBinding.Back, "Alt+Left, Backspace", "Ctrl+[", "Alt+Left"), - SK(StandardKeyBinding.Backspace, _, "Meta+H", _), - SK(StandardKeyBinding.Bold, "Ctrl+B", "Ctrl+B", "Ctrl+B"), - SK(StandardKeyBinding.Cancel, "Escape", "Escape, Ctrl+.", "Escape"), - SK(StandardKeyBinding.Close, "Ctrl+F4, Ctrl+W", "Ctrl+W, Ctrl+F4", "Ctrl+W"), - SK(StandardKeyBinding.Copy, "Ctrl+C, Ctrl+Ins", "Ctrl+C", "Ctrl+C, F16, Ctrl+Ins"), - SK(StandardKeyBinding.Cut, "Ctrl+X Shift+Del", "Ctrl+X Meta+K", "Ctrl+X F20 Shift+Del"), - SK(StandardKeyBinding.Delete, "Del", "Del, Meta+D", "Del, Ctrl+D"), - SK(StandardKeyBinding.DeleteCompleteLine, _, _, "Ctrl+U"), - SK(StandardKeyBinding.DeleteEndOfLine, _, _, "Ctrl+K"), - SK(StandardKeyBinding.DeleteEndOfWord, "Ctrl+Del", _, "Ctrl+Del"), - SK(StandardKeyBinding.DeleteStartOfWord, "Ctrl+Backspace", "Alt+Backspace", "Ctrl+Backspace"), - SK(StandardKeyBinding.Deselect, _, _, "Ctrl+Shift+A"), - SK(StandardKeyBinding.Find, "Ctrl+F", "Ctrl+F", "Ctrl+F"), - SK(StandardKeyBinding.FindNext, "F3, Ctrl+G", "Ctrl+G", "Ctrl+G, F3"), - SK(StandardKeyBinding.FindPrevious, "Shift+F3 Ctrl+Shift+G", "Ctrl+Shift+G", "Ctrl+Shift+G Shift+F3"), - SK(StandardKeyBinding.Forward, "Alt+Right, Shift+Backspace", "Ctrl+]", "Alt+Right"), - SK(StandardKeyBinding.FullScreen, "F11, Alt+Enter", "Ctrl+Meta+F", "Ctrl+F11"), - SK(StandardKeyBinding.HelpContents, "F1", "Ctrl+?", "F1"), - SK(StandardKeyBinding.InsertLineSeparator, "Shift+Enter", "Meta+Enter Meta+O", "Shift+Enter"), - SK(StandardKeyBinding.InsertParagraphSeparator, "Enter", "Enter", "Enter"), - SK(StandardKeyBinding.Italic, "Ctrl+I", "Ctrl+I", "Ctrl+I"), - SK(StandardKeyBinding.MoveToEndOfBlock, _, "Alt+Down, Meta+E", _), - SK(StandardKeyBinding.MoveToEndOfDocument, "Ctrl+End", "Ctrl+Down, End", "Ctrl+End"), - SK(StandardKeyBinding.MoveToEndOfLine, "End", "Ctrl+Right Meta+Right", "End Ctrl+E"), - SK(StandardKeyBinding.MoveToNextChar, "Right", "Right, Meta+F", "Right"), - SK(StandardKeyBinding.MoveToNextLine, "Down", "Down, Meta+N", "Down"), - SK(StandardKeyBinding.MoveToNextPage, "PgDown", "PgDown Alt+PgDown Meta+Down Meta+PgDown Meta+V", "PgDown"), - SK(StandardKeyBinding.MoveToNextWord, "Ctrl+Right", "Alt+Right", "Ctrl+Right"), - SK(StandardKeyBinding.MoveToPreviousChar, "Left", "Left, Meta+B", "Left"), - SK(StandardKeyBinding.MoveToPreviousLine, "Up", "Up, Meta+P", "Up"), - SK(StandardKeyBinding.MoveToPreviousPage, "PgUp", "PgUp Alt+PgUp Meta+Up Meta+PgUp", "PgUp"), - SK(StandardKeyBinding.MoveToPreviousWord, "Ctrl+Left", "Alt+Left", "Ctrl+Left"), - SK(StandardKeyBinding.MoveToStartOfBlock, _, "Alt+Up, Meta+A", _), - SK(StandardKeyBinding.MoveToStartOfDocument, "Ctrl+Home", "Ctrl+Up Home", "Ctrl+Home"), - SK(StandardKeyBinding.MoveToStartOfLine, "Home", "Ctrl+Left, Meta+Left", "Home"), - SK(StandardKeyBinding.New, "Ctrl+N", "Ctrl+N", "Ctrl+N"), - SK(StandardKeyBinding.NextChild, "Ctrl+Tab Forward Ctrl+F6", "Ctrl+} Forward Ctrl+Tab", "Ctrl+Tab Forward"), - SK(StandardKeyBinding.Open, "Ctrl+O", "Ctrl+O", "Ctrl+O"), - SK(StandardKeyBinding.Paste, "Ctrl+V Shift+Ins", "Ctrl+V Meta+Y", "Ctrl+V F18 Shift+Ins"), - SK(StandardKeyBinding.Preferences, _, "Ctrl+,", _), - SK(StandardKeyBinding.PreviousChild, "Ctrl+Shift+Tab Back Ctrl+Shift+F6", "Ctrl+{ Back Ctrl+Shift+Tab", "Ctrl+Shift+Tab Back"), - SK(StandardKeyBinding.Print, "Ctrl+P", "Ctrl+P", "Ctrl+P"), - SK(StandardKeyBinding.Quit, _, "Ctrl+Q", "Ctrl+Q"), - SK(StandardKeyBinding.Redo, "Ctrl+Y Shift+Ctrl+Z Alt+Shift+Backspace", "Ctrl+Shift+Z", "Ctrl+Shift+Z"), - SK(StandardKeyBinding.Refresh, "F5", "F5", "Ctrl+R, F5"), - SK(StandardKeyBinding.Replace, "Ctrl+H", _, "Ctrl+H"), - SK(StandardKeyBinding.Save, "Ctrl+S", "Ctrl+S", "Ctrl+S"), - SK(StandardKeyBinding.SaveAs, _, "Ctrl+Shift+S", "Ctrl+Shift+S"), - SK(StandardKeyBinding.SelectAll, "Ctrl+A", "Ctrl+A", "Ctrl+A"), - SK(StandardKeyBinding.SelectEndOfBlock, _, "Alt+Shift+Down Meta+Shift+E", _), - SK(StandardKeyBinding.SelectEndOfDocument, "Ctrl+Shift+End", "Ctrl+Shift+Down Shift+End", "Ctrl+Shift+End"), - SK(StandardKeyBinding.SelectEndOfLine, "Shift+End", "Ctrl+Shift+Right", "Shift+End"), - SK(StandardKeyBinding.SelectNextChar, "Shift+Right", "Shift+Right", "Shift+Right"), - SK(StandardKeyBinding.SelectNextLine, "Shift+Down", "Shift+Down", "Shift+Down"), - SK(StandardKeyBinding.SelectNextPage, "Shift+PgDown", "Shift+PgDown", "Shift+PgDown"), - SK(StandardKeyBinding.SelectNextWord, "Ctrl+Shift+Right", "Alt+Shift+Right", "Ctrl+Shift+Right"), - SK(StandardKeyBinding.SelectPreviousChar, "Shift+Left", "Shift+Left", "Shift+Left"), - SK(StandardKeyBinding.SelectPreviousLine, "Shift+Up", "Shift+Up", "Shift+Up"), - SK(StandardKeyBinding.SelectPreviousPage, "Shift+PgUp", "Shift+PgUp", "Shift+PgUp"), - SK(StandardKeyBinding.SelectPreviousWord, "Ctrl+Shift+Left", "Alt+Shift+Left", "Ctrl+Shift+Left"), - SK(StandardKeyBinding.SelectStartOfBlock, _, "Alt+Shift+Up Meta+Shift+A", _), - SK(StandardKeyBinding.SelectStartOfDocument, "Ctrl+Shift+Home", "Ctrl+Shift+Up Shift+Home", "Ctrl+Shift+Home"), - SK(StandardKeyBinding.SelectStartOfLine, "Shift+Home", "Ctrl+Shift+Left", "Shift+Home"), - SK(StandardKeyBinding.Underline, "Ctrl+U", "Ctrl+U", "Ctrl+U"), - SK(StandardKeyBinding.Undo, "Ctrl+Z, Alt+Backspace", "Ctrl+Z", "Ctrl+Z, F14"), - SK(StandardKeyBinding.WhatsThis, "Shift+F1", "Shift+F1", "Shift+F1"), - SK(StandardKeyBinding.ZoomIn, "Ctrl+Plus", "Ctrl+Plus", "Ctrl+Plus"), - SK(StandardKeyBinding.ZoomOut, "Ctrl+Minus", "Ctrl+Minus", "Ctrl+Minus"), + SK(StandardKeyBinding.AddTab, KeyMod.CtrlCmd | KeyCode.KeyT), + SK(StandardKeyBinding.Back, KeyMod.Alt | KeyCode.LeftArrow, _, KeyMod.CtrlCmd | KeyCode.BracketLeft), + SK(StandardKeyBinding.Bold, KeyMod.CtrlCmd | KeyCode.KeyB), + SK(StandardKeyBinding.Cancel, KeyCode.Escape), + SK(StandardKeyBinding.Close, KeyMod.CtrlCmd | KeyCode.KeyW), + SK(StandardKeyBinding.Copy, KeyMod.CtrlCmd | KeyCode.KeyC), + SK(StandardKeyBinding.Cut, KeyMod.CtrlCmd | KeyCode.KeyX), + SK(StandardKeyBinding.Delete, KeyCode.Delete), + SK(StandardKeyBinding.DeleteCompleteLine, _, _, _, KeyMod.CtrlCmd | KeyCode.KeyU), + SK(StandardKeyBinding.DeleteEndOfLine, _, _, _, KeyMod.CtrlCmd | KeyCode.KeyK), + SK(StandardKeyBinding.DeleteEndOfWord, _, KeyMod.CtrlCmd | KeyCode.Delete, _, KeyMod.CtrlCmd | KeyCode.Delete), + SK(StandardKeyBinding.DeleteStartOfWord, _, KeyMod.CtrlCmd | KeyCode.Backspace, KeyMod.Alt | KeyCode.Backspace, KeyMod.CtrlCmd | KeyCode.Backspace), + SK(StandardKeyBinding.Deselect, _, _, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA), + SK(StandardKeyBinding.Find, KeyMod.CtrlCmd | KeyCode.KeyF), + SK(StandardKeyBinding.FindNext, KeyMod.CtrlCmd | KeyCode.KeyG), + SK(StandardKeyBinding.FindPrevious, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG), + SK(StandardKeyBinding.Forward, _, KeyMod.Alt | KeyCode.RightArrow, KeyMod.CtrlCmd | KeyCode.BracketRight, KeyMod.Alt | KeyCode.RightArrow), + SK(StandardKeyBinding.FullScreen, _, KeyMod.Alt | KeyCode.Enter, KeyMod.WinCtrl | KeyMod.CtrlCmd | KeyCode.KeyF, KeyMod.CtrlCmd | KeyCode.F11), + SK(StandardKeyBinding.HelpContents, KeyCode.F1, _, KeyMod.CtrlCmd | KeyCode.Slash), + SK(StandardKeyBinding.Italic, KeyMod.CtrlCmd | KeyCode.KeyI), + SK(StandardKeyBinding.MoveToEndOfDocument, KeyMod.CtrlCmd | KeyCode.End, _, KeyMod.CtrlCmd | KeyCode.DownArrow), + SK(StandardKeyBinding.MoveToEndOfLine, KeyCode.End, _, KeyMod.CtrlCmd | KeyCode.RightArrow), + SK(StandardKeyBinding.MoveToNextChar, KeyCode.RightArrow), + SK(StandardKeyBinding.MoveToNextLine, KeyCode.DownArrow), + SK(StandardKeyBinding.MoveToNextPage, KeyCode.PageDown), + SK(StandardKeyBinding.MoveToNextWord, KeyMod.CtrlCmd | KeyCode.RightArrow, _, KeyMod.Alt | KeyCode.RightArrow), + SK(StandardKeyBinding.MoveToPreviousChar, KeyCode.LeftArrow), + SK(StandardKeyBinding.MoveToPreviousLine, KeyCode.UpArrow), + SK(StandardKeyBinding.MoveToPreviousPage, KeyCode.PageUp), + SK(StandardKeyBinding.MoveToPreviousWord, KeyMod.CtrlCmd | KeyCode.LeftArrow, _, KeyMod.Alt | KeyCode.LeftArrow), + SK(StandardKeyBinding.MoveToStartOfDocument, KeyMod.CtrlCmd | KeyCode.Home, _, KeyCode.Home), + SK(StandardKeyBinding.MoveToStartOfLine, KeyCode.Home, _, KeyMod.CtrlCmd | KeyCode.LeftArrow), + SK(StandardKeyBinding.New, KeyMod.CtrlCmd | KeyCode.KeyN), + SK(StandardKeyBinding.NextChild, KeyMod.CtrlCmd | KeyCode.Tab, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketRight), + SK(StandardKeyBinding.Open, KeyMod.CtrlCmd | KeyCode.KeyO), + SK(StandardKeyBinding.Paste, KeyMod.CtrlCmd | KeyCode.KeyV), + SK(StandardKeyBinding.Preferences, _, _, KeyMod.CtrlCmd, KeyCode.Comma), + SK(StandardKeyBinding.PreviousChild, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Tab, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketLeft), + SK(StandardKeyBinding.Print, KeyMod.CtrlCmd | KeyCode.KeyP), + SK(StandardKeyBinding.Quit, KeyMod.CtrlCmd | KeyCode.KeyQ), + SK(StandardKeyBinding.Redo, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ, KeyMod.CtrlCmd | KeyCode.KeyY), + SK(StandardKeyBinding.Refresh, KeyMod.CtrlCmd | KeyCode.KeyR), + SK(StandardKeyBinding.Replace, KeyMod.CtrlCmd | KeyCode.KeyH), + SK(StandardKeyBinding.Save, KeyMod.CtrlCmd | KeyCode.KeyS), + SK(StandardKeyBinding.SaveAs, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyS), + SK(StandardKeyBinding.SelectAll, KeyMod.CtrlCmd | KeyCode.KeyA), + SK(StandardKeyBinding.SelectEndOfDocument, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.End), + SK(StandardKeyBinding.SelectEndOfLine, KeyMod.Shift | KeyCode.End, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.RightArrow), + SK(StandardKeyBinding.SelectNextChar, KeyMod.Shift | KeyCode.RightArrow), + SK(StandardKeyBinding.SelectNextLine, KeyMod.Shift | KeyCode.DownArrow), + SK(StandardKeyBinding.SelectNextPage, KeyMod.Shift | KeyCode.PageDown), + SK(StandardKeyBinding.SelectNextWord, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.RightArrow, _, KeyMod.Alt | KeyMod.Shift | KeyCode.RightArrow), + SK(StandardKeyBinding.SelectPreviousChar, KeyMod.Shift | KeyCode.LeftArrow), + SK(StandardKeyBinding.SelectPreviousLine, KeyMod.Shift | KeyCode.UpArrow), + SK(StandardKeyBinding.SelectPreviousPage, KeyMod.Shift | KeyCode.PageUp), + SK(StandardKeyBinding.SelectPreviousWord, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.LeftArrow, _, KeyMod.Alt | KeyMod.Shift | KeyCode.LeftArrow), + SK(StandardKeyBinding.SelectStartOfDocument, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Home), + SK(StandardKeyBinding.SelectStartOfLine, KeyMod.Shift | KeyCode.Home, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.LeftArrow), + SK(StandardKeyBinding.Underline, KeyMod.CtrlCmd | KeyCode.KeyU), + SK(StandardKeyBinding.Undo, KeyMod.CtrlCmd | KeyCode.KeyZ), + SK(StandardKeyBinding.WhatsThis, KeyMod.Shift | KeyCode.F1), + SK(StandardKeyBinding.ZoomIn, KeyMod.CtrlCmd | KeyCode.Equal), + SK(StandardKeyBinding.ZoomOut, KeyMod.CtrlCmd | KeyCode.Minus), ] # fmt: on _STANDARD_KEY_MAP: Dict[StandardKeyBinding, Dict[str, str]] = { - nt.sk: {"win": nt.win, "mac": nt.mac, "linux": nt.gnome} for nt in _STANDARD_KEYS + nt.sk: {"primary": nt.primary, "win": nt.win, "mac": nt.mac, "linux": nt.gnome} + for nt in _STANDARD_KEYS } From e15b3bf4964bdc3df17e6d95e9370c2e0df53c2c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Jul 2022 06:07:39 -0400 Subject: [PATCH 03/22] feat: just add all the stuff --- demo2/model_app.py | 84 ++++++++--------------- src/app_model/_app.py | 5 ++ src/app_model/backends/qt/_qaction.py | 43 ++++++++++-- src/app_model/backends/qt/_qmainwindow.py | 8 ++- src/app_model/backends/qt/_qmenu.py | 15 +++- src/app_model/expressions/_expressions.py | 5 ++ src/app_model/registries/_commands_reg.py | 36 ++++------ src/app_model/registries/_register.py | 12 ++-- src/app_model/types/_base.py | 1 + src/app_model/types/_command_rule.py | 4 ++ src/app_model/types/_icon.py | 2 + src/app_model/types/_menu_rule.py | 8 ++- tests/conftest.py | 8 ++- tests/test_qt/test_qmenu.py | 25 +++++-- 14 files changed, 158 insertions(+), 98 deletions(-) diff --git a/demo2/model_app.py b/demo2/model_app.py index ed00ee2..6791d10 100644 --- a/demo2/model_app.py +++ b/demo2/model_app.py @@ -1,52 +1,9 @@ -from app_model import types, Application from fonticon_fa6 import FA6S +from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream +from qtpy.QtWidgets import QApplication, QFileDialog, QMessageBox, QTextEdit +from app_model import Application, types from app_model.backends.qt import QModelMainWindow -from qtpy.QtWidgets import QTextEdit, QApplication, QMessageBox, QFileDialog -from qtpy.QtCore import QFile, QFileInfo, QSaveFile, QTextStream, Qt - - -class MenuId: - FILE = "file" - EDIT = "edit" - HELP = "help" - - -def new_file(win: "MainWindow"): - win.new_file() - - -def open_file(win: "MainWindow"): - win.open_file() - - -def save(win: "MainWindow"): - win.save() - - -def save_as(win: "MainWindow"): - win.save_as() - - -def close(win: "MainWindow"): - win.close() - - -def cut(win: "MainWindow"): - win._text_edit.cut() - - -def copy(win: "MainWindow"): - win._text_edit.copy() - - -def paste(win: "MainWindow"): - win._text_edit.paste() - - -def about(win: "MainWindow"): - win.about() - class MainWindow(QModelMainWindow): @@ -154,6 +111,25 @@ def about(self): "toolbars, and a status bar.", ) + def cut(self): + self._text_edit.cut() + + def copy(self): + self._text_edit.copy() + + def paste(self): + self._text_edit.paste() + + +# Actions defined declaratively outside of QMainWindow class ... +# menus and toolbars will be made and added automatically + + +class MenuId: + FILE = "file" + EDIT = "edit" + HELP = "help" + ACTIONS = [ types.Action( @@ -163,7 +139,7 @@ def about(self): keybindings=[types.StandardKeyBinding.New], status_tip="Create a new file", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], - callback=new_file, + callback=MainWindow.new_file, ), types.Action( id="open_file", @@ -181,7 +157,7 @@ def about(self): keybindings=[types.StandardKeyBinding.Save], status_tip="Save the document to disk", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], - callback=save, + callback=MainWindow.save, ), types.Action( id="save_file_as", @@ -189,7 +165,7 @@ def about(self): keybindings=[types.StandardKeyBinding.SaveAs], status_tip="Save the document under a new name", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], - callback=save_as, + callback=MainWindow.save_as, ), types.Action( id="close", @@ -197,7 +173,7 @@ def about(self): keybindings=[types.StandardKeyBinding.Quit], status_tip="Exit the application", menus=[{"id": MenuId.FILE, "group": "3_launchexit"}], - callback=close, + callback=MainWindow.close, ), types.Action( id="cut", @@ -207,7 +183,7 @@ def about(self): enablement="copyAvailable", status_tip="Cut the current selection's contents to the clipboard", menus=[{"id": MenuId.EDIT}], - callback=cut, + callback=MainWindow.cut, ), types.Action( id="copy", @@ -217,7 +193,7 @@ def about(self): enablement="copyAvailable", status_tip="Copy the current selection's contents to the clipboard", menus=[{"id": MenuId.EDIT}], - callback=copy, + callback=MainWindow.copy, ), types.Action( id="paste", @@ -226,14 +202,14 @@ def about(self): keybindings=[types.StandardKeyBinding.Paste], status_tip="Paste the clipboard's contents into the current selection", menus=[{"id": MenuId.EDIT}], - callback=paste, + callback=MainWindow.paste, ), types.Action( id="about", title="About", status_tip="Show the application's About box", menus=[{"id": MenuId.HELP}], - callback=about, + callback=MainWindow.about, ), ] diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 15fceac..09fe60a 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, ClassVar, Dict, List, Tuple import in_n_out as ino +from psygnal import Signal from .registries import ( CommandsRegistry, @@ -20,6 +21,7 @@ class Application: """Full application model.""" + destroyed = Signal(str) _instances: ClassVar[Dict[str, Application]] = {} def __init__(self, name: str) -> None: @@ -48,9 +50,12 @@ def get_or_create(cls, name: str) -> Application: @classmethod def destroy(cls, name: str) -> None: """Destroy the app named `name`.""" + if name not in cls._instances: + return app = cls._instances.pop(name) app.dispose() app.injection_store.destroy(name) + app.destroyed.emit(app.name) def __del__(self) -> None: """Remove the app from the registry when it is garbage collected.""" diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index 09257bd..ebc359e 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Mapping, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Tuple, Type, Union, cast from qtpy.QtWidgets import QAction @@ -37,6 +37,7 @@ def __init__( super().__init__(parent) self._app = Application.get_or_create(app) if isinstance(app, str) else app self._command_id = command_id + self.setObjectName(command_id) if kb := self._app.keybindings.get_keybinding(command_id): self.setShortcut(QKeyBindingSequence(kb.keybinding)) self.triggered.connect(self._on_triggered) @@ -68,7 +69,6 @@ def __init__( ): super().__init__(command_rule.id, app, parent) self._cmd_rule = command_rule - self.setObjectName(command_rule.id) if use_short_title and command_rule.short_title: self.setText(command_rule.short_title) # pragma: no cover else: @@ -90,16 +90,51 @@ class QMenuItemAction(QCommandRuleAction): to toggle visibility. """ + _cache: Dict[Tuple[int, int], QMenuItemAction] = {} + _cache_key: Optional[Tuple[int, int]] = None + + def __new__( + cls: Type[QMenuItemAction], + menu_item: MenuItem, + app: Union[str, Application], + **kwargs: Any, + ) -> QMenuItemAction: + cache = kwargs.pop("cache", True) + app = Application.get_or_create(app) if isinstance(app, str) else app + key = (id(app), hash(menu_item)) + if cache and key in cls._cache: + return cls._cache[key] + + self = cast(QMenuItemAction, super().__new__(cls)) + if cache: + self._cache_key = key + cls._cache[key] = self + return self + def __init__( self, menu_item: MenuItem, app: Union[str, Application], parent: Optional[QObject] = None, + *, + cache: bool = True, ): - super().__init__(menu_item.command, app, parent) - self._menu_item = menu_item + if not getattr(self, "_initialized", False): + super().__init__(menu_item.command, app, parent) + self._menu_item = menu_item + self.destroyed.connect( + lambda: QMenuItemAction._cache.pop(self._cache_key, None) + ) + self._app.destroyed.connect( + lambda: QMenuItemAction._cache.pop(self._cache_key, None) + ) + self._initialized = True def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled/visible state of this menu item from `ctx`.""" super().update_from_context(ctx) self.setVisible(expr.eval(ctx) if (expr := self._menu_item.when) else True) + + def __repr__(self) -> str: + name = self.__class__.__name__ + return f"{name}({self._menu_item!r}, app={self._app.name!r})" diff --git a/src/app_model/backends/qt/_qmainwindow.py b/src/app_model/backends/qt/_qmainwindow.py index 6c4ec68..72e3173 100644 --- a/src/app_model/backends/qt/_qmainwindow.py +++ b/src/app_model/backends/qt/_qmainwindow.py @@ -16,7 +16,13 @@ def __init__( super().__init__(parent) self._app = Application.get_or_create(app) if isinstance(app, str) else app - def setModelMenuBar(self, menu_ids: List[str]) -> None: + def setModelMenuBar(self, menu_ids: List[str]) -> QModelMenuBar: """Set the menu bar to a list of menu ids.""" menu_bar = QModelMenuBar(menu_ids, self._app, self) self.setMenuBar(menu_bar) + return menu_bar + + def addModelToolbar(self, menu_ids: List[str]): + """Add a toolbar to the main window.""" + for menu_id in menu_ids: + self.addToolBar(menu_id) diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index 7b2fce7..70720c8 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -12,7 +12,7 @@ from ._util import to_qicon if TYPE_CHECKING: - from qtpy.QtWidgets import QWidget + from qtpy.QtWidgets import QAction, QWidget class QModelMenu(QMenu): @@ -62,7 +62,7 @@ def rebuild(self) -> None: for n, group in enumerate(groups): for item in group: if isinstance(item, SubmenuItem): - submenu = QModelSubmenu(item, self._app, self) + submenu = QModelSubmenu(item, self._app, parent=self) self.addMenu(submenu) else: action = QMenuItemAction(item, app=self._app, parent=self) @@ -102,6 +102,17 @@ def update_from_context( if _recurse: parent.update_from_context(ctx, _recurse=False) + def findAction(self, object_name: str) -> Union[QAction, QModelMenu, None]: + """Find an action by its ObjectName. + + Parameters + ---------- + action_id : str + Action ID to find. Note that `QCommandAction` have `ObjectName` set + to their `command.id` + """ + return next((a for a in self.actions() if a.objectName() == object_name), None) + class QModelSubmenu(QModelMenu): """QMenu for a menu_id in an `app_model` MenusRegistry. diff --git a/src/app_model/expressions/_expressions.py b/src/app_model/expressions/_expressions.py index 690133d..10db612 100644 --- a/src/app_model/expressions/_expressions.py +++ b/src/app_model/expressions/_expressions.py @@ -320,6 +320,11 @@ def validate(cls, v: Any) -> Expr: """Validator for Expr. For use with Pydantic.""" return v if isinstance(v, Expr) else parse_expression(v) + def __hash__(self) -> int: + return hash(self.__class__) + hash( + tuple(getattr(self, f) for f in self._fields) + ) + LOAD = ast.Load() diff --git a/src/app_model/registries/_commands_reg.py b/src/app_model/registries/_commands_reg.py index f62396e..b247975 100644 --- a/src/app_model/registries/_commands_reg.py +++ b/src/app_model/registries/_commands_reg.py @@ -9,7 +9,7 @@ from typing_extensions import ParamSpec if TYPE_CHECKING: - from typing import Dict, Iterator, List, Tuple + from typing import Dict, Iterator, Tuple DisposeCallable = Callable[[], None] @@ -73,7 +73,7 @@ class CommandsRegistry: registered = Signal(str) def __init__(self, injection_store: Optional[Store] = None) -> None: - self._commands: Dict[str, List[_RegisteredCommand]] = {} + self._commands: Dict[str, _RegisteredCommand] = {} self._injection_store = injection_store def register_command( @@ -95,20 +95,19 @@ def register_command( DisposeCallable A function that can be called to unregister the command. """ - commands = self._commands.setdefault(id, []) + if id in self._commands: + raise KeyError(f"Command {id!r} already registered") cmd = _RegisteredCommand(id, callback, title, self._injection_store) - commands.insert(0, cmd) + self._commands[id] = cmd def _dispose() -> None: - commands.remove(cmd) - if not commands: - del self._commands[id] + self._commands.pop(id, None) self.registered.emit(id) return _dispose - def __iter__(self) -> Iterator[Tuple[str, List[_RegisteredCommand]]]: + def __iter__(self) -> Iterator[Tuple[str, _RegisteredCommand]]: yield from self._commands.items() def __contains__(self, id: str) -> bool: @@ -118,7 +117,7 @@ def __repr__(self) -> str: name = self.__class__.__name__ return f"<{name} at {hex(id(self))} ({len(self._commands)} commands)>" - def __getitem__(self, id: str) -> List[_RegisteredCommand]: + def __getitem__(self, id: str) -> _RegisteredCommand: """Retrieve commands registered under a given ID.""" return self._commands[id] @@ -145,7 +144,7 @@ def execute_command( Returns ------- - Future: conconrent.futures.Future + Future: concurrent.futures.Future Future object containing the result of the command Raises @@ -153,14 +152,10 @@ def execute_command( KeyError If the command is not registered or has no callbacks. """ - if cmds := self[id]: - # TODO: decide whether we'll ever have more than one command - # and if so, how to handle it - cmd = cmds[0].run_injected - else: - raise KeyError( - f'Command "{id}" has no registered callbacks' - ) # pragma: no cover + try: + cmd = self._commands[id].run_injected + except KeyError as e: + raise KeyError(f"Command {id!r} not registered") from e if execute_asychronously: with ThreadPoolExecutor() as executor: @@ -170,12 +165,9 @@ def execute_command( try: future.set_result(cmd(*args, **kwargs)) except Exception as e: - print(e) future.set_exception(e) return future def __str__(self) -> str: - lines: list = [] - for id, cmds in self: - lines.extend(f"{id!r:<32} -> {cmd.title!r}" for cmd in cmds) + lines = [f"{id!r:<32} -> {cmd.title!r}" for id, cmd in self] return "\n".join(lines) diff --git a/src/app_model/registries/_register.py b/src/app_model/registries/_register.py index ce23c25..2882ba7 100644 --- a/src/app_model/registries/_register.py +++ b/src/app_model/registries/_register.py @@ -223,11 +223,13 @@ def _register_action_obj( # keybinding for keyb in action.keybindings or (): if action.enablement is not None: - _keyb = keyb.copy() - if _keyb.when is None: - _keyb.when = action.enablement - else: - _keyb.when = action.enablement | _keyb.when + kwargs = keyb.dict() + kwargs["when"] = ( + action.enablement + if keyb.when is None + else action.enablement | keyb.when + ) + _keyb = type(keyb)(**kwargs) else: _keyb = keyb if _d := app.keybindings.register_keybinding_rule(action.id, _keyb): diff --git a/src/app_model/types/_base.py b/src/app_model/types/_base.py index 27484dc..8066dd3 100644 --- a/src/app_model/types/_base.py +++ b/src/app_model/types/_base.py @@ -6,3 +6,4 @@ class _StrictModel(BaseModel): class Config: extra = Extra.forbid + frozen = True diff --git a/src/app_model/types/_command_rule.py b/src/app_model/types/_command_rule.py index b319b69..70d2e20 100644 --- a/src/app_model/types/_command_rule.py +++ b/src/app_model/types/_command_rule.py @@ -52,3 +52,7 @@ class CommandRule(_StrictModel): "the UI. Menus pick either `title` or `short_title` depending on the context " "in which they show commands.", ) + + def _as_command_rule(self) -> "CommandRule": + """Simplify (subclasses) to a plain CommandRule.""" + return CommandRule(**{f: getattr(self, f) for f in CommandRule.__fields__}) diff --git a/src/app_model/types/_icon.py b/src/app_model/types/_icon.py index 70847aa..a113715 100644 --- a/src/app_model/types/_icon.py +++ b/src/app_model/types/_icon.py @@ -31,6 +31,8 @@ def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: def validate(cls, v: Any) -> "Icon": """Validate icon.""" # if a single string is passed, use it for both light and dark. + if isinstance(v, Icon): + return v if isinstance(v, str): v = {"dark": v, "light": v} return cls(**v) diff --git a/src/app_model/types/_menu_rule.py b/src/app_model/types/_menu_rule.py index 3f0275f..7c059dd 100644 --- a/src/app_model/types/_menu_rule.py +++ b/src/app_model/types/_menu_rule.py @@ -1,6 +1,6 @@ from typing import Any, Callable, Generator, Optional, Type, TypedDict, Union -from pydantic import Field +from pydantic import Field, validator from .. import expressions from ._base import _StrictModel @@ -74,6 +74,12 @@ class MenuItem(_MenuItemBase): "selected, (e.g. when the Alt-key is held when opening the menu)", ) + @validator("command") + def _simplify_command_rule(cls, v: Any) -> CommandRule: + if isinstance(v, CommandRule): + return v._as_command_rule() + raise TypeError("command must be a CommandRule") + class SubmenuItem(_MenuItemBase): """Point to another Menu that will be displayed as a submenu.""" diff --git a/tests/conftest.py b/tests/conftest.py index 5b62c2f..02329c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import sys from pathlib import Path +from typing import List from unittest.mock import Mock import pytest @@ -25,6 +26,7 @@ class Menus: class Commands: + TOP = "top" OPEN = "open" UNDO = "undo" REDO = "redo" @@ -98,7 +100,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: ] ) - actions = [ + actions: List[Action] = [ Action( id=Commands.OPEN, title="Open...", @@ -160,9 +162,9 @@ def build_app(name: str = "complete_test_app") -> FullApp: ), # test the navigation key Action( - id=Commands.OPEN, + id=Commands.TOP, title="AtTop", - callback=app.mocks.open, + callback=lambda: None, menus=[{"id": Menus.EDIT, "group": "navigation"}], ), # test submenus diff --git a/tests/test_qt/test_qmenu.py b/tests/test_qt/test_qmenu.py index b4adc5c..d41a72a 100644 --- a/tests/test_qt/test_qmenu.py +++ b/tests/test_qt/test_qmenu.py @@ -5,7 +5,8 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QAction, QMainWindow -from app_model.backends.qt import QModelMenu +from app_model.backends.qt import QMenuItemAction, QModelMenu +from app_model.types import MenuItem if TYPE_CHECKING: from pytestqt.plugin import QtBot @@ -28,12 +29,13 @@ def test_menu(qtbot: "QtBot", full_app: "FullApp") -> None: # check that triggering the actions calls the associated commands for cmd in (app.Commands.UNDO, app.Commands.REDO): - action: QAction = menu.findChild(QAction, cmd) + action: QAction = menu.findAction(cmd) with qtbot.waitSignal(action.triggered): action.trigger() getattr(app.mocks, cmd).assert_called_once() - redo_action: QAction = menu.findChild(QAction, app.Commands.REDO) + redo_action: QAction = menu.findAction(app.Commands.REDO) + assert redo_action.isVisible() assert redo_action.isEnabled() @@ -68,7 +70,8 @@ def test_submenu(qtbot: "QtBot", full_app: "FullApp") -> None: menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["Open From...", "Open..."] - submenu: QModelMenu = menu.findChild(QModelMenu, app.Menus.FILE_OPEN_FROM) + submenu = menu.findChild(QModelMenu, app.Menus.FILE_OPEN_FROM) + assert isinstance(submenu, QModelMenu) submenu.setVisible(True) assert submenu.isVisible() assert submenu.isEnabled() @@ -107,10 +110,20 @@ def test_shortcuts(qtbot: "QtBot", full_app: "FullApp") -> None: with qtbot.waitExposed(win): win.show() - copy_action = menu.findChild(QAction, app.Commands.COPY) + copy_action = menu.findAction(app.Commands.COPY) + with qtbot.waitSignal(copy_action.triggered, timeout=1000): qtbot.keyClicks(win, "C", Qt.KeyboardModifier.ControlModifier) - paste_action = menu.findChild(QAction, app.Commands.PASTE) + paste_action = menu.findAction(app.Commands.PASTE) with qtbot.waitSignal(paste_action.triggered, timeout=1000): qtbot.keyClicks(win, "V", Qt.KeyboardModifier.ControlModifier) + + +def test_cache_action(full_app: "FullApp") -> None: + action = next( + i for k, items in full_app.menus for i in items if isinstance(i, MenuItem) + ) + a1 = QMenuItemAction(action, full_app) + a2 = QMenuItemAction(action, full_app) + assert a1 is a2 From 3ab22a200f44b62c1c3f9b7bc65ea710cc05bac1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Jul 2022 10:42:46 +0000 Subject: [PATCH 04/22] style: [pre-commit.ci] auto fixes [...] --- demo2/qapplication.py | 5 ++--- src/app_model/types/__init__.py | 9 ++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/demo2/qapplication.py b/demo2/qapplication.py index d47be55..23ce6fc 100644 --- a/demo2/qapplication.py +++ b/demo2/qapplication.py @@ -3,14 +3,13 @@ # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause from fonticon_fa6 import FA6S - -from qtpy.QtCore import QFile, QFileInfo, QSaveFile, QTextStream, Qt +from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import ( + QAction, QApplication, QFileDialog, QMainWindow, - QAction, QMessageBox, QTextEdit, ) diff --git a/src/app_model/types/__init__.py b/src/app_model/types/__init__.py index 3ffa88e..0c720cf 100644 --- a/src/app_model/types/__init__.py +++ b/src/app_model/types/__init__.py @@ -3,7 +3,14 @@ from ._command_rule import CommandRule from ._icon import Icon, IconOrDict from ._keybinding_rule import KeyBindingRule, KeyBindingRuleDict, KeyBindingRuleOrDict -from ._keys import KeyBinding, KeyChord, KeyCode, KeyMod, SimpleKeyBinding, StandardKeyBinding +from ._keys import ( + KeyBinding, + KeyChord, + KeyCode, + KeyMod, + SimpleKeyBinding, + StandardKeyBinding, +) from ._menu_rule import ( MenuItem, MenuOrSubmenu, From eada6bdf3e14146277b1e463213d3c58d302e7eb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Jul 2022 06:50:43 -0400 Subject: [PATCH 05/22] feat: cache qactions [wip] --- src/app_model/_app.py | 5 +++ src/app_model/backends/qt/_qaction.py | 46 +++++++++++++++++++++++---- src/app_model/types/_command_rule.py | 9 ++++++ src/app_model/types/_icon.py | 2 ++ src/app_model/types/_menu_rule.py | 8 ++++- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 15fceac..09fe60a 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, ClassVar, Dict, List, Tuple import in_n_out as ino +from psygnal import Signal from .registries import ( CommandsRegistry, @@ -20,6 +21,7 @@ class Application: """Full application model.""" + destroyed = Signal(str) _instances: ClassVar[Dict[str, Application]] = {} def __init__(self, name: str) -> None: @@ -48,9 +50,12 @@ def get_or_create(cls, name: str) -> Application: @classmethod def destroy(cls, name: str) -> None: """Destroy the app named `name`.""" + if name not in cls._instances: + return app = cls._instances.pop(name) app.dispose() app.injection_store.destroy(name) + app.destroyed.emit(app.name) def __del__(self) -> None: """Remove the app from the registry when it is garbage collected.""" diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index 35d1ee3..0e0cb0e 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Mapping, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Tuple, Type, Union, cast from qtpy.QtWidgets import QAction @@ -37,6 +37,7 @@ def __init__( super().__init__(parent) self._app = Application.get_or_create(app) if isinstance(app, str) else app self._command_id = command_id + self.setObjectName(command_id) if kb := self._app.keybindings.get_keybinding(command_id): self.setShortcut(QKeyBindingSequence(kb.keybinding)) self.triggered.connect(self._on_triggered) @@ -68,7 +69,6 @@ def __init__( ): super().__init__(command_rule.id, app, parent) self._cmd_rule = command_rule - self.setObjectName(command_rule.id) if use_short_title and command_rule.short_title: self.setText(command_rule.short_title) # pragma: no cover else: @@ -78,8 +78,6 @@ def __init__( if command_rule.tooltip: self.setToolTip(command_rule.tooltip) - self.setIconVisibleInMenu(False) - def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled state of this menu item from `ctx`.""" self.setEnabled(expr.eval(ctx) if (expr := self._cmd_rule.enablement) else True) @@ -92,16 +90,52 @@ class QMenuItemAction(QCommandRuleAction): to toggle visibility. """ + _cache: Dict[Tuple[int, int], QMenuItemAction] = {} + _cache_key: Tuple[int, int] = (0, 0) + + def __new__( + cls: Type[QMenuItemAction], + menu_item: MenuItem, + app: Union[str, Application], + **kwargs: Any, + ) -> QMenuItemAction: + """Create and cache a QMenuItemAction for the given menu item.""" + cache = kwargs.pop("cache", True) + app = Application.get_or_create(app) if isinstance(app, str) else app + key = (id(app), hash(menu_item)) + if cache and key in cls._cache: + return cls._cache[key] + + self = cast(QMenuItemAction, super().__new__(cls)) + if cache: + self._cache_key = key + cls._cache[key] = self + return self + def __init__( self, menu_item: MenuItem, app: Union[str, Application], parent: Optional[QObject] = None, + *, + cache: bool = True, ): - super().__init__(menu_item.command, app, parent) - self._menu_item = menu_item + if not getattr(self, "_initialized", False): + super().__init__(menu_item.command, app, parent) + self._menu_item = menu_item + self.destroyed.connect( + lambda: QMenuItemAction._cache.pop(self._cache_key, None) + ) + self._app.destroyed.connect( + lambda: QMenuItemAction._cache.pop(self._cache_key, None) + ) + self._initialized = True def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled/visible state of this menu item from `ctx`.""" super().update_from_context(ctx) self.setVisible(expr.eval(ctx) if (expr := self._menu_item.when) else True) + + def __repr__(self) -> str: + name = self.__class__.__name__ + return f"{name}({self._menu_item!r}, app={self._app.name!r})" diff --git a/src/app_model/types/_command_rule.py b/src/app_model/types/_command_rule.py index 4e7011c..70d2e20 100644 --- a/src/app_model/types/_command_rule.py +++ b/src/app_model/types/_command_rule.py @@ -30,6 +30,11 @@ class CommandRule(_StrictModel): tooltip: Optional[str] = Field( None, description="(Optional) Tooltip to show when hovered." ) + status_tip: Optional[str] = Field( + None, + description="(Optional) Help message to show in the status bar when a " + "button representing this command is hovered (For backends that support it).", + ) icon: Optional[Icon] = Field( None, description="(Optional) Icon used to represent this command, e.g. on buttons " @@ -47,3 +52,7 @@ class CommandRule(_StrictModel): "the UI. Menus pick either `title` or `short_title` depending on the context " "in which they show commands.", ) + + def _as_command_rule(self) -> "CommandRule": + """Simplify (subclasses) to a plain CommandRule.""" + return CommandRule(**{f: getattr(self, f) for f in CommandRule.__fields__}) diff --git a/src/app_model/types/_icon.py b/src/app_model/types/_icon.py index 70847aa..a113715 100644 --- a/src/app_model/types/_icon.py +++ b/src/app_model/types/_icon.py @@ -31,6 +31,8 @@ def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: def validate(cls, v: Any) -> "Icon": """Validate icon.""" # if a single string is passed, use it for both light and dark. + if isinstance(v, Icon): + return v if isinstance(v, str): v = {"dark": v, "light": v} return cls(**v) diff --git a/src/app_model/types/_menu_rule.py b/src/app_model/types/_menu_rule.py index 3f0275f..7c059dd 100644 --- a/src/app_model/types/_menu_rule.py +++ b/src/app_model/types/_menu_rule.py @@ -1,6 +1,6 @@ from typing import Any, Callable, Generator, Optional, Type, TypedDict, Union -from pydantic import Field +from pydantic import Field, validator from .. import expressions from ._base import _StrictModel @@ -74,6 +74,12 @@ class MenuItem(_MenuItemBase): "selected, (e.g. when the Alt-key is held when opening the menu)", ) + @validator("command") + def _simplify_command_rule(cls, v: Any) -> CommandRule: + if isinstance(v, CommandRule): + return v._as_command_rule() + raise TypeError("command must be a CommandRule") + class SubmenuItem(_MenuItemBase): """Point to another Menu that will be displayed as a submenu.""" From 3fc5f8e39c3afc9e05b38812df41dad96ba7f101 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Jul 2022 07:05:29 -0400 Subject: [PATCH 06/22] test: fix test --- src/app_model/_app.py | 8 -------- src/app_model/expressions/_expressions.py | 5 +++++ tests/conftest.py | 2 +- tests/test_app.py | 1 + 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 09fe60a..7b4127c 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -1,6 +1,5 @@ from __future__ import annotations -import contextlib from typing import TYPE_CHECKING, ClassVar, Dict, List, Tuple import in_n_out as ino @@ -50,18 +49,11 @@ def get_or_create(cls, name: str) -> Application: @classmethod def destroy(cls, name: str) -> None: """Destroy the app named `name`.""" - if name not in cls._instances: - return app = cls._instances.pop(name) app.dispose() app.injection_store.destroy(name) app.destroyed.emit(app.name) - def __del__(self) -> None: - """Remove the app from the registry when it is garbage collected.""" - with contextlib.suppress(KeyError): - Application.destroy(self.name) - @property def name(self) -> str: """Return the name of the app.""" diff --git a/src/app_model/expressions/_expressions.py b/src/app_model/expressions/_expressions.py index 690133d..10db612 100644 --- a/src/app_model/expressions/_expressions.py +++ b/src/app_model/expressions/_expressions.py @@ -320,6 +320,11 @@ def validate(cls, v: Any) -> Expr: """Validator for Expr. For use with Pydantic.""" return v if isinstance(v, Expr) else parse_expression(v) + def __hash__(self) -> int: + return hash(self.__class__) + hash( + tuple(getattr(self, f) for f in self._fields) + ) + LOAD = ast.Load() diff --git a/tests/conftest.py b/tests/conftest.py index 02329c9..c621e40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -206,7 +206,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: def full_app(monkeypatch) -> Application: """Premade application.""" try: - app = build_app() + app = build_app("complete_test_app") with monkeypatch.context() as m: # mock path to add fake_module m.setattr(sys, "path", [str(FIXTURES)] + sys.path) diff --git a/tests/test_app.py b/tests/test_app.py index 4a352e1..b28ffb1 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -22,6 +22,7 @@ def test_app_create(): Application("my_app") assert repr(app) == "Application('my_app')" + Application.destroy("my_app") def test_app(full_app: FullApp): From ecdee8b563a3acdd17ac7c62a6921866c28ec94f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Jul 2022 07:08:24 -0400 Subject: [PATCH 07/22] feat: add findAction and test --- src/app_model/backends/qt/_qmainwindow.py | 3 ++- src/app_model/backends/qt/_qmenu.py | 15 ++++++++++++-- tests/test_qt/test_qmenu.py | 25 +++++++++++++++++------ 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/app_model/backends/qt/_qmainwindow.py b/src/app_model/backends/qt/_qmainwindow.py index 6c4ec68..3783fba 100644 --- a/src/app_model/backends/qt/_qmainwindow.py +++ b/src/app_model/backends/qt/_qmainwindow.py @@ -16,7 +16,8 @@ def __init__( super().__init__(parent) self._app = Application.get_or_create(app) if isinstance(app, str) else app - def setModelMenuBar(self, menu_ids: List[str]) -> None: + def setModelMenuBar(self, menu_ids: List[str]) -> QModelMenuBar: """Set the menu bar to a list of menu ids.""" menu_bar = QModelMenuBar(menu_ids, self._app, self) self.setMenuBar(menu_bar) + return menu_bar diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index 7b2fce7..703688b 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -12,7 +12,7 @@ from ._util import to_qicon if TYPE_CHECKING: - from qtpy.QtWidgets import QWidget + from qtpy.QtWidgets import QAction, QWidget class QModelMenu(QMenu): @@ -62,7 +62,7 @@ def rebuild(self) -> None: for n, group in enumerate(groups): for item in group: if isinstance(item, SubmenuItem): - submenu = QModelSubmenu(item, self._app, self) + submenu = QModelSubmenu(item, self._app, parent=self) self.addMenu(submenu) else: action = QMenuItemAction(item, app=self._app, parent=self) @@ -102,6 +102,17 @@ def update_from_context( if _recurse: parent.update_from_context(ctx, _recurse=False) + def findAction(self, object_name: str) -> Union[QAction, QModelMenu, None]: + """Find an action by its ObjectName. + + Parameters + ---------- + object_name : str + Action ID to find. Note that `QCommandAction` have `ObjectName` set + to their `command.id` + """ + return next((a for a in self.actions() if a.objectName() == object_name), None) + class QModelSubmenu(QModelMenu): """QMenu for a menu_id in an `app_model` MenusRegistry. diff --git a/tests/test_qt/test_qmenu.py b/tests/test_qt/test_qmenu.py index b4adc5c..d41a72a 100644 --- a/tests/test_qt/test_qmenu.py +++ b/tests/test_qt/test_qmenu.py @@ -5,7 +5,8 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QAction, QMainWindow -from app_model.backends.qt import QModelMenu +from app_model.backends.qt import QMenuItemAction, QModelMenu +from app_model.types import MenuItem if TYPE_CHECKING: from pytestqt.plugin import QtBot @@ -28,12 +29,13 @@ def test_menu(qtbot: "QtBot", full_app: "FullApp") -> None: # check that triggering the actions calls the associated commands for cmd in (app.Commands.UNDO, app.Commands.REDO): - action: QAction = menu.findChild(QAction, cmd) + action: QAction = menu.findAction(cmd) with qtbot.waitSignal(action.triggered): action.trigger() getattr(app.mocks, cmd).assert_called_once() - redo_action: QAction = menu.findChild(QAction, app.Commands.REDO) + redo_action: QAction = menu.findAction(app.Commands.REDO) + assert redo_action.isVisible() assert redo_action.isEnabled() @@ -68,7 +70,8 @@ def test_submenu(qtbot: "QtBot", full_app: "FullApp") -> None: menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["Open From...", "Open..."] - submenu: QModelMenu = menu.findChild(QModelMenu, app.Menus.FILE_OPEN_FROM) + submenu = menu.findChild(QModelMenu, app.Menus.FILE_OPEN_FROM) + assert isinstance(submenu, QModelMenu) submenu.setVisible(True) assert submenu.isVisible() assert submenu.isEnabled() @@ -107,10 +110,20 @@ def test_shortcuts(qtbot: "QtBot", full_app: "FullApp") -> None: with qtbot.waitExposed(win): win.show() - copy_action = menu.findChild(QAction, app.Commands.COPY) + copy_action = menu.findAction(app.Commands.COPY) + with qtbot.waitSignal(copy_action.triggered, timeout=1000): qtbot.keyClicks(win, "C", Qt.KeyboardModifier.ControlModifier) - paste_action = menu.findChild(QAction, app.Commands.PASTE) + paste_action = menu.findAction(app.Commands.PASTE) with qtbot.waitSignal(paste_action.triggered, timeout=1000): qtbot.keyClicks(win, "V", Qt.KeyboardModifier.ControlModifier) + + +def test_cache_action(full_app: "FullApp") -> None: + action = next( + i for k, items in full_app.menus for i in items if isinstance(i, MenuItem) + ) + a1 = QMenuItemAction(action, full_app) + a2 = QMenuItemAction(action, full_app) + assert a1 is a2 From 8f3f45d2257f05dab5f6eb5002758a0c1e939758 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Jul 2022 07:13:32 -0400 Subject: [PATCH 08/22] fix: fix for pyqt --- src/app_model/backends/qt/_qaction.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index 0e0cb0e..933dbb9 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Tuple, Type, Union, cast from qtpy.QtWidgets import QAction @@ -120,7 +121,11 @@ def __init__( *, cache: bool = True, ): - if not getattr(self, "_initialized", False): + initialized = False + with contextlib.suppress(RuntimeError): + initialized = getattr(self, "_initialized", False) + + if not initialized: super().__init__(menu_item.command, app, parent) self._menu_item = menu_item self.destroyed.connect( From 3ad1810e4bde1ad668e22648569570c36f44aa0b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Jul 2022 07:37:19 -0400 Subject: [PATCH 09/22] fix: add close method --- demo2/model_app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/demo2/model_app.py b/demo2/model_app.py index 6791d10..c952c83 100644 --- a/demo2/model_app.py +++ b/demo2/model_app.py @@ -120,6 +120,9 @@ def copy(self): def paste(self): self._text_edit.paste() + def close(self): + super().close() + # Actions defined declaratively outside of QMainWindow class ... # menus and toolbars will be made and added automatically From 1492e99d86db0ddaeb23599d9005c0e0bb39b8d4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Jul 2022 14:15:13 -0400 Subject: [PATCH 10/22] docs: demo closer --- demo2/model_app.py | 15 +++- src/app_model/backends/qt/_qaction.py | 2 + src/app_model/backends/qt/_qmainwindow.py | 18 +++- src/app_model/backends/qt/_qmenu.py | 104 +++++++++++++++++----- 4 files changed, 114 insertions(+), 25 deletions(-) diff --git a/demo2/model_app.py b/demo2/model_app.py index c952c83..419a896 100644 --- a/demo2/model_app.py +++ b/demo2/model_app.py @@ -1,3 +1,4 @@ +from typing import List from fonticon_fa6 import FA6S from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream from qtpy.QtWidgets import QApplication, QFileDialog, QMessageBox, QTextEdit @@ -14,6 +15,9 @@ def __init__(self, app: Application): self._text_edit = QTextEdit() self.setCentralWidget(self._text_edit) self.setModelMenuBar([MenuId.FILE, MenuId.EDIT, MenuId.HELP]) + self.addModelToolBar(MenuId.FILE, exclude={CommandId.SAVE_AS, CommandId.EXIT}) + self.addModelToolBar(MenuId.EDIT) + self.statusBar().showMessage("Ready") self.set_current_file("") @@ -134,7 +138,12 @@ class MenuId: HELP = "help" -ACTIONS = [ +class CommandId: + SAVE_AS = "save_file_as" + EXIT = "exit" + + +ACTIONS: List[types.Action] = [ types.Action( id="new_file", icon=FA6S.file_circle_plus, @@ -163,7 +172,7 @@ class MenuId: callback=MainWindow.save, ), types.Action( - id="save_file_as", + id=CommandId.SAVE_AS, title="Save As...", keybindings=[types.StandardKeyBinding.SaveAs], status_tip="Save the document under a new name", @@ -171,7 +180,7 @@ class MenuId: callback=MainWindow.save_as, ), types.Action( - id="close", + id=CommandId.EXIT, title="Exit", keybindings=[types.StandardKeyBinding.Quit], status_tip="Exit the application", diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index f9f6b01..748e77f 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -78,6 +78,8 @@ def __init__( self.setIcon(to_qicon(command_rule.icon)) if command_rule.tooltip: self.setToolTip(command_rule.tooltip) + if command_rule.status_tip: + self.setStatusTip(command_rule.status_tip) def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled state of this menu item from `ctx`.""" diff --git a/src/app_model/backends/qt/_qmainwindow.py b/src/app_model/backends/qt/_qmainwindow.py index 3783fba..39a7c8a 100644 --- a/src/app_model/backends/qt/_qmainwindow.py +++ b/src/app_model/backends/qt/_qmainwindow.py @@ -1,10 +1,12 @@ -from typing import List, Optional, Union +from typing import Collection, List, Optional, Union from qtpy.QtWidgets import QMainWindow, QWidget +from qtpy.QtCore import Qt from app_model import Application from ._qmenubar import QModelMenuBar +from ._qmenu import QModelToolBar class QModelMainWindow(QMainWindow): @@ -21,3 +23,17 @@ def setModelMenuBar(self, menu_ids: List[str]) -> QModelMenuBar: menu_bar = QModelMenuBar(menu_ids, self._app, self) self.setMenuBar(menu_bar) return menu_bar + + def addModelToolBar( + self, + menu_id: str, + *, + exclude: Optional[Collection[str]] = None, + area: Optional[Qt.ToolBarArea] = None + ) -> None: + """Add a tool bar to the main window.""" + menu_bar = QModelToolBar(menu_id, self._app, exclude=exclude, parent=self) + if area is not None: + self.addToolBar(area, menu_bar) + else: + self.addToolBar(menu_bar) diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index 703688b..4906e55 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -1,9 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Mapping, Optional, Set, Union +from typing import ( + TYPE_CHECKING, + Callable, + Collection, + Mapping, + Optional, + Protocol, + Set, + Union, +) from qtpy import QT6 -from qtpy.QtWidgets import QMenu +from qtpy.QtWidgets import QMenu, QToolBar, QWidget +from qtpy.QtCore import QObject from app_model import Application from app_model.types import SubmenuItem @@ -14,34 +24,30 @@ if TYPE_CHECKING: from qtpy.QtWidgets import QAction, QWidget +# fmt: off +class _AcceptsMenus(Protocol): + _app: Application + def clear(self) -> None: ... + def addMenu(self, menu: QMenu): ... + def addAction(self, menu: QAction): ... + def addSeparator(self): ... -class QModelMenu(QMenu): - """QMenu for a menu_id in an `app_model` MenusRegistry. +# fmt: on - Parameters - ---------- - menu_id : str - Menu ID to look up in the registry. - app : Union[str, Application] - Application instance or name of application instance. - parent : Optional[QWidget] - Optional parent widget, by default None - """ + +class _MenuMixin(QObject): + _app: Application + _menu_id: str def __init__( self, menu_id: str, app: Union[str, Application], - title: Optional[str] = None, - parent: Optional[QWidget] = None, ): assert isinstance(menu_id, str), f"Expected str, got {type(menu_id)!r}" self._menu_id = menu_id - super().__init__(parent) self._app = Application.get_or_create(app) if isinstance(app, str) else app self.setObjectName(menu_id) - if title is not None: - self.setTitle(title) self.rebuild() self._app.menus.menus_changed.connect(self._on_registry_changed) self.destroyed.connect(self._disconnect) @@ -53,18 +59,23 @@ def _on_registry_changed(self, changed_ids: Set[str]) -> None: if self._menu_id in changed_ids: self.rebuild() - def rebuild(self) -> None: + def rebuild( + self: _AcceptsMenus, + include_submenus=True, + exclude: Optional[Collection[str]] = None, + ) -> None: """Rebuild menu by looking up self._menu_id in menu_registry.""" self.clear() + _exclude = exclude or set() groups = list(self._app.menus.iter_menu_groups(self._menu_id)) n_groups = len(groups) for n, group in enumerate(groups): for item in group: - if isinstance(item, SubmenuItem): + if isinstance(item, SubmenuItem) and include_submenus: submenu = QModelSubmenu(item, self._app, parent=self) self.addMenu(submenu) - else: + elif item.command.id not in _exclude: action = QMenuItemAction(item, app=self._app, parent=self) self.addAction(action) if n < n_groups - 1: @@ -102,6 +113,32 @@ def update_from_context( if _recurse: parent.update_from_context(ctx, _recurse=False) + +class QModelMenu(QMenu, _MenuMixin): + """QMenu for a menu_id in an `app_model` MenusRegistry. + + Parameters + ---------- + menu_id : str + Menu ID to look up in the registry. + app : Union[str, Application] + Application instance or name of application instance. + parent : Optional[QWidget] + Optional parent widget, by default None + """ + + def __init__( + self, + menu_id: str, + app: Union[str, Application], + title: Optional[str] = None, + parent: Optional[QWidget] = None, + ): + QMenu.__init__(self, parent) + _MenuMixin.__init__(self, menu_id, app) + if title is not None: + self.setTitle(title) + def findAction(self, object_name: str) -> Union[QAction, QModelMenu, None]: """Find an action by its ObjectName. @@ -150,3 +187,28 @@ def update_from_context( # TODO: ... visibility needs to be controlled at the level of placement # in the submenu. consider only using the `when` expression # self.setVisible(expr.eval(ctx) if (expr := self._submenu.when) else True) + + +class QModelToolBar(QToolBar, _MenuMixin): + """QToolBar that is built from a list of model menu ids.""" + + def __init__( + self, + menu_id: str, + app: Union[str, Application], + *, + exclude: Optional[Collection[str]] = None, + title: Optional[str] = None, + parent: Optional[QWidget] = None, + ) -> None: + self._exclude = exclude + QToolBar.__init__(self, parent) + _MenuMixin.__init__(self, menu_id, app) + if title is not None: + self.setWindowTitle(title) + + def rebuild(self, include_submenus=False) -> None: + super().rebuild(include_submenus=include_submenus, exclude=self._exclude) + + def addMenu(self, menu: QMenu): + pass From f817f3388b5c513aae7e14b7590722496ba69a9e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Jul 2022 14:22:10 -0400 Subject: [PATCH 11/22] chore: formatting --- demo2/model_app.py | 1 + src/app_model/backends/qt/_qmainwindow.py | 4 ++-- src/app_model/backends/qt/_qmenu.py | 13 ++----------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/demo2/model_app.py b/demo2/model_app.py index 419a896..dc675e9 100644 --- a/demo2/model_app.py +++ b/demo2/model_app.py @@ -1,4 +1,5 @@ from typing import List + from fonticon_fa6 import FA6S from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream from qtpy.QtWidgets import QApplication, QFileDialog, QMessageBox, QTextEdit diff --git a/src/app_model/backends/qt/_qmainwindow.py b/src/app_model/backends/qt/_qmainwindow.py index 39a7c8a..856fceb 100644 --- a/src/app_model/backends/qt/_qmainwindow.py +++ b/src/app_model/backends/qt/_qmainwindow.py @@ -1,12 +1,12 @@ from typing import Collection, List, Optional, Union -from qtpy.QtWidgets import QMainWindow, QWidget from qtpy.QtCore import Qt +from qtpy.QtWidgets import QMainWindow, QWidget from app_model import Application -from ._qmenubar import QModelMenuBar from ._qmenu import QModelToolBar +from ._qmenubar import QModelMenuBar class QModelMainWindow(QMainWindow): diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index 4906e55..557bedc 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -1,19 +1,10 @@ from __future__ import annotations -from typing import ( - TYPE_CHECKING, - Callable, - Collection, - Mapping, - Optional, - Protocol, - Set, - Union, -) +from typing import TYPE_CHECKING, Collection, Mapping, Optional, Protocol, Set, Union from qtpy import QT6 -from qtpy.QtWidgets import QMenu, QToolBar, QWidget from qtpy.QtCore import QObject +from qtpy.QtWidgets import QMenu, QToolBar, QWidget from app_model import Application from app_model.types import SubmenuItem From 0038f6bdd479e17f83fd2a537c489101a08d4801 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Jul 2022 15:02:48 -0400 Subject: [PATCH 12/22] fix: working context --- demo2/model_app.py | 13 +++++ src/app_model/backends/qt/__init__.py | 3 +- src/app_model/backends/qt/_qmainwindow.py | 3 +- src/app_model/backends/qt/_qmenu.py | 60 ++++++++++++++++++++++- src/app_model/backends/qt/_qmenubar.py | 34 ++++++++++++- src/app_model/expressions/_context.py | 4 +- 6 files changed, 108 insertions(+), 9 deletions(-) diff --git a/demo2/model_app.py b/demo2/model_app.py index dc675e9..37a09e7 100644 --- a/demo2/model_app.py +++ b/demo2/model_app.py @@ -6,6 +6,7 @@ from app_model import Application, types from app_model.backends.qt import QModelMainWindow +from app_model.expressions import create_context class MainWindow(QModelMainWindow): @@ -14,6 +15,8 @@ def __init__(self, app: Application): self._cur_file: str = "" self._text_edit = QTextEdit() + self._text_edit.copyAvailable.connect(self._update_context) + self.setCentralWidget(self._text_edit) self.setModelMenuBar([MenuId.FILE, MenuId.EDIT, MenuId.HELP]) self.addModelToolBar(MenuId.FILE, exclude={CommandId.SAVE_AS, CommandId.EXIT}) @@ -22,6 +25,16 @@ def __init__(self, app: Application): self.set_current_file("") + self._ctx = create_context(self) + self._ctx.changed.connect(self._on_context_changed) + self._ctx['copyAvailable'] = False + + def _update_context(self, available: bool): + self._ctx['copyAvailable'] = available + + def _on_context_changed(self): + self.menuBar().update_from_context(self._ctx) + def set_current_file(self, fileName: str) -> None: self._cur_file = fileName self._text_edit.document().setModified(False) diff --git a/src/app_model/backends/qt/__init__.py b/src/app_model/backends/qt/__init__.py index db0b388..13d9481 100644 --- a/src/app_model/backends/qt/__init__.py +++ b/src/app_model/backends/qt/__init__.py @@ -2,8 +2,7 @@ from ._qaction import QCommandAction, QCommandRuleAction, QMenuItemAction from ._qkeymap import QKeyBindingSequence from ._qmainwindow import QModelMainWindow -from ._qmenu import QModelMenu, QModelSubmenu -from ._qmenubar import QModelMenuBar +from ._qmenu import QModelMenu, QModelMenuBar, QModelSubmenu from ._util import to_qicon __all__ = [ diff --git a/src/app_model/backends/qt/_qmainwindow.py b/src/app_model/backends/qt/_qmainwindow.py index 856fceb..b8ab632 100644 --- a/src/app_model/backends/qt/_qmainwindow.py +++ b/src/app_model/backends/qt/_qmainwindow.py @@ -5,8 +5,7 @@ from app_model import Application -from ._qmenu import QModelToolBar -from ._qmenubar import QModelMenuBar +from ._qmenu import QModelMenuBar, QModelToolBar class QModelMainWindow(QMainWindow): diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index 557bedc..09f9203 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -1,10 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Collection, Mapping, Optional, Protocol, Set, Union +from typing import ( + TYPE_CHECKING, + Collection, + List, + Mapping, + Optional, + Protocol, + Set, + Union, +) from qtpy import QT6 from qtpy.QtCore import QObject -from qtpy.QtWidgets import QMenu, QToolBar, QWidget +from qtpy.QtWidgets import QMenu, QMenuBar, QToolBar, QWidget from app_model import Application from app_model.types import SubmenuItem @@ -203,3 +212,50 @@ def rebuild(self, include_submenus=False) -> None: def addMenu(self, menu: QMenu): pass + + +class QModelMenuBar(QMenuBar): + """QMenuBar that is built from a list of model menu ids.""" + + def __init__( + self, + menus: List[str], + app: Union[str, Application], + parent: Optional[QWidget] = None, + ) -> None: + super().__init__(parent) + + for menu_id in menus: + self.addMenu(QModelMenu(menu_id, app, "File", self)) + + def update_from_context( + self, ctx: Mapping[str, object], _recurse: bool = True + ) -> None: + """Update the enabled/visible state of each menu item with `ctx`. + + See `app_model.expressions` for details on expressions. + + Parameters + ---------- + ctx : Mapping + A namepsace that will be used to `eval()` the `'enablement'` and + `'when'` expressions provided for each action in the menu. + *ALL variables used in these expressions must either be present in + the `ctx` dict, or be builtins*. + _recurse : bool + recursion check, internal use only + """ + for action in self.actions(): + if isinstance(action, QMenuItemAction): + action.update_from_context(ctx) + elif not QT6 and isinstance(menu := action.menu(), QModelMenu): + menu.update_from_context(ctx) + elif isinstance(parent := action.parent(), QModelMenu): + # FIXME: this is a hack for Qt6 that I don't entirely understand. + # QAction has lost the `.menu()` method, and it's a bit hard to find + # how to get to the parent menu now. Checking parent() seems to work, + # but I'm not sure if it's the right thing to do, and it leads to a + # recursion error. I stop it with the _recurse flag here, but I wonder + # whether that will cause other problems. + if _recurse: + parent.update_from_context(ctx, _recurse=False) diff --git a/src/app_model/backends/qt/_qmenubar.py b/src/app_model/backends/qt/_qmenubar.py index 0b3ed46..e73a2f0 100644 --- a/src/app_model/backends/qt/_qmenubar.py +++ b/src/app_model/backends/qt/_qmenubar.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import List, Mapping, Optional, Union from qtpy.QtWidgets import QMenuBar, QWidget @@ -19,3 +19,35 @@ def __init__( for menu_id in menus: self.addMenu(QModelMenu(menu_id, app, "File", self)) + + def update_from_context( + self, ctx: Mapping[str, object], _recurse: bool = True + ) -> None: + """Update the enabled/visible state of each menu item with `ctx`. + + See `app_model.expressions` for details on expressions. + + Parameters + ---------- + ctx : Mapping + A namepsace that will be used to `eval()` the `'enablement'` and + `'when'` expressions provided for each action in the menu. + *ALL variables used in these expressions must either be present in + the `ctx` dict, or be builtins*. + _recurse : bool + recursion check, internal use only + """ + for action in self.actions(): + if isinstance(action, QMenuItemAction): + action.update_from_context(ctx) + elif not QT6 and isinstance(menu := action.menu(), QModelMenu): + menu.update_from_context(ctx) + elif isinstance(parent := action.parent(), QModelMenu): + # FIXME: this is a hack for Qt6 that I don't entirely understand. + # QAction has lost the `.menu()` method, and it's a bit hard to find + # how to get to the parent menu now. Checking parent() seems to work, + # but I'm not sure if it's the right thing to do, and it leads to a + # recursion error. I stop it with the _recurse flag here, but I wonder + # whether that will cause other problems. + if _recurse: + parent.update_from_context(ctx, _recurse=False) diff --git a/src/app_model/expressions/_context.py b/src/app_model/expressions/_context.py index 99e5570..cfce123 100644 --- a/src/app_model/expressions/_context.py +++ b/src/app_model/expressions/_context.py @@ -75,7 +75,7 @@ def create_context( root: Optional[Context] = None, root_class: Type[Context] = Context, frame_predicate: Callable[[FrameType], bool] = _pydantic_abort, -) -> Optional[Context]: +) -> Context: """Create context for any object. Parameters @@ -120,7 +120,7 @@ def create_context( # to scope this new context off of. while frame and (i := i + 1) < max_depth: if frame_predicate(frame): - return None # pragma: no cover + return root # pragma: no cover # FIXME: should this be allowed? # FIXME: this might be a bit napari "magic" # it also assumes someone uses "self" as the first argument From 5e414412d0f64c65a9f7f8da67e1c9c7d520e515 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Jul 2022 19:03:06 +0000 Subject: [PATCH 13/22] style: [pre-commit.ci] auto fixes [...] --- demo2/model_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo2/model_app.py b/demo2/model_app.py index 37a09e7..9879802 100644 --- a/demo2/model_app.py +++ b/demo2/model_app.py @@ -27,10 +27,10 @@ def __init__(self, app: Application): self._ctx = create_context(self) self._ctx.changed.connect(self._on_context_changed) - self._ctx['copyAvailable'] = False + self._ctx["copyAvailable"] = False def _update_context(self, available: bool): - self._ctx['copyAvailable'] = available + self._ctx["copyAvailable"] = available def _on_context_changed(self): self.menuBar().update_from_context(self._ctx) From 53ab0fb8d484742b91f58064cd04a17cff3435d8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Jul 2022 19:36:19 -0400 Subject: [PATCH 14/22] test: move demo, add test --- {demo2 => demo}/model_app.py | 0 {demo_app => demo/multi_file}/__init__.py | 0 demo/multi_file/__main__.py | 12 ++++++++++++ {demo_app => demo/multi_file}/actions.py | 0 {demo_app => demo/multi_file}/app.py | 0 {demo_app => demo/multi_file}/constants.py | 0 {demo_app => demo/multi_file}/functions.py | 0 {demo2 => demo}/qapplication.py | 0 demo_app/__main__.py | 7 ------- pyproject.toml | 2 +- tests/test_demos.py | 13 +++++++++++++ 11 files changed, 26 insertions(+), 8 deletions(-) rename {demo2 => demo}/model_app.py (100%) rename {demo_app => demo/multi_file}/__init__.py (100%) create mode 100644 demo/multi_file/__main__.py rename {demo_app => demo/multi_file}/actions.py (100%) rename {demo_app => demo/multi_file}/app.py (100%) rename {demo_app => demo/multi_file}/constants.py (100%) rename {demo_app => demo/multi_file}/functions.py (100%) rename {demo2 => demo}/qapplication.py (100%) delete mode 100644 demo_app/__main__.py create mode 100644 tests/test_demos.py diff --git a/demo2/model_app.py b/demo/model_app.py similarity index 100% rename from demo2/model_app.py rename to demo/model_app.py diff --git a/demo_app/__init__.py b/demo/multi_file/__init__.py similarity index 100% rename from demo_app/__init__.py rename to demo/multi_file/__init__.py diff --git a/demo/multi_file/__main__.py b/demo/multi_file/__main__.py new file mode 100644 index 0000000..63d3d19 --- /dev/null +++ b/demo/multi_file/__main__.py @@ -0,0 +1,12 @@ +import pathlib +import sys + +sys.path.append(str(pathlib.Path(__file__).parent.parent)) + +from multi_file.app import MyApp # noqa: E402 +from qtpy.QtWidgets import QApplication # noqa: E402 + +qapp = QApplication([]) +app = MyApp() +app.show() +qapp.exec_() diff --git a/demo_app/actions.py b/demo/multi_file/actions.py similarity index 100% rename from demo_app/actions.py rename to demo/multi_file/actions.py diff --git a/demo_app/app.py b/demo/multi_file/app.py similarity index 100% rename from demo_app/app.py rename to demo/multi_file/app.py diff --git a/demo_app/constants.py b/demo/multi_file/constants.py similarity index 100% rename from demo_app/constants.py rename to demo/multi_file/constants.py diff --git a/demo_app/functions.py b/demo/multi_file/functions.py similarity index 100% rename from demo_app/functions.py rename to demo/multi_file/functions.py diff --git a/demo2/qapplication.py b/demo/qapplication.py similarity index 100% rename from demo2/qapplication.py rename to demo/qapplication.py diff --git a/demo_app/__main__.py b/demo_app/__main__.py deleted file mode 100644 index cbaa784..0000000 --- a/demo_app/__main__.py +++ /dev/null @@ -1,7 +0,0 @@ -from demo_app.app import MyApp -from qtpy.QtWidgets import QApplication - -qapp = QApplication([]) -app = MyApp() -app.show() -qapp.exec_() diff --git a/pyproject.toml b/pyproject.toml index cea8fd0..fb75939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ W503 # line break before binary operator B010 """ per-file-ignores = [ - "tests/*,demo_app/*: D", + "tests/*,demo/*: D", "src/app_model/_registries.py,src/app_model/context/_expressions.py: D10", ] diff --git a/tests/test_demos.py b/tests/test_demos.py new file mode 100644 index 0000000..1549be5 --- /dev/null +++ b/tests/test_demos.py @@ -0,0 +1,13 @@ +import runpy +from pathlib import Path + +import pytest +from qtpy.QtWidgets import QApplication + +DEMO = Path(__file__).parent.parent / "demo" + + +@pytest.mark.parametrize("fname", ["qapplication.py", "model_app.py"]) +def test_qapp(fname, monkeypatch): + monkeypatch.setattr(QApplication, "exec_", lambda *a, **k: None) + runpy.run_path(str(DEMO / fname), run_name="__main__") From 9bd0425df398b6093dfc5254bc46a82799a0ad0e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Jul 2022 19:58:49 -0400 Subject: [PATCH 15/22] fix: lint --- src/app_model/backends/qt/_qmenu.py | 30 +++++++++++++++----------- src/app_model/backends/qt/_qmenubar.py | 4 +++- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index 09f9203..490d206 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -13,7 +13,7 @@ from qtpy import QT6 from qtpy.QtCore import QObject -from qtpy.QtWidgets import QMenu, QMenuBar, QToolBar, QWidget +from qtpy.QtWidgets import QMenu, QMenuBar, QToolBar from app_model import Application from app_model.types import SubmenuItem @@ -24,13 +24,14 @@ if TYPE_CHECKING: from qtpy.QtWidgets import QAction, QWidget + # fmt: off class _AcceptsMenus(Protocol): _app: Application - def clear(self) -> None: ... - def addMenu(self, menu: QMenu): ... - def addAction(self, menu: QAction): ... - def addSeparator(self): ... + def clear(self) -> None: ... # noqa: E704 + def addMenu(self, menu: QMenu) -> None: ... # noqa: E704 + def addAction(self, menu: QAction) -> None: ... # noqa: E704 + def addSeparator(self) -> None: ... # noqa: E704 # fmt: on @@ -60,8 +61,8 @@ def _on_registry_changed(self, changed_ids: Set[str]) -> None: self.rebuild() def rebuild( - self: _AcceptsMenus, - include_submenus=True, + self: _MenuMixin, + include_submenus: bool = True, exclude: Optional[Collection[str]] = None, ) -> None: """Rebuild menu by looking up self._menu_id in menu_registry.""" @@ -75,8 +76,10 @@ def rebuild( if isinstance(item, SubmenuItem) and include_submenus: submenu = QModelSubmenu(item, self._app, parent=self) self.addMenu(submenu) - elif item.command.id not in _exclude: - action = QMenuItemAction(item, app=self._app, parent=self) + elif item.command.id not in _exclude: # type: ignore + action = QMenuItemAction( + item, app=self._app, parent=self # type: ignore + ) self.addAction(action) if n < n_groups - 1: self.addSeparator() @@ -207,11 +210,14 @@ def __init__( if title is not None: self.setWindowTitle(title) - def rebuild(self, include_submenus=False) -> None: + def rebuild( + self, include_submenus: bool = True, exclude: Optional[Collection[str]] = None + ) -> None: + """Rebuild toolbar by looking up self._menu_id in menu_registry.""" super().rebuild(include_submenus=include_submenus, exclude=self._exclude) - def addMenu(self, menu: QMenu): - pass + def addMenu(self, menu: QMenu) -> None: + """No-op for toolbar.""" class QModelMenuBar(QMenuBar): diff --git a/src/app_model/backends/qt/_qmenubar.py b/src/app_model/backends/qt/_qmenubar.py index e73a2f0..560692f 100644 --- a/src/app_model/backends/qt/_qmenubar.py +++ b/src/app_model/backends/qt/_qmenubar.py @@ -1,8 +1,10 @@ from typing import List, Mapping, Optional, Union +from qtpy import QT6 from qtpy.QtWidgets import QMenuBar, QWidget -from ..._app import Application +from ... import Application +from ._qaction import QMenuItemAction from ._qmenu import QModelMenu From a2a974f475ed4b4f136a0f6438d6dfda25089586 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Jul 2022 20:44:35 -0400 Subject: [PATCH 16/22] fix: fix fonticon --- demo/multi_file/actions.py | 16 ++++++++-------- pyproject.toml | 2 +- src/app_model/types/_command_rule.py | 2 +- src/app_model/types/_icon.py | 4 ++-- src/app_model/types/_menu_rule.py | 2 +- tests/conftest.py | 14 +++++++------- tests/test_types.py | 4 ++-- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/demo/multi_file/actions.py b/demo/multi_file/actions.py index 9d2b62b..9dd2197 100644 --- a/demo/multi_file/actions.py +++ b/demo/multi_file/actions.py @@ -1,6 +1,6 @@ from typing import List -from fonticon_fa5 import FA5S +from fonticon_fa6 import FA6S from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod, MenuRule @@ -11,7 +11,7 @@ Action( id=CommandId.OPEN, title="Open", - icon=FA5S.folder_open, + icon=FA6S.folder_open, callback=functions.open_file, menus=[MenuRule(id=MenuId.FILE)], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO)], @@ -19,7 +19,7 @@ Action( id=CommandId.CLOSE, title="Close", - icon=FA5S.window_close, + icon=FA6S.window_close, callback=functions.close, menus=[MenuRule(id=MenuId.FILE)], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], @@ -27,7 +27,7 @@ Action( id=CommandId.UNDO, title="Undo", - icon=FA5S.undo, + icon=FA6S.undo, callback=functions.undo, menus=[MenuRule(id=MenuId.EDIT, group="1_undo_redo")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyZ)], @@ -35,7 +35,7 @@ Action( id=CommandId.REDO, title="Redo", - icon=FA5S.redo, + icon=FA6S.rotate_right, callback=functions.redo, menus=[MenuRule(id=MenuId.EDIT, group="1_undo_redo")], keybindings=[ @@ -45,7 +45,7 @@ Action( id=CommandId.CUT, title="Cut", - icon=FA5S.cut, + icon=FA6S.cut, callback=functions.cut, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyX)], @@ -53,7 +53,7 @@ Action( id=CommandId.COPY, title="Copy", - icon=FA5S.copy, + icon=FA6S.copy, callback=functions.copy, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyC)], @@ -61,7 +61,7 @@ Action( id=CommandId.PASTE, title="Paste", - icon=FA5S.paste, + icon=FA6S.paste, callback=functions.paste, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyV)], diff --git a/pyproject.toml b/pyproject.toml index fb75939..7a37b0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = ['psygnal', 'pydantic', 'in-n-out', 'typing_extensions>=4.0'] # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] test = ["pytest>=6.0", "pytest-cov"] -test_qt = ["pytest-qt", "fonticon-fontawesome5"] +test_qt = ["pytest-qt", "fonticon-fontawesome6"] qt = ["qtpy", "superqt"] dev = [ "black", diff --git a/src/app_model/types/_command_rule.py b/src/app_model/types/_command_rule.py index 70d2e20..2b7aa42 100644 --- a/src/app_model/types/_command_rule.py +++ b/src/app_model/types/_command_rule.py @@ -38,7 +38,7 @@ class CommandRule(_StrictModel): icon: Optional[Icon] = Field( None, description="(Optional) Icon used to represent this command, e.g. on buttons " - "or in menus. These may be superqt fonticon keys, such as `fa5s.arrow_down`", + "or in menus. These may be superqt fonticon keys, such as `fa6s.arrow_down`", ) enablement: Optional[expressions.Expr] = Field( None, diff --git a/src/app_model/types/_icon.py b/src/app_model/types/_icon.py index a113715..831f669 100644 --- a/src/app_model/types/_icon.py +++ b/src/app_model/types/_icon.py @@ -15,12 +15,12 @@ class Icon(_StrictModel): dark: Optional[str] = Field( None, description="Icon path when a dark theme is used. These may be superqt " - "fonticon keys, such as `fa5s.arrow_down`", + "fonticon keys, such as `fa6s.arrow_down`", ) light: Optional[str] = Field( None, description="Icon path when a light theme is used. These may be superqt " - "fonticon keys, such as `fa5s.arrow_down`", + "fonticon keys, such as `fa6s.arrow_down`", ) @classmethod diff --git a/src/app_model/types/_menu_rule.py b/src/app_model/types/_menu_rule.py index 7c059dd..95b3108 100644 --- a/src/app_model/types/_menu_rule.py +++ b/src/app_model/types/_menu_rule.py @@ -89,7 +89,7 @@ class SubmenuItem(_MenuItemBase): icon: Optional[Icon] = Field( None, description="(Optional) Icon used to represent this submenu. " - "These may be superqt fonticon keys, such as `fa5s.arrow_down`", + "These may be superqt fonticon keys, such as `fa6s.arrow_down`", ) enablement: Optional[expressions.Expr] = Field( None, diff --git a/tests/conftest.py b/tests/conftest.py index c621e40..a814580 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,11 +9,11 @@ from app_model.types import KeyCode, KeyMod, SubmenuItem try: - from fonticon_fa5 import FA5S + from fonticon_fa6 import FA6S - UNDO_ICON = FA5S.undo + UNDO_ICON = FA6S.rotate_left except ImportError: - UNDO_ICON = "fa5s.undo" + UNDO_ICON = "fa6s.undo" FIXTURES = Path(__file__).parent / "fixtures" @@ -92,7 +92,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: SubmenuItem( submenu=Menus.FILE_OPEN_FROM, title="Open From...", - icon="fa5s.folder-open", + icon="fa6s.folder-open", when="not something_open", enablement="friday", ), @@ -112,7 +112,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: Action( id=Commands.COPY, title="Copy", - icon="fa5s.copy", + icon="fa6s.copy", callback=app.mocks.copy, menus=[{"id": Menus.EDIT, "group": "2_copy_paste"}], keybindings=[{"primary": KeyMod.CtrlCmd | KeyCode.KeyC}], @@ -120,7 +120,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: Action( id=Commands.PASTE, title="Paste", - icon="fa5s.paste", + icon="fa6s.paste", callback=app.mocks.paste, menus=[{"id": Menus.EDIT, "group": "2_copy_paste"}], keybindings=[{"primary": "Ctrl+V", "mac": "Cmd+V"}], @@ -130,7 +130,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: id=Commands.REDO, title="Redo", tooltip="Redo it!", - icon="fa5s.redo", + icon="fa6s.rotate_right", enablement="allow_undo_redo", callback="fake_module:run_me", # this is a function in fixtures keybindings=[{"primary": "Ctrl+Shift+Z"}], diff --git a/tests/test_types.py b/tests/test_types.py index 4c1ae50..864686d 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -5,8 +5,8 @@ def test_icon_validate(): - assert Icon.validate('"fa5s.arrow_down"') == Icon( - dark='"fa5s.arrow_down"', light='"fa5s.arrow_down"' + assert Icon.validate('"fa6s.arrow_down"') == Icon( + dark='"fa6s.arrow_down"', light='"fa6s.arrow_down"' ) From 03680049294033a0ece2baaaabd800dfd31056a2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Jul 2022 20:45:45 -0400 Subject: [PATCH 17/22] build: fix manifest --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7a37b0b..ffb76cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,7 +156,7 @@ ignore = [ ".pre-commit-config.yaml", "tests/**/*", "codecov.yml", - "demo_app/**/*" + "demo/**/*" ] # https://python-semantic-release.readthedocs.io/en/latest/configuration.html From a426fa0b5689bb46c52f57d2942a54a06c532d03 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Jul 2022 21:47:54 -0400 Subject: [PATCH 18/22] test: fix qapp creation --- demo/model_app.py | 2 +- demo/multi_file/__main__.py | 2 +- demo/qapplication.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/demo/model_app.py b/demo/model_app.py index 9879802..c33007f 100644 --- a/demo/model_app.py +++ b/demo/model_app.py @@ -246,7 +246,7 @@ class CommandId: app = Application(name="my_app") for action in ACTIONS: app.register_action(action) - qapp = QApplication([]) + qapp = QApplication.instance() or QApplication([]) qapp.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) main_win = MainWindow(app=app) diff --git a/demo/multi_file/__main__.py b/demo/multi_file/__main__.py index 63d3d19..580adfc 100644 --- a/demo/multi_file/__main__.py +++ b/demo/multi_file/__main__.py @@ -6,7 +6,7 @@ from multi_file.app import MyApp # noqa: E402 from qtpy.QtWidgets import QApplication # noqa: E402 -qapp = QApplication([]) +qapp = QApplication.instance() or QApplication([]) app = MyApp() app.show() qapp.exec_() diff --git a/demo/qapplication.py b/demo/qapplication.py index 23ce6fc..9f1a559 100644 --- a/demo/qapplication.py +++ b/demo/qapplication.py @@ -252,7 +252,7 @@ def stripped_name(self, fullFileName: str): if __name__ == "__main__": - qapp = QApplication([]) + qapp = QApplication.instance() or QApplication([]) qapp.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) main_win = MainWindow() main_win.show() From 4549d650cb6b4d530d9819081870fab9e5c7b472 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Jul 2022 21:50:04 -0400 Subject: [PATCH 19/22] test: move test --- tests/{ => test_qt}/test_demos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{ => test_qt}/test_demos.py (86%) diff --git a/tests/test_demos.py b/tests/test_qt/test_demos.py similarity index 86% rename from tests/test_demos.py rename to tests/test_qt/test_demos.py index 1549be5..95a4fbe 100644 --- a/tests/test_demos.py +++ b/tests/test_qt/test_demos.py @@ -4,7 +4,7 @@ import pytest from qtpy.QtWidgets import QApplication -DEMO = Path(__file__).parent.parent / "demo" +DEMO = Path(__file__).parent.parent.parent / "demo" @pytest.mark.parametrize("fname", ["qapplication.py", "model_app.py"]) From 052016f78531adac9db0fd98a39ea1b9c69c0cf7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Jul 2022 21:53:16 -0400 Subject: [PATCH 20/22] test: add fixture to test qapp --- tests/test_qt/test_demos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_qt/test_demos.py b/tests/test_qt/test_demos.py index 95a4fbe..e6e51e8 100644 --- a/tests/test_qt/test_demos.py +++ b/tests/test_qt/test_demos.py @@ -8,6 +8,6 @@ @pytest.mark.parametrize("fname", ["qapplication.py", "model_app.py"]) -def test_qapp(fname, monkeypatch): +def test_qapp(qapp, fname, monkeypatch): monkeypatch.setattr(QApplication, "exec_", lambda *a, **k: None) runpy.run_path(str(DEMO / fname), run_name="__main__") From 5fd6a62df4d5bf36403ead35a101e09f3728d714 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 13 Jul 2022 06:46:57 -0400 Subject: [PATCH 21/22] test: ignore deprecation --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ffb76cc..379c15c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,7 +110,10 @@ ignore = "D100,D213,D401,D413,D107" # https://docs.pytest.org/en/6.2.x/customize.html [tool.pytest.ini_options] minversion = "6.0" -filterwarnings = ["error"] +filterwarnings = [ + "error", + "ignore:QPixmapCache.find(const QString & key) is deprecated:DeprecationWarning", +] # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] From dbce193ee3491bef37c0a506e81bd64b66a1ea24 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 13 Jul 2022 06:50:02 -0400 Subject: [PATCH 22/22] test: fix ignore --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 379c15c..5b5cde4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,7 +112,7 @@ ignore = "D100,D213,D401,D413,D107" minversion = "6.0" filterwarnings = [ "error", - "ignore:QPixmapCache.find(const QString & key) is deprecated:DeprecationWarning", + "ignore:QPixmapCache.find:DeprecationWarning:", ] # https://mypy.readthedocs.io/en/stable/config_file.html