# Technical guide This guide explains how ComposeUI works inside, and how to change it: add a widget, add a backend, and run the checks. To build an app with ComposeUI, read the [user guide](user-guide.md) instead. ## Contents - [Project layout](#project-layout) - [Architecture](#architecture) - [The core imports no backend](#the-core-imports-no-backend) - [The registry](#the-registry) - [Views and renderers](#views-and-renderers) - [Signals](#signals) - [View state](#view-state) - [A button click](#a-button-click) - [The web backend](#the-web-backend) - [A button click on the web](#a-button-click-on-the-web) - [Reloading on a change](#reloading-on-a-change) - [Reloading on Qt](#reloading-on-qt) - [Reloading on Textual](#reloading-on-textual) - [Reloading on the web](#reloading-on-the-web) - [Add a widget](#add-a-widget) - [Add a backend](#add-a-backend) - [Development](#development) - [Setup](#setup) - [Commands](#commands) - [Tests](#tests) - [Translations](#translations) - [Styles of the web backend](#styles-of-the-web-backend) - [Code style](#code-style) - [Documentation site](#documentation-site) - [Continuous integration](#continuous-integration) - [Release](#release) ## Project layout ``` src/composeui/ core/ # toolkit-agnostic: views, signals, renderer base, application, registry qtcore/ # Qt backend via qtpy (QtApplication, qtrenderer.py, custom widgets/) textualcore/ # Textual (TUI) backend webcore/ # Web backend (Starlette + htmx + daisyUI); static/app.css is built css/ # source of webcore/static/app.css (`make css`; tools land in css/vendor/) src/examples/ # example apps (pipeform.py, pipecatalogue.py, ...), also used by tests; not in the wheel tests/ # pytest suite (pytest-qt, pytest-asyncio, Textual run_test pilot) docs/ # the user guide (user-guide/), the examples guide, this guide, the Sphinx site ``` ## Architecture ### The core imports no backend `composeui.core` never imports a backend (Qt, Textual, Starlette) at import time. The Qt binding lives entirely in `composeui.qtcore`. Python imports it only when a view renders with `RendererType.QT`. The same is true for Textual and the web. `tests/test_renderer_registry.py` checks that importing `composeui.core.view` imports no toolkit. ### The registry `composeui.core.registry` maps renderer keys to the renderers of each backend: - Each `RendererType` member is a backend. Its value is a `Backend`, which holds: - `renderers`: the dotted path of the module that exposes the `RENDERERS` mapping, `dict[str, Callable[[Any], Renderer]]`; - `applications`: the dotted path of the module that exposes `APPLICATION` and `MODEL_APPLICATION`, the application classes that `core.application.run` starts; - `extra` and `packages`: the extra of the package that installs the backend's dependencies, and their top-level modules. When the backend's import fails and one of these modules is missing, `run` stops and names the extra. - A view names its renderer with its `_renderer_key` class variable, for example `"label"`. - `create_renderer(renderer_type, key, view)` imports the renderers module of the backend and looks up the key. When the backend doesn't register the key, it returns `None`, and the view is not rendered. So a partial backend works. - A renderer class of the `"router"` or `"tabs"` key can set `renders_on_demand = True`, as Qt's do. `Router` and `TabView` read it with `renders_on_demand(renderer_type, key)`. `Router` then renders the shown page, and only the toolbar entry and the dialogs of a page not built yet. `TabView` renders the renderer of each `TabPage`, which shows the tab's title and state, and the tab's dialogs. The renderer builds the views of a page or a tab the first time it shows. The web and Textual render every page and tab. The three backends register the same keys except for maps, which Textual omits. `test_every_backend_registers_the_same_renderer_keys_except_graphical_maps` checks this contract. ### Views and renderers The views are the view-models of MVVM, and the toolkit objects are the views. The renderers are the binding between them. See [How ComposeUI maps to MVVM](user-guide/core-concepts.md#how-composeui-maps-to-mvvm). A view is an `attrs` class. `@define_view` adds the `notify_renderer` hook to the `on_setattr` of its fields. When code sets a public attribute to a new value, the hook calls `refresh_attribute(name, value)` on the renderer of the view. Do not add manual `update()` or `refresh()` calls. A renderer (`composeui.core.renderer.Renderer`) binds a view to a toolkit object, its rendered object. It syncs them in both directions: | Method | Direction | Does | | --- | --- | --- | | `render()` | view to toolkit | Creates the rendered object. | | `refresh()` | view to toolkit | Calls every handler of `_refresh_dispatcher` with the current value of its attribute. | | `refresh_attribute(name, value)` | view to toolkit | Calls the handler of one attribute. | | A handler of a widget signal, connected in `render()` | toolkit to view | Sets the view attribute inside `stop_refresh()`. | `_refresh_dispatcher` maps each view attribute that the renderer cares about to its handler. When a view attribute changes and it has no handler, `refresh_attribute` raises `MissingRefreshAttributeFunctionError`. Inside `stop_refresh()`, `is_updating_state()` is true. `notify_renderer` then doesn't refresh the toolkit with the values that came from it. `call_on_main_thread(callback)` calls the callback directly by default. Each backend of this repo overrides it: Qt and Textual run the callback on their main thread, and the web runs it under the session's lock. See [A button click](#a-button-click). ### Signals `Signal.connect` and `Application.connect` read the parameter names of a callback: - `Signal.connect` injects the `main_view` and `model` keyword arguments when the callback asks for them. The callback gets only as many of the signal's arguments as it takes. - `Application.connect` also injects `view`, the view that it connects. These names are part of the API: keep them exact. ### View state `MainView.save_state` and `MainView.load_state` serialize the view tree with the `cattrs` converter `CONVERTER` of `composeui.core.view`. A new view field must stay serializable, or be excluded: - Fields of the types in `_OMITTED_TYPES` (`Signal`, `Renderer`, `RendererContainer`, `threading.Timer`) are skipped automatically. - Other fields that can't be serialized, such as `Validator` fields, take the `OMIT` metadata key. - Text that the app's code sets, not the user, takes the `UI_TEXT` metadata key. See [Saving and loading state](user-guide/building-views.md#saving-and-loading-state). ### A button click An event goes from the toolkit's widget to the view, and a change goes back from the view to the toolkit's widget. For example, a page has a `ButtonView` and a `LabelView`. A callback connected to the button's `clicked` signal sets the label's `text` to "Done". On every backend, one click does this: ![Each backend turns the click into the view's clicked signal. In composeui.core, the signal runs the callback, which sets the label's text, and notify_renderer calls refresh_attribute. Each backend's label handler then shows the value.](images/button-click.svg) 1. **The button's renderer emits the view's signal.** It turns the toolkit's click into the `clicked` signal of the `ButtonView`. 2. **The signal runs the callback.** `Signal.emit` calls each connected callback, with the arguments that it injects (see [Signals](#signals)). 3. **The view notifies its renderer.** The callback sets `label.text = "Done"`. `notify_renderer` compares the new value with the old one, and calls `refresh_attribute("text", "Done")` on the label's renderer. 4. **The renderer shows the value.** The `"text"` handler of the label renderer's `_refresh_dispatcher` changes the toolkit's widget. Steps 2 and 3 are in `composeui.core`, and are the same on every backend. Steps 1 and 4 are the backend's: - **Qt.** The button is a `QPushButton`. Its Qt `clicked` signal calls `QtButtonRenderer._emit_clicked`, which emits the view's `clicked`. The click and the callback run in Qt's event loop, on the main thread. The label's `"text"` handler calls `QLabel.setText`. `QtWidgetRenderer.refresh_attribute` blocks the widget's signals while it sets the value. So the widget does not send the app's own change back to the view as an edit of the user. - **Textual.** The button is a `ComposeButton`, a Textual `Button`. Its `on_button_pressed` emits its own `clicked` signal, which is connected to the view's. The click and the callback run in the app's event loop. The label's `"text"` handler calls `Label.update`. `TextualWidgetRenderer.refresh_attribute` goes through `call_on_main_thread`, which calls the handler directly on the app's own thread. A widget that is not mounted yet, for example on a screen that is not shown, gets its state when it is mounted (`_is_waiting_to_mount`). - **The web.** The button is a `