From 57896e4135e794ce6ee3804dec77f9a6fcfa50f1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 1 Nov 2023 15:28:22 -0400 Subject: [PATCH 01/12] docs: update wip --- docs/css/style.css | 134 ++++++++++++++++++++++++++ mkdocs.yml | 27 +++++- pyproject.toml | 14 +-- scripts/gen_ref_nav.py | 34 +++++++ src/app_model/_app.py | 8 +- src/app_model/backends/qt/_qaction.py | 6 +- src/app_model/backends/qt/_qkeymap.py | 2 +- 7 files changed, 209 insertions(+), 16 deletions(-) create mode 100644 docs/css/style.css create mode 100644 scripts/gen_ref_nav.py diff --git a/docs/css/style.css b/docs/css/style.css new file mode 100644 index 00000000..2bd0067c --- /dev/null +++ b/docs/css/style.css @@ -0,0 +1,134 @@ +/* Increase logo size */ +.md-header__button.md-logo { + padding-bottom: 0.2rem; + padding-right: 0; +} +.md-header__button.md-logo img { + height: 1.5rem; +} + +/* Mark external links as such (also in nav) */ +a.external:hover::after, +a.md-nav__link[href^="https:"]:hover::after { + /* https://primer.style/octicons/link-external-16 */ + background-image: url('data:image/svg+xml,'); + height: 0.8em; + width: 0.8em; + margin-left: 0.2em; + content: " "; + display: inline-block; +} + +/* More space at the bottom of the page */ +.md-main__inner { + margin-bottom: 1.5rem; +} + +/* Avoid breaking parameter names, etc. in table cells. */ +.doc-contents td code { + word-break: normal !important; +} + +/* No line break before first paragraph of descriptions. */ +.doc-md-description, +.doc-md-description > p:first-child { + display: inline; +} + +/* Max width for docstring sections tables. */ +.doc .md-typeset__table, +.doc .md-typeset__table table { + display: table !important; + width: 100%; +} + +.doc .md-typeset__table tr { + display: table-row; +} + +/* Defaults in Spacy table style. */ +.doc-param-default { + float: right; +} + +/* Symbols in Navigation and ToC. */ +:root, +[data-md-color-scheme="default"] { + --doc-symbol-attribute-fg-color: #953800; + --doc-symbol-function-fg-color: #8250df; + --doc-symbol-method-fg-color: #8250df; + --doc-symbol-class-fg-color: #0550ae; + --doc-symbol-module-fg-color: #5cad0f; + + --doc-symbol-attribute-bg-color: #9538001a; + --doc-symbol-function-bg-color: #8250df1a; + --doc-symbol-method-bg-color: #8250df1a; + --doc-symbol-class-bg-color: #0550ae1a; + --doc-symbol-module-bg-color: #5cad0f1a; +} + +[data-md-color-scheme="slate"] { + --doc-symbol-attribute-fg-color: #ffa657; + --doc-symbol-function-fg-color: #d2a8ff; + --doc-symbol-method-fg-color: #d2a8ff; + --doc-symbol-class-fg-color: #79c0ff; + --doc-symbol-module-fg-color: #baff79; + + --doc-symbol-attribute-bg-color: #ffa6571a; + --doc-symbol-function-bg-color: #d2a8ff1a; + --doc-symbol-method-bg-color: #d2a8ff1a; + --doc-symbol-class-bg-color: #79c0ff1a; + --doc-symbol-module-bg-color: #baff791a; +} + +code.doc-symbol { + border-radius: 0.1rem; + font-size: 0.85em; + padding: 0 0.3em; + font-weight: bold; +} + +code.doc-symbol-attribute { + color: var(--doc-symbol-attribute-fg-color); + background-color: var(--doc-symbol-attribute-bg-color); +} + +code.doc-symbol-attribute::after { + content: "attr"; +} + +code.doc-symbol-function { + color: var(--doc-symbol-function-fg-color); + background-color: var(--doc-symbol-function-bg-color); +} + +code.doc-symbol-function::after { + content: "func"; +} + +code.doc-symbol-method { + color: var(--doc-symbol-method-fg-color); + background-color: var(--doc-symbol-method-bg-color); +} + +code.doc-symbol-method::after { + content: "meth"; +} + +code.doc-symbol-class { + color: var(--doc-symbol-class-fg-color); + background-color: var(--doc-symbol-class-bg-color); +} + +code.doc-symbol-class::after { + content: "class"; +} + +code.doc-symbol-module { + color: var(--doc-symbol-module-fg-color); + background-color: var(--doc-symbol-module-bg-color); +} + +code.doc-symbol-module::after { + content: "mod"; +} diff --git a/mkdocs.yml b/mkdocs.yml index be14a02a..fb13e6c9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,17 +2,34 @@ site_name: App Model site_url: https://github.com/pyapp-kit/app-model site_author: Talley Lambert site_description: Generic application schema implemented in python. +# strict: true repo_name: pyapp-kit/app-model repo_url: https://github.com/pyapp-kit/app-model -copyright: Copyright © 2021 - 2022 Talley Lambert +copyright: Copyright © 2021 - 2023 Talley Lambert watch: - src +nav: + - index.md + - application.md + - expressions.md + - keybindings.md + - types.md + - registries.md + + # defer to gen-files + literate-nav + - API reference: reference/ + plugins: - search + - gen-files: + scripts: + - scripts/gen_ref_nav.py + - literate-nav: + nav_file: SUMMARY.txt - autorefs - minify: minify_html: true @@ -32,7 +49,6 @@ plugins: show_root_heading: yes docstring_section_style: list - markdown_extensions: - tables - pymdownx.inlinehilite @@ -48,5 +64,12 @@ theme: logo: material/application-cog-outline features: - navigation.instant + - navigation.indexes - search.highlight - search.suggest + +extra_css: +- css/style.css +- css/material.css +- css/mkdocstrings.css +- css/insiders.css \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b9ce7d42..20f4e216 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,13 +53,13 @@ dev = [ "rich", ] docs = [ - "griffe==0.22.0", - "mkdocs-material~=8.3", - "mkdocs-minify-plugin==0.5.0", - "mkdocs==1.3.0", - "mkdocstrings-python==0.7.0", - "mkdocstrings==0.19.0", - "mkdocs-macros-plugin==0.7.0", + "griffe==0.36.9", + "mkdocs-material==9.4.1", + "mkdocs-minify-plugin==0.7.1", + "mkdocs==1.5.3", + "mkdocstrings-python==1.7.3", + "mkdocstrings==0.23.0", + "mkdocs-macros-plugin==1.0.5", "typing_extensions>=4.0", ] diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py new file mode 100644 index 00000000..8b7cda91 --- /dev/null +++ b/scripts/gen_ref_nav.py @@ -0,0 +1,34 @@ +"""Generate the code reference pages and navigation.""" + +from pathlib import Path + +import mkdocs_gen_files + +nav = mkdocs_gen_files.Nav() +mod_symbol = '' + +for path in sorted(Path("src").rglob("*.py")): + module_path = path.relative_to("src").with_suffix("") + doc_path = path.relative_to("src/app_model").with_suffix(".md") + full_doc_path = Path("reference", doc_path) + + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + nav_parts = [f"{mod_symbol} {part}" for part in parts] + nav[tuple(nav_parts)] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(parts) + fd.write(f"::: {ident}") + + mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path) + +with mkdocs_gen_files.open("reference/SUMMARY.txt", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 1be2bb7e..87c6aba8 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -44,13 +44,13 @@ class Application: Attributes ---------- - - commands : CommandsRegistry + commands : CommandsRegistry The Commands Registry for this application. - - menus : MenusRegistry + menus : MenusRegistry The Menus Registry for this application. - - keybindings : KeyBindingsRegistry + keybindings : KeyBindingsRegistry The KeyBindings Registry for this application. - - injection_store : in_n_out.Store + injection_store : in_n_out.Store The Injection Store for this application. """ diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index ef293c12..a85770d2 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -68,12 +68,14 @@ class QCommandRuleAction(QCommandAction): Parameters ---------- - command_id : str - Command ID. + command_rule : CommandRule + `CommandRule` instance to create an action for. app : Union[str, Application] Application instance or name of application instance. parent : Optional[QWidget] Optional parent widget, by default None + use_short_title : bool + If True, use the `short_title` of the command rule, if it exists. """ def __init__( diff --git a/src/app_model/backends/qt/_qkeymap.py b/src/app_model/backends/qt/_qkeymap.py index 646cabae..dfe1f690 100644 --- a/src/app_model/backends/qt/_qkeymap.py +++ b/src/app_model/backends/qt/_qkeymap.py @@ -6,7 +6,7 @@ from qtpy.QtGui import QKeySequence from app_model.types._constants import OperatingSystem -from app_model.types._keys import ( +from app_model.types import ( KeyBinding, KeyCode, KeyCombo, From 446e872f5de7660dcfe509aaca70e201510f6bb5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 1 Nov 2023 17:31:05 -0400 Subject: [PATCH 02/12] working on docs --- docs/application.md | 5 --- docs/css/style.css | 58 ++++++++++++++++++++++----- docs/expressions.md | 14 ------- docs/keybindings.md | 8 ---- docs/my_hooks.py | 0 docs/registries.md | 5 --- docs/types.md | 5 --- mkdocs.yml | 29 ++++++++------ scripts/gen_ref_nav.py | 9 +++-- src/app_model/backends/qt/_qaction.py | 2 +- src/app_model/types/_action.py | 8 ++++ 11 files changed, 78 insertions(+), 65 deletions(-) delete mode 100644 docs/application.md delete mode 100644 docs/expressions.md delete mode 100644 docs/keybindings.md create mode 100644 docs/my_hooks.py delete mode 100644 docs/registries.md diff --git a/docs/application.md b/docs/application.md deleted file mode 100644 index ce7fc391..00000000 --- a/docs/application.md +++ /dev/null @@ -1,5 +0,0 @@ -# Application - -::: app_model.Application - options: - show_signature: false diff --git a/docs/css/style.css b/docs/css/style.css index 2bd0067c..49b8c022 100644 --- a/docs/css/style.css +++ b/docs/css/style.css @@ -1,21 +1,20 @@ /* Increase logo size */ .md-header__button.md-logo { - padding-bottom: 0.2rem; - padding-right: 0; + padding-bottom: 0.2rem; + padding-right: 0; } .md-header__button.md-logo img { - height: 1.5rem; + height: 1.5rem; } /* Mark external links as such (also in nav) */ -a.external:hover::after, -a.md-nav__link[href^="https:"]:hover::after { +a.external:hover::after, a.md-nav__link[href^="https:"]:hover::after { /* https://primer.style/octicons/link-external-16 */ background-image: url('data:image/svg+xml,'); height: 0.8em; width: 0.8em; margin-left: 0.2em; - content: " "; + content: ' '; display: inline-block; } @@ -24,6 +23,43 @@ a.md-nav__link[href^="https:"]:hover::after { margin-bottom: 1.5rem; } +/* ------------------------------- */ + +/* Indentation. */ +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: .05rem solid var(--md-typeset-table-color); +} + +/* Mark external links as such. */ +a.external::after, +a.autorefs-external::after { + /* https://primer.style/octicons/arrow-up-right-24 */ + mask-image: url('data:image/svg+xml,'); + -webkit-mask-image: url('data:image/svg+xml,'); + content: ' '; + + display: inline-block; + vertical-align: middle; + position: relative; + + height: 1em; + width: 1em; + background-color: var(--md-typeset-a-color); +} + +a.external:hover::after, +a.autorefs-external:hover::after { + background-color: var(--md-accent-fg-color); +} + +/* Avoid breaking parameters name, etc. in table cells. */ +td code { + word-break: normal !important; +} + +/* ------------------------------- */ + /* Avoid breaking parameter names, etc. in table cells. */ .doc-contents td code { word-break: normal !important; @@ -31,7 +67,7 @@ a.md-nav__link[href^="https:"]:hover::after { /* No line break before first paragraph of descriptions. */ .doc-md-description, -.doc-md-description > p:first-child { +.doc-md-description>p:first-child { display: inline; } @@ -82,9 +118,9 @@ a.md-nav__link[href^="https:"]:hover::after { } code.doc-symbol { - border-radius: 0.1rem; - font-size: 0.85em; - padding: 0 0.3em; + border-radius: .1rem; + font-size: .85em; + padding: 0 .3em; font-weight: bold; } @@ -131,4 +167,4 @@ code.doc-symbol-module { code.doc-symbol-module::after { content: "mod"; -} +} \ No newline at end of file diff --git a/docs/expressions.md b/docs/expressions.md deleted file mode 100644 index 0d3ac1df..00000000 --- a/docs/expressions.md +++ /dev/null @@ -1,14 +0,0 @@ -# Expressions - -::: app_model.expressions.Expr - options: - - members: - - parse - - eval - - __str__ - -::: app_model.expressions._expressions.parse_expression - options: - show_signature: yes - show_signature_annotations: yes diff --git a/docs/keybindings.md b/docs/keybindings.md deleted file mode 100644 index 40515d12..00000000 --- a/docs/keybindings.md +++ /dev/null @@ -1,8 +0,0 @@ -# KeyCodes and KeyBindings - -::: app_model.types.KeyCode - options: - show_signature_annotations: yes -::: app_model.types.KeyMod -::: app_model.types.KeyCombo -::: app_model.types.KeyChord diff --git a/docs/my_hooks.py b/docs/my_hooks.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/registries.md b/docs/registries.md deleted file mode 100644 index c157d7e6..00000000 --- a/docs/registries.md +++ /dev/null @@ -1,5 +0,0 @@ -# Registries - -::: app_model.registries.CommandsRegistry -::: app_model.registries.KeyBindingsRegistry -::: app_model.registries.MenusRegistry diff --git a/docs/types.md b/docs/types.md index 9e995fae..5ef248bb 100644 --- a/docs/types.md +++ b/docs/types.md @@ -13,12 +13,7 @@ {{ pydantic_table('app_model.types.KeyBindingRule') }} ::: app_model.types.Action - options: - show_bases: true {{ pydantic_table('app_model.types.Action') }} ::: app_model.types.Icon - options: - members: - - {{ pydantic_table('app_model.types.Icon') }} diff --git a/mkdocs.yml b/mkdocs.yml index fb13e6c9..62ad9dd3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,11 +14,7 @@ watch: nav: - index.md - - application.md - - expressions.md - - keybindings.md - types.md - - registries.md # defer to gen-files + literate-nav - API reference: reference/ @@ -42,12 +38,19 @@ plugins: - https://docs.python.org/3/objects.inv options: docstring_style: numpy - show_bases: false - merge_init_into_class: yes - show_source: no - show_root_full_path: no - show_root_heading: yes + docstring_options: + ignore_init_summary: true docstring_section_style: list + filters: ["!^_"] + heading_level: 1 + inherited_members: true + merge_init_into_class: true + separate_signature: true + show_root_heading: true + show_root_full_path: false + show_signature_annotations: true + show_bases: true + show_source: true markdown_extensions: - tables @@ -69,7 +72,7 @@ theme: - search.suggest extra_css: -- css/style.css -- css/material.css -- css/mkdocstrings.css -- css/insiders.css \ No newline at end of file + - css/style.css + +hooks: + - docs/my_hooks.py diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py index 8b7cda91..c95bd325 100644 --- a/scripts/gen_ref_nav.py +++ b/scripts/gen_ref_nav.py @@ -4,12 +4,15 @@ import mkdocs_gen_files +SRC = Path("src") +PKG = SRC / "app_model" + nav = mkdocs_gen_files.Nav() mod_symbol = '' -for path in sorted(Path("src").rglob("*.py")): +for path in sorted(SRC.rglob("*.py")): module_path = path.relative_to("src").with_suffix("") - doc_path = path.relative_to("src/app_model").with_suffix(".md") + doc_path = path.relative_to(PKG).with_suffix(".md") full_doc_path = Path("reference", doc_path) parts = tuple(module_path.parts) @@ -18,7 +21,7 @@ parts = parts[:-1] doc_path = doc_path.with_name("index.md") full_doc_path = full_doc_path.with_name("index.md") - elif parts[-1].startswith("_"): + if parts[-1].startswith("_"): continue nav_parts = [f"{mod_symbol} {part}" for part in parts] diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index a85770d2..7a42bc4e 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -126,7 +126,7 @@ def _refresh(self) -> None: class QMenuItemAction(QCommandRuleAction): """QAction for a MenuItem. - Mostly the same as a CommandRuleAction, but aware of the `menu_item.when` clause + Mostly the same as a `CommandRuleAction`, but aware of the `menu_item.when` clause to toggle visibility. """ diff --git a/src/app_model/types/_action.py b/src/app_model/types/_action.py index a8b49712..0db6087d 100644 --- a/src/app_model/types/_action.py +++ b/src/app_model/types/_action.py @@ -29,6 +29,14 @@ class Action(CommandRule, Generic[P, R]): actual callable object, as well as any additional menu and keybinding rules. Most commands and menu items will be represented by Actions, and registered using `register_action`. + + Attributes + ---------- + callback : Union[Callable[P, R], str] + A function to call when the associated command id is executed. If a string is + provided, it must be a fully qualified name to a callable python object. This + usually takes the form of `{obj.__module__}:{obj.__qualname__}` (e.g. + `my_package.a_module:some_function`) """ callback: Union[Callable[P, R], str] = Field( From 133d75e794a2c13e22f038ed420147f9ae5e01f4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 2 Nov 2023 11:40:04 -0400 Subject: [PATCH 03/12] ugly wip --- .pre-commit-config.yaml | 2 +- docs/_handler.py | 75 +++++++++++++++ docs/my_hooks.py | 129 ++++++++++++++++++++++++++ mkdocs.yml | 3 +- pyproject.toml | 4 +- src/app_model/backends/qt/_qkeymap.py | 2 +- src/app_model/types/_command_rule.py | 7 ++ 7 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 docs/_handler.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 54e8e4df..9a366f3e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: rev: v0.1.3 hooks: - id: ruff - args: ["--fix"] + args: ["--fix", "--unsafe-fixes"] - repo: https://github.com/abravalheri/validate-pyproject rev: v0.15 diff --git a/docs/_handler.py b/docs/_handler.py new file mode 100644 index 00000000..87777405 --- /dev/null +++ b/docs/_handler.py @@ -0,0 +1,75 @@ +"""https://www.mkdocs.org/dev-guide/plugins/#events .""" +from __future__ import annotations + +import importlib.abc +from importlib import import_module +from importlib.machinery import ModuleSpec +from textwrap import dedent +from typing import TYPE_CHECKING, Any + +from griffe.dataclasses import Alias +from mkdocstrings_handlers.python.handler import PythonHandler + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from types import ModuleType + + from mkdocstrings.handlers.base import CollectorItem + + +def inject_dynamic_docstring(item: Alias, identifier: str) -> None: + module, name = identifier.rsplit(".", 1) + obj = getattr(import_module(module), name) + first_line, *rest = (obj.__doc__ or "").splitlines() + if first_line and item.target.docstring: + item.target.docstring.value = first_line + "\n" + dedent("\n".join(rest)) + + +class AppModelHandler(PythonHandler): + def collect(self, identifier: str, config: Mapping[str, Any]) -> CollectorItem: + item = super().collect(identifier, config) + if isinstance(item, Alias): + inject_dynamic_docstring(item, identifier) + # to edit default in the parameter table + # item.parameters["something"].default = ... + return item + + +class MyLoader(importlib.abc.Loader): + def exec_module(self, module: ModuleType) -> None: + def get_handler( + theme: str, + custom_templates: str | None = None, + config_file_path: str | None = None, + **config: Any, + ) -> PythonHandler: + paths = config.get("paths", []) + locale = config.get("locale", None) + load_external_modules = config.get("load_external_modules", False) + # breakpoint() + return PythonHandler( + handler="python", + theme=theme, + custom_templates=custom_templates, + config_file_path=config_file_path, + paths=paths, + locale=locale, + load_external_modules=load_external_modules, + ) + + # return AppModelHandler(handler="python", *args, **kwargs) + # return PythonHandler(handler="python", *args, **kwargs) + + module.get_handler = get_handler # type: ignore + + +class Finder(importlib.abc.MetaPathFinder): + def find_spec( + self, + fullname: str, + path: Sequence[str] | None, + target: ModuleType | None = None, + ) -> ModuleSpec | None: + if fullname == "mkdocstrings_handlers.app_model": + return ModuleSpec(fullname, MyLoader()) + return None diff --git a/docs/my_hooks.py b/docs/my_hooks.py index e69de29b..f11ea746 100644 --- a/docs/my_hooks.py +++ b/docs/my_hooks.py @@ -0,0 +1,129 @@ +"""https://www.mkdocs.org/dev-guide/plugins/#events .""" +from __future__ import annotations + +import importlib.abc +import sys +import warnings +from functools import partial +from importlib import import_module +from importlib.machinery import ModuleSpec +from typing import TYPE_CHECKING, Any + +from griffe.dataclasses import Alias +from mkdocstrings_handlers.python.handler import PythonHandler + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from pathlib import Path + from types import ModuleType + + from fieldz import Field + from mkdocstrings.handlers.base import CollectorItem + +warnings.simplefilter("ignore", DeprecationWarning) + + +def _parse_pydantic(doc, fields: tuple[Field, ...]): + from griffe.docstrings.dataclasses import ( + DocstringAttribute, + DocstringSectionAttributes, + ) + from griffe.docstrings.parsers import parse + + val = parse(doc, "numpy") + + val.pop() + val.append( + DocstringSectionAttributes( + [ + DocstringAttribute( + name=f.name, + # annotation=ExprName(name=f.type_display()), + description=f.description, + value=f.default, + ) + for f in fields + ] + ) + ) + return val + + +def inject_dynamic_docstring(item: Alias, identifier: str) -> None: + from fieldz import fields + from pydantic import BaseModel + + module, name = identifier.rsplit(".", 1) + try: + obj = getattr(import_module(module), name) + if not issubclass(obj, BaseModel): + return + except (ModuleNotFoundError, TypeError): + return + # breakpoint() + item.target.docstring.parse = partial( + _parse_pydantic, item.target.docstring, fields=fields(obj) + ) + return True + # item.target.docstring.value = "asdf" + # return + # if not (docstring := obj.__doc__): + # return + # first_line, *rest = docstring.splitlines() + # if first_line and item.target.docstring: + # item.target.docstring.value = first_line + "\n" + dedent("\n".join(rest)) + + +class AppModelHandler(PythonHandler): + def collect(self, identifier: str, config: Mapping[str, Any]) -> CollectorItem: + item = super().collect(identifier, config) + if isinstance(item, Alias): + if inject_dynamic_docstring(item, identifier): + try: + config["docstring_options"]["docstring_section_style"] = "table" + except KeyError: + pass + return item + + def get_templates_dir(self, handler: str | None = None) -> Path: + return super().get_templates_dir("python") + + +class MyLoader(importlib.abc.Loader): + def exec_module(self, module: ModuleType) -> None: + def get_handler( + theme: str, + custom_templates: str | None = None, + config_file_path: str | None = None, + **config: Any, + ) -> PythonHandler: + paths = config.get("paths", []) + locale = config.get("locale", None) + load_external_modules = config.get("load_external_modules", False) + return AppModelHandler( + handler="app_model", + theme=theme, + custom_templates=custom_templates, + config_file_path=config_file_path, + paths=paths, + locale=locale, + load_external_modules=load_external_modules, + ) + + module.get_handler = get_handler # type: ignore + + +class Finder(importlib.abc.MetaPathFinder): + def find_spec( + self, + fullname: str, + path: Sequence[str] | None, + target: ModuleType | None = None, + ) -> ModuleSpec | None: + if fullname == "mkdocstrings_handlers.app_model": + return ModuleSpec(fullname, MyLoader()) + return None + + +def on_startup(command: str, dirty: bool) -> None: + sys.meta_path.append(Finder()) diff --git a/mkdocs.yml b/mkdocs.yml index 62ad9dd3..f706d853 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,8 +32,9 @@ plugins: - macros: module_name: docs/_macros - mkdocstrings: + default_handler: app_model handlers: - python: + app_model: import: - https://docs.python.org/3/objects.inv options: diff --git a/pyproject.toml b/pyproject.toml index 9081fe47..aca90d95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,8 @@ docs = [ "mkdocstrings==0.23.0", "mkdocs-macros-plugin==1.0.5", "typing_extensions>=4.0", + "mkdocs-gen-files", + "mkdocs-literate-nav", ] [project.urls] @@ -138,7 +140,7 @@ pretty = true plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] -module = ["tests.*"] +module = ["tests.*", "docs.*"] disallow_untyped_defs = false [[tool.mypy.overrides]] diff --git a/src/app_model/backends/qt/_qkeymap.py b/src/app_model/backends/qt/_qkeymap.py index dfe1f690..baab3f05 100644 --- a/src/app_model/backends/qt/_qkeymap.py +++ b/src/app_model/backends/qt/_qkeymap.py @@ -5,7 +5,6 @@ from qtpy.QtCore import QCoreApplication, Qt from qtpy.QtGui import QKeySequence -from app_model.types._constants import OperatingSystem from app_model.types import ( KeyBinding, KeyCode, @@ -13,6 +12,7 @@ KeyMod, SimpleKeyBinding, ) +from app_model.types._constants import OperatingSystem try: from qtpy import QT6 diff --git a/src/app_model/types/_command_rule.py b/src/app_model/types/_command_rule.py index 16c7447f..9f08c42a 100644 --- a/src/app_model/types/_command_rule.py +++ b/src/app_model/types/_command_rule.py @@ -30,6 +30,13 @@ class CommandRule(_BaseModel): grouping. However, the Command Palette doesn't show icons nor disabled commands. Menus, on the other hand, shows disabled items as grayed out, but don't show the category label. + + Parameters + ---------- + id : str + A global identifier for the command. + title : str + Title by which the command is represented in the UI. """ id: str = Field(..., description="A global identifier for the command.") From 813f0ad6102fb6294f77aa8f88343db32fe7c8dd Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 2 Nov 2023 13:45:52 -0400 Subject: [PATCH 04/12] griffe extension --- docs/_griffe_ext.py | 71 ++++++++++++ docs/_handler.py | 75 ------------ docs/_macros.py | 59 ---------- docs/my_hooks.py | 134 +--------------------- docs/types.md | 19 --- mkdocs.yml | 10 +- src/app_model/types/_action.py | 8 -- src/app_model/types/_command_rule.py | 7 -- src/app_model/types/_keys/_key_codes.py | 7 ++ src/app_model/types/_keys/_keybindings.py | 9 +- 10 files changed, 94 insertions(+), 305 deletions(-) create mode 100644 docs/_griffe_ext.py delete mode 100644 docs/_handler.py delete mode 100644 docs/_macros.py delete mode 100644 docs/types.md diff --git a/docs/_griffe_ext.py b/docs/_griffe_ext.py new file mode 100644 index 00000000..35010329 --- /dev/null +++ b/docs/_griffe_ext.py @@ -0,0 +1,71 @@ +import ast +import inspect + +import fieldz +from griffe import Extension, Object, ObjectNode, dynamic_import, get_logger +from griffe.dataclasses import Docstring +from griffe.docstrings.dataclasses import DocstringParameter, DocstringSectionParameters + +from app_model.types._base import _BaseModel + +logger = get_logger(__name__) + + +class DynamicDocstrings(Extension): + def __init__(self, object_paths: list[str] | None = None) -> None: + self.object_paths = object_paths + + def on_instance(self, node: ast.AST | ObjectNode, obj: Object) -> None: + if isinstance(node, ObjectNode): + return # skip runtime objects, their docstrings are already right + + if self.object_paths and obj.path not in self.object_paths: + return # skip objects that were not selected + + # import object to get its evaluated docstring + try: + runtime_obj = dynamic_import(obj.path) + docstring = runtime_obj.__doc__ + except ImportError: + logger.debug(f"Could not get dynamic docstring for {obj.path}") + return + except AttributeError: + logger.debug(f"Object {obj.path} does not have a __doc__ attribute") + return + + if isinstance(runtime_obj, type) and issubclass(runtime_obj, _BaseModel): + docstring = inspect.cleandoc(docstring) + self._fix_pydantic(docstring, obj, runtime_obj) + + def _fix_pydantic( + self, docstring: str, obj: Object, runtime_obj: type[_BaseModel] + ) -> None: + # update the object instance with the evaluated docstring + if obj.docstring: + obj.docstring.value = docstring + else: + obj.docstring = Docstring(docstring, parent=obj) + + params = [ + DocstringParameter( + name=field.name, + annotation=field.type_display(modern_union=True) + if field.type + else None, + description=field.description or "", + value=repr(field.default) + if field.default is not field.MISSING + else None, + ) + for field in fieldz.fields(runtime_obj) + if field.name in runtime_obj.__annotations__ + ] + param_section = DocstringSectionParameters(params) + + parsed = [ + x + for x in obj.docstring.parsed + if not isinstance(x, DocstringSectionParameters) + ] + parsed.append(param_section) + obj.docstring.parsed = parsed diff --git a/docs/_handler.py b/docs/_handler.py deleted file mode 100644 index 87777405..00000000 --- a/docs/_handler.py +++ /dev/null @@ -1,75 +0,0 @@ -"""https://www.mkdocs.org/dev-guide/plugins/#events .""" -from __future__ import annotations - -import importlib.abc -from importlib import import_module -from importlib.machinery import ModuleSpec -from textwrap import dedent -from typing import TYPE_CHECKING, Any - -from griffe.dataclasses import Alias -from mkdocstrings_handlers.python.handler import PythonHandler - -if TYPE_CHECKING: - from collections.abc import Mapping, Sequence - from types import ModuleType - - from mkdocstrings.handlers.base import CollectorItem - - -def inject_dynamic_docstring(item: Alias, identifier: str) -> None: - module, name = identifier.rsplit(".", 1) - obj = getattr(import_module(module), name) - first_line, *rest = (obj.__doc__ or "").splitlines() - if first_line and item.target.docstring: - item.target.docstring.value = first_line + "\n" + dedent("\n".join(rest)) - - -class AppModelHandler(PythonHandler): - def collect(self, identifier: str, config: Mapping[str, Any]) -> CollectorItem: - item = super().collect(identifier, config) - if isinstance(item, Alias): - inject_dynamic_docstring(item, identifier) - # to edit default in the parameter table - # item.parameters["something"].default = ... - return item - - -class MyLoader(importlib.abc.Loader): - def exec_module(self, module: ModuleType) -> None: - def get_handler( - theme: str, - custom_templates: str | None = None, - config_file_path: str | None = None, - **config: Any, - ) -> PythonHandler: - paths = config.get("paths", []) - locale = config.get("locale", None) - load_external_modules = config.get("load_external_modules", False) - # breakpoint() - return PythonHandler( - handler="python", - theme=theme, - custom_templates=custom_templates, - config_file_path=config_file_path, - paths=paths, - locale=locale, - load_external_modules=load_external_modules, - ) - - # return AppModelHandler(handler="python", *args, **kwargs) - # return PythonHandler(handler="python", *args, **kwargs) - - module.get_handler = get_handler # type: ignore - - -class Finder(importlib.abc.MetaPathFinder): - def find_spec( - self, - fullname: str, - path: Sequence[str] | None, - target: ModuleType | None = None, - ) -> ModuleSpec | None: - if fullname == "mkdocstrings_handlers.app_model": - return ModuleSpec(fullname, MyLoader()) - return None diff --git a/docs/_macros.py b/docs/_macros.py deleted file mode 100644 index 4c297a09..00000000 --- a/docs/_macros.py +++ /dev/null @@ -1,59 +0,0 @@ -import collections.abc -from importlib import import_module -from typing import TYPE_CHECKING, Any, TypeVar, Union - -from pydantic_compat import BaseModel -from typing_extensions import ParamSpec - -if TYPE_CHECKING: - from mkdocs_macros.plugin import MacrosPlugin - - -def _import_attr(name: str): - mod, attr = name.rsplit(".", 1) - return getattr(import_module(mod), attr) - - -def define_env(env: "MacrosPlugin") -> None: - @env.macro - def pydantic_table(name: str) -> str: - cls = _import_attr(name) - assert issubclass(cls, BaseModel) - rows = ["| Field | Type | Description |", "| ---- | ---- | ----------- |"] - if hasattr(cls, "model_fields"): - fields = cls.model_fields - else: - fields = cls.__fields__ - for fname, f in fields.items(): - typ = f.outer_type_ if hasattr(f, "outer_type_") else f.annotation - type_ = _build_type_link(typ) - if hasattr(f, "field_info"): - description = f.field_info.description or "" - else: - description = f.description - row = f"| {fname} | {type_} | {description} |" - rows.append(row) - return "\n".join(rows) - - -def _type_link(typ: Any) -> str: - mod = f"{typ.__module__}." if typ.__module__ != "builtins" else "" - type_fullpath = f"{mod}{typ.__name__}" - return f"[`{typ.__name__}`][{type_fullpath}]" - - -def _build_type_link(typ: Any) -> str: - origin = getattr(typ, "__origin__", None) - if origin is None: - return _type_link(typ) - - args = getattr(typ, "__args__", ()) - if origin is collections.abc.Callable and any( - isinstance(a, (TypeVar, ParamSpec)) for a in args - ): - return _type_link(origin) - types = [_build_type_link(a) for a in args if a is not type(None)] - if origin is Union: - return " or ".join(types) - type_ = ", ".join(types) - return f"{_type_link(origin)}[{type_}]" diff --git a/docs/my_hooks.py b/docs/my_hooks.py index f11ea746..3466bb6b 100644 --- a/docs/my_hooks.py +++ b/docs/my_hooks.py @@ -1,129 +1,5 @@ -"""https://www.mkdocs.org/dev-guide/plugins/#events .""" -from __future__ import annotations - -import importlib.abc -import sys -import warnings -from functools import partial -from importlib import import_module -from importlib.machinery import ModuleSpec -from typing import TYPE_CHECKING, Any - -from griffe.dataclasses import Alias -from mkdocstrings_handlers.python.handler import PythonHandler - -if TYPE_CHECKING: - from collections.abc import Mapping, Sequence - from pathlib import Path - from types import ModuleType - - from fieldz import Field - from mkdocstrings.handlers.base import CollectorItem - -warnings.simplefilter("ignore", DeprecationWarning) - - -def _parse_pydantic(doc, fields: tuple[Field, ...]): - from griffe.docstrings.dataclasses import ( - DocstringAttribute, - DocstringSectionAttributes, - ) - from griffe.docstrings.parsers import parse - - val = parse(doc, "numpy") - - val.pop() - val.append( - DocstringSectionAttributes( - [ - DocstringAttribute( - name=f.name, - # annotation=ExprName(name=f.type_display()), - description=f.description, - value=f.default, - ) - for f in fields - ] - ) - ) - return val - - -def inject_dynamic_docstring(item: Alias, identifier: str) -> None: - from fieldz import fields - from pydantic import BaseModel - - module, name = identifier.rsplit(".", 1) - try: - obj = getattr(import_module(module), name) - if not issubclass(obj, BaseModel): - return - except (ModuleNotFoundError, TypeError): - return - # breakpoint() - item.target.docstring.parse = partial( - _parse_pydantic, item.target.docstring, fields=fields(obj) - ) - return True - # item.target.docstring.value = "asdf" - # return - # if not (docstring := obj.__doc__): - # return - # first_line, *rest = docstring.splitlines() - # if first_line and item.target.docstring: - # item.target.docstring.value = first_line + "\n" + dedent("\n".join(rest)) - - -class AppModelHandler(PythonHandler): - def collect(self, identifier: str, config: Mapping[str, Any]) -> CollectorItem: - item = super().collect(identifier, config) - if isinstance(item, Alias): - if inject_dynamic_docstring(item, identifier): - try: - config["docstring_options"]["docstring_section_style"] = "table" - except KeyError: - pass - return item - - def get_templates_dir(self, handler: str | None = None) -> Path: - return super().get_templates_dir("python") - - -class MyLoader(importlib.abc.Loader): - def exec_module(self, module: ModuleType) -> None: - def get_handler( - theme: str, - custom_templates: str | None = None, - config_file_path: str | None = None, - **config: Any, - ) -> PythonHandler: - paths = config.get("paths", []) - locale = config.get("locale", None) - load_external_modules = config.get("load_external_modules", False) - return AppModelHandler( - handler="app_model", - theme=theme, - custom_templates=custom_templates, - config_file_path=config_file_path, - paths=paths, - locale=locale, - load_external_modules=load_external_modules, - ) - - module.get_handler = get_handler # type: ignore - - -class Finder(importlib.abc.MetaPathFinder): - def find_spec( - self, - fullname: str, - path: Sequence[str] | None, - target: ModuleType | None = None, - ) -> ModuleSpec | None: - if fullname == "mkdocstrings_handlers.app_model": - return ModuleSpec(fullname, MyLoader()) - return None - - -def on_startup(command: str, dirty: bool) -> None: - sys.meta_path.append(Finder()) +def on_page_markdown(md: str, page, config, files): + T = "::: app_model.types" + T2 = T + "\n\toptions:\n\t\tdocstring_section_style: table" + md = md.replace(T, T2) + return md diff --git a/docs/types.md b/docs/types.md deleted file mode 100644 index 5ef248bb..00000000 --- a/docs/types.md +++ /dev/null @@ -1,19 +0,0 @@ -# App Model Types - -::: app_model.types.CommandRule -{{ pydantic_table('app_model.types.CommandRule') }} - -::: app_model.types.ToggleRule -{{ pydantic_table('app_model.types.ToggleRule') }} - -::: app_model.types.MenuRule -{{ pydantic_table('app_model.types.MenuRule') }} - -::: app_model.types.KeyBindingRule -{{ pydantic_table('app_model.types.KeyBindingRule') }} - -::: app_model.types.Action -{{ pydantic_table('app_model.types.Action') }} - -::: app_model.types.Icon -{{ pydantic_table('app_model.types.Icon') }} diff --git a/mkdocs.yml b/mkdocs.yml index f706d853..2a03802f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,8 +14,6 @@ watch: nav: - index.md - - types.md - # defer to gen-files + literate-nav - API reference: reference/ @@ -29,15 +27,15 @@ plugins: - autorefs - minify: minify_html: true - - macros: - module_name: docs/_macros - mkdocstrings: - default_handler: app_model handlers: - app_model: + python: import: - https://docs.python.org/3/objects.inv options: + extensions: + - pydantic: { schema: true } + - docs/_griffe_ext.py:DynamicDocstrings docstring_style: numpy docstring_options: ignore_init_summary: true diff --git a/src/app_model/types/_action.py b/src/app_model/types/_action.py index 0db6087d..a8b49712 100644 --- a/src/app_model/types/_action.py +++ b/src/app_model/types/_action.py @@ -29,14 +29,6 @@ class Action(CommandRule, Generic[P, R]): actual callable object, as well as any additional menu and keybinding rules. Most commands and menu items will be represented by Actions, and registered using `register_action`. - - Attributes - ---------- - callback : Union[Callable[P, R], str] - A function to call when the associated command id is executed. If a string is - provided, it must be a fully qualified name to a callable python object. This - usually takes the form of `{obj.__module__}:{obj.__qualname__}` (e.g. - `my_package.a_module:some_function`) """ callback: Union[Callable[P, R], str] = Field( diff --git a/src/app_model/types/_command_rule.py b/src/app_model/types/_command_rule.py index 9f08c42a..16c7447f 100644 --- a/src/app_model/types/_command_rule.py +++ b/src/app_model/types/_command_rule.py @@ -30,13 +30,6 @@ class CommandRule(_BaseModel): grouping. However, the Command Palette doesn't show icons nor disabled commands. Menus, on the other hand, shows disabled items as grayed out, but don't show the category label. - - Parameters - ---------- - id : str - A global identifier for the command. - title : str - Title by which the command is represented in the UI. """ id: str = Field(..., description="A global identifier for the command.") diff --git a/src/app_model/types/_keys/_key_codes.py b/src/app_model/types/_keys/_key_codes.py index 2fb549fc..b7c8ebde 100644 --- a/src/app_model/types/_keys/_key_codes.py +++ b/src/app_model/types/_keys/_key_codes.py @@ -694,6 +694,13 @@ class KeyChord(int): It could be two [`KeyCombo`][app_model.types.KeyCombo] [`KeyCode`][app_model.types.KeyCode], or [int][]. + + Parameters + ---------- + first_part : KeyCombo | int + The first part of the chord. + second_part : KeyCombo | int + The second part of the chord. """ def __new__(cls: Type["KeyChord"], first_part: int, second_part: int) -> "KeyChord": diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index 20147985..c6560bbb 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -143,13 +143,18 @@ class KeyBinding: Chords (two separate keypress actions) are expressed as a string by separating the two keypress codes with a space. For example, 'Ctrl+K Ctrl+C'. + + Parameters + ---------- + parts : List[SimpleKeyBinding] + The parts of the keybinding. There must be at least one part. """ + parts: List[SimpleKeyBinding] = Field(..., **MIN1) # type: ignore + def __init__(self, *, parts: List[SimpleKeyBinding]): self.parts = parts - parts: List[SimpleKeyBinding] = Field(..., **MIN1) # type: ignore - def __str__(self) -> str: return " ".join(str(part) for part in self.parts) From f135a44abbd981ad6a4ab5c6434ca638a41b69bc Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 2 Nov 2023 16:29:04 -0400 Subject: [PATCH 05/12] add getting started --- docs/getting_started.md | 216 ++++++++++++++++++++++ docs/images/qmainwindow.jpeg | Bin 0 -> 44931 bytes docs/index.md | 18 +- mkdocs.yml | 2 + src/app_model/expressions/_expressions.py | 6 +- 5 files changed, 233 insertions(+), 9 deletions(-) create mode 100644 docs/getting_started.md create mode 100644 docs/images/qmainwindow.jpeg diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 00000000..2470b938 --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,216 @@ +# Getting Started + +## Creating an Application + +Typical usage will begin by creating a [`Application`][app_model.Application] +object. + +```python +from app_model import Application + +my_app = Application('my-app') +``` + +## Registering Actions + +Most applications will have some number of actions that can be invoked by the +user. Actions are typically callable objects that perform some operation, such +as "open a file", "save a file", "copy", "paste", etc. +These actions will usually be exposed in the application's menus and +toolbars, and will usually have associated keybindings. Sometimes actions +hold state, such as "toggle word wrap" or "toggle line numbers". + +`app-model` provides a high level [`Action`][app_model.Action] object that +comprises a pointer to a callable object, along with placement in menus, keybindings, +and additional metadata like title, icons, tooltips, etc... + +```python +from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod, MenuRule + +def open_file(): + print('open file!') + +def close_window(): + print('close window!') + +ACTIONS: list[Action] = [ + Action( + id='open', + title="Open", + icon="fa6-solid:folder-open", + callback=open_file, + menus=['File'], + keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO)], + ), + Action( + id='close', + title="Close", + icon="fa-solid:window-close", + callback=close_window, + menus=['File'], + keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], + ), + # ... +] +``` + +Actions are registered with the application using the +[`Application.register_action()`][app_model.Application.register_action] method. + +```python +for action in ACTIONS: + my_app.register_action(action) +``` + +## Registries + +The application maintains three internal registries. + +1. `Application.commands` is an instance of + [`CommandsRegistry`][app_model.registries.CommandsRegistry]. It maintains + all of the commands (the actual callable object) that have been registered with + the application. +2. `Application.menus` is an instance of + [`MenusRegistry`][app_model.registries.MenusRegistry]. It maintains all of + the menus and submenu items that have been registered with the application. +3. `Application.keybindings` is an instance of + [`KeyBindingsRegistry`][app_model.registries.KeyBindingsRegistry]. It maintains + an association between a [KeyBinding][app_model.types.KeyBinding] and a command + id in the `CommandsRegistry`. + +### Registry events + +Each of these registries has a signal that is emitted when a new item is added. + +- `CommandsRegistry.registered` is emitted with the new command id (`str`) whenever + [`CommandsRegistry.register_command`][app_model.registries.CommandsRegistry.register_command] + is called +- `MenusRegistry.menus_changed` is emitted with the new menu ids (`set[str]`) whenever + [`MenusRegistry.append_menu_items`][app_model.registries.MenusRegistry.append_menu_items] + or if the menu items have been disposed. +- `KeyBindingsRegistry.registered` is emitted (no arguments) whenever + [`KeyBindingsRegistry.register_keybinding_rule`][app_model.registries.KeyBindingsRegistry.register_keybinding_rule] is called. + +You can connect callbacks to these events to handle them as needed. + +```python +@my_app.commands.registered.connect +def on_command_registered(command_id: str): + print(f'Command {command_id!r} registered!') + +my_app.commands.register_command('new-id', lambda: None, title='No-op') +# Command 'new-id' registered! +``` + +## Executing Commands + +Registered commands may be executed on-demand using [`execute_command`][app_model.registries.CommandsRegistry.execute_command] method on the command registry: + +```python +my_app.commands.execute_command('open') +# prints "open file!" from the `open_file` function registered above. +``` + +### Command Arguments and Dependency Injection + +The `execute_command` function does accept `*args` and `**kwargs` that will +be passed to the command. However, very often in a GUI application +you may wish to infer some of the arguments from the current state of the +application. For example, if you have menu item linked to a "close window", +you likely want to close the current window. For this, `app-model` uses +a dependency injection pattern, provided by the +[`in-n-out`](https://github.com/pyapp-kit/in-n-out) library. + +The application has a [`injection_store`][app_model.Application.injection_store] +attribute that is an instance of an `in_n_out.Store`. A `Store` is a collection +of "providers" (functions that can be called to return an instance of a given +type), and "processors" (functions that accept an instance of a given type and +do something with it). The `Store` is used to provide the arguments to the +command when it is executed, based on the type annotations in the command +function definition. + +Here's an example. Let's say an application has a `User` object with a `name()` +method: + +```python +class User: + def name(self): + return 'John Doe' +``` + +Assume the application has some way of retrieving the current user: + +```python +def get_current_user() -> User: + # ... get the current user from somewhere + return User() +``` + +We register this provider function with the application's injection store: + +```python +my_app.injection_store.register_provider(get_current_user) +``` + +Now commands may be defined that accept a `User` argument, and used +for callbacks in actions registered with the application. + +```python +def print_user_name(user: User) -> None: + print(f"Hi {user.name()}!") + +action = Action( + id='greet', + title="Greet Current User", + callback=print_user_name, +) + +my_app.register_action(action) +my_app.commands.execute_command('greet') +# prints "Hi John Doe!" +``` + +## Connecting a GUI framework + +Of course, most of this is useless without some way to connect the application +to a GUI framework. The [`app_model.backends`][app_model.backends] module +provides functions that map the `app-model` model onto various GUI framework models. + +!!! note "erm... someday 😂" + + Well, really it's just Qt for now, but the abstraction is valuable for the + ability to swap backends. And we hope to add more backends if the demand is + there. + +Currently, we don't have a generic abstraction for the application window, so +users are encouraged to directly use the classes in the `app_model.backends.qt` +module. One of the main classes is the [`QModelMainWindow`][app_model.backends.qt.QModelMainWindow] object: a subclass of `QMainWindow` that knows how to map +an `Application` object onto the Qt model. + +```python +from app_model.backends.qt import QModelMainWindow +from qtpy.QtWidgets import QApplication + +app = QApplication([]) + +# create the main window with our app_model.Application +main = QModelMainWindow(my_app) + +# pick menus for main menu bar, +# using menu ids from the application's MenusRegistry +main.setModelMenuBar(['File']) + +# add toolbars using menu ids from the application's MenusRegistry +# here we re-use the File menu ... but you can have menus +# dedicated for toolbars, or just exclude items from the menu +main.addModelToolBar('File') +main.show() + +app.exec_() +``` + +You should now have a QMainWindow with a menu bar and toolbar populated with +the actions you registered with the application with icons, keybindings, +and callbacks all connected. + +![QMainWindow with menu bar and toolbar](../images/qmainwindow.jpeg) diff --git a/docs/images/qmainwindow.jpeg b/docs/images/qmainwindow.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..4fdb2c8879bac8455ef5b1b21706084c3219e9f5 GIT binary patch literal 44931 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 UeZ*Q}Y)u?Eg!t>AX5aX~0K@P3mH+?% literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 3ecbc551..23f3e886 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,11 +9,17 @@ that make up their application. ## General architecture Typical usage will begin by creating a [`Application`][app_model.Application] -object. [Commands][app_model.types.CommandRule], [menu items][app_model.types.MenuRule], and [keybindings][app_model.types.KeyBindingRule] will usually be declared by creating -[`Action`][app_model.Action] objects, and registered with the application -using the [`Application.register_action`][app_model.Application.register_action] - -An application maintains a [registry](registries) for all registered [commands][app_model.registries.CommandsRegistry], [menus][app_model.registries.MenusRegistry], and [keybindings][app_model.registries.KeyBindingsRegistry]. +object. [Commands][app_model.types.CommandRule], [menu +items][app_model.types.MenuRule], and +[keybindings][app_model.types.KeyBindingRule] will usually be declared by +creating [`Action`][app_model.Action] objects, and registered with the +application using the +[`Application.register_action`][app_model.Application.register_action] + +An application maintains a registry for all registered +[commands][app_model.registries.CommandsRegistry], +[menus][app_model.registries.MenusRegistry], and +[keybindings][app_model.registries.KeyBindingsRegistry]. !!! Note Calling [`Application.register_action`][app_model.Application.register_action] with a single @@ -72,7 +78,7 @@ qmenu = QModelMenu(menu_id='file', app=app) ``` !!! Tip - Application [registries](registries) are backed by + Application [registries][app_model.registries] are backed by [psygnal](https://github.com/tlambert03/psygnal), and emit events when modified. These events are connected to the Qt objects, so `QModel...` objects such as `QModelMenu` and `QCommandAction` will be updated when the diff --git a/mkdocs.yml b/mkdocs.yml index 2a03802f..bad5ec27 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ watch: nav: - index.md + - getting_started.md # defer to gen-files + literate-nav - API reference: reference/ @@ -69,6 +70,7 @@ theme: - navigation.indexes - search.highlight - search.suggest + - navigation.expand extra_css: - css/style.css diff --git a/src/app_model/expressions/_expressions.py b/src/app_model/expressions/_expressions.py index 6b3046ca..1e17bddc 100644 --- a/src/app_model/expressions/_expressions.py +++ b/src/app_model/expressions/_expressions.py @@ -1,4 +1,4 @@ -"""Provides the :class:`Expr` and its subclasses.""" +"""Provides `Expr` and its subclasses.""" from __future__ import annotations import ast @@ -39,12 +39,12 @@ def parse_expression(expr: Union[str, Expr]) -> Expr: - """Parse string expression into an :class:`Expr` instance. + """Parse string expression into an [`Expr`][app_model.expressions.Expr] instance. Parameters ---------- expr : Union[str, Expr] - Expression to parse. (If already an :class:`Expr`, it is returned) + Expression to parse. (If already an `Expr`, it is returned) Returns ------- From e921fb0b0d25fa091b8fe97c7381067c1c7ce199 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 2 Nov 2023 16:46:46 -0400 Subject: [PATCH 06/12] more --- docs/_griffe_ext.py | 6 ++-- docs/getting_started.md | 30 +++++++++++++++++ docs/index.md | 74 +++++++++++++++-------------------------- mkdocs.yml | 5 +++ 4 files changed, 65 insertions(+), 50 deletions(-) diff --git a/docs/_griffe_ext.py b/docs/_griffe_ext.py index 35010329..f824d845 100644 --- a/docs/_griffe_ext.py +++ b/docs/_griffe_ext.py @@ -49,9 +49,9 @@ def _fix_pydantic( params = [ DocstringParameter( name=field.name, - annotation=field.type_display(modern_union=True) - if field.type - else None, + annotation=( + field.type_display(modern_union=True) if field.type else None + ), description=field.description or "", value=repr(field.default) if field.default is not field.MISSING diff --git a/docs/getting_started.md b/docs/getting_started.md index 2470b938..b0da6f1f 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -78,6 +78,15 @@ The application maintains three internal registries. an association between a [KeyBinding][app_model.types.KeyBinding] and a command id in the `CommandsRegistry`. +!!! Note + Calling [`Application.register_action`][app_model.Application.register_action] with a single + [`Action`][app_model.Action] object is just a convenience around independently registering + objects with each of the registries using: + + - [CommandsRegistry.register_command][app_model.registries.CommandsRegistry.register_command] + - [MenusRegistry.append_menu_items][app_model.registries.MenusRegistry.append_menu_items] + - [KeyBindingsRegistry.register_keybinding_rule][app_model.registries.KeyBindingsRegistry.register_keybinding_rule] + ### Registry events Each of these registries has a signal that is emitted when a new item is added. @@ -182,6 +191,8 @@ provides functions that map the `app-model` model onto various GUI framework mod ability to swap backends. And we hope to add more backends if the demand is there. +### Qt + Currently, we don't have a generic abstraction for the application window, so users are encouraged to directly use the classes in the `app_model.backends.qt` module. One of the main classes is the [`QModelMainWindow`][app_model.backends.qt.QModelMainWindow] object: a subclass of `QMainWindow` that knows how to map @@ -214,3 +225,22 @@ the actions you registered with the application with icons, keybindings, and callbacks all connected. ![QMainWindow with menu bar and toolbar](../images/qmainwindow.jpeg) + +Once objects have been registered with the application, it becomes very easy to +create Qt objects (such as +[`QMainWindow`](https://doc.qt.io/qt-6/qmainwindow.html), +[`QMenu`](https://doc.qt.io/qt-6/qmenu.html), +[`QMenuBar`](https://doc.qt.io/qt-6/qmenubar.html), +[`QAction`](https://doc.qt.io/qt-6/qaction.html), +[`QToolBar`](https://doc.qt.io/qt-6/qtoolbar.html), etc...) with very minimal +boilerplate and repetitive procedural code. + +See all objects in the [Qt backend API docs][app_model.backends.qt]. + +!!! Tip + + Application registries are backed by + [psygnal](https://github.com/pyapp-kit/psygnal), and emit events when modified. + These events are connected to the Qt objects, so `QModel...` objects such as + `QModelMenu` and `QCommandAction` will be updated when the application's + registry is updated. diff --git a/docs/index.md b/docs/index.md index 23f3e886..73e21f6c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,29 +6,25 @@ The primary goal of this library is to provide a set of types that enable an application developer to declare the commands, keybindings, macros, etc. that make up their application. -## General architecture - -Typical usage will begin by creating a [`Application`][app_model.Application] -object. [Commands][app_model.types.CommandRule], [menu -items][app_model.types.MenuRule], and -[keybindings][app_model.types.KeyBindingRule] will usually be declared by -creating [`Action`][app_model.Action] objects, and registered with the -application using the -[`Application.register_action`][app_model.Application.register_action] - -An application maintains a registry for all registered -[commands][app_model.registries.CommandsRegistry], -[menus][app_model.registries.MenusRegistry], and -[keybindings][app_model.registries.KeyBindingsRegistry]. - -!!! Note - Calling [`Application.register_action`][app_model.Application.register_action] with a single - [`Action`][app_model.Action] object is just a convenience around independently registering - objects with each of the registries using: - - - [CommandsRegistry.register_command][app_model.registries.CommandsRegistry.register_command] - - [MenusRegistry.append_menu_items][app_model.registries.MenusRegistry.append_menu_items] - - [KeyBindingsRegistry.register_keybinding_rule][app_model.registries.KeyBindingsRegistry.register_keybinding_rule] +## Installation + +Install from pip + +```bash +pip install app-model +``` + +Or from conda-forge + +```bash +conda install -c conda-forge app-model +``` + +## Usage + +See the [Getting Started](getting_started.md) guide for a quick introduction to +`app-model`. See the [API Reference](reference/) for a complete +reference of the types and functions provided by `app-model`. ## Motivation @@ -61,29 +57,13 @@ Why bother with a declarative application model? `app-model` is backend-agnostic, and can be used with any GUI toolkit, but [Qt](https://www.qt.io) is currently the primary target, and a Qt-backend comes with this library. -### Qt backend - -Once objects have been registered with the application, it becomes very easy to create -Qt objects (such as [`QMainWindow`](https://doc.qt.io/qt-6/qmainwindow.html), [`QMenu`](https://doc.qt.io/qt-6/qmenu.html), [`QMenuBar`](https://doc.qt.io/qt-6/qmenubar.html), [`QAction`](https://doc.qt.io/qt-6/qaction.html), [`QToolBar`](https://doc.qt.io/qt-6/qtoolbar.html), etc...) with very minimal boilerplate and repetitive procedural code. - -```python -from app_model import Application, Action -from app_model.backends.qt import QModelMenu - -app = Application("my-app") -action = Action(id="my-action", ..., menus=[{'id': 'file', ...}]) -app.register_action(action) - -qmenu = QModelMenu(menu_id='file', app=app) -``` - -!!! Tip - Application [registries][app_model.registries] are backed by - [psygnal](https://github.com/tlambert03/psygnal), and emit events when - modified. These events are connected to the Qt objects, so `QModel...` - objects such as `QModelMenu` and `QCommandAction` will be updated when the - application's registry is updated. +See some details in the [qt section](getting_started.md#qt) of the getting started guide. -### Example Application +## Example Application -For a working example of a QApplication built with and without `app-model`, compare [`demo/model_app.py`](https://github.com/pyapp-kit/app-model/blob/main/demo/model_app.py) to [`demo/qapplication.py`](https://github.com/pyapp-kit/app-model/blob/main/demo/qapplication.py) in the `demo` directory of the `app-model` repository. +For a working example of a QApplication built with and without `app-model`, +compare +[`demo/model_app.py`](https://github.com/pyapp-kit/app-model/blob/main/demo/model_app.py) +to +[`demo/qapplication.py`](https://github.com/pyapp-kit/app-model/blob/main/demo/qapplication.py) +in the `demo` directory of the `app-model` repository. diff --git a/mkdocs.yml b/mkdocs.yml index bad5ec27..5b340f5f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,11 @@ markdown_extensions: - pymdownx.superfences - pymdownx.details - admonition + - toc: + permalink: "#" + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg theme: name: material From 9af75db5bbfcac0cca6afea921c23ceb6acab207 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 2 Nov 2023 16:55:27 -0400 Subject: [PATCH 07/12] fix docs --- mkdocs.yml | 1 - pyproject.toml | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 5b340f5f..0ce514b0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,7 +35,6 @@ plugins: - https://docs.python.org/3/objects.inv options: extensions: - - pydantic: { schema: true } - docs/_griffe_ext.py:DynamicDocstrings docstring_style: numpy docstring_options: diff --git a/pyproject.toml b/pyproject.toml index aca90d95..0ddb9f5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ docs = [ "typing_extensions>=4.0", "mkdocs-gen-files", "mkdocs-literate-nav", + "fieldz", ] [project.urls] From 6ce04a139b4b4feb55180eaecb8b6a52c1873ee4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 2 Nov 2023 17:02:51 -0400 Subject: [PATCH 08/12] fix again --- docs/_griffe_ext.py | 5 ++++- docs/getting_started.md | 2 +- docs/index.md | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/_griffe_ext.py b/docs/_griffe_ext.py index f824d845..916f53be 100644 --- a/docs/_griffe_ext.py +++ b/docs/_griffe_ext.py @@ -2,6 +2,7 @@ import inspect import fieldz +from fieldz._repr import display_as_type from griffe import Extension, Object, ObjectNode, dynamic_import, get_logger from griffe.dataclasses import Docstring from griffe.docstrings.dataclasses import DocstringParameter, DocstringSectionParameters @@ -50,7 +51,9 @@ def _fix_pydantic( DocstringParameter( name=field.name, annotation=( - field.type_display(modern_union=True) if field.type else None + display_as_type(field.type, modern_union=True) + if field.type + else None ), description=field.description or "", value=repr(field.default) diff --git a/docs/getting_started.md b/docs/getting_started.md index b0da6f1f..0e079650 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -224,7 +224,7 @@ You should now have a QMainWindow with a menu bar and toolbar populated with the actions you registered with the application with icons, keybindings, and callbacks all connected. -![QMainWindow with menu bar and toolbar](../images/qmainwindow.jpeg) +![QMainWindow with menu bar and toolbar](images/qmainwindow.jpeg) Once objects have been registered with the application, it becomes very easy to create Qt objects (such as diff --git a/docs/index.md b/docs/index.md index 73e21f6c..d9fff82c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,7 @@ conda install -c conda-forge app-model ## Usage See the [Getting Started](getting_started.md) guide for a quick introduction to -`app-model`. See the [API Reference](reference/) for a complete +`app-model`. See the [API Reference](reference/index.md) for a complete reference of the types and functions provided by `app-model`. ## Motivation From a5b37411bcc11eae77ed37fe09c9c6e26bc3eea7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 2 Nov 2023 22:35:00 -0400 Subject: [PATCH 09/12] Update docstrings and menu rules --- docs/_griffe_ext.py | 20 ++++++++++++-------- docs/my_hooks.py | 8 +++++--- src/app_model/types/__init__.py | 2 ++ src/app_model/types/_action.py | 6 ++++-- src/app_model/types/_keys/_keybindings.py | 12 +++++++----- src/app_model/types/_menu_rule.py | 12 ++++++------ 6 files changed, 36 insertions(+), 24 deletions(-) diff --git a/docs/_griffe_ext.py b/docs/_griffe_ext.py index 916f53be..14cef624 100644 --- a/docs/_griffe_ext.py +++ b/docs/_griffe_ext.py @@ -1,4 +1,5 @@ import ast +import contextlib import inspect import fieldz @@ -6,8 +7,8 @@ from griffe import Extension, Object, ObjectNode, dynamic_import, get_logger from griffe.dataclasses import Docstring from griffe.docstrings.dataclasses import DocstringParameter, DocstringSectionParameters - -from app_model.types._base import _BaseModel +from griffe.docstrings.utils import parse_annotation +from pydantic import BaseModel logger = get_logger(__name__) @@ -34,12 +35,13 @@ def on_instance(self, node: ast.AST | ObjectNode, obj: Object) -> None: logger.debug(f"Object {obj.path} does not have a __doc__ attribute") return - if isinstance(runtime_obj, type) and issubclass(runtime_obj, _BaseModel): + with contextlib.suppress(TypeError): + fieldz.get_adapter(runtime_obj) docstring = inspect.cleandoc(docstring) - self._fix_pydantic(docstring, obj, runtime_obj) + self._inject_fields(docstring, obj, runtime_obj) - def _fix_pydantic( - self, docstring: str, obj: Object, runtime_obj: type[_BaseModel] + def _inject_fields( + self, docstring: str, obj: Object, runtime_obj: type[BaseModel] ) -> None: # update the object instance with the evaluated docstring if obj.docstring: @@ -51,7 +53,9 @@ def _fix_pydantic( DocstringParameter( name=field.name, annotation=( - display_as_type(field.type, modern_union=True) + parse_annotation( + display_as_type(field.type, modern_union=True), obj.docstring + ) if field.type else None ), @@ -64,7 +68,7 @@ def _fix_pydantic( if field.name in runtime_obj.__annotations__ ] param_section = DocstringSectionParameters(params) - + # TODO: merge rather than overwrite parsed = [ x for x in obj.docstring.parsed diff --git a/docs/my_hooks.py b/docs/my_hooks.py index 3466bb6b..bb8d5c65 100644 --- a/docs/my_hooks.py +++ b/docs/my_hooks.py @@ -1,5 +1,7 @@ -def on_page_markdown(md: str, page, config, files): +from typing import Any + + +def on_page_markdown(md: str, **kwargs: Any) -> str: T = "::: app_model.types" T2 = T + "\n\toptions:\n\t\tdocstring_section_style: table" - md = md.replace(T, T2) - return md + return md.replace(T, T2) diff --git a/src/app_model/types/__init__.py b/src/app_model/types/__init__.py index 3074378e..d3c9d9a7 100644 --- a/src/app_model/types/__init__.py +++ b/src/app_model/types/__init__.py @@ -14,6 +14,7 @@ ) from ._menu_rule import ( MenuItem, + MenuItemBase, MenuOrSubmenu, MenuRule, MenuRuleDict, @@ -35,6 +36,7 @@ "KeyCombo", "KeyMod", "MenuItem", + "MenuItemBase", "MenuOrSubmenu", "MenuRule", "MenuRuleDict", diff --git a/src/app_model/types/_action.py b/src/app_model/types/_action.py index a8b49712..52748d90 100644 --- a/src/app_model/types/_action.py +++ b/src/app_model/types/_action.py @@ -23,7 +23,7 @@ class Action(CommandRule, Generic[P, R]): - """Callable object along with specific context, menu, keybindings logic. + """An Action is a callable object with menu placement, keybindings, and metadata. This is the "complete" representation of a command. Including a pointer to the actual callable object, as well as any additional menu and keybinding rules. @@ -41,7 +41,9 @@ class Action(CommandRule, Generic[P, R]): ) menus: Optional[List[MenuRule]] = Field( None, - description="(Optional) Menus to which this action should be added.", + description="(Optional) Menus to which this action should be added. Note that " + "menu items in the sequence may be supplied as a plain string, which will " + "be converted to a `MenuRule` with the string as the `id` field.", ) keybindings: Optional[List[KeyBindingRule]] = Field( None, diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index c6560bbb..488a8dc3 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -22,11 +22,13 @@ class SimpleKeyBinding(BaseModel): """Represent a simple combination modifier(s) and a key, e.g. Ctrl+A.""" - ctrl: bool = False - shift: bool = False - alt: bool = False - meta: bool = False - key: Optional[KeyCode] = None + ctrl: bool = Field(False, description='Whether the "Ctrl" modifier is active.') + shift: bool = Field(False, description='Whether the "Shift" modifier is active.') + alt: bool = Field(False, description='Whether the "Alt" modifier is active.') + meta: bool = Field(False, description='Whether the "Meta" modifier is active.') + key: Optional[KeyCode] = Field( + None, description="The key that is pressed (e.g. `KeyCode.A`)" + ) # def hash_code(self) -> str: # used by vscode for caching during keybinding resolution diff --git a/src/app_model/types/_menu_rule.py b/src/app_model/types/_menu_rule.py index 37b4c85f..500b8df6 100644 --- a/src/app_model/types/_menu_rule.py +++ b/src/app_model/types/_menu_rule.py @@ -17,7 +17,7 @@ from ._icon import Icon -class _MenuItemBase(_BaseModel): +class MenuItemBase(_BaseModel): """Data representing where and when a menu item should be shown.""" when: Optional[expressions.Expr] = Field( @@ -43,9 +43,9 @@ def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: yield cls._validate @classmethod - def _validate(cls: Type["_MenuItemBase"], v: Any) -> "_MenuItemBase": + def _validate(cls: Type["MenuItemBase"], v: Any) -> "MenuItemBase": """Validate icon.""" - if isinstance(v, _MenuItemBase): + if isinstance(v, MenuItemBase): return v if isinstance(v, dict): if "command" in v: @@ -57,7 +57,7 @@ def _validate(cls: Type["_MenuItemBase"], v: Any) -> "_MenuItemBase": raise ValueError(f"Invalid menu item: {v!r}", cls) # pragma: no cover -class MenuRule(_MenuItemBase): +class MenuRule(MenuItemBase): """A MenuRule defines a menu location and conditions for presentation. It does not define an actual command. That is done in either `MenuItem` or `Action`. @@ -79,7 +79,7 @@ def _validate_model(cls, v: Any) -> Any: return {"id": v} if isinstance(v, str) else v -class MenuItem(_MenuItemBase): +class MenuItem(MenuItemBase): """Combination of a Command and conditions for menu presentation. This object is mostly constructed by `register_action` right before menu item @@ -103,7 +103,7 @@ def _simplify_command_rule(cls, v: Any) -> CommandRule: raise TypeError("command must be a CommandRule") # pragma: no cover -class SubmenuItem(_MenuItemBase): +class SubmenuItem(MenuItemBase): """Point to another Menu that will be displayed as a submenu.""" submenu: str = Field(..., description="Menu to insert as a submenu.") From 9a48d697ddfd5d1e84bb070a6cd572bbc51b3791 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 2 Nov 2023 22:53:43 -0400 Subject: [PATCH 10/12] Fix typo and add explanation of scan codes. --- src/app_model/types/_keys/_key_codes.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app_model/types/_keys/_key_codes.py b/src/app_model/types/_keys/_key_codes.py index b7c8ebde..38d9f72c 100644 --- a/src/app_model/types/_keys/_key_codes.py +++ b/src/app_model/types/_keys/_key_codes.py @@ -196,8 +196,16 @@ class ScanCode(IntEnum): https://en.wikipedia.org/wiki/Scancode - These are the scan codea required to conform to the W3C specification for + These are the scan codes required to conform to the W3C specification for KeyboardEvent.code + + A scan code is a hardware-specific code that is generated by the keyboard when a key + is pressed or released. It represents the physical location of a key on the keyboard + and is unique to each key. A key code, on the other hand, is a higher-level + representation of a keypress or key release event. They are associated with + characters, functions, or actions rather than hardware locations. + As an example, the left and right control keys have the same key code (KeyCode.Ctrl) + but different scan codes (LeftControl and RightControl). https://w3c.github.io/uievents-code/ From 50dab7b7b2a106b9ee2d5ad771b212cfb91db89e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 3 Nov 2023 08:35:22 -0400 Subject: [PATCH 11/12] Refactored injection_store attribute to use providers and processors --- docs/getting_started.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/getting_started.md b/docs/getting_started.md index 0e079650..862740b4 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -132,11 +132,14 @@ a dependency injection pattern, provided by the The application has a [`injection_store`][app_model.Application.injection_store] attribute that is an instance of an `in_n_out.Store`. A `Store` is a collection -of "providers" (functions that can be called to return an instance of a given -type), and "processors" (functions that accept an instance of a given type and -do something with it). The `Store` is used to provide the arguments to the -command when it is executed, based on the type annotations in the command -function definition. +of: + +- **providers**: Functions that can be called to return an instance of a given + type. These may be used to provide arguments to commands, based on the type + annotations in the command function definition. +- **processors**: Functions that accept an instance of a given type and do + something with it. These are used to process the return value of the command + function at execution time, based on command definition return type annotations. Here's an example. Let's say an application has a `User` object with a `name()` method: From 0ce61b9f7fc4f2a8e22deb2c0481d18ee7391c0b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 3 Nov 2023 11:22:59 -0400 Subject: [PATCH 12/12] Refactor application model for easier querying and modification --- docs/index.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/index.md b/docs/index.md index d9fff82c..1aa54108 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,17 +32,24 @@ Why bother with a declarative application model? 1. **It's easier to query the application's state** - If you want to ask "what commands are available in this application?", or "what items are currently in a given menu", you can directly query the application registries. For example, you don't need to find a specific `QMenu` instance and iterate its `actions()` to know whether a given item is present. + If you want to ask "what commands are available in this application?", or + "what items are currently in a given menu", you can directly query the + application registries. For example, you don't need to find a specific + `QMenu` instance and iterate its `actions()` to know whether a given item is + present. 1. **It's easier to modify the application's state** - For applications that need to be dynamic (e.g. adding and removing menu items and actions as plugins are loaded and unloaded), it is convenient to have an application - model that emits events when modified, with the "view" (the actual GUI backend) responding to those events to update the actual presentation. + For applications that need to be dynamic (e.g. adding and removing menu + items and actions as plugins are loaded and unloaded), it is convenient to + have an application model that emits events when modified, with the "view" + (the actual GUI framework) responding to those events to update the actual + presentation. -1. **It decouples the structure of the application from the underlying backend** +1. **It decouples the structure of the application from the GUI framework** - This makes it easier to change the backend without having to change the - application. (Obviously, as an application grows with a particular backend, + This makes it easier to change the GUI framework without having to change the + application. (Obviously, as an application grows with a particular framework, it does become harder to extract, but having a loosely coupled model is a step in the right direction) @@ -52,12 +59,14 @@ Why bother with a declarative application model? one-off procedurally created menus, we can test reusable *patterns* of command/menu/keybinding creation and registration. -## Back Ends +## GUI Frameworks -`app-model` is backend-agnostic, and can be used with any GUI toolkit, but [Qt](https://www.qt.io) is -currently the primary target, and a Qt-backend comes with this library. +`app-model` is framework-agnostic, and can be used with any GUI toolkit, but +[Qt](https://www.qt.io) is currently the primary target, and a +[Qt adapter][app_model.backends.qt] comes with this library. -See some details in the [qt section](getting_started.md#qt) of the getting started guide. +See some details in the [qt section](getting_started.md#qt) of the getting +started guide. ## Example Application