Framework for building native Android apps in typed Python — declarative typed UI tree, Qt simulator + Compose device renderers.
Project description
Tempestroid
📖 Documentação / Docs: site MkDocs bilíngue em
docs/com seletor PT-BR / EN-US no header — rodeuv run mkdocs servee abra http://127.0.0.1:8000 (PT) ou http://127.0.0.1:8000/en/ (EN). Guia do usuário, arquitetura e referência da API.🤖 Ler com sua IA / Read with your AI: o site publica
/llms.txt(índice) e/llms-full.txt(docs inteiras num arquivo) seguindo a convenção llmstxt.org — entregue a URL ao seu assistente para usar o projeto como referência (sem servidor/MCP).
Build native Android apps in typed Python.
You write one declarative, fully typed widget tree (a Pydantic IR). A renderer-agnostic reconciler diffs it into patches. Two leaf renderers apply those patches: Qt for the desktop simulator, Jetpack Compose for the device. The runtime is async-first, with an Expo-style dev loop: hot reload in the Qt simulator and LAN code-push to a device over QR — both shipping today.
This is a framework, not a web service — no FastAPI, SQLAlchemy, Redis, or HTTP layering. See
docs/plan.mdfor the full design and the phase roadmap.
Why
- Typed end to end. Style model, widget primitives, events, and the
Python↔Kotlin boundary contract are all Pydantic v2 / fully typed.
pyrightruns in strict mode. - One tree, two targets. The reconciler is pure data-in → patches-out. All
platform divergence is confined to the two
Styletranslators (Qt today, Compose next). - Async-first. Event handlers and lifecycle hooks may be sync or
async; Python runs on a background asyncio loop, never the UI thread. - Fast inner loop.
tempest devwatches your file and hot-restarts the Qt simulator on save — no device or emulator needed for UI work.
How it works
view(app) ──build──▶ Node tree (IR)
│
diff pure, renderer-agnostic
▼
[ Patch ] Insert / Remove / Update / Reorder / Replace
╱ ╲
Qt renderer Compose renderer
(simulator) (device, B4)
view(app) -> Widgetbuilds a declarative widget tree from current state.buildlowers it to aNodeIR;diffcompares old vs. new and emits a minimalPatchlist.- A renderer applies patches to live widgets. State changes coalesce into one rebuild per tick.
Install
Building an app? Install from PyPI — the core needs only pydantic:
pip install tempestroid # core
pip install "tempestroid[qt]" # + desktop simulator (PySide6 + qasync)
pip install "tempestroid[icons]" # + tempest icon (Pillow)
Building the Android APK (tempest build apk) needs only a JDK + Android
SDK (no NDK, no CPython toolchain, no repo clone — the android-host ships in
the package). Run tempest setup --install to get the SDK and tempest doctor
to check what's missing.
Contributing to the framework? Clone this repo and use uv — one command
installs the core + dev tooling + Qt simulator + docs:
uv sync
See the installation guide (EN) for the full breakdown.
Quick start
from dataclasses import dataclass
from tempestroid import App, Button, Column, Style, Text, Widget
@dataclass
class CounterState:
value: int = 0
def make_state() -> CounterState:
return CounterState()
def view(app: App[CounterState]) -> Widget:
def increment() -> None:
app.set_state(lambda s: setattr(s, "value", s.value + 1))
return Column(
style=Style(gap=8.0),
children=[
Text(content=f"Count: {app.state.value}", key="label"),
Button(label="+", on_click=increment, key="inc"),
],
)
if __name__ == "__main__":
# Import the Qt renderer lazily — keep the module Qt-free so the SAME file
# also loads on the Android device (which has no PySide6). A top-level
# `from tempestroid.renderers.qt import run_qt` would crash the on-device
# load; the framework now shows an error screen instead of a blank window,
# but the fix is to import Qt only where you run the desktop simulator.
from tempestroid.renderers.qt import run_qt
raise SystemExit(run_qt(make_state(), view, title="counter"))
💡 The module above only ever imports
tempestroid(renderer-agnostic) at the top level —run_qtis imported lazily inside__main__. That is what lets the samemake_state()+view(app)run in the Qt simulator and on a device viatempest servewith no changes. If an app file (or one of its imports) fails to load on the device, the host now renders a red error screen carrying the traceback instead of a silent white window.
Full example with sync and async handlers:
examples/counter/app.py.
Design system (M3 variants + Chakra-style API)
The styled components carry a Chakra-ergonomics variant API — variant /
size / color_scheme — and resolve a complete Material 3 Style from a
Theme (tonal color schemes, spacing/shape/typography/elevation scales). Seed
one brand color with Theme.from_seed(...) and the whole palette (light + dark,
WCAG-AA contrast, ≥ 48dp touch targets) comes for free. It is additive: raw
Style still works, and an explicit style= is merged on top of the resolved
variant.
from tempestroid import (
Button,
Color,
Column,
FieldVariant,
IconButton,
Input,
Size,
Style,
Theme,
Variant,
Widget,
)
# One brand seed → a full Material 3 theme (light + dark).
theme = Theme.from_seed(Color.from_hex("#2563eb"))
def panel() -> Widget:
return Column(
style=Style(gap=12.0),
children=[
# variant / size / color_scheme resolve a Material 3 Style:
Button(
label="Save",
variant=Variant.SOLID,
size=Size.MD,
color_scheme="primary",
theme=theme,
key="save",
),
IconButton(icon="add", label="Add", color_scheme="primary", theme=theme, key="add"),
# The field family adds field_variant; pass the live theme so the
# whole kit follows dark mode:
Input(
value="",
placeholder="Name",
field_variant=FieldVariant.OUTLINE,
color_scheme="primary",
theme=theme,
key="name",
),
],
)
The full kit (Buttons + IconButtons, the field family, selection controls,
slider, the BR inputs) is shown in examples/h2gallery/app.py
and the Button variant matrix in examples/h1buttons/app.py.
📖 Tutorial-first guide: Theme and tokens · Chakra-style variants · Action and entry kit · Surface & layout · Data display & feedback (in-repo:
docs/guia/design-system/).
Gallery
A set of runnable example apps lives in examples/. Each
exposes the same make_state() + view(app) contract, so it runs in the Qt
simulator (uv run python examples/<name>/app.py) and on a device via
code-push (uv run tempest serve examples/<name>/app.py) with no changes.
| App | What it shows |
|---|---|
counter |
Sync + async handlers, the basics. |
todo |
Type-to-add list — Input + insert / remove / update patches. |
calculator |
Dense nested Row/Column button grid. |
stopwatch |
Async loop ticking the UI via asyncio.sleep. |
colorpicker |
Dynamic Style updates (swatches + toggles). |
form |
The value-bearing inputs (Input / Checkbox / DatePicker / FilePicker) + their typed change events. |
forms |
A validating Form of FormFields (typed validators block an invalid submit, per-field error inline) + Dropdown + PinInput OTP. |
gallery |
The expanded set — Slider / Switch / ProgressBar / Spinner / Image / Icon / ScrollView, secure + regex + multiline text fields, and a Style.transition. |
layout |
Refined layout — Wrap chips that wrap, a paginated PageView (PageChangeEvent + dot indicator) and a CollapsingAppBar that shrinks on scroll. |
platform |
Platform/system (E8) — haptics, real preferences, the lifecycle stream and a KeyboardAvoidingView. |
theming |
Cross-cutting (E9) — a light/dark ThemeMode toggle (App.set_theme), a PT↔AR locale/RTL toggle (App.set_locale + translate), and a counter label carrying Semantics(label=…). |
native_caps |
Native capabilities — clipboard / storage / database (SQLite) / secure_storage / system, each a request/response round-trip returning a typed result (device-verified). |
Both renderers — the Qt simulator and Compose on the device — support the
full Track E widget set (~70 types): layout, text & action, the value-bearing
inputs (Input / TextArea / Checkbox / Switch / Slider / RangeSlider
/ Dropdown / DatePicker / TimePicker / FilePicker / PinInput /
MaskedInput / Autocomplete / Form) with their typed change events,
virtualized lists, navigation, overlays, animation, gestures, and media. Parity
is pinned by the conformance suite (golden snapshots of both Style translators)
and device-verified across E0–E9. A few hardware widgets (CameraPreview /
QrScanner / MapView) are device-only and show a signalled placeholder on Qt.
See the per-widget renderer-coverage matrix
(Qt vs Compose), the widget set
and examples/README.md.
CLI
uv run tempest new # scaffold in the CURRENT dir (id = folder name)
uv run tempest dev # dev loop: edit + save → hot reload (reads pyproject)
uv run tempest dev -d pixel-7 # …sized to a device preset (dp; matches Compose)
uv run tempest install # download + adb-install the prebuilt host (no SDK/NDK)
uv run tempest deploy # push the whole project to a device — offline, no SDK/NDK
uv run tempest serve # LAN code-push + hot reload (whole project) in dev mode
uv run tempest doctor # check the Android build/run prerequisites
uv run tempest build apk # per-app APK (own id, installs side by side); reads [tool.tempest]; JDK+SDK
uv run tempest build release-apk # release-signed standalone APK (distribute off-Play); --keystore
uv run tempest build prd # store-ready release AAB (Play); reads [tool.tempest] + keystore
uv run tempest run # build + install on a device + stream logs (needs SDK/NDK)
uv run tempest icon logo.png # generate icon.png + splash.png from one image (needs [icons])
uv run tempest optimize model.onnx # quantize (INT8) + .ort for on-device ONNX (needs [vision])
uv run tempest spec # print the typed contract (widgets/events) as JSON
uv run tempest uitest test_app.py # run a Playwright-style native UI test (headless)
uv run tempest --version # print the framework version (also: tempest version)
uv run tempest --help
Run tempest new inside your already-created project folder (and venv): it
scaffolds in place and uses the folder name as the app id — no extra wrapping
directory. Pass a name (tempest new other) only if you want a new subdirectory.
The generated pyproject.toml carries [tool.tempest] app = "app.py", so
dev / serve / build / run take no app argument inside a project —
pass an explicit path (tempest build path/to/app.py) only to override.
Pick a starting structure with --template/-t:
default(the default) — a singleapp.py, great for a quick demo.multi— a pythonic multi-file layout: a typedstate.py, oneviewper screen underscreens/, a reusableCardComponentundercomponents/, and anapp.pythat routes withNavigator/Route(push/pop + Android back).native— themultilayout plus a screen that calls native capabilities:notify(fire-and-forget) andawait get_position()(request/response, guarded byon_device()+try/except NativeError).
uv run tempest new -t multi # multi-file project (in the current dir)
tempest dev cockpit commands: r (hot reload, state preserved), R (hot
restart, clean state), s (raise window), q (quit). Saving the file
hot-reloads; a reload incompatible with the live state falls back to a clean
restart.
Apps are multi-file: main.py may import sibling modules and packages from
your project tree. The simulator (tempest dev/run) puts the project root on
sys.path, and every device path (deploy/serve/build) bundles the whole
importable tree (the project root — the nearest ancestor with a
pyproject.toml — minus .venv, caches, VCS, build output) and puts it on
sys.path on the device, so from my_pkg.foo import bar resolves identically on
desktop and device.
Running on your own device — the easy path (no toolchain). You do not
need an Android SDK/NDK or the android-host source to test on hardware:
uv run tempest deploy # install the bundled host (once) + push the whole project + launch
tempest deploy <app> ensures the prebuilt host APK (downloaded from the GitHub
release on first use, then cached under ~/.cache/tempestroid) is installed on
the connected device, pushes the project bundle once
over a short-lived dev server, launches it, and exits. No SDK/NDK, Gradle, or
android-host checkout. Repeat runs skip the ~50 MB install (the host is already
there) and just push the new bundle; pass --force-install to reinstall the
host. The app keeps running on the device — but it lives in the host, so it is
not a standalone artifact you can hand to someone else (use tempest build
for that). For a persistent hot-reload loop instead, tempest serve keeps
the dev server up: editing + saving any file in the tree hot-reloads on device.
uv run tempest install # download (cached) + adb-install the prebuilt host APK
uv run tempest serve # persistent LAN code-push: edit + save → hot reload on device
tempest install resolves the host APK in order: an explicit .apk path/URL →
TEMPESTROID_HOST_APK → a bundled asset (only in a source checkout staged with
make stage-host) → a download from the matching GitHub release
(TEMPESTROID_HOST_APK_URL to override), cached under ~/.cache/tempestroid so
it's fetched only once. The published wheel does not embed the ~100 MB APK
(it would exceed PyPI's per-file limit), so from a PyPI install the download is
the normal path — offline thereafter. With a device connected,
tempest serve wires adb reverse and launches the host in dev mode pointing at
the dev server. Use --no-launch to serve only.
Shipping a standalone APK — tempest build apk. To produce a self-contained
.apk you can give to anyone (it runs the app with no dev server), use
tempest build apk: it stamps the APK with the project's own applicationId
so any number of tempestroid apps install side by side (never overwriting).
Identity + branding come from [tool.tempest] in pyproject.toml:
[tool.tempest]
app = "app.py"
id = "com.yourcompany.todolist" # applicationId; derived (com.example.<project>) if unset
name = "Todo List" # launcher label; icon / splash / splash_bg / version optional
The derived com.example.* id is a placeholder, not publishable (the Play
Store rejects it) — set your own id before publishing and keep it forever.
The build runs Gradle but reuses the prebuilt host natives (libpython / the
JNI shim / stdlib that ship in the package) and bundles the android-host
project inside the wheel, so it needs only a JDK + the Android SDK — no
NDK, no CPython toolchain, no git clone (tempest setup --install bootstraps
the SDK). Output: dist/<project>.apk (debug-signed). tempest build release-apk
is the release-signed standalone APK for distributing off the Play Store
(signed with your own --keystore, else an auto-generated one; output
dist/<project>-release.apk, verify with apksigner verify); tempest build prd is the store-ready release AAB; tempest run = build + install + launch
- logs.
Without a JDK/SDK, tempest build falls back to --fast (repackage the
prebuilt host, no SDK at all) with a warning — that APK keeps the shared
org.tempestroid.host id (one app per device). tempest deploy covers the same
toolchain-free path for your own connected device.
Maintainers: the host APK (~100 MB — it embeds CPython) is not shipped inside the PyPI wheel (it would exceed PyPI's per-file limit).
make releasebuilds it (make apk) and attaches it to the GitHub release astempest-host-<version>.apk;tempest install/deploydownload it from there (cached).make publish-host(re)uploads the asset to an existing release;make stage-hostcopies it into a local checkout (tempestroid/_assets/host.apk, gitignored) so that checkout installs offline.
Transparent output. build/run/deploy/install announce each step
(→ … ✓/✗ with elapsed time). build/run (the from-source APK paths) run a
preflight first — checking the host tree, Android SDK, adb, and (for run)
a connected device — so they fail fast with an actionable hint instead of an
opaque Gradle stack trace; tempest doctor runs that same preflight on its own.
Pass -v/--verbose (on build/run/deploy/dev) to echo the raw commands
and stream the full adb/Gradle output; without it, a failed command's tail is
surfaced and the happy path stays quiet.
| Command | Status | Notes |
|---|---|---|
tempest new [name] |
✅ | Scaffold a fully configured project in the current dir (id = folder name); pass a name only for a new subdirectory. Writes pyproject.toml + app.py + .gitignore. --template/-t: default (single file), multi (state + screens/ + components/ + Navigator), native (multi + native-capabilities screen) |
tempest dev [app] |
✅ | Simulator + hot reload / hot restart (needs qt extra); app from [tool.tempest] when omitted; --device/-d sizes the window to a device preset (e.g. pixel-7, galaxy-s24 — dp, matches Compose); -v for tracebacks |
tempest deploy [app] |
✅ | Offline push of the whole project to a device (no SDK/NDK): install the bundled host (if needed) + push bundle + launch; --force-install, -v |
tempest serve [app] |
✅ | LAN code-push of the whole project + log relay + hot reload; auto adb reverse + launch in dev mode (--no-launch to skip) |
tempest install [src] |
✅ | Fetch + adb-install the prebuilt host APK (no SDK/NDK); resolves src/env/bundled/GitHub-release (cached); src = local .apk/URL |
tempest icon <src> |
✅ | Generate a square launcher icon.png + a centered splash.png from one source image (--out, --icon-size, --splash-size, --splash-scale). --adaptive also writes ic_launcher_foreground.png for an Android adaptive icon (the launcher applies its mask). Needs Pillow (pip install tempestroid[icons]); feed the output to tempest build --icon/--splash / --adaptive-icon |
tempest spec |
✅ | Typed widget/event contract as JSON |
tempest doctor |
✅ | Check the Android build/run prerequisites (JDK, android-host, SDK, adb, device); build readiness sets the exit code, a missing device is informational (only run/install need one) |
tempest setup |
✅ | Configure the build environment: diagnose JDK/SDK/NDK/build-tools/toolchain; --install auto-installs the Android SDK + NDK (--sdk-dir, -v) |
tempest build [apk|release-apk|prd] |
✅ | apk (default): a debug, per-app APK — its own applicationId + launcher label so any number of tempestroid apps install side by side (never overwriting). Reuses the prebuilt host natives → needs only JDK + Android SDK (no NDK, no CPython toolchain). release-apk: a release-signed standalone APK to distribute outside the Play Store (--keystore, else auto-generated; verify with apksigner verify). prd: a store-ready release AAB. Identity + branding come from [tool.tempest] (id/name/icon/splash/splash_bg/version/adaptive_icon/icon_bg) so the command stays short; flags (--app-id/--app-name/--icon/--adaptive-icon/--icon-bg/…) override. --adaptive-icon <fg.png> --icon-bg <#rrggbb> emits a real Android adaptive icon (the launcher masks it). Heavy native capabilities are opt-in — --feature camera|qr|push|video|maps (repeatable; also [tool.tempest] features) bundles only what the app uses; the lean default ships none, keeping the APK small. Each feature needs a from-source build (SDK/NDK). Advanced: --fast (repackage, no SDK, shared id, one app), --from-source (stage the CPython toolchain). -o, -v |
tempest run [app] |
✅ | build + install on a device + launch <app-id>/…MainActivity + stream logs (needs the toolchain + adb); --app-id, --app-name, --app-version, --version-code, -v |
tempest version |
✅ | Print the framework version (alias of the global --version/-V) |
tempest clean |
✅ | Reset the build caches under ~/.tempestroid (extracted host natives, bundled-host copy, cloned source) — fixes stale-cache build failures after an upgrade; --keystore also drops the cached release keystore |
tempest lint [path] |
✅ | ruff check on the target (lint only) |
tempest fix [path] |
✅ | ruff check --fix + ruff format in one pass; --unsafe also applies ruff's unsafe autofixes |
tempest format [path] |
✅ | ruff format (writes files) |
tempest fmt-check [path] |
✅ | ruff format --check (read-only) |
tempest type [path] |
✅ | pyright on the target (strict type check) |
tempest test [path] |
✅ | pytest (forwards the optional path filter) |
tempest uitest <path> |
✅ | Run a Playwright-style native UI test file (F9 driver): an app module + async def test_*(page) functions, driven against the renderer-agnostic IR with auto-wait (no sleep). --target/-t: headless (default, in-process, no renderer) or emulator (REAL Compose render on an Android emulator); -j N shards across N isolated emulators with a real screenshot per test; --isolate-adb (or -P <port>) runs against a private adb server so parallel agents never contend on — nor wedge — the shared one. qt/device are reserved — the same script runs on every target unchanged |
tempest check [path] |
✅ | Full quality gate: lint + fmt-check + type + test (stops at the first failure). Each tool is resolved on PATH or via uv run |
Running on a device from WSL
Connecting a physical Android device to a WSL 2 session needs USB
passthrough plus an adb workaround for WSL's mirrored networking:
- Windows (admin PowerShell) — install usbipd-win
(
winget install usbipd), thenusbipd bind --busid <id>andusbipd attach --wsl --busid <id>(find<id>viausbipd list). - Device — enable USB debugging; on MIUI/HyperOS also enable "Install via
USB" (else
adb installfailsINSTALL_FAILED_USER_RESTRICTED). - WSL — under mirrored networking
adb start-serverhangs; start it in the foreground instead and leave it running:adb nodaemon server &, thenadb devicesresponds normally. - Build + install:
ANDROID_SDK_ROOT=/usr/lib/android-sdk make apk-install(Gradle wrapper 8.11.1).
Full walkthrough + troubleshooting: Running on a device (WSL).
Public API
Everything below is importable from the top-level tempestroid package.
Style (tempestroid.style)
Frozen Pydantic value objects, diffed by value.
Style— the style model (layout, box model, paint, typography, sizing, effects, animation). Notable fields:opacity,shadow,align_self,letter_spacing,line_height,max_lines,text_overflow,aspect_ratio,flex_wrap(flow wrapping for aWrapcontainer), and the phase-E9 typography knobstext_scale(afont_sizemultiplier — Qt scales the emittedfont-size, Compose emitstextScaleforLocalDensity) andfont_asset(a bundle-relative custom font path — QtQFontDatabase, ComposeFontFamily).Color—Color.from_hex("#101418").Edge— insets;Edge.all(24.0).Border(uniform) /SideBorder(per-side, e.g. a bottom divider).Corners— per-corner radii forStyle.radius(e.g. top-rounded sheets).Shadow—box-shadow/ elevation (color/blur/offset_x/offset_y); Compose maps it to elevation, Qt to aQGraphicsDropShadowEffect.Gradient+GradientStop— a linear gradient usable wherever a backgroundColoris (QSSqlineargradient/ ComposeBrush).Transition— implicit animation (duration_ms/curve/delay_ms): on rebuild the renderer tweens changed visual props instead of snapping (Compose maps it toanimate*AsState; Qt animation is renderer-imperative).- Enums:
FlexDirection,FlexWrap(NOWRAP/WRAP/WRAP_REVERSE),JustifyContent,AlignItems,TextAlign,FontWeight,FontStyle,TextDecoration,TextOverflow,GradientDirection,Curve(easing —LINEAR/EASE_IN/EASE_OUT/EASE_IN_OUTplusEASE/BOUNCE/ELASTIC),StackAlign(overlay child alignment in aStack).
Theme, media query + i18n (phase E9)
Cross-cutting context the view(app) reads — not nodes in the tree. Changing
any of them swaps an immutable snapshot on the App and schedules one coalesced
rebuild (no new patch kind).
Theme(tempestroid.theme) — frozen: the activeThemeMode(LIGHT/DARK/SYSTEM) plus a small color palette (primary/secondary/background/surface/on_primary/on_background/error).Theme.is_dark(platform_dark_mode=...)resolvesSYSTEMagainst the OS. Swap it withApp.set_theme(theme).MediaQueryData(tempestroid.theme) — frozen viewport/environment snapshot:width/height/device_pixel_ratio/text_scale_factor/platform_dark_mode/orientation. The renderer keeps it current viaApp._update_media(data)on resize/config-change.Locale(tempestroid.i18n) — frozen:language(BCP-47) + optionalregion+rtl(layout direction). Swap it withApp.set_locale(locale). When the renderer is told a node is RTL, bothStyletranslators mirror the box model's start/end (padding/margin left↔right) and fliptext_align.translate(key, locale, translations, **kwargs)/ aliast(tempestroid.i18n) — a dependency-free table lookup withstr.formatinterpolation; a missing key/language degrades to the key itself.
Design system (M3 variants + tokens)
The Chakra-style variant API and Material 3 token surface, all re-exported from
tempestroid (the underlying engine is tempest_core).
Variant— the visual emphasis of a styled component (SOLID/OUTLINE/GHOST/SOFT/LINK), Chakra-style.Size— the size scale (XS/SM/MD/LG/XL) driving padding, typography and touch-target sizing.FieldVariant— the field-family flavor for inputs (OUTLINE/FILLED/UNDERLINE).CardVariant— the surface flavor for cards/surfaces (ELEVATED/FILLED/OUTLINED), M3 — elevation resolves to aShadow, no newStylefield.BadgeVariant— the badge/chip/tag flavor (SOLID/SUBTLE/OUTLINE); theSUBTLEtreatment uses the tonal container pair (WCAG-AA safe for status).AlertVariant— the alert/banner flavor (SUBTLE/SOLID/LEFT_ACCENT/TOP_ACCENT); accents use a directionalSideBorder(RTL-mirrored).- Status
color_schemes — beyond the brand roles,success/warning/info(+error) are first-class color schemes (M3 tonal families generated from fixed seeds), soAlert/Badge/ProgressBartakecolor_scheme="success"etc. ComponentState— the visual state a resolvedStyletargets (DEFAULT/HOVER/PRESSED/FOCUS/DISABLED) — the M3 state layers.ColorRole— a semantic Material 3 color role (PRIMARY/SECONDARY/SURFACE/ERROR/…) resolved against the activeTheme.TokenSet— the resolved bundle of M3 tonal/spacing/shape/typography/ elevation/motion tokens aThemeexposes.TokenRef— a typed reference to a single token within aTokenSet, so aStylecan point at a theme token instead of a raw literal.
💡 The low-level resolver functions (
resolve_variant,resolve_field_variant,resolve_selection_variant,resolve_slider_variant) andmerge_styles/VALID_COLOR_SCHEMESare NOT re-exported here — import them fromtempest_corewhen you need the rawvariant → Stylemachinery.
Widgets (tempestroid.widgets)
The declarative IR — bare-noun widgets.
Widget(base) — every node carrieskey/styleplus the phase-E9 accessibility fieldssemantics(Semantics:label/role/hint, propagated to both renderers andintrospect()),focusable, andfocus_order.Text,Button,Column,Row,Container,ScrollView(scrollable container),SafeArea(insets its child past the status/navigation bars + notch;edgesselects which sides, default all —SafeAreaEdgeenum).Stack— overlay/z-order container: children share one box, layered in declaration order. A child withposition=ABSOLUTEis anchored by itstop/right/bottom/leftinsets; the rest align byStyle.stack_align(StackAlignenum). The framework's overlay primitive (scrim, modal, FAB).- Refined layout (phase E6) —
Wrap(a flow container whose children wrap to the next line when the row fills, driven byStyle.flex_wrap; ComposeFlowRow/FlowColumn, Qt custom flow layout),PageView(a paginated horizontal carousel:childrenare pages, the activepagelives in app state andon_page_change(PageChangeHandler) →PageChangeEventupdates it; ComposeHorizontalPager, QtQStackedWidget+ prev/next) andAspectRatio(a single-child box fixing theratio= width / height; ComposeModifier.aspectRatio, Qt derives the missing dimension). - Design-system layout (Trilho H3) —
Surface(a theme-resolved M3 surface box, the primitiveCardbuilds on;variant=CardVariant),Cardstyled withCardVariant(elevated/filled/outlined),StyledContainer(token-step padding overContainer),HStack/VStack(Row/Columnpresets with a token-stepgap), andSpacer(a flexible gap whosegrowpushes siblings apart; ComposeModifier.weight, Qt layout stretch). - Platform layout (phase E8) —
KeyboardAvoidingView(a vertical container that insets itschildrenwhen the on-screen keyboard appears; ComposeModifier.imePadding()viaWindowInsets.ime, Qt listens onQApplication.inputMethod().keyboardRectangleChangedand behaves like aColumnon the desktop). Declares no event contract. GestureDetector— wraps achildand reports pointer gestures viaTapHandler/LongPressHandler/SwipeHandlerprops (on_tap/on_double_tap/on_long_press/on_swipe).- Advanced gestures (phase E4) — specialized single-purpose wrappers, each
lowering to the same renderer-agnostic contract (Qt via mouse/
QGraphicsView/QDrag, Compose viapointerInput/SwipeToDismissBox/graphicsLayer):PanHandler(on_pan→PanEvent: delta + fling velocity),ScaleHandler(on_scale→ScaleEvent: pinch scale/focus/rotation, pluson_double_tap),DoubleTapHandler(on_double_tap→TapEvent),Draggable(drag_data+on_drag→DragEvent) paired withDragTarget(on_drop→DragEvent) — both via theDragHandleralias,Dismissible(swipe-to-delete:direction+on_dismiss→DismissEvent),ReorderableList(drag to reorder:children+on_reorder(ReorderHandler) →ReorderEvent; the handler mutates a keyed list so the A2 diff emits aReorder) andInteractiveViewer(pan + zoom:min_scale/max_scale+on_interaction→ScaleEvent). - Animation widgets (phase E3) — the interpolation runs in the core
(
AnimationControlleradvances a 0..1 value on the app's frame clock,Tweeninterpolates afloat/Color/Edge, theviewfolds the result into aStyle), so both renderers receive only the final per-frame props.Animated(wraps achildrebuilt with interpolated style each frame),AnimatedList(aColumn/Rowwhose items fade + expand in on insert and collapse out on remove —enter_duration_ms/exit_duration_ms/curves),Hero(ahero_tagshared-element transition acrossNavigatorscreens),Shimmer(sweeps a gradient highlight over achildas a loading placeholder) andSkeleton(the childless rectangular shimmer). Qt interpolates in the core and drivesQPropertyAnimation/QTimer; Compose can use its native animation engine (a documented conformance divergence). - Navigation hosts — render the
NavStackinto a tree (a route change diffs to anUpdate/Replace, no new patch kind):Navigator(stack host: shows the topchild,transitionslide/fade/none +depthdrive the animation),TabView(tab strip + active tabchild),TabBar(standalone tab strip),RouteDrawer(mainchild+ a slide-overdrawerpanel toggled byopen). Each emitsRouteChangeEventvia anon_change(RouteChangeHandler) prop. In the Qt simulatorEscmaps to back (App.pop); the device back button is the Compose/device half. Component(base) — a composite widget that lowers to a primitive tree viarender(); the reconciler expands it before diffing, so renderers never see it.- Value-bearing inputs:
Input(text — withsecurepassword masking + a modern eye / eye-off reveal toggle, regexpattern,keyboardtype,max_length, andleading_icon/trailing_iconshown inside the field),TextArea(multi-line),Checkbox(boolean),Switch(boolean toggle),Slider(numeric range),DatePicker(ISO date),FilePicker(file selection). - Selection + segmented inputs (phase E5):
Dropdown(single-choice select —options+value, emitsSelectEventwith the optionvalue+index),TimePicker("HH:MM"value, emitsTimeChangeEvent),RangeSlider(dual-handlelow/highover[min_value, max_value], emitsRangeChangeEvent),Autocomplete(text + filtered suggestions; emitsTextChangeEventwhile typing andSelectEventon pick),PinInput(segmented PIN/OTP oflengthcells; emitsTextChangeEventper edit and aSubmitEventonce full) andMaskedInput(inputmask—'9'digit,'A'letter, else literal — emitsTextChangeEvent). - Forms (phase E5,
tempestroid.widgets.forms):Form(a container of **FormField**s,on_submit→SubmitEvent) andFormField(a labelled wrapper around achildinput, carrying typedValidatorrules,name,error,on_validate→ValidationEvent). AValidatoris aCallable[[Any], str | None](an error string orNone).Form.validate(values)runs every field's validators purely in Python — the same boundary-validation philosophy asparse_event— and returns aFormState(a frozen{"errors": dict[str, str], "valid": bool}that serializes to plain JSON, with no nested models), so a renderer receives an already-validated tree with each field'serrorfilled in; the app gatesSubmitEventonFormState.valid. BothForm.fieldsandFormField.childcross the bridge as child nodes (never as props); validators are pure Python and are never serialized. - Presentation widgets:
Image(URL/asset,fit),Icon(named glyph — resolves a built-inIconsname to a vector glyph, else falls back to the platform set),ProgressBar(determinate/indeterminate),Spinner(activity). - Media + graphics widgets (phase E7):
Canvas— a retained-mode drawing surface taking acommandslist of serializable draw commands (MoveTo/LineTo/ArcTo/Close/FillCmd/StrokeCmd/DrawText/DrawRect/DrawOval, the discriminatedDrawCommandunion; colors are[r, g, b, a]float lists, so the list lowers to pure JSON and diffs by value);VideoPlayer(src+autoplay/loop/controls/muted),WebView(url+javascript_enabled),Svg(src+fit),CameraPreview(facing),QrScanner(on_scan→QrScanEvent),MapView(latitude/longitude/zoom+ JSONmarkers), and the effect wrappersBlur/BackdropFilter(radius+child) andClipPath(ClipShapeshape+radius+child).CameraPreview/QrScanner/MapVieware device-only — the Qt simulator shows an explicit placeholder. - Virtualized lists (only the visible window is materialized; declare an
item_count+ anitem_builder(index) -> Widget, never a static child list):LazyColumn/LazyRow(vertical/horizontal lazy lists),LazyGrid(columns-wide lazy grid),SectionList(a list ofSectionHeadersections with sticky headers) andRefreshControl(standalone pull-to-refresh). The widgets materialize their initial window atbuildtime —child_nodes()builds the items inwindow(when set) or the firstwindow_sizeitems (defaultDEFAULT_WINDOW_SIZE= 20), each keyed by its absolute index — so the very first mount has content. The app slides the window withApp.slide_window(key, start, end)(andApp.slide_section_window(key, title, start, end)for sections) from a scroll handler; the keyed diff turns a slide into a minimal remove/reorder/insert. They emitScrollEvent(on_scroll),RefreshEvent(on_refresh) andEndReachedEvent(on_end_reached, fired pastend_reached_threshold— wire it to paginate). The matching handler aliases areScrollHandler/RefreshHandler/EndReachedHandler. - Overlay + feedback widgets (pushed onto the floating overlay layer via the
Appoverlay API, not nested in the screen tree):Dialog(modal, optionaltitle+ bodychildren,on_dismiss),BottomSheet(children,on_dismiss),Toast(transientmessage+duration_s, auto-dismisses),Tooltip(message+ optionalchild),Menu(selectableMenuItemitems, optionalanchorkey,on_select),Popover(anchoredchild,on_dismiss) andActionSheet(titleditems,on_select).MenuItemis a frozen value model (label/value/icon) that crosses the bridge as plain JSON. The matching handler aliases areDismissHandlerandMenuSelectHandler. - Enums:
KeyboardType(text/number/email/phone/url/password),ImageFit(contain/cover/fill/none),ClipShape(circle/rounded_rect/oval). EventHandler— the typed handler-prop wrapper used by every handler field (on_click,on_change,on_select); sync orasync, zero- or one-argument.
Icons (tempestroid.icons)
A curated, DIY (dependency-free) set of common line icons — Lucide-style vector
glyphs both renderers draw identically by stroking one 24×24 SVG path. Pass a
name to Icon(name=…) or to an input's leading_icon/trailing_icon.
Icons— aStrEnumof the curated names (Icons.EYE,Icons.LOCK,Icons.SEARCH, …Icons.EYE == "eye"), so you get autocomplete and may also pass the raw string.ICON_PATHS—dict[str, str]mapping each name to its SVG pathddata.icon_path(name)— resolve anIconsmember or raw string (curated or custom) to itsdstring, orNonewhen unknown (renderers fall back to the platform set / the raw name).icon_names()— the sorted list of available names (curated + custom).svg_to_path(source)— convert an SVG image (a file path or raw markup) to one normalizeddstring, flatteningpath/circle/line/rect/polyline/polygonshapes — so a project SVG becomes a tempestroid icon.register_icon(name, source=…)/register_icon(name, path=…)— register a custom icon (from an SVG file/markup, or a readyd) under a name, soIcon(name=…), an input'sleading_icon/trailing_iconandicon_pathall resolve it like a built-in.
Input icon slots are typed Icons | str | None: pass an Icons member for
autocomplete on the curated set, or any string for a registered custom / platform
icon.
Components (tempestroid.components)
Higher-level, reusable building blocks — each a Component that lowers to
primitive widgets, so they work in both renderers (Qt and Compose) with zero
renderer changes and are fully device-ready. Every component takes an optional
style that is merged over its default via merge_style.
AppBar— top bar: optionalleadingwidget,title, trailingactions.CollapsingAppBar— a sliver-style header that shrinks as the content scrolls: the app feeds the currentscroll_offset(from a list'son_scroll) and the component eases its height fromexpanded_heightdown tocollapsed_height, diffing the derived height as an ordinary prop (no new IR).Header/Footer— page header band (title + optional subtitle) and a centered bottom bar holding arbitrarychildren.Table— a static data table built from typedTableRow/TableCellvalues plus optionalheaders;DataTable— a string-matrix convenience (columns+rows, optionalsortableheader glyph). Both lower to aColumnofRows of cells, so they render in both renderers unchanged.Sidebar— fixed-widthlateral column ofchildren.Scaffold— page frame stackingapp_bar, a growingbodyand an optionalbottom_bar(setscroll=Trueto wrap the body in aScrollView).NavBar— selectable navigation/tab bar:itemslabels, anactiveindex and anon_select(index)callback (generalises thetabsexample). The active destination paints thecolor_schemeaccent pill (Trilho H5).Tabs(Trilho H5) — a styled M3 tab strip:tabslabels +activeindex +on_select; the active tab gets thecolor_schemeaccent + an underline indicator. The H5 nav skins (AppBar/Footer/Sidebar/Drawer/SearchBar/Breadcrumb) resolve their surfaces/fields from the theme — no hand-set colors.Burger/Drawer— a hamburger menu button (on_click) and a controlled lateral panel (openlives in app state; toggle it from the burger).Calendar— month grid of selectable day cells:month("YYYY-MM"),selected("YYYY-MM-DD") andon_select(iso_date).Clock— digital clock rendering a preformattedtimestring (the app drives the tick from state, as instopwatch).Card— elevated surface (shadow + radius) groupingchildren.ListTile— list row:leading/trailingwidgets around atitleplus an optionalsubtitle.Avatar— round badge of shortinitials;Divider— thin rule.SegmentedControl/RadioGroup— single-choice pickers (options,selected,on_select(index)).Chip— small rounded label, selectable when given anon_click.Rating— a row ofmax_starsstars;on_rate(value)makes it tappable.Stepper— numeric-/+around a value with optionalmin_value/max_valueclamping;on_change(value).SearchBar— controlled textInputwith an optional clear button.- Brazilian form inputs — labelled fields that lower to
Input/MaskedInput, each callingon_change(value)with the new string:EmailInput(e-mail keyboard +mailicon),PasswordInput(secure,lockicon),PhoneInput(mask(99) 99999-9999),CPFInput(mask999.999.999-99),CNPJInput(mask99.999.999/9999-99) and the groupedAddressInput(CEP + street/number/complement/neighborhood/city/UF,on_change(field, value)). Pair them with thevalidatorsbelow in aFormField. - Media pickers —
ImagePicker(FilePicker+ inlineImagepreview,on_pick(uri)),DocumentPicker(FilePickerfor documents) andImagePicture(circular profile-photo picker —ClipPath-clippedImagewith auser-icon placeholder,on_pick(uri)). Accordion— controlled expand/collapse section (openin state,on_toggle).Banner— inline status bar (tone: info/success/warning/error) with an optionalaction;Badge— small status pill;EmptyState— centered glyph + title + subtitle + action placeholder.- Data-display & feedback (Trilho H4, status-themed) —
Alert(glyph + title + body + optional dismiss;variant=AlertVariant,color_schemestatus),Stat(label + value + up/down-tinteddelta),ProgressStepper(a wizard step indicator:steps+current, accent fromcolor_scheme), andTag(a closed, non-selectableChippreset).Badge/Chip/ProgressBar/Spinnertakevariant/color_schemetoo. - Research / data-science (Trilho H6, ties to the
ort-vision-sdk) —MetricCard/StatCard(label + value + tinted delta over a themedCard),ConfidenceBadge(a confidence pill colored byconfidence_scheme— success/warning/error, WCAG-AA via the tonal container),LineChart/BarChart(lower to theCanvas: data → themed draw commands; data shape isChartSeries),DetectionOverlay(an image + labeled bounding boxes from a list ofDetectionBox— normalized[0,1]xyxy, conf-colored), andResultView(anImagePicker→ result flow).DataTablegains sort/paginate;Calendar/Clockare theme-styled.confidence_scheme(conf)is the shared success/warning/error threshold picker. Breadcrumb— path trail (items+separator, optionalon_select).Grid— equal-widthcolumnsgrid ofchildren.
Validators (tempestroid.validators)
Pure, dependency-free field validators matching the Form validator shape
Callable[[Any], str | None] — they return a PT-BR error message when invalid or
None when valid, after stripping mask characters. Plug them into a FormField
(e.g. FormField(validators=[validate_cpf], child=CPFInput(...))):
validate_cpf— 11 digits + the two mod-11 check digits (rejects all-same-digit).validate_cnpj— 14 digits + the two check digits with the standard CNPJ weights (rejects all-same-digit).validate_email— a pragmatic email regex;EMAIL_PATTERNis the reusable pattern string (also used asEmailInput'sInput.pattern).validate_phone— Brazilian phone: 10 (landline) or 11 (mobile) digits.
Events (tempestroid.widgets) — typed boundary contract
Event(base),TapEvent,TextChangeEvent(carriesvalidagainst the input'spattern),ToggleEvent,SlideEvent,DateChangeEvent,FileSelectEvent.- Gesture events (from
GestureDetector):LongPressEvent(optionalx/y),SwipeEvent(direction+dx/dy) with theSwipeDirectionenum (left/right/up/down). - Advanced-gesture events (phase E4):
PanEvent(dx/dydelta +vx/vyfling velocity),ScaleEvent(scale+focus_x/focus_yfocal point +rotation),DragEvent(dataopaque label + optionalx/ydrop position) andReorderEvent(from_index→to_index).DismissiblereusesDismissEvent. RouteChangeEvent(name+ typedparams) — emitted when navigation settles on a new route.- Virtualized-list events:
ScrollEvent(offset+direction),RefreshEvent(pull-to-refresh) andEndReachedEvent(threshold reached) — emitted byLazyColumn/LazyRow/LazyGrid/SectionList/RefreshControl. - Overlay events:
DismissEvent(optionaloverlay_id) — an overlay dismissed by a host-owned gesture (Dialog/BottomSheet/Popover); andMenuSelectEvent(value+label) — aMenu/ActionSheetselection. - Input + form events (phase E5):
SelectEvent(value+ 0-basedindex),TimeChangeEvent("HH:MM"value),RangeChangeEvent(low+highfloats),SubmitEvent(flatvalues: dict[str, str]) andValidationEvent(field+value+ optionalerror). The matching handler aliases areSelectHandler/TimeChangeHandler/RangeChangeHandler/SubmitHandler/ValidationHandler. - Layout event (phase E6):
PageChangeEvent(page+previous) — emitted by aPageViewwhen the active page changes (handler aliasPageChangeHandler). - Media event (phase E7):
QrScanEvent(data+format) — emitted by aQrScannerfor each decoded QR/barcode (handler propon_scan). - Platform/system events (phase E8) — streamed from the host over reserved event
tokens (no widget handler):
LifecycleEvent(state, theAppStateenum foreground/background/inactive),SensorEvent(sensor— theSensorTypeenum — +values+timestamp_ms),ConnectivityEvent(state, theConnectivityStateenum connected/disconnected/wifi/mobile) andDeepLinkEvent(url+ parsedparams). - Context events (phase E9) — streamed from the host over reserved tokens (no
widget handler):
ThemeChangeEvent(mode, theThemeModeenum) overTHEME_TOKEN→App.set_theme, andLocaleChangeEvent(language+ optionalregion+rtl) overLOCALE_TOKEN→App.set_locale. parse_event(event_type, raw)— boundary gate: validates a raw payload into a typed event or raisesEventValidationErrorwith structured field errors. This is the Python↔Kotlin contract for the device bridge. The bridge passes the validated event to handlers that accept a positional argument.
Core — IR + reconciler (tempestroid.core)
Node,Path— the lowered IR.Pathistuple[int | str, ...]: a child-index path, except the reserved leading"overlay"token that addresses the overlay layer (("overlay", i, …)).Scene— a full UI document: arootnode plus an ascending z-orderoverlayslayer (each overlay node keyed by its stable overlay id).- Patches:
Insert,Remove,Update,Reorder,Replace, and thePatchunion. Overlays reuse these — no new kind. build(widget) -> Node,diff(old, new) -> list[Patch],build_scene(widget, overlays) -> Scene(overlays as(id, widget, barrier)tuples),diff_scene(old, new) -> list[Patch](root diffed as before; overlays diffed keyed under the("overlay", …)prefix).App[S]— renderer-agnostic state container: owns state, builds viaview(app)into aScene(root tree + overlay layer), diffs, hands patches to anapply_patchescallback.App.start()returns theSceneandApp.current_treeis the liveScene. It also owns aNavStack(app.nav) and exposes navigation helpers:push(route)/pop() -> bool/replace(route)/reset(stack)— each mutates the stack and schedules the same coalesced rebuild (no new patch kind).pop()returnsFalseat the root.- Overlay API (imperative, returns a stable overlay id for
dismiss):show_dialog(widget, *, barrier=True),show_sheet(widget, *, barrier=True),show_menu(widget, *, anchor=None, barrier=False),toast(widget, *, duration_s=2.5)(auto-dismisses vialoop.call_later) anddismiss(overlay_id). Each schedules the same coalesced rebuild;OverlayEntryis the internal overlay slot.
Animation (tempestroid.animation)
The interpolation runs in the core, so both renderers only ever see final per-frame props (the divergence — Qt interpolates in the core, Compose may drive its native engine — is pinned by the conformance suite).
AnimationController— drives a normalizedvalue(0.0..1.0) on the app's frame clock:forward()ramps toward 1.0,reverse()toward 0.0,stop()halts and unregisters. Constructed withduration_s+curve, or aSpringfor physics-based motion. Injectabletime_sourcefor deterministic tests.Tween[T]— a frozen linear interpolator (begin→end);at(t)interpolatesfloat,Color(per channel),Edge(per side) or a numerictuple. Theviewreadsat(controller.value)to feed an interpolatedStyle.Spring— frozen spring parameters (stiffness/damping/mass) for anAnimationControllerinstead of a fixed duration.Appowns the frame clock:register_animation(ctrl)starts a coalescedloop.call_later(1/60)tick that advances every active controller and requests a rebuild; the clock stops re-arming once no controller remains. The reserved__frame__device token routes toApp._tick_from_device()(one advance per host frame).App.__init__accepts an optionaltime_sourcekwarg.
Navigation (tempestroid)
Route— a frozen navigation destination:name+ typedparams.NavStack— the mutable route stack (defaults to[Route(name="/")]);topis the visible screen andcan_popisTruepast the root. The stack is not a new IR node —view(app)readsapp.nav.topto build the current screen, so changing routes diffs through the existing reconciler.routes_from_path(path) -> list[Route]— resolve a deep-link path into an initial stack ("/a/b"→["/", "/a", "/a/b"], so back pops through the intermediate screens). The entry point hands the result toApp.resetso a deep link opens directly on the linked screen with its back stack built.
Introspection (tempestroid.core)
introspect()— full JSON contract{"widgets": {...}, "events": {...}}(powerstempest spec).widget_catalog(),event_catalog().
Renderer (tempestroid.renderers.qt, needs qt extra)
run_qt(state, view, *, title, size)— run an app in the Qt simulator.run_dev(app_path)— thetempest devcockpit.
UI test driver (tempestroid.testing)
A Playwright-style driver that automates an app against the renderer-agnostic
IR (not pixels): locate nodes, inject typed events, assert with auto-wait
(no sleep). Because every backend speaks the same IR + typed events, the same
script runs on every target: the headless backend drives the
IR/state/event core in-process, and the emulator backend drives a REAL app
through the Compose renderer on an Android emulator. Drive it with
tempest uitest — --target emulator -j N shards across N isolated emulators and
saves a real on-device screenshot per test. Add --isolate-adb so the run uses a
private adb server (ANDROID_ADB_SERVER_PORT): the emulator instances are
already isolated by port/userdata, and this isolates the last shared resource so
multiple agents can drive emulators in parallel without contending on — or
wedging — one another's adb server.
Page— the top-level driver. Locators:get_by_key/get_by_text/get_by_role/get_by_semantics/get_by_prop. Actions:tap/fill/back. Auto-waiting assertions:expect_text/expect_visible/expect_count.snapshot()returns a JSON-able tree dump.Locator— a lazy node query that resolves against the live scene at action/assert time (first/all()/count()/resolve()); raisesLocatorErrorwhen zero or (forresolve) many nodes match.TestBackend— theProtocola renderer target implements (mount/scene/dispatch/settle/patches).HeadlessBackend— the no-renderer reference backend (wraps anApp).EmulatorBackend(app_path, serial)— drives a real Compose render on an emulator over the dev-server harness bridge (mount/patch mirrored back into a host-sideScene; events fed throughDeviceApp.handle_event— no C/JNI change).screenshot(path)captures REAL Compose pixels viaadb screencap.EmulatorPool— allocate / recycle N isolated emulators (reusing running ones, capped by CPU/RAM).max_parallel_emulators()/running_emulators().run_test_file(path, target="headless")— load + run a UI test file'stest_*functions, returning aTestReportofTestOutcomes.run_test_files_emulator(paths, serials)— shard files across emulator serials and run each shard in parallel.deserialize_scene/apply_patches(intempestroid.testing.mirror) — rebuild + patch the host-side scene mirror from the wire JSON.
from tempestroid.testing import HeadlessBackend, Page
page = Page(HeadlessBackend(make_state, view))
await page.mount()
await page.tap(page.get_by_key("inc"))
await page.expect_text("Count: 1") # auto-waits until the tree settles
Device presets (tempestroid.devices)
Logical (dp) viewport sizes for common Android phones, so the simulator window
can match a real device instead of a generic guess.
Device—Enumof presets (Pixel, Galaxy S/A, Redmi / Redmi Note, Poco, Xiaomi, Moto, OnePlus). Each member carrieswidth/height(indp) and a humanlabel;.sizereturns the(width, height)tuple.DEFAULT_DEVICE— the simulator default (Device.REDMI_NOTE_12, 393×873 dp).resolve_device(name)— resolve a forgiving name ("pixel-7","PIXEL_7","Google Pixel 7") to aDevice, orNone. Backstempest dev --device.
from tempestroid import Device, run_qt
run_qt(state, view, size=Device.GALAXY_S23.size)
Compose + bridge — device side (phases B3/B4)
The Python half is device-independent and tested without a phone; the JNI
transport (B3) and the Kotlin Compose renderer (B4) are implemented in
android-host/ and verified on a real arm64 device.
to_compose(style)(tempestroid.renderers.compose) — serializableStyle → Composespec; the secondStyletranslator (pairs withStyle → Qt).serialize_node/serialize_patch— lower the IR/patches to JSON-able dicts (handlers → path tokens, style → Compose spec).MountMessage/PatchMessage/EventMessage— the wire protocol across the bridge:mountcarries the full serialized tree (plus anoverlayslist of serialized overlay nodes),patchan incremental patch list (overlay patches ride under the("overlay", …)path),eventa device→Python callback addressed by handler token.mount/patchalso carrycan_pop(the liveapp.nav.can_pop), so the host can gate its system-back handler without a round-trip, andhas_animations(app.has_animations), so the host can start/stop itswithFrameNanosframe loop without a round-trip.BACK_TOKEN("__back__") — the reserved event token the host sends on a system back action (e.g. the Android back gesture). The bridge routes it straight toApp.pop(no widget handler, no new JNI entry) — it pops a screen, or is a no-op at the root where the host's default close-the-app action runs.FRAME_TOKEN("__frame__") — the reserved event token the host sends once per frame from itswithFrameNanosloop whilehas_animationsisTrue. The bridge routes it straight toApp._tick_from_device, which advances every activeAnimationControllerone frame and re-renders (no widget handler, no new JNI entry). The Qt simulator drives its own clock and never emits this token.DISMISS_TOKEN_PREFIX("__dismiss__") — the reserved event-token prefix the host sends when an overlay is dismissed by a host-owned gesture (scrim tap, swipe-down):"__dismiss__:<overlay_id>". The bridge strips the prefix and routes the id toApp.dismiss(no widget handler, no new JNI entry).SENSOR_TOKEN_PREFIX("__sensor__") /LIFECYCLE_TOKEN("__lifecycle__") /CONNECTIVITY_TOKEN_PREFIX("__connectivity__") (phase E8) — reserved tokens carrying continuous host streams over the same event channel:"__sensor__:<type>"→dispatch_sensor_event,"__lifecycle__"→dispatch_lifecycle_event,"__connectivity__:<state>"→dispatch_connectivity_event. Each rides the existing transport (no new JNI/C entry) and is routed in bothbridge/jni.pyanddevserver/client.py(so code-push gets them too).THEME_TOKEN("__theme__") /LOCALE_TOKEN("__locale__") (phase E9) — reserved bare tokens carrying a host-driven context change over the same event channel:"__theme__"(payload{"mode": "dark"}, validated as aThemeChangeEvent) →App.set_theme, and"__locale__"(payload{"language": "ar", "rtl": true}, validated as aLocaleChangeEvent) →App.set_locale. Both ride the existing transport (no new JNI/C entry).DeviceApp+Bridge/LoopbackBridge— wire anAppto a device transport; the device-side analogue ofrun_qt. Events come back by handler token, are validated byparse_event, and trigger coalesced patches.JniBridge+run_device— the real on-device transport (phase B3):JniBridgeships messages to Kotlin via the native_tempest_hostmodule;run_device(state, view)boots aDeviceAppon a fresh asyncio loop and marshals incoming events back onto it. Imports cleanly off-device (the native module is loaded lazily), so the framework still develops/tests on the desktop.
Dev server — LAN code-push (phase B5)
The Expo-style on-device inner loop: edit on the dev machine, hot-restart on the
phone without rebuilding the APK (tempest serve <app>).
DevServer— serves the app source (/version,/app) and relays device logs (/log) over HTTP.run_dev_client— the device poll loop: fetch on change → re-exec source → hot-restart theDeviceApp(transport/fetch injected, so it's desktop-testable).serve_device(url)— device entry point wiring the realJniBridge+ the native sink + anurllibfetch intorun_dev_client.render_qr(url)— ASCII QR for pairing (falls back to the plain URL).
Native capabilities (phase B6+)
Device-native features driven from Python as {"kind": "native"} commands the
Kotlin host routes to capability modules. Two shapes share the one JNI channel:
fire-and-forget (one-way) and request/response (await a result; the
host replies over the event channel under a reserved token — no extra native
entry point). A failed request/response call raises NativeError carrying a
machine-readable code (permission_denied / cancelled / not_found /
unavailable / io_error). Permissions (location, camera, bluetooth) are
requested on demand by the host.
Fire-and-forget:
notify(title, body="")— post a system notification.share(text="", url="", title="")— open the system share sheet.share_to_whatsapp(text="", phone="")— share to WhatsApp (wa.me, optional E.164 number).open_url(url)— open a URL with the default handler.set_text(text)— write to the clipboard.
Request/response (async, awaited from a handler):
await get_position(high_accuracy=True) -> Position— a single location fix (latitude/longitude/accuracy/altitude).await take_photo(*, camera=CameraFacing.BACK, max_width=None, max_height=None) -> Photo— capture a photo (path/width/height); the host downscales to the size caps.await record_video(*, camera=CameraFacing.BACK, max_duration_s=None, quality=VideoQuality.HIGH) -> Video— record a clip (path/duration_ms/width/height).await record_audio(*, max_duration_s=None) -> AudioClip— record from the microphone (path/duration_ms).await play_sound(src, *, volume=1.0)/stop_sound()— play/stop audio on the device speaker (src= local path or URL).await read_file(name)/write_file(name, content)/delete_file(name)/list_files() -> list[str]— app-private device storage.await get_text() -> str— read the clipboard.await scan(timeout=8.0) -> list[BluetoothDevice]— discover nearby Bluetooth devices (address/name/rssi).
from tempestroid import App, Button, Text, Widget
from tempestroid.native import get_position, share, NativeError
async def _locate(app: App[State]) -> None:
try:
pos = await get_position()
app.set_state(lambda s: setattr(s, "label", f"{pos.latitude}, {pos.longitude}"))
except NativeError as exc:
app.set_state(lambda s: setattr(s, "label", f"erro: {exc.code}"))
The native_command / native_request envelope + the host module router is the
extension point for further capabilities (sensors, contacts, …). The Python side
(envelopes, pending-future resolution, typed results) is fully unit-tested
off-device; the Kotlin capability modules need an Android device to validate.
on_device() reports whether the native host is present, so a module can
emulate (prefs/SQLite) or stub (device_only) on the desktop.
Platform + system (phase E8)
A wider platform surface, same two shapes (plus the sensor/lifecycle/connectivity
streams over the reserved tokens above). Capabilities with no desktop hardware
stub on the Qt simulator with an explicit device_only NativeError; the ones
that can be emulated run for real off-device.
- Haptics (fire-and-forget):
vibrate(duration_ms=50),impact(style=ImpactStyle.MEDIUM)(theImpactStyleenum light/medium/heavy). - System (set = fire-and-forget, get =
async):set_status_bar(*, hidden=None, color=None, style=None)(StatusBarStyleenum),await get_brightness() -> float,set_brightness(value),keep_awake(enabled),set_orientation(orientation)(theOrientationenum portrait/landscape/auto). - Sensors (stream):
start_sensor(sensor, callback, rate_ms=100) -> Callable[[], None]registers aSensorEventcallback (theSensorCallbackalias; returns astophandle) andstop_sensor(sensor). - Lifecycle (stream):
on_app_state_change(callback) -> Callable[[], None]registers aLifecycleEventcallback (theLifecycleCallbackalias; returns anunregister); driven for real on the Qt simulator byQApplication.applicationStateChanged. - Connectivity:
await get_connectivity() -> ConnectivityStateand the streamon_connectivity_change(callback) -> Callable[[], None](theConnectivityCallbackalias). - Permissions (
async):await request_permission(permission)/await check_permission(permission)→PermissionResult(permission+PermissionStatusgranted/denied/permanently_denied; the Qt simulator returns granted — the desktop has every capability). - Biometrics (
async):await authenticate(reason="") -> BiometricResult(authenticated+ optionalerror); Qt raisesdevice_only. - Secure storage:
await get_secret(key)/set_secret(key, value)/delete_secret(key)(Android Keystore-backed; Qt raisesdevice_only— no silent plaintext fallback). - Preferences (real on the desktop — a JSON file under
~/.tempestroid/prefs.json):await get_pref(key, default=None)/set_pref(key, value)/delete_pref(key)/await get_all_prefs() -> dict[str, Any]. - Database (real on the desktop —
sqlite3under~/.tempestroid/app.db):await execute(sql, params=()) -> QueryResult(columns+rows) /await execute_many(sql, params_list). - Push (FCM):
await register_push() -> PushToken(Qt raisesdevice_only; the device path needsgoogle-services.json— drop it intoandroid-host/app/and the build enables FCM) andschedule_notification(title, body, delay_s)(local notification). - Background tasks (WorkManager):
schedule_task(name, *, interval_s=None)(one-shot wheninterval_sisNone, else periodic ≥15 min) /cancel_task(name), withon_background_task(name, callback)to run a handler when the task fires — the worker re-enters Python (the live interpreter if the app is up, else a fresh short-lived one).
Example: examples/platform/app.py exercises haptics
(with the Qt fallback), preferences (real JSON store on the desktop), the
lifecycle stream and a KeyboardAvoidingView-wrapped input. The Python half is
fully unit-tested off-device (envelopes, typed results, stream-callback
registries, the real prefs/SQLite emulation via tmp_path); biometrics, FCM,
WorkManager and real sensors are hardware-gated and validated on a device.
Project layout
tempestroid/
├── style.py # Style + value objects (Color/Edge/Border/Corners/Shadow/Gradient/Transition) + enums (frozen Pydantic)
├── widgets/ # Widget base + Component base + layout/inputs/media/indicators widgets + events.py
├── components/ # composite components (AppBar/Header/Footer/Sidebar/Scaffold/NavBar)
├── core/ # ir.py, reconciler.py, state.py, introspection.py
├── renderers/qt/ # renderer, Style→Qt, run_qt, simulator, dev_loop
├── renderers/compose/ # Style→Compose translator (device renderer, Python side)
├── bridge/ # IR/patch serialization, handler registry, DeviceApp
└── cli/ # tempest entry point + app_loader + watcher
# Trilho B (Android), outside the Python package:
docs/research/ # web research + executable B0–B6 runbook
toolchain/ # fetch CPython 3.14 + cibuildwheel native wheels
android-host/ # Gradle/Kotlin host embedding official CPython via JNI
Status
Track A (pure desktop CPython) is complete: A0–A6.
| Phase | Scope | Status |
|---|---|---|
| A0 | Foundation: package, tooling, tempest --help |
✅ |
| A1 | Style model + typed widget primitives | ✅ |
| A2 | Reconciler: build → diff → patch |
✅ |
| A3 | Qt renderer: patches → QWidgets, Style → Qt |
✅ |
| A4 | Async event loop: asyncio ⨉ Qt (qasync) |
✅ |
| A5 | tempest dev: watcher, hot restart, command loop |
✅ |
| A6 | Typed event contract + introspection | ✅ |
| B0–B6 | Android runtime: CPython 3.14 arm64, native wheels, Kotlin host, JNI bridge, Compose renderer, LAN code-push, native capabilities | ✅ |
| C | Polish: new/build/run + stateful hot reload |
✅ |
| D | Conformance golden snapshots (Qt vs Compose) | ✅ |
| E0 | Navigation + routes (push/pop, tabs, drawer, back button, deep link) | ✅ |
| E1 | Virtualized lists + scroll (lazy, sticky section, pull-to-refresh, infinite) | ✅ |
| E2 | Overlays + feedback (dialog, bottom sheet, toast, tooltip, menu/popover, action sheet) | ✅ |
| E3 | Animation framework (AnimationController/Tween/Spring, Animated/AnimatedList/Hero/Shimmer/Skeleton) |
✅ |
| E4 | Advanced gestures (PanHandler/ScaleHandler/Draggable/DragTarget/Dismissible/ReorderableList/InteractiveViewer) |
✅ |
| E5 | Inputs + forms (Dropdown/TimePicker/RangeSlider/Autocomplete/PinInput/MaskedInput, Form/FormField/Validator/FormState) |
✅ |
| E6 | Refined layout (flex_wrap/Wrap/PageView/AspectRatio/CollapsingAppBar/Table/DataTable, PageChangeEvent) |
✅ |
| E7 | Media + graphics (Canvas/Svg/VideoPlayer/WebView/Blur/ClipPath/CameraPreview/QrScanner/MapView) |
✅ |
| E8 | Platform + system (haptics/sensors/system/lifecycle/permissions/biometrics/secure_storage/prefs/database/connectivity/push/background, KeyboardAvoidingView, LifecycleEvent/SensorEvent/ConnectivityEvent/DeepLinkEvent) |
✅ |
| E9 | Cross-cutting: theme/dark mode (Theme/ThemeMode) + MediaQueryData + i18n/RTL (Locale/translate) + accessibility (Semantics/focusable) + custom fonts (text_scale/font_asset), ThemeChangeEvent/LocaleChangeEvent over THEME_TOKEN/LOCALE_TOKEN |
✅ |
Develop
uv run ruff check .
uv run pyright # strict mode
uv run pytest
Conventions: double quotes everywhere, every parameter/return/annotation typed,
Google-style English docstrings, absolute imports re-exported from each
__init__.py. See CLAUDE.md for the full set.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tempestroid-0.15.0.tar.gz.
File metadata
- Download URL: tempestroid-0.15.0.tar.gz
- Upload date:
- Size: 11.3 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9be2d3b8c5247b35c3fc3535aca10c4e1a4c9c952f474036cf74adb5a8aa5113
|
|
| MD5 |
310f1204b16ba5d7f960849722136d58
|
|
| BLAKE2b-256 |
29385cace51854357bfe2fa38060bbe31b69e44946cf824ef3fc3eb5c41bb0b8
|
Provenance
The following attestation bundles were made for tempestroid-0.15.0.tar.gz:
Publisher:
publish.yml on mauriciobenjamin700/tempestroid
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tempestroid-0.15.0.tar.gz -
Subject digest:
9be2d3b8c5247b35c3fc3535aca10c4e1a4c9c952f474036cf74adb5a8aa5113 - Sigstore transparency entry: 1901553759
- Sigstore integration time:
-
Permalink:
mauriciobenjamin700/tempestroid@a5f2827795fb6c4e42fe231fe1ebebaf84d6451b -
Branch / Tag:
refs/tags/v0.15.0 - Owner: https://github.com/mauriciobenjamin700
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a5f2827795fb6c4e42fe231fe1ebebaf84d6451b -
Trigger Event:
push
-
Statement type:
File details
Details for the file tempestroid-0.15.0-py3-none-any.whl.
File metadata
- Download URL: tempestroid-0.15.0-py3-none-any.whl
- Upload date:
- Size: 529.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8ded7fa95bcb31a8cc9374b5485f255b17c14126cc9d5deab6f427f84e98e406
|
|
| MD5 |
39a3a1921a633efb09fe5b0e687fa124
|
|
| BLAKE2b-256 |
95f8dbe30c6b04fc00784eec683b84860e6a88cebdd7f03453ad797cee90358e
|
Provenance
The following attestation bundles were made for tempestroid-0.15.0-py3-none-any.whl:
Publisher:
publish.yml on mauriciobenjamin700/tempestroid
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tempestroid-0.15.0-py3-none-any.whl -
Subject digest:
8ded7fa95bcb31a8cc9374b5485f255b17c14126cc9d5deab6f427f84e98e406 - Sigstore transparency entry: 1901553937
- Sigstore integration time:
-
Permalink:
mauriciobenjamin700/tempestroid@a5f2827795fb6c4e42fe231fe1ebebaf84d6451b -
Branch / Tag:
refs/tags/v0.15.0 - Owner: https://github.com/mauriciobenjamin700
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a5f2827795fb6c4e42fe231fe1ebebaf84d6451b -
Trigger Event:
push
-
Statement type: