
Makepad 2.0 Events
- 43 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-events is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- makepad-2.0-events
- AI & Agent Building
- AI-coding skill
Makepad 2.0 Events by the numbers
- 43 all-time installs (skills.sh)
- +1 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #7,972 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-eventsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 43 |
|---|---|
| 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 Event & Action System
Overview
Makepad 2.0 uses a two-layer event system:
1. Splash Layer -- Inline event handlers written directly in script_mod! Splash code (on_click, on_render, on_return, on_startup). These handle UI interactions declaratively inside the script, close to the widget definitions.
2. Rust Layer -- The MatchEvent trait with handle_actions, handle_timer, handle_http_response, etc. These handle business logic, external I/O, and anything that needs full Rust power.
Both layers communicate through two bridge macros:
script_eval!(cx, { ... })-- Execute Splash code from Rust (update state, trigger renders)script_apply_eval!(cx, widget_ref, { ... })-- Patch widget properties from Rust at runtime
---
1. Splash Inline Event Handlers
Event handlers are attached directly to widgets inside script_mod! blocks. They use closure syntax with || for no arguments or |arg| for callbacks that receive a value.
on_click -- Button/widget click
Fires when the user clicks a button or clickable widget. No arguments for plain buttons, or |checked| for CheckBox which passes the new boolean state.
// Plain 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("")
}
}
}
// CheckBox click with checked state argument
check.on_click: |checked| toggle_todo(i, checked)
// Inline delete with closure capturing loop variable
delete.on_click: || delete_todo(i)
// Calling another widget's click programmatically
clear_done := ButtonFlatter{
text: "Clear completed"
on_click: ||{
todos.retain(|todo| !todo.done)
ui.todo_list.render()
}
}on_render -- Dynamic rendering
Fires when .render() is called on the target view. This is the primary mechanism for dynamic content. The body replaces the previous draw content of the view.
main_view := View{
width: Fill
height: Fill
on_render: ||{
counter_label := Label{
text: "Count: " + state.counter
draw_text.text_style.font_size: 24
}
}
}
// List rendering with for loop and per-item event handlers
todo_list := ScrollYView{
width: Fill height: Fill
new_batch: true
on_render: ||{
if todos.len() == 0
EmptyState{}
else for i, todo in todos {
TodoItem{
label.text: todo.text
check.active: todo.done
check.on_click: |checked| toggle_todo(i, checked)
delete.on_click: || delete_todo(i)
}
}
}
EmptyState{}
}Key point: on_render is NOT called automatically. You must call ui.widget_name.render() to trigger it. The new_batch: true property on a view tells the system to clear previous draw content before re-rendering.
on_return -- TextInput enter key
Fires when the user presses Enter/Return inside a TextInput. Commonly used to submit forms.
todo_input := TextInput{
width: Fill height: 9. * theme.space_1
empty_text: "What needs to be done?"
on_return: || ui.add_button.on_click()
}on_startup -- App startup
Fires once when the application starts. Defined at the Root level. Commonly used to trigger initial renders.
ui: Root{
on_startup: ||{
ui.main_view.render()
}
main_window := Window{
// ...
}
}Event handler capabilities
Inside event handlers you can:
- Call Splash functions:
add_todo(text, "dev") - Read widget values:
let text = ui.todo_input.text() - Set widget values:
ui.todo_input.set_text("") - Trigger re-renders:
ui.todo_list.render() - Trigger other widget clicks:
ui.add_button.on_click() - Modify state variables:
state.counter += 1 - Use array methods:
todos.push({text: "new", done: false}) - Use control flow:
if text != "" { ... }
---
2. Rust Event Handling -- MatchEvent Trait
The MatchEvent trait is the Rust-side event dispatcher. It receives platform events and widget actions through a set of handler methods.
Core trait definition (from draw/src/match_event.rs)
pub trait MatchEvent {
// Lifecycle
fn handle_startup(&mut self, _cx: &mut Cx) {}
fn handle_shutdown(&mut self, _cx: &mut Cx) {}
fn handle_foreground(&mut self, _cx: &mut Cx) {}
fn handle_background(&mut self, _cx: &mut Cx) {}
fn handle_pause(&mut self, _cx: &mut Cx) {}
fn handle_resume(&mut self, _cx: &mut Cx) {}
// Window focus
fn handle_window_got_focus(&mut self, _cx: &mut Cx, _window_id: &WindowId) {}
fn handle_window_lost_focus(&mut self, _cx: &mut Cx, _window_id: &WindowId) {}
// Frame
fn handle_next_frame(&mut self, _cx: &mut Cx, _e: &NextFrameEvent) {}
// Widget actions (most commonly used)
fn handle_action(&mut self, _cx: &mut Cx, _e: &Action) {}
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
for action in actions {
self.handle_action(cx, action);
}
}
// Input
fn handle_key_down(&mut self, _cx: &mut Cx, _e: &KeyEvent) {}
fn handle_key_up(&mut self, _cx: &mut Cx, _e: &KeyEvent) {}
fn handle_back_pressed(&mut self, _cx: &mut Cx) -> bool { false }
// Timer
fn handle_timer(&mut self, _cx: &mut Cx, _e: &TimerEvent) {}
// Drawing
fn handle_draw(&mut self, _cx: &mut Cx, _e: &DrawEvent) {}
fn handle_draw_2d(&mut self, _cx: &mut Cx2d) {}
// Network
fn handle_http_response(&mut self, _cx: &mut Cx, _request_id: LiveId, _response: &HttpResponse) {}
fn handle_http_request_error(&mut self, _cx: &mut Cx, _request_id: LiveId, _err: &HttpError) {}
fn handle_http_progress(&mut self, _cx: &mut Cx, _request_id: LiveId, _progress: &HttpProgress) {}
fn handle_http_stream(&mut self, _cx: &mut Cx, _request_id: LiveId, _data: &HttpResponse) {}
fn handle_http_stream_complete(&mut self, _cx: &mut Cx, _request_id: LiveId, _data: &HttpResponse) {}
// Signals
fn handle_signal(&mut self, _cx: &mut Cx) {}
// Media devices
fn handle_audio_devices(&mut self, _cx: &mut Cx, _e: &AudioDevicesEvent) {}
fn handle_midi_ports(&mut self, _cx: &mut Cx, _e: &MidiPortsEvent) {}
fn handle_video_inputs(&mut self, _cx: &mut Cx, _e: &VideoInputsEvent) {}
}Standard App boilerplate (required)
Every Makepad 2.0 app needs this Rust structure:
use makepad_widgets::*;
app_main!(App);
script_mod! {
// ... Splash UI code ...
}
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) {
// Handle widget actions here
}
}
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());
}
}CRITICAL: handle_event must call BOTH self.match_event(cx, event) (to dispatch to the MatchEvent handlers) AND self.ui.handle_event(cx, event, &mut Scope::empty()) (to propagate events to widgets).
---
3. Widget Action API
Access widgets from Rust using self.ui.widget_type(cx, ids!(name)), then query their action state by passing the &Actions reference.
Button
// Access: self.ui.button(cx, ids!(my_button))
// Returns ButtonRef
.clicked(actions) -> bool // Was clicked (finger down + up inside)
.pressed(actions) -> bool // Was pressed down
.long_pressed(actions) -> bool // Was long-pressed (not yet released)
.released(actions) -> bool // Was released (NOT a click)
.clicked_modifiers(actions) -> Option<KeyModifiers> // Clicked with modifier keys
.pressed_modifiers(actions) -> Option<KeyModifiers>
.released_modifiers(actions) -> Option<KeyModifiers>TextInput
// Access: self.ui.text_input(cx, ids!(my_input))
// Returns TextInputRef
.changed(actions) -> Option<String> // Text changed, returns new text
.returned(actions) -> Option<(String, KeyModifiers)> // Enter pressed, returns text + mods
.escaped(actions) -> bool // Escape pressed
.key_down_unhandled(actions) -> Option<KeyEvent> // Unhandled key event
.selected_text() -> String // Current selection (no actions needed)CheckBox
// Access: self.ui.check_box(cx, ids!(my_check))
// Returns CheckBoxRef
.changed(actions) -> Option<bool> // Toggled, returns new checked stateDropDown
// Access: self.ui.drop_down(cx, ids!(my_dropdown))
// Returns DropDownRef
.selected(actions) -> Option<usize> // Item selected, returns index
.changed(actions) -> Option<usize> // Same as selected
.changed_label(actions) -> Option<String> // Item selected, returns label string
.selected_item() -> usize // Current selection (no actions needed)
.selected_label() -> String // Current label (no actions needed)Slider
// Access: self.ui.slider(cx, ids!(my_slider))
// Returns SliderRef
.slided(actions) -> Option<f64> // Value changed during slide or text edit
.end_slide(actions) -> Option<f64> // Slide ended or text committed
.value() -> Option<f64> // Current value (no actions needed)RadioButton / RadioButtonGroup
// Access: self.ui.radio_button(cx, ids!(my_radio))
// Returns RadioButtonRef
.clicked(actions) -> bool // Was clicked
// For groups:
.selected(cx, actions) -> Option<usize> // Selected index in groupLinkLabel
// Access: self.ui.link_label(cx, ids!(my_link))
// Returns LinkLabelRef
.clicked(actions) -> bool
.clicked_modifiers(actions) -> Option<KeyModifiers>Complete example
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()
});
}
if let Some(text) = self.ui.text_input(cx, ids!(search_input)).changed(actions) {
self.perform_search(cx, &text);
}
if let Some(checked) = self.ui.check_box(cx, ids!(dark_mode)).changed(actions) {
self.set_theme(cx, checked);
}
if let Some(index) = self.ui.drop_down(cx, ids!(language)).selected(actions) {
self.change_language(cx, index);
}
}
}---
4. script_eval! Macro -- Rust to Splash Communication
script_eval! executes Splash code from within Rust handlers. It is the primary bridge for updating Splash state and triggering UI re-renders from Rust.
Signature: script_eval!(cx_or_vm, { splash_code })
The first argument can be &mut Cx (inside event handlers) or &mut ScriptVm (during initialization).
// Update Splash state and re-render
if self.ui.button(cx, ids!(increment_button)).clicked(actions) {
script_eval!(cx, {
mod.state.counter += 1
ui.main_view.render()
});
}
// Pass Rust values into Splash using #(expr) interpolation
let rust_string = "Hello from Rust";
script_eval!(cx, {
mod.value = #(rust_string)
});
// During init (inside App::run), use vm instead of cx
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)
}
}Value interpolation with #(expr)
Inside script_eval!, use #(rust_expression) to inject Rust values into Splash:
let count = 42_u64;
let message = "items found".to_string();
script_eval!(cx, {
mod.state.count = #(count)
mod.state.message = #(message)
ui.results_view.render()
});---
5. script_apply_eval! Macro -- Runtime Property Patching
script_apply_eval! patches widget properties at runtime from Rust. Unlike script_eval! which runs general Splash code, this targets a specific widget reference and applies property changes directly.
Signature: script_apply_eval!(cx, widget_ref, { property_patches })
// Patch a single property
let height = 500.0_f64;
script_apply_eval!(cx, item, {
page_view: { height: #(height) }
});
// Patch margin
let margin = Inset { top: 10.0, bottom: 10.0, left: 5.0, right: 5.0 };
script_apply_eval!(cx, content_view, {
margin: #(margin)
});
// Patch width with a Makepad type
script_apply_eval!(cx, content_view, {
width: #(Size::fit())
});
// Patch nested draw shader properties
let bg_color = vec4(0.2, 0.3, 0.4, 1.0);
let triangle_height = 8.0_f64;
script_apply_eval!(cx, content, {
draw_bg +: {
triangle_height: #(triangle_height)
background_color: #(bg_color)
}
});---
6. Widget Reference Access from Rust
Accessing widgets by ID
Use self.ui.widget_type(cx, ids!(name)) to get a typed reference:
self.ui.button(cx, ids!(increment_button)) // -> ButtonRef
self.ui.label(cx, ids!(status_label)) // -> LabelRef
self.ui.text_input(cx, ids!(search_input)) // -> TextInputRef
self.ui.check_box(cx, ids!(dark_mode)) // -> CheckBoxRef
self.ui.drop_down(cx, ids!(language)) // -> DropDownRef
self.ui.slider(cx, ids!(volume)) // -> SliderRef
self.ui.view(cx, ids!(content)) // -> ViewRef
self.ui.radio_button(cx, ids!(option_a)) // -> RadioButtonRefNested widget access with ids!
Use dot notation to access nested widgets:
self.ui.label(cx, ids!(container.inner.child_label))
self.ui.button(cx, ids!(toolbar.save_button))Generic widget access
self.ui.widget(cx, ids!(my_widget)) // -> WidgetRef (untyped)Common widget setter methods (called from Rust)
// Label
self.ui.label(cx, ids!(my_label)).set_text(cx, "Hello");
// TextInput
self.ui.text_input(cx, ids!(my_input)).set_text(cx, "default value");
// CheckBox
if let Some(mut inner) = self.ui.check_box(cx, ids!(my_check)).borrow_mut() {
inner.set_active(cx, true);
}
// DropDown
self.ui.drop_down(cx, ids!(my_dd)).set_selected_item(cx, 2);
self.ui.drop_down(cx, ids!(my_dd)).set_labels(cx, vec!["A".into(), "B".into()]);
// Slider
self.ui.slider(cx, ids!(my_slider)).set_value(cx, 0.5);
// Redraw any widget
self.ui.widget(cx, ids!(my_widget)).redraw(cx);
// Visibility
self.ui.button(cx, ids!(my_button)).set_visible(cx, false);---
7. Widget Methods from Splash
Inside script_mod! Splash code, access widgets through the ui prefix:
// Trigger a re-render of a view's on_render handler
ui.main_view.render()
// Get text from a TextInput
let text = ui.todo_input.text()
// Set text on a TextInput
ui.todo_input.set_text("")
// Trigger a click on another widget
ui.add_button.on_click()---
8. Hit Events (Low-Level)
For custom widgets that need raw input handling, the Hit enum provides low-level events. Access them through event.hits(cx, area) in a widget's handle_event.
pub enum Hit {
// Keyboard focus
KeyFocus(KeyFocusEvent),
KeyFocusLost(KeyFocusEvent),
// Keyboard input
KeyDown(KeyEvent),
KeyUp(KeyEvent),
// Text input
TextInput(TextInputEvent),
TextRangeReplace(TextRangeReplaceEvent),
TextCopy(TextClipboardEvent),
TextCut(TextClipboardEvent),
ImeAction(ImeActionEvent),
// Pointer/finger events
FingerDown(FingerDownEvent),
FingerMove(FingerMoveEvent),
FingerUp(FingerUpEvent),
FingerScroll(FingerScrollEvent),
FingerLongPress(FingerLongPressEvent),
// Hover events
FingerHoverIn(FingerHoverEvent),
FingerHoverOver(FingerHoverEvent),
FingerHoverOut(FingerHoverEvent),
// Triggers
Trigger(TriggerHitEvent),
Nothing,
}Usage in custom widgets
impl Widget for MyCustomWidget {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
match event.hits(cx, self.draw_bg.area()) {
Hit::FingerDown(fd) => {
cx.set_key_focus(self.draw_bg.area());
// fd.abs -- absolute position
// fd.rel -- relative position within widget
// fd.modifiers -- KeyModifiers
}
Hit::FingerUp(fu) => {
if fu.is_over {
// Finger was released inside the widget = click
let uid = self.widget_uid();
cx.widget_action(uid, MyWidgetAction::Clicked);
}
}
Hit::FingerMove(fm) => {
// fm.abs, fm.rel -- current position
}
Hit::FingerHoverIn(_) => {
self.animator_play(cx, ids!(hover.on));
}
Hit::FingerHoverOut(_) => {
self.animator_play(cx, ids!(hover.off));
}
Hit::KeyDown(ke) => {
match ke.key_code {
KeyCode::ReturnKey => { /* handle enter */ }
KeyCode::Escape => { /* handle escape */ }
_ => {}
}
}
_ => {}
}
}
}---
9. Timer Events
Timers are created via Cx methods and received in handle_timer.
Creating timers
// One-shot timer (fires once after delay in seconds)
let timer: Timer = cx.start_timeout(2.0); // 2 seconds
// Repeating timer (fires every interval seconds)
let timer: Timer = cx.start_interval(0.5); // every 500ms
// Stop a timer
cx.stop_timer(timer);Handling timer events
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
poll_timer: Timer,
}
impl MatchEvent for App {
fn handle_startup(&mut self, cx: &mut Cx) {
self.poll_timer = cx.start_interval(30.0);
}
fn handle_timer(&mut self, cx: &mut Cx, event: &TimerEvent) {
if self.poll_timer.is_timer(event).is_some() {
self.do_periodic_work(cx);
}
}
}---
10. HTTP / Network Events
Making HTTP requests from Rust
let request_id = live_id!(my_request);
let mut req = HttpRequest::new(url, HttpMethod::Get);
req.set_header("Content-Type", "application/json");
req.set_body(body_bytes);
cx.http_request(request_id, req);Handling HTTP responses
impl MatchEvent for App {
fn handle_http_response(&mut self, cx: &mut Cx, request_id: LiveId, response: &HttpResponse) {
if request_id == live_id!(my_request) {
let body = &response.body;
// Process response...
}
}
fn handle_http_request_error(&mut self, cx: &mut Cx, request_id: LiveId, err: &HttpError) {
if request_id == live_id!(my_request) {
log!("Request failed: {}", err.message);
}
}
fn handle_http_progress(&mut self, cx: &mut Cx, request_id: LiveId, progress: &HttpProgress) {
if request_id == live_id!(my_request) {
let pct = progress.loaded as f64 / progress.total as f64;
// Update progress UI...
}
}
// For streaming responses
fn handle_http_stream(&mut self, cx: &mut Cx, request_id: LiveId, data: &HttpResponse) {
// Receive incremental chunks
}
fn handle_http_stream_complete(&mut self, cx: &mut Cx, request_id: LiveId, data: &HttpResponse) {
// Stream finished
}
}Making HTTP requests from Splash
let req = net.HttpRequest{
url: "https://api.example.com/data"
method: net.HttpMethod.GET
headers: {"Content-Type": "application/json"}
}
net.http_request(req) do net.HttpEvents{
on_response: |res| {
// handle response
let data = res.body.to_string().parse_json()
}
on_error: |e| {
// handle error
}
}
// Streaming HTTP request
let req = net.HttpRequest{
url: "https://api.openai.com/v1/chat/completions"
method: net.HttpMethod.POST
headers: {"Content-Type": "application/json"}
is_streaming: true
body: {model: "gpt-4" messages: [{role: "user" content: "Hello"}]}
}
net.http_request(req) do net.HttpEvents{
on_stream: fn(res){
// Process each streaming chunk
let chunk = res.body.to_string()
}
on_complete: fn(res){
// Stream finished
}
}---
11. Event Flow Diagram
User Input (click/key/mouse)
|
v
Platform Event Loop (Cx)
|
v
AppMain::handle_event(&mut self, cx, event)
|
+--> self.match_event(cx, event)
| |
| +--> MatchEvent::handle_startup / handle_shutdown / ...
| +--> MatchEvent::handle_actions(cx, actions)
| | |
| | +--> self.ui.button(cx, ids!(name)).clicked(actions)
| | +--> script_eval!(cx, { ... })
| | |
| | v
| | ScriptVm::eval -> updates Splash state
| | |
| | +--> ui.view.render() -> schedules re-draw
| |
| +--> MatchEvent::handle_timer(cx, event)
| +--> MatchEvent::handle_http_response(cx, id, response)
| +--> MatchEvent::handle_key_down(cx, event)
|
+--> self.ui.handle_event(cx, event, scope)
|
+--> Widget tree event propagation
+--> Hit detection (FingerDown/Up/Move)
+--> Splash on_click / on_return handlers execute
+--> on_render handlers execute during draw
+--> Widget actions emitted -> feed back into Actions---
12. Complete Patterns
Pattern 1: Counter (button click -> state update -> re-render)
use makepad_widgets::*;
app_main!(App);
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"
}
}
}
}
}
}
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());
}
}Pattern 2: Form (text input -> validation -> submission)
// Inside script_mod!
fn submit_form(){
let name = ui.name_input.text()
let email = ui.email_input.text()
if name == "" {
ui.error_label.set_text("Name is required")
} else if email == "" {
ui.error_label.set_text("Email is required")
} else {
// Process form...
ui.error_label.set_text("")
ui.name_input.set_text("")
ui.email_input.set_text("")
}
}
// In the UI tree:
name_input := TextInput{
empty_text: "Your name"
}
email_input := TextInput{
empty_text: "your@email.com"
on_return: || submit_form()
}
submit_button := Button{
text: "Submit"
on_click: || submit_form()
}
error_label := Label{
text: ""
draw_text.color: #f00
}Pattern 3: List with per-item events
let items = []
items.push({name: "Item A", selected: false})
items.push({name: "Item B", selected: false})
fn toggle_item(index, checked){
items[index].selected = checked
}
fn delete_item(index){
items.remove(index)
ui.item_list.render()
}
// In UI:
item_list := ScrollYView{
width: Fill height: Fill
new_batch: true
on_render: ||{
for i, item in items {
RoundedView{
width: Fill height: Fit
flow: Right
check := CheckBox{
active: item.selected
on_click: |checked| toggle_item(i, checked)
}
Label{text: item.name}
Button{
text: "Delete"
on_click: || delete_item(i)
}
}
}
}
}Pattern 4: Cross-layer communication (Splash state + Rust logic)
// Rust side: handle complex logic, then update Splash state
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.button(cx, ids!(fetch_button)).clicked(actions) {
// Rust handles the HTTP request
let request_id = live_id!(fetch_data);
let req = HttpRequest::new("https://api.example.com/data", HttpMethod::Get);
cx.http_request(request_id, req);
}
}
fn handle_http_response(&mut self, cx: &mut Cx, request_id: LiveId, response: &HttpResponse) {
if request_id == live_id!(fetch_data) {
let data = String::from_utf8_lossy(&response.body).to_string();
// Bridge back to Splash to update UI
script_eval!(cx, {
mod.state.data = #(data)
mod.state.loading = false
ui.results_view.render()
});
}
}
}---
Source Files Reference
| File | Purpose |
|---|---|
draw/src/match_event.rs | MatchEvent trait definition |
widgets/src/widget_match_event.rs | WidgetMatchEvent trait (for custom widgets) |
platform/src/event/event.rs | Event enum, Hit enum, Timer |
platform/src/event/finger.rs | Finger/pointer event types |
platform/script/src/lib.rs | script_eval! macro definition |
platform/script/derive/src/lib.rs | script_apply_eval! proc macro |
widgets/src/button.rs | Button action API |
widgets/src/text_input.rs | TextInput action API |
widgets/src/check_box.rs | CheckBox action API |
widgets/src/drop_down.rs | DropDown action API |
widgets/src/slider.rs | Slider action API |
widgets/src/radio_button.rs | RadioButton action API |
examples/counter/src/app.rs | Counter example (minimal) |
examples/todo/src/app.rs | Todo example (comprehensive) |
examples/git/src/app.rs | Git example (HTTP, timers) |
examples/camera/src/ | Camera example (media events) |
examples/text_selection/src/ | Text selection example |
---
13. New Events (March 2026)
Selection & Clipboard Events
New Hit variants for text selection and clipboard operations:
Hit::TextCopy(TextClipboardEvent) // User copied text (Ctrl+C or native toolbar)
Hit::TextCut(TextClipboardEvent) // User cut text (Ctrl+X or native toolbar)Mobile Selection Handles: Native selection handles on iOS/Android emit:
Event::SelectionHandleDrag // User dragging a selection handleLong-press on text selects a word and shows the native clipboard toolbar. TextFlow widget integrates clipboard actions automatically.
Popup Window Events
For popup windows (context menus, dropdowns):
Event::WindowClosed // Popup window was closed
Event::PopupDismissed // Popup was dismissed by compositor (Wayland) or click-outsideWindowClosed is always emitted before PopupDismissed. Apps must handle PopupDismissed for explicit-close semantics.
IME Events
IME (Input Method Editor) support for CJK text input:
Hit::ImeAction(ImeActionEvent) // IME composition committed/cancelled
Hit::TextRangeReplace(TextRangeReplaceEvent) // IME text replacementLinux X11 IME: Full IME support added for X11. IME popup window positioning fixed to avoid appearing without TextInput focus.
Video & Media Events
// In MatchEvent trait:
fn handle_video_inputs(&mut self, _cx: &mut Cx, _e: &VideoInputsEvent) {}Video input device enumeration events from the media plugin system. Camera and video playback events route through the media plugin architecture.
Primary Selection (Linux)
Linux primary selection (middle-click paste):
- Wayland:
zwp_primary_selection_device_manager_v1protocol - X11: PRIMARY atom handling
- Integrated with TextInput and TextFlow widgets automatically
Makepad 2.0 Event Patterns -- Working Examples
This reference contains complete working patterns extracted from the Makepad 2.0 codebase. All Splash code uses Makepad 2.0 syntax (no commas, no semicolons, colon properties). All Rust code uses makepad_widgets::* imports.
---
1. Counter App -- Button Click -> State Update -> Re-render
The simplest complete event flow. A button click in Rust triggers script_eval! which updates Splash state and calls .render() to refresh the view.
Source: examples/counter/src/app.rs
Splash side (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 side
use makepad_widgets::*;
app_main!(App);
// script_mod! { ... } (Splash code above)
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());
}
}Event flow
1. User clicks the "Increment" button 2. Button widget emits ButtonAction::Clicked into Actions 3. AppMain::handle_event calls self.match_event(cx, event) 4. MatchEvent::match_event dispatches Event::Actions to handle_actions 5. self.ui.button(cx, ids!(increment_button)).clicked(actions) returns true 6. script_eval! runs Splash code: increments state.counter, calls render() 7. render() schedules main_view for redraw 8. On next draw pass, on_render fires, creating a Label with the new count
---
2. Todo App -- List Rendering with Per-Item Events
Demonstrates dynamic list rendering with for loops, per-item click handlers, and multiple Splash functions coordinating state.
Source: examples/todo/src/app.rs
State and functions
let todos = []
// Seed sample data
todos.push({text: "Get AI to control UI", tag: "dev", done: true})
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
}Template definition
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
}
delete := ButtonFlatter{
text: "x"
width: 28 height: 28
}
}Input bar with on_return and on_click
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 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
check.active: todo.done
check.on_click: |checked| toggle_todo(i, checked)
delete.on_click: || delete_todo(i)
}
}
}
EmptyState{}
}Clear completed with array retain
clear_done := ButtonFlatter{
text: "Clear completed"
on_click: ||{
todos.retain(|todo| !todo.done)
ui.todo_list.render()
}
}Key patterns demonstrated
on_returnon TextInput delegates to another widget'son_clickon_renderwithfor i, todo in todoscreates per-item UI with closures- Each closure captures the loop variable
ifor per-item actions CheckBox.on_click: |checked|receives the new boolean statetodos.retain(|todo| !todo.done)filters the array using a predicateui.todo_list.render()triggers re-render after any state mutation
---
3. Form Validation Pattern
A pattern for input validation before submission, using Splash-only event handlers.
let form_errors = {
name: ""
email: ""
}
fn validate_email(email){
// Simple check -- Splash does not have regex by default
if email == "" "Email is required"
else if !email.contains("@") "Invalid email address"
else ""
}
fn validate_name(name){
if name == "" "Name is required"
else if name.len() < 2 "Name too short"
else ""
}
fn submit_form(){
let name = ui.name_input.text()
let email = ui.email_input.text()
form_errors.name = validate_name(name)
form_errors.email = validate_email(email)
ui.error_view.render()
if form_errors.name == "" && form_errors.email == "" {
// All valid -- process submission
ui.name_input.set_text("")
ui.email_input.set_text("")
ui.success_view.render()
}
}
// UI definition
View{
width: Fill height: Fit
flow: Down spacing: theme.space_2
name_input := TextInput{
width: Fill
empty_text: "Your name"
}
email_input := TextInput{
width: Fill
empty_text: "your@email.com"
on_return: || submit_form()
}
error_view := View{
width: Fill height: Fit
flow: Down
on_render: ||{
if form_errors.name != "" {
Label{text: form_errors.name draw_text.color: #f44}
}
if form_errors.email != "" {
Label{text: form_errors.email draw_text.color: #f44}
}
}
}
submit_button := Button{
text: "Submit"
on_click: || submit_form()
}
success_view := View{
width: Fill height: Fit
on_render: ||{
Label{text: "Submitted!" draw_text.color: #4f4}
}
}
}---
4. Custom Widget Event Handling
When building a custom widget in Rust, use the Hit enum to handle raw input events and emit widget-specific actions.
use makepad_widgets::*;
// Define custom action
#[derive(Clone, Debug, DefaultNone)]
pub enum ColorPickerAction {
Changed(Vec4),
DragStart,
DragEnd,
None,
}
#[derive(Script, ScriptHook, Widget)]
pub struct ColorPicker {
#[deref]
view: View,
#[live]
draw_bg: DrawQuad,
#[rust]
current_color: Vec4,
#[rust]
dragging: bool,
}
impl Widget for ColorPicker {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
let uid = self.widget_uid();
match event.hits(cx, self.draw_bg.area()) {
Hit::FingerDown(fd) => {
self.dragging = true;
cx.set_key_focus(self.draw_bg.area());
self.update_color_from_pos(fd.rel);
cx.widget_action(uid, ColorPickerAction::DragStart);
cx.widget_action(uid, ColorPickerAction::Changed(self.current_color));
self.draw_bg.redraw(cx);
}
Hit::FingerMove(fm) => {
if self.dragging {
self.update_color_from_pos(fm.rel);
cx.widget_action(uid, ColorPickerAction::Changed(self.current_color));
self.draw_bg.redraw(cx);
}
}
Hit::FingerUp(_fu) => {
if self.dragging {
self.dragging = false;
cx.widget_action(uid, ColorPickerAction::DragEnd);
}
}
Hit::FingerHoverIn(_) => {
cx.set_cursor(MouseCursor::Crosshair);
}
Hit::FingerHoverOut(_) => {
cx.set_cursor(MouseCursor::Default);
}
_ => {}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
self.draw_bg.begin(cx, walk, Layout::default());
self.draw_bg.end(cx);
DrawStep::done()
}
}
impl ColorPicker {
fn update_color_from_pos(&mut self, pos: DVec2) {
// Convert position to color...
self.current_color = vec4(pos.x as f32, pos.y as f32, 0.5, 1.0);
}
}
// Consuming the custom action from the App
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if let Some(item) = actions.find_widget_action(
self.ui.widget(cx, ids!(color_picker)).widget_uid()
) {
if let ColorPickerAction::Changed(color) = item.cast() {
// Use the selected color
script_eval!(cx, {
mod.state.selected_color = #(color)
ui.preview.render()
});
}
}
}
}---
5. Keyboard Shortcut Handling
Global keyboard shortcuts are handled through handle_key_down on the MatchEvent trait.
impl MatchEvent for App {
fn handle_key_down(&mut self, cx: &mut Cx, event: &KeyEvent) {
// Ctrl+S / Cmd+S -- Save
if event.modifiers.control || event.modifiers.logo {
match event.key_code {
KeyCode::KeyS => {
self.save_document(cx);
}
KeyCode::KeyZ => {
if event.modifiers.shift {
self.redo(cx);
} else {
self.undo(cx);
}
}
KeyCode::KeyN => {
self.new_document(cx);
}
_ => {}
}
}
// Plain keys
match event.key_code {
KeyCode::Escape => {
self.cancel_current_operation(cx);
}
KeyCode::F5 => {
self.refresh(cx);
}
_ => {}
}
}
}Widget-level key handling (inside a custom widget)
impl Widget for MyEditor {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
match event.hits(cx, self.draw_bg.area()) {
Hit::KeyDown(ke) => {
match ke.key_code {
KeyCode::ReturnKey => {
let uid = self.widget_uid();
cx.widget_action(uid, EditorAction::Submit);
}
KeyCode::Escape => {
let uid = self.widget_uid();
cx.widget_action(uid, EditorAction::Cancel);
}
KeyCode::Tab => {
// Insert tab or move focus
}
_ => {}
}
}
Hit::KeyFocus(_) => {
self.animator_play(cx, ids!(focus.on));
}
Hit::KeyFocusLost(_) => {
self.animator_play(cx, ids!(focus.off));
}
_ => {}
}
}
}---
6. Timer and Animation Events
Polling timer (from Git example)
Source: examples/git/src/app.rs
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
poll_timer: Timer,
#[rust]
active_request_id: Option<LiveId>,
}
impl MatchEvent for App {
fn handle_startup(&mut self, cx: &mut Cx) {
// Start polling every 30 seconds
self.poll_timer = cx.start_interval(30.0);
self.poll_remote(cx);
}
fn handle_timer(&mut self, cx: &mut Cx, event: &TimerEvent) {
if self.poll_timer.is_timer(event).is_some() {
self.poll_remote(cx);
}
}
}One-shot delay timer
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
debounce_timer: Timer,
#[rust]
pending_search: String,
}
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
// Debounce text input -- wait 300ms after last keystroke
if let Some(text) = self.ui.text_input(cx, ids!(search_input)).changed(actions) {
self.pending_search = text;
// Cancel previous timer, start new one
cx.stop_timer(self.debounce_timer);
self.debounce_timer = cx.start_timeout(0.3);
}
}
fn handle_timer(&mut self, cx: &mut Cx, event: &TimerEvent) {
if self.debounce_timer.is_timer(event).is_some() {
// Timer fired -- execute the search
self.execute_search(cx, &self.pending_search.clone());
}
}
}NextFrame animation
For smooth per-frame animations, use handle_next_frame:
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
animating: bool,
#[rust]
animation_progress: f64,
}
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.button(cx, ids!(animate_button)).clicked(actions) {
self.animating = true;
self.animation_progress = 0.0;
cx.request_next_frame();
}
}
fn handle_next_frame(&mut self, cx: &mut Cx, _event: &NextFrameEvent) {
if self.animating {
self.animation_progress += 0.016; // ~60fps
if self.animation_progress >= 1.0 {
self.animation_progress = 1.0;
self.animating = false;
} else {
cx.request_next_frame(); // Request another frame
}
let progress = self.animation_progress;
script_eval!(cx, {
mod.state.progress = #(progress)
ui.animated_view.render()
});
}
}
}---
7. Network Response Handling (Full Pattern)
Complete pattern showing HTTP requests initiated from Rust, with response handling that updates Splash UI state.
Source: adapted from examples/git/src/app.rs
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
let state = {
loading: false
data: ""
error: ""
}
mod.state = state
startup() do #(App::script_component(vm)){
ui: Root{
on_startup: ||{
ui.content_view.render()
}
main_window := Window{
window.inner_size: vec2(600, 400)
body +: {
width: Fill height: Fill
flow: Down spacing: 12
padding: 20
fetch_button := Button{
text: "Fetch Data"
}
content_view := View{
width: Fill height: Fill
flow: Down
on_render: ||{
if state.loading {
Label{text: "Loading..." draw_text.color: #888}
} else if state.error != "" {
Label{text: "Error: " + state.error draw_text.color: #f44}
} else if state.data != "" {
Label{text: state.data}
} else {
Label{text: "Click Fetch to load data" draw_text.color: #888}
}
}
}
}
}
}
}
}
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,
#[rust]
active_request: Option<LiveId>,
}
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.button(cx, ids!(fetch_button)).clicked(actions) {
// Show loading state
script_eval!(cx, {
mod.state.loading = true
mod.state.error = ""
ui.content_view.render()
});
// Fire HTTP request
let request_id = live_id!(fetch_data);
let req = HttpRequest::new(
"https://api.example.com/data",
HttpMethod::Get,
);
cx.http_request(request_id, req);
self.active_request = Some(request_id);
}
}
fn handle_http_response(
&mut self,
cx: &mut Cx,
request_id: LiveId,
response: &HttpResponse,
) {
if Some(request_id) != self.active_request {
return;
}
self.active_request = None;
let body = String::from_utf8_lossy(&response.body).to_string();
script_eval!(cx, {
mod.state.loading = false
mod.state.data = #(body)
ui.content_view.render()
});
}
fn handle_http_request_error(
&mut self,
cx: &mut Cx,
request_id: LiveId,
err: &HttpError,
) {
if Some(request_id) != self.active_request {
return;
}
self.active_request = None;
let message = err.message.clone();
script_eval!(cx, {
mod.state.loading = false
mod.state.error = #(message)
ui.content_view.render()
});
}
fn handle_http_progress(
&mut self,
cx: &mut Cx,
request_id: LiveId,
progress: &HttpProgress,
) {
if Some(request_id) != self.active_request {
return;
}
let loaded = progress.loaded;
let total = progress.total;
log!("Download progress: {} / {} bytes", loaded, total);
}
}
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());
}
}---
Summary of Key Rules
1. Splash handlers (on_click, on_render, on_return, on_startup) are written inline in script_mod! and handle UI-level interactions.
2. Rust handlers (handle_actions, handle_timer, handle_http_response, etc.) handle business logic, I/O, and anything that needs typed Rust code.
3. Bridge macros: Use script_eval!(cx, { ... }) to push state changes from Rust into Splash and trigger re-renders.
4. Widget actions: Access widgets with self.ui.widget_type(cx, ids!(name)) and query actions with .clicked(actions), .changed(actions), etc.
5. on_render is not automatic: You must call ui.widget_name.render() to trigger a view's on_render handler. Set new_batch: true to clear old content.
6. Closures capture loop variables: In for i, item in list { ... }, event handlers like on_click: || delete(i) correctly capture the current i.
7. AppMain::handle_event must call both:
self.match_event(cx, event)-- dispatches to MatchEvent handlersself.ui.handle_event(cx, event, &mut Scope::empty())-- propagates to widgets
8. `fn tick()` auto-timer: Define fn tick() in Splash code and the Splash widget automatically starts a 1-second interval timer that calls it. No setup needed.
9. `fn on_audio()` callback: Canvas injects audio state globals (_playing, _pos, _dur, _amp, _b0–_b15) and calls fn on_audio() ~10Hz during playback. Use this for real-time audio-reactive UI updates.
10. Splash button events don't reach `handle_actions`: Dynamically created Splash buttons (via POST /splash) have unstable UIDs. The uid_map may not rebuild in time. Always use on_click: handlers inside Splash for button interactions. The HTTP GET /event bridge is unreliable for this reason.