Python SDK
Overview
Plexi Python SDK.
Apps expose module-level init(size, args), update(event), and view()
functions. The V3AppRuntime drives the event loop: host sends JSON events on
stdin, app responds with effects and component trees on stdout.
Python apps are sandboxed. They run through the CPython-in-WASM adapter
(src/host/wasm_python.rs) inside their own wasmtime::Store with isolated
linear memory — the same component boundary a Rust WASM app gets, not a native
subprocess. An app reaches only the host interfaces Plexi links for its world,
and protected effects (file, network, AI, clipboard, pane, audio, GPU) each
require an explicit capability grant the user approves on first request. There
is no ambient process access to escape to. See docs/wasm-runtime.md
§ Security Model for the full boundary.
Effects
SetState
SetState(data: dict, scope: str | None = None)
Merge data into in-memory state. scope selects which declared
state scope receives the keys; None means the app’s default scope
(the first entry of the manifest’s [state] scopes). Using a scope the
app did not declare raises at apply time — never a silent fallback.
PersistState
PersistState(data: dict, scope: str | None = None)
Merge data into a scope’s state and persist that scope’s snapshot
to disk. The host owns path construction: global state lives in
~/.plexi/app_states/, context state in
<context_root>/.plexi/app_states/ — resolved against the pane’s
context root at call time. scope=None targets the default scope.
SetSchedulerMode
SetSchedulerMode(mode: str, fps: int | None = None)
FileRead
FileRead(path: str)
Read a workspace file; the reply is an events.FileReadResult whose
content is the file’s exact bytes (binary-safe). Decode yourself for text.
Requires the fs.read capability.
FileWrite
FileWrite(path: str, content: bytes)
Write content bytes to a workspace file, binary-exact. Requires the
fs.write capability. Reply is an events.FileWriteResult.
read_bytes(path: str)
Binary-exact file read. The reply’s FileReadResult.content holds the
file’s exact bytes — WAV, PNG, any media round-trips unchanged.
write_bytes(path: str, content: bytes | bytearray)
Binary-exact file write. Rejects non-bytes payloads and payloads over
MAX_FILE_IO_BYTES immediately, before the host round trip.
ReadHostLog
ReadHostLog(max_bytes: int = 256 * 1024)
Request the tail of the Plexi host channel log (logs.read capability).
The host owns path resolution — it tails its own ~/.plexi-<channel>/plexi.log
and replies with an events.HostLogResult. The app never names or opens the
file, so the request carries only how many trailing bytes to read.
OpenFilePicker
OpenFilePicker(request_id: str, filter: list[str] = list(), multiple: bool = False, mode: str = 'open')
Show a host file picker (fs.pick capability).
mode is "open" (existing file, or several when multiple is
true), "folder" (directory; the grant covers the subtree), or
"save" (destination that may not exist yet). filter lists file
extensions without leading dots; empty accepts all files.
The host replies with :class:events.FilePicked — whose paths are
already registered as scoped fs grants, so they can be passed verbatim
to :class:FileRead / :class:FileWrite — or
:class:events.FilePickCancelled (user dismissed, or capability
denied).
HttpFetch
HttpFetch(url: str, method: str = 'GET', headers: dict = dict(), body: Optional[bytes] = None)
AiMessage
AiMessage(role: str, content: str)
AiQuery
AiQuery(request_id: str, model_tier: str, system: str, messages: list)
AiTool
AiTool(name: str, description: str, input_schema: dict[str, Any], output_schema: dict[str, Any], timeout_ms: Optional[int] = None, read_only: bool = False)
ExposeTools
ExposeTools(tools: list[AiTool])
ToolResult
ToolResult(call_id: str, output_json: Optional[str] = None, error: Optional[str] = None)
SetTimer
SetTimer(id: int, delay_ms: int, repeat: bool = False)
CancelTimer
CancelTimer(id: int)
GetSystemStats
GetSystemStats()
SetTitle
SetTitle(title: str)
SetStatus
SetStatus(text: str)
CloseSelf
CloseSelf()
RequestCapability
RequestCapability(name: str)
EventStreamDecl
EventStreamDecl(name: str, schema_json: str, description: Optional[str] = None)
DeclareEventStreams
DeclareEventStreams(streams: list)
EmitEvent
EmitEvent(event: str, actor: str, summary: str, resource_id: str, revision_after: str, actor_id: Optional[str] = None, caused_by: Optional[str] = None, resource_scope: Optional[str] = None, payload_json: Optional[str] = None, state_ref: Optional[str] = None, revision_before: Optional[str] = None, rollback_token: Optional[str] = None, changed_resources: list = list(), suggested_trigger: Optional[str] = None)
SubscribeEventStreams
SubscribeEventStreams(request_id: str, app_id: str, event_names: list[str], payload_mode: str = 'full', trigger_mode: str = 'conversation', resource_id: Optional[str] = None)
UnsubscribeEventStreams
UnsubscribeEventStreams(request_id: str, subscription_id: str)
Events
Modifiers
Modifiers(ctrl: bool = False, shift: bool = False, alt: bool = False, meta: bool = False)
KeyEvent
KeyEvent(key: str, modifiers: Modifiers = Modifiers(), pressed: bool = True)
MouseEvent
MouseEvent(x: float, y: float, button: Optional[str] = None, pressed: bool = False, scroll_x: float = 0.0, scroll_y: float = 0.0)
UiAction
UiAction(handler_id: str)
UiValueChange
UiValueChange(handler_id: str, value: str)
Resize
Resize(width: float, height: float)
FocusGained
FocusGained()
FocusLost
FocusLost()
FocusChanged
FocusChanged(timestamp: str, duration_secs: int = 0, reason: str = 'focus_changed', pane_id: Optional[int] = None, context_name: Optional[str] = None, context_root: Optional[str] = None, cwd: Optional[str] = None)
TimerFired
TimerFired(id: int)
RenderFrame
RenderFrame(frame_id: int, elapsed: float)
SystemStats
SystemStats(cpu_usage_pct: float, memory_used_bytes: int, memory_total_bytes: int, disk_read_bps: int, disk_write_bps: int, net_rx_bps: int, net_tx_bps: int, uptime_secs: int, load_avg_one_min: float)
SystemStatsResult
SystemStatsResult(stats: SystemStats)
FileReadResult
FileReadResult(content: Optional[bytes], error: Optional[str])
FileWriteResult
FileWriteResult(error: Optional[str])
HostLogResult
HostLogResult(content: Optional[bytes], path: Optional[str], error: Optional[str])
Host reply to an effects.ReadHostLog request.
content is the raw tail text (whole log records, newline-separated) on
success; error names why the log could not be reached otherwise. path is
the host-resolved channel-log path, echoed back for the app to display in its
empty/error state. Exactly one of content / error is set.
FilePicked
FilePicked(request_id: str, paths: list[str])
Host reply to :class:effects.OpenFilePicker: the user picked paths.
paths are absolute, canonicalized, and already registered as scoped
fs grants for this pane — pass them verbatim to FileRead /
FileWrite.
FilePickCancelled
FilePickCancelled(request_id: str)
Host reply to :class:effects.OpenFilePicker: the user dismissed the
dialog, or the app lacks the fs.pick capability. No grant was
created.
HttpResponse
HttpResponse(status: int, headers: list, body: bytes)
AiStreamChunk
AiStreamChunk(request_id: str, delta: str, reasoning: Optional[str], done: bool)
AiResponse
AiResponse(request_id: str, content: Optional[str], tokens_in: int, tokens_out: int, error: Optional[str])
ToolCall
ToolCall(call_id: str, name: str, input_json: str, caller_id: str)
EventSubscriptionResult
EventSubscriptionResult(request_id: str, subscription_id: Optional[str], error: Optional[str])
EventUnsubscriptionResult
EventUnsubscriptionResult(request_id: str, removed: bool, error: Optional[str])
AppEvent
AppEvent(subscription_id: str, app_id: str, event: str, event_id: int, resource_id: str, trigger_mode: str, summary: Optional[str], payload_json: Optional[str], state_ref: Optional[str], created_at: str)
DeclareEventStreamsResult
DeclareEventStreamsResult(streams: Optional[list], error: Optional[str])
EmitEventResult
EmitEventResult(sequence: Optional[int], error: Optional[str])
SurfaceReady
SurfaceReady(texture_handle: int, width: int, height: int)
SurfaceResized
SurfaceResized(texture_handle: int, width: int, height: int)
PipePayload
PipePayload(binary: Optional[bytes] = None, json: Optional[str] = None)
PipeMessage
PipeMessage(handle: int, payload: PipePayload)
PipePeerConnected
PipePeerConnected(handle: int)
PipeClosed
PipeClosed(handle: int)
PipeError
PipeError(handle: int, error: str)
CapabilityGranted
CapabilityGranted(name: str)
CapabilityDenied
CapabilityDenied(name: str)
PaymentComplete
PaymentComplete()
PaymentFailed
PaymentFailed(reason: str)
State and Logging
StateSnapshot
StateSnapshot(values: Mapping[str, Any], raw_values: Mapping[str, bytes], scoped: Mapping[str, Mapping[str, Any]] = dict(), scopes: tuple[str, ...] = ('global',))
StateProxy
get(key: str, default: Any = None, scope: Optional[str] = None)
raw(key: str)
all(scope: Optional[str] = None)
set(key: str, value: Any, scope: Optional[str] = None)
scopes()
The scopes this app declared in its manifest, ordered; the first entry is the default scope.
LogProxy
debug(msg: str)
info(msg: str)
warn(msg: str)
error(msg: str)
UI Components
Declarative UI primitives.
Apps return a component tree from view(). The host owns layout,
theme, input, and rendering.
HasToNode
Anything that can serialize itself to a UiNode wire dict.
Component
Base class. Subclasses implement measure and render.
Heading
Heading(text: str, level: int = 1, color: 'str | None' = None, bold: bool = True)
Title-ish text. level 1 = TEXT_TITLE_XL, 2 = TEXT_TITLE, 3 = TEXT_HEADING.
ctx.text(x, y, ...) treats y as the TOP of the text box (host renders
with egui::Align2::LEFT_TOP). A Heading with font size fs occupies rows
y .. y + fs plus descender padding.
Label
Label(text: str, tone: str = 'body', color: Optional[str] = None, bold: bool = False, max_lines: int = 3)
Body/caption/hint text. Wraps up to max_lines, then truncates.
Line height = font_size + LINE_LEADING; lines stack top-to-bottom with
the first line’s top at the component’s y.
Text
Text(text: str, tone: str = 'body', color: Optional[str] = None, bold: bool = False, max_lines: int = 3, size: Optional[float] = None, truncate: bool = False, align: str = 'start', key: str = '')
SDK v3 inline text node.
Button
Button(label: str, on_click: str, style: str = 'secondary', disabled: bool = False, key: str = '')
HStack
HStack(children: Sequence[Component], gap: 'float | None' = None, grow: bool = False)
Sized
Sized(child: Component, width: float | None = None, height: float | None = None)
Canvas-mode only (measure()/render()). There is no host-native
fixed-size wrapper node — the WIT ui-node-data enum (wit/plexi.wit)
has no sized variant, and none of row/column/padding carry a
per-child width/height override. This class has no to_node() and
cannot be placed in a declarative tree.
ActionBar
ActionBar(actions: Sequence[Button])
Spacer
Spacer(size: float = SPACE_MD, grow: bool = False)
Fixed or flex gap. grow=True expands to consume remaining space.
Badge
Badge(text: str, color: BadgeColor = 'neutral', tone: 'BadgeColor | None' = None, key: str = '')
Tooltip
Tooltip(text: str, child: Component)
Avatar
Avatar(label: str = '', size: float = 0.0)
Icon
Icon(name: str, size: float = 0.0, color: str = '')
CodeBlock
CodeBlock(code: str, language: str = '')
Table
Table(columns: list[str], rows: list[list[str]])
KeyValue
KeyValue(rows: list[tuple[str, str]])
Breadcrumb
Breadcrumb(items: list[str])
Pagination
Pagination(node_id: str, page: int = 0, total: int = 0)
Accordion
Accordion(node_id: str, title: str, child: Component, open: bool = False)
Checkbox
Checkbox(node_id: str, label: str = '', checked: bool = False, disabled: bool = False)
Radio
Radio(node_id: str, options: list[str], selected: int = 0, disabled: bool = False)
Switch
Switch(node_id: str, label: str = '', on: bool = False, disabled: bool = False)
Slider
Slider(node_id: str, value: float = 0.0, min: float = 0.0, max: float = 1.0, disabled: bool = False)
Select
Select(node_id: str, options: list[str], selected: int = 0, placeholder: str = '')
DateTimePicker
DateTimePicker(node_id: str, value: str = '', mode: str = 'datetime')
Progress
Progress(value: float = 0.0, label: str = '', indeterminate: bool = False)
Spinner
Spinner(label: str = '')
Banner
Banner(text: str, tone: "BadgeColor | Literal['']" = '', title: str = '')
TabBar
TabBar(node_id: str, tabs: list[str], active: int = 0)
EmptyState
EmptyState(title: str, description: str = '', icon: str = '')
Skeleton
Skeleton(rows: int = 3, height: float = 0.0)
Modal
Modal(node_id: str, title: str, child: Component)
Divider
Divider(color: 'str | None' = None, margin_top: float = SPACE_SM, margin_bottom: float = SPACE_SM)
A horizontal 1px rule.
color only affects canvas-mode render(). The WIT divider variant
(wit/plexi.wit) is a bare unit with no fields, so to_node() cannot
carry a color to the host — it is not emitted in the wire dict.
CanvasRect
CanvasRect(x: float, y: float, width: float, height: float, fill: str, radius: float = 0.0)
CanvasCircle
CanvasCircle(x: float, y: float, radius: float, fill: str)
CanvasLine
CanvasLine(x1: float, y1: float, x2: float, y2: float, color: str, width: float = 1.0)
CanvasText
CanvasText(x: float, y: float, text: str, size: float = 14.0, color: str = '#ffffff', bold: bool = False, align: str = 'left_top')
Canvas
Canvas(commands: 'list | None' = None, width: float = 640.0, height: float = 360.0, grow: bool = True, fit: str = 'fill', key: str = '')
SDK v3 CPU canvas node.
Pass typed drawing commands for host-side rendering from view().
AppBar
AppBar(title: str, subtitle: Optional[str] = None, accent: Optional[str] = None)
Thin top-of-pane app bar with optional subtitle.
Single-line (title only): fixed BAND_H band, title vertically centred. Two-line (title + subtitle): taller BAND_H_DOUBLE band, title/subtitle stacked and centred together.
Section
Section(title: str)
Section divider with a small uppercase label sitting above the rule.
Vertical stack: SPACE_SM padding, label (TEXT_HINT), SPACE_XS, divider, SPACE_XS padding. The bottom padding is intentionally tight (SPACE_XS instead of SPACE_SM) so the section headline sits close to its associated content block below.
KeyRow
KeyRow(key: Union[str, List[str]], description: str)
A keycap chip (or a chord of chips) followed by a description, left-aligned.
Emits DrawCommand::KeyChipRow — the host measures each chip with real font metrics and flows them left-to-right. No Python-side width math.
key accepts a single string (e.g. "m") or a list (e.g. ["⌘", "K"]).
Canvas-mode only (measure()/render()). There is no to_node() — no
declarative-tree node exists for a single key-chip row. Use
FooterKeys for a tree-mode shortcuts row.
ScrollLog
ScrollLog(lines: List[str], line_size: float = TEXT_CAPTION, empty_text: str = 'no events yet', max_pixel_height: Optional[float] = None)
Bounded text log. Shows the most recent lines that fit in the available space; older lines are hidden. Lines are rendered newest-at-top.
Canvas-mode only (measure()/render()). There is no to_node() — no
declarative-tree node exists for a bounded reversed-order text log.
Scrollable
Scrollable(child: Component, scroll_offset: float = 0.0, align: str = 'top', key_step: float = 20.0)
A clip-bounded vertically-scrollable container.
Renders its child component clipped to the allocated rect. If the child
is taller than the available height the excess is hidden and a thin
scrollbar indicator is drawn on the right edge.
Scroll offset is persisted on the instance, so the Scrollable must be
stable across renders — create it once at module level or in init(),
not inside view().
Keyboard scroll: j/k or arrow-down/up keys update scroll_offset.
Apps drive this by calling handle_key(key) from their on_key handler.
Mouse-wheel scroll: call handle_scroll(delta_y) from the app’s
on_scroll_delta handler (receives PlexiEvent::Scroll from the host).
ensure_visible(scroll_offset: float, viewport_h: float, top: float, bottom: float, margin: float = 0.0)
Solve ‘selection follows scroll’ in one call. Returns the new offset.
The pattern: the user is navigating items with j/k. The cursor moves freely while it stays inside the visible viewport; the moment it would go off the top or bottom edge, the viewport scrolls just enough to keep it visible. Identical to every native list widget.
Apps call this from their nav handler after mutating their
selected_index — works whether the app uses a Scrollable component
or hand-rolls its own scroll offset (commit-graph’s clip-and-offset
style):
new_sel = min(self._sel + 1, len(items) - 1)
item_top = new_sel * ROW_H
self._scroll_offset = ensure_visible(
self._scroll_offset, viewport_h,
top=item_top, bottom=item_top + ROW_H,
)
self._sel = new_sel
Args: scroll_offset: current scroll offset in the child’s local space. viewport_h: visible height of the viewport. top, bottom: the item’s top/bottom edges in the child’s local space. margin: scrolloff equivalent. Set to one row-height to keep a row of breathing room above/below the cursor.
Returns:
The new scroll_offset that keeps [top, bottom] visible. Identical
to the input if the cursor was already in view.
Footer
Footer(text: str, color: 'str | None' = None, max_lines: int = 2)
Small caption row. Wraps instead of clipping. The parent Column
provides the outer bottom padding, so no extra padding is needed here.
Canvas-mode only (measure()/render()). There is no host-native node
for a standalone footer band — the WIT ui-node-data enum
(wit/plexi.wit) has no footer variant, only footer-keys. Use
FooterKeys in a declarative tree instead; this class has no
to_node() and cannot be placed in one.
FooterKeys
FooterKeys(shortcuts: List[tuple], divider: bool = True)
Footer row that renders keyboard shortcuts as key chips + descriptions.
Each shortcut is a (key_or_keys, description) tuple — the same shape
as KeyRow. Chips are rendered inline (horizontal flow) separated by a
small gap, identical in style to KeyRow but packed tightly so many
shortcuts fit on one line.
Example::
FooterKeys([
("j", "down"),
("k", "up"),
(["g", "G"], "ends"),
("?", "help"),
])
key_or_keys may be a single string or a list of strings (chord).
Lists are joined with / as a single chip label so they stay compact
in the footer context.
ListItem
ListItem(title: str, subtitle: Optional[str] = None, leading: Optional[str] = None, trailing: Optional[str] = None, selected: bool = False, background: Optional[str] = None, radius: float = RADIUS_MD)
Single or double-line list item with optional leading icon and trailing text.
Replaces the manual ctx.rect + y-offset pattern for list rows.
All vertical centering is handled internally — no align= juggling or
h * 0.38 / h * 0.72 magic numbers needed.
Example::
ListItem(
title=cmd["name"],
subtitle=cmd.get("description"),
trailing="›",
selected=(i == self._sel),
)
Canvas-mode only (measure()/render()) — used internally by
SelectList.render(). There is no to_node(); SelectList.to_node()
synthesizes the equivalent tree shape itself
(_adapter.py::_normalize_node_data’s select_list branch), it does not
delegate to this class.
Row
Row(label: str, leading: Optional[str] = None, trailing: Optional[str] = None, font_size: float = TEXT_BODY, color: 'str | None' = None, leading_color: Optional[str] = None, trailing_color: Optional[str] = None, height: Optional[float] = None, bold: bool = False)
Horizontal row: optional leading icon, main label, optional trailing text.
Vertically centres all items automatically. Use instead of paired
ctx.text(x, y + h/2, ..., align="left_center") calls when building
info rows with an icon, label, and badge or chevron.
Example::
Row(label="Workspace", leading="⚡", trailing=f"{count}")
Canvas-mode only (measure()/render()). There is no to_node() — no
declarative-tree node exists for this leading/trailing text row. Compose
a "row" of Text nodes yourself in tree mode.
badge(ctx, x: float, y_center: float, label: str, fill: 'str | None' = None, fg: 'str | None' = None, font_size: float = TEXT_HINT, radius: float = RADIUS_BADGE)
Render a host-measured pill badge centred on y_center.
The host measures the label with real egui font metrics, sizes the pill (text_w + padding), and centres the text — no Python width math.
Args:
ctx: Canvas context.
x: Left edge of the badge.
y_center: Vertical centre of the badge (e.g. the commit-node cy).
label: Text to display inside the pill.
fill: Pill background colour.
fg: Text colour (default: theme bg — dark text on light pill).
font_size: Label pt size (default TEXT_HINT).
radius: Corner radius. Use RADIUS_SM (4 px) for tag chips,
RADIUS_BADGE (6 px, default) for rounded badges without
the perfect-stadium look of RADIUS_MD (8 px).
loading_pill(ctx, x: float, y: float, label: str = 'Fetching…')
Render a small spinner+label pill at (x, y). Returns rendered width.
The pill uses host-measured badge() rendering (so widths are
correct), with a wall-clock-driven Braille spinner glyph that ticks
at 8 fps regardless of how often loading_pill is called.
Pattern: position this in the top-right of the region being
refreshed. While _fetching is true, render it on top of the stale
content. When the fetch completes, just stop calling it.
Args: ctx: Canvas context. x, y: Top-left of the pill (NOT y-centre — easier to anchor). label: Text shown after the spinner glyph.
Card
Card(children: Sequence[Component], padding: float = SPACE_LG, gap: float = SPACE_XS, background: 'str | None' = None, border: Optional[str] = '__theme__', radius: float = RADIUS_MD)
Surface-colored container with inner padding. Stacks its children
vertically with a configurable gap. A 1px border in theme.highlight separates
it from the pane background — essential when surface and bg are close
in brightness.
TextInput
TextInput(id: str, placeholder: str = '', height: float = 48.0, multiline: bool = False, value: Optional[str] = None, on_change: str = '', on_submit: str = '', password: bool = False, autofocus: bool = False)
Layout-aware text input. Place inside a Column like any other child.
Return it from view() inside a component tree. After the render pass,
read .submitted to get the text the user submitted (pressed Enter), or
None if nothing was submitted this frame.
Create once (in on_init) and update placeholder as needed — the
instance is stable across renders so the host can track focus state.
When multiline=True, Shift+Enter inserts a newline and Enter submits.
In a declarative view() tree, typing delivers UiValueChange and
Enter delivers UiAction. Both handler ids default to this input’s
id — pass on_change/on_submit only to route them elsewhere::
TextInput("guess", value=state.get("guess", ""))
# update():
# UiValueChange(handler_id="guess", value=...) → echo into state
# UiAction(handler_id="guess") → Enter pressed
TextEdit
TextEdit(node_id: str, placeholder: str = '', value: str = '', multiline: bool = False, max_length: int = 0, height: float = 48.0)
Host-rendered text editor. Use inside view() like any other component.
The host maintains a persistent buffer keyed on node_id. Typing fires
ComponentEvent with event_type="change" and payload={"value": "..."};
Enter (single-line) or Cmd+Enter (multiline) fires event_type="submit".
height controls the allocated row height (pixels). Default 48.0 suits
single-line use; set it larger for multiline (e.g. height=120.0).
Example::
def view(self):
return Column([
TextEdit("body", multiline=True, height=120.0, placeholder="Type here..."),
FooterKeys([("↩", "submit")]),
])
ChatBubble
ChatBubble(text: str, role: str = 'assistant', max_lines: int = 50)
A chat message bubble with left/right alignment and colored background.
align="right" for user messages (accent bg), "left" for
assistant messages (surface bg). Error messages use role="error".
Canvas-mode only (measure()/render(), which also draws markdown via
ctx.markdown()). There is no to_node() — no declarative-tree node
exists for a colored, markdown-rendering chat bubble.
SelectList
SelectList(items: List[dict], selected_idx: int = 0)
Keyboard-navigable scrollable list. Stateful — create at module level or in init().
items: list of dicts with keys: name (str), description (str, optional), leading (str, optional), trailing (str, optional) selected_idx: currently highlighted row index
Call handle_key(key) from on_key. Call hit_index(click_y) from on_click.
FormField
FormField(id: str, label: str, placeholder: str = '', required: bool = False, height: float = 48.0, value: Optional[str] = None, autofocus: bool = False, on_change: str = '', on_submit: str = '', LABEL_H: float = TEXT_HINT + SPACE_XS, LABEL_GAP: float = SPACE_SM, BOTTOM_PAD: float = SPACE_LG)
Label + TextInput, as one form row.
In a declarative view() tree this composes to a column of a caption
label and a text input, so a labeled field is one component instead of
hand-stacked parts. value makes it a controlled input (the app owns the
text and echoes UiValueChange back into state); autofocus hands it
the cursor the frame it appears. Handler ids default to id, matching
:class:TextInput.
In canvas mode (measure()/render()) it draws the same pair itself;
read .submitted after the render pass for the text the user entered.
Actions
Actions(buttons: Sequence[Button], gap: float = SPACE_SM)
A form’s action row: buttons laid side by side, never stacked.
Buttons declared as plain children of a Column each take the column’s
full width and stack vertically, which is how a Save/Cancel pair ends up
reading as two unrelated full-bleed bars. Actions is the primitive for
the pair: one row, standard button spacing, primary action first.
Actions([Button("Add", "add", style="primary"),
Button("Cancel", "cancel", style="ghost")])
Column
Column(children: Sequence[Component], padding: float = SPACE_XL, padding_top: Optional[float] = None, gap: Optional[float] = None, align: str = 'start', grow: bool = False, key: str = '')
The root container. Stacks children vertically. Handles grow spacers:
measures fixed-height children first, then distributes leftover space to
any Spacer(grow=True) descendants at the top level.
Spacing is good by default: leave gap unset and the host applies its
standard inter-child spacing; pass an explicit gap= (including gap=0
to pack children flush) to override. The root’s content padding is owned
by the host too — a declarative tree is inset automatically, and app bars /
footers stay full-bleed — so apps need no layout code to look right.
padding / padding_top only affect the legacy canvas-mode layout
(measure/render); they are ignored by the declarative tree, whose inset
the host owns.
render_tree(ctx, root: Component, fill: Optional[str] = None)
Clear the pane to fill, then render root into the full pane rect.
fill defaults to the active host theme background (theme.bg).
Apps normally call ctx.render(root) instead, which calls this.
The root component and every descendant must support to_node(). The SDK
emits a single ComponentTree command and the host renders it natively.
InfoTable
InfoTable(rows: List[tuple], key_width: float = 100.0, background: Optional[str] = None, border: Optional[str] = None, radius: float = RADIUS_MD)
Key-value table with surface background, border, and row dividers.
Each row is a (key, value) tuple rendered in a fixed-width key column
(monospace, green accent) and a value column (monospace, FG).
Example::
InfoTable([
("app_id", "my-app"),
("workspace", "/path/to/ws"),
])
Canvas-mode only (measure()/render()). There is no to_node() — no
declarative-tree node exists for a bordered key-value table with row
dividers. Compose a "column" of "row"s of Text yourself in tree
mode (see KeyValue, an unexported reference implementation earlier in
this module).
ButtonRow
ButtonRow(id: str, label: str, text_color: 'str | None' = None, fill: 'str | None' = None, hover_fill: 'str | None' = None, active_fill: 'str | None' = None, font_size: float = TEXT_BODY, radius: float = RADIUS_MD, height: float = 36.0, clicked: bool = False)
A clickable button rendered as a component in the declarative tree.
Use this from view(). Button presses arrive through
on_component_event(node_id, event_type, payload).
Example::
self._btn = ButtonRow("action", "Click me")
def view(self):
return Column([self._btn])
def on_component_event(self, node_id, event_type, payload):
if node_id == "action" and event_type == "click":
handle_click()
LeadingBadge
LeadingBadge(label: str, color: str = 'accent')
Badge leading slot for :class:ListRow.
Renders a pill badge with label text and the given color.
LeadingAvatar
LeadingAvatar(handle: str)
Circular avatar leading slot for :class:ListRow.
handle must be a UUID returned by emit.load_image(url).
LeadingIcon
LeadingIcon(name: str)
Text/emoji icon leading slot for :class:ListRow.
RowChip
RowChip(label: str, color: str = 'accent')
A small colored chip label on a :class:ListRow.
ListRow
ListRow(id: str, primary: str, leading: 'LeadingBadge | LeadingAvatar | LeadingIcon | None' = None, secondary: 'str | None' = None, chips: 'list[RowChip]' = list(), trailing: 'str | None' = None)
Typed row descriptor for list views.
Example::
rows = [
ListRow(
id=f"issue-{issue['number']}",
leading=LeadingBadge(f"#{issue['number']}", color="accent"),
primary=issue["title"],
chips=[RowChip(lbl["name"], _label_color(lbl["name"])) for lbl in issue["labels"][:2]],
).to_dict()
for issue in self._issues
]
ctx.list_view("issues", rows, selected=self._sel, y=float(HEADER_H))
Tabs
Tabs(tabs: 'list[tuple[str, HasToNode]]', active: int = 0)
Tabbed container. Renders as a horizontal tab bar + active content area.
Decomposes to a vertical Stack containing:
- a horizontal Stack of Interactive(Text) tab buttons
- the active tab’s content node
Example::
tabs = Tabs([
("Overview", overview_node),
("Details", details_node),
], active=0)
ctx.render_tree(tabs.to_node())
Grid
Grid(columns: int, children: 'list[HasToNode]', gap: float = 8.0)
Fixed-column grid layout.
Decomposes to a vertical Stack of rows, where each row is a horizontal
Stack of up to columns children.
Example::
grid = Grid(2, [item_a, item_b, item_c, item_d], gap=8.0)
ctx.render_tree(grid.to_node())
Toggle
Toggle(node_id: str, value: bool, label: str = '')
On/off toggle switch (L1 sugar).
Renders as a Button whose label carries the on/off state; clicking it
fires node_id as the on_click handler.
Example::
toggle = Toggle("dark_mode", value=True, label="Dark mode")
ctx.render_tree(toggle.to_node())
ProgressBar
ProgressBar(value: float, max_value: float = 1.0)
Horizontal progress bar backed by the host’s native progress-bar node.
Example::
bar = ProgressBar(0.75)
ctx.render_tree(bar.to_node())
Testing
Headless snapshot testing for Plexi widgets.
Spawns the plexi binary with --render, feeds it DrawCommand JSON,
reads back PNG bytes. Provides pixel-level assertions and snapshot file helpers.
render_draw_commands(commands: list[dict], width: int, height: int, background: str = DEFAULT_BG)
Feed draw commands to the headless renderer, return PNG bytes.
Args: commands: list of PGAP draw command dicts (rect, text, line, …). width: viewport width in pixels. height: viewport height in pixels. background: CSS hex colour string for the background fill.
Returns: Raw PNG bytes.
Raises: RuntimeError if the binary is not found or exits nonzero.
decode_png(png_bytes: bytes)
Decode an RGBA 8-bit PNG to (width, height, raw_rgba_bytes).
Pure stdlib implementation using zlib + struct. Only handles RGBA 8-bit PNGs — which is exactly what tiny-skia produces.
Raises: ValueError on an unrecognised PNG or unsupported colour type/bit depth.
pixel_at(png_bytes: bytes, x: int, y: int)
Return (r, g, b, a) of the pixel at (x, y).
hex_to_rgba(hex_color: str)
Convert a CSS hex color string to (r, g, b, a).
Supports #RGB, #RRGGBB, and #RRGGBBAA. Alpha defaults to 255 when not specified.
assert_pixel(png_bytes: bytes, x: int, y: int, expected: Union[str, tuple[int, int, int, int]], tolerance: int = 4)
Assert the pixel at (x, y) matches the expected color within per-channel tolerance.
Args: png_bytes: raw PNG bytes from render_draw_commands. x, y: pixel coordinates. expected: CSS hex string (e.g. “#ff0000”) or (r, g, b, a) tuple. tolerance: per-channel absolute tolerance (default 4).
Raises: AssertionError with a diagnostic message on mismatch.
save_snapshot(png_bytes: bytes, path: Union[str, Path])
Write PNG bytes to path. Creates parent directories as needed.
AppHarness
AppHarness(app_path: 'str | Path', width: int = 800, height: int = 600, timeout: float = 5.0, host_log_path: 'str | Path | None' = None)
Headless runner for Plexi Python apps.
Spawns the app script as a subprocess, communicates over stdin/stdout using the PGAP v3 protocol. No display or running Plexi host required.
Usage::
with AppHarness("my_app.py", width=400, height=300) as h:
cmds = h.run(1) # step one render frame
h.key("enter") # inject a key event
cmds = h.run(1) # render again to see effects
h.assert_pixel(10, 10, "#1e1e2e") # pixel assertion (needs plexi binary)
Types
CapabilityDeniedError
Raised when the host rejects a brokered call because the app’s manifest didn’t declare the required capability. Distinct from generic RuntimeError so apps can catch the gate-denial path explicitly.
VideoHandle
VideoHandle(handle_id: int, width: int, height: int, fps: float, duration_ms: int, pipe: 'object')
Video handle. handle_id is opaque, passed back to video control
effects. pipe delivers decoded RGBA8 frames of length
width * height * 4.
RectCommand
RectCommand(x: float, y: float, w: float, h: float, fill: str, radius: float = 0.0)
Typed constructor for ctx.rect(). Validates geometry at construction.
TextCommand
TextCommand(x: float, y: float, text: str, size: float, color: str, monospace: bool = False, bold: bool = False, align: str = 'left_top', max_width: Optional[float] = None, elide: bool = True, selectable: bool = False)
Typed constructor for ctx.text(). Validates align at construction.
BadgeCommand
BadgeCommand(x: float, y_center: float, label: str, fill: str = '', fg: str = '', font_size: float = 11.0, radius: float = 8.0)
Typed constructor for ctx.badge().
ShortcutPair
ShortcutPair(keys: List[str], description: str = '')
Typed constructor for one entry in ctx.shortcuts() pairs.
NotifyOption
NotifyOption(label: str, value: Optional[str] = None, shortcut: Optional[str] = None)
Typed constructor for one option in ctx.notify_choice().
Theme
Live theme singleton.
Populated from the host Init payload so app chrome tracks the host theme (light/dark + user overrides in config.toml). Until Init arrives the attributes hold the built-in dark defaults.
Process-wide instance theme is mutated in place on Init. Never rebind
the name, only set attributes.
theme.is_dark is True when the background luminance is below 0.5.
Theme
Theme()
Mutable bag of semantic color roles, each a #rrggbb string.
is_dark is a computed boolean: True when the background luminance
is below 0.5 (i.e. the theme is dark-mode). Derived from bg on every
update_from call so it stays in sync with theme hot-reloads.
reset()
update_from(payload: 'dict | None')
Overlay host-provided roles. Unknown keys and non-string/empty values are ignored so a partial payload never blanks a color.
AppPalette
AppPalette(dark: 'dict[str, str]', light: 'dict[str, str]')
Light/dark palette for app-defined color tokens.
Both dicts must have the same keys. resolve(theme) returns the
matching set based on theme.is_dark.
resolve(theme: Theme)
Return the dark or light token set based on theme.is_dark.
Constants
rgba(r: int, g: int, b: int, a: int = 255)
Return an 8-digit hex color string #rrggbbaa.
dim(hex_color: str, alpha: int)
Return hex_color with the given alpha (0-255). Strips existing alpha.
Protocol Types
AiResponse
AiResponse(tokens_in: int, tokens_out: int, content: Optional[str] = None, error: Optional[str] = None)
Result of Emitter.ai_query. tokens_in/tokens_out are zero on error.
MidiPortInfo
MidiPortInfo(id: str, name: str, default: bool)
One MIDI port. Mirrors MidiPortWire in the Rust protocol.
MidiDeviceList
MidiDeviceList(inputs: list, outputs: list)
Result of Emitter.list_midi_devices.
AudioDeviceInfo
AudioDeviceInfo(id: str, name: str, default: bool)
One audio device. Mirrors AudioDeviceWire in the Rust protocol.
AudioDeviceList
AudioDeviceList(inputs: list, outputs: list)
Result of Emitter.list_audio_devices.