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(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(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(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(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.