PyGUI
Beautiful native desktop apps in pure Python, with zero dependencies. A developer's sticker book of how it all fits together.
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.
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()
# 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
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.
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
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.
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.
Page
A function under @app.page(path). Typed params: {id:int}, {x:float}, {p:path}.
Layout
@app.layout receives each page's content and returns the shell around it.
Element
Tag, attrs, events, children, optional key. Built by nested calls or with blocks.
Component
@ui.component makes a lazy call with its own hook state, rendered later by the Renderer.
State
Reactive value. Reading it during a render subscribes the window; setting it re-renders subscribers.
Hook
ui.use_state: per-window State keyed by position in the tree.
Session
One per window: path, query, theme, hooks, handlers and a dedicated worker thread.
Render
Build the complete JSON tree and send it with the latest ack.
Handler & event spec
A Python callable on an element event. The client only gets its id plus keys, debounce, prevent, stop, self.
Sequence & ack
The client numbers each event; each render carries the highest number Python has processed.
Backend & bridge
native, window or browser. Native uses WebKit script messages, with no socket.
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.
.notify().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
@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…")
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.
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.
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.
Converted values
Inputs hand on_change a str, a number or None, a bool, or a select's original Python value.
Async & background
An async def handler runs on its own thread with asyncio.run; ui.background(fn) keeps the window context.
Debounce & keys
debounce=300 waits for a pause in the client. on_enter becomes a keydown filtered to Enter.
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.
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", )
{
"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.
Claiming arguments
An element passed into a component is detached from where it was built, so it never renders twice.
Positional hooks
Same position, same State. Insert an unkeyed sibling before a component and its state shifts; give it a key.
Error view
A page that raises renders its traceback in the window. Handlers that raise become an error toast.
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.
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.
Same API, two pipes
The client runtime detects which one it has (window.webkit.messageHandlers.pygui) and the app code never knows the difference.
NSWindow + WKWebView, no socket
- ctypes loads libobjc and calls
objc_msgSendto build the window, web view, menu bar and delegate classes at runtime. - The page is written to a temp
index.htmlwith CSS and JS inlined;app.staticfolders are symlinked beside it. - JS → Python:
webkit.messageHandlers.pygui.postMessage(json)reaches_on_script_message. - Python → JS: the worker queues a call and
dispatch_async_frunsevaluateJavaScript("__pg_recv(…)")on the main thread. - Routing uses the URL hash. External links go to
open_url, which only accepts http, https and mailto.
Chromium app window over loopback
ThreadingHTTPServerbinds127.0.0.1on a free port; a random launch token is minted.- Edge, Chrome, Brave, Chromium or Vivaldi opens
/?_pgt=tokenwith--appand a private profile. - The token is swapped for an
HttpOnly,SameSite=Strictcookie and a redirect to a clean URL. - A hand-written RFC 6455 WebSocket at
/_pg/ws?sid=…carries the same JSON messages. - Reconnects resume the session by id. A new
bootid after a restart makes the page reload; the app exits 3 s after its last window leaves.
The pattern catalog
Where each classic idea shows up in the code, and what it buys the framework.
| Pattern | Where | What it buys |
|---|---|---|
| Observer (implicit) | State._subs WeakSet | Dependencies tracked by reading; nothing to declare, nothing leaks. |
| Actor / serial queue | Session._loop | Per-window ordering without locks in app code; any thread can post work. |
| Render coalescing | dirty flag + empty-queue check | Many state changes, one frame (REQ-RND-002). |
| Virtual tree + reconciliation | Renderer → patchChildren | Declarative pages, DOM nodes reused by tag and key. |
| Composite | Element / ComponentCall | Elements, fragments, components and States nest freely. |
| Builder with context manager | __enter__ + _local.stack | with blocks that build the exact same tree as nested calls. |
| Hooks | use_state, Renderer.hook | Local state for plain functions, garbage-collected per render. |
| Two-way binding | ui._bind | Pass a State as value; reads and writes are wired for you. |
| Optimistic UI guard | seq / ack in client.js | Typing never fights a render in flight. |
| Signature-based injection | _arity, _accepted_kwargs | Handlers and pages take only the arguments they ask for. |
| Decorator registry | @app.page, layout, timer, expose | The app is described by annotating functions. |
| Strategy (backends) | run(mode=…) | native, window or browser behind one App API, with automatic fallback. |
| Adapter / FFI | macos.msg, _make_class | Cocoa from pure Python, with no PyObjC. |
| Thread confinement | MacWindow.on_main | Cocoa calls only ever run on the main thread. |
| Supervisor process | reloader.run_with_reloader | Restart on save, survive crashes until the next fix. |
| Facade | App, ui.* actions | Toasts, dialogs and navigation reach the right window(s) without plumbing. |
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_sectionheaderText
h1–h4textlinkcodecode_blockmarkdownkbdInputs
buttonicon_buttoninputtextareacheckboxswitchsliderselectradio_groupsegmentedformDisplay
badgeavatariconimageprogressspinneralertstattabletabsaccordionmodaltooltipempty_statetheme_toggleCharts
line_chartbar_chartdonutsparklineActions
toastnavigateset_themetoggle_themeset_titlecopyrun_jsbackgroundDesktop
open_filesave_filechoose_foldernotifyopen_urlopen_pathEscape hatches
ui.elui.htmlui.run_js@app.exposeregister_iconapp.add_cssapp.staticui.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
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
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( "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.
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.
Loopback only
The server binds 127.0.0.1. The native backend opens no socket at all.
Path traversal
Static serving refuses any path that resolves outside its folder.
Markdown & text
Raw HTML is escaped; javascript:, vbscript: and data: URLs are neutralised. Text children are text nodes.
Size cap
A WebSocket message over 32 MiB closes the connection.
URL schemes
The UI can ask Python to open http, https and mailto URLs, nothing else. Shell strings for AppleScript and PowerShell are escaped.
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.
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
# 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.
What it doesn't do yet
Stated in the spec rather than hidden. Each one is a candidate requirement.
WebView2 and WebKitGTK through ctypes would drop the browser and the socket.
Browser discovery, PowerShell dialogs and zenity/kdialog have only been verified on macOS so far.
A single native window and session; no API for a second window.
No signed .app or .exe yet; PyInstaller works since there is nothing to collect.
File dialogs are not sheets and can open behind the window.
An unkeyed sibling inserted before a component shifts its state, with no warning.
Some ARIA roles exist; keyboard and screen-reader behaviour are unreviewed.
Only 3.9 has a recorded run; 3.10 to 3.13 are expected to work.