Desktop UI runtime¶
All runtime imports below come from patera.desktop.ui. Browser modules run in Pyodide; native
Patera services remain behind the local HTTP API. Jinja2 is used in both interpreters. Frontend
templates are application code, not a sandbox for untrusted template authors.
Pages and layouts¶
from patera.desktop.ui import Application, Component, SubLayout, layout, page, tag, template
@layout
@tag("main-layout")
class MainLayout(Component):
def markup(self):
return template('<header>My application</header><main id="page"></main>')
@page("/notes/<int:note_id>", title="Note")
@tag("note-page")
class NotePage(Component):
def markup(self):
return template('<h1>Note {{ params.note_id }}</h1>')
app = Application(__name__, base_layout=MainLayout)
Every component needs a unique @tag. Classes referenced by page layout metadata are registered
automatically.
@page accepts name, title, layout, sublayouts, target and extras. A title may be a
sync or async callable receiving the initialized page instance. For nested layouts use
sublayouts=[SubLayout(DetailLayout, target="#page")] and target="#detail-content" on the page.
Each target is selected inside its owning layout. The default page target is #page,
configurable with Application(default_target=...).
Navigation reuses the common prefix of the layout chain, keeping its state and DOM. Pages and
removed layouts are disposed. Layout params.* and query.* fragments update when route values
change; use component.context when a retained layout needs the current page context.
Controller pages remain supported and can use layouts:
from patera.desktop.ui import Controller, PageContext, View, page, path
@path("/notes")
class Notes(Controller):
@page("/<int:note_id>", layout=MainLayout)
async def detail(self, ctx: PageContext, note_id: int) -> View:
note = await ctx.api.get_json(f"/api/notes/{note_id}")
return View("templates/note.html", note=note)
Routing¶
- Routes support literal segments,
<name>,<str:name>/<string:name>,<int:name>and<path:name>. More specific routes win. base_urlcan include parameters, for exampleApplication(base_url="/<str:language>").app.url_for("NotePage", note_id=3)generates a URL; base parameters must also be supplied where required.await app.navigate(url, history="push")navigates;historycan also bereplaceornone.app.router.redirect(...)supportsuse_base_urlandbase_parameters.- Same-origin application links are intercepted unless they have
router-ignoreor the UI ignore attribute. Back/Forward restores saved scroll positions. Superseded navigations cannot commit stale pages or error pages.
Jinja2 and reactive fragments¶
template(source, **context) returns a deferred inline template;
Template(name="templates/note.html", context={...}) loads a named template;
html(source, **context) renders immediately. Templates use auto-escaping and strict undefined
values, and support inheritance, includes, macros, loops and custom
Application(filters={...}, globals={...}). Only .html files are registered as named templates.
Component template variables are component, app, data, query, params, args and ctx.
Component properties are accessed explicitly as component.property. trusted_html() bypasses
escaping; use it only for HTML you trust. Server-rendered HTML is inserted directly and never
interpreted again as a frontend template.
from patera.desktop.ui import Component, react, tag, template
@tag("note-counter")
class Counter(Component):
count = react(0)
def markup(self):
return template('''
<button @click="increment">Increment</button>
<span data-bind="count">{{ component.count }}</span>
''')
def increment(self, event):
self.count += 1
- Assigning a reactive property re-renders the contents of matching
data-bindelements; the outer element stays in place. Explicit{% bind "count" %}...{% endbind %}blocks work where a wrapper is unsuitable. - Bindings keep Jinja loop locals, support nesting and discard stale child bindings. Replaced descendant components are disposed and new ones initialized.
- Use
await self.rerender()when attributes or the whole component must change. - Mutable defaults are copied per instance;
react_factory(list)accepts a factory. In-place mutations needself.invalidate("binding")or assignment of a new value.
Application data and query parameters¶
app.set_data("notes", notes)notifiesdata-bind="app.notes"fragments.@tag(..., data_field="notes")requests a full component re-render when that field changes.query_param("filter", default="")exposes a URL query value as a descriptor. Assigning it updates browser history andquery.filterbindings.app.update_query_parameters({...}, history="replace")updates several values;Noneremoves a key.app.query_parameterscontains the last value per key;PageContext.querypreserves lists of repeated values.
The earlier State, data-ui-text and data-ui-value APIs remain available.
Events, arguments and DOM access¶
Use @click="method", @input="method" or jolt-click="method" on elements. The compatibility
syntax data-ui-on-click="method" requires a matching @event("click") decorator. Methods
receive the original browser event proxy.
Submit events are prevented synchronously. Add data-ui-prevent-default or
data-ui-stop-propagation for other events; calling preventDefault() after an await is too
late.
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from patera.desktop.ui import select
if TYPE_CHECKING:
from patera.desktop.ui.types import HTMLInputElement, InputEvent
# Inside a Component:
title_input = select('input[name="title"]', required=True)
def changed(self, event: InputEvent) -> None:
value = cast("HTMLInputElement", event.target).value
- Use
:name='JSON',data-jolt-nameordata-ui-arg-nameattributes to pass component arguments throughself.args. Non-JSON strings stay strings. select()/select_all()respect component ownership: a child host belongs to its parent, a child's interior belongs to the child. Selector shorthand[:name]works with colon attributes.insert_html,hydrateandfetch_htmlintegrate dynamically inserted markup with event binding and child initialization. Listener proxies are released on replacement.
Lifecycle and cancellation¶
Decorate sync or async methods with @lifecycle(Lifecycle.BEFORE_INIT) and the other phases:
EXTENSION_INIT, BEFORE_INIT_RESOLVE, AFTER_INIT, BEFORE_RERENDER, AFTER_RERENDER,
AFTER_DISCONNECT. Inherited hooks execute base first; overriding a method suppresses its
inherited registration.
Initialization loads dependencies, runs before-init hooks, renders, waits for child
initialization, runs before-init-resolve hooks, and resolves init_complete before after-init
hooks. The mounted / unmounted hooks are kept. Initialization failures reject with
ElementInitializationError, keeping the original error as __cause__.
self.spawn(coroutine)andself.on_cleanup(callback)bind work and resources to the component lifetime;ctx.spawn/ctx.on_cleanuphave the page lifetime.self.apicancels outstanding requests when the component disconnects.ElementDisconnectedErroris cancellation, not an application failure.dependencies = [OtherComponent, lazy("frontend.widgets.editor")]registers component dependencies and defers imports. This defers execution, not downloading: the offline archive contains the source.
Reactive update scheduling¶
Existing applications keep synchronous binding updates. Opt into frame batching when constructing the application:
app = Application(__name__, batch_updates=True, diagnostics=True)
# Inside an async component handler:
self.count += 1 # Python reads see the new value immediately
self.count += 1
await self.flush() # or await app.flush()
With batching, repeated invalidations of a binding are combined and commits run on the next
animation frame; flush() commits immediately, also when the window is hidden. Updates caused
during rendering are handled in later batches, with a 100-batch limit to surface update loops.
flush() is a DOM commit barrier for queued bindings, not a paint, network or event-task
barrier. After inserting components through a binding, use await self.wait_children() if their
initialization must finish before continuing.
Diagnostics¶
With diagnostics=True, app.diagnostics_snapshot() returns aggregate counts and timings in
milliseconds (template evaluation, DOM replacement, hydration, listeners, initialization and
cleanup) and counts of components, tasks, listeners, subscriptions, fragments and queued
components. app.diagnostics.reset() resets measurements. Nested measurements overlap and must
not be summed.
Keyed lists¶
Use a Jinja keyed block where a data-bind loop would otherwise replace every row:
<ul>
{% keyed "items" %}
{% for item in component.items %}
<li data-ui-key="{{ item.id }}">
<input value="{{ item.title }}">
<button @click="remove" :item="{{ item.id }}">Remove</button>
</li>
{% endfor %}
{% endkeyed %}
</ul>
- Every direct row needs a stable, unique, non-empty
data-ui-key. Avoid array positions when items can move. Missing or duplicate keys fail before DOM reconciliation. - Matching keys and element types keep their row nodes: changed text and declared attributes are patched, rows are created, removed and moved without remounting child components. Input edits, focus and selection survive.
- Use balanced HTML; for table rows put the block inside a
<tbody>. - Place a nested keyed or virtual list in a child component. Keyed rendering still evaluates the whole loop; it reduces DOM and lifecycle churn, not template evaluation.
Fixed-height virtual lists¶
For a large sequence, a virtual block evaluates and mounts only a visible window plus
overscan:
{% virtual "items", component.items, key="id", row_height=40, height=400, overscan=3 %}
<span>{{ item.title }}</span>
<button @click="open" :item="{{ item.id }}">Open</button>
{% endvirtual %}
keyis a mapping key, attribute name or callable. Keys must be unique non-empty strings or integers (integer1and string"1"collide).row_heightandheightare positive pixel sizes;overscanis a non-negative integer. Defaults are 40, 400 and 3. Variable-height rows are not supported.- Use a normal block container, not a table or select element.
- Off-screen rows are disposed, so keep persistent edits and selection in application state.
Errors, authorization and application events¶
ExceptionHandler methods decorated with @handles(ErrorType) receive (ctx, error). Dispatch
checks the exception's MRO, then compatible registered types, then its cause chain. Methods may
return a View or call await app.show_error_page(ErrorComponent, error=error, args={...}).
Error pages do not change the current URL.
Use @auth() or @auth(roles=["editor"]) on page and layout classes or controller handlers.
Override Application.is_authenticated() and check_user_roles(roles) (sync or async). All
applicable layout and page requirements are checked before mounting; failures raise
AuthenticationError or AuthorizationError.
Warning
These hooks control the UI only. Enforce permissions on the backend as well.
app.on(name, callback) returns an unsubscribe function; await app.emit(name, *args) awaits
subscribers. Built-in route-start and route-finished events receive the page context.
Cached and server-rendered components¶
@tag(..., cache=True, cache_duration=30) caches the markup provider's result for 30 seconds.
The key includes tag, group, route and query parameters and args. Concurrent requests share one
load. Application(templates_cache_duration=..., templates_cache_cleanup_interval=...) sets
defaults; clear_templates_cache() and clean_templates_cache() invalidate entries.
For native rendering, configure the backend explicitly:
DESKTOP_UI = DesktopUIConfig(
SERVER_COMPONENTS_PATH="/api/ui-components",
SERVER_COMPONENTS={"default/server-card": "components/card.html"},
)
Then declare @tag("server-card", use_server=True) on a frontend component. The endpoint must be
inside API_PATHS, so the UI session-token guard applies. Only allowlisted group/tag pairs select
templates, which receive the request values as args, query and params.
DesktopUI.register_server_component(tag, template, group="default", context=callback) adds
server context; the callback receives (request, payload). Never choose a template path from
request input.
app.fetch_html and app.prefetch_html use the same rendering and cache path. The API client
supports JSON and text responses, CRUD helpers and cancellation, and distinguishes APIError,
NetworkError and JsonResponseError.
Compatibility boundaries¶
This is a Python counterpart of JoltUI, not a TypeScript execution layer. Jinja expressions refer to Python values; there is no doT engine or JavaScript template evaluation. The packaged browser runtime targets PyScript/Pyodide on the main thread, not MicroPython or workers. The extension registers no routes and adds no dependencies to applications that do not use it.