How-to guide

Themes, layout, styling, and the callback pattern.

The core premise

Most scientists and engineers already have working Python code — functions that load data, run models, compute results. Guile is designed so that code stays completely untouched. You write a thin app layer on top: some state, a few callbacks that call your existing functions, and a ui() that displays the results. The distance between a working script and a shareable desktop app is typically twenty to thirty lines of glue code. None of those lines touch the code you already wrote.

How a guile app is structured

Every Guile application is built using five structural parts that always execute in the same sequence. Hover over any code segment to highlight its documentation card. You can view the live implementation by checking the howto_structure_example.py file inside the /examples directory of the GitHub repository.

import guile as gui
# ── 1. Your existing code ───────────
def to_celsius(f):
    return round((f - 32) * 5/9, 1)
def water_phase(c):
    if c <= 0: return "Solid"
    if c < 100: return "Liquid"
    return "Gas"
# ── 2. State ───────────────────────
fahrenheit = gui.state(32)
celsius = gui.state(None)
phase = gui.state("")
# ── 3. Callback ────────────────────
def analyze():
    c = to_celsius(fahrenheit.value)
    celsius.set(c)
    phase.set(water_phase(c))
# ── 4. Layout ──────────────────────
@gui.app("Water", width=360)
def ui():
    gui.slider("°F", min=20, max=230,
               value=fahrenheit,
               on_change=fahrenheit.set)
    gui.button("Convert", on_click=analyze)
    if celsius.value is not None:
        gui.text(f"{celsius.value} °C")
        gui.text(phase.value)
# ── 5. Run ─────────────────────────
gui.run()

1 · Your existing code

Ordinary Python. These are the functions you already have. Guile doesn't change them.

2 · State

Shared values that the UI and callbacks read and update. Declare them once at module level, outside every function. If created inside ui(), they would be recreated on every render.

3 · Callback

Responds to user actions. Reads state through .value, calls your existing functions, stores results with .set(), then Guile automatically re-renders the UI.

💡 In this particular example, the callback aggregates the computation of Celsius and the water phase. Use custom callbacks like this whenever multiple states or UI updates must happen together from a single user action.

4 · Layout

Reads .value to display state and passes callbacks by reference (for example, on_change=fahrenheit.set). Never call .set() directly inside ui(), or it will run on every render and create a render loop.

💡 You can also add layout elements using with: blocks, such as with gui.card(gap=10):

5 · Run

Opens the window and blocks until the user closes it. @gui.app() only defines the app — gui.run() launches it. Any code written after this line runs when the window closes — see When the window closes.

State variables and their methods

gui.state(x) creates a reactive variable with initial value x. Any Python type works — number, string, list, DataFrame, None.

Method What it does Where to use it
.valueRead the current valueui() and callbacks
.set(new)Replace the value, trigger re-renderCallbacks; or passed as on_change=x.set
.update(fn)Set based on current value: .set(fn(.value))Callbacks only
.toggle()Flip a boolean: True ↔ FalseCallbacks — for checkbox state

Built-in themes

Call gui.theme() as the first line inside your ui() function. It injects a <style> block that overrides the CSS variables before any widgets render.

Available presets

light
#6366f1
dark
#818cf8
neon
#22d3ee
rose
#f43f5e
forest
#16a34a
slate
#64748b
Example
@gui.app("My App", width=480, height=400)
def ui():
    gui.theme("forest")         # apply theme first

    with gui.card():
        gui.title("Hello")
        gui.button("Click me")
List all presets in Python
import guile as gui
print(list(gui.THEMES.keys()))
# ['light', 'dark', 'neon', 'rose', 'forest', 'slate']

Custom theme

Start from a preset and override individual values, or build a theme entirely from scratch.

Override part of a preset

gui.theme("forest", primary="#ff6b6b")    # forest colours, red accent
gui.theme("light", radius=2)              # light theme, sharp corners
gui.theme("dark",  surface="#242430")     # dark theme, custom card colour

Fully custom

gui.theme(
    primary = "#0ea5e9",   # sky blue accent
    bg      = "#f0f9ff",   # very light blue background
    surface = "#ffffff",   # white cards
    text    = "#0c1a2e",   # near-black text
    border  = "#c7dced",   # subtle blue-grey borders
    radius  = 8,           # px, also accepts "8px"
)

Every value you don't pass falls back to the preset (or light when you build from scratch). All the rest — hover shades, tints, and the danger / success / warning colours — are derived automatically. You never need to set them manually.

Switch themes at runtime

Because gui.theme() is called inside ui(), it re-runs on every render. Tie it to a state value for a live theme switcher:

theme_name = gui.state("light")

@gui.app("App", width=480, height=300)
def ui():
    gui.theme(theme_name.value)         # re-applied every render

    with gui.card():
        gui.select(
            ["light","dark","neon","rose","forest","slate"],
            "Theme", value=theme_name, key="theme-sel"
        )

CSS variables reference

These are the eight arguments gui.theme() accepts. All other colours are derived from them.

Argument CSS variable Default (light) What it controls
primary--primary#6366f1Buttons, sliders, focus rings, links
bg--bg#f2f2f7Window / page background
surface--surface#ffffffCard and input background
surface_2--surface-2#f5f5f7Hover rows, tints, secondary surfaces
text--text#1c1c1ePrimary text colour
text_2--text-2#6e6e73Secondary / muted text
border--border#d1d1d6Borders, dividers, input outlines
radius--r10pxBorder radius of cards and inputs

The full list of derived variables (set automatically) is in _template.py under Design tokens. You can still override any of them directly via gui.html("<style>:root{--border-focus:#ff0}...</style>").

Fill vs. align — the short version

Every container is a flexbox with two independent axes: a main axis (vertical for gui.col(), horizontal for gui.row()) and a cross axis (the other one). Three different props touch these axes, and mixing them up is the #1 source of "why won't this stretch" confusion:

gui.col() cross · align main · justify gui.row() cross · align main · justify

Squares are children. The main axis arrow is where justify spaces things out and where fill grows an element; the cross axis arrow is where align works. Rotate one 90° to get the other.

Prop Lives on Controls
align= the container how its children sit on the cross axis. align="stretch" (col's default) already stretches children to full width — it's the reason a single card usually fills a column with no extra work.
justify= the container how its children are spaced on the main axis.
fill= the element itself grows that element to fill remaining space on its parent's main axis. It never reaches down into children — gui.row(fill=True) grows the row, not what's inside it.

The case that trips people up: a single gui.card() as the only thing in a gui.row(). gui.row(fill=True) does nothing visible here because the row was probably already full width; gui.row(align="stretch") does nothing useful because a row's cross axis is vertical, not horizontal. What you actually want is to grow the card, so put fill=True on the card:

gui.card() — default keeps its natural width gui.card(fill=True) flex:1 — fills the row
# Card fills the row's width
with gui.row():
    with gui.card(fill=True):
        gui.title("I fill the row")

# Equivalent, if you need something style= can do that fill= can't
with gui.row():
    with gui.card(style="flex:1"):
        gui.title("Same result")
gui.col(), gui.row(), and gui.card() all accept fill= — put it on whichever one is actually supposed to grow. Every widget also accepts style=, so style="flex:1" works anywhere fill= isn't offered.
⚠️ center=True on @gui.app() switches the app's root container from align="stretch" to align="center" — so everything stops auto-filling the window width. It's meant for small, single-card apps only. If you're building a dashboard or anything with a real layout, leave center at its default (False).

Common layouts

Most apps need one of four shapes. Here's each one at its shortest.

fixed / default size fill=True (grows)

1. Single column (the default — no wrapping needed)

The app's root is already a full-width column with align="stretch". Just write widgets top to bottom; each card fills the window width on its own.

@gui.app("My app", width=900, height=600)
def ui():
    gui.title("Dashboard")
    with gui.card():
        gui.text("First section")
    with gui.card():
        gui.text("Second section")

2. Sidebar + main area

A fixed-width column next to a column that fills whatever's left. fill=True on the main column is the correct use of fill: it grows to consume the row's remaining space after the fixed sidebar.

with gui.row(fill=True, gap=0):
    with gui.col(style="width:220px;flex-shrink:0", padding=16):
        gui.title("Menu", size="lg")
    with gui.col(fill=True, padding=16):
        gui.title("Main content")

3. Three or four equal columns

Give fill=True to every column (or card) in the row — each one claims an equal share of the width.

with gui.row(gap=16):
    for label in ["Revenue", "Users", "Errors", "Uptime"]:
        with gui.card(fill=True):
            gui.text(label, muted=True, size="sm")
            gui.text("—", size="2xl", bold=True)

4. One level of nested columns

A row of columns, each stacking its own cards — the most nesting most apps ever need.

with gui.row(fill=True, gap=16):
    with gui.col(fill=True, gap=16):
        with gui.card(): gui.title("Top left")
        with gui.card(): gui.title("Bottom left")
    with gui.col(fill=True, gap=16):
        with gui.card(): gui.title("Top right")

Centering a small app

To drop a single card in the middle of the window, you can wrap everything in a full-height, centred column:

@gui.app("Converter", width=360, height=300)
def ui():
    with gui.col(align="center", justify="center", style="height:100vh"):
        with gui.card(gap=16):
            gui.title("Temperature converter")
            gui.slider("°F", value=fahrenheit,
                       on_change=fahrenheit.set, min=0, max=212)

For simple apps that wrapper is pure boilerplate, so @gui.app() takes a center=True shortcut that does the same thing — it fills the window and centres its contents on both axes:

@gui.app("Converter", width=360, height=300, center=True)
def ui():
    with gui.card(gap=16):
        gui.title("Temperature converter")
        gui.slider("°F", value=fahrenheit,
                   on_change=fahrenheit.set, min=0, max=212)
Reach for center=True on small, single-card apps. Once you have a real layout — a sidebar, a scrolling list, several stacked sections — drop it and arrange things with gui.col() / gui.row() as usual.

Unequal columns

Use gui.row() with style="flex:N" on child containers to get proportional widths. The number after flex: is the relative weight.

Two columns — 1/3 + 2/3

with gui.row(gap=16, align="flex-start"):
    with gui.col(style="flex:1"):      # takes 1 part
        with gui.card():
            gui.title("Sidebar")
    with gui.col(style="flex:2"):      # takes 2 parts
        with gui.card():
            gui.title("Main content")

Three columns — equal

with gui.row(gap=12, align="flex-start"):
    for label in ["Alpha", "Beta", "Gamma"]:
        with gui.col(style="flex:1"):
            with gui.card():
                gui.title(label)

Fixed + flexible

with gui.row(gap=12, align="flex-start"):
    with gui.col(style="width:200px;flex-shrink:0"):
        with gui.card():
            gui.title("Fixed 200px")
    with gui.col(style="flex:1"):
        with gui.card():
            gui.title("Fills the rest")

CSS grid

For more control — unequal rows, spanning cells — use display:grid in a style= string on any container.

Two-column grid with fractional widths

with gui.col(style="display:grid;grid-template-columns:1fr 2fr;gap:16px"):
    with gui.card():
        gui.title("Narrow (1fr)")
    with gui.card():
        gui.title("Wide (2fr)")

Three-column dashboard grid

with gui.col(style="display:grid;"
             "grid-template-columns:repeat(3,1fr);gap:12px"):
    for metric, value in [("Revenue","$42k"), ("Users","1,204"), ("Uptime","99.9%")]:
        with gui.card(padding=16):
            gui.text(metric, muted=True, size="sm")
            gui.title(value, size="2xl")

Mixed fixed and flexible columns

with gui.col(style="display:grid;"
             "grid-template-columns:240px 1fr 160px;gap:16px"):
    with gui.card(): gui.title("Nav")
    with gui.card(): gui.title("Content")
    with gui.card(): gui.title("Panel")

A cell that spans multiple columns

with gui.col(style="display:grid;grid-template-columns:1fr 1fr;gap:12px"):
    with gui.card(style="grid-column:1/-1"):   # span all columns
        gui.title("Full-width header")
    with gui.card(): gui.title("Left")
    with gui.card(): gui.title("Right")

Sidebar layout

A common pattern: fixed-width sidebar on the left, scrollable content on the right.

@gui.app("Dashboard", width=800, height=600)
def ui():
    with gui.row(gap=0, style="height:100vh"):

        # ── Sidebar ──────────────────────────────────
        with gui.col(
            padding=16, gap=8,
            style="width:200px;flex-shrink:0;"
                  "border-right:1px solid var(--border);"
                  "background:var(--surface)"
        ):
            gui.title("Menu", size="lg")
            gui.divider()
            gui.button("Dashboard", variant="ghost",
                       style="width:100%;justify-content:flex-start")
            gui.button("Reports",   variant="ghost",
                       style="width:100%;justify-content:flex-start")
            gui.button("Settings",  variant="ghost",
                       style="width:100%;justify-content:flex-start")

        # ── Main content ──────────────────────────────
        with gui.col(padding=24, gap=16, fill=True, scroll=True):
            gui.title("Dashboard")
            gui.text("Main content goes here.")

Inline style=

Every widget and container accepts a style= argument that appends raw CSS to the element. This is for one-off visual tweaks that don't warrant a theme change.

# Coloured text
gui.text("Warning", color="var(--warning)")
gui.text("Custom hex", color="#0ea5e9")

# Extra spacing on a specific card
with gui.card(style="margin-top:24px"):
    gui.title("Spaced out")

# Centred text inside a card
gui.title("Centred", style="text-align:center")

# Fixed-width number display
gui.text(count.value, size="2xl", bold=True, style="min-width:64px;text-align:center")

# Danger zone card
with gui.card(style="border:1px solid var(--danger-light)"):
    gui.text("Destructive action", color="var(--danger)", bold=True)
Layout props (gap, padding, align, justify) control where children sit. style= controls how the element itself looks. If you find yourself writing style="display:flex;gap:12px", use gui.row(gap=12) instead.

Inject a <style> block

For anything beyond what gui.theme() covers, use gui.html() to drop a raw <style> tag into the page. Because it's called inside ui(), it re-applies on every render — which is fine, the browser just overwrites the rule.

Custom animation

gui.html("""
<style>
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0.4; }
}
.live-dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--success); animation: pulse 1.5s infinite;
  display: inline-block; margin-right: 6px;
}
</style>
""")
gui.html('<span class="live-dot"></span> Live')

Custom scrollbar

gui.html("""
<style>
::-webkit-scrollbar       { width: 4px; }
::-webkit-scrollbar-thumb { background: var(--primary); border-radius: 2px; }
</style>
""")

Google Font

gui.html("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root { --font: 'Inter', sans-serif; }
body  { font-family: var(--font); }
</style>
""")

Override individual CSS variables

The full design system uses CSS custom properties. You can override any of them without touching the others. All variables are defined in _template.py.

Change just the border radius

# Sharp, modern look
gui.html("<style>:root { --r: 2px; --r-sm: 2px; --r-lg: 4px; }</style>")

# Very rounded, soft look
gui.html("<style>:root { --r: 20px; --r-sm: 12px; --r-lg: 28px; }</style>")

Change just the shadow

# Flat design — no shadow
gui.html("<style>:root { --shadow: none; --shadow-sm: none; }</style>")

# Heavy shadow
gui.html("<style>:root { --shadow: 0 8px 40px rgba(0,0,0,.18); }</style>")

All available variables

/* Colours */
--primary        accent colour (buttons, sliders, focus rings)
--primary-h      darker hover shade of primary (auto-derived by gui.theme)
--primary-light  tint/ghost background for primary (auto-derived)
--bg             window background
--surface        card and input background
--surface-2      slightly tinted surface (hover rows, secondary panels)
--text           primary text
--text-2         secondary / muted text
--border         default border colour
--border-focus   border colour when an input has focus
--danger         red for danger buttons and error text
--danger-light   tint for danger backgrounds
--success        green for success badges
--success-light  tint for success backgrounds
--warning        amber for warning text
--warning-light  tint for warning backgrounds

/* Shape */
--r              default border radius   (cards, inputs)
--r-sm           small border radius     (buttons, badges)
--r-lg           large border radius     (modals, large cards)

/* Depth */
--shadow         default card shadow
--shadow-sm      subtle shadow
--shadow-lg      prominent shadow

/* Typography */
--mono           monospace font stack

The reactive state cycle

Everything in guile revolves around three concepts. Understanding them means you can build any app.

The three methods

gui.state(initial) — creates the state object. The initial value is your default. Any type works: number, string, list, dict, DataFrame, None.

.value — reads the current value. Call this inside ui() to display it, or inside callbacks to compute with it. It is the only way to read a state — comparisons and arithmetic included: if count.value > 0:, price.value * qty.value. This works identically whether the state holds a number, a numpy array, or a DataFrame; comparing the state object itself raises a TypeError that points at the line to fix.

.set(new_value) — replaces the value and triggers a re-render. Always called from a callback, never directly inside ui().

There is also .update(fn) — a functional shorthand for .set(fn(.value)), useful when the new value depends on the current one:

count = gui.state(0)

count.set(10)                       # replace
count.update(lambda x: x + 1)      # increment
count.update(lambda x: x * 2)      # double
count.toggle()                      # bool flip (True ↔ False)

A minimal example showing all three

import guile as gui

# 1. State — declare the default at the top
score = gui.state(0)

# 2. Callbacks — call .set() or .update() here
def add_point():
    score.update(lambda x: x + 1)

def reset():
    score.set(0)

# 3. ui() — read .value, never call .set() here
@gui.app("Score", width=320, height=200)
def ui():
    with gui.col(align="center", justify="center", style="height:100vh"):
        gui.title(score.value, size="3xl")
        with gui.row(gap=8):
            gui.button("+1", on_click=add_point)
            gui.button("Reset", variant="ghost", on_click=reset)

# 4. Open the window
gui.run()
The rule: never call state.set(x) as a bare statement inside ui() — it fires on every render and causes an infinite loop. Passing it as a reference via on_change=state.set is fine — it only fires when the user interacts with the widget.

The canonical guile pattern

Every guile app follows the same structure, regardless of complexity. Understanding this structure once means you can build any app.

There are five parts, always in this order:

  1. Your functions — pure Python, no guile inside
  2. State variables — one per piece of data the app needs to remember
  3. Callbacks — call your functions, store the results in state
  4. ui() — reads state, builds the layout, nothing else
  5. gui.run() — the last line: opens the window and blocks until it closes
import guile as gui


# ── 1. Your function — pure Python, no guile ─────────────────────────
#       Works identically in a script, notebook, or test.
def to_celsius(f):
    return (f - 32) * 5 / 9


# ── 2. State — everything the app needs to remember ──────────────────
fahrenheit = gui.state(32.0)
celsius    = gui.state(None)


# ── 3. Callback — calls your function, stores the result ─────────────
#       This is the only place where your code and guile meet.
def convert():
    celsius.set(round(to_celsius(fahrenheit.value), 2))


# ── 4. ui() — reads state, builds layout, no logic inside ────────────
@gui.app("Temperature converter", width=380, height=300)
def ui():
    with gui.col(padding=24, gap=14):
        gui.title("°F  →  °C")
        gui.number_input("Fahrenheit", value=fahrenheit,
                         on_change=fahrenheit.set, step=0.5)
        gui.button("Convert", on_click=convert)
        if celsius.value is not None:
            gui.text(f"{celsius.value} °C", size="2xl", bold=True)


# ── 5. Open the window — blocks until the user closes it ─────────────
gui.run()

Why on_change=fahrenheit.set?

When the user commits a new value — pressing Enter, clicking away, or clicking a spinner arrow — on_change fires and calls fahrenheit.set(new_value). This stores the value in state so that convert() can read it later via fahrenheit.value. Without on_change= the widget would display correctly but the state would never update, and convert() would always see the original value.

State declarations are your defaults

The gui.state() lines at the top of your script are the single source of truth for starting values.

The value=state argument on a widget may look redundant, but it serves a different purpose — it binds the widget to the state object so they stay in sync. Without it, the widget manages its own internal value and your callbacks have no way to read it.

# Pattern A — widget owns its state (simple cases)
# No pre-declared state needed. Read the value back via f.value inside ui().
f = gui.number_input("°F", value=32.0)

# Pattern B — external state, widget bound to it (most real apps)
# Callbacks defined outside ui() can read fahrenheit.value and call fahrenheit.set().
fahrenheit = gui.state(32.0)
gui.number_input("°F", value=fahrenheit, on_change=fahrenheit.set)

Use Pattern A when the value is only needed inside ui(). Use Pattern B whenever a callback needs to read or update the value — which is most of the time. The 32.0 in gui.state(32.0) sets the starting value; value=fahrenheit connects the widget to that state going forward. They are not saying the same thing twice.

Always use value= and on_change= together. Writing on_change=state.set without value=state is a common mistake. It works on the first render, but as soon as any state changes and ui() re-runs, the widget is recreated without reading from state — so it resets to its initial appearance, silently discarding whatever the user selected. The symptom is a widget that appears to lose its value or selection on every interaction.

# ✗ Incomplete — widget resets on every re-render
gui.multiselect(["A","B","C"], on_change=variables.set, key="vars")

# ✓ Correct — widget reads state on render, writes state on interaction
gui.multiselect(["A","B","C"], value=variables,
                on_change=variables.set, key="vars")

To reset to the default from a button, call station.set("Manhattan") in a callback.

What is key= for?

key= is a stable identifier for the DOM — it tells guile which element is which between re-renders, so inputs keep their focus and don't reset unexpectedly. It has no connection to the state variable name. Writing key="fahrenheit" when you also have fahrenheit = gui.state(...) is purely a readability convention. You could write key="abc" and the app would behave identically. key= can be omitted entirely for simple apps — guile assigns one automatically.

Coming from Streamlit

In Streamlit, buttons and inputs return values you check inline:

# Streamlit habit
run_btn = st.button("Convert")
if run_btn:
    do_something()

In guile, widgets don't return True/False — they fire a callback. The equivalent is:

# Guile equivalent
gui.button("Convert", on_click=do_something)

That's the main habit to unlearn. Everything else — keeping your functions pure, separating logic from display — works the same way in both frameworks.

Keep your functions pure

The real payoff of the callback pattern: your processing functions stay completely unaware of guile. They take inputs and return outputs, nothing more. This means they work identically in Jupyter, in tests, in scripts — the guile app is just one consumer of the same functions.

import guile as gui

# ── Pure functions — no guile imports, no .set() calls ───────────────
# These could live in a separate module. They work the same in a
# Jupyter notebook, a unit test, or a command-line script.

def to_celsius(f: float) -> float:
    return (f - 32) * 5 / 9

def to_fahrenheit(c: float) -> float:
    return c * 9 / 5 + 32

def feels_like(temp_c: float, wind_kmh: float) -> float:
    """Wind chill (°C) — valid for temp ≤ 10 °C and wind ≥ 4.8 km/h."""
    return (13.12 + 0.6215 * temp_c
            - 11.37 * wind_kmh**0.16
            + 0.3965 * temp_c * wind_kmh**0.16)


# ── State ─────────────────────────────────────────────────────────────
temp_f  = gui.state(32.0)
wind    = gui.state(20.0)
results = gui.state(None)   # None = not yet calculated


# ── Callbacks — the thin bridge between pure functions and guile ──────
def calculate():
    f   = temp_f.value
    w   = wind.value
    c   = to_celsius(f)
    wc  = feels_like(c, w) if c <= 10 and w >= 4.8 else None
    results.set({
        "celsius":    round(c, 1),
        "feels_like": round(wc, 1) if wc is not None else "N/A",
    })


# ── UI ────────────────────────────────────────────────────────────────
@gui.app("Weather calculator", width=420, height=360)
def ui():
    with gui.col(padding=24, gap=14):
        gui.title("Weather calculator")

        with gui.card(gap=12):
            gui.number_input("Temperature (°F)", value=temp_f,
                             on_change=temp_f.set, step=0.5)
            gui.number_input("Wind speed (km/h)", value=wind,
                             on_change=wind.set, step=1.0, min=0)
            gui.button("Calculate", on_click=calculate)

        if results.value is not None:
            r = results.value
            with gui.card(gap=8):
                gui.text(f"Temperature:  {r['celsius']} °C",   bold=True)
                gui.text(f"Wind chill:   {r['feels_like']} °C", bold=True)


gui.run()

Notice that to_celsius(), to_fahrenheit(), and feels_like() contain zero guile references. You could copy them into a notebook and use them immediately. The calculate() callback is the only place where guile state and your own logic meet.

Passing arguments to callbacks

Callbacks registered with on_click= take no arguments. When you need to pass a value — the most common case is a list of items each with their own button — use a default-argument lambda to capture the current value at the time the widget is created.

The loop capture problem

cities = ["Nairobi", "Tokyo", "Paris"]

# ✗ Wrong — all three lambdas hold a reference to `city`, not its value.
#   By the time a button is clicked the loop is done and `city` is
#   always "Paris" (the last value). Every button does the same thing.
for city in cities:
    gui.button(f"Show {city}", on_click=lambda: print(city))

# ✓ Correct — c=city is evaluated immediately at lambda creation time.
#   Each lambda gets its own private copy of the current value.
for city in cities:
    gui.button(f"Show {city}", on_click=lambda c=city: print(c))

# ✗ Also wrong — `i` is a required parameter; guile calls on_click
#   with zero arguments, so this crashes on first click.
for city in cities:
    gui.button(f"Show {city}", on_click=lambda i: print(city))

Real example: per-row delete button

import guile as gui

rows = gui.state([
    {"id": 1, "name": "Alice", "score": 92},
    {"id": 2, "name": "Bob",   "score": 78},
    {"id": 3, "name": "Carol", "score": 85},
])

def delete(row_id):
    rows.set([r for r in rows.value if r["id"] != row_id])

@gui.app("Gradebook", width=480, height=360)
def ui():
    with gui.col(padding=20, gap=12):
        gui.title("Gradebook")

        with gui.card(gap=8):
            for row in rows.value:
                with gui.row(justify="space-between", align="center",
                             key=str(row["id"])):
                    gui.text(row["name"], bold=True)
                    gui.badge(str(row["score"]), variant="primary")
                    gui.button("✕", variant="ghost", size="sm",
                               # capture row["id"] now, not at click time
                               on_click=lambda i=row["id"]: delete(i),
                               key=f"del-{row['id']}")

gui.run()

The key=f"del-{row['id']}" on the button is also important here — it gives each button a stable DOM ID so the patcher can tell them apart when the list changes length.

Tabs

gui.tabs() renders a tab strip and returns the active label as a plain string. It manages its own internal state — no gui.state() declaration at module level is needed. The active panel is a plain Python if/elif block on that string.

import guile as gui

@gui.app("Dashboard", width=560, height=420)
def ui():
    with gui.col(padding=20, gap=14):
        gui.title("Dashboard")

        # gui.tabs() renders the strip and returns the active label.
        # key= is required so the active tab survives re-renders.
        tab = gui.tabs(["Overview", "Data", "Info"], key="main-tabs")

        # Each panel is a plain if/elif block — only the active panel
        # is rendered; the others don't exist in the DOM at all.
        if tab == "Overview":
            with gui.card(gap=8):
                gui.text("Summary statistics here.")

        elif tab == "Data":
            with gui.card(padding=0, style="overflow-y:auto;max-height:280px"):
                gui.table(records)

        elif tab == "Info":
            with gui.card(gap=6):
                gui.text("Built with guile.", muted=True, size="sm")

gui.run()
Unlike a hidden-div approach, only the active panel is rendered at all. Inactive panels cost nothing — no DOM nodes, no layout, no memory.

Programmatic tab switching

When a callback needs to switch the active tab — for example, jumping to the Data tab automatically after a file loads — bind to an external State using value= and on_change=, the same pattern every other input widget uses:

import guile as gui

active = gui.state("Overview")   # module level — controls the active tab

def load_file(path):
    records.set(load(path))
    active.set("Data")           # jump to Data tab on load

@gui.app("Dashboard", width=560, height=420)
def ui():
    with gui.col(padding=20, gap=14):
        with gui.row(gap=8, align="center"):
            gui.file_picker("Load CSV", on_change=load_file, key="fp")

        # Pass the external State via value= and on_change= so the
        # strip stays in sync with both user clicks and active.set().
        gui.tabs(["Overview", "Data", "Info"],
                 value=active, on_change=active.set, key="main-tabs")

        if active.value == "Overview":
            ...
        elif active.value == "Data":
            gui.table(records.value)

gui.run()

When using an external state, read active.value in the panel conditions (not the return value of gui.tabs(), which is only the initial value in that render).

Structuring larger UIs — ambient attachment

Sooner or later a ui() grows past one screen and four or five indent levels. The fix is the most ordinary tool in Python: extract a function. It works because of a property called ambient attachment — every widget you create attaches itself to whichever container is currently open. A helper function needs no special signature, returns nothing, and threads nothing: call it inside a with block and its widgets land in that container.

# A plain function that emits widgets. No special signature,
# no children lists to build and return — just widget calls.
def note_row(note):
    with gui.row(justify="space-between", align="center",
                 key=f"row-{note['id']}"):
        gui.checkbox(note["text"], value=note["done"],
                     on_change=lambda _, i=note["id"]: toggle(i),
                     key=f"cb-{note['id']}")
        gui.button("✕", variant="ghost", size="sm",
                   on_click=lambda i=note["id"]: delete(i),
                   key=f"del-{note['id']}")

def header():
    with gui.row(justify="space-between", align="center"):
        gui.title("Notes")
        gui.badge(f"{len(notes.value)} items")

@gui.app("Notes", width=460, height=560)
def ui():
    with gui.col(padding=20, gap=14):
        with gui.card(gap=12):
            header()                     # widgets land inside the card
            gui.divider()
            for n in notes.value:
                note_row(n)              # one row per note, same card

gui.run()

Each helper reads like a small ui() of its own, and the main ui() collapses into a table of contents for the window. This is the intended way to keep indentation shallow — cut the tree at natural seams (a header, a row, a settings panel, a tab's contents) and name the pieces.

Three rules keep helpers predictable:

  • Call them from inside ui() (directly or nested) — they only make sense while a render is building the tree. Calling one from a callback does nothing useful.
  • Pass data in as arguments, like any function. Helpers may read state.value freely; like ui() itself, they should never call .set().
  • Keys still matter — a helper called inside a loop must put a unique key= on its stateful widgets, exactly as if the code were written inline (it effectively is).
A helper can also open its own container (like note_row above opening a gui.row()) — the container attaches to the current parent, and the helper's widgets attach inside it. Composition nests to any depth.

When the window closes — code after gui.run()

A guile script has three phases. @gui.app() only defines the app; gui.run() opens the window and blocks while it is in use; and everything written after gui.run() executes the moment the user closes the window. Your gui.state() variables still hold whatever the user left in them, so the closing code can read them directly.

# ── 1. Before the window: load the previous session ──────────────
notes = gui.state(load_notes())          # runs at import

@gui.app("Field notes", width=560, height=560)
def ui():
    ...                                  # the app itself

# ── 2. Open the window; blocks until the user closes it ──────────
gui.run()

# ── 3. After the window closes: persist and summarise ────────────
save_notes(notes.value)
print(f"{len(notes.value)} notes saved")

This turns "closing the app" into a meaningful event. Common uses:

  • Save on exit — write the session to CSV/JSON so no "Save" button discipline is needed. Pair it with a load at the top of the script and your app gets persistence in four lines.
  • Cleanup — close a serial port to an instrument, disconnect from a database, stop a logger, delete temp files.
  • Interactive stage in a pipeline — the window becomes one step of a longer script: the user picks or QCs data interactively, closes the window, and batch processing continues with their choices.
  • Exit summary — print where results were written, or sys.exit(1) if a required step was skipped, so the app can participate in shell scripts.

Because the decorator no longer launches anything, your script is an ordinary Python module: importing it does not open a window, which makes app files testable and tool-friendly.

Forgot gui.run()? The script exits after printing a reminder — an app was defined but never launched. Just add gui.run() as the last line.

See examples/field_notes.py for a complete working example of the load → run → save pattern.

Dev mode — hot reload

While you are building a UI, relaunching the window on every edit is the biggest friction in the workflow. Dev mode removes it: pass dev=True to gui.run() and guile watches your script file. Every time you save, the app reloads inside the open window — same position, same size, no flicker of a closing and reopening window.

@gui.app("My App", width=560, height=480)
def ui():
    ...

gui.run(dev=True)     # save the file → the window updates

What a reload does, precisely:

  • Fresh start — your module-level code re-runs and every gui.state() resets to its initial value, exactly as if you had re-run the script. There is no state-migration magic: what you see after a save is what a cold start of the new code shows.
  • Errors don't kill the session — a save with a syntax error or a crash shows the traceback in the window while the previous working UI keeps running. Fix the file, save again.
  • Code after gui.run() stays quiet — execution stops at the gui.run() line on reloads, so a save-on-exit block doesn't fire every time you press Ctrl+S. It runs once, when you finally close the window.
  • Window settings follow — changing the title or size in @gui.app() is applied to the open window on reload.
Dev mode is for building. For normal use — and always for gui.package() builds — call plain gui.run().

An interactive map

gui.leaflet() embeds a Leaflet map that behaves like any other widget: it reads state, and its callbacks write state. Two things make maps different from a button. The map keeps the user's pan and zoom across re-renders, and it needs a stable key= to do so — always pass one.

import guile as gui

stations = [("Manhattan", 39.19, -96.58), ("Salina", 38.84, -97.61)]
picked   = gui.state(None)            # name of the clicked station
view     = gui.state("satellite")     # bound to a select → live base-map switch

@gui.app("Stations", width=820, height=560)
def ui():
    with gui.col(padding=20, gap=12):
        gui.select(["street", "satellite", "hybrid", "terrain"], "Base map",
                   value=view, on_change=view.set, key="view")

        gui.leaflet(
            center=(39.0, -97.0), zoom=8, height=420,
            tiles=view.value,                          # preset name or XYZ URL
            markers=[gui.Marker((lat, lon), tooltip=name,
                                on_click=lambda n=name: picked.set(n))
                     for name, lat, lon in stations],
            on_click=lambda lat, lon: picked.set(f"{lat:.3f}, {lon:.3f}"),
            key="map",                                 # required
        )
        gui.text(picked.value or "Click a marker or the map", muted=True, size="sm")

gui.run()
Tiles come from public servers, so maps need an internet connection. on_click(lat, lon) fires on the background; a marker's own on_click fires instead when a marker is hit. Bind tiles= to a state, as above, to switch imagery without rebuilding the map.

Draping imagery: image and tile overlays

Overlay layers go in layers=[...] and draw in list order between the base tiles and the markers. There are two ways to drape a raster, and the choice is purely about size.

Small rasters — gui.ImageOverlay

A PNG or JPG plus the lat/lon box it covers. guile embeds the file in the page, so this is right for a field map or a plot mosaic of a few MB, and wrong for a 2 GB drone orthomosaic. guile never reads GeoTIFFs — you export the PNG and its corner coordinates from your own pipeline.

opacity = gui.state(0.7)

@gui.app("NDVI", width=860, height=600)
def ui():
    with gui.col(padding=20, gap=12):
        gui.slider("Opacity", min=0, max=1, step=0.05,
                   value=opacity, on_change=opacity.set, key="op")
        gui.leaflet(
            center=(39.19, -96.5875), zoom=15, height=480, tiles="satellite",
            layers=[
                gui.ImageOverlay("ndvi.png",
                                 bounds=((39.180, -96.600), (39.200, -96.575)),  # (S,W),(N,E)
                                 opacity=opacity.value),
            ],
            key="map")

gui.run()

Large rasters (drone mosaics) — gui.TileOverlay

Don't embed a big image: tile it once, serve the folder locally, and let Leaflet stream only the tiles in view. guile stays out of the way — it just takes a tile URL. Tiling is a one-off command:

gdal2tiles.py --xyz -z 14-21 mosaic.tif tiles/

Serving can happen inside the app, on a background thread, with nothing but the standard library:

import functools, threading
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler

def serve_folder(folder, port=8765):
    """Serve `folder` on localhost from a daemon thread; returns the base URL."""
    handler = functools.partial(SimpleHTTPRequestHandler, directory=folder)
    srv = ThreadingHTTPServer(("127.0.0.1", port), handler)
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    return f"http://127.0.0.1:{port}"

TILES  = serve_folder("tiles")                     # once, at module level
BOUNDS = ((39.180, -96.600), (39.200, -96.575))    # extent of the mosaic

@gui.app("Orthomosaic", width=900, height=640)
def ui():
    gui.leaflet(center=(39.19, -96.5875), zoom=16, height=560, tiles="satellite",
                layers=[gui.TileOverlay(TILES + "/{z}/{x}/{y}.png",
                                        max_zoom=21, bounds=BOUNDS, opacity=0.9)],
                key="map")

gui.run()
max_zoom is the deepest level you tiled to; zooming past it upscales the last level instead of blanking the layer. bounds stops requests for tiles outside the mosaic, so the server log isn't full of 404s. If you ran gdal2tiles without --xyz, add tms=True.

GeoJSON: style, labels, click and hover

gui.GeoJSON draws vector features from a dict, a JSON string, or a file path. Each feature can carry a permanent label, show a popup, and report clicks and hovers back to Python with its properties dict — which is all you need to drive a sidebar from the map.

selected = gui.state(None)     # properties of the clicked plot
hovered  = gui.state(None)

@gui.app("Plots", width=900, height=640)
def ui():
    with gui.row(gap=14, padding=20):
        with gui.col(fill=True):
            gui.leaflet(
                center=(39.19, -96.5875), zoom=15, height=560, tiles="satellite",
                layers=[
                    # A wide dark stroke under a thin bright one reads as a glow,
                    # because layers stack in list order.
                    gui.GeoJSON("plots.geojson", color="#0a2a0a", weight=7,
                                opacity=0.6, fill_opacity=0),
                    gui.GeoJSON("plots.geojson", color="#39ff14", weight=2,
                                fill_opacity=0.05,
                                label=lambda p: f"{p['cover_pct']:.0f}%",   # pinned on the shape
                                popup="plot_id",                            # on click
                                on_click=selected.set,
                                on_hover=hovered.set),                      # props, or None on leave
                ],
                key="map")

        with gui.col(gap=8, style="width:220px;flex-shrink:0"):
            with gui.card(gap=6):
                gui.text("Selected", bold=True, size="sm")
                p = selected.value
                gui.text(p["plot_id"] if p else "—")
                gui.text(f"{p['cover_pct']:.1f} % cover" if p else "", muted=True, size="sm")
            if hovered.value:
                gui.badge(hovered.value["plot_id"], variant="neutral")

gui.run()
label= and popup= take either a property name or a callable props → str. on_hover fires a render on every enter and leave, so keep that callback to a single .set(). To restyle the hovered feature, put it in a separate layer rather than rebuilding the whole file each time.

Drawing and editing areas

The draw toolbar (draw=[...]) lets users outline areas in-app. The pattern that makes this work well is one list of areas owned by Python: shapes drawn with the toolbar are appended to it, the toolbar's edit and delete tools update it, clicking selects from it, and the same list is what you export. Pass the list back as drawn= and guile rebuilds the editable layer from it — there is no separate JS copy, so nothing gets out of sync and nothing is drawn twice.

Each entry is exactly what on_shape hands you, plus an id you assign:

import guile as gui, itertools

areas    = gui.state([])       # [{"id", "type", "coords"}, ...]
selected = gui.state(None)     # id of the selected area
_ids     = itertools.count(1)

# The three toolbar events keep the list current ───────────────────────
def add_area(shape_type, coords):                # a shape was drawn
    areas.update(lambda a: a + [{"id": f"A{next(_ids)}",
                                 "type": shape_type, "coords": coords}])

def edit_area(area_id, shape_type, coords):      # edit toolbar → Save
    areas.update(lambda a: [dict(x, coords=coords) if x["id"] == area_id else x
                            for x in a])

def delete_area(area_id):                        # delete toolbar → Save
    areas.update(lambda a: [x for x in a if x["id"] != area_id])

@gui.app("Areas", width=940, height=660)
def ui():
    # Decorate for display: a label on every shape, a highlight on the
    # selected one. The per-shape "style" overrides draw_style.
    shapes = [dict(a, label=a["id"],
                   style={"color": "#ffdd00", "weight": 4, "fill_opacity": 0.25}
                         if a["id"] == selected.value else None)
              for a in areas.value]

    with gui.row(gap=14, padding=20):
        with gui.col(fill=True):
            gui.leaflet(
                center=(39.19, -96.5875), zoom=15, height=560, tiles="satellite",
                draw=["polygon", "rectangle", "circle"],
                drawn=shapes,
                draw_style={"color": "#39ff14", "weight": 3, "fill_opacity": 0.05},
                on_shape=add_area,
                on_shape_edit=edit_area,
                on_shape_delete=delete_area,
                on_shape_click=selected.set,
                key="map")

        with gui.col(gap=6, style="width:220px;flex-shrink:0"):
            gui.text("Areas", bold=True, size="sm")
            for a in areas.value:
                gui.button(f"{a['id']} · {a['type']}", variant="ghost", size="sm",
                           on_click=lambda i=a["id"]: selected.set(i),
                           key=f"sel-{a['id']}")

gui.run()

Plot boundaries from a file, into the same list

Because drawn= entries are plain dicts, a GeoJSON file loads into the same list — and is then editable with the toolbar like anything drawn by hand. Note that GeoJSON stores [lon, lat] while guile's shapes use [lat, lon]:

import json

def areas_from_geojson(path):
    out = []
    for i, f in enumerate(json.load(open(path))["features"]):
        g = f["geometry"]
        if g["type"] == "Polygon":
            ring = g["coordinates"][0][:-1]                  # drop the closing point
            out.append({"id": f["properties"].get("id", f"P{i + 1}"),
                        "type": "polygon",
                        "coords": [[lat, lon] for lon, lat in ring]})
    return out

def to_geojson(area_list):                                   # the inverse, for export
    feats = []
    for a in area_list:
        if a["type"] in ("polygon", "rectangle"):
            ring = [[lon, lat] for lat, lon in a["coords"]]
            feats.append({"type": "Feature", "properties": {"id": a["id"]},
                          "geometry": {"type": "Polygon",
                                       "coordinates": [ring + [ring[0]]]}})
    return {"type": "FeatureCollection", "features": feats}

# in a file-picker callback:   areas.set(areas_from_geojson(path))
# in an export callback:       json.dump(to_geojson(areas.value), open("plots.geojson", "w"))
Ids are yours. Give every entry a stable id; an entry without one gets its list index, which shifts when you delete. on_shape must append. A freshly drawn shape stays on screen by itself until the list next changes, then only what is in your list survives. Circles arrive as {"lat", "lng", "radius"} with the radius in metres — buffer them in a projected CRS on the analysis side. drawn= also works with draw=False for a read-only view.

Build an executable

gui.package() wraps PyInstaller so a colleague without Python can run your app. It is a build step: put it in a separate build.py, never inside ui().

# build.py — run once from the environment your app runs in
import guile as gui

gui.package("my_app.py", name="Field Mapper",
            icon="app.ico",                       # .ico on Windows, .icns on macOS
            add_data=[("presets.yaml", ".")])     # runtime data files only; code is found automatically

By default this produces a folder (package_mode="onedir"): the executable next to its libraries, under dist/Field Mapper/. Pass package_mode="onefile" for a single self-contained executable instead. The folder is the better default for two reasons that matter when you hand the app to someone else: it starts faster, and it trips far fewer antivirus and SmartScreen heuristics — a one-file build unpacks itself into a temp directory at launch, which is exactly what malware droppers do, so scanners are suspicious of it.

Build from a clean environment

This is the step that decides whether packaging is painless or a struggle. PyInstaller bundles the environment it runs in, and it follows optional imports: from a full Anaconda install, a trivial app comes out at several gigabytes (torch, tensorflow, dask and friends ride along) or fails to build at all. gui.package() prints a note when it detects that situation. The cure is a small virtual environment holding only what the app needs:

python -m venv build-env
build-env\Scripts\pip install guile pyinstaller pandas   # plus whatever the app imports
build-env\Scripts\python build.py

On macOS and Linux the paths are build-env/bin/pip and build-env/bin/python. Keep the environment around; rebuilding is then a single command. A venv made from Anaconda's Python works too: guile puts Anaconda's DLL folder on the build's PATH, which PyInstaller otherwise cannot find. One gotcha: install your own packages normally, not with pip install -e. Editable installs work through an import hook that PyInstaller's analysis cannot follow, and the built app fails at launch with ModuleNotFoundError.

While a build misbehaves, pass windowed=False to keep a console open so tracebacks are visible. PyInstaller must be installed in the same environment as the app (pip install pyinstaller, or install_missing=True). On Windows the built app relies on the WebView2 runtime, which ships with Windows 10/11.

Avoiding install warnings

The bullet-proof answer is code signing (a Windows certificate, an Apple Developer ID plus notarization). Short of that, these are the steps that work in most situations, roughly in order of impact:

  • Ship the folder inside an installer, not as a loose exe. On Windows, Inno Setup (free) turns the onedir folder into a normal Setup.exe that installs to Program Files and adds a Start-menu entry. On macOS, a .dmg. Users trust an installer far more than a bare executable, and it removes the "what do I click?" problem of a folder.
  • Keep the name and publisher stable. SmartScreen reputation is earned: once enough people have run the same unsigned installer without incident, the warning goes away on its own. Renaming the exe every release starts that clock over.
  • Tell macOS users about right-click → Open. Gatekeeper blocks unsigned apps by default and there is no reputation path. Right-clicking the app and choosing Open, once, whitelists it. Put that sentence in your README.
  • Signing is cheaper than it sounds, later. A standard Windows code-signing certificate costs on the order of a hundred dollars a year and removes most friction; an EV certificate clears SmartScreen immediately. Apple's program is $99/year. Both are an upgrade you can add without changing the build.