
Makepad 2.0 Splash
- 45 installs
- 745 repo stars
- Updated April 7, 2026
- zhanghandong/makepad-skills
Helps with ai & agent building tasks during AI-assisted development.
About
makepad-2.0-splash is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- makepad-2.0-splash
- AI & Agent Building
- AI-coding skill
Makepad 2.0 Splash by the numbers
- 45 all-time installs (skills.sh)
- +1 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #7,734 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/zhanghandong/makepad-skills --skill makepad-2.0-splashAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 745 |
| Last updated | April 7, 2026 |
| Repository | zhanghandong/makepad-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Makepad 2.0 Splash Scripting Language
Splash is Makepad 2.0's core runtime UI scripting language, released February 12, 2026. It replaces the old compile-time live_design! macro system with a runtime script_mod! macro that enables hot reload, streaming evaluation, and AI-first code generation.
Core Concepts
Script Structure
Every Splash script starts with a use import and is embedded in Rust via the script_mod!{} macro:
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
// let bindings, functions, state, and UI definitions go here
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(800, 600)
body +: {
// UI content
}
}
}
}
}Syntax Rules
- No commas between properties -- whitespace-delimited
- No semicolons -- cleaner syntax optimized for LLM generation
- Property assignment:
key: value - Dot-path shorthand:
draw_bg.color: #f00(equivalent todraw_bg +: { color: #f00 }) - Merge operator:
key +: { ... }extends parent without replacing - Named children:
name := Widget{...}(addressable, overridable per-instance) - Let bindings:
let MyTemplate = Widget{...}(local scope, must be defined before use) - Rust binding:
#(Struct::register_widget(vm))connects Splash to Rust structs - Debug logging:
~expressionlogs value during evaluation
State Management
State is managed via the mod.state object and reactive on_render callbacks:
// Define state
let state = { counter: 0 }
mod.state = state
// Reactive rendering -- re-runs when .render() is called
main_view := View{
on_render: ||{
Label{ text: "Count: " + state.counter }
}
}Event Handling
Events are handled both inline in Splash and from Rust:
// Inline event handlers in Splash
add_button := Button{
text: "Add"
on_click: ||{
add_todo(ui.todo_input.text(), "")
ui.todo_input.set_text("")
}
}
// TextInput return key
todo_input := TextInput{
on_return: || ui.add_button.on_click()
}
// Startup event
on_startup: ||{
ui.main_view.render()
}From Rust, use script_eval! to execute Splash code:
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.button(cx, ids!(increment_button)).clicked(actions) {
script_eval!(cx, {
mod.state.counter += 1
ui.main_view.render()
});
}
}
}Functions
fn tag_color(tag) {
if tag == "dev" theme.color_highlight
else if tag == "design" theme.color_selection_focus
else theme.color_highlight
}
fn add_todo(text, tag) {
todos.push({text: text, tag: tag, done: false})
ui.todo_list.render()
}Control Flow
// If/else
if todos.len() == 0
EmptyState{}
else for i, todo in todos {
TodoItem{ label.text: todo.text }
}
// For loops
for i, item in array {
Label{ text: item.name }
}
// While
while condition { ... }HTTP Requests
let req = net.HttpRequest{
url: "https://api.example.com/data"
method: net.HttpMethod.GET
headers: {"User-Agent": "MakepadApp/1.0"}
}
net.http_request(req) do net.HttpEvents{
on_response: |res| {
let text = res.body.to_string()
let json = res.body.parse_json()
}
on_error: |e| { /* handle error */ }
}Streaming responses use is_streaming: true with on_stream and on_complete callbacks.
HTML Parsing
let doc = html_string.parse_html()
doc.query("p") // all <p> elements
doc.query("#main") // by id
doc.query("p.bold") // by class
doc.query("div > p") // direct children
doc.query("p[0]").text // text content
doc.query("a@href") // attribute valueStreaming Evaluation
Splash's parser supports checkpoint-based incremental parsing, designed for AI/LLM streaming code generation:
// Rust API for streaming evaluation
vm.eval_with_append_source(script_mod, &code, NIL.into())This enables real-time UI updates as code is generated token-by-token, without requiring a complete script before evaluation.
Hot Reload & Script Mod Tracking
Splash scripts support hot reload via the --hot flag. The VM tracks each script_mod! block with a unique ScriptModKey (file, line, column):
// Internal: ScriptModKey uniquely identifies a script_mod! block
ScriptModKey { file: "src/app.rs", line: 5, col: 1 }
// Runtime substitution via overrides
ScriptCode::script_mod_overrides // HashMap of ScriptModKey -> updated sourceHow hot reload works: 1. File watcher (makepad_live_reload_core) detects source file changes 2. script_mod! blocks are extracted from Rust source (handles raw strings, comments, char literals) 3. Rust placeholder counts (#(...)) are tracked -- adding/removing placeholders requires full rebuild 4. Validated script mods are applied via script_mod_overrides 5. IP-to-location mapping provides source maps for error reporting (fallback to nearest token for synthetic opcodes)
ScriptSource variants:
ScriptSource::Mod-- Standard module evaluation (startup)ScriptSource::Streaming-- Incremental streaming evaluation (AI/LLM)
Critical Layout Rules
1. Always set `height: Fit` on containers -- default height: Fill causes invisible UI (0px height) 2. Use `width: Fill` on the root container -- never fixed pixel width at the top level 3. Set `new_batch: true` on any View with show_bg: true that contains text children 4. Use `:=` for named children in templates -- without it, text overrides fail silently 5. `draw_bg.border_radius` takes a float, not an Inset -- draw_bg.border_radius: 16.0 6. Use styled Views (RoundedView, SolidView) instead of raw View{show_bg: true}
Widget Reference
Core containers: View, SolidView, RoundedView, RectView, RoundedShadowView, CircleView, GradientXView, GradientYView, ScrollXYView, ScrollXView, ScrollYView
Text: Label, H1-H4, P, TextBox, TextInput, LinkLabel, Markdown, Html
Controls: Button, ButtonFlat, ButtonFlatter, CheckBox, Toggle, RadioButton, Slider, DropDown
Layout: Splitter, FoldHeader, Hr, Vr, Filler
Lists: PortalList, FlatList
Navigation: Modal, Tooltip, PopupNotification, SlidePanel, ExpandablePanel, PageFlip, StackNavigation
Dock: Dock, DockSplitter, DockTabs, DockTab
Media: Image, Icon, LoadingSpinner, Vector, MathView, MapView
Canvas: Rendering Splash from Claude Code
Makepad Canvas (tools/canvas/) is a standalone app that renders Splash code received via HTTP/WS. Used by Claude Code for visual output.
HTTP API (recommended for sending Splash)
PORT=$(cat /tmp/makepad-canvas.port)
# Full render
curl -s -X POST "http://127.0.0.1:$PORT/splash" -d 'View{width:Fill height:Fit Label{text:"Hello"}}'
# Streaming render
curl -s -X POST "http://127.0.0.1:$PORT/splash/stream" # begin
curl -s -X POST "http://127.0.0.1:$PORT/splash/stream" -d 'View{...' # append
curl -s -X POST "http://127.0.0.1:$PORT/splash/end" # end
# Clear
curl -s -X POST "http://127.0.0.1:$PORT/clear"WS Event Listening (for receiving click events)
# Long-lived WS connection receives button click events as JSON
mkfifo /tmp/ws_fifo; (sleep 99999 > /tmp/ws_fifo &)
websocat ws://127.0.0.1:$PORT < /tmp/ws_fifo > /tmp/canvas_events &
# Events arrive as: {"event":"click","widget":"btn_name"}Interactive Buttons
Use name := Button{...} to create clickable buttons. The name is sent in click events:
View{width:Fill height:Fit flow:Right spacing:12
btn_save := Button{text:"Save"}
btn_cancel := Button{text:"Cancel"}
}When clicked: {"event":"click","widget":"btn_save"}
Vector Animations in Splash
// Pulsing dot (loop_:true = indefinite, NOT "indefinite"!)
Vector{width:16 height:16
Circle{cx:8 cy:8 r:6 fill:#x44ddaa opacity:Tween{from:0.3 to:1.0 dur:1.5 loop_:true}}
}
// Moving dot with color change
Vector{width:Fill height:30
Path{d:"M 20 15 L 400 15" stroke:#x222244 stroke_width:1.}
Circle{cx:Tween{from:20 to:400 dur:3.0 loop_:true} cy:15 r:4 fill:Tween{from:#x44ddaa to:#xffaa44 dur:3.0 loop_:true}}
}Canvas Splash Syntax (CRITICAL -- differs from script_mod!)
When generating Splash for Canvas HTTP rendering (POST /splash), use these EXACT patterns. Canvas Splash syntax differs from script_mod! macro context in several critical ways:
1. Properties use dot-path inline, NOT nested blocks:
// WRONG -- nested block syntax does not render backgrounds
RoundedView{height: Fit draw_bg: { color: #x1a1a2e border_radius: 8.0 } }
// CORRECT -- dot-path inline
RoundedView{width: Fill height: Fit draw_bg.color: #x1a1a2e draw_bg.radius: 8.}2. Border radius is `draw_bg.radius`, NOT `draw_bg.border_radius`:
// WRONG
draw_bg.border_radius: 8.0
// CORRECT
draw_bg.radius: 8.3. Padding uses explicit `Inset{}` type with trailing-dot floats:
// WRONG -- bare number or nested block
padding: 20
padding: { top: 20 bottom: 20 }
// CORRECT
padding: Inset{left: 20. right: 20. top: 16. bottom: 16.}4. Align uses explicit `Align{}` type:
// WRONG
align: { y: 0.5 }
// CORRECT
align: Align{y: 0.5}
align: Center5. Float values use trailing dot:
// WRONG // CORRECT
8.0 8.
16.0 16.
0.5 0.56. `SolidView` and `RoundedView` do NOT need `show_bg: true` or `new_batch: true` -- they render backgrounds out of the box.
7. Use `--data-binary` for multi-line Splash via curl -- plain -d strips newlines.
Proven Canvas Dashboard Template
Source: tools/canvas/examples/token-dashboard.splash
SolidView{width: Fill height: Fit draw_bg.color: #x0c0c18 flow: Down padding: Inset{left: 32. right: 32. top: 24. bottom: 24.} spacing: 20
// Title
Label{text: "Dashboard Title" draw_text.color: #xeeeeff draw_text.text_style.font_size: 20}
// Card row
View{width: Fill height: Fit flow: Right spacing: 16
RoundedView{width: Fill height: Fit draw_bg.color: #x161628 draw_bg.radius: 8. padding: Inset{left: 20. right: 20. top: 16. bottom: 16.} flow: Down spacing: 6
Label{text: "Metric Name" draw_text.color: #x888899 draw_text.text_style.font_size: 10}
Label{text: "Value" draw_text.color: #xcc66ff draw_text.text_style.font_size: 28}
}
}
// Horizontal bar chart row
View{width: Fill height: Fit flow: Right spacing: 8 align: Align{y: 0.5}
Label{text: "Label" width: 100 draw_text.color: #xbbbbbb draw_text.text_style.font_size: 10}
RoundedView{width: 200 height: 12 draw_bg.color: #xf44336 draw_bg.radius: 2.}
Label{text: "200" draw_text.color: #x777777 draw_text.text_style.font_size: 10}
}
// Vertical bar chart (bars bottom-aligned)
View{width: Fill height: 130 flow: Right spacing: 2 align: Align{y: 1.0}
View{width: Fill height: Fit flow: Down align: Center spacing: 4
RoundedView{width: 14 height: 80 draw_bg.color: #x7733cc draw_bg.radius: 2.}
Label{text: "Mon" draw_text.color: #x444455 draw_text.text_style.font_size: 7}
}
}
}Canvas Tips
- HTTP for splash, WS for events — most reliable pattern
- Vector shape properties:
fill,stroke,stroke_width— NOTdraw_bg.* - CJK/Chinese text: Supported in both body text and code blocks. CodeEditor uses double-width columns for CJK characters (fixed 2026-03-23). Theme
font_codeincludes LXGWWenKai as Chinese fallback. - Large POST bodies: Canvas supports up to 512KB HTTP body
- Pub/sub broadcast: All connected WS clients receive click events
- Health check: Use
GET /ping(NOT/health) - Monitor toggle:
/tmp/canvas-monitor-activeflag file controls statusline auto-refresh. Canvas sidebar has a toggle button.
Compiled vs Eval: Two Fundamentally Different Widget Creation Paths
Understanding this distinction is CRITICAL for debugging any Splash eval issue:
Compiled path (script_mod!, examples, studio):
parse → compile → execute → create_widget
→ script_apply(FULL type default object with all vec + map entries)
→ on_after_apply(value = full object) ✓ ← ScriptHook runs
→ Templates registered, #[live] fields populated from type default
Eval path (POST /splash, Splash.set_text):
parse → eval → create_widget_from_prototype
→ script_apply(EVAL VALUE ONLY, e.g. {body: "..."})
→ on_after_apply: SKIPPED (is_eval() guard in TextFlow/Markdown)
→ Templates NOT registered, #[live] fields use defaults (not type default)| Aspect | Compiled (script_mod!) | Eval (set_text) |
|---|---|---|
| Widget creation | Full Rust + ScriptVm init | ScriptVm eval string only |
on_after_apply | Called with full type default | NOT called |
| Type default vec (named children) | Fully inherited | May lose entries in proto copy |
| Type default map (properties) | Applied to #[live] fields | Only eval value applied |
| Template registration | Via ScriptHook during apply | Must be done lazily in draw_walk |
ScrollYView | Works | Renders blank — use View |
Fix pattern for eval-path issues: Implement lazy initialization in draw_walk that detects missing state and looks up the type default via cx.with_vm(|vm| vm.bx.heap.type_default_for_object(...)). This is how Markdown's code_block template inheritance was fixed.
Splash Eval Pitfalls (CRITICAL -- learned 2026-03-23)
These issues only affect widgets created via Splash runtime eval (POST /splash, Splash.set_text()), NOT compiled script_mod! widgets:
1. `ScrollYView` does NOT work in Splash eval -- renders blank
// WRONG -- Splash eval renders nothing
ScrollYView{ width: Fill height: Fill flow: Down
Label{ text: "invisible" }
}
// CORRECT -- use View, Canvas wraps it in its own ScrollYView
View{ width: Fill height: Fit flow: Down
Label{ text: "visible" }
}2. `on_after_apply` / `ScriptHook` is NOT called for eval-created widgets
When Splash eval creates a widget (e.g. Markdown{body: "..."}), the ScriptHook::on_after_apply callback is never invoked. Any initialization that depends on on_after_apply must have a fallback path (e.g. lazy init in draw_walk).
3. Type default properties are NOT fully inherited in eval path
When set_type_default() overrides a widget type with extra properties (like use_code_block_widget: true) or named children (like code_block := View{...}), instances created via Splash eval may not inherit these. The #[live] fields only get values from the eval apply value (e.g. {body: "..."}), not the full type default.
Workaround: Check for missing state in draw_walk and look up the type default via vm.bx.heap.type_default_for_object().
4. Type default vec entries (named children) may not copy to instances
Even though copy_type_default_vec exists, the vec entries from set_type_default() may lose entries between registration and instance creation. The auto-proto vec copy in new_with_proto_impl copies from the direct proto, which may have fewer entries than the type_default.
5. Nested `Markdown{}` inside Splash works but needs type default templates
When Canvas overrides mod.widgets.Markdown with code_block := View{CodeView{...}}, and Splash eval creates Markdown{body: "..."}, the code_block template is NOT automatically available. The fix (in widgets/src/markdown.rs) lazily looks up the type default at draw time and registers missing templates.
---
Debugging Splash
Command-Line Flags
| Flag | Description |
|---|---|
--hot | Enable hot reload: watches script_mod! source files and auto-refreshes UI on save. Only reloads Splash DSL; Rust changes need recompilation. |
--stdin-loop | Studio mode: communicates with Makepad Studio via stdin/websocket. Used internally by Studio. |
Print Debugging
std.println() / std.print() are the primary debugging tools. Output goes to both terminal and Studio's Log View:
std.println("debug: state.counter = " + state.counter)The ~expression debug log syntax also prints values during evaluation:
~state.counter // prints the value of state.counter during evalMakepad Studio Integration
Studio can run Splash scripts directly from the Run List panel (looks for makepad.splash in project root). Script errors appear in the Log View with file path and error details.
When running under Studio, Splash scripts get a hub module:
| API | Description |
|---|---|
hub.run(env, cmd, args) | Launch subprocess from Splash |
hub.set_run_items(items) | Register runnable items in Studio's Run List |
hub.studio_ip | Studio's WebSocket address |
Current Limitations
- No breakpoint debugging -- Splash VM does not support breakpoints or stepping
- No AST dump flag -- Inspecting parse results requires adding logs in Rust source (
script/) - Print-based debugging --
std.println()and~exprare the primary debugging tools
---
File References
- Full language manual:
splash.md(2559 lines) - Migration guide:
AGENTS.md(815 lines) - Counter example:
examples/counter/src/app.rs - Todo example:
examples/todo/src/app.rs - Detailed reference:
skills/makepad-2.0-splash/references/splash-language-reference.md - Patterns guide:
skills/makepad-2.0-splash/references/splash-scripting-patterns.md
---
Practical Splash Lessons (learned 2026-03-31, from building Vox voice input app)
Lesson 1: instance Fields Cannot Be Added in +: Blocks
The most critical Splash limitation. Adding instance my_var: 0.5 inside a draw_bg +: { } block causes a runtime error: "cannot push to frozen vec".
// WRONG — runtime crash
draw_bg +: {
instance hover: 0.0 // CRASH: cannot push to frozen vec
pixel: fn() { ... }
}
// CORRECT — override pixel function only, use built-in variables
draw_bg +: {
pixel: fn() {
let t = self.draw_pass.time // built-in, always available
return Pal.premul(vec4(t, 0.0, 0.0, 1.0))
}
}Workarounds:
- Use
self.draw_pass.timefor time-based animation - Use
self.pos,self.rect_size(always available) - For custom instance variables, create a Rust
DrawQuadsubtype (see makepad-2.0-shaders skill) - Use
LoadingSpinnerwidget for simple animated indicators
Lesson 2: Transparent Floating Window Recipe
Creating a truly transparent overlay window requires three things:
my_window := Window{
show_caption_bar: false
window.transparent: true // 1. Window-level transparency
pass.clear_color: #x00000000 // 2. Render pass clear to fully transparent
body +: {
// 3. Do NOT set draw_bg on body — it overrides transparency
View{
width: Fill height: Fill
// Only visible elements show; rest is see-through
}
}
}Common mistakes:
- Setting only
window.transparent: true— window stays opaque (gray background) - Setting
draw_bg.color: #x00000000on body — does NOT make it transparent, just black - Must also configure as floating panel from Rust:
MacosWindowConfig::floating_panel()+Borderless
Reference implementation: Makepad tools/canvas/src/app.rs uses this exact pattern.
Lesson 3: Property Names That Don't Exist
Splash silently ignores unknown properties. These caused us real debugging time:
| Wrong | Correct | Notes |
|---|---|---|
password: true | is_password: true | TextInput password mode |
color: #fff on LoadingSpinner | (not supported) | LoadingSpinner has no color property |
window.backdrop: Vibrancy | Works, but needs transparent: true too | Backdrop alone doesn't make window transparent |
Lesson 4: Emoji Rendering
Makepad's text renderer supports some emoji but not all:
| Works | Doesn't Work |
|---|---|
| 🎙 🔍 🔄 | ✨ ⏳ |
If an emoji shows as a box or garbage character, try a different one. Stick to basic emoji from the BMP (Basic Multilingual Plane).
Lesson 5: width: Fit + Large border_radius = Spiky Shape
RoundedView with border_radius: 28.0 on a height: 56 capsule produces diamond-shaped ends instead of half-circles. The underlying sdf.box() formula breaks when radius >= min(w,h)/2.
Fix: Use a custom SDF capsule shader instead of RoundedView:
View{
show_bg: true
draw_bg +: {
pixel: fn() {
let r = self.rect_size.y * 0.5
let px = self.pos.x * self.rect_size.x
let py = self.pos.y * self.rect_size.y
let cx = clamp(px, r, max(r, self.rect_size.x - r))
let d = length(vec2(px - cx, py - self.rect_size.y * 0.5)) - r
let alpha = 1.0 - smoothstep(-1.0, 1.0, d)
return Pal.premul(vec4(0.1, 0.1, 0.18, alpha * 0.82))
}
}
}Lesson 6: Multi-Window App — Window Visibility Control
Windows declared in script_mod! auto-show on startup. Makepad WindowRef has no close()/open() method.
Working pattern: Declare windows at normal size. Use configure_window() to bring to front when needed.
// Show: configure_window triggers makeKeyAndOrderFront on macOS
let settings = self.ui.window(cx, ids!(settings_window));
settings.configure_window(cx, dvec2(480.0, 560.0), dvec2(500.0, 200.0), false, "Settings".into());
// Hide: resize to 1x1 (no close/minimize on WindowRef)
let capsule = self.ui.window(cx, ids!(capsule_window));
capsule.resize(cx, dvec2(1.0, 1.0));Note: reposition(cx, dvec2(-9999, -9999)) does NOT reliably hide macOS floating panels.
Lesson 7: new_batch: true and Widget Z-Order
Adding a LoadingSpinner (or any child widget with its own draw shader) inside a custom-shader View can cause bleed-through at the edges — the child widget draws outside the parent's SDF mask.
Fix: Either: 1. Draw animation in the parent's own pixel shader (no child widget z-order issues) 2. Use clip_x: true + clip_y: true on the parent (may not fully solve it)
Lesson 8: Continuous Redraw for Time-Based Shader Animation
self.draw_pass.time in a shader only advances when the widget is redrawn. Without explicit redraw, time freezes.
fn handle_next_frame(&mut self, cx: &mut Cx, _e: &NextFrameEvent) {
if self.inner.state != STATE_IDLE {
// Redraw the WINDOW (not just the view) to update draw_pass.time
self.ui.widget(cx, ids!(my_window)).redraw(cx);
self.inner.next_frame = cx.new_next_frame();
}
}Key: Redraw the Window widget, not just the inner View. The draw pass time is per-window.
Lesson 9: #[rust] Fields with Complex Types in Script-Derived Structs
#[derive(Script, ScriptHook)] structs can only have #[rust] fields whose types implement Default. For complex non-Default types (channels, handles, etc.), wrap them in an Inner struct:
#[derive(Default)]
struct Inner {
timer: Timer,
rx: Option<crossbeam_channel::Receiver<u64>>,
handle: Option<SomeNonDefaultHandle>,
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live] ui: WidgetRef,
#[rust] inner: Inner, // Single Default-able wrapper
}Canvas Splash Patterns — Lessons from Real-World Development
Hard-won lessons from building the Claude Code Monitor and Music Player apps on Canvas. These patterns apply to any Splash code rendered via Canvas (HTTP/WS POST /splash).
---
Architecture: POST Once, Drive Internally
The Golden Rule
POST the Splash code ONCE. All interaction and state changes happen inside Splash.
driver.sh: gather data → generate Splash code → POST /splash once → EXIT
Splash: on_click handles interaction / fn tick() drives timers / set_text updates labelsWhy NOT Loop-POST
Every POST /splash rebuilds the entire widget tree: 1. Destroys all existing Views, Buttons, Labels 2. Creates new widgets with new UIDs 3. Triggers full redraw cycle 4. uid_map for event routing gets invalidated
If you POST every 3-5 seconds, Canvas CPU hits 100% and the window freezes.
Correct Pattern: Data Snapshot + Internal Logic
# driver.sh — runs once, gathers data, generates Splash, POSTs, exits
DATA=$(jq ... session.jsonl)
cat > /tmp/splash.tmp <<EOF
let state = { tab: "a" elapsed: 0 }
fn show_a() {
state.tab = "a"
ui.label.set_text("Content A: $DATA_A")
}
fn tick() {
state.elapsed = state.elapsed + 1
ui.timer.set_text("" + state.elapsed)
}
View{...}
EOF
curl -s -X POST "$API/splash" --data-binary @/tmp/splash.tmpRe-run the driver script to refresh data. This replaces the Splash once (not in a loop).
---
Tab Switching Without on_render
Problem: set_visible() Does NOT Work in Splash
set_visible() is a Rust-only method. Calling it from Splash silently does nothing.
// WRONG — silently fails
ui.tab_a.set_visible(true)
ui.tab_b.set_visible(false)Problem: on_render Not Called on Initial Load
on_render: only fires when you call ui.view.render(). On first load, the view is empty.
// WRONG — content_view is empty until someone calls render()
content_view := View{
on_render: ||{
Label{text: "Hello"}
}
}Solution: Static Layout + set_text() for All Dynamic Content
Put ALL possible UI elements in the layout statically. Give them := names. Use set_text() to update their content per tab.
let state = { tab: "session" }
fn show_session() {
state.tab = "session"
ui.c1_title.set_text("Input")
ui.c1_value.set_text("12.3K")
ui.c2_title.set_text("Output")
ui.c2_value.set_text("45.6K")
ui.detail.set_text("Human: 42 | Assistant: 67")
}
fn show_stats() {
state.tab = "stats"
ui.c1_title.set_text("Today In")
ui.c1_value.set_text("1.2M")
ui.c2_title.set_text("Today Out")
ui.c2_value.set_text("3.4M")
ui.detail.set_text("Week: 10M in / 30M out")
}
// Static layout — cards and labels are always present
View{width: Fill height: Fit flow: Right spacing: 12
RoundedView{...
c1_title := Label{text: "Input" ...}
c1_value := Label{text: "12.3K" ...}
}
RoundedView{...
c2_title := Label{text: "Output" ...}
c2_value := Label{text: "45.6K" ...}
}
}
detail := Label{text: "..." ...}---
Timer Pattern: fn tick()
Splash widget auto-detects fn tick() in the code and starts a 1-second interval timer.
let state = { elapsed: 0 }
fn fmt_elapsed() {
let t = state.elapsed
let h = 0
while t >= 3600 { h = h + 1 t = t - 3600 }
let m = 0
while t >= 60 { m = m + 1 t = t - 60 }
let s = t
let hh = if h < 10 { "0" + h } else { "" + h }
let mm = if m < 10 { "0" + m } else { "" + m }
let ss = if s < 10 { "0" + s } else { "" + s }
hh + ":" + mm + ":" + ss
}
fn tick() {
state.elapsed = state.elapsed + 1
ui.timer_label.set_text(fmt_elapsed())
}Key: fn tick() is called EVERY second. Keep it cheap — only set_text() calls, no heavy computation.
---
Audio Callback: fn on_audio()
Canvas injects audio state as global variables and calls fn on_audio() ~10 times per second when audio is playing.
Available Globals (injected by Canvas)
| Variable | Type | Description |
|---|---|---|
_playing | bool | Is audio currently playing |
_pos | f64 | Current playback position (seconds, float) |
_dur | f64 | Total duration (seconds, float) |
_amp | f64 | Current RMS amplitude (0.0–1.0) |
_b0–_b15 | f64 | 16-band FFT spectrum (0.0–1.0 each) |
Pattern
fn on_audio() {
ui.time_cur.set_text(fmt_time(_pos))
ui.time_end.set_text(fmt_time(_dur))
if _playing { ui.play_btn.set_text("Pause") }
else { ui.play_btn.set_text("Play") }
}Float-to-Integer for Time Display
_pos and _dur are floats (e.g., 123.456). Splash has no floor() or round(). Use a while-loop to truncate:
fn fmt_time(secs) {
// Truncate float to integer
let total = 0
while total < secs { total = total + 1 }
if total > secs { total = total - 1 }
// Now total is an integer
let m = 0
while total >= 60 { m = m + 1 total = total - 60 }
let s = total
let ms = if m < 10 { "0" + m } else { "" + m }
let ss = if s < 10 { "0" + s } else { "" + s }
ms + ":" + ss
}---
Button Events: Splash on_click vs HTTP Event Bridge
Splash on_click: — WORKS Reliably
Buttons defined in Splash with on_click: handlers work perfectly for internal state changes:
play_btn := Button{text: "Play" ...
on_click: ||{
if state.playing { state.playing = false }
else { state.playing = true }
refresh()
}
}HTTP Event Bridge (GET /event) — UNRELIABLE for Dynamic Splash
The HTTP event bridge routes ButtonAction::Clicked from Splash buttons through uid_map lookup. This is unreliable because:
1. Each POST /splash rebuilds widget tree → new UIDs 2. uid_map rebuilds on next Draw event → may be stale 3. Events during splash rebuild are lost
Rule: Use on_click: for ALL button interactions. Never depend on GET /event for Splash buttons.
Special Button Names for Audio Control
Canvas app.rs routes these button names to audio API automatically (via uid_map when it works):
play_btnoraudio_toggle→ toggle play/pauseaudio_stop→ stop playback
But since uid_map is unreliable for Splash buttons, audio control should be done via HTTP API from the driver script, not via Splash button names.
---
Splash Script API Reference (Available Methods)
Works in Splash
| Method | Description |
|---|---|
ui.widget.set_text("...") | Update Label/Button text |
ui.widget.text() | Read current text |
ui.view.render() | Trigger on_render: callback |
ui.button.on_click() | Programmatically trigger click |
Does NOT Work in Splash (Rust-Only)
| Method | Description | Workaround |
|---|---|---|
set_visible(bool) | Show/hide widget | Use on_render + conditional, or set_text |
set_active(bool) | Enable/disable | Use state variable + visual feedback |
redraw() | Force redraw | Use render() instead |
configure_macos_window() | Platform config | Set from Rust handle_startup |
---
Full-Script Mode vs View-Children Mode
Splash widget auto-detects the mode based on the first non-whitespace token:
Full-Script Mode (starts with let, fn, or mod.)
let state = { count: 0 }
fn tick() { state.count = state.count + 1 }
View{...}Prefix added: use mod.prelude.widgets.*\n (just imports, no wrapping View)
View-Children Mode (starts with lowercase property or widget)
flow: Down spacing: 10
Label{text: "Hello"}
Button{text: "Click"}Prefix added: use mod.prelude.widgets.*View{height:Fit, (wraps in View)
Key: View-children mode's default flow is Right (horizontal). Add flow: Down explicitly for vertical layout.
---
Common Mistakes Summary
| Mistake | Symptom | Fix |
|---|---|---|
| Loop-POST splash | 100% CPU, window freezes | POST once, use set_text/on_click internally |
set_visible() in Splash | Silently ignored | Use set_text or on_render conditional |
on_render without initial render() | Empty view on load | Put initial content statically, use on_render only for dynamic updates |
| Float in time display | "01:23.456789" | Truncate with while loop before formatting |
!expr for boolean negation | Parse error | Use if x { false } else { true } |
math.floor() | Not available | Use while loop to truncate |
Missing flow: Down in View-children mode | Horizontal layout | Add flow: Down explicitly |
| Depending on HTTP event bridge | Events lost/unreliable | Use on_click: handlers in Splash |
width: Fill on Button | Hit-test may fail | Use fixed width: 80 height: 36 |
Splash Language Reference
Complete reference for the Splash scripting language in Makepad 2.0. Splash is a runtime UI scripting language -- no commas between properties, no semicolons, whitespace-delimited.
---
Script Structure
Every Splash script must start with a use statement to bring widgets into scope:
use mod.prelude.widgets.*
// All widgets (View, Label, Button, etc.) are now available
View{
flow: Down
height: Fit
padding: 20
Label{text: "Hello world"}
}Without use mod.prelude.widgets.*, widget names will not be found.
Embedding in Rust
Splash code is embedded in Rust via script_mod!{}:
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
// Splash code here
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(800, 600)
body +: {
// UI content
}
}
}
}
}For apps using the theme system:
impl App {
fn run(vm: &mut ScriptVm) -> Self {
crate::makepad_widgets::theme_mod(vm);
script_eval!(vm, {
mod.theme = mod.themes.light
});
crate::makepad_widgets::widgets_mod(vm);
App::from_script_mod(vm, self::script_mod)
}
}---
Let Bindings
Define reusable widget templates with let. Bindings are local to the current scope and must be defined before the places where they are used.
// Simple template (style overrides only)
let MyHeader = Label{
draw_text.color: #fff
draw_text.text_style.font_size: 16
}
// Template with per-instance named children (MUST use :=)
let MyCard = RoundedView{
width: Fill height: Fit
padding: 15 flow: Down spacing: 8
draw_bg.color: #334
draw_bg.border_radius: 8.0
title := Label{text: "default" draw_text.color: #fff draw_text.text_style.font_size: 16}
body := Label{text: "" draw_text.color: #aaa}
}
// Instantiate and override
View{
flow: Down height: Fit spacing: 12 padding: 20
MyCard{title.text: "First Card" body.text: "Content here"}
MyCard{title.text: "Second Card" body.text: "More content"}
}---
Property Syntax
Assignment
key: valueDot-path Shorthand
draw_bg.color: #f00
// equivalent to:
draw_bg +: { color: #f00 }Merge Operator
The +: operator extends/merges with the parent definition instead of replacing it entirely:
// Replaces entire draw_bg:
draw_bg: { color: #fff }
// Merges -- only overrides color, keeps other draw_bg properties:
draw_bg +: { color: #fff }Named Children (:= Operator)
Children inside a template that you want to override per-instance MUST be declared with :=:
let TodoItem = View{
width: Fill height: Fit
flow: Right spacing: 8
check := CheckBox{text: ""}
label := Label{text: "task" draw_text.color: #ddd}
Filler{}
tag := Label{text: "" draw_text.color: #888}
}
// Override named children with dot-path syntax:
TodoItem{label.text: "Walk the dog" tag.text: "personal"}CRITICAL: Using label: (colon) instead of label := (colon-equals) makes the child static and non-addressable. Overrides fail silently.
Named children inside anonymous containers are UNREACHABLE. Every container in the path from root to the child must have a := name:
let Item = View{
flow: Right
texts := View{ // named with :=
flow: Down
label := Label{text: "default"}
}
}
Item{texts.label.text: "new text"} // full path through named containersInherit + Override
Use inheritance syntax to take a base value but override specific fields:
padding: theme.mspace_1{left: theme.space_2} // takes mspace_1 but overrides left---
Colors
#f00 // RGB short
#ff0000 // RGB full
#ff0000ff // RGBA
#0000 // transparent black
vec4(1.0 0.0 0.0 1.0) // explicit RGBAHex Color Escape (#x prefix)
When hex colors contain the letter e adjacent to digits (which could be misinterpreted as scientific notation), use the #x prefix:
// Need #x prefix (contain 'e' adjacent to digits):
fill: #x2ecc71
fill: #x1e1e2e
fill: #x4466ee
// Fine without #x (no 'e' issue):
fill: #ff4444
fill: #00ff00Color Arithmetic
theme.color_label_inner_inactive * 0.8 // darken by 20%---
Sizing (Size Enum)
width: Fill // Fill available space (default)
width: Fit // Shrink to content
width: 200 // Fixed 200px (bare number = Fixed)
width: Fill{min: 100 max: 500}
width: Fit{max: Abs(300)}
height: Fill height: Fit height: 100---
Layout
Flow (direction children are laid out)
flow: Right // default, left-to-right (no wrap)
flow: Down // top-to-bottom
flow: Overlay // stacked on top of each other
flow: Flow.Right{wrap: true} // wrapping horizontal
flow: Flow.Down{wrap: true} // wrapping verticalSpacing, Padding, Margin
spacing: 10 // gap between children
padding: 15 // uniform padding (bare number)
padding: Inset{top: 5 bottom: 5 left: 10 right: 10}
margin: Inset{top: 2 bottom: 2 left: 5 right: 5}
margin: 0. // uniform zeroAlignment
align: Center // Align{x: 0.5 y: 0.5}
align: HCenter // Align{x: 0.5 y: 0.0}
align: VCenter // Align{x: 0.0 y: 0.5}
align: TopLeft // Align{x: 0.0 y: 0.0}
align: Align{x: 1.0 y: 0.0} // top-right
align: Align{x: 0.0 y: 0.5} // center-leftClipping
clip_x: true // default
clip_y: true // default
clip_x: false // overflow visible---
Control Flow
If/Else
if condition {
Label{text: "true branch"}
} else {
Label{text: "false branch"}
}
// Single-expression form (no braces):
if todos.len() == 0
EmptyState{}
else for i, todo in todos {
TodoItem{label.text: todo.text}
}For Loops
for i, item in array {
Label{text: item.name}
}
for todo in todos {
if !todo.done { n = n + 1 }
}While Loops
while condition {
// body
}---
Functions
fn name(params) {
body
}
// Examples:
fn tag_color(tag) {
if tag == "dev" theme.color_highlight
else if tag == "urgent" theme.color_warning
else theme.color_highlight
}
fn add_todo(text, tag) {
todos.push({text: text, tag: tag, done: false})
ui.todo_list.render()
}
fn count_remaining() {
let n = 0
for todo in todos {
if !todo.done { n = n + 1 }
}
n
}---
State Management
Defining State
// Object literal state
let state = {
counter: 0
}
mod.state = state
// Array state
let todos = []
todos.push({text: "First task", tag: "dev", done: false})Reactive Rendering with on_render
The on_render callback runs every time .render() is called on the widget. It rebuilds children dynamically:
main_view := View{
on_render: ||{
Label{text: "Count: " + state.counter}
}
}Trigger re-renders with:
ui.main_view.render()Widget Referencing
ui.widget_name.render() // trigger re-render
ui.widget_name.text() // get text content (TextInput)
ui.widget_name.set_text("") // set text content
ui.widget_name.on_click() // programmatically trigger click---
Event Handling
Inline Events in Splash
// Button click
add_button := Button{
text: "Add"
on_click: ||{
let text = ui.todo_input.text()
if text != "" {
add_todo(text, "")
ui.todo_input.set_text("")
}
}
}
// TextInput return key
todo_input := TextInput{
on_return: || ui.add_button.on_click()
}
// CheckBox toggle (receives checked state)
check := CheckBox{
on_click: |checked| toggle_todo(i, checked)
}
// Startup event
on_startup: ||{
ui.main_view.render()
}Events from Rust with script_eval!
if self.ui.button(cx, ids!(increment_button)).clicked(actions) {
script_eval!(cx, {
mod.state.counter += 1
ui.main_view.render()
});
}---
HTTP Requests
GET Request
let req = net.HttpRequest{
url: "https://html.duckduckgo.com/html/?q=rust+programming"
method: net.HttpMethod.GET
headers: {"User-Agent": "MakepadApp/1.0"}
}
net.http_request(req) do net.HttpEvents{
on_response: |res| {
let text = res.body.to_string()
let json = res.body.parse_json()
// res.status_code -- HTTP status (200, 404, etc.)
}
on_error: |e| {
// e.message
}
}POST Request with JSON Body
let req = net.HttpRequest{
url: "https://api.example.com/data"
method: net.HttpMethod.POST
headers: {"Content-Type": "application/json"}
body: {key: "value" count: 42}.to_json()
}
net.http_request(req) do net.HttpEvents{
on_response: |res| { /* ... */ }
on_error: |e| { /* ... */ }
}Streaming Response
let req = net.HttpRequest{
url: "https://api.example.com/stream"
method: net.HttpMethod.POST
is_streaming: true
body: {stream: true}.to_json()
}
var total = ""
net.http_request(req) do net.HttpEvents{
on_stream: |res| {
total += res.body.to_string() // called per chunk
}
on_complete: |res| {
// stream finished
}
on_error: |e| { /* ... */ }
}HttpMethod Values
net.HttpMethod.GET, POST, PUT, DELETE, HEAD, PATCH, OPTIONS
---
HTML Parsing
Call .parse_html() on any string to get a queryable HTML document.
Querying Elements
let doc = html_string.parse_html()
doc.query("p") // all <p> elements
doc.query("p[0]") // first <p> element
doc.query("#main") // element with id "main"
doc.query("p.bold") // <p> with class "bold"
doc.query("div > p") // direct children
doc.query("div p") // descendants
doc.query("div > *") // all direct children (wildcard)
doc.query("div").query("p") // chained queriesExtracting Data
doc.query("p[0]").text // text content: "Hello"
doc.query("div@class") // attribute value: "box"
doc.query("div@id") // attribute value: "main"
doc.query("p.text") // array of text from all <p>
doc.query("p@class") // array of class attrs from all <p>Properties on HTML Handles
handle.length // number of matched elements
handle.text // text content (concatenated)
handle.html // reconstructed HTML string
handle.attr("name") // attribute value (string or nil)
handle.array() // convert to array of element handlesIterating Results
let items = doc.query("a.result__a").array()
for item, i in items {
let title = item.text
let href = item.attr("href")
}---
View Widgets (Containers)
All inherit from ViewBase. Default: no background.
| Widget | Background | Shape |
|---|---|---|
View | none | -- |
SolidView | flat color | rectangle |
RoundedView | color | rounded rect |
RoundedAllView | color | per-corner radius (vec4) |
RoundedXView | color | left/right radius (vec2) |
RoundedYView | color | top/bottom radius (vec2) |
RectView | color | rectangle with border |
RectShadowView | color+shadow | rectangle |
RoundedShadowView | color+shadow | rounded rect |
CircleView | color | circle |
HexagonView | color | hexagon |
GradientXView | horizontal gradient | rectangle |
GradientYView | vertical gradient | rectangle |
CachedView | texture-cached | rectangle |
CachedRoundedView | texture-cached | rounded rect |
Scrollable: ScrollXYView, ScrollXView, ScrollYView
View Properties
width: Fill // Size: Fill | Fit | <number>
height: Fit // CRITICAL: default Fill breaks in Fit containers
flow: Down // Flow: Right | Down | Overlay | Flow.Right{wrap: true}
spacing: 10 // gap between children
padding: 15 // Inset or bare number
margin: 0. // Inset or bare number
align: Center // Align preset or Align{x: y:}
show_bg: true // enable background drawing (false by default)
visible: true
new_batch: true // REQUIRED on Views with show_bg containing text
cursor: MouseCursor.Hand
grab_key_focus: true
clip_x: true
clip_y: truedraw_bg Properties
draw_bg +: {
color: instance(#334)
color_2: instance(vec4(-1))
border_size: uniform(1.0)
border_radius: uniform(5.0)
border_color: instance(#888)
shadow_color: instance(#0007)
shadow_radius: uniform(10.0)
shadow_offset: uniform(vec2(0 0))
}Draw Batching (new_batch: true)
Makepad batches same-shader widgets into one GPU draw call. Without new_batch: true, text can render behind backgrounds.
When to use:
- Any View with
show_bg: truethat contains Labels or text - Hoverable items with animator + background
- Parent containers of repeated items with backgrounds
- Whenever text appears invisible despite correct color
---
Text Widgets
Label
Label{text: "Hello"}
Label{
width: Fit height: Fit
draw_text.color: #fff
draw_text.text_style.font_size: 12
text: "Styled"
}Label does NOT support `animator` or `cursor`. To make hoverable text, wrap a Label in a View with animator.
Default text color is WHITE. For light themes, always set draw_text.color explicitly.
Label Variants
Label, Labelbold, LabelGradientX, LabelGradientY, TextBox, P, Pbold
Headings
H1{text: "Title"} // font_size_1
H2{text: "Subtitle"} // font_size_2
H3{text: "Section"} // font_size_3
H4{text: "Subsection"} // font_size_4draw_text Properties
draw_text +: {
color: #fff
color_2: uniform(vec4(-1))
text_style: theme.font_regular{font_size: 11}
}Available fonts: theme.font_regular, theme.font_bold, theme.font_italic, theme.font_bold_italic, theme.font_code, theme.font_icons
TextInput
TextInput{width: Fill height: Fit empty_text: "Placeholder"}
TextInputFlat{width: Fill height: Fit empty_text: "Type here"}
TextInput{is_password: true empty_text: "Password"}
TextInput{is_read_only: true}
TextInput{is_numeric_only: true}---
Button Widgets
Button{text: "Standard"}
ButtonFlat{text: "Flat"}
ButtonFlatter{text: "Minimal"}
// Customize colors
ButtonFlat{
text: "Custom"
draw_bg +: {
color: uniform(#336)
color_hover: uniform(#449)
color_down: uniform(#225)
}
draw_text +: { color: #fff }
}---
Toggle Widgets
CheckBox{text: "Enable"}
CheckBoxFlat{text: "Flat style"}
Toggle{text: "Dark mode"}
ToggleFlat{text: "Flat toggle"}
RadioButton{text: "Option A"}
RadioButtonFlat{text: "Option A"}---
Input Widgets
Slider
Slider{width: Fill text: "Volume" min: 0.0 max: 100.0 default: 50.0}
SliderMinimal{text: "Value" min: 0.0 max: 1.0 step: 0.01 precision: 2}DropDown
DropDown{labels: ["Option A" "Option B" "Option C"]}
DropDownFlat{labels: ["Small" "Medium" "Large"]}---
Media Widgets
Image
Image{width: 200 height: 150 fit: ImageFit.Stretch}
// ImageFit: Stretch | Horizontal | Vertical | Smallest | Biggest | SizeIcon
Icon{
draw_icon.svg: crate_resource("self://resources/icons/my_icon.svg")
draw_icon.color: #0ff
icon_walk: Walk{width: 32 height: 32}
}Vector (SVG-like Drawing)
Vector{width: 200 height: 200 viewbox: vec4(0 0 200 200)
Rect{x: 10 y: 10 w: 80 h: 60 rx: 5 ry: 5 fill: #f80}
Circle{cx: 150 cy: 50 r: 30 fill: #08f}
Path{d: "M 10 10 L 100 100 Z" fill: #f00 stroke: #000 stroke_width: 2}
Group{opacity: 0.7 transform: Rotate{deg: 15}
Rect{x: 20 y: 20 w: 60 h: 60 fill: #f00}
}
}MathView (LaTeX)
MathView{text: "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}" font_size: 14.0}MapView
CRITICAL: Must use fixed pixel height. Never Fit or Fill.
MapView{width: Fill height: 500 dark_theme: true}---
Layout Widgets
Dividers
Hr{} // horizontal rule
Vr{} // vertical ruleFiller (Spacer)
Filler{} // View{width: Fill height: Fill}Do NOT use Filler{} next to a width: Fill sibling in flow: Right -- they split space 50/50.
Splitter
Splitter{
axis: SplitterAxis.Horizontal
align: SplitterAlign.FromA(250.0)
a := left_panel
b := right_panel
}FoldHeader
FoldHeader{
header: View{height: Fit
flow: Right align: Align{y: 0.5} spacing: 8
FoldButton{}
Label{text: "Section Title"}
}
body: View{height: Fit
flow: Down padding: Inset{left: 23} spacing: 8
}
}---
List Widgets
PortalList (Virtualized)
list := PortalList{
width: Fill height: Fill
flow: Down
scroll_bar: ScrollBar{}
Item := View{
width: Fill height: Fit
title := Label{text: ""}
}
}FlatList (Non-virtualized)
FlatList{
width: Fill height: Fill
flow: Down
Item := View{height: Fit ...}
}---
Animator
Drives instance() variables over time for hover effects, transitions, and animations.
Supports animator: View, SolidView, RoundedView, Button, ButtonFlat, ButtonFlatter, CheckBox, Toggle, RadioButton, LinkLabel, TextInput, ScrollXView, ScrollYView, ScrollXYView
Does NOT support animator: Label, H1--H4, P, Image, Icon, Slider, DropDown, Splitter, Hr, Filler
Structure
animator: Animator{
hover: {
default: @off
off: AnimatorState{
from: {all: Forward {duration: 0.15}}
apply: {draw_bg: {hover: 0.0}}
}
on: AnimatorState{
from: {all: Forward {duration: 0.15}}
apply: {draw_bg: {hover: 1.0}}
}
}
}Play Types
Forward {duration: 0.2} // play once forward
Snap // instant
Reverse {duration: 0.2, end: 1.0} // play in reverse
Loop {duration: 1.0, end: 1000000000.0} // repeat forward
BounceLoop {duration: 1.0, end: 1.0} // bounce back and forthEase Functions
Linear, InQuad, OutQuad, InOutQuad, InCubic, OutCubic, InOutCubic, InSine, OutSine, InOutSine, InExp, OutExp, InOutExp, InElastic, OutElastic, InOutElastic, InBack, OutBack, InOutBack, InBounce, OutBounce, InOutBounce, ExpDecay{...}, Bezier{...}
---
Shader System
Instance vs Uniform
draw_bg +: {
hover: instance(0.0) // per-draw-call, animatable
color: uniform(#fff) // shared across instances
tex: texture_2d(float) // texture sampler
}Pixel Shader
draw_bg +: {
pixel: fn() {
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.box(0. 0. self.rect_size.x self.rect_size.y 4.0)
sdf.fill(#f00)
return sdf.result
}
}CRITICAL: When returning a color directly (not via sdf.result), premultiply alpha with Pal.premul():
pixel: fn() {
return Pal.premul(self.color.mix(self.color_hover, self.hover))
}SDF Primitives
sdf.circle(cx cy radius), sdf.rect(x y w h), sdf.box(x y w h border_radius), sdf.hexagon(cx cy radius), sdf.hline(y half_height)
SDF Drawing
sdf.fill(color), sdf.fill_keep(color), sdf.stroke(color width), sdf.stroke_keep(color w), sdf.glow(color width), sdf.clear(color)
---
Theme Variables
Spacing
theme.space_1, theme.space_2, theme.space_3
Inset Presets
theme.mspace_1, theme.mspace_2, theme.mspace_3 (uniform) theme.mspace_h_1, theme.mspace_h_2, theme.mspace_h_3 (horizontal) theme.mspace_v_1, theme.mspace_v_2, theme.mspace_v_3 (vertical)
Key Colors
theme.color_bg_app, theme.color_fg_app, theme.color_bg_container, theme.color_bg_even, theme.color_bg_odd, theme.color_text, theme.color_text_hl, theme.color_label_inner, theme.color_label_outer, theme.color_highlight, theme.color_white, theme.color_black, theme.color_error, theme.color_warning, theme.color_panic, theme.color_selection_focus, theme.color_shadow, theme.color_app_caption_bar
Color variants: _hover, _down, _focus, _active, _disabled, _inactive
Typography
Font sizes: theme.font_size_1 through theme.font_size_4, theme.font_size_p, theme.font_size_code, theme.font_size_base
Fonts: theme.font_regular, theme.font_bold, theme.font_italic, theme.font_bold_italic, theme.font_code, theme.font_icons
---
Enums Reference
MouseCursor
Default, Hand, Arrow, Text, Move, Wait, Help, NotAllowed, Crosshair, Grab, Grabbing, NResize, EResize, SResize, WResize, NsResize, EwResize, ColResize, RowResize, Hidden
Usage: cursor: MouseCursor.Hand
ImageFit
Stretch, Horizontal, Vertical, Smallest, Biggest, Size
SplitterAxis
Horizontal, Vertical
SplitterAlign
FromA(250.0), FromB(200.0), Weighted(0.5)
---
Critical Rules Summary
1. Always `height: Fit` on containers -- default Fill causes invisible 0px UI 2. Always `width: Fill` on root container -- never fixed pixel width at top level 3. `new_batch: true` on any View with show_bg: true that contains text 4. `:=` for named children in templates -- without it, overrides fail silently 5. `draw_bg.border_radius` is a float -- 16.0, not an Inset 6. Use styled Views (RoundedView, SolidView) instead of raw View{show_bg: true} 7. Default text color is WHITE -- set draw_text.color explicitly for light themes 8. No commas between properties -- whitespace-delimited 9. Strings use double quotes only -- no single quotes, no backticks 10. Use commas in `vec2()`/`vec4()` when values are negative to avoid subtraction ambiguity 11. Shader function args are space-separated -- sdf.box(0. 0. 100. 100. 5.0) 12. Label does NOT support animator -- wrap in a View for hover effects 13. Resources: crate_resource("self://relative/path") 14. `let` bindings must be defined before use -- they are local scope
Splash Scripting Patterns
Common patterns with complete working examples for the Splash scripting language in Makepad 2.0. All examples are based on the actual examples/counter and examples/todo apps.
---
1. Counter App Pattern (State + Render + script_eval)
The simplest complete Splash app demonstrating state management, reactive rendering, and Rust-to-Splash event bridging.
Splash Script (inside script_mod!{})
use mod.prelude.widgets.*
let state = {
counter: 0
}
mod.state = state
startup() do #(App::script_component(vm)){
ui: Root{
on_startup: ||{
ui.main_view.render()
}
main_window := Window{
window.inner_size: vec2(420, 220)
body +: {
main_view := View{
width: Fill
height: Fill
flow: Down
spacing: 12
align: Center
on_render: ||{
counter_label := Label{
text: "Count: " + state.counter
draw_text.text_style.font_size: 24
}
}
}
increment_button := Button{
text: "Increment"
}
}
}
}
}Rust Boilerplate
use makepad_widgets::*;
app_main!(App);
// script_mod! { ... } goes here
impl App {
fn run(vm: &mut ScriptVm) -> Self {
crate::makepad_widgets::script_mod(vm);
App::from_script_mod(vm, self::script_mod)
}
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
}
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.button(cx, ids!(increment_button)).clicked(actions) {
script_eval!(cx, {
mod.state.counter += 1
ui.main_view.render()
});
}
}
}
impl AppMain for App {
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}Key Takeaways
mod.statestores application state accessible from both Splash and Ruston_render: ||{ ... }is a reactive callback that rebuilds children when.render()is calledscript_eval!(cx, { ... })executes Splash code from Rust event handlersui.widget_name.render()triggers a re-render of that widget'son_renderblockon_startupruns once when the app launches
---
2. Todo List Pattern (Templates + For Loops + Events)
A full todo list demonstrating reusable templates, dynamic list rendering, inline events, and array state management.
Splash Script
use mod.prelude.widgets.*
// Vector Icons
let IconCheck = Vector{width: 18 height: 18 viewbox: vec4(0 0 24 24)
Path{d: "M20 6L9 17L4 12" fill: false stroke: theme.color_highlight stroke_width: 2.5
stroke_linecap: "round" stroke_linejoin: "round"}
}
let IconClipboard = Vector{width: 40 height: 40 viewbox: vec4(0 0 24 24)
Path{d: "M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"
fill: false stroke: theme.color_label_inner_inactive stroke_width: 1.2
stroke_linecap: "round" stroke_linejoin: "round"}
}
// Tag color function
fn tag_color(tag) {
if tag == "dev" theme.color_highlight
else if tag == "urgent" theme.color_warning
else if tag == "personal" theme.color_outset_focus
else theme.color_highlight
}
// Reusable template with named children
let TodoItem = RoundedView{
width: Fill height: Fit
padding: theme.mspace_2{left: theme.space_3, right: theme.space_3}
flow: Right spacing: theme.space_2
align: Align{y: 0.5}
draw_bg.color: theme.color_bg_container
draw_bg.border_radius: 10.0
check := CheckBox{text: ""}
label := Label{
width: Fill
text: "task"
draw_text.color: theme.color_label_inner
draw_text.text_style.font_size: theme.font_size_p
}
tag := RoundedView{
width: Fit height: Fit
padding: theme.mspace_h_1{left: theme.space_2, right: theme.space_2}
draw_bg.color: theme.color_bg_highlight_inline
draw_bg.border_radius: 4.0
tag_label := Label{
text: ""
draw_text.color: theme.color_highlight
draw_text.text_style.font_size: theme.font_size_code
draw_text.text_style: theme.font_bold{}
}
}
delete := ButtonFlatter{
text: "x"
width: 28 height: 28
draw_text +: {
color: theme.color_label_inner_inactive
text_style +: {font_size: theme.font_size_p}
}
}
}
// Empty state display
let EmptyState = View{
width: Fill height: 260
align: Center
flow: Down spacing: theme.space_2
IconClipboard{}
Label{text: "No tasks yet" draw_text.color: theme.color_label_inner_inactive
draw_text.text_style.font_size: theme.font_size_4}
Label{text: "Add one below to get started"
draw_text.color: theme.color_label_inner_inactive * 0.8
draw_text.text_style.font_size: theme.font_size_p}
}
// State (array)
let todos = []
todos.push({text: "Get AI to control UI", tag: "dev", done: true})
// State manipulation functions
fn add_todo(text, tag) {
todos.push({text: text, tag: tag, done: false})
ui.todo_list.render()
}
fn toggle_todo(index, checked) {
todos[index].done = checked
}
fn delete_todo(index) {
todos.remove(index)
ui.todo_list.render()
}
fn count_remaining() {
let n = 0
for todo in todos {
if !todo.done { n = n + 1 }
}
n
}
// UI -- app structure
let app = startup() do #(App::script_component(vm)){
ui: Root{
on_startup: ||{
ui.todo_list.render()
}
main_window := Window{
pass.clear_color: theme.color_bg_app
window.inner_size: vec2(520, 720)
body +: {
width: Fill height: Fill
flow: Down spacing: 0
align: Align{x: 0.5}
// Add bar with TextInput and Button
SolidView{
width: Fill height: Fit
padding: theme.mspace_2{left: theme.space_3 * 2, right: theme.space_3 * 2}
draw_bg.color: theme.color_bg_container
View{
width: Fill height: Fit
flow: Right spacing: 10
align: Align{y: 0.5}
todo_input := TextInput{
width: Fill height: 9. * theme.space_1
empty_text: "What needs to be done?"
on_return: || ui.add_button.on_click()
}
add_button := Button{
text: "+"
width: 40 height: 34
on_click: ||{
let text = ui.todo_input.text()
if text != "" {
add_todo(text, "")
ui.todo_input.set_text("")
}
}
}
}
}
// Dynamic todo list with on_render
todo_list := ScrollYView{
width: Fill height: Fill
padding: theme.mspace_2{left: theme.space_3, right: theme.space_3}
flow: Down spacing: theme.space_1
new_batch: true
on_render: ||{
if todos.len() == 0
EmptyState{}
else for i, todo in todos {
TodoItem{
label.text: todo.text
tag.tag_label.text: todo.tag
check.active: todo.done
check.on_click: |checked| toggle_todo(i, checked)
delete.on_click: || delete_todo(i)
}
}
}
EmptyState{}
}
// Footer with clear button
SolidView{
width: Fill height: Fit
padding: theme.mspace_2{left: theme.space_3 * 2, right: theme.space_3 * 2}
draw_bg.color: theme.color_app_caption_bar
flow: Right
align: Align{y: 0.5}
status := Label{
text: ""
draw_text.color: theme.color_label_inner_inactive
draw_text.text_style.font_size: theme.font_size_code
}
Filler{}
clear_done := ButtonFlatter{
text: "Clear completed"
on_click: ||{
todos.retain(|todo| !todo.done)
ui.todo_list.render()
}
}
}
}
}
}
}
appKey Takeaways
let TodoItem = RoundedView{...}defines a reusable template with named childrenfor i, todo in todos { TodoItem{label.text: todo.text} }iterates and overrides per-instancecheck.on_click: |checked| toggle_todo(i, checked)captures loop variableiin closuretodos.retain(|todo| !todo.done)filters array in-placeui.todo_list.render()triggers re-render after state changesScrollYViewwithnew_batch: truefor scrollable, properly-batched lists
---
3. Reusable Template Pattern (Let Bindings with := Children)
Define component-like templates with overridable named children.
use mod.prelude.widgets.*
// Card template with title and body
let InfoCard = RoundedView{
width: Fill height: Fit
padding: 16 flow: Down spacing: 6
draw_bg.color: #2a2a3d
draw_bg.border_radius: 8.0
new_batch: true
title := Label{text: "Title" draw_text.color: #fff draw_text.text_style.font_size: 14}
body := Label{text: "Body" draw_text.color: #aaa draw_text.text_style.font_size: 11}
}
// List item with multiple named parts
let ListItem = View{
width: Fill height: Fit
padding: Inset{top: 8 bottom: 8 left: 12 right: 12}
flow: Right spacing: 10
align: Align{y: 0.5}
icon := Icon{
draw_icon.color: #0ff
icon_walk: Walk{width: 20 height: 20}
}
texts := View{
width: Fill height: Fit
flow: Down spacing: 2
title := Label{text: "" draw_text.color: #fff draw_text.text_style.font_size: 12}
subtitle := Label{text: "" draw_text.color: #888 draw_text.text_style.font_size: 10}
}
badge := Label{text: "" draw_text.color: #666 draw_text.text_style.font_size: 9}
}
// Usage -- override any named child property
View{
flow: Down height: Fit spacing: 10 padding: 20
InfoCard{title.text: "Welcome" body.text: "Getting started with Makepad"}
InfoCard{title.text: "Settings" body.text: "Configure your preferences"}
ListItem{
texts.title.text: "Documents"
texts.subtitle.text: "3 files"
badge.text: "NEW"
}
}Rules for Templates
1. Use `:=` for all children you want to override -- label :=, title :=, body := 2. Named children inside unnamed Views are unreachable -- name every container in the path 3. Override with dot-path syntax -- Item{texts.title.text: "new value"} 4. Templates are local scope -- define with let before use
---
4. HTTP Request Pattern (GET, POST, Streaming)
Search and Display Results
use mod.prelude.widgets.*
let results = []
fn do_search(query) {
let req = net.HttpRequest{
url: "https://html.duckduckgo.com/html/?q=" + query
method: net.HttpMethod.GET
headers: {"User-Agent": "MakepadApp/1.0"}
}
net.http_request(req) do net.HttpEvents{
on_response: |res| {
let doc = res.body.to_string().parse_html()
let links = doc.query("a.result__a").array()
let snippets = doc.query("a.result__snippet").array()
results = []
for link, i in links {
results.push({
title: link.text
url: link.attr("href")
snippet: if i < snippets.len() snippets[i].text else ""
})
}
ui.results_view.render()
}
on_error: |e| {
// handle error
}
}
}
// ... inside startup() UI definition:
search_input := TextInput{
width: Fill height: Fit
empty_text: "Search..."
on_return: || do_search(ui.search_input.text())
}
results_view := ScrollYView{
width: Fill height: Fill
flow: Down spacing: 8
on_render: ||{
for i, result in results {
RoundedView{
width: Fill height: Fit
padding: 12 flow: Down spacing: 4
draw_bg.color: #2a2a3d
draw_bg.border_radius: 6.0
new_batch: true
Label{text: result.title draw_text.color: #4af
draw_text.text_style.font_size: 12}
Label{text: result.snippet draw_text.color: #aaa
draw_text.text_style.font_size: 10}
}
}
}
}POST with JSON
fn submit_data(name, email) {
let req = net.HttpRequest{
url: "https://api.example.com/users"
method: net.HttpMethod.POST
headers: {"Content-Type": "application/json"}
body: {name: name, email: email}.to_json()
}
net.http_request(req) do net.HttpEvents{
on_response: |res| {
let data = res.body.parse_json()
// handle response
}
on_error: |e| { /* handle error */ }
}
}Streaming Response (LLM-style)
fn stream_chat(prompt) {
let req = net.HttpRequest{
url: "https://api.example.com/chat"
method: net.HttpMethod.POST
is_streaming: true
headers: {"Content-Type": "application/json" "Authorization": "Bearer TOKEN"}
body: {prompt: prompt, stream: true}.to_json()
}
var accumulated = ""
net.http_request(req) do net.HttpEvents{
on_stream: |res| {
accumulated += res.body.to_string()
// Update UI incrementally
ui.response_view.render()
}
on_complete: |res| {
// Stream finished
}
on_error: |e| { /* handle error */ }
}
}---
5. HTML Parsing Pattern (Search + Extract)
Parse and Query HTML
fn parse_page(html_string) {
let doc = html_string.parse_html()
// Query by tag
let paragraphs = doc.query("p")
// Query by class
let highlights = doc.query("span.highlight")
// Query by id
let main = doc.query("#main-content")
// Nested query
let nav_links = doc.query("nav").query("a")
// Extract text and attributes
let items = doc.query("a.result__a").array()
for item, i in items {
let title = item.text
let href = item.attr("href")
results.push({title: title, url: href})
}
}Full Search + Parse + Display
fn search_and_display(query) {
let req = net.HttpRequest{
url: "https://html.duckduckgo.com/html/?q=" + query
method: net.HttpMethod.GET
headers: {"User-Agent": "MakepadApp/1.0"}
}
net.http_request(req) do net.HttpEvents{
on_response: |res| {
let doc = res.body.to_string().parse_html()
// Extract results using CSS-like selectors
let links = doc.query("a.result__a").array()
let snippets = doc.query("a.result__snippet").array()
results = []
for link, i in links {
results.push({
title: link.text
url: link.attr("href")
snippet: if i < snippets.len() snippets[i].text else ""
})
}
ui.results_view.render()
}
on_error: |e| { /* handle */ }
}
}---
6. Hoverable Item Pattern (View with Animator Wrapping Label)
Label does NOT support animator. Wrap it in a View to get hover effects. Always set new_batch: true on both the item and the parent container.
use mod.prelude.widgets.*
let HoverItem = View{
width: Fill height: Fit
padding: 8
cursor: MouseCursor.Hand
new_batch: true
show_bg: true
draw_bg +: {
color: uniform(#0000)
color_hover: uniform(#fff2)
hover: instance(0.0)
pixel: fn(){
return Pal.premul(self.color.mix(self.color_hover, self.hover))
}
}
animator: Animator{
hover: {
default: @off
off: AnimatorState{
from: {all: Forward {duration: 0.15}}
apply: {draw_bg: {hover: 0.0}}
}
on: AnimatorState{
from: {all: Forward {duration: 0.15}}
apply: {draw_bg: {hover: 1.0}}
}
}
}
label := Label{text: "item" draw_text.color: #fff}
}
// Parent container ALSO needs new_batch
RoundedView{
width: 300 height: Fit
padding: 10 flow: Down spacing: 4
new_batch: true
draw_bg.color: #222
draw_bg.border_radius: 5.0
Label{text: "Todo Items" draw_text.color: #fff}
HoverItem{label.text: "Walk the dog"}
HoverItem{label.text: "Do laundry"}
HoverItem{label.text: "Buy groceries"}
}Key Points
show_bg: trueenables background renderingdraw_bg +: { ... }defines custom shader withinstance(0.0)for hover statepixel: fn()custom shader mixes between normal and hover colors- CRITICAL:
Pal.premul()wraps the return value for correct alpha blending new_batch: trueon BOTH the item AND the parent prevents text vanishing on hovercursor: MouseCursor.Handchanges cursor on hover- Animator
hovergroup drivesdraw_bg.hoverbetween 0.0 and 1.0
---
7. Theme-Aware Styling Pattern
Use theme.* variables for consistent, theme-respecting styling across light and dark modes.
Colors
// Background colors
draw_bg.color: theme.color_bg_app // app background
draw_bg.color: theme.color_bg_container // card/panel background
draw_bg.color: theme.color_app_caption_bar // header/footer bar
draw_bg.color: theme.color_bg_highlight // subtle highlight
draw_bg.color: theme.color_fg_app // foreground/toolbar
// Text colors
draw_text.color: theme.color_label_inner // primary text
draw_text.color: theme.color_label_inner_inactive // secondary/muted text
draw_text.color: theme.color_highlight // accent/link text
draw_text.color: theme.color_white // white text
draw_text.color: theme.color_warning // warning text
draw_text.color: theme.color_error // error textTypography
// Font sizes
draw_text.text_style.font_size: theme.font_size_1 // largest heading
draw_text.text_style.font_size: theme.font_size_2 // heading
draw_text.text_style.font_size: theme.font_size_3 // subheading
draw_text.text_style.font_size: theme.font_size_4 // small heading
draw_text.text_style.font_size: theme.font_size_p // body/paragraph
draw_text.text_style.font_size: theme.font_size_code // code/monospace
// Font faces
draw_text.text_style: theme.font_regular{}
draw_text.text_style: theme.font_bold{font_size: theme.font_size_2}
draw_text.text_style: theme.font_italic{}
draw_text.text_style: theme.font_code{}Spacing
// Uniform spacing
padding: theme.mspace_1 // small
padding: theme.mspace_2 // medium
padding: theme.mspace_3 // large
// Horizontal-only spacing
padding: theme.mspace_h_1
padding: theme.mspace_h_2
// Override specific sides
padding: theme.mspace_2{left: theme.space_3, right: theme.space_3}
// Gap between children
spacing: theme.space_1 // small
spacing: theme.space_2 // medium
spacing: theme.space_3 // largeComplete Theme-Aware Card
let ThemeCard = RoundedView{
width: Fill height: Fit
padding: theme.mspace_2{left: theme.space_3, right: theme.space_3}
flow: Down spacing: theme.space_1
draw_bg.color: theme.color_bg_container
draw_bg.border_radius: theme.corner_radius
new_batch: true
title := Label{
text: "Title"
draw_text.color: theme.color_label_inner
draw_text.text_style: theme.font_bold{font_size: theme.font_size_3}
}
body := Label{
text: "Body"
draw_text.color: theme.color_label_inner_inactive
draw_text.text_style.font_size: theme.font_size_p
}
}Setting the Theme
In Rust, set light or dark theme before loading widgets:
impl App {
fn run(vm: &mut ScriptVm) -> Self {
crate::makepad_widgets::theme_mod(vm);
script_eval!(vm, {
mod.theme = mod.themes.light // or mod.themes.dark
});
crate::makepad_widgets::widgets_mod(vm);
App::from_script_mod(vm, self::script_mod)
}
}---
8. Cross-Module Sharing via mod Object
The mod object is a shared namespace accessible from both Splash scripts and Rust code.
Storing State on mod
// In script_mod!
let state = {
counter: 0
user: {name: "" logged_in: false}
items: []
}
mod.state = stateAccessing from Rust with script_eval!
// Read and modify state from Rust
script_eval!(cx, {
mod.state.counter += 1
mod.state.user.name = #("Alice") // #() interpolates Rust values
ui.main_view.render()
});Sharing Functions
// Define utility functions accessible across the module
fn format_count(n) {
if n == 0 "No items"
else if n == 1 "1 item"
else n + " items"
}
// Use from on_render or event handlers
on_render: ||{
Label{text: format_count(todos.len())}
}Sharing Between script_mod! and script_eval!
Variables defined at the top level of script_mod! are accessible from script_eval!:
// script_mod!
let todos = []
fn add_todo(text, tag) {
todos.push({text: text, tag: tag, done: false})
ui.todo_list.render()
}
// From Rust:
script_eval!(cx, {
add_todo("New task from Rust", "dev")
});mod.state Pattern (Recommended)
Store all shared state on mod.state for clarity:
// Define
let state = {
counter: 0
theme: "dark"
settings: {sound: true, notifications: false}
}
mod.state = state
// Access from anywhere
mod.state.counter += 1
mod.state.theme = "light"
// From Rust
script_eval!(cx, {
mod.state.counter = #(new_value)
ui.main_view.render()
});---
Pattern Summary
| Pattern | Key Elements | When to Use |
|---|---|---|
| Counter | mod.state, on_render, script_eval! | Simple state + reactive UI |
| Todo List | let templates, for loops, inline events | Lists with CRUD operations |
| Reusable Template | let + := children, dot-path overrides | Component-like reuse |
| HTTP Request | net.http_request, callbacks | API calls, search |
| HTML Parsing | .parse_html(), .query() | Scraping, content extraction |
| Hoverable Item | View + animator + new_batch | Interactive list items |
| Theme-Aware | theme.* variables | Consistent cross-theme styling |
| Cross-Module | mod.state, script_eval! | Rust-Splash data sharing |