Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第9章:事件与交互

为什么这很重要

前面的章节已经多次使用 on_click: ||{...} 让按钮响应点击。但事件系统远不止按钮点击——TextInput 的回车提交、Slider 的值变化、View 的渲染回调、应用的启动事件,都是 Splash 事件系统的一部分。

本章系统讲解 Splash 中所有可用的事件类型、闭包语法和事件处理模式。读完本章,你能够让 UI 中的任何组件响应用户操作,并理解事件从触发到回调执行的完整链路。

注意:本章聚焦于 Splash 侧的事件(on_clickon_render 等回调),不涉及 Rust 侧的 MatchEvent 细节(详见第22章:事件与 Action 系统)。在纯 Splash 应用和 Canvas Agent-to-App 场景中,Splash 侧事件就是你需要的全部。

flowchart TD
    A["用户操作"] --> B{事件类型}
    B -->|点击按钮| C["on_click: ||{...}"]
    B -->|输入回车| D["on_return: ||{...}"]
    B -->|Slider 变化| E["on_change: |val|{...}"]
    B -->|渲染请求| F["on_render: ||{...}"]
    B -->|应用启动| G["on_startup: ||{...}"]
    C --> H["执行 Splash 代码"]
    D --> H
    E --> H
    F --> H
    G --> H

四种事件回调

on_click:按钮点击

on_click 是最常用的事件。它绑定在 Button 上,在用户点击(鼠标松开或触屏抬起)时触发:

Button{text: "Start" draw_bg.color: #x51cf66
    on_click: ||{
        state.running = true
        refresh()
    }
}

改编自:tools/canvas/examples/pomodoro.splash:58-63

||{...} 是 Splash 的闭包语法——两个竖线表示"没有参数",花括号内是执行体。闭包可以访问外部作用域的变量(如 stateui),这就是闭包的"捕获"能力。

pomodoro 应用中,每个按钮都有自己的 on_click——Start/Pause 切换运行状态,Reset 重置计时器,Skip 跳到下一个阶段:

// Start/Pause 按钮
start_btn := Button{text: "Start"
    on_click: ||{
        if pomo.running { pomo.running = false }
        else { pomo.running = true }
        refresh()
    }
}

// Reset 按钮
Button{text: "Reset"
    on_click: ||{ pomo.running = false  pomo.remaining = mode_dur()  refresh() }
}

// Skip 按钮
Button{text: "Skip"
    on_click: ||{ next_mode()  refresh() }
}

来源:tools/canvas/examples/pomodoro.splash:58-70(格式化)

注意 on_click 的位置——它是 Button 的一个属性,和 textdraw_bg.color 同级。这意味着它遵循 Splash 的属性语法:空格分隔,不需要逗号。你可以把 on_click 和其他属性写在同一行,也可以单独一行。

多行 vs 单行 on_click:当逻辑简短(一两行)时,可以写成紧凑的单行形式:

Button{text: "Reset" on_click: ||{ state.count = 0  refresh() }}

多个语句用双空格分隔(Splash 没有分号,空格就是语句分隔符)。当逻辑复杂时,展开为多行更清晰。

on_return:TextInput 回车

on_return 绑定在 TextInput 上,在用户按回车键时触发。这是表单提交的标准模式:

input := TextInput{width: Fill height: Fit
    empty_text: "What needs to be done?"
    on_return: ||{
        let text = ui.input.text()
        if text != "" {
            state.items[state.count] = text
            state.count = state.count + 1
            ui.input.set_text("")
            refresh()
        }
    }
}

on_return 的常见写法是无参数闭包(||{...}),输入框中的文字通过 ui.input.text() 获取——这是 Splash 的 Widget API,通过 := 名称访问 Widget 实例并调用方法。运行时也会把当前文本作为第一个参数传给处理器,因此需要时同样可以写成 |text|{...}

在 Rust + Splash 模式中,on_return 的等价写法是 text_input.returned(actions)(详见第5章的 Todo 示例)。纯 Splash 模式下直接使用 on_return 更简洁。

scratchpad 示例中有一个优雅的用法——TextInput 回车时程序化触发按钮点击:

input := TextInput{
    on_return: || ui.search_button.on_click()
}
search_button := Button{text: "Search"
    on_click: ||{ /* 搜索逻辑 */ }
}

来源:examples/scratchpad/src/main.rs:72(简化)

ui.search_button.on_click() 程序化触发按钮的点击事件——这避免了在 on_returnon_click 中重复相同的逻辑。

on_change:Slider 值变化

on_change 绑定在 Slider 上,在用户拖动滑块改变值时触发。它使用有参数的闭包语法 |val|{...}

Slider{text: "Volume" min: 0. max: 100. default: 50.
    on_change: |val|{
        state.volume = val
        ui.volume_label.set_text("Volume: " + val)
    }
}

|val|{...} 中的 val 是 Slider 的当前值(浮点数)。这是 on_changeon_click 的关键区别:on_click 是无参数闭包(||{...}),on_change 是有参数闭包(|val|{...})。

参数名可以是任意合法标识符:|v|{...}|value|{...}|x|{...} 都可以。

on_render:渲染回调

on_render 和前三个不同——它不是用户触发的事件,而是程序触发的渲染回调。当你调用 ui.view_name.render() 时,这个 View 的 on_render 闭包被执行,返回的 Widget 定义会以 reload 方式重新应用到该 View。

main_view := View{width: Fill height: Fit flow: Down
    on_render: ||{
        if state.page == "home" {
            Label{text: "Welcome Home" draw_text.color: #xfff draw_text.text_style.font_size: 20}
        }
        if state.page == "settings" {
            Label{text: "Settings" draw_text.color: #xfff draw_text.text_style.font_size: 20}
            Slider{text: "Brightness" min: 0. max: 100. default: 70.}
        }
    }
}

// 切换页面
Button{text: "Go Home" on_click: ||{ state.page = "home"  ui.main_view.render() }}
Button{text: "Settings" on_click: ||{ state.page = "settings"  ui.main_view.render() }}

on_render 实现了条件渲染——根据状态的不同值,生成不同的 Widget 树。这是 Splash 中 if/else 在 UI 层面的应用。每次 render() 被调用,on_render 中的代码都会重新执行,并把新的结果重新应用到该 View;同名 / 同 id 的子节点会尽量复用,缺失的子节点则会被移除。

on_render 的典型用途

  • 条件渲染(如上面的页面切换)
  • 动态列表(while 循环生成 N 个 Widget,如第5章的 Todo)
  • 数据驱动显示(Label{text: "Count: " + state.counter}

on_render vs set_text:两种方式都能更新 UI,但适用场景不同:

方式适用场景优势限制
set_text()更新已有 Widget 的文字快速,不重建 Widget只能改文字,不能增删 Widget
on_render根据状态动态生成 Widget可以条件渲染、循环生成会重新应用整块子树,结构变化成本更高

经验法则:如果只是更新文字或颜色,用 set_text()。如果需要根据状态显示/隐藏组件或改变组件数量,用 on_render

两者也可以结合使用。比如一个仪表板,大部分内容用 set_text() 更新(效率高),但"当前页面"的切换用 on_render(需要替换整个 Widget 子树)。pomodoro 就是这种混合模式——refresh() 函数用 set_text() 更新时间标签和按钮文字,而不重建整个 UI。

on_render 的另一个重要用途是动态列表。在第5章的纯 Splash Todo 中,on_render 内的 while 循环根据数组长度生成 N 个列表项。每次添加或删除 Todo 后调用 render(),这段列表定义会重新执行,并重新应用到目标 View。这比手动增删单个 Widget 简单得多——代价是列表结构变化越大,更新成本越高。大规模列表仍然需要 PortalList 虚拟化(详见第15章)。

on_renderon_startup 的区别on_startup 在应用启动时执行一次,常用于初始化(如第一次调用 render())。on_render 在每次 render() 调用时执行,可以执行多次。两者都是无参数闭包。


闭包语法详解

无参数闭包

on_click: ||{ state.counter = state.counter + 1 }
on_return: ||{ /* 处理回车 */ }
on_render: ||{ Label{text: "dynamic"} }
on_startup: ||{ ui.main_view.render() }

|| 表示"无参数"。花括号内是执行体。

有参数闭包

on_change: |val|{ state.volume = val }

|val| 表示"一个参数,名为 val"。on_change 是最常见的有参数用法;另外 TextInputon_change / on_return 在运行时也会传入当前文本,不需要时可以继续写成 ||{...}

闭包中的变量访问

闭包可以访问定义时的外层作用域中的变量:

let state = { counter: 0 }

fn refresh() { ui.label.set_text("" + state.counter) }

Button{text: "Add"
    on_click: ||{
        state.counter = state.counter + 1   // 访问 state
        refresh()                             // 调用函数
        ui.label.set_text("Updated!")         // 访问 ui
    }
}

在闭包内可以访问的东西:

  • state 和其他 let 定义的变量
  • ui——Widget 树的根引用
  • 全局函数——用 fn 定义的函数
  • Splash 内置 API——math.floor()

不能在闭包内做的事:

  • 定义新的 let 模板(let 模板只能在顶层定义,详见第8章)
  • 创建命名 Widget(:= 只能在 Widget 树定义中使用,不能在事件回调中)
  • 直接调用 Rust 函数(需要通过 #(...) 桥接,这是 Rust 侧的高级用法)

一个常见的困惑是:为什么在 on_click 中不能写 label := Label{text: "new"}?因为 := 创建命名 Widget 是 Widget 树构建阶段的操作——它在 Splash 代码首次解析时执行,不在事件回调中执行。事件回调只能修改已有 Widget 的属性(通过 ui.name.set_text())或触发 on_render 重建。

这种分离是有意的:Widget 树的结构在定义时确定(或在 on_render 中动态重建),事件回调只修改数据和触发更新。这让事件处理保持简单——你不需要担心在事件中创建新 Widget 时的生命周期问题。


fn tick()Splash{} / Canvas 中的定时器约定

除了 on_click 等 Widget 级事件外,Splash{} widget 和 Canvas 还额外约定了一个 fn tick() 定时器。如果脚本里定义了名为 tick 的函数,承载它的 Splash widget 会启动一个约 1 秒的 interval,并周期性调用它:

let state = { seconds: 0 running: true }

fn tick() {
    if state.running {
        state.seconds = state.seconds + 1
        ui.timer_label.set_text("" + state.seconds + "s")
    }
}

timer_label := Label{text: "0s" draw_text.color: #xfff draw_text.text_style.font_size: 24}

来源:tools/canvas/CLAUDE.md(Timer 支持段落)

fn tick() 是 pomodoro 在 Canvas 中的定时器核心——它每秒递减剩余时间,当时间到零时切换到下一个阶段:

fn tick() {
    if pomo.running {
        if pomo.remaining > 0 { pomo.remaining = pomo.remaining - 1 }
        if pomo.remaining <= 0 { next_mode() }
        refresh()
    }
}

来源:tools/canvas/examples/pomodoro.splash:45-51

fn tick() 在这个环境中的约定:

  • 函数名必须tick,不是其他名字
  • 调用频率约为 1 秒一次(事件循环调度下的近似值,不应用于精确定时)
  • Splash{} / Canvas 环境里不需要额外注册,定义后会自动启动
  • 适合计时器、轮询更新等场景

fn tick()on_click 的区别在于触发源:on_click 由用户操作触发,tick 由承载它的 Splash widget / Canvas 定时器触发。但两者在函数体内可以做的事情完全相同——修改状态、调用函数、更新 UI。实际上 pomodoro 的 tick() 和按钮的 on_click 都调用同一个 refresh() 函数,说明事件处理的输出端是统一的。

如果你需要不同于 1 秒的定时间隔,这个约定目前没有更细粒度的原生配置。一个变通方案是在 tick() 中使用计数器来降频:

let state = { tick_count: 0 }
fn tick() {
    state.tick_count = state.tick_count + 1
    if state.tick_count >= 5 {   // 每 5 秒执行一次
        state.tick_count = 0
        // 实际逻辑
    }
}

另一个变通是用 fn on_audio() 回调(约 10Hz,Canvas 音频管线提供),但那属于 Canvas 的特定功能(详见第30章:音频可视化案例)。


net.http_request:网络请求

Splash 不仅能处理 UI 事件——它还内置了 HTTP 请求能力。net.http_request 让纯 Splash 应用可以调用外部 API:

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 data = res.body.parse_json()
        ui.result_label.set_text("" + data.title)
    }
    on_error: |e|{
        ui.result_label.set_text("Error: " + e.message)
    }
}

支持的方法:GETPOSTPUTDELETEHEADPATCHOPTIONS

POST 请求可以发送 JSON body:

let req = net.HttpRequest{
    url: "https://api.example.com/submit"
    method: net.HttpMethod.POST
    headers: {"Content-Type": "application/json"}
    body: {name: "test" value: 42}.to_json()
}

流式响应用 is_streaming: true + on_stream 回调:

let req = net.HttpRequest{url: "..." method: net.HttpMethod.POST is_streaming: true}
net.http_request(req) do net.HttpEvents{
    on_stream: |res|{ /* 每个 chunk 调用一次 */ }
    on_complete: |res|{ /* 流结束 */ }
}

Splash 还内置了 parse_html() 方法,可以解析 HTML 响应并用 CSS 选择器查询元素——适合抓取网页数据:

let doc = html_string.parse_html()
let titles = doc.query("h2.title")

net.http_request 改变了纯 Splash 的能力边界——以前纯 Splash 应用无法做网络请求,现在可以了。这让 AI 生成的纯 Splash 应用可以直接调用 API、搜索引擎、加载远程数据(详见第4章:两种模式的选择)。


实战:pomodoro 的完整事件地图

把 pomodoro.splash 中的所有事件梳理出来,形成一张完整的事件地图:

flowchart TD
    subgraph 定时器
        T["fn tick()"] -->|约 1s / Canvas| TC{"pomo.running?"}
        TC -->|是| TD["remaining -= 1"]
        TD --> TE{"remaining <= 0?"}
        TE -->|是| TF["next_mode()"]
        TE -->|否| TG["refresh()"]
        TF --> TG
    end

    subgraph 按钮事件
        B1["Start btn on_click"] --> B1A["toggle pomo.running"]
        B2["Reset btn on_click"] --> B2A["remaining = mode_dur()"]
        B3["Skip btn on_click"] --> B3A["next_mode()"]
        B4["25min btn on_click"] --> B4A["set_mode('work')"]
        B5["5min btn on_click"] --> B5A["set_mode('break')"]
        B6["15min btn on_click"] --> B6A["set_mode('long_break')"]
    end

    B1A --> TG
    B2A --> TG
    B3A --> TG
    B4A --> TG
    B5A --> TG
    B6A --> TG

pomodoro 有 7 个事件源(1 个定时器 + 6 个按钮),全部通过 refresh() 函数统一更新 UI。这就是第4章总结的"refresh 辅助函数"模式——所有事件处理的最后一步都是调用 refresh()


模式提炼

模式一:事件 → 状态 → UI

所有 Splash 事件的处理模式都是相同的三步:

事件触发 → 修改 state → 调用 refresh() 或 render()

不要在事件回调中直接操作多个 Widget 的属性——先修改状态,然后让一个统一的函数(refreshon_render)根据新状态更新所有 UI。

模式二:on_return → on_click 委托

当 TextInput 和 Button 需要执行相同的逻辑时,不要重复代码——让 on_return 程序化触发 on_click

input := TextInput{on_return: || ui.submit_btn.on_click()}
submit_btn := Button{text: "Submit" on_click: ||{ /* 逻辑只写一次 */ }}

模式三:on_render 条件渲染

content := View{on_render: ||{
    if state.condition { /* 条件为真时的 Widget */ }
    else { /* 条件为假时的 Widget */ }
}}

// 状态变化时触发重新渲染
Button{on_click: ||{ state.condition = true  ui.content.render() }}

这是当前纯 Splash 中实现"显示/隐藏"的标准方式——与其依赖未统一暴露的 set_visible() 脚本接口,不如直接用条件渲染表达结构变化。


本章小结

事件触发时机闭包语法常用组件
on_click用户点击||{...}Button
on_return用户按回车||{...} / |text|{...}TextInput
on_change值变化|val|{...}Slider, TextInput
on_render程序调用 render()||{...}View
on_startup应用启动||{...}Root
fn tick()Splash{} / Canvas 周期调用普通函数Splash{} / Canvas

核心规则:

  1. 所有事件回调最终都要更新 UI——通过 set_text()render()
  2. 闭包可以访问外层变量——stateui、全局函数
  3. 在当前纯 Splash 里,条件替换整块子树通常用 on_render 完成——通用脚本侧 set_visible() 不是稳定的通用 API
  4. Splash{} / Canvas 环境里,可用 fn tick() 获得约 1 秒周期回调

下一章将讲解 Splash 的状态机和动画系统——mod.state、Animator、hover/pressed 效果(详见第10章:状态与动画)。