PyGUI Sticker Book
PyGUI
パイ・ジー・ユー・アイ · v0.1.0

PyGUI

Beautiful native desktop apps in pure Python, with zero dependencies. A developer's sticker book of how it all fits together.

0runtime dependencies
50+components
99requirements · 0 uncovered
75automated tests
3.9+Python
ssss-tate.set()!
01 · Hello, window (。•ᴗ•。)

A window in twelve lines

Pages are Python functions that return a tree of components. Change a State and every window showing it redraws. No pip install, no Node, no Rust toolchain.

hello.py
from pygui import App, State, ui

app = App("Hello")
count = State(0)

@app.page("/")
def home():
    return ui.column(
        ui.h1(f"Clicked {count.value} times"),
        ui.button("Click me", lambda: count.set(count.value + 1)),
    )

app.run()
terminal
# one time, from the repo folder
python3 -m pip install -e .

# scaffold an app with sidebar, pages, theme toggle
python3 -m pygui new myapp
python3 myapp/app.py

# or run the examples straight from the repo
python examples/hello.py       # counter
python examples/todo.py        # keyed list, filters
python examples/dashboard.py   # charts, table, modal

# run options
app.run(reload=True)        # restart on save
app.run(debug=True)         # web inspector
app.run(mode="browser")     # default browser
02 · Architecture ( ˘ᵕ˘ )

One loop, two worlds

Your logic stays in Python. The window is a system web engine that only knows how to patch a DOM and report events. Between them travels plain JSON: whole trees going out, tiny event messages coming back.

PYTHON PROCESS WEB ENGINE (WKWebView / Chromium) Your code @app.page State · ui.* Renderer Element tree → JSON tree Transport bridge (mac) WebSocket client.js patchChildren() listener() DOM design-system CSS calls render patch Session · worker thread queue: event · nav · theme · fn · call one per window · renders when idle event {h, d, s} invoke handler → State.set() _render() when queue empty
The whole framework is this cycle. Python never touches the DOM, and the browser never runs app logic.
03 · Module map ʕ•ᴥ•ʔ

What lives where

About 4,200 lines in the package, all standard library. The component library is the biggest piece; the native macOS window is only 358 lines of ctypes.

Lines per file

pygui/ package, counted with wc -l · hover a bar

app.py

App, routing, Session, Renderer, both run loops.

ui.py

Element, @component, use_state, every widget, charts, icons, markdown.

state.py

State, thread-local runtime context, invoke() by arity, spawn().

macos.py

NSWindow + WKWebView via objc_msgSend, menus, main-queue dispatch.

server.py · window.py

Stdlib HTTP + WebSocket fallback, Chromium app-window launcher.

static/

client.js (DOM patching, events) and style.css (design system).

desktop.py

Dialogs, notifications, open URL/path via osascript, PowerShell, zenity.

reloader.py · __main__.py

Restart-on-save supervisor; pygui new scaffolder.

04 · Core concepts (✿◠‿◠)

The vocabulary, one sticker each

These are the terms from the specification. Everything else in this book is built from them.

App

Routes, layout, timers, exposed functions, theme and window settings. The facade you talk to.

app.py · App

Page

A function under @app.page(path). Typed params: {id:int}, {x:float}, {p:path}.

app.py · Route

Layout

@app.layout receives each page's content and returns the shell around it.

app.py · App.layout

Element

Tag, attrs, events, children, optional key. Built by nested calls or with blocks.

ui.py · Element

Component

@ui.component makes a lazy call with its own hook state, rendered later by the Renderer.

ui.py · ComponentCall

State

Reactive value. Reading it during a render subscribes the window; setting it re-renders subscribers.

state.py · State

Hook

ui.use_state: per-window State keyed by position in the tree.

app.py · Renderer.hook

Session

One per window: path, query, theme, hooks, handlers and a dedicated worker thread.

app.py · Session

Render

Build the complete JSON tree and send it with the latest ack.

Session._render

Handler & event spec

A Python callable on an element event. The client only gets its id plus keys, debounce, prevent, stop, self.

Renderer.ser_element

Sequence & ack

The client numbers each event; each render carries the highest number Python has processed.

client.js · emit / setAttr

Backend & bridge

native, window or browser. Native uses WebKit script messages, with no socket.

macos.py · server.py
05 · Reactive State (ノ◕ヮ◕)ノ*:・゚✧

Read to subscribe, set to redraw

There is no dependency declaration. Whatever a page reads while it renders is what it depends on. Subscriptions live in a WeakSet, so a closed window is never kept alive by the values it once read.

Session render _local.rendering = s State _value _subs: WeakSet[Session] reads .value subs.add(session) Any thread handler · timer · async equal & immutable? int str tuple None … session.invalidate() worker: dirty · else queue "render" .set(new) no → notify() for each subscriber yes → nothing happens
A mutable value (list, dict) always notifies on set. After mutating one in place yourself, call .notify().
shared state
count = State(0)         # shared by all windows
count.value               # read (subscribes in a render)
count.set(5)              # write → re-render
count.value += 1
count.update(lambda n: n * 2)

todos = State([])
todos.append("Write docs")  # list helpers notify
todos.value[0]["done"] = True
todos.notify()               # in-place change: tell it
local state + two-way binding
@ui.component
def Counter(label):
    n = ui.use_state(0)      # per window, per position
    return ui.button(f"{label}: {n.value}",
                     lambda: n.set(n.value + 1))

@app.page("/search")
def search():
    q = ui.use_state("")
    # a State as value = two-way binding
    return ui.input(q, placeholder="Search…")
06 · Session worker (っ˘ω˘ς )

One thread per window, one render per burst

Each window owns a queue and a daemon thread. Messages are handled strictly in order, and the render waits until the queue drains, so ten state changes from one click still produce a single render.

queue.get() blocks until work _process(item) event·nav·theme·fn·call dirty and idle or >100ms? _render() tree + ack · GC hooks send ack only if ack moved yes no loop · exceptions become an error toast, the loop keeps going
The 100 ms escape keeps a window responsive under a constant stream of events, such as a timer that ticks faster than renders finish.
07 · Event lifecycle (•̀ᴗ•́)و

From a click to a fresh frame

Follow one click on the counter button. The sequence number is what keeps typing safe: an input the user is editing is not overwritten until Python has acknowledged the user's latest keystroke.

User client.js Transport Session Handler · State click seq += 1 → s = 7 {h:"r.0.1:click", s:7} queue.put(event) invoke(fn, ev) count.set() → dirty queue empty → _render() {render, tree, ack:7} __pg_recv / onmessage ack = 7 → patchChildren() sees "Clicked 1 times"
The handler id r.0.1:click is the element's path in the tree plus the event name. An event naming an id absent from the last render is ignored.

Arity injection

invoke() inspects the signature and passes the event only if the function accepts an argument. lambda: … and def f(ev) both work.

state.py · _arity, invoke

Converted values

Inputs hand on_change a str, a number or None, a bool, or a select's original Python value.

ui.py · _bind, _coerce

Async & background

An async def handler runs on its own thread with asyncio.run; ui.background(fn) keeps the window context.

state.py · _run_thread, spawn

Debounce & keys

debounce=300 waits for a pause in the client. on_enter becomes a keydown filtered to Enter.

client.js · listener
08 · Renderer & hooks ( •ᴗ• )⊃━☆

Trees in, compact JSON out

The Renderer walks the element tree, expands each component with its hook state, gives every handler a stable path id, and emits a small JSON shape. Hooks no longer reached in this render are deleted afterwards.

Python
with ui.card(title="Sign in"):
    ui.input(email, label="Email")
    ui.button("Continue", login, full=True)

# identical tree, nested style (REQ-TRE-001)
ui.card(
    ui.input(email, label="Email"),
    ui.button("Continue", login, full=True),
    title="Sign in",
)
wire format
{
  "t": "button",                 // tag
  "a": {"class": "pg-btn …"},     // attributes
  "k": "save",                   // key (optional)
  "e": {"click": [{"h": "r.0.2:click"}]},
  "c": ["Continue"]               // children
}                                 // "h": raw html instead of "c"

// hook key = (component path, call index)
("r.0/Counter", 0) → State(3)

Thread-local builder stack

Every new Element adopts itself into the element on top of _local.stack. That one trick makes with blocks and nested calls equivalent.

ui.py · Element._auto_attach

Claiming arguments

An element passed into a component is detached from where it was built, so it never renders twice.

ui.py · _claim

Positional hooks

Same position, same State. Insert an unkeyed sibling before a component and its state shifts; give it a key.

Renderer.hook · ser_children

Error view

A page that raises renders its traceback in the window. Handlers that raise become an error toast.

app.py · _error_view
09 · DOM patching (・ω・)ノ

Reuse every node you can

Every render ships the complete tree, and client.js reconciles it against the live DOM. There is no server-side diff, which keeps Python simple and the patcher at about forty lines.

old DOM li k=1 (A) li k=2 (B) "3 left" div new tree li k=2 (B) li k=1 (A) "2 left" li k=3 (C) nodeValue moved by key div removed · C created match: same tag and same key
The div has no partner of the same tag and key, so it is removed and C is created. Keyed rows keep their DOM node (and focus, scroll, animations) when reordered.

Input protection. Each input element remembers the sequence number of its last event (el._pgSeq). While that number is above the latest ack, or a debounced send is pending, value and checked are not overwritten. Fast typists never see characters disappear.

Listeners are installed once. A node gets one listener per event type; the current specs live on el._pgE and are swapped on every patch, so handler ids can change without rebinding.

10 · Transports ✈ (。・ω・。)

Same API, two pipes

The client runtime detects which one it has (window.webkit.messageHandlers.pygui) and the app code never knows the difference.

macOS · native

NSWindow + WKWebView, no socket

  1. ctypes loads libobjc and calls objc_msgSend to build the window, web view, menu bar and delegate classes at runtime.
  2. The page is written to a temp index.html with CSS and JS inlined; app.static folders are symlinked beside it.
  3. JS → Python: webkit.messageHandlers.pygui.postMessage(json) reaches _on_script_message.
  4. Python → JS: the worker queues a call and dispatch_async_f runs evaluateJavaScript("__pg_recv(…)") on the main thread.
  5. Routing uses the URL hash. External links go to open_url, which only accepts http, https and mailto.
Windows · Linux · browser

Chromium app window over loopback

  1. ThreadingHTTPServer binds 127.0.0.1 on a free port; a random launch token is minted.
  2. Edge, Chrome, Brave, Chromium or Vivaldi opens /?_pgt=token with --app and a private profile.
  3. The token is swapped for an HttpOnly, SameSite=Strict cookie and a redirect to a clean URL.
  4. A hand-written RFC 6455 WebSocket at /_pg/ws?sid=… carries the same JSON messages.
  5. Reconnects resume the session by id. A new boot id after a restart makes the page reload; the app exits 3 s after its last window leaves.
11 · Design patterns φ(゜▽゜*)♪

The pattern catalog

Where each classic idea shows up in the code, and what it buys the framework.

PatternWhereWhat it buys
Observer (implicit)State._subs WeakSetDependencies tracked by reading; nothing to declare, nothing leaks.
Actor / serial queueSession._loopPer-window ordering without locks in app code; any thread can post work.
Render coalescingdirty flag + empty-queue checkMany state changes, one frame (REQ-RND-002).
Virtual tree + reconciliationRenderer → patchChildrenDeclarative pages, DOM nodes reused by tag and key.
CompositeElement / ComponentCallElements, fragments, components and States nest freely.
Builder with context manager__enter__ + _local.stackwith blocks that build the exact same tree as nested calls.
Hooksuse_state, Renderer.hookLocal state for plain functions, garbage-collected per render.
Two-way bindingui._bindPass a State as value; reads and writes are wired for you.
Optimistic UI guardseq / ack in client.jsTyping never fights a render in flight.
Signature-based injection_arity, _accepted_kwargsHandlers and pages take only the arguments they ask for.
Decorator registry@app.page, layout, timer, exposeThe app is described by annotating functions.
Strategy (backends)run(mode=…)native, window or browser behind one App API, with automatic fallback.
Adapter / FFImacos.msg, _make_classCocoa from pure Python, with no PyObjC.
Thread confinementMacWindow.on_mainCocoa calls only ever run on the main thread.
Supervisor processreloader.run_with_reloaderRestart on save, survive crashes until the next fix.
FacadeApp, ui.* actionsToasts, dialogs and navigation reach the right window(s) without plumbing.
12 · Components (^▽^)

The toy box

Every component takes cls=, style= and key=; any other keyword becomes an HTML attribute. Spacing uses a 4 px scale, so gap=4 is 16 px.

Layout

columnrowgridcontainercardspacerdividerscroll_areashellsidebarnav_itemnav_sectionheader

Text

h1–h4textlinkcodecode_blockmarkdownkbd

Inputs

buttonicon_buttoninputtextareacheckboxswitchsliderselectradio_groupsegmentedform

Display

badgeavatariconimageprogressspinneralertstattabletabsaccordionmodaltooltipempty_statetheme_toggle

Charts

line_chartbar_chartdonutsparkline

Actions

toastnavigateset_themetoggle_themeset_titlecopyrun_jsbackground

Desktop

open_filesave_filechoose_foldernotifyopen_urlopen_path

Escape hatches

ui.elui.htmlui.run_js@app.exposeregister_iconapp.add_cssapp.static
table with formatters
ui.table(users, [
    ("name", "Name"),
    {"key": "role", "label": "Role",
     "format": lambda v: ui.badge(v, "blue")},
    {"key": "spent", "align": "right",
     "format": lambda v, row: f"${v:,}"},
], on_row_click=open_user)   # sortable by default
modal + chart
ui.modal(show, ui.text("Are you sure?"),
         title="Confirm",
         footer=[ui.button("Cancel", close, variant="ghost"),
                 ui.button("Yes", confirm)])

ui.line_chart({"Sales": [3, 5, 4, 8]},
              ["Q1", "Q2", "Q3", "Q4"])  # pure SVG
13 · Theming (◕‿◕✿)

One accent paints everything

The stylesheet derives soft fills and focus rings from --accent with color-mix(). Light and dark are two token sets under [data-theme]; the native title bar follows along.

App(...)
app = App(
    "My App",
    theme="auto",        # follows the OS · "dark" · "light"
    accent="#f0609a",     # drives the whole palette
    radius=16,            # → --radius
    width=1200, height=800, min_size=(600, 400),
    icon="icon.png",      # Dock icon on macOS
)
app.add_css(".hero { background: var(--accent) }")

--accent

Plus --accent-soft 14% and --accent-ring 35%.

Surfaces

--bg, --surface, -2, -3, --border

Text

--text, --text-2, --muted

Status

--green, --red, --yellow, --blue

Charts

--c1…--c6, starting from the accent.

Persisted

The user's choice is kept per app in localStorage.

14 · Security (ง •̀_•́)ง

Small surface, sharp edges filed down

A desktop app that runs a local server is a target for other local processes and web pages. These are the guards, each tied to a requirement.

Launch token

Every page and the WebSocket need the token or its cookie. Only /_pg/ assets are public.

REQ-SEC-001 · 002

Loopback only

The server binds 127.0.0.1. The native backend opens no socket at all.

REQ-FBK-003 · REQ-WIN-002

Path traversal

Static serving refuses any path that resolves outside its folder.

REQ-SEC-003

Markdown & text

Raw HTML is escaped; javascript:, vbscript: and data: URLs are neutralised. Text children are text nodes.

REQ-SEC-004 · 005

Size cap

A WebSocket message over 32 MiB closes the connection.

REQ-SEC-006

URL schemes

The UI can ask Python to open http, https and mailto URLs, nothing else. Shell strings for AppleScript and PowerShell are escaped.

REQ-SEC-007 · REQ-DSK-002 · 003
15 · Dev tools (⌐■_■)

Comfortable while you build

reload=True

A parent process polls file mtimes every 0.5 s, ignoring hidden, venv, __pycache__ and node_modules, and restarts the child. A crash waits for your next save.

debug=True

Turns on the WebKit inspector: right-click, Inspect Element. Or use mode="browser" for full dev tools.

pygui new

Scaffolds app.py with a sidebar layout, two pages and a theme toggle; refuses to overwrite.

@app.expose

await pygui.call("add", 2, 3) from your own JS; results or errors come back as a promise.

@app.timer(1.0)

Runs periodically while the app runs. Set a State inside and every open window updates.

Lifecycle hooks

@app.on_startup and @app.on_shutdown; closing the window makes run() return.

16 · Specs & tests ✓(◍•ᴗ•◍)

Every promise has a test

The specification states 99 atomic REQ-* requirements. Tests claim them with a covers: line in their docstring, and tests/matrix.py rebuilds the traceability matrix, failing if any requirement is left uncovered.

How the 99 requirements are verified

from specs/matrix.md
Headless auto · 70 GUI auto, macOS · 7 Manual · 22
Test functions per file · 75 total
run the suites
# headless, everywhere
python3 -m unittest discover -s tests

# + real native windows (macOS)
PYGUI_GUI_TESTS=1 python3 -m unittest discover -s tests

# rebuild specs/matrix.md; fails on any gap
python3 tests/matrix.py

Ids never move. Requirements are REQ-<AREA>-<NNN>; a retired one keeps its id and is marked WITHDRAWN.

Fourteen areas: STA, TRE, HOK, RND, EVT, RTE, CMP, THM, WIN, FBK, SEC, DSK, DEV, PKG. Manual rows live in specs/manual.md.

17 · Known gaps (´・ω・`)

What it doesn't do yet

Stated in the spec rather than hidden. Each one is a candidate requirement.

Native Windows & Linux

WebView2 and WebKitGTK through ctypes would drop the browser and the socket.

Untested on those systems

Browser discovery, PowerShell dialogs and zenity/kdialog have only been verified on macOS so far.

One window on macOS

A single native window and session; no API for a second window.

No packaging

No signed .app or .exe yet; PyInstaller works since there is nothing to collect.

Out-of-process dialogs

File dialogs are not sheets and can open behind the window.

Positional hooks

An unkeyed sibling inserted before a component shifts its state, with no warning.

Accessibility

Some ARIA roles exist; keyboard and screen-reader behaviour are unreviewed.

Python versions

Only 3.9 has a recorded run; 3.10 to 3.13 are expected to work.