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/_griffe_ext.py b/docs/_griffe_ext.py
new file mode 100644
index 00000000..14cef624
--- /dev/null
+++ b/docs/_griffe_ext.py
@@ -0,0 +1,78 @@
+import ast
+import contextlib
+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
+from griffe.docstrings.utils import parse_annotation
+from pydantic 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
+
+ with contextlib.suppress(TypeError):
+ fieldz.get_adapter(runtime_obj)
+ docstring = inspect.cleandoc(docstring)
+ self._inject_fields(docstring, obj, runtime_obj)
+
+ def _inject_fields(
+ 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=(
+ parse_annotation(
+ display_as_type(field.type, modern_union=True), obj.docstring
+ )
+ 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)
+ # TODO: merge rather than overwrite
+ 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/_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/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
new file mode 100644
index 00000000..49b8c022
--- /dev/null
+++ b/docs/css/style.css
@@ -0,0 +1,170 @@
+/* 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;
+}
+
+/* ------------------------------- */
+
+/* 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;
+}
+
+/* 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: .1rem;
+ font-size: .85em;
+ padding: 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";
+}
\ 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/getting_started.md b/docs/getting_started.md
new file mode 100644
index 00000000..862740b4
--- /dev/null
+++ b/docs/getting_started.md
@@ -0,0 +1,249 @@
+# 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`.
+
+!!! 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.
+
+- `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. 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:
+
+```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.
+
+### 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
+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.
+
+
+
+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/images/qmainwindow.jpeg b/docs/images/qmainwindow.jpeg
new file mode 100644
index 00000000..4fdb2c88
Binary files /dev/null and b/docs/images/qmainwindow.jpeg differ
diff --git a/docs/index.md b/docs/index.md
index 3ecbc551..1aa54108 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -6,23 +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
+## Installation
-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]
+Install from pip
-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].
+```bash
+pip install app-model
+```
+
+Or from conda-forge
-!!! 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:
+```bash
+conda install -c conda-forge app-model
+```
- - [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]
+## Usage
+
+See the [Getting Started](getting_started.md) guide for a quick introduction to
+`app-model`. See the [API Reference](reference/index.md) for a complete
+reference of the types and functions provided by `app-model`.
## Motivation
@@ -30,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)
@@ -50,34 +59,20 @@ 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
-
-`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.
+## GUI Frameworks
-### 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)
-```
+`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.
-!!! Tip
- Application [registries](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/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..bb8d5c65
--- /dev/null
+++ b/docs/my_hooks.py
@@ -0,0 +1,7 @@
+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"
+ return md.replace(T, T2)
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
deleted file mode 100644
index 9e995fae..00000000
--- a/docs/types.md
+++ /dev/null
@@ -1,24 +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
- 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 be14a02a..0ce514b0 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -2,36 +2,54 @@ 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
+ - getting_started.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
- - macros:
- module_name: docs/_macros
- mkdocstrings:
handlers:
python:
import:
- https://docs.python.org/3/objects.inv
options:
+ extensions:
+ - docs/_griffe_ext.py:DynamicDocstrings
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
@@ -40,6 +58,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
@@ -48,5 +71,13 @@ theme:
logo: material/application-cog-outline
features:
- navigation.instant
+ - navigation.indexes
- search.highlight
- search.suggest
+ - navigation.expand
+
+extra_css:
+ - css/style.css
+
+hooks:
+ - docs/my_hooks.py
diff --git a/pyproject.toml b/pyproject.toml
index 691fb554..0ddb9f5a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,14 +53,17 @@ 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",
+ "mkdocs-gen-files",
+ "mkdocs-literate-nav",
+ "fieldz",
]
[project.urls]
@@ -138,7 +141,7 @@ pretty = true
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
-module = ["tests.*"]
+module = ["tests.*", "docs.*"]
disallow_untyped_defs = false
[[tool.mypy.overrides]]
diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py
new file mode 100644
index 00000000..c95bd325
--- /dev/null
+++ b/scripts/gen_ref_nav.py
@@ -0,0 +1,37 @@
+"""Generate the code reference pages and navigation."""
+
+from pathlib import Path
+
+import mkdocs_gen_files
+
+SRC = Path("src")
+PKG = SRC / "app_model"
+
+nav = mkdocs_gen_files.Nav()
+mod_symbol = ''
+
+for path in sorted(SRC.rglob("*.py")):
+ module_path = path.relative_to("src").with_suffix("")
+ doc_path = path.relative_to(PKG).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")
+ if 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 b6f9ee36..0177d51b 100644
--- a/src/app_model/backends/qt/_qaction.py
+++ b/src/app_model/backends/qt/_qaction.py
@@ -67,12 +67,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__(
@@ -123,7 +125,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/backends/qt/_qkeymap.py b/src/app_model/backends/qt/_qkeymap.py
index 6a3f8d04..88956e7e 100644
--- a/src/app_model/backends/qt/_qkeymap.py
+++ b/src/app_model/backends/qt/_qkeymap.py
@@ -8,14 +8,14 @@
from qtpy.QtCore import QCoreApplication, Qt
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,
KeyMod,
SimpleKeyBinding,
)
+from app_model.types._constants import OperatingSystem
if TYPE_CHECKING:
from qtpy.QtCore import QKeyCombination
diff --git a/src/app_model/expressions/_expressions.py b/src/app_model/expressions/_expressions.py
index 102b0a4c..cc6b102e 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
-------
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/_key_codes.py b/src/app_model/types/_keys/_key_codes.py
index 20f43f49..1b5cbabb 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/
@@ -694,6 +702,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 b5b09bed..b8c8cc2e 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
@@ -143,13 +145,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)
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.")