关于 makepad-2.0-splash
Splash 是 Makepad 2.0 的运行时 UI 脚本语言,可通过 `script_mod!` 嵌入 Rust。它支持热重载和增量求值,也面向流式代码生成,可用于 Makepad UI 开发。
主要功能
- 01在 Rust 中编写运行时 UI 脚本
- 02脚本模块热重载
- 03增量与流式求值
- 04响应式状态与事件处理
- 05HTTP 请求与 HTML 解析
- 06用于构建 UI 的控件库
适用场景
- 01用运行时脚本构建 Makepad 界面
- 02通过热重载更新 UI 脚本
- 03在流式代码生成期间渲染 UI
- 04处理界面事件和响应式状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zhanghandong/ makepad-skills --skill makepad-2.0-splash安装后在 Claude Code 中输入/makepad-2.0-splash确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/canvas-splash-patterns.mdsplash-language-reference.mdsplash-scripting-patterns.md详细介绍
Makepad 2.0 Splash 脚本语言
Splash 是 Makepad 2.0 的核心运行时 UI 脚本语言,于 2026 年 2 月 12 日发布。它用运行时 script_mod! 宏取代了旧的编译时 live_design! 宏系统,可实现热重载、流式求值和 AI 优先的代码生成。
核心概念
脚本结构
每个 Splash 脚本都以 use 导入语句开头,并通过 script_mod!{} 宏嵌入 Rust:
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
}
}
}
}
}
语法规则
- 属性之间不使用逗号——以空白分隔
- 不使用分号——语法更简洁,针对 LLM 代码生成进行了优化
- 属性赋值:
key: value - 点路径简写:
draw_bg.color: #f00(等同于draw_bg +: { color: #f00 }) - 合并运算符:
key +: { ... }扩展父级,而不替换它 - 命名子项:
name := Widget{...}(可寻址,并且可按实例覆盖) - Let 绑定:
let MyTemplate = Widget{...}(局部作用域,必须在使用前定义) - Rust 绑定:
#(Struct::register_widget(vm))将 Splash 连接到 Rust 结构体 - 调试日志:
~expression在求值期间记录值
状态管理
状态通过 mod.state 对象和响应式 on_render 回调进行管理:
// 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 }
}
}
事件处理
事件既可在 Splash 中内联处理,也可从 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()
}
在 Rust 中,使用 script_eval! 执行 Splash 代码:
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()
});
}
}
}
函数
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()
}
控制流
// 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 请求
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 */ }
}
流式响应使用 is_streaming: true,并配合 on_stream 和 on_complete 回调。
HTML 解析
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 value
流式求值
Splash 解析器支持基于检查点的增量解析,专为 AI/LLM 流式代码生成而设计:
// Rust API for streaming evaluation
vm.eval_with_append_source(script_mod, &code, NIL.into())
这样,代码生成时即可逐个 token 实时更新 UI,无需等到完整脚本生成后再进行求值。
热重载与脚本模块跟踪
Splash 脚本支持通过 --hot 标志进行热重载。VM 使用唯一的 ScriptModKey(文件、行、列)跟踪每个 script_mod! 代码块:
// 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 source
热重载的工作原理:
- 文件监视器(
makepad_live_reload_core)检测源文件更改 - 从 Rust 源代码中提取
script_mod!代码块(可处理原始字符串、注释和字符字面量) - 跟踪 Rust 占位符数量(
#(...))——添加或移除占位符需要完整重新构建 - 通过
script_mod_overrides应用通过验证的脚本模块 - IP 到位置的映射为错误报告提供源映射(对于合成操作码,则回退到最近的 token)
ScriptSource 变体:
ScriptSource::Mod——标准模块求值(启动时)ScriptSource::Streaming——增量流式求值(AI/LLM)
关键布局规则
- 始终为容器设置
height: Fit——默认的height: Fill会导致 UI 不可见(高度为 0px) - 在根容器上使用
width: Fill——顶层绝不要使用固定像素宽度 - 对于包含文本子项且
show_bg: true的任何 View,设置new_batch: true - 在模板中为命名子项使用
:=——否则文本覆盖会静默失败 draw_bg.border_radius接受浮点数,而非 Inset——draw_bg.border_radius: 16.0- 使用带样式的 View(
RoundedView、SolidView),而非原始的View{show_bg: true}
Widget 参考
核心容器:View、SolidView、RoundedView、RectView、RoundedShadowView、CircleView、GradientXView、GradientYView、ScrollXYView、ScrollXView、ScrollYView
文本:Label、H1-H4、P、TextBox、TextInput、LinkLabel、Markdown、Html
控件:Button、ButtonFlat、ButtonFlatter、CheckBox、Toggle、RadioButton、Slider、DropDown
布局:Splitter、FoldHeader、Hr、Vr、Filler
列表:PortalList、FlatList
导航:Modal、Tooltip、PopupNotification、SlidePanel、ExpandablePanel、PageFlip、StackNavigation
停靠:Dock、DockSplitter、DockTabs、DockTab
媒体:Image、Icon、LoadingSpinner、Vector、MathView、MapView
Canvas:从 Claude Code 渲染 Splash
Makepad Canvas(tools/canvas/)是一个独立应用,可通过 HTTP/WS 渲染接收到的 Splash 代码。Claude Code 会用它输出可视化内容。
HTTP API(建议用于发送 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 事件监听(用于接收点击事件)
# 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"}
交互式按钮
使用 name := Button{...} 创建可点击按钮。点击事件中会发送 name:
View{width:Fill height:Fit flow:Right spacing:12
btn_save := Button{text:"Save"}
btn_cancel := Button{text:"Cancel"}
}
点击时:{"event":"click","widget":"btn_save"}
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 语法(重要——与 script_mod! 不同)
为 Canvas HTTP 渲染生成 Splash 时(POST /splash),请使用以下精确模式。Canvas Splash 语法在几个关键方面不同于 script_mod! 宏上下文:
1. 属性使用点路径内联,而非嵌套块:
// 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. 边框圆角使用 draw_bg.radius,而非 draw_bg.border_radius:
// WRONG
draw_bg.border_radius: 8.0
// CORRECT
draw_bg.radius: 8.
3. 内边距使用显式的 Inset{} 类型,浮点数末尾带点:
// 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{} 类型:
// WRONG
align: { y: 0.5 }
// CORRECT
align: Align{y: 0.5}
align: Center
5. 浮点数末尾带点:
// WRONG // CORRECT
8.0 8.
16.0 16.
0.5 0.5
6. SolidView 和 RoundedView 无需设置 show_bg: true 或 new_batch: true——它们默认就会渲染背景。
7. 使用 curl 发送多行 Splash 时,请使用 --data-binary——普通的 -d 会移除换行符。
经验证的 Canvas 仪表板模板
来源: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 提示
- Splash 使用 HTTP,事件使用 WS——这是最可靠的模式
- 矢量形状属性:
fill、stroke、stroke_width——而非draw_bg.* - CJK/中文文本:正文和代码块均支持。CodeEditor 为 CJK 字符使用双倍宽度列(已于 2026-03-23 修复)。主题
font_code将 LXGWWenKai 作为中文回退字体。 - 较大的 POST 请求体:Canvas 支持最大 512KB 的 HTTP 请求体
- 发布/订阅广播:所有已连接的 WS 客户端都会收到点击事件
- 健康检查:使用
GET /ping(而非/health) - 监视器开关:
/tmp/canvas-monitor-active标志文件控制状态栏自动刷新。Canvas 侧边栏中有一个切换按钮。
编译与求值:两种本质不同的 Widget 创建路径
理解这一区别对于调试任何 Splash 求值问题都至关重要:
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)
| 方面 | 编译(script_mod!) | 求值(set_text) |
|---|---|---|
| Widget 创建 | 完整的 Rust + ScriptVm 初始化 | 仅求值 ScriptVm 字符串 |
on_after_apply | 使用完整类型默认值调用 | 不会调用 |
| 类型默认 vec(命名子项) | 完整继承 | 在 proto 副本中可能丢失条目 |
| 类型默认 map(属性) | 应用于 #[live] 字段 | 仅应用求值结果 |
| 模板注册 | 在 apply 期间通过 ScriptHook 注册 | 必须在 draw_walk 中延迟完成 |
ScrollYView | 可正常工作 | 渲染为空白——请使用 View |
求值路径问题的修复模式:在 draw_walk 中实现延迟初始化,检测缺失状态,并通过 cx.with_vm(|vm| vm.bx.heap.type_default_for_object(...)) 查找类型默认值。这正是修复 Markdown 的 code_block 模板继承问题所采用的方法。
Splash 求值注意事项(严重——2026-03-23 总结)
这些问题仅影响通过 Splash 运行时求值创建的组件(POST /splash、Splash.set_text()),不影响编译后的 script_mod! 组件:
1. ScrollYView 在 Splash 求值中不起作用——渲染为空白
// 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
当 Splash 求值创建组件(例如 Markdown{body: "..."})时,永远不会调用 ScriptHook::on_after_apply 回调。任何依赖 on_after_apply 的初始化逻辑都必须有备用路径(例如,在 draw_walk 中延迟初始化)。
3. 求值路径不会完整继承类型默认属性
当 set_type_default() 为组件类型设置额外属性(例如 use_code_block_widget: true)或命名子项(例如 code_block := View{...})时,通过 Splash 求值创建的实例可能不会继承这些属性或子项。#[live] 字段只会从求值应用值(例如 {body: "..."})获取值,而不会从完整的类型默认值获取。
解决方法:在 draw_walk 中检查是否缺少状态,并通过 vm.bx.heap.type_default_for_object() 查找类型默认值。
4. 类型默认值中的 vec 条目(命名子项)可能不会复制到实例
虽然存在 copy_type_default_vec,但 set_type_default() 中的 vec 条目可能会在注册到实例创建的过程中丢失。new_with_proto_impl 中自动执行的原型 vec 复制会从直接原型复制,而直接原型中的条目可能少于类型默认值中的条目。
5. 在 Splash 中嵌套 Markdown{} 可以正常工作,但需要类型默认模板
当 Canvas 使用 code_block := View{CodeView{...}} 覆盖 mod.widgets.Markdown,而 Splash 求值创建 Markdown{body: "..."} 时,code_block 模板不会自动提供。解决方法(位于 widgets/src/markdown.rs)是在绘制时延迟查找类型默认值,并注册缺失的模板。
调试 Splash
命令行标志
| 标志 | 说明 |
|---|---|
--hot | 启用热重载:监视 script_mod! 源文件,并在保存时自动刷新 UI。只会重新加载 Splash DSL;Rust 更改需要重新编译。 |
--stdin-loop | Studio 模式:通过 stdin/websocket 与 Makepad Studio 通信。供 Studio 内部使用。 |
打印调试信息
std.println() / std.print() 是主要的调试工具。输出会同时显示在终端和 Studio 的 Log View 中:
std.println("debug: state.counter = " + state.counter)
~expression 调试日志语法也会在求值过程中打印值:
~state.counter // prints the value of state.counter during eval
Makepad Studio 集成
Studio 可以直接从 Run List 面板运行 Splash 脚本(会在项目根目录中查找 makepad.splash)。脚本错误会在 Log View 中显示,包括文件路径和错误详情。
在 Studio 中运行时,Splash 脚本会获得一个 hub 模块:
| API | 说明 |
|---|---|
hub.run(env, cmd, args) | 从 Splash 启动子进程 |
hub.set_run_items(items) | 在 Studio 的 Run List 中注册可运行项 |
hub.studio_ip | Studio 的 WebSocket 地址 |
当前限制
- 不支持断点调试——Splash VM 不支持断点或单步执行
- 没有 AST 转储标志——若要检查解析结果,需在 Rust 源代码(
script/)中添加日志 - 基于打印的调试——
std.println()和~expr是主要的调试工具
文件参考
- 完整语言手册:
splash.md(2559 行) - 迁移指南:
AGENTS.md(815 行) - 计数器示例:
examples/counter/src/app.rs - 待办事项示例:
examples/todo/src/app.rs - 详细参考:
skills/makepad-2.0-splash/references/splash-language-reference.md - 模式指南:
skills/makepad-2.0-splash/references/splash-scripting-patterns.md
Splash 实践经验(2026-03-31 总结,来自 Vox 语音输入应用的构建)
第 1 课:无法在 +: 块中添加 instance 字段
这是 Splash 最关键的限制。在 draw_bg +: { } 块内添加 instance my_var: 0.5 会导致运行时错误:"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))
}
}
解决方法:
- 使用
self.draw_pass.time实现基于时间的动画 - 使用
self.pos、self.rect_size(始终可用) - 对于自定义实例变量,创建 Rust
DrawQuad子类型(参见 makepad-2.0-shaders skill) - 对于简单的动画指示器,使用
LoadingSpinner组件
第 2 课:透明浮动窗口配置方法
要创建真正透明的覆盖层窗口,需要满足 三 个条件:
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
}
}
}
常见错误:
- 仅设置
window.transparent: true——窗口仍然不透明(灰色背景) - 在 body 上设置
draw_bg.color: #x00000000——不会让窗口透明,只会变成黑色 - 还必须从 Rust 将窗口配置为浮动面板:
MacosWindowConfig::floating_panel()+Borderless
参考实现: Makepad tools/canvas/src/app.rs 使用了这一确切模式。
第 3 课:不存在的属性名称
Splash 会静默忽略未知属性。这些问题曾耗费我们大量调试时间:
| 错误 | 正确 | 说明 |
|---|---|---|
password: true | is_password: true | TextInput 密码模式 |
LoadingSpinner 上的 color: #fff | (不支持) | LoadingSpinner 没有 color 属性 |
window.backdrop: Vibrancy | 可用,但还需要 transparent: true | 单独设置 Backdrop 不会让窗口透明 |
第 4 课:Emoji 渲染
Makepad 的文本渲染器支持部分 Emoji,但并非全部:
| 可用 | 不可用 |
|---|---|
| 🎙 🔍 🔄 | ✨ ⏳ |
如果 Emoji 显示为方框或乱码,请尝试换一个。使用 BMP(基本多文种平面)中的基础 Emoji。
第 5 课:width: Fit + 较大的 border_radius = 尖角形状
在 height: 56 的胶囊形控件上使用 border_radius: 28.0 的 RoundedView,两端会变成菱形,而不是半圆。底层的 sdf.box() 公式在 radius >= min(w,h)/2 时会出错。
修复方法: 使用自定义 SDF 胶囊着色器,而不是 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))
}
}
}
第 6 课:多窗口应用——窗口可见性控制
在 script_mod! 中声明的窗口会在启动时自动显示。Makepad WindowRef 没有 close()/open() 方法。
可行模式: 以正常尺寸声明窗口。需要时使用 configure_window() 将窗口置于前台。
// 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));
注意: reposition(cx, dvec2(-9999, -9999)) 无法可靠地隐藏 macOS 浮动面板。
第 7 课:new_batch: true 与组件 Z 轴顺序
在自定义着色器 View 中添加 LoadingSpinner(或任何带有独立绘制着色器的子组件),可能会导致边缘溢出——子组件会绘制到父级 SDF 蒙版之外。
修复方法: 二选一:
- 在父级自身的像素着色器中绘制动画(不会遇到子组件 Z 轴顺序问题)
- 在父级上使用
clip_x: true+clip_y: true(可能无法完全解决问题)
第 8 课:基于时间的着色器动画需要持续重绘
着色器中的 self.draw_pass.time 只有在组件重绘时才会推进。若不显式重绘,时间就会冻结。
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();
}
}
关键点: 重绘 Window 组件,而不只是内部 View。绘制通道的时间是按窗口计算的。
第 9 课:脚本派生结构体中的复杂类型 #[rust] 字段
#[derive(Script, ScriptHook)] 结构体只能包含类型实现了 Default 的 #[rust] 字段。对于复杂的非 Default 类型(通道、句柄等),请将它们封装在 Inner 结构体中:
#[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
}