makepad/splash.md
2026-07-10 15:34:38 +02:00

26 KiB
Raw Permalink Blame History

Splash DSL Guide

General Makepad Splash DSL patterns that apply to ANY app body.

Key Rules

  • let/fn declarations must be at the top, before any widget. The body starts with declarations, then the root widget.
  • Every container MUST have height: Fit — most common failure mode. Inside a fixed-height parent, height: Fill is fine.
  • Root container MUST use width: Fill — never a fixed pixel width. The app renders inside a parent container that provides the width.
  • ui object is built-in; do NOT declare it with :=
  • for loops render widgets at build time only — array changes do NOT re-render. Use set_text() for dynamic content.
  • Functions with for loops return empty strings when called from on_click — inline string building instead
  • as int type casting produces NaN — use string display + set_text() only
  • Colons inside string arguments work correctly"Time: 2:30" is fine
  • Every TextInput must have a fixed numeric height (e.g. 34)
  • on_render works for dynamic lists. Store results in an array, render rows inside a ScrollYView{on_render: || {...}}, and call ui.<view>.render() after the array changes. Give the scroll view a fixed height (e.g. height: 360) — a Fill height inside a Fit parent collapses to zero.
  • A := id is only a field of its DIRECT parent. In Card{ a := Image{} inner := View{ b := Label{} } }, reach the label as Card{inner.b.text: "x"}Card{b.text: ...} fails with "field b not found in type-check". Name every wrapper you need to address through.
  • AI Chat runsplash blocks run in a sandbox with net.http_request, http_resource(...), parse_json(), and url_encode() enabled. Use these for HTTP-backed mini apps.
  • Array / argument / object-body items may be separated by whitespace, newlines, OR commas — all work. Adjacent values like {a:1} {b:2} (or one per line) are TWO separate items. Two object literals next to each other are NEVER "object inherits from object" — to extend/merge an object use base += {field: val} or a named prototype Proto{...}, not bare {...}{...}. (So let days = [{...} {...} {...}] correctly yields a 3-element array; a stray earlier bug collapsed comma-less object arrays to length 1.)

Widget Availability

Available: View, RoundedView, SolidView, ScrollYView, Label, TextInput, LinkLabel, Button, ButtonFlat, ButtonFlatter, Slider, CheckBox, CheckBoxFlat, RadioButton, RadioButtonFlat, ToggleFlat, DropDown, TabBar, Tab, PopupMenu, ScrollBar, ScrollBars, LoadingSpinner, Hr, Vr, Icon, Image

NOT available (silently fail): Stack, Divider, ProgressBar, IconButton, ToggleButton, ListView, Grid, ColorPicker, ScrollPair

Wanted Not Available Use Instead
Divider line Divider Hr{height:1 width:Fill}
Progress bar ProgressBar Slider{value:0.65 is_read_only:true}
Tabbed UI TabBar/Tab ButtonFlat rows (TabBar renders zero-size)

Styling Gotchas

draw_bg.border_radius takes a float, not an Inset:

// ✅
draw_bg.border_radius: 16.0

// ❌ parse error — silently breaks layout
draw_bg.border_radius: Inset{top:0 bottom:16 left:0 right:0}

#x prefix for hex colors containing 'e': When a hex color contains the letter e adjacent to digits (like #1e1e2e), use #x to avoid parser ambiguity. Without #x, Makepad's parser may misinterpret digits following 'e' as an exponent:

#x2ecc71     // ✅ contains 'e' next to digits, use #x
#x1e1e2e     // ✅ contains 'e' next to digits, use #x
#ff4444      // ✅ no 'e' issue, plain # works
#00ff00      // ✅ no 'e' issue

Default text color is white: All text widgets (Label, Button, etc.) default to #fff. For light/white backgrounds, you MUST explicitly set draw_text.color to a dark color on every text element:

RoundedView{draw_bg.color:#f5f5f5 height:Fit
  Label{text:"Visible!" draw_text.color:#x222}
}

Label styling shorthand: Both syntaxes work:

Label{text:"Hello" color:#x2ecc71 font_size:16}              // bare props work
Label{text:"Hello" draw_text.color:#x2ecc71 draw_text.text_style.font_size:16}  // draw_text also works

new_batch: true for text visibility: Required on any container with show_bg: true that contains text children. Without it, text renders behind the background (invisible):

// ✅ Correct
RoundedView{width:Fill height:Fit new_batch:true show_bg:true draw_bg.color:#x334
  Label{text:"Visible" draw_text.color:#fff}
}
// ❌ Text may be invisible (draws behind bg)
RoundedView{width:Fill height:Fit show_bg:true draw_bg.color:#x334
  Label{text:"Invisible!" draw_text.color:#fff}
}

Widget Reliability Reference

Widget Capabilities Best For
ButtonFlat Click → variable write, set_text(), text() All interactive controls
Button Click → variable write, set_text(), text() Standard buttons
Label set_text() updates visible text, text() reads back Display values, status, dynamic list display
TextInput type_text fills first input, text() reads value, set_text() writes Text entry
Image src: http_resource(url), set_src(http_resource(url)), fit: ImageFit.CropToFill/Smallest Thumbnails and remote images
Hr Full-width line divider Visual separation
RoundedView Container with rounded corners App root, groups

Splash VM Variable Scope

let variables DO persist across click events in the same app session:

  • Counter: let count = 0; count = count + 1 correctly produces 1, 2, 3, 4 across consecutive clicks
  • Toggle: let toggled = false; toggled = !toggled persists true state across separate button clicks

However, widget checked state on RadioButton, ToggleFlat, CheckBox does NOT persist because internal post-processing discards the on_click scope context.

Widget Visual State Variable Persistence
RadioButton checked: true in widget tree Lost — internal post-processing discards on_click scope
ToggleFlat checked visual renders Same limitation
CheckBox / CheckBoxFlat checked: true in widget tree Same limitation

Use ButtonFlat with manual toggle for persistent boolean state:

let toggled = false
ButtonFlat{text:"Toggle" on_click:||{toggled = !toggled; ui.display.set_text("" + toggled)}}
ButtonFlat{text:"Show" on_click:||{ui.display.set_text("Current: " + toggled)}}

Patterns

Struct Arrays & Array Operations

The Splash VM supports arrays of structs with .push(), .remove(), .len(), and .retain(). Read fields via array[index].field, update with array[index] += {field: val}:

let items = [
    {text: "Task 1" tag: "work" done: false}
    {text: "Task 2" tag: "personal" done: false}
]
let max_items = 5

fn add_item(text){
    let clean = ("" + text).trim()
    if clean == "" { return }
    if items.len() >= max_items { return }
    items.push({text: clean tag: "" done: false})
    sync_all()
}

fn toggle_item(index){
    if index >= items.len() { return }
    items[index] += {done: !items[index].done}
    sync_all()
}

fn remove_item(index){
    if index >= items.len() { return }
    items.remove(index)
    sync_all()
}

fn clear_flagged(){
    items.retain(|it| !it.done)
    sync_all()
}

Component / Template Pattern

Define reusable templates with let and instantiate with property overrides:

let ItemRow = RoundedView{
    width: Fill height: Fit
    padding: Inset{top: 8 bottom: 8 left: 12 right: 12}
    flow: Right spacing: 10
    align: Align{y: 0.5}
    new_batch: true
    draw_bg.color: #x2a2a3a
    draw_bg.border_radius: 8.0
    label := Label{text: "item" width: Fill draw_text.color: #xddd}
    action := ButtonFlatter{text: "Do" width: 56 height: 28}
    remove := ButtonFlatter{text: "X" width: 56 height: 28}
}

row_0 := ItemRow{
    label.text: "First item"
    action.on_click: || do_something(0)
    remove.on_click: || remove_item(0)
}

Override syntax: <child-name>.<property>: <value> — every segment in the path must use :=.

Pre-allocated Fixed Slots

for loops render at build-time only — array changes don't add/remove widgets. Pre-allocate a fixed number of rows and update via sync functions:

let items = [{text: "Item 1"} {text: "Item 2"}]

fn sync_row_0(){
    if 0 < items.len() {
        ui.row_0.label.set_text(items[0].text)
    } else {
        ui.row_0.label.set_text("Empty slot")
    }
}
fn sync_rows(){
    sync_row_0()
    sync_row_1()
    sync_status()
}

Pre-allocate 5 rows for a 5-item max list. Call sync_rows() after every mutation.

Numeric State Pattern

let count = 0
RoundedView{width:Fill height:Fit flow:Down spacing:10 padding:16 new_batch:true
  display := Label{text:"0" draw_text.color:#x44cc88 draw_text.text_style.font_size:32}
  View{flow:Right spacing:12 align:Align{x:0.5 y:0.5}
    ButtonFlat{text:"-" on_click:||{count -= 1; ui.display.set_text(count + "")}}
    ButtonFlat{text:"Reset" on_click:||{count = 0; ui.display.set_text("0")}}
    ButtonFlat{text:"+" on_click:||{count += 1; ui.display.set_text(count + "")}}
  }
}

Use count + "" to convert numbers to strings.

Dynamic List Display

let task_count = 0
inp := TextInput{height:34}
lst := Label{text:"" font_size:14.0}
ButtonFlat{text:"Add" on_click:||{
  let t = ui.inp.text()
  if t != "" {
    task_count = task_count + 1
    let cur = ui.lst.text()
    if cur == " " { cur = "" }
    if cur != "" { cur = cur + "\n" }
    ui.lst.set_text(cur + task_count + ". " + t)
    ui.inp.set_text("")
  }
}}

TextInput with on_return

input := TextInput{
    width: Fill height: 34
    empty_text: "Enter something"
    on_return: |text| add_item(text)
}
Button{text: "Add" width: 64 height: 34 on_click: || add_item(ui.input.text())}

For image-search mini apps in AI Chat, use the built-in network sandbox. Fetch text/JSON with net.http_request, parse with .parse_json(), and display thumbnails with Image{src: http_resource(url)}. Render changing result arrays through ScrollYView.on_render and refresh with ui.results.render().

let image_results = []

let ResultCard = glass.Card{
    width: Fill height: Fit
    flow: Right spacing: 10 padding: 10
    thumb := Image{width: 92 height: 72 fit: ImageFit.CropToFill}
    // Name every wrapper you need to reach through: a `:=` id is only a field of its
    // DIRECT parent. Under an unnamed `View{...}`, `title`/`source` would be unreachable
    // from `ResultCard{...}` ("field title not found in type-check").
    info := View{width: Fill height: Fit flow: Down spacing: 3
        title := glass.Body{text: "" width: Fill}
        source := glass.Caption{text: ""}
    }
}

fn fetch(url, extra_headers){
    let p = promise()
    let h = {"User-Agent": "Mozilla/5.0"}
    if extra_headers != nil { for k, v in extra_headers { h[k] = v } }
    net.http_request(net.HttpRequest{url: url method: net.HttpMethod.GET headers: h}) do net.HttpEvents{
        on_response: |res| p.resolve(res)
        on_error: |_err| p.resolve(nil)
    }
    p
}

fn search_images(query){
    let clean = ("" + query).trim()
    if clean == "" { return }
    ui.status.set_text("Searching...")
    let q = clean.url_encode()
    let page = fetch("https://duckduckgo.com/?q=" + q + "&iax=images&ia=images", nil).await()
    if page == nil { ui.status.set_text("Search failed"); return }
    let vqd = ""
    let parts = page.body.to_string().split("vqd=\"")
    if parts.len() > 1 { vqd = parts[1].split("\"")[0] }
    if vqd == "" { ui.status.set_text("No image token"); return }
    let res = fetch("https://duckduckgo.com/i.js?l=us-en&o=json&q=" + q + "&vqd=" + vqd + "&f=,,,,,&p=1", {"Referer": "https://duckduckgo.com/"}).await()
    if res == nil { ui.status.set_text("No response"); return }
    let data = res.body.to_string().parse_json()
    image_results.clear()
    if data != nil && data.results != nil {
        for img in data.results {
            image_results.push({title: img.title source: img.source thumbnail: img.thumbnail})
        }
    }
    ui.status.set_text("" + image_results.len() + " images")
    ui.results.render()
}

View{width: Fill height: Fit flow: Down spacing: 12 padding: 16
    glass.H1{text: "Picture Search"}
    glass.Card{width: Fill height: Fit flow: Right spacing: 8
        query := glass.TextInput{width: Fill height: 38 empty_text: "Search images..." on_return: |text| search_images(text)}
        glass.GlassButtonProminent{text: "Search" height: 38 on_click: || search_images(ui.query.text())}
    }
    status := glass.Caption{text: "Type a query"}
    results := ScrollYView{width: Fill height: 360 flow: Down spacing: 8 on_render: || {
        if image_results.len() == 0 {
            glass.Card{glass.Body{text: "Results will appear here."}}
        }
        else for item in image_results {
            ResultCard{thumb.src: http_resource(item.thumbnail) info.title.text: item.title info.source.text: item.source}
        }
    }}
}

Sequential Digit Input

Perform arithmetic by accumulating digits:

let a = 0
ButtonFlat{text:"7" on_click:||{a = a*10+7; ui.display.set_text("" + a)}}

Naming Children: := vs :

Use := for addressable children, : for static children:

label := Label{text:"default"}    // ✅ addressable via ui.label, overridable
label: Label{text:"default"}     // ❌ static — NOT addressable

Every path segment in an override must use :=:

// ✅ Correct
let Item = View{flow:Right
  texts := View{flow:Down
    label := Label{text:"default"}
  }
}
Item{texts.label.text:"new text"}  // works!

// ❌ Wrong — anonymous parent blocks override
let Item = View{flow:Right
  View{flow:Down
    label := Label{text:"default"}  // UNREACHABLE
  }
}
Item{label.text:"new text"}  // silent failure

Styling Reference

Property Example Effect
draw_bg.color #x1e1e2e Background color (hex)
draw_bg.border_radius 10.0 Rounded corners
draw_text.color #xddd Text color
draw_text.text_style.font_size 14 Font size (float)
padding Inset{top:8 bottom:8 left:12 right:12} Inner padding
spacing 10 Gap between children in flow
align Align{x:0.5 y:0.5} Center alignment
new_batch true Required for text visibility on show_bg:true containers
empty_text "Type here..." Placeholder for TextInput

Not in Build

Widget Behavior
TabBar / Tab width=0, height=0 — no visible output

Liquid Glass widgets (glass.*)

Makepad ships an Apple-style "Liquid Glass" widget kit under the glass. namespace. It is already in scope inside every runsplash block (no import needed). All names below are addressed as glass.<Name>.

HARD REQUIREMENT — use glass widgets

The chat this renders in is a glass-styled app. Unless the user explicitly asks for a plain/non-glass look, you MUST build every app out of glass.* widgets on a detailed backdrop:

  • Surfaces/cards → glass.Card / glass.Panel / glass.Group (NOT plain View/RoundedView with a flat fill).
  • Buttons → glass.GlassButton / glass.GlassButtonProminent — these are the REAL liquid-glass buttons that lens and refract the backdrop (the proper "gauss glass" look). They are fully clickable (on_click works). Prefer these for every button. glass.Button / glass.Chip are flatter, non-refracting fallbacks — only use them if you specifically want a flat chip.
  • Toggles → glass.GlassRadio (a liquid-glass switch, on_click works).
  • Text → glass.H1 / glass.H2 / glass.Body / glass.Caption / glass.OptionLabel.
  • Inputs/toggles/sliders → glass.TextInput / glass.SearchField / glass.Toggle / glass.Slider / glass.GlassRadio.
  • And the app MUST open with a detailed colourful backdrop (see the golden rule below) so the glass is actually visible.

An app that uses plain Button/View/Label on a flat dark fill is WRONG for this chat — it will look like a generic dark app with no glass.

THE GOLDEN RULE: be TRANSPARENT — the chat already paints the backdrop

The chat this renders in already paints a rich, detailed, colourful backdrop behind your app. Your job is to float transparent glass on top of it so the glass refracts that backdrop. Glass lenses whatever is painted behind it; if you paint an opaque background, the glass has nothing to bend and the whole thing looks like a flat dark app.

Therefore your app MUST be transparent:

  • NEVER put show_bg: true with an opaque draw_bg.color on your root or on any container. No draw_bg.color: #x05070e, no opaque RoundedView, no full-screen background layer, no Svg backdrop. The host backdrop is already there — let it show through.
  • Build surfaces only from glass.* widgets (glass.Card, glass.Panel, glass.Group) — these are already translucent and refract the backdrop. Plain View/RoundedView with a fill will hide it.
  • The root is just a transparent layout View (flow: Down, spacing, padding). Nothing else.
View{
  width: Fill height: Fit
  flow: Down spacing: 14 padding: 20
  // No background here — transparent on purpose so the chat backdrop shows through.
  glass.H1{text: "Glass Kit"}
  glass.Body{text: "A liquid-glass showcase."}
  glass.Card{
    glass.H2{text: "Liquid Glass"}
    glass.Body{text: "Floats above the chat backdrop, refracting it."}
  }
  View{width:Fill height:Fit flow:Right spacing:10
    glass.GlassButtonProminent{text:"Continue"}
    glass.GlassButton{text:"Cancel"}
  }
}

If you catch yourself writing draw_bg.color on a container, delete it and use a glass.Card/glass.Panel instead.

Surfaces / containers

Widget Use for Notes
glass.Panel Main glass card / sheet Fill width, Fit height, flow Down, padding 16. Also aliased GlassPanel.
glass.Card Content card More tint than Panel — good default card.
glass.Group Grouped controls Tighter padding than Panel.
glass.ClearPanel Clearer floating surface Stronger lensing, less tint.
glass.NavBar Top/bottom bar height 58, flow Right.
glass.TabBar Floating tab bar height 66, flow Right. Put glass.Caption/Label children in it. (This is the glass TabBar — NOT the broken core TabBar.)
glass.List List container translucent panel, flow Down.
glass.ListRow One row in a list height 54, flow Right, vertically centered.
glass.Badge Small status pill green-tinted capsule.

All surfaces already set width/height/flow/padding, so you can drop children straight in. They do NOT need new_batch (they manage their own batching), but the outer dark root DOES (new_batch: true + show_bg: true).

Every glass surface already carries a built-in soft drop shadow (kept deliberately subtle by default). Do not add your own large shadows, and don't crank shadow_radius/shadow_offset — an oversized shadow reads as "fat" and gets clipped by tight parent bounds. If you ever need to tune it, keep it small (e.g. draw_bg +: { shadow_radius: 9.0 shadow_offset: vec2(0.0, 3.0) }).

Buttons (reliable — use these for clicks)

glass.Button, glass.ProminentButton, glass.IconButton, and glass.Chip are flat SDF glass buttons built on ButtonFlat. They are the reliable interactive choice and support on_click, set_text(), text() exactly like Button:

Row{flow:Right spacing:10
  glass.GlassButtonProminent{text:"Add" on_click:|| add_item(ui.inp.text())}
  glass.GlassButton{text:"Clear" on_click:|| clear_all()}
  glass.Chip{text:"All"}
}

Premium lensing controls (decorative-first)

These are custom widgets that draw a real refracting lens over the scene. They look spectacular but are best used as showcase / display controls — prefer the flat glass.Button family when you need a reliable on_click handler in a generated app.

Widget What it is Default size
glass.GlassButton Refracting pill button with a label Fit × 44, has text:
glass.GlassButtonProminent Blue-tinted refracting button same
glass.GlassRadio Liquid toggle switch (slides + "gloops") 70 × 34
glass.GlassSlider Refracting slider knob Fill × 32, value: 0.4
ToggleRow{flow:Right spacing:16 align:Align{y:0.5}
  glass.GlassRadio{}
  glass.OptionLabel{text:"Wi-Fi"}
}
glass.GlassButton{text:"Continue"}

Lensing widgets sample a blurred snapshot of the window. Inside the chat they fall back to a solid frosted color when no snapshot is available, so they still render — just less dramatically than in a full-screen app like example-glass.

Inputs

Widget Use for
glass.TextInput Glass text field (height 38)
glass.SearchField Same, "Search" placeholder
glass.Slider Labeled glass slider (height 40)
glass.Toggle Glass on/off toggle
glass.RadioButton Glass pill radio

Typography (pre-styled white-on-dark labels)

Use these instead of restyling Label by hand — they already set the right color and weight for dark glass UIs:

glass.H1 (30), glass.H2 (18), glass.OptionLabel (16), glass.Body (13, Fill width), glass.ButtonLabel (13), glass.Caption (11, muted blue — good for SECTION HEADERS).

Full minimal glass app skeleton

fn noop(){}

RoundedView{
  width: Fill height: Fit
  flow: Down spacing: 14 padding: 20
  new_batch: true show_bg: true
  draw_bg.color: #x05070e
  draw_bg.border_radius: 18.0

  glass.H1{text: "Settings"}
  glass.Caption{text: "NETWORK"}
  glass.Card{
    View{width:Fill height:Fit flow:Right spacing:16 align:Align{y:0.5}
      glass.GlassRadio{}
      glass.OptionLabel{text:"Wi-Fi"}
    }
  }
  glass.Caption{text: "ACTIONS"}
  Row{flow:Right spacing:10
    glass.GlassButtonProminent{text:"Save" on_click:|| noop()}
    glass.GlassButton{text:"Cancel" on_click:|| noop()}
  }
}

Interactivity that ACTUALLY works (read this for calculators / counters / forms)

The ui object is only injected into the scope of an on_click / on_return / on_change closure. It is NOT visible inside a separately-declared fn. So:

  • Do all ui.<id>.set_text(...) / ui.<id>.text() calls inline inside the closure.
  • Do NOT call a helper fn that itself references ui — it will fail with "variable ui not found".
  • Shared state lives in top-level let variables (these persist across clicks). Mutate them inline.
  • Arithmetic must stay numeric — never store a number as a string and add it (string + concatenates: "5"+"3"=="53"). Build numbers with acc = acc*10 + digit. Display with "" + number. Never use as int (produces NaN).
  • The widget you update with set_text must be reachable as ui.<id> — give it a := id and keep it directly addressable.

Working glass calculator (numeric, inline ui, copy this shape)

let acc = 0.0       // number currently being entered
let stored = 0.0    // left-hand operand
let op = ""

View{ width: Fill height: Fit flow: Down spacing: 12 padding: 18
  // Transparent root — the chat backdrop shows through and the glass refracts it.
  glass.Card{ display := glass.H1{text:"0" width: Fill align: Align{x:1.0}} }
    View{width:Fill height:Fit flow:Right spacing:8
      glass.GlassButton{text:"7" width:Fill height:52 on_click:|| { acc = acc*10.0 + 7.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"8" width:Fill height:52 on_click:|| { acc = acc*10.0 + 8.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"9" width:Fill height:52 on_click:|| { acc = acc*10.0 + 9.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"+" width:Fill height:52 on_click:|| { stored = acc; acc = 0.0; op = "+" }}
    }
    View{width:Fill height:Fit flow:Right spacing:8
      glass.GlassButton{text:"4" width:Fill height:52 on_click:|| { acc = acc*10.0 + 4.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"5" width:Fill height:52 on_click:|| { acc = acc*10.0 + 5.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"6" width:Fill height:52 on_click:|| { acc = acc*10.0 + 6.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"-" width:Fill height:52 on_click:|| { stored = acc; acc = 0.0; op = "-" }}
    }
    View{width:Fill height:Fit flow:Right spacing:8
      glass.GlassButton{text:"1" width:Fill height:52 on_click:|| { acc = acc*10.0 + 1.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"2" width:Fill height:52 on_click:|| { acc = acc*10.0 + 2.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"3" width:Fill height:52 on_click:|| { acc = acc*10.0 + 3.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"*" width:Fill height:52 on_click:|| { stored = acc; acc = 0.0; op = "*" }}
    }
    View{width:Fill height:Fit flow:Right spacing:8
      glass.GlassButton{text:"0" width:Fill height:52 on_click:|| { acc = acc*10.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"C" width:Fill height:52 on_click:|| { acc = 0.0; stored = 0.0; op = ""; ui.display.set_text("0") }}
      glass.GlassButtonProminent{text:"=" width:Fill height:52 on_click:|| {
        let r = stored
        if op == "+" { r = stored + acc }
        if op == "-" { r = stored - acc }
        if op == "*" { r = stored * acc }
        acc = r; op = ""
        ui.display.set_text("" + r)
      }}
    }
  }

Every digit/operator handler is inline (no helper fn), so ui.display is in scope, and all arithmetic is numeric. This pattern computes correctly AND looks glassy.

Glass gotchas

  • Transparent root is mandatory — the chat paints the backdrop; if you paint your own opaque background the glass refracts nothing and looks like a flat dark app. No draw_bg.color / show_bg on your root or containers.
  • For buttons use the lensing glass.GlassButton / glass.GlassButtonProminent (they refract the backdrop — the real gauss-glass look — and are fully clickable). For toggles use glass.GlassRadio. The flat glass.Button/glass.Chip don't refract; only use them when you explicitly want a flat chip.
  • Don't restyle glass surface colors — they're tuned. Just set layout (width/height/spacing/padding) and drop content in.
  • Hex colors with e next to a digit still need the #x prefix (e.g. #x05070e).