A lightweight Python framework for building desktop apps.
Guile started as a personal tool for building lab and research apps — the kind of quick internal dashboards, data explorers, and parameter tools that are too specific to justify a full web stack, but too interactive for a script. It does two things and tries to do them well. First, it layers interactivity on top of the Python you already have: your functions stay ordinary Python, and guile just wires them to widgets. Second, it follows a single reactive rule — change a state value and the interface updates, patching only the parts that actually changed, so text stays in inputs and focus is never lost. The app is one Python process that opens a native window: no server, no ports, no browser tab.
pip install guileRequires pywebview. On Windows, WebView2 ships with Windows 10/11 — nothing extra to install.
import guile as gui
count = gui.state(0)
@gui.app("Counter", width=400, height=300)
def ui():
with gui.col(align="center", justify="center", style="height:100vh"):
with gui.card(gap=14):
gui.title("Counter")
with gui.row(gap=16, align="center", justify="center"):
gui.button("−", variant="secondary",
on_click=lambda: count.update(lambda x: x - 1))
gui.text(count.value, size="2xl", bold=True,
style="min-width:64px;text-align:center")
gui.button("+",
on_click=lambda: count.update(lambda x: x + 1))
gui.run()@gui.app() defines the app; gui.run() opens the window and blocks until it is closed and any already-started gui.task() jobs and their completion callbacks finish. Code written after gui.run() can save the final session or continue a processing pipeline (see examples/field_notes.py). Long-running jobs delay return; task functions must eventually finish.
While building a UI, use gui.run(dev=True): guile watches your script and reloads the app inside the open window every time you save the file. Errors show in the window without killing the session; each reload resets state to its initial values.
gui.state(value)— a reactive value; setting it re-renders the UI automatically. Read it through.value, alwayswith gui.card():/with gui.col():/with gui.row():— layout containers; everything indented goes insidegui.button(),gui.slider(),gui.input(),gui.table()— widgets that takeon_click=or return their current valuegui.figure(fig)— embed a matplotlib figure inlinegui.leaflet(center, markers=..., layers=...)— embed an interactive map; drape a georeferenced image (gui.ImageOverlay), a pre-tiled drone mosaic (gui.TileOverlay), or vector features (gui.GeoJSON) over itgui.task(fn, on_done=...)— run slow work on a background thread; the window stays responsivegui.run()— opens the window;gui.run(dev=True)adds hot reload while you buildgui.package("my_app.py")— build a shareable executable in one call
| File | What it shows |
|---|---|
counter.py |
State, buttons, badges |
todo.py |
Lists, dynamic rendering, checkboxes |
settings.py |
Sliders, selects, form layout |
field_notes.py |
Save on exit — code after gui.run() |
mesonet_map.py |
Leaflet map with markers |
mesonet_interactive.py |
Live mesonet station data |
weather_explorer.py |
Table, date picker, file picker |
soils_lab.py |
Lab data entry form |
soil_water_retention.py |
Sliders driving a live chart |
upload_weather_data.py |
File picker, DataFrame, table |
canopeo.py |
Image analysis |
map_draw.py |
Leaflet with draw tools |
map_overlays.py |
Image overlay + GeoJSON on a map |
map_areas.py |
Draw, edit, label and select areas |
If you build guile apps with an AI tool (Claude Code, Copilot, ChatGPT, …), point it at the machine-readable docs instead of the HTML pages:
- llms-full.txt — the complete API, the golden rules, and verified examples in one self-contained file. Everything an assistant needs to write correct guile apps.
- llms.txt — the short index, following the
llms.txtconvention.
Tools working inside a cloned repo can also read the guile source directly — every public function carries a full docstring.
| Package | Purpose |
|---|---|
pywebview |
The window (installed with guile) |
matplotlib |
gui.figure() (installed with guile) |
numpy, pandas |
Optional — pip install guile[science] |
Everything else is Python standard library.
| File | Role |
|---|---|
state.py |
Reactive value class |
ui.py |
Render engine + all widgets |
_app.py |
Window lifecycle, pywebview bridge |
_template.py |
Embedded HTML/CSS/JS |
_package.py |
gui.package() — PyInstaller wrapper |
_dev.py |
gui.run(dev=True) — hot reload |
__init__.py |
Public API (gui.*) |
v1.1.1
- Fix: OpenStreetMap tiles showed "Access blocked". guile loaded its page from a string, so the page had no origin and the browser sent no
Refererheader; OpenStreetMap's tile servers answer such requests with an "Access blocked" image. guile now serves the page from a small built-in server onhttp://127.0.0.1:<free port>/(standard library only, local machine only, closed with the window), so tile requests carry a valid Referer and the default street map loads normally. If you worked around this with a customtiles=URL, you can go back to the default. - The OpenStreetMap URL no longer uses the deprecated
a./b./c.subdomains (https://tile.openstreetmap.org/{z}/{x}/{y}.png).
v1.1.0
- Breaking: OpenStreetMap is now the only built-in base map.
tiles="satellite","hybrid","terrain","light"and"dark"are removed. The trigger: CARTO, which served"light"/"dark", started requiring an API key and returned an "API KEY REQUIRED" image for every tile, and other commercial "free" tile servers can do the same at any time. Instead of maintaining a provider list, guile keeps the community-run OpenStreetMap as the default and lets you bring your own provider: pass its XYZ URL, with your API key in it if it needs one, e.g.tiles={"url": "https://.../{z}/{x}/{y}.jpg?key=YOUR_KEY", "attribution": "..."}. Using a removed name raises aValueErrorexplaining this, instead of silently showing a blank map. The how-to has a new "bring your own provider" section, and the map examples now run on the default street map. - The landing page has a new gallery: code side by side with a screenshot of that exact app (layouts, widgets, tabs, button rails, maps, metric cards, figures).
tools/doc_shots.pyregenerates the screenshots and the gallery. examples/icon_rail.pyand the how-to sidebar snippets addalign="stretch"to the sidebar row, so the sidebar fills the window height instead of floating in the middle.
v1.0.0
gui.run()now waits for in-flight work before returning. Closing the window stops new interaction, but any already-startedgui.task()jobs and their completion callbacks now finish beforerun()returns — so save-on-exit code written aftergui.run()sees their final results instead of racing them. Long-running jobs therefore delay return; task functions must eventually finish. The dev-mode file watcher also stops promptly on close instead of lingering.- Events dispatch in the order the user made them. pywebview delivers browser API calls on independent threads, so rapid interactions could be processed out of order; each event now carries a sequence number and is restored to browser order before dispatch.
- Multi-shape edits save atomically. Saving several edits or deletes at once from the Leaflet draw toolbar is delivered as a single operation, so no render runs partway through a batch.
- Committed fields reflect Python's corrections. A text field, number field, checkbox, or dropdown you've committed (Enter or focus-leave) now shows the value your callback set — clamped, cleared, upper-cased, etc. — while text you're still typing, and the caret, are preserved as before.
live=Trueinputs fireon_changeonce on commit without repeating it. gui.select()/gui.multiselect()accept non-string option values. Dict keys/values and initial values are coerced to strings, so numeric or falsy options (0,False) select and round-trip correctly.static=Truefigures cache by figure object, not layout position. A cached static figure no longer shows a stale image after the surrounding layout shifts, and discarded figures (with their cached images) are garbage-collected.
v0.9.2
- Security: map data is no longer interpreted as HTML. Leaflet renders a string popup/tooltip/label as HTML, so a
gui.leaflet()layer built from untrusted GeoJSON (or a marker with an untrustedpopup/tooltip) could run injected markup or script in the app. Feature popups, marker popups/tooltips, and permanent labels now render as plain text. Behavior change: a callablepopup=/label=that returned HTML (e.g.lambda p: f"<b>{p['id']}</b>") is now shown literally rather than formatted. If you need rich content, that must be an explicit, sanitised opt-in — open an issue if you rely on it. - Fix: a click during a redraw can no longer trigger the wrong action. Auto-generated widget ids are positional, so an unkeyed button at a given spot could be "Cancel" in one layout and "Delete" in the next, and callbacks go live a moment before the page repaints. A click left over from the old layout could reach the newly-assigned callback. Every render is now stamped with a generation that rides along with each event; an event from a superseded page is dropped instead of dispatched. This is conservative — a rapid click that spans a redraw can be discarded — but it can never invoke the wrong action. (Keys give a widget stable state, but do not by themselves make its events safe.)
v0.9.1
- The built-in chrome now uses matching Lucide glyphs: the file-picker button's
📁emoji is afoldericon, and the modal close's✕is anx— both stroke incurrentColor, so they take the theme color like everything else. (Their paths are inlined, so file pickers and modals don't load the icon dataset.) Thegui.select()dropdown chevron was already a matching SVG and is unchanged.
v0.9.0
gui.rail()— icon + label button rail. A compact navigation control for fitting many destinations in a narrow sidebar (orientation="vertical", the default) or a toolbar (orientation="horizontal"). Works likegui.tabs()— returns the active item's value, manages its own state (passkey=), and binds to aStateviavalue=for programmatic switching. Each item is a dict with alabeland optionalicon. Passborder=Trueto wrap it in a subtle themed panel. Seeexamples/icon_rail.py.gui.icon()— bundled Lucide icon set. Returns inline SVG markup for any of ~2100 Lucide icons (gui.icon("home")), for use in a rail item or wrapped ingui.html(). Icons stroke incurrentColor, so they inherit the surrounding text color. No CDN and no runtime dependency — the icon data is vendored and imported lazily, so apps that never callicon()pay nothing. You can still pass your own<svg>string anywhere an icon is expected.- Added a
LICENSEfile (guile is MIT); it also carries the ISC/MIT attribution for the vendored Lucide/Feather icons.
v0.8.9
- Map fix: with
drawn=andon_shape_click, clicking a shape while the draw toolbar's delete (or edit) tool was active also fired the click callback; if that re-render changed the list (e.g. a selection style), every shape was rebuilt, so the shape just deleted popped back and edit handles were lost. Rebuilds are now held while a tool is active and the latest list is applied on Save or Cancel, and shape clicks made while a tool is active belong to the tool and no longer fireon_shape_click.
v0.8.8
- Improved root-container cleanup
v0.8.7
- Fix: a progress bar inside a height-constrained column could shrink to nothing; the track now has
flex-shrink: 0.
v0.8.6
gui.package()now bundles only the native pywebview backend for the platform, so a machine with PyQt or PySide installed no longer drags all of Qt into the build (native_only=,exclude_modules=).gui.package()warns when building from the Anaconda base and suggests a clean venv; the docs no longer claim a separate environment is unnecessary.- Fix: PyInstaller
RecursionErroron Anaconda machines, caused by matplotlib's optional IPython import chain, which is now excluded. - Fix: apps built from a venv made with Anaconda's Python failed at launch with
DLL load failed while importing _ctypes. - Fix:
gui.package()crashed withUnicodeEncodeErrorwhen its output was redirected to a file.
v0.8.5
- Fix: v0.8.4 is broken — do not use it. Its inline JavaScript contained a stray
}(a splice error in the draw-tools rewrite), so the script never parsed,window._guilewas undefined, and every app rendered blank. The test suite now syntax-checks the inline script (node --check, with a bracket-balance fallback) so this cannot ship again.
v0.8.4
- Drawn shapes owned by Python:
gui.leaflet(drawn=[...], draw_style=...)rebuilds the editable draw layer from your list, so file-loaded plots and in-app drawings live in one list with no doubled outlines. New callbackson_shape_edit(id, type, coords)andon_shape_delete(id)report the toolbar's edit/delete saves;on_shape_click(id)andon_shape_hover(id | None)make shapes selectable. Per-shapestyleandlabel. Seeexamples/map_areas.py. gui.GeoJSON: newlabel=(permanent text pill on each feature, property name or callable) andon_hover=(properties on enter,Noneon leave).- Docs: the how-to gains a Maps chapter (interactive map, draping imagery, GeoJSON labels/hover, drawing and editing areas) and a Sharing chapter (building an executable, avoiding install warnings).
llms-full.txtgains a worked mapping example and new pitfalls; both LLM files are current for AI-assisted coding.
v0.8.3
- Map fix: switching a
TileOverlayURL or anImageOverlayno longer flashes the base map. New overlay layers are added first and the previous ones are removed once the new rasters have loaded (2 s fallback), so the swap is a cross-fade.
v0.8.2
- Map fix: a keyed map no longer goes blank when an unkeyed ancestor is replaced (e.g. a sidebar element appearing or disappearing shifts auto-numbered ids). The registry now detects the orphaned Leaflet instance, disposes it, and rebuilds the map in place, keeping the user's current pan/zoom.
v0.8.1
- Map overlay layers:
gui.leaflet(layers=[...])acceptsgui.ImageOverlay(png, bounds=...)to drape a georeferenced PNG/JPG,gui.TileOverlay(url)for a pre-tiled pyramid (the practical route for large drone mosaics — tile with gdal2tiles, serve withpython -m http.server), andgui.GeoJSON(data, popup=, on_click=)for vector features with a per-feature click callback. Seeexamples/map_overlays.py.
v0.8.0
gui.package()now defaults topackage_mode="onedir"(a folder holding the executable and its libraries) instead of a single file. Onedir draws fewer antivirus/SmartScreen false positives and starts faster — zip it or wrap it in an installer to share. Passpackage_mode="onefile"for the old single-executable behavior. This replaces theonefile=argument.
v0.7.0
@gui.appnow only defines the app; addgui.run()at the end of your script to open the window. Code aftergui.run()executes when the window closes — a natural place to save the session or continue a pipeline (seeexamples/field_notes.py).- Hot reload:
gui.run(dev=True)watches your script and reloads the app inside the open window on every save. Errors show in the window without killing the session; each reload resets state to initial values. Stateis now fully explicit: read and compare through.value(if count.value > 0:). Comparing or operating on the State object itself raises a clearTypeError— this fixes silent misbehavior with numpy arrays and DataFrames.- New
gui.task(fn, on_done=, on_error=, busy=)— run slow work on a background thread; clicks and renders keep flowing, and state set inside the task drives live progress bars. - Errors raised in callbacks now show a danger toast in the window (previously invisible in packaged, windowed apps).
- DOM patcher fix: programmatic updates to inputs, checkboxes, textareas, and selects now render correctly after the user has interacted with them; the caret no longer jumps in
live=Trueinputs. gui.number_input()keeps.valuecurrent per keystroke but re-renders on commit (Enter, focus leave, spinner); an empty or invalid commit keeps the current value.- Maps rendered conditionally (e.g. inside a tab) now load Leaflet lazily instead of staying blank.
- Duplicate
key=values print a one-time warning;gui.progress(value, max=0)no longer raises. - New example
field_notes.py(load → run → save-on-exit); test suite expanded to 14 tests.
v0.6.0 — Improved map tile presets. Added gui.package() for one-call PyInstaller builds.
v0.5.0 — Added center=True to app window. Improved code structure in how-to page.
v0.4.0 — Added tabs. Fixed datetime-local input to display in 24-hour format.
v0.3.0 — Added notify and modal widgets.
v0.2.0 — Added max_height to gui.scroll(). Fixed multiselect change event.
v0.1.0 — First release. 27 widgets.
MIT License