前言
为什么写这本书
2026 年,AI Agent 正在改变软件开发的方式。Claude Code、GitHub Copilot、Cursor 等工具让 AI 能够自主编写、测试和修复代码。但在 GUI 开发领域,有一个根本性的障碍:大多数 UI 框架的代码需要编译后才能看到效果。
这意味着 AI 生成 UI 代码后,必须经过编译、打包、加载的完整链路,才能验证结果是否正确。对于需要多轮迭代的 AI 生成场景来说,这条链路通常过长。
Makepad 2.0 用一种不同的方式解决了这个问题。它的 UI 描述语言 Splash 是运行时求值的——AI 输出的代码不必先经过传统前端那条编译/打包链路,就能被解析、编译和渲染。更进一步,Splash 支持流式求值——AI 每输出一段代码,用户就能看到 UI 的一部分逐步成型。
这本书记录了 Makepad 2.0 的完整技术体系:从入门到架构,从语法设计到 AI 集成。
配套项目:Makepad Skills
本书有一个配套项目——makepad-skills——一组为 AI Agent(如 Claude Code)设计的技能插件。这些 Skills 将本书中的知识(Splash 语法规则、Widget API、常见陷阱、架构模式)编码为机器可消费的格式,让 AI 在编写 Makepad 代码时能够自动加载相关上下文。
本书是"给人类读的文档",makepad-skills 是"给 AI 读的文档"。两者覆盖相同的知识体系,但面向不同的受众。如果你在使用 Claude Code 开发 Makepad 应用,建议同时安装 makepad-skills——AI 会自动在你遇到 Makepad 相关问题时加载对应的 skill。
阅读准备
前置知识
- Rust 基础:了解 struct、trait、enum、生命周期的基本概念
- GUI 开发经验:使用过任何 GUI 框架(Qt、Flutter、React、SwiftUI)
- 不需要:GPU 编程经验、编译器知识、AI/ML 知识
推荐阅读路径
路径 A:应用开发者(想用 Makepad 构建应用)
ch01 → ch02 → ch03 → ch04 → ch05 → ch06 → ch07 → ch08 → ch09 → ch12 → ch15
路径 B:框架贡献者(想理解 Makepad 内部实现)
ch01 → ch03 → ch06 → ch11 → ch12 → ch17 → ch18 → ch22 → ch23 → ch24 → ch25
路径 C:AI 工具开发者(想构建 AI 生成 UI 的工具)
ch01 → ch06 → ch11 → ch27 → ch28 → ch29 → ch31 → 附录E
全书知识地图
flowchart TD
subgraph P1["Part I 入门"]
C1[ch01 设计哲学] --> C2[ch02 环境搭建]
C2 --> C3[ch03 第一个应用]
C3 --> C4[ch04 Counter]
C4 --> C5[ch05 Todo]
end
subgraph P2["Part II Splash 语言"]
C6[ch06 语法哲学] --> C7[ch07 属性]
C7 --> C8[ch08 模板]
C8 --> C9[ch09 事件]
C9 --> C10[ch10 动画]
C10 --> C11[ch11 流式求值]
end
subgraph P3["Part III Widget"]
C12[ch12 布局] --> C13[ch13 文本]
C13 --> C14[ch14 交互]
C14 --> C15[ch15 列表]
C15 --> C16[ch16 高级容器]
C16 --> C17[ch17 自定义Widget]
end
subgraph P6["Part VI AI-Native"]
C27[ch27 Canvas架构] --> C28[ch28 Agent-to-App]
C28 --> C29[ch29 自愈循环]
end
P1 --> P2
P2 --> P3
P2 -->|AI 路径| P6
C11 -->|核心桥梁| C27
style C6 fill:#51cf66,color:#111
style C11 fill:#339af0,color:#fff
style C27 fill:#ff6b6b,color:#fff
三个锚点章节:
- 第6章(Splash 语法哲学):理解"为什么这样设计"
- 第11章(流式求值):理解"AI 如何实时生成 UI"
- 第27章(Canvas 架构):理解"完整的 AI-to-App 系统"
标记说明
file:line— 源码引用,指向 Makepad 仓库中的实际文件和行号splash.md— Splash 语言参考手册(权威来源)详见第N章— 跨章交叉引用- 代码块标记
splash— Splash 语言代码 - 代码块标记
rust— Rust 代码
致谢
感谢 Makepad 团队创造了这个框架,特别是 Rik Arends 对"运行时 UI"理念的坚持。感谢 Claude Code 和 Canvas 的使用者——你们的实践验证了 Agent-to-App 管线的可行性。
本书基于 Makepad 2.0(dev 分支,2026 年 4 月快照)。Makepad 仍在快速发展中,部分 API 可能在后续版本中变化。
第1章:Makepad 的设计赌注
为什么需要又一个 GUI 框架
2026 年的 GUI 框架市场已经足够拥挤。React 统治了 Web 前端,Flutter 占据了跨平台移动开发,SwiftUI 是 Apple 生态的首选,Qt 在桌面和嵌入式领域根深蒂固,egui 在 Rust 社区快速崛起。每一个都有成熟的生态系统、活跃的社区和数百万行的生产代码。
在这种局面下,Makepad 为什么存在?它不是要在现有赛道上击败某个框架——它在赌一条新赛道:AI 时代的 UI 开发。
这个赌注的核心论点是:现有的 GUI 框架都基于一个隐含假设——UI 由人类程序员编写。它们的语法(JSX、QML、SwiftUI DSL)、构建流程(编译、打包、热重载)和开发工具(IDE、调试器)都围绕人类的认知和工作方式设计。当 AI Agent 需要动态生成和修改 UI 时,这些框架的架构成为了障碍而不是助力。
Makepad 2.0 的设计从一个不同的起点出发:如果 UI 描述语言的主要"作者"不只是人类,还包括 AI,框架应该怎样设计?
六个框架,六种设计赌注
为了理解 Makepad 的定位,先看六个主流框架各自下了什么"赌注"——它们认为 GUI 开发的未来在哪个方向。
对比总览
| 框架 | 语言 | UI 描述方式 | 热重载 | 跨平台 | AI 友好度 |
|---|---|---|---|---|---|
| React | JavaScript/TSX | JSX(编译时转换) | 快(HMR) | Web + RN | 中——JSX 需要编译 |
| Flutter | Dart | Widget 树(代码即 UI) | 快(有状态热重载) | 移动+桌面+Web | 低——Dart 编译链路长 |
| SwiftUI | Swift | 声明式 DSL(编译时宏) | 预览(Xcode) | Apple 全家桶 | 低——Swift 编译+Apple 专属 |
| Qt/QML | C++ / QML | QML(运行时解释) | 有(QML 热重载) | 全平台 | 中——QML 是运行时的 |
| egui | Rust | 即时模式(每帧重建) | 无(需重编译) | 桌面+Web | 低——Rust 编译 |
| Makepad | Rust + Splash | Splash(运行时 VM 执行) | 即时 | 全平台 | 高——Splash 为 AI 设计 |
这个表格需要仔细解读,因为每个维度都有细微差别。
UI 描述方式:编译时 vs 运行时
这是最关键的对比维度。
编译时 UI(React JSX, SwiftUI, Flutter):UI 描述在构建阶段被转换为代码。JSX 被 Babel 转换为 React.createElement() 调用,SwiftUI 的 @ViewBuilder 是编译器宏。修改 UI 需要重新经过编译步骤。
运行时 UI(QML, Makepad Splash):UI 描述在应用运行时被解释或执行。QML 有自己的 JavaScript 引擎,Splash 有自己的 VM。修改 UI 只需要发送新的代码字符串给运行时——不需要编译器参与。
即时模式 UI(egui):每一帧重新构建整个 UI。没有 UI 描述文件——UI 是 Rust 函数调用的副作用。修改 UI 需要重新编译 Rust 代码。
为什么这个维度对 AI 重要?因为 AI Agent 生成代码的自然输出是文本——一段字符串。如果框架能直接接收一段 UI 描述字符串并渲染它,AI 的输出就能即时变成可见的 UI。如果框架需要编译器参与(JSX → Babel → Webpack → Bundle),AI 输出和用户看到结果之间就有一个不可压缩的延迟。
QML 也是运行时的,为什么 Makepad 的 AI 友好度更高?因为 QML 嵌入了完整的 JavaScript 引擎——表达式可以是任意 JavaScript 代码。这对流式解析(AI 逐 token 输出时能否增量渲染)是不利的。Splash 的语法专门为流式解析设计(详见第6章:Splash 语法设计哲学)——没有需要完整解析才能确定含义的结构。
热重载能力
所有现代框架都声称支持某种形式的"热重载"。但热重载的粒度和速度差异很大:
| 框架 | 热重载粒度 | 反馈速度 | 保持状态? |
|---|---|---|---|
| React HMR | 模块级 | 快 | 部分保持 |
| Flutter 热重载 | Widget 级 | 快 | 保持 |
| SwiftUI Preview | 整页重建 | 较慢 | 不保持 |
| QML 热重载 | QML 文件级 | 快 | 部分保持 |
| egui | 需重编译 | 慢 | 不保持 |
| Makepad Splash | 表达式级 | 很快 | 保持 |
Makepad 的热重载粒度是最细的——改一个属性值,不需要重新走 Rust 编译链路。这不只是"开发体验好"——当 AI 逐 token 输出 Splash 代码时,这种粒度意味着用户可以看到 UI 逐步成型的过程(详见第11章:流式求值)。
跨平台覆盖
| 框架 | macOS | Windows | Linux | Android | iOS | Web |
|---|---|---|---|---|---|---|
| React | via Electron | via Electron | via Electron | React Native | React Native | 原生 |
| Flutter | 有 | 有 | 有 | 有 | 有 | 有 |
| SwiftUI | 原生 | — | — | — | 原生 | — |
| Qt | 有 | 有 | 有 | 有 | 有 | 有(WASM) |
| egui | 有 | 有 | 有 | 实验性 | 实验性 | WASM |
| Makepad | Metal | D3D11 | OpenGL | 有 | 有 | WASM |
Makepad 的跨平台策略是"每个平台用原生 GPU API"——macOS 用 Metal,Windows 用 D3D11,Linux 用 OpenGL,Web 用 WebGL/WGSL。这和 Flutter 的 Skia 渲染策略类似——完全绕过平台原生 UI 组件,自己绘制一切。好处是跨平台一致性极高,代价是没有原生平台控件的"感觉"。
Makepad 的演进:从 1.x 到 2.0
Makepad 不是一夜之间诞生的。它经历了一个重要的架构转变。
1.x 时代:live_design!
Makepad 1.x 使用 live_design! 宏定义 UI。它是一种声明式数据格式——可以描述 Widget 的属性和结构,支持热重载,但不是编程语言。没有变量、没有函数、没有条件判断、没有循环。
// Makepad 1.x 风格(live_design!)
live_design!{
import makepad_widgets::base::*;
App = {{App}} {
ui: <Window> {
body = <View> {
<Label> { text: "Hello" }
}
}
}
}
live_design! 的能力边界是"声明静态 UI 树"。当你需要条件渲染("如果 logged_in 显示用户名,否则显示登录按钮"),你必须在 Rust 代码中处理——live_design! 无法表达这种逻辑。
2.0 转折点:Splash 脚本
2025 年 11 月,第一个 Splash 脚本运行成功。2026 年 2 月 12 日,Makepad 2.0 正式发布。核心变化是 live_design! 被 script_mod! 替代——UI 描述从"声明式数据"升级为"完整脚本语言"。
// Makepad 2.0 风格(script_mod! / Splash)
let state = { counter: 0 }
fn refresh() { ui.label.set_text("Count: " + state.counter) }
View{flow: Down align: Center
label := Label{text: "Count: 0" draw_text.text_style.font_size: 24}
Button{text: "Add" on_click: ||{ state.counter = state.counter + 1 refresh() }}
}
Splash 不只是"带逻辑的 UI 描述"——它是一种完整的脚本语言,有自己的 VM、GC、字节码编译器。它可以定义变量、函数、闭包、控制流,可以操作 Widget 属性和响应用户事件。
这个转变的意义远超"语法升级"。它打开了一扇门:外部程序(包括 AI Agent)可以向运行中的应用发送 Splash 代码,应用立即执行并渲染新的 UI。这就是 Canvas Agent-to-App 管线的技术基础(详见第27章)。
AI 叙事:为什么 UI 框架需要关心 AI
2025-2026 年,AI 编程助手(Claude Code、GitHub Copilot、Cursor)从"补全代码片段"进化到"自主完成任务"。AI Agent 可以读取需求、编写代码、运行测试、修复 bug——整个开发循环。
在这个趋势下,GUI 开发面临一个独特的挑战:AI 生成的 UI 代码需要被"看到"才能被验证。写后端代码时,AI 可以运行测试来验证正确性。写 UI 代码时,AI 需要"看到"渲染结果——截图——来判断布局是否正确、颜色是否协调、交互是否流畅。
这意味着 AI 生成 UI 的循环是:
生成代码 → 渲染 → 截图 → 分析 → 修改代码 → 重新渲染 → ...
这个循环的每一步都需要时间。如果"渲染"需要编译+打包+加载(React/Flutter),反馈闭环会被显著拉长。如果"渲染"可以直接在运行时求值(Splash),AI 就更有机会在一次交互中完成多轮迭代。
Makepad 2.0 的 Canvas 工具就是这个循环的实现:AI Agent 生成 Splash 代码 → 通过 WebSocket 推送到 Canvas → Canvas 渲染 → 截图回传给 Agent → Agent 分析并修改 → 重新推送。整个闭环的价值在于省掉传统前端那条编译/打包/刷新链路(详见第29章:自愈循环与流式渲染)。
这不是一个理论上的可能性——它是一个正在运行的系统。本书的后半部分(Part VI)将详细剖析 Canvas 的架构和 Agent-to-App 管线的实现。
本书的结构
基于 Makepad 2.0 的架构特点,本书分为六个部分:
| 部分 | 主题 | 章节 | 面向 |
|---|---|---|---|
| Part I | 入门 | ch01-ch05 | 零基础读者 |
| Part II | Splash 语言 | ch06-ch11 | 想深入 Splash 的开发者 |
| Part III | Widget 体系 | ch12-ch17 | 构建复杂 UI 的开发者 |
| Part IV | 渲染与 Shader | ch18-ch21 | 对 GPU 渲染感兴趣的开发者 |
| Part V | 架构深度 | ch22-ch26 | 想理解内核的贡献者 |
| Part VI | AI-Native | ch27-ch32 | AI 工具开发者 |
推荐阅读路径:
- 应用开发者:Part I → Part II → Part III(跳过 Part IV-V)→ Part VI ch27-28
- 框架贡献者:Part I → Part II → Part III → Part IV → Part V
- AI 工具开发者:ch01 → ch06 → ch11 → Part VI
下一章将帮你搭建开发环境——安装 Rust 工具链、配置 cargo-makepad、在 macOS/Windows/Linux 上编译和运行第一个 Makepad 应用(详见第2章:环境搭建)。
第2章:环境搭建
为什么这很重要
在写第一行 Makepad 代码之前,你需要一个能编译和运行 Makepad 应用的开发环境。Makepad 基于 Rust,所以你需要 Rust 工具链。但 Makepad 还有自己的构建工具 cargo-makepad,用于处理跨平台编译、资源打包和移动端部署。
好消息是:Makepad 的依赖非常少。不需要 Node.js、不需要 Gradle(除了 Android)、不需要 Xcode CLI 以外的 Apple 工具(除了 iOS 签名)。大多数平台上,安装 Rust + 运行 cargo run 就够了。
本章覆盖 macOS、Windows、Linux 三个桌面平台,以及 Android、iOS、WASM 三个部署目标。
flowchart TD
A["安装 Rust 工具链"] --> B["克隆 Makepad"]
B --> C{"目标平台?"}
C -->|桌面| D["cargo run"]
C -->|Android| E["cargo makepad android"]
C -->|iOS| F["cargo makepad apple ios"]
C -->|WASM| G["cargo makepad wasm"]
style A fill:#51cf66,color:#111
第一步:安装 Rust
Makepad 使用 stable Rust(不需要 nightly)。安装方式在所有平台上相同:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
安装完成后验证:
rustc --version # 应显示 rustc 1.85.0 或更高
cargo --version # 应显示 cargo 1.85.0 或更高
如果你已经安装了 Rust,确保更新到最新的 stable 版本:
rustup update stable
Makepad 2.0 需要 Rust 1.80+(因为使用了 LazyLock 等新特性)。如果你的版本较旧,rustup update 会解决问题。
第二步:获取 Makepad 源码
git clone https://github.com/makepad/makepad.git
cd makepad
Makepad 是一个 Cargo workspace——所有的 crate(platform、draw、widgets、examples)都在这个仓库中。你不需要单独安装 makepad-widgets crate——它作为 workspace 成员直接引用。
第三步:运行第一个示例
macOS
macOS 是 Makepad 的主要开发平台。不需要额外依赖:
cargo run -p makepad-example-counter --release
首次编译需要几分钟(下载依赖 + 编译整个 workspace)。后续增量编译通常在 5-15 秒内完成。
--release 参数启用优化编译。Makepad 的 debug 构建也能运行,但渲染性能会明显较差。建议始终使用 --release。
Windows
需要安装 Visual Studio Build Tools(C++ 工作负载):
- 下载 Visual Studio Build Tools
- 安装时选择"使用 C++ 的桌面开发"工作负载
- 确保包含 Windows SDK
然后和 macOS 一样:
cargo run -p makepad-example-counter --release
Windows 上 Makepad 使用 D3D11 渲染后端。
Linux
需要安装 X11 和 OpenGL 开发库:
Ubuntu/Debian:
sudo apt-get install -y libx11-dev libxcursor-dev libxrandr-dev libxinerama-dev \
libxi-dev libgl1-mesa-dev libglu1-mesa-dev
Fedora:
sudo dnf install -y libX11-devel libXcursor-devel libXrandr-devel libXinerama-devel \
libXi-devel mesa-libGL-devel
然后:
cargo run -p makepad-example-counter --release
Linux 上 Makepad 使用 OpenGL 渲染后端。
第四步(可选):移动和 Web 目标
Android
首先安装 cargo-makepad 工具和 Android 工具链:
cargo install cargo-makepad
cargo makepad android install-toolchain
这会下载 Android NDK 和必要的工具链组件。然后构建 APK:
cargo makepad android run -p makepad-example-counter --release
确保有 Android 设备通过 USB 连接或 emulator 运行中。
iOS
需要 macOS + Xcode。安装 iOS 工具链:
cargo install cargo-makepad
cargo makepad apple ios install-toolchain
构建并运行:
cargo makepad apple ios run -p makepad-example-counter --release
需要有效的 Apple 开发者账号进行真机调试。模拟器运行不需要签名。
WASM (Web)
cargo install cargo-makepad
cargo makepad wasm run -p makepad-example-counter --release
这会启动一个本地 Web 服务器,在浏览器中打开 Makepad 应用。
验证安装
运行 counter 示例后,你应该看到一个 420×220 的窗口,中间显示 "Count: 0" 和一个 "Increment" 按钮。点击按钮,数字增加。
如果看到这个界面——恭喜,你的 Makepad 开发环境已经就绪。
常见问题排查
| 问题 | 平台 | 解决方案 |
|---|---|---|
error: linker 'cc' not found | Linux | 安装 build-essential(Ubuntu)或 gcc(Fedora) |
fatal error: 'X11/Xlib.h' not found | Linux | 安装 X11 开发库(见上面的 apt/dnf 命令) |
error: linking with 'link.exe' failed | Windows | 安装 Visual Studio Build Tools 的 C++ 工作负载 |
| 编译时内存不足 | 所有 | 首次编译可能需要 8GB+ RAM;关闭其他大型应用 |
cargo run 很慢 | 所有 | 使用 --release;确认是增量编译而非全量编译 |
| Android 设备未识别 | Android | 确认 USB 调试已开启;运行 adb devices 验证 |
| iOS 签名错误 | iOS | 确认 Xcode 中已配置开发者团队 |
| WASM 运行白屏 | Web | 检查浏览器控制台错误;确认 WebGL 2.0 支持 |
项目结构速览
在进入下一章写代码之前,了解一下 Makepad 仓库的结构:
makepad/
├── platform/ # 核心运行时:事件、窗口、GPU 抽象
│ ├── src/ # Cx, Event, Action, DrawShader 等
│ └── script/ # Splash VM、解析器、GC
├── draw/ # 2D/3D 渲染引擎
│ └── src/ # Turtle 布局、Sdf2d、文本、矢量
├── widgets/ # UI 组件库
│ └── src/ # View, Label, Button, PortalList 等
├── examples/ # 示例应用
│ ├── counter/ # 计数器(本书主要示例)
│ ├── todo/ # Todo 列表
│ └── splash/ # Splash 语言演示
├── studio/ # Makepad Studio IDE
├── tools/
│ └── canvas/ # AI Agent-to-App 画布
├── splash.md # Splash 语言参考手册
└── Cargo.toml # Workspace 根配置
本书前五章主要使用 examples/counter/ 和 examples/todo/。Part II(Splash 语言篇)大量引用 platform/script/ 和 splash.md。Part VI(AI-Native 篇)聚焦于 tools/canvas/。
本章小结
| 步骤 | 命令 |
|---|---|
| 安装 Rust | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
| 克隆仓库 | git clone https://github.com/makepad/makepad.git |
| 运行桌面 | cargo run -p makepad-example-counter --release |
| 运行 Android | cargo makepad android run -p makepad-example-counter --release |
| 运行 iOS | cargo makepad apple ios run -p makepad-example-counter --release |
| 运行 WASM | cargo makepad wasm run -p makepad-example-counter --release |
环境就绪后,下一章将带你逐行分析 counter 应用的代码——理解 Makepad 2.0 应用的基本结构(详见第3章:第一个应用)。
第3章:第一个应用
��什么这很重要
学任何框架,第一步都是"跑起来"。一个能编译、能运行、能在屏幕上显示东西的最小应用,就是你理解整个框架的起点。
Makepad 2.0 的应用由两部分组成:Rust 骨架和 Splash 脚本。Rust 骨架定义应用的生命周期和事件处理,Splash 脚本定义 UI 的外观和结构。这种"双层结构"是 Makepad 2.0 的核心设计——Rust 负责"做什么",Splash 负责"长什么样"。
本章将带你从零构建一个最小的 Makepad 应用,逐行讲解每个部分的作用。你不需要记住所有细节——本章的目标是建立一个"可运行的心智模型",让你在后续章节中能把新知识放到正确的位置上。
flowchart TD
A["Cargo.toml<br/>(依赖配置)"] --> B["main.rs"]
B --> C["app_main!(App)<br/>(入口宏)"]
B --> D["script_mod!{...}<br/>(Splash UI 定义)"]
B --> E["struct App<br/>(Rust 应用结构体)"]
D --> F["运行时求值<br/>→ Widget 树"]
E --> G["MatchEvent<br/>→ 事件处理"]
E --> H["AppMain<br/>→ 生命周期"]
style D fill:#51cf66,color:#111
style F fill:#339af0,color:#fff
完整的最小应用
下面是一个完���的 Makepad 2.0 应用——counter(计数器)。它能在屏幕上显示一个数字和一个按钮,点击按钮数字加一。整个应用只有一个文件,71 行代码:
Cargo.toml
[package]
name = "makepad-example-counter"
version = "1.0.0"
edition = "2021"
[dependencies]
makepad-widgets = { path = "../../widgets", version = "2.0.0" }
来源:examples/counter/Cargo.toml(省略 dev-dependencies)
唯一的运行时依赖是 makepad-widgets——它包含了 Makepad 的所有 UI 组件、渲染引擎和运行时。不需要额外的构建工具、打包器或代码生成器。
main.rs:完整源码
#![allow(unused)] fn main() { pub use makepad_widgets; 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" } } } } } } #[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 script_mod(vm: &mut ScriptVm) -> ScriptValue { crate::makepad_widgets::script_mod(vm); self::script_mod(vm) } fn handle_event(&mut self, cx: &mut Cx, event: &Event) { self.match_event(cx, event); self.ui.handle_event(cx, event, &mut Scope::empty()); } } }
来源:examples/counter/src/main.rs
71 行。下面逐部分解剖。
逐层解剖
第一层:入口宏 app_main!
#![allow(unused)] fn main() { app_main!(App); }
来源:examples/counter/src/main.rs:5
这一行做了两件事:
- 生成跨平台的
main函数(在 macOS 上是标准的fn main(),在 Android 上是 JNI 入口,在 WASM 上是wasm_bindgen入口) - 初始化 Makepad 运行时(
Cx),然后启动App
你不需要手写 fn main()——app_main! 替你处理了所有平台差异。无论你的目标是桌面、移动还是 Web,这一行不需要改变。
第二层:Splash 脚��� script_mod!
#![allow(unused)] fn main() { script_mod! { use mod.prelude.widgets.* // ... Splash 代码 ... } }
来源:examples/counter/src/main.rs:7
script_mod! 是整个应用中最重要的部分。花括号内的代码不是 Rust——它是 Splash 脚本。编译期宏会把这段脚本展开成一个 ScriptMod:其中包含源码字符串、源位置以及 #(...) 注入值;运行时再由 Splash VM 解析和执行。
这就是 Makepad 2.0 最根本的设计决策:UI 描述是运行时求值的,不是编译时展开的。
对比 Makepad 1.x 的 live_design!:
live_design!(1.x) | script_mod!(2.0) | |
|---|---|---|
| UI 描述能力 | 声明式数据格式 | 完整脚本语言(变量、函数、闭包、控制流) |
| 求值方式 | 宏提取 + 运行时解析 | 完整的 VM 执行 |
| 修改 UI | 支持热重载 | 支持热重载 |
| 动态能力 | 静态属性声明 | on_render 动态生成 Widget、条件渲染 |
| AI 生成 | 技术上可行,但缺乏脚本能力 | 完整支持(VM 可接收外部代码) |
这个表格中最关键的变化是"动态能力"和"AI 生成"。live_design! 是声明式的数据格式——你只能描述 Widget 的静态属性。script_mod! 是完整的脚本语言——变量、函数、闭包、条件渲染、on_render 回调……这些能力让 AI Agent 可以生成"有逻辑的 UI"而不只是"静态的布局"。这就是 Canvas 项目(详见第27章:Canvas 架构剖析)的技术基础。
需要澄清的是:live_design! 在 1.x 中也支持热重载——它的宏会提取 DSL 内容作为字符串,在运行时解析。script_mod! 的真正飞跃是从"声明数据"到"执行脚本"——一个质的变化,让 UI 描述从静态变为动态。
script_mod! 内部结构
让我们拆解 script_mod! 里面的 Splash 代码:
导入预置组件:
use mod.prelude.widgets.*
这行导入所有标准 Widget(View, Label, Button 等)。没有这行,Widget 名称不会被识别。
状态定义:
let state = {
counter: 0
}
mod.state = state
let state = {...} 定义一个状态对象,mod.state = state 把它注册到模块级作用域——这样后面的代码和 Rust 侧都能通过 mod.state 访问它。
UI 组件树:
startup() do #(App::script_component(vm)){
ui: Root{
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"
}
}
}
}
}
来��:examples/counter/src/main.rs:13-41(格式化)
这段代码定义了完整的 UI 树:
Root{}是应用的根容器Window{}创建一个操作系统窗口,window.inner_size: vec2(420, 220)设置窗口大小body +:用合并运算符向窗口的 body 区域添加内容main_view := View{...}是主内容区域,垂直排列(flow: Down)、居中对齐on_render: ||{...}是一个渲染回调——每次main_view.render()被调用时,这段代码会重新执行,更新 Label 的文字increment_button := Button{text: "Increment"}是一个按钮
on_render 是一个渲染回调——在每次调用 render() 时重新执行。counter_label 的定义来自 on_render 返回结果;每次渲染时这段定义都会重新应用到 main_view,同名节点可被复用。当 state.counter 改变后,调用 ui.main_view.render() 会触发 on_render 重新执行,Label 的 text 属性随之更新。这种"状态变化 → 重新渲染 → UI 更新"的模式是 Makepad 2.0 中 UI 更新的标准方式。
startup() do #(...) 语法说明: startup() 声明这段 UI 定义在应用启动时执行。do #(App::script_component(vm)) 连接 Splash 和 Rust——它告诉 VM 这个 UI 树对应的 Rust 组件是 App。#(...) 是 Splash 调用 Rust 方法的语法,与 Rust 侧的 script_eval! 构成双向���梁。
第三层:Rust 结构体和事件处理
#![allow(unused)] fn main() { #[derive(Script, ScriptHook)] pub struct App { #[live] ui: WidgetRef, } }
来源:examples/counter/src/main.rs:43-47
App 是应用的 Rust 侧结构体。#[derive(Script, ScriptHook)] 让它能和 Splash VM 交互。#[live] 标记 ui 字段为"运行时活跃的"——它持有 Splash 脚本创建的 Widget 树的引用。
事件处理 MatchEvent:
#![allow(unused)] fn main() { 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() }); } } } }
来源:examples/counter/src/main.rs:49-58
MatchEvent trait 定义了应用如何响应用户操作。handle_actions 在每次用户交互时被调用:
self.ui.button(cx, ids!(increment_button))查找名为increment_button的按钮.clicked(actions)检查这个按钮是否被点击了script_eval!(cx, {...})在 Splash VM 中执行代码:增加计数器并触发 UI 重新渲染
script_eval! 是 Rust 调用 Splash 的桥梁——它把花括号内的 Splash 代码发送给 VM 执行。这让 Rust 代码可以修改 Splash 的状态和触发 UI 更新。
生命周期 AppMain:
#![allow(unused)] fn main() { impl AppMain for App { fn script_mod(vm: &mut ScriptVm) -> ScriptValue { crate::makepad_widgets::script_mod(vm); self::script_mod(vm) } fn handle_event(&mut self, cx: &mut Cx, event: &Event) { self.match_event(cx, event); self.ui.handle_event(cx, event, &mut Scope::empty()); } } }
来源:examples/counter/src/main.rs:60-70
AppMain trait 定义应用的生命周期:
script_mod()在启动时被调用,负责初始化 Splash VM——先加载标准 Widget 库(makepad_widgets::script_mod),再加载本应用的script_mod!内容handle_event()是事件循环的入口——系统事件(鼠标、键盘、触摸、窗口)从这里进入应用
这两个函数几乎在所有 Makepad 应用中都是一样的——它们是"样板代码"。应用的个性化逻辑在 MatchEvent 和 script_mod! 中。
运行时 vs 编译时:一个实验
理解 script_mod! 是运行时求值的最好方式是亲身体验。
实验:修改 Splash 代码,不重新编译。
Makepad 支持热重载——当你修改 script_mod! 内的 Splash 代码并保存时(在 Studio 中),应用立即显示修改后的 UI,不需要重新编译 Rust 代码。
1. 运行 counter 应用
2. 在 script_mod! 中修改 Label 的字号:
draw_text.text_style.font_size: 24 → font_size: 48
3. 保存文件
4. 应用立即显示更大的字体——没有等待编译
这是怎么做到的?因为 script_mod! 内的代码不会先变成 Rust UI 代码再编译。宏在编译期生成的是一个包含脚本源码和源位置信息的 ScriptMod;运行时由 Splash VM 解析它并构建 Widget 树。当源文件变化时,VM 重新解析修改后的脚本并更新 UI——整个过程不涉及 Rust 重新编译。
sequenceDiagram
participant R as Rust 编译器
participant V as Splash VM
participant S as 屏幕
Note over R: 编译时
R->>R: script_mod! 内容作为字符串保留
R->>R: 编译 Rust 代码(App, MatchEvent)
Note over V: 运行���
V->>V: 解析 Splash 字符串
V->>V: 构建 Widget 树
V->>S: 渲染 UI
Note over V: 热重载(文件变化时)
V->>V: 重新解析修改后的 Splash
V->>V: 重建 Widget 树
V->>S: 渲染更新后的 UI
Note over R: Rust 编译器不参与
这种"运行时求值"的设计是 Makepad 2.0 的核心优势。它不仅支持热重载,还为 AI 动态生成 UI 打开了大门——AI Agent 可以通过 WebSocket 发送 Splash 代码给运行中的应用,应用立即渲染新的 UI(详见第11章:流式求值,第27章:Canvas 架构剖析)。
应用的数据流
把上面的分析汇总为一张完整的数据流图:
用户点击 "Increment" 按钮
→ Makepad 生成 ButtonAction::Clicked
→ App::handle_actions() 被调用
→ 匹配到 increment_button.clicked
→ script_eval!{mod.state.counter += 1; ui.main_view.render()}
→ Splash VM 执行:
1. state.counter 从 0 变为 1
2. main_view.render() 被调用
→ on_render 回调执行
→ Label.text 更新为 "Count: 1"
→ 屏幕刷新
关键的交互有两个方向:
- Rust → Splash:通过
script_eval!发送 Splash 代码给 VM 执行 - Splash → Rust:通过
#(App::script_component(vm))在 Splash 中注册 Rust 组件,通过ids!(increment_button)在 Rust 中查找 Splash 创建的 Widget
这两个方向构成了 Makepad 的"双层架构":Splash 是 UI 的"声明层",Rust 是逻辑的"执行层"。
模式提炼
模式一:Makepad 应用骨架
每个 Makepad 2.0 应用都有相同的骨架结构:
#![allow(unused)] fn main() { pub use makepad_widgets; use makepad_widgets::*; app_main!(App); // 1. 入口宏 script_mod! { // 2. Splash UI 定义 use mod.prelude.widgets.* // ... Widget 树 ... } #[derive(Script, ScriptHook)] pub struct App { // 3. Rust 应用结构体 #[live] ui: WidgetRef, } impl MatchEvent for App { // 4. 事件处�� fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { // ... 处理用户操作 ... } } impl AppMain for App { // 5. 生命周期(几乎不变) fn script_mod(vm: &mut ScriptVm) -> ScriptValue { crate::makepad_widgets::script_mod(vm); self::script_mod(vm) } fn handle_event(&mut self, cx: &mut Cx, event: &Event) { self.match_event(cx, event); self.ui.handle_event(cx, event, &mut Scope::empty()); } } }
五个部分中,第 1、3、5 部分在所有应用中几乎相同。你的个性化工作集中在第 2 部分(Splash UI)和第 4 部分(事件处理)。
模式二:Rust-Splash 双向通信
| 方向 | 机制 | 用途 |
|---|---|---|
| Rust → Splash | script_eval!(cx, {...}) | 修改状态、触发渲染 |
| Splash → Rust | #(RustType::method(vm)) | 注册 Rust 组件到 Splash |
| Splash → Rust | ids!(widget_name) | 在 Rust 中查找 Splash Widget |
模式三:状态-渲染循环
状态变化 → 调用 render() → on_render 回调执行 → UI 更新
这是 on_render 驱动模式 中的标准循环。若只是更新现有 Widget 的文字或数值,也可以直接调用 set_text() 之类的命令式 API;只有在需要根据状态重新生成结构时,才需要修改状态后调用 render()。这和 React 的 setState → re-render 类似,但 Makepad 也保留了更直接的局部更新路径。
本章小结
| 组件 | 作用 | 所在文件 |
|---|---|---|
app_main!(App) | 跨平台入口 | main.rs:5 |
script_mod!{...} | Splash UI 定义(运行时求值) | main.rs:7-41 |
struct App | Rust 应用结构体 | main.rs:43-47 |
MatchEvent | 事件处理逻辑 | main.rs:49-58 |
AppMain | 应用生命周期(样板代码) | main.rs:60-70 |
核心要点:
script_mod!是运行时的——Splash 代码在 VM 中执行,不是在 Rust 编译器中展开- UI 是声明式的——你描述 UI 应该长什么样,不是命令式地构建它
- Rust 和 Splash 双向通信——
script_eval!从 Rust 到 Splash,ids!从 Splash 到 Rust
下一章将在这个基础上,给 counter 应用加入更丰富的交互——多个按钮、状态显示、条件渲染(详见第4章:Counter:状态与事件)。
第4章:Counter——状态与事件
为什么这很重要
上一章展示了 Makepad 应用的骨架结构——app_main!、script_mod!、MatchEvent、AppMain。但那个 counter 应用的交互逻辑被一笔带过。本章要回答一个核心问题:在 Makepad 2.0 中,状态如何流动?事件如何响应?
这个问题有两种回答方式。第一种是"纯 Splash"——所有逻辑都写在 Splash 脚本中,不需要 Rust 代码参与。第二种是"Rust + Splash 协作"——Splash 负责 UI,Rust 负责逻辑。两种方式各有适用场景,理解它们的区别是构建更复杂应用的基础。
本章将同一个 Counter 应用实现两次:先用纯 Splash,再用 Rust + Splash。通过对比,你会理解 Makepad 的状态管理不是一种固定模式,而是一个灵活的光谱——从"全部在 Splash"到"全部在 Rust"之间,你可以根据应用复杂度选择合适的位置。
flowchart LR
A["纯 Splash 模式"] -->|简单应用| B["状态 + UI + 逻辑<br/>全在 script_mod!"]
C["Rust + Splash 模式"] -->|复杂应用| D["Splash: UI<br/>Rust: 逻辑 + 状态"]
B --> E["适合 AI 生成"]
D --> F["适合团队协作"]
style A fill:#51cf66,color:#111
style C fill:#339af0,color:#fff
方式一:纯 Splash Counter
先看最简单的版本——所有逻辑都在 Splash 脚本中,零 Rust 逻辑代码。这种模式使用 set_text() 命令式更新 UI,和 Canvas 中的 pomodoro 应用类似(pomodoro 也有 on_render 声明式模式,但核心交互逻辑同样基于 on_click + 状态修改):
let state = { counter: 0 }
fn refresh() {
ui.counter_label.set_text("Count: " + state.counter)
}
SolidView{width: Fill height: Fit draw_bg.color: #x1a1a2e
flow: Down spacing: 16 padding: 24 align: Center
counter_label := Label{text: "Count: 0"
draw_text.color: #xffffff
draw_text.text_style.font_size: 32}
View{width: Fit height: Fit flow: Right spacing: 12
Button{text: "+1" draw_bg.color: #x51cf66
padding: Inset{left: 20. right: 20. top: 10. bottom: 10.}
draw_bg.radius: 6.
on_click: ||{
state.counter = state.counter + 1
refresh()
}
}
Button{text: "-1" draw_bg.color: #xff6b6b
padding: Inset{left: 20. right: 20. top: 10. bottom: 10.}
draw_bg.radius: 6.
on_click: ||{
if state.counter > 0 {
state.counter = state.counter - 1
}
refresh()
}
}
Button{text: "Reset" draw_bg.color: #x666688
padding: Inset{left: 20. right: 20. top: 10. bottom: 10.}
draw_bg.radius: 6.
on_click: ||{
state.counter = 0
refresh()
}
}
}
}
这段代码可以直接在 Canvas 中运行(通过 POST /splash 推送),不需要任何 Rust 代码。
纯 Splash 模式的状态流
sequenceDiagram
participant U as 用户
participant B as Button (on_click)
participant S as state 对象
participant L as Label (set_text)
U->>B: 点击 "+1"
B->>S: state.counter = state.counter + 1
B->>L: ui.counter_label.set_text("Count: 1")
Note over L: 屏幕刷新
流程很直接:
- 状态定义:
let state = { counter: 0 }创建一个 Splash 对象 - 事件处理:
on_click: ||{...}在按钮点击时执行——修改state.counter,然后调用refresh()更新 UI - UI 更新:
ui.counter_label.set_text(...)直接修改 Label 的文字
这里的关键 API 是 ui.widget_name.set_text(string)——它通过 := 命名找到对应的 Widget,然后设置其文字内容。counter_label := 声明了一个可寻址的 Label(详见第8章:模板与组合),ui.counter_label 就可以在运行时访问它。
纯 Splash 模式的特点
| 优势 | 局限 |
|---|---|
| 代码全在一个地方,结构清晰 | 没有类型系统保护 |
| AI 可以完整生成和修改 | 大型应用难以维护 |
| 不需要 Rust 编译 | 无法直接调用宿主 Rust 库(文件、数据库、加密、本地系统资源等;网络请求可用 net.http_request) |
| 热重载即时生效 | 性能不如原生 Rust |
纯 Splash 模式最适合三种场景:AI 生成的应用(详见第27章:Canvas 架构剖析)、快速原型、以及交互逻辑简单的工具型 UI。
方式二:Rust + Splash Counter
现在看 examples/counter/ 中的实际实现——Splash 负责 UI 结构,Rust 负责事件处理。这是 Makepad 应用的标准模式:
Splash 部分:UI 定义
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"
}
}
}
}
}
来源:examples/counter/src/main.rs:7-41
Rust 部分:事件处理
#![allow(unused)] fn main() { 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() }); } } } }
来源:examples/counter/src/main.rs:49-58
Rust + Splash 模式的状态流
sequenceDiagram
participant U as 用户
participant M as Makepad Runtime
participant R as Rust (MatchEvent)
participant V as Splash VM
participant S as Splash 屏幕
U->>M: 点击 increment_button
M->>R: handle_actions(actions)
R->>R: button.clicked(actions)?
R->>V: script_eval!{counter += 1; render()}
V->>V: mod.state.counter = 1
V->>V: on_render 回调执行
V->>S: Label.text = "Count: 1"
和纯 Splash 版本的关键区别:
- 事件在 Rust 中被捕获:
self.ui.button(cx, ids!(increment_button)).clicked(actions)是 Rust 代码,在 Rust 的类型系统保护下运行 - 状态修改通过
script_eval!桥接:Rust 代码通过script_eval!向 Splash VM 发送脚本执行 - UI 更新仍在 Splash 中:
ui.main_view.render()触发 Splash 侧的on_render回调
深入理解:双向通信的三种机制
Makepad 提供了三种 Rust↔Splash 通信机制,对应不同的数据流方向。
机制一:Rust → Splash(script_eval!)
#![allow(unused)] fn main() { script_eval!(cx, { mod.state.counter += 1 ui.main_view.render() }); }
来源:examples/counter/src/main.rs:52-55
script_eval! 会把花括号内的 Splash 代码经 script! 宏展开成一个 ScriptMod,然后在 VM 中执行。它是 Rust 向 Splash VM 发送指令的主要方式。
Makepad 还提供了另一个宏——script_apply_eval!——用于直接修改特定 Widget 的属性:
#![allow(unused)] fn main() { let color = vec4(1.0, 0.0, 0.0, 1.0); script_apply_eval!(cx, my_widget, { draw_bg +: { color: #(color) } }); }
script_apply_eval! 和 script_eval! 的区别:script_eval! 执行任意 Splash 代码(可以访问 mod.state、调用函数),script_apply_eval! 只能 patch 一个具体 Widget 的属性。它更像"运行时属性补丁"而不是完整脚本环境:动态值通常通过 #(rust_expr) 注入;部分枚举/常量也可以直接写(例如现有代码中的 cursor: Hand),但可用范围取决于具体属性和运行时类型。
使用场景:用户交互后需要更新 UI 状态——修改计数器、切换页面、加载数据后刷新显示。
注意:script_eval! 的执行是同步的——花括号内的 Splash 代码在当前帧内立即执行。不需要等待下一帧。这意味着你可以在 script_eval! 之后立即读取更新后的状态。
script_eval! 的内容不会进入 Rust 的类型系统。编译期宏会生成一个 ScriptMod,其中脚本主体以源码字符串保存,#(...) 注入值则单独收集到 values。如果你写了无效的 Splash 代码(比如拼错了变量名),错误会在运行时而不是编译时出现。这是"运行时求值"的代价——灵活性换来了编译期安全性的降低。
机制二:Splash → Rust(Widget 查询 ids!)
#![allow(unused)] fn main() { self.ui.button(cx, ids!(increment_button)).clicked(actions) }
来源:examples/counter/src/main.rs:51
ids!(increment_button) 是 Splash → Rust 方向的桥梁。它通过 Widget 名称(Splash 中用 := 声明的)在 Rust 中查找对应的 Widget 引用。然后在这个引用上调用 .clicked(actions) 检查用户是否点击了这个按钮。
使用场景:Rust 代码需要知道"用户做了什么"——某个按钮是否被点击、某个输入框的内容是什么。
ids! 宏将字符串转换为 LiveId——Makepad 内部用于快速查找 Widget 的标识符。它和 Splash 中 := 声明的名称必须完全匹配。如果名称拼错了,button() 不会 panic,但 .clicked(actions) 永远返回 false——一个静默的 bug。在更复杂的应用中,建议在 handle_actions 开头统一解构所有 Widget 引用,而不是在每个条件分支中分散查询。
常用查询方法:
#![allow(unused)] fn main() { // 按钮点击 self.ui.button(cx, ids!(my_button)).clicked(actions) // 文本输入值 self.ui.text_input(cx, ids!(my_input)).text() // Slider 值变化 self.ui.slider(cx, ids!(my_slider)).changed(actions) }
机制三:Splash → Rust(Rust 组件注册 #(...))
startup() do #(App::script_component(vm)){...}
来源:examples/counter/src/main.rs:13
#(App::script_component(vm)) 在 Splash 中注册一个 Rust 组件。这告诉 VM:"这段 UI 树归 Rust 侧的 App 管理"。注册后,App 的 MatchEvent 实现就能接收这个 UI 树中的所有事件。
使用场景:应用初始化时建立 Splash UI 树和 Rust 事件处理器之间的关联。
三种机制的配合
flowchart TD
subgraph Splash["Splash (VM)"]
S1["state 对象"]
S2["Widget 树"]
S3["on_render 回调"]
end
subgraph Rust["Rust"]
R1["MatchEvent"]
R2["script_eval!"]
R3["ids!() 查询"]
end
S2 -->|"#(App::...)"| R1
R1 -->|"ids!(name)"| S2
R1 -->|"script_eval!"| S1
S1 -->|"render()"| S3
S3 -->|"更新 Widget"| S2
style Splash fill:#1a1a2e,color:#fff
style Rust fill:#2a2a4e,color:#fff
数据流形成一个循环:
- 用户交互产生事件 → Makepad 分发到 Rust 的
MatchEvent - Rust 通过
ids!查找 Widget → 判断是哪个按钮被点击 - Rust 通过
script_eval!修改 Splash 状态 → 触发render() - Splash 的
on_render回调执行 → 根据新状态更新 Widget → 屏幕刷新
两种模式的选择
| 维度 | 纯 Splash | Rust + Splash |
|---|---|---|
| 适用复杂度 | 中等交互(按钮、表单、HTTP 请求) | 复杂逻辑(并发、加密、文件系统) |
| AI 友好度 | 高(AI 可完整生成) | 中(AI 生成 Splash,人写 Rust) |
| 类型安全 | 无 | Rust 编译器保护 |
| 网络能力 | net.http_request(GET/POST/流式) | 任何 Rust crate(reqwest、tonic 等) |
| 热重载范围 | 全部代码 | 仅 Splash 部分 |
| 代码组织 | 单文件 | Splash UI + Rust 逻辑分离 |
注意:纯 Splash 现在也支持网络请求。 Splash 内置了 net.http_request API,支持 GET/POST/流式响应和 HTML 解析(parse_html())。这意味着纯 Splash 应用可以调用外部 API、搜索引擎、加载远程数据——不再局限于"纯本地"场景。
经验法则:
- 简单到中等复杂度(UI + HTTP API 调用)→ 纯 Splash
- 需要文件系统、加密、并发、或特定 Rust crate → Rust + Splash
- 如果 AI Agent 需要动态生成和修改 UI → 纯 Splash(这是 Canvas 的模式)
- 如果团队协作开发 → Rust + Splash(Splash 负责 UI,Rust 负责业务逻辑)
大多数生产应用使用 Rust + Splash 模式,因为实际应用往往还需要存储、宿主资源或特定 Rust 集成。网络请求本身已经不再是必须切回 Rust 的分界线。但纯 Splash 模式在 AI 生成场景中仍然是核心模式——Canvas 中很多示例界面都可以主要用 Splash 描述;像 pomodoro、token-dashboard 这类案例基本是脚本内自洽的,而 music-player 则额外依赖 Canvas 宿主提供的音频服务(详见第27章、第30章)。
值得注意的是,两种模式可以混合使用。你可以从纯 Splash 原型开始,当需要 Rust 能力时逐步迁移特定的事件处理到 Rust 侧——不需要重写整个应用。on_click 中的 Splash 逻辑和 MatchEvent 中的 Rust 逻辑可以共存。这种渐进式迁移路径意味着你不需要一开始就做出最终的架构决策。
实际上,pomodoro.splash 就是一个很好的例子:它的全部逻辑(状态、定时器、UI 更新)都在 Splash 中。如果将来需要添加"保存历史记录到文件"的功能,只需要在 Rust 侧添加一个 MatchEvent 处理器,用 ids! 监听某个按钮,然后用 Rust 的 std::fs 写文件——pomodoro 的 Splash 代码不需要改动。
扩展练习:给 Counter 添加功能
在纯 Splash 版本的基础上,尝试以下扩展。这些练习不需要修改 Rust 代码:
练习一:添加步长控制
let state = { counter: 0 step: 1 }
fn refresh() {
ui.counter_label.set_text("Count: " + state.counter)
ui.step_label.set_text("Step: " + state.step)
}
// 在 UI 中添加步长按钮
Button{text: "Step +1"
on_click: ||{ state.step = state.step + 1 refresh() }}
Button{text: "Step -1"
on_click: ||{
if state.step > 1 { state.step = state.step - 1 }
refresh()
}}
// 修改 +1 按钮为 +step
Button{text: "Add"
on_click: ||{
state.counter = state.counter + state.step
refresh()
}}
练习二:添加最大值限制
let state = { counter: 0 max: 100 }
fn add(n) {
state.counter = state.counter + n
if state.counter > state.max { state.counter = state.max }
if state.counter < 0 { state.counter = 0 }
refresh()
}
这些练习展示了纯 Splash 模式的灵活性——不需要重新编译,修改 Splash 代码即可添加新功能。
模式提炼
模式一:状态-更新-渲染循环
状态变化 → 调用 refresh()/render() → UI 反映新状态
无论是纯 Splash 还是 Rust + Splash,UI 更新的模式都是一样的:先修改状态,再触发更新函数。不要试图直接修改 Widget 属性然后期望它自动持久化——状态是"单一真相来源"(Single Source of Truth),UI 是状态的投影。
模式二:refresh() 辅助函数
在纯 Splash 模式中,定义一个 refresh() 函数来集中处理所有 UI 更新逻辑:
fn refresh() {
ui.label_a.set_text("..." + state.a)
ui.label_b.set_text("..." + state.b)
// 所有 UI 更新在这里
}
每个 on_click 处理器只需要:修改状态 → 调用 refresh()。这避免了在每个事件处理器中重复写 UI 更新代码。
模式三:事件处理位置选择
| 场景 | 在哪里处理事件 |
|---|---|
| 简单 UI 交互 | Splash on_click |
| 需要调用 Rust 库 | Rust MatchEvent |
| AI 生成的应用 | Splash on_click |
| 需要类型安全 | Rust MatchEvent |
本章小结
| 概念 | 纯 Splash | Rust + Splash |
|---|---|---|
| 状态定义 | let state = {...} | let state = {...} + mod.state |
| 事件处理 | on_click: ||{...} | MatchEvent::handle_actions |
| UI 更新 | ui.name.set_text(...) | script_eval!{...render()} |
| 适用场景 | AI 生成、简单工具 | 生产应用、团队协作 |
核心要点:
- Makepad 的状态管理是灵活的光谱——从纯 Splash 到纯 Rust,根据应用复杂度选择
script_eval!是 Rust→Splash 的桥梁,ids!是 Splash→Rust 的桥梁- 状态是唯一真相来源——修改状态后触发更新,不要直接操作 Widget
下一章将用这些知识构建一个更复杂的应用——Todo 列表,引入列表渲染和数据驱动 UI(详见第5章:Todo 数据驱动 UI)。
第5章:Todo——数据驱动 UI
为什么这很重要
Counter 教会了你状态管理和事件响应的基本模式。但 Counter 只有一个数字——现实应用需要管理一组数据。Todo 列表就是这类需求的最小案例:一个可变长度的数据列表,每一项可以独立操作(添加、删除、标记完成)。
本章将构建一个完整的 Todo 应用,涉及 Makepad 2.0 的几个新概念:
- 列表渲染:如何把一组数据映射为一组 Widget
- 模板实例化:如何用
let模板定义列表项的样式(详见第8章:模板与组合) - TextInput 输入处理:如何接收用户的文字输入
- PortalList:Makepad 的虚拟化列表组件
我们会先用纯 Splash 构建一个简化版 Todo(展示核心模式),再分析 examples/todo/ 中的完整 Rust+Splash 实现。最后对比 React/Flutter 中同样功能的实现方式,突出 Makepad 运行时修改的优势。
flowchart TD
A["数据: Vec<TodoItem>"] -->|渲染| B["PortalList"]
B --> C["TodoRow 1"]
B --> D["TodoRow 2"]
B --> E["TodoRow N"]
F["用户操作"] -->|添加/删除/完成| A
A -->|redraw| B
style A fill:#51cf66,color:#111
简化版:纯 Splash Todo
先看一个不需要 Rust 代码的 Todo——使用纯 Splash 的 set_text 和 on_render 模式。这个版本没有虚拟化列表(所有项同时渲染),适合小规模场景:
let state = { items: ["Buy groceries" "Fix login bug" "Write tests" "Call dentist"] count: 4 }
fn refresh() {
ui.list_view.render()
ui.status.set_text("" + state.count + " items")
}
SolidView{width: Fill height: Fit draw_bg.color: #x1a1a2e
flow: Down spacing: 12 padding: 20
Label{text: "My Todo" draw_text.color: #xfff draw_text.text_style.font_size: 20}
View{width: Fill height: Fit flow: Right spacing: 8
input := TextInput{width: Fill height: Fit empty_text: "What needs to be done?"}
Button{text: "+" draw_bg.color: #x51cf66 draw_bg.radius: 6.
padding: Inset{left: 16. right: 16. top: 8. bottom: 8.}
on_click: ||{
let text = ui.input.text()
if text != "" {
state.items[state.count] = text
state.count = state.count + 1
ui.input.set_text("")
refresh()
}
}
}
}
list_view := View{width: Fill height: Fit flow: Down spacing: 4
on_render: ||{
let i = 0
while i < state.count {
RoundedView{width: Fill height: Fit draw_bg.color: #x252540 draw_bg.radius: 6.
padding: Inset{left: 12. right: 12. top: 8. bottom: 8.}
Label{text: state.items[i] draw_text.color: #xddd draw_text.text_style.font_size: 12}
}
i = i + 1
}
}
}
status := Label{text: "4 items" draw_text.color: #x888 draw_text.text_style.font_size: 10}
}
这个纯 Splash 版本展示了数据驱动 UI 的核心模式:
- 数据在
state中:items数组持有所有 Todo 文本 on_render根据数据生成 UI:用while循环为每一项创建一个RoundedView- 用户操作修改数据:
on_click向数组添加新项 - 修改后触发重新渲染:
refresh()调用ui.list_view.render()让on_render重新执行
这就是"数据驱动 UI"的本质——UI 是数据的投影。你不需要手动创建、移动、删除 Widget,只需要修改数据然后重新渲染。
和上一章的 Counter 对比,关键的新模式是 on_render 中的 while 循环。Counter 的 on_render 只生成一个 Label;Todo 的 on_render 根据数组长度生成 N 个 RoundedView。每次 render() 被调用,这段列表定义都会重新执行,并以 reload 方式重新应用到 list_view。这种"整块子树重新应用"的方式简单但有性能上限——当列表超过几百项时,结构更新的开销会变得明显。
纯 Splash 版本并非完全不能删除。当前 Splash 数组已经支持 remove(index),因此理论上可以在纯 Splash 中实现删除;只是它仍然没有 splice 这类更丰富的数组编辑 API。完整版 Todo 仍把数据放在 Rust 侧,主要是为了和 PortalList 虚拟化、Rust 事件处理以及真实数据源整合。
纯 Splash 版本的价值在于快速原型和 AI 生成。AI Agent 可以很快生成上面的代码,用户随后就能看到一个可交互的 Todo 原型。当需要添加持久化、同步、删除等高级功能时,再迁移到 Rust + Splash 模式。这种渐进式开发路径(详见第4章)是 Makepad 的核心工作流。
完整版:examples/todo/ 分析
纯 Splash 版本适合小规模场景。当 Todo 项数超过几十个时,每次 on_render 都重新生成并应用整段列表子树,开销会逐渐明显。这就是 examples/todo/ 中使用 PortalList 的原因——它只渲染可见区域内的项目(虚拟化),即使有上千个 Todo 项也能流畅滚动。
数据层:Rust 中的 Todo 数据
#![allow(unused)] fn main() { #[derive(Clone, Debug)] struct TodoItemData { text: String, tag: String, done: bool, } static TODOS: LazyLock<RwLock<Vec<TodoItemData>>> = LazyLock::new(|| RwLock::new(initial_todos())); }
来源:examples/todo/src/main.rs:8-23
数据用 Rust 的 Vec<TodoItemData> 存储,通过 static + RwLock 实现全局访问。每个 Todo 项有三个字段:text(内容)、tag(标签)、done(是否完成)。
为什么数据在 Rust 侧而不是 Splash 侧?因为在真实应用中,数据往往还要和持久化、虚拟化列表、复杂事件处理以及宿主侧资源整合。Splash 现在已经能直接发 HTTP 请求,但像数据库访问、共享状态管理和高吞吐数据通路,通常仍更适合放在 Rust 侧。把数据放在 Rust 侧因此仍是 Makepad 生产应用的常见模式。
UI 层:Splash 中的 TodoRow 模板
let TodoRow = 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: "" ...}
}
delete := ButtonFlatter{text: "x" width: 28 height: 28}
}
来源:examples/todo/src/main.rs:50-85(简化)
TodoRow 用 let 定义了列表项的模板(详见第8章)。每一行包含:一个复选框(check :=)、一个文字标签(label :=)、一个分类标签(tag :=)、一个删除按钮(delete :=)。所有需要从 Rust 侧动态设置内容的子组件都用 := 命名。
注意这个模板使用了 theme. 变量(如 theme.color_bg_container、theme.space_2)——这是 Makepad 的主题系统(详见第13章),让 UI 可以适配深色/浅色模式。
列表渲染:PortalList
todo_list := mod.widgets.TodoList{}
Todo 列表使用自定义的 TodoList Widget,内部包含一个 PortalList:
mod.widgets.TodoList = ...{
list := PortalList{
width: Fill height: Fill
Item := CachedView{TodoRow{}}
Empty := CachedView{EmptyState{}}
}
}
来源:examples/todo/src/main.rs:96-109(简化)
PortalList 是 Makepad 的虚拟化列表——它只渲染可视区域内的项目。Item 和 Empty 是两种行模板:有数据时用 Item(包含 TodoRow),没有数据时显示 Empty(空状态提示)。
CachedView 包裹 TodoRow,启用纹理缓存——已渲染的行被缓存为 GPU 纹理,滚动时直接使用缓存而不重新绘制。这是 PortalList 高性能的关键(详见第15章:列表与虚拟化)。
渲染桥梁:draw_walk 中的数据绑定
连接 Rust 数据和 Splash 模板的关键代码在 TodoList 的 draw_walk 方法中:
#![allow(unused)] fn main() { impl Widget for TodoList { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { let todos = TODOS.read().unwrap(); while let Some(step) = self.view.draw_walk(cx, scope, walk).step() { if let Some(mut list) = step.as_portal_list().borrow_mut() { list.set_item_range(cx, 0, todos.len()); while let Some(item_id) = list.next_visible_item(cx) { let item = list.item(cx, item_id, id!(Item)); let todo = &todos[item_id]; item.check_box(cx, ids!(check)).set_active(cx, todo.done); item.label(cx, ids!(label)).set_text(cx, &todo.text); item.label(cx, ids!(tag.tag_label)).set_text(cx, &todo.tag); item.draw_all_unscoped(cx); } } } DrawStep::done() } } }
来源:examples/todo/src/main.rs:210-241(简化,省略空列表处理)
逐步分析这段代码:
TODOS.read().unwrap():获取数据的读锁list.set_item_range(cx, 0, todos.len()):告诉 PortalList 有多少项数据list.next_visible_item(cx):获取下一个需要渲染的可见项 ID(PortalList 只返回可视区域内的项)list.item(cx, item_id, id!(Item)):为这个 item_id 创建或复用一个Item模板实例item.check_box(...).set_active(cx, todo.done):把 Rust 数据绑定到 Splash Widgetitem.draw_all_unscoped(cx):绘制这一行
这就是 Makepad 的"数据绑定"——不是框架自动的双向绑定,而是在 draw_walk 中手动将 Rust 数据设置到 Widget 属性上。这种显式绑定看起来比 React 的 JSX 更繁琐,但它的好处是零魔法——你能清楚地看到每个属性是在哪里被设置的,调试时不需要追踪数据流。
CRUD 操作:Rust 侧的事件处理
Create(添加):
#![allow(unused)] fn main() { fn add_todo(&mut self, cx: &mut Cx, text: &str) { let text = text.trim(); if text.is_empty() { return; } TODOS.write().unwrap().push(TodoItemData { text: text.to_string(), tag: String::new(), done: false, }); self.ui.text_input(cx, ids!(todo_input)).set_text(cx, ""); self.sync_status(cx); self.ui.redraw(cx); } }
来源:examples/todo/src/main.rs:249-262
添加 Todo:向 TODOS Vec 推入新项,清空输入框,更新状态栏,触发重绘。
Update(标记完成):
#![allow(unused)] fn main() { fn toggle_item(&mut self, cx: &mut Cx, item_id: usize, checked: bool) { if let Some(todo) = TODOS.write().unwrap().get_mut(item_id) { todo.done = checked; } self.sync_status(cx); self.ui.redraw(cx); } }
来源:examples/todo/src/main.rs:270-276
Delete(删除):
#![allow(unused)] fn main() { fn delete_item(&mut self, cx: &mut Cx, item_id: usize) { let mut todos = TODOS.write().unwrap(); if item_id < todos.len() { todos.remove(item_id); } drop(todos); self.sync_status(cx); self.ui.redraw(cx); } }
来源:examples/todo/src/main.rs:278-286
三个操作的模式完全一致:修改数据 → 同步状态栏 → 触发重绘。这就是第4章提到的"状态是唯一真相来源"原则的实际应用。
事件分发:MatchEvent
#![allow(unused)] fn main() { impl MatchEvent for App { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { // TextInput 回车 if let Some((text, _)) = self.ui.text_input(cx, ids!(todo_input)).returned(actions) { self.add_todo(cx, &text); } // "+" 按钮点击 if self.ui.button(cx, ids!(add_button)).clicked(actions) { let text = self.ui.text_input(cx, ids!(todo_input)).text(); self.add_todo(cx, &text); } // "Clear completed" 按钮 if self.ui.button(cx, ids!(clear_done)).clicked(actions) { self.clear_done(cx); } // 列表项内的事件 let todo_list = self.ui.widget(cx, ids!(todo_list)); let list = todo_list.portal_list(cx, ids!(list)); for (item_id, item) in list.items_with_actions(actions) { if let Some(checked) = item.check_box(cx, ids!(check)).changed(actions) { self.toggle_item(cx, item_id, checked); } if item.button(cx, ids!(delete)).clicked(actions) { self.delete_item(cx, item_id); } } } } }
来源:examples/todo/src/main.rs:297-320(简化)
注意 list.items_with_actions(actions) 这个 API——它遍历 PortalList 中所有产生了 action 的项目,返回 (item_id, item) 对。item_id 就是数据的索引,直接用于查找和修改 TODOS[item_id]。
这种"列表项事件 → item_id → 数据索引"的映射是 Makepad 列表交互的标准模式。不需要给每个列表项绑定唯一的回调——PortalList 自动管理 item_id 和实际 Widget 的映射。
数据流全景
把 Todo 应用的完整数据流画出来:
flowchart TD
subgraph Rust["Rust 侧"]
D["TODOS: Vec<TodoItemData>"]
H["handle_actions()"]
A["add_todo()"]
T["toggle_item()"]
DEL["delete_item()"]
end
subgraph Splash["Splash 侧"]
TI["TextInput (todo_input)"]
AB["Button (+)"]
PL["PortalList"]
TR["TodoRow × N"]
CB["CheckBox (check)"]
DB["Button (delete)"]
end
TI -->|"returned()"| H
AB -->|"clicked()"| H
CB -->|"changed()"| H
DB -->|"clicked()"| H
H --> A
H --> T
H --> DEL
A -->|"push"| D
T -->|"修改 done"| D
DEL -->|"remove"| D
D -->|"redraw()"| PL
PL -->|"draw_walk"| TR
style Rust fill:#2a2a4e,color:#fff
style Splash fill:#1a1a2e,color:#fff
数据流是单向的:用户操作 → Rust 事件处理 → 修改数据 → 重绘 UI。Splash 侧从不直接修改数据,只负责渲染和收集用户事件。
对比:React 中的 Todo
同样的 Todo 功能在 React 中的核心逻辑:
function TodoApp() {
const [todos, setTodos] = useState([{text: "Buy groceries", done: false}]);
const [input, setInput] = useState("");
const addTodo = () => {
if (!input.trim()) return;
setTodos([...todos, {text: input, done: false}]);
setInput("");
};
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={addTodo}>+</button>
{todos.map((todo, i) => (
<div key={i}>
<input type="checkbox" checked={todo.done}
onChange={() => {
const next = [...todos];
next[i].done = !next[i].done;
setTodos(next);
}} />
<span>{todo.text}</span>
</div>
))}
</div>
);
}
两者的核心模式相同:状态驱动 UI,事件修改状态。但有几个关键差异:
| 维度 | Makepad (Splash + Rust) | React |
|---|---|---|
| 列表渲染 | PortalList + draw_walk | Array.map() + virtual DOM diff |
| 虚拟化 | 原生支持(PortalList) | 需要 react-window 等三方库 |
| 运行时修改 UI | 可以(修改 Splash 后可直接重新求值) | 需要重新编译/打包 |
| AI 修改 UI | Splash 代码可以被 AI 动态替换 | 需要构建工具链参与 |
| 性能 | GPU 渲染,零 GC 暂停 | DOM 操作,JavaScript GC |
Makepad 的核心优势不在于"Todo 写起来更简单"——两者的代码量和复杂度相当。优势在于运行时能力:你可以在应用运行时修改 Splash 中的 TodoRow 模板,并在重新求值后看到所有列表项的样式变化,不需要重新编译。这在 React 中是不可能的——JSX 是编译时的。
对于 AI 生成场景,这个差异更加显著。假设你想让 AI 重新设计 Todo 列表的外观——添加渐变背景、改变字体、调整布局。在 React 中,AI 需要生成 JSX + CSS,经过 Babel/Webpack 编译,生成新的 bundle,浏览器重新加载;在 Makepad 中,AI 可以把新的 Splash 代码(新的 TodoRow 模板)直接发给运行时并重新求值,反馈链路更短(详见第11章:流式求值)。
更根本的是,React 的 Todo 和它的构建工具链是耦合的——你不能在没有 Node.js、npm、Webpack 的环境中运行它。Makepad 的 Todo 是一个独立的原生二进制文件,不需要任何运行时环境。Splash 脚本内嵌在 Rust 代码中,在任何支持的平台(macOS、Windows、Linux、Android、iOS、WASM)上编译一次就能运行。
这并不意味着 Makepad 在所有方面都优于 React——React 的生态系统(npm 包、UI 库、开发者社区)远远超过 Makepad。但在"运行时修改 UI"和"AI 动态生成 UI"这两个特定场景中,Makepad 的架构提供了 React 无法比拟的优势。这正是 Canvas Agent-to-App 管线(详见第27章)选择 Makepad 的原因。
模式提炼
模式一:数据-渲染分离
Rust 侧:Vec<Data> + 修改方法(add, toggle, delete)
Splash 侧:模板定义(let TodoRow = ...)+ PortalList 渲染
桥梁:handle_actions → 修改数据 → redraw()
数据存在 Rust,模板定义在 Splash,渲染逻辑在 draw_walk 中连接两者。这是 Makepad 列表应用的标准架构。
模式二:PortalList 列表交互
#![allow(unused)] fn main() { let list = widget.portal_list(cx, ids!(list)); for (item_id, item) in list.items_with_actions(actions) { // item_id = 数据索引 // item = Widget 引用 if item.check_box(cx, ids!(check)).changed(actions) { ... } if item.button(cx, ids!(delete)).clicked(actions) { ... } } }
不需要为每个列表项单独注册事件——items_with_actions 自动找出哪些项有用户操作,返回 (index, widget) 对供你处理。
模式三:统一的 CRUD 尾部
#![allow(unused)] fn main() { // 每个 CRUD 操作的结尾都是相同的三步 self.sync_status(cx); // 1. 更新状态栏 self.ui.redraw(cx); // 2. 触发重绘 }
把"同步 UI"的逻辑集中在少数几个方法中,避免在每个事件处理器中重复写 redraw 逻辑。
本章小结
| 概念 | 实现方式 |
|---|---|
| 数据存储 | Rust: static TODOS: LazyLock<RwLock<Vec<TodoItemData>>> |
| 列表模板 | Splash: let TodoRow = RoundedView{check := ... label := ...} |
| 虚拟化列表 | PortalList + CachedView |
| 列表项事件 | list.items_with_actions(actions) → (item_id, item) |
| CRUD 操作 | Rust 方法修改 Vec → redraw() |
| 输入处理 | TextInput.returned(actions) / TextInput.text() |
核心要点:
- 数据驱动 UI = 修改数据 → 触发重绘 → UI 自动反映新状态
- PortalList 提供原生虚拟化列表,性能远超 DOM 列表
- Makepad 的运行时优势在列表场景中尤为明显——修改模板样式即时生效
Part I(入门篇)到此结束。下一步是 Part III 的 Phase 3:回头写第1-2章(设计哲学和环境搭建),然后进入 Part II 的进阶章节。
第6章:Splash 语法设计哲学
为什么这很重要
如果你曾用过 Qt/QML、Flutter、SwiftUI 或 React,你已经习惯了一种模式:UI 描述语言是某种"宿主语言的方言"。QML 是 JavaScript 的扩展,JSX 是 JavaScript 的语法糖,SwiftUI 是 Swift 的 DSL。它们的语法规则——逗号、分号、括号匹配——继承自宿主语言。
Makepad 2.0 做了一个不同的选择:从零设计一种新语言。
这种语言叫 Splash。它没有逗号,没有分号,用空格分隔属性,用花括号嵌套组件。第一次看到 Splash 代码,你会注意到它不像任何你熟悉的语法——它像 JSON 但又不是 JSON,像 CSS 但又不是 CSS。这种"似曾相识又全然不同"的感觉是刻意的。
Splash 的语法设计服务于三个目标:
- 人类可读性:去掉所有不承载语义的标点符号,让 UI 描述清晰直白
- 流式解析:代码可以逐字符到达,解析器无需等待完整输入即可开始工作
- AI 友好性:更紧凑的表达意味着 AI 生成相同 UI 所需的 token 更少
这三个目标并非巧合。它们指向同一个设计赌注:UI 描述应该是运行时的、可流式的、AI 可写的。当你把这三个约束放在一起,大多数现有 UI 语法都无法同时满足。JSON 对机器友好但对人类不友好;XML 对人类可读但关闭标签浪费空间;QML 接近理想但它的 JavaScript 表达式让流式解析变得复杂。
Splash 不是对某种现有语法的改良,它是为 AI 时代专门设计的 UI 语言。本章将通过一个真实的 Splash 应用——番茄钟计时器——来解剖这种语法设计的每一个决策,并深入源码验证这些决策如何被实现。
flowchart LR
A[传统 UI 语言] -->|编译时解析| B[静态 UI]
C[Splash] -->|运行时流式求值| D[动态 UI]
C -->|AI 逐 token 输出| D
E[宿主语言方言] -->|继承语法负担| A
F[从零设计] -->|为流式优化| C
style C fill:#51cf66,color:#111
style D fill:#339af0,color:#fff
初见 Splash:一个番茄钟
在讲解语法规则之前,先看一段真实的 Splash 代码。这是 Canvas 项目中的番茄钟计时器,一个完整的、可运行的应用——84 行代码,包含状态管理、定时器、交互逻辑和 UI 布局。
这里先只看它的 UI 布局部分(已做格式调整以增强可读性):
SolidView{width: Fill height: Fit draw_bg.color: #x0a0a12
flow: Down align: Center spacing: 20
padding: Inset{left: 40. right: 40. top: 50. bottom: 40.}
mode_label := Label{text: "FOCUS TIME"
draw_text.color: #xff6b6b
draw_text.text_style.font_size: 14}
timer_label := Label{text: "25:00"
draw_text.color: #xffffff
draw_text.text_style.font_size: 64}
View{height: 20}
View{width: Fit height: Fit flow: Right spacing: 16 align: Center
start_btn := Button{text: "Start"
draw_bg.color: #x51cf66 draw_text.color: #x111111
padding: Inset{left: 24. right: 24. top: 12. bottom: 12.}
draw_bg.radius: 6.}
Button{text: "Reset"
draw_bg.color: #x444466 draw_text.color: #xccccdd
padding: Inset{left: 24. right: 24. top: 12. bottom: 12.}
draw_bg.radius: 6.}
Button{text: "Skip"
draw_bg.color: #x333355 draw_text.color: #x9999bb
padding: Inset{left: 24. right: 24. top: 12. bottom: 12.}
draw_bg.radius: 6.}
}
}
来源:tools/canvas/examples/pomodoro.splash:53-84(已格式化)
先不纠结每个属性的含义(详见第7章:属性与容器),注意这段代码的"外观":
- 没有逗号——属性之间用空格或换行分隔
- 没有分号——语句不需要终止符
- 花括号嵌套——
SolidView{...}表示组件及其内容 - 冒号赋值——
text: "Start"而不是text = "Start" - 点路径属性——
draw_text.color: #xff6b6b而不是嵌套对象 - 内联布局——多个属性可以写在同一行,也可以分行写
这不是偶然的风格选择。每一条都是经过权衡的设计决策。接下来逐条解剖。
语法解剖:七条核心规则
规则一:空格是分隔符,不是逗号
width: Fill height: Fit flow: Down spacing: 20
在 Splash 中,属性之间用空格(或换行)分隔。不需要逗号,不需要分号。解析器看到 identifier: 模式就知道新属性开始了。
对比番茄钟中的按钮行,两个属性紧挨着写:
draw_bg.color: #x51cf66 draw_text.color: #x111111
来源:tools/canvas/examples/pomodoro.splash:58(简化提取)
中间只有一个空格。draw_bg.color: 和 draw_text.color: 各自是完整的 key: value 对,空格是它们之间的天然边界。
这个设计消除了一类常见的编程错误:漏写逗号。在 JSON 中,{"a": 1 "b": 2} 是语法错误——漏了一个逗号。在 Splash 中,a: 1 b: 2 天然合法。对于 AI 生成代码来说,少一种可能出错的标点符号意味着更高的生成成功率。
同时,这也意味着 Splash 代码的换行是可选的。你可以一行写多个属性(紧凑),也可以每个属性一行(清晰):
// 紧凑风格
Button{text: "OK" draw_bg.color: #x51cf66 draw_bg.radius: 6.}
// 展开风格
Button{
text: "OK"
draw_bg.color: #x51cf66
draw_bg.radius: 6.
}
两种写法语义完全相同。这种灵活性让人类和 AI 可以根据场景选择最合适的格式。
规则二:Widget / 对象主要用花括号,数组和调用各用自己的定界符
SolidView{ ... }
Inset{left: 40. right: 40. top: 50. bottom: 40.}
在 UI 结构层面,Splash 主要靠 {} 表示 Widget 和内联对象。Widget 用 WidgetName{...},内联值对象用 TypeName{...}。但它并不是“只有一种括号”的语言:数组字面量和索引用 [],函数 / 方法调用参数用 ()。
这仍然比 XML 和 JSX 简单得多。对比 XML 和 JSX,关闭标签必须和开启标签的名字匹配(<View>...</View>)——如果 AI 在生成过程中写错了关闭标签的名字,整个 UI 描述就崩溃了。而在 Splash 的 Widget / 对象层,关闭只需要一个 }。
更进一步,这让流式解析器在处理最常见的 UI 结构时,不需要回头查找对应的开启标签名——它只需要维护括号栈。这在 AI 逐 token 输出的场景下是重要的简化。
规则三:冒号赋值,不是等号
text: "Start"
width: Fill
draw_bg.color: #x51cf66
Splash 用 : 做属性赋值,不用 =。这和 JSON、YAML、CSS 一致,而不是 JavaScript 或 Rust 的 =。
选择 : 而不是 = 有一个实际原因:在 Splash 的脚本部分(函数和逻辑代码),= 用于变量赋值:
pomo.running = false // 脚本赋值用 =
text: "Start" // 属性声明用 :
来源:tools/canvas/examples/pomodoro.splash:21,58(分别取自函数和 UI 部分)
两种语义用两种符号,解析器可以仅凭符号类型区分"正在声明 UI 属性"还是"正在执行脚本赋值"。这种区分对流式解析也有帮助——遇到 : 就知道是在 UI 描述区域,遇到 = 就知道是在脚本区域。
规则四:点路径展平嵌套
draw_text.text_style.font_size: 14
来源:tools/canvas/examples/pomodoro.splash:54
而不是:
{
"draw_text": {
"text_style": {
"font_size": 14
}
}
}
点路径是 Splash 最节省空间的设计之一。三层嵌套被压缩成一行。这不只是语法糖——它改变了代码的认知负担。读者不需要在脑中维护缩进层级,属性的完整路径在一行内自解释。
点路径还解决了一个更微妙的问题:在嵌套对象语法中,添加一个深层属性需要正确地嵌套多层花括号,任何一层的遗漏都会导致语法错误。在点路径语法中,每个属性都是独立的一行,互不干扰:
draw_bg.color: #x1e1e2e
draw_bg.border_radius: 10.0
draw_text.color: #xffffff
draw_text.text_style.font_size: 14
即使删除其中任意一行,其余行仍然是合法的 Splash 代码。这种"每行独立"的特性让 AI 在流式生成时更加安全——即使输出被截断,已经输出的部分仍然可以被正确解析。
规则五:浮点数用尾部点号
padding: Inset{left: 40. right: 40. top: 50. bottom: 40.}
draw_bg.radius: 6.
来源:tools/canvas/examples/pomodoro.splash:53,58(简化提取)
40. 而不是 40.0。Splash 的数字字面量中,尾部的 . 表示"这是浮点数"。tokenizer 通过检查 . 的存在来区分整数和浮点数——没有 .、没有 e/E 的数字被解析为 U40(整数),有 . 的被解析为 F64(浮点数):
#![allow(unused)] fn main() { // 简化自 platform/script/src/tokenizer.rs:369-390 if !(self.temp.contains('.') || self.temp.contains('e') || self.temp.contains('E')) && v <= 0xFF_FFFF_FFFFu64 as f64 { // → 解析为整数 U40 token: ScriptToken::U40(v as u64), } // → 否则解析为浮点数 F64 token: ScriptToken::F64(number), }
来源:platform/script/src/tokenizer.rs:369-390(简化展示,中文注释为译注)
这是一个典型的"小决策大收益":少写一个字符 0,但它出现在每一个浮点数值上。在番茄钟的 UI 部分中,有 16 个浮点数值(padding、radius 等),每个节省一个字符,累计节省 16 个字符。在更大的应用中,这种节省会更加显著。
规则六:十六进制颜色的 #x 前缀
draw_bg.color: #x0a0a12
draw_text.color: #xff6b6b
来源:tools/canvas/examples/pomodoro.splash:53-54
大多数颜色值用 # 前缀就够了(如 #f00、#ff0000)。但当十六进制颜色包含字母 e 时,tokenizer 会把它当作科学计数法的指数符号。#1e1e2e 中的 1e1 被解析为 10.0,而不是颜色值的一部分。
解决方案:#x 前缀。#x1e1e2e 明确告诉 tokenizer"这是颜色"。这是一个务实的妥协——为了保持 tokenizer 的简单性(不需要回溯来判断 e 到底是颜色值还是指数),用一个额外的字符消除歧义。
在实际开发中,建议始终使用 #x 前缀——即使颜色值中没有 e。这样做的好处是统一风格,消除"这个颜色需不需要 #x"的心智负担。番茄钟的代码就采用了这个惯例:所有颜色都用 #x 前缀。
规则七:组件声明即实例化
SolidView{ ... }
Label{text: "FOCUS TIME" ...}
Button{text: "Start" ...}
在 Splash 中,写 Button{text: "Start"} 就是创建一个 Button 实例并设置其 text 属性。没有 new、没有 createElement、没有 @Component 装饰器。组件名后跟花括号就是声明加实例化。
这使得 Splash 代码的结构和最终 UI 的 Widget 树是同构的——代码的嵌套结构就是 UI 的嵌套结构。一个 SolidView 包含两个 Label 和一个 View,代码中的缩进就直接反映了这种父子关系。
对比 React 中需要 import 组件、调用 createElement(或使用 JSX 编译器将标签转换为函数调用),Splash 跳过了这些中间步骤。组件名以大写字母开头就是组件——这是 Splash 唯一的约定。这个简化对 AI 生成特别有利:AI 不需要知道某个组件是否已经导入,不需要管理 import 列表,直接写组件名和属性就行。
为什么这样设计:与四种语法的对比
现在把同一个 UI 片段——番茄钟的按钮区域——用五种不同的语法表达,来具体量化 Splash 设计的效果。
同一 UI 的五种表达
Splash:
View{width: Fit height: Fit flow: Right spacing: 16
Button{text: "Start" draw_bg.color: #x51cf66 draw_bg.radius: 6.}
Button{text: "Reset" draw_bg.color: #x444466 draw_bg.radius: 6.}
}
JSON(假设 JSON-based UI DSL):
{
"type": "View",
"width": "Fit",
"height": "Fit",
"flow": "Right",
"spacing": 16,
"children": [
{
"type": "Button",
"text": "Start",
"draw_bg": {"color": "#51cf66", "radius": 6.0}
},
{
"type": "Button",
"text": "Reset",
"draw_bg": {"color": "#444466", "radius": 6.0}
}
]
}
XML:
<View width="Fit" height="Fit" flow="Right" spacing="16">
<Button text="Start" draw_bg.color="#51cf66" draw_bg.radius="6.0"/>
<Button text="Reset" draw_bg.color="#444466" draw_bg.radius="6.0"/>
</View>
JSX(React 风格):
<View width="Fit" height="Fit" flow="Right" spacing={16}>
<Button text="Start" drawBg={{color: "#51cf66", radius: 6.0}} />
<Button text="Reset" drawBg={{color: "#444466", radius: 6.0}} />
</View>
QML(Qt Quick 风格):
Row {
spacing: 16
Button {
text: "Start"
background: Rectangle {
color: "#51cf66"
radius: 6.0
}
}
Button {
text: "Reset"
background: Rectangle {
color: "#444466"
radius: 6.0
}
}
}
语法对比矩阵
| 维度 | Splash | JSON | XML | JSX | QML |
|---|---|---|---|---|---|
| 属性分隔符 | 空格 | 逗号 , | 空格 | 空格 | 换行/分号 |
| 赋值符 | : | : | = | = / {} | : |
| 嵌套标记 | {} [] () | {} [] | <> </> | <> </> {} | {} |
| 关闭标记 | } | } ] | </Tag> | </Tag> | } |
| 嵌套属性 | 点路径 | 嵌套对象 | 属性或嵌套 | 嵌套对象 | 子组件嵌套 |
| 类型声明 | 组件名 | "type": 字段 | 标签名 | 标签名 | 组件名 |
| 流式解析友好度 | 高 | 低 | 中 | 中 | 中 |
| 闭合匹配复杂度 | 仅 } | } ] " | 标签名匹配 | 标签名匹配 | 仅 } |
关于"流式解析友好度"的评分说明:
- Splash(高):tokenizer 和 parser 都原生支持增量输入,无需分隔符确认属性边界
- JSON(低):严格依赖括号和逗号匹配,部分 JSON 无法被确定性解析
- XML/JSX(中):标签结构可以增量解析,但关闭标签需要名字匹配;JSX 中的
{expression}需要完整的 JavaScript 表达式求值 - QML(中):花括号嵌套可以增量解析,但属性值可以包含 JavaScript 表达式
字符效率对比
直接对比上述五个代码片段的字符数:
| 语法 | 字符数 | 相对 Splash | 主要开销来源 |
|---|---|---|---|
| Splash | ~155 | 1.0x | — |
| XML | ~195 | 1.26x | 关闭标签 </View> |
| JSX | ~210 | 1.35x | 关闭标签 + {{}} 嵌套 |
| QML | ~240 | 1.55x | 子组件 Rectangle{} 嵌套 |
| JSON | ~320 | 2.06x | 引号、逗号、"type": 字段、"children": 数组 |
Splash 的字符效率主要来自三个方面:
- 无冗余标点:没有逗号、引号(对非字符串值)、闭合标签
- 点路径:
draw_bg.color: #x51cf66vs JSON 的"draw_bg": {"color": "#51cf66"} - 隐式类型:组件名即类型,不需要
"type": "Button"这样的显式声明
这种紧凑性的最大受益者是 JSON 对比——Splash 大约只需要 JSON 一半的字符。对 XML 和 JSX,优势在 25-35% 左右。对 QML,优势主要来自点路径 vs 子组件嵌套。
需要注意的是,字符数和 token 数不是线性关系——不同 tokenizer 对不同语法结构的编码效率不同。但字符数是一个合理的近似:更少的字符通常意味着更少的 token,也意味着 AI 生成相同 UI 所需的时间更短。
源码佐证:为流式设计的 tokenizer 和 parser
Splash 的语法选择不是拍脑袋的结果。打开 tokenizer.rs 的第一行注释:
#![allow(unused)] fn main() { // Makepad script streaming tokenizer }
来源:platform/script/src/tokenizer.rs:1
"streaming tokenizer"——这两个词定义了整个语法设计的出发点。
tokenizer 的状态机
Splash 的 tokenizer 是一个字符级状态机,只有 14 个状态:
#![allow(unused)] fn main() { enum State { Whitespace, // 初始状态和空白 Identifier, // 标识符(组件名、属性名、关键字) Operator, // 运算符(: = + - . 等) RustValue, // Rust 互操作值 Number, // 数字 Color, // 颜色值(# 开头) String(bool), // 字符串(true=双引号, false=单引号) EscapeInString(bool), // 字符串中的转义序列 UnicodeHexInString(bool), // \xNN 转义 UnicodeCurlyInString(bool), // \u{NNNN} 转义 AsciiHexInString(bool), // \uNNNN 转义 BlockComment(usize), // 块注释 /* */ MaybeEndBlock(usize), // 块注释可能结束(看到 *) LineComment, // 行注释 // } }
来源:platform/script/src/tokenizer.rs:226-242
14 个状态中,8 个是字符串处理和注释的子状态。核心的文本处理只有 6 个主状态:Whitespace、Identifier、Operator、RustValue、Number、Color。一个能描述完整 UI 应用的语言(包含布局、样式、颜色、数值),tokenizer 只需要 6 个主状态——这是语法简洁的直接证据。
作为对比,一个标准的 JSON tokenizer 需要处理字符串转义(6种)、数字精度(整数/浮点/指数)、Unicode 多字节、true/false/null 字面量等。状态数可能不多,但每个状态的分支逻辑要复杂得多——因为 JSON 的语法规则(引号必须匹配、逗号必须精确放置)给 tokenizer 增加了额外的验证负担。
为什么没有逗号:tokenizer 的视角
看 tokenizer 如何处理分隔符:
#![allow(unused)] fn main() { fn is_separator(c: char) -> bool { c == ',' || c == ';' } }
来源:platform/script/src/tokenizer.rs:618-619
逗号和分号被识别为 Separator token,但它们不是必需的——tokenizer 将它们当作空白处理。这意味着你可以写逗号也可以不写,两种风格在语法上完全等价。实际上,从 Makepad 1.x live_design! 迁移过来的代码大量使用逗号,Splash 继续接受它们以保持向后兼容。当前的指导是"匹配周围代码风格"——在新项目中推荐省略逗号(更简洁),在已有项目中保持一致即可。
在 Whitespace 状态下,遇到新的标识符就自动开始新属性,无需先看到分隔符:
#![allow(unused)] fn main() { State::Whitespace => { if c.is_numeric() { self.state = State::Number; self.temp.push(c); } else if c == '_' || c == '$' || c.is_alphabetic() { self.state = State::Identifier; self.temp.push(c); } else if c == '#' { self.state = State::Color; } else if is_separator(c) { self.emit_separator(c); // 接受但不要求 } else if is_operator(c) { self.state = State::Operator; self.temp.push(c); } // ... } }
来源:platform/script/src/tokenizer.rs:647-668
空白状态下,遇到字母就进入 Identifier 状态,遇到数字就进入 Number 状态。不需要先看到逗号来确认"上一个值已结束"。逗号如果出现,会被接受(emit_separator)但不是必须的。这就是"空格作为分隔符"的实现基础:属性边界由 token 类型的转换来确定,而不是由显式的分隔符来标记。
流式 tokenize 的关键:增量输入
#![allow(unused)] fn main() { pub fn tokenize(&mut self, new_chars: &str, heap: &mut ScriptHeap) -> &[ScriptTokenPos] }
来源:platform/script/src/tokenizer.rs:596
tokenize 方法接收 new_chars——不是完整的源码,而是新到达的字符片段。tokenizer 将内部状态(self.state、self.unfinished、self.temp)保持在 struct 中,在每次调用之间延续上下文。ScriptToken::StreamEnd 标记"当前输入结束但后续可能还有更多内容到达"。
这就是为什么 Splash 不需要逗号和分号的深层原因:在流式场景下,你不知道输入何时结束。如果语法依赖逗号来分隔属性,那么在 AI 输出 text: "Start" 之后、逗号之前,解析器无法确定这个属性是否完整。而 Splash 的设计中,看到下一个 identifier: 或 } 就自然结束上一个属性——无需等待任何分隔符。
parser 的流式支持:ParserCheckpoint
流式设计不仅在 tokenizer 层面,parser 也原生支持增量解析。parser.rs 定义了 ParserCheckpoint 结构:
#![allow(unused)] fn main() { /// Snapshot of parser state that can be restored for incremental parsing. /// Captures the state before auto-close so we can undo those synthetic /// closings when more source arrives. pub struct ParserCheckpoint { pub opcodes_len: usize, pub source_map_len: usize, pub token_index: u32, state: Vec<State>, destruct_defaults_len: usize, nested_patterns_len: usize, /// The last opcode before the checkpoint, saved because auto-close's /// set_pop_to_me() mutates it in place. Must be restored on continuation. last_opcode: Option<ScriptValue>, } }
来源:platform/script/src/parser.rs:738-751
这个设计揭示了 Splash 流式解析的工作机制:当一批新 token 到达时,parser 先保存当前状态(checkpoint),然后尝试解析新 token。如果解析成功,状态向前推进。如果输入在中途断开(比如 AI 还没输出完),parser 会"自动关闭"未完成的结构(auto-close),让已有的 UI 可以被渲染。当更多 token 到达时,parser 恢复到 checkpoint,撤销之前的自动关闭,继续解析完整的输入。
这是一种"乐观解析"策略:即使输入不完整,也渲染已有的部分。这正是 AI 逐 token 输出时用户看到 UI 逐步成型的技术基础(详见第11章:流式求值)。
sequenceDiagram
participant AI as AI Agent
participant T as Tokenizer
participant P as Parser
participant R as Renderer
AI->>T: "View{width:"
T->>P: [Identifier(View), OpenCurly, Identifier(width), Operator(:)]
Note over P: 保存 Checkpoint
AI->>T: " Fill he"
T->>P: [Identifier(Fill)]
Note over T: "he"未完成,停留在 Identifier 状态
P->>R: auto-close → 渲染 View{width:Fill}
AI->>T: "ight: Fit}"
Note over P: 恢复 Checkpoint,撤销 auto-close
T->>P: [Identifier(height), Operator(:), Identifier(Fit), CloseCurly]
P->>R: 渲染完整 View{width:Fill, height:Fit}
模式提炼
从 Splash 的语法设计中,可以提炼出三条可复用的 UI 语言设计模式。
模式一:分隔符最小化
问题:逗号、分号等分隔符不承载语义,但消耗字符和 token,且要求精确放置。漏写一个逗号就导致语法错误。
方案:用空格作为天然分隔符,通过 key: 模式(identifier 后跟冒号)识别属性边界。
前提条件:语言的 token 类型足够清晰(标识符、数字、字符串、运算符各有明确的起始字符),使得解析器仅凭 token 类型就能判断属性边界,无需显式分隔符。
收益:
- 减少约 15% 的字符数(每个省略的逗号 = 1 个字符)
- 消除"漏写逗号"类的语法错误
- 简化流式解析——无需等待分隔符确认属性完整性
局限:如果属性值本身是标识符(如 width: Fill),那么紧跟其后的下一个标识符需要能被区分为"新属性"还是"当前值的延续"。Splash 通过约定解决这个问题:属性名后面必须紧跟 :,而值不会以 : 结尾。
模式二:点路径展平
问题:嵌套属性需要多层花括号或对象字面量,增加缩进深度、认知负担和字符数。
方案:draw_text.text_style.font_size: 14 一行表达三层嵌套。
前提条件:属性的层级结构是静态已知的(Widget 的属性路径在编译时确定)。如果属性路径是动态的(比如用变量作为属性名),点路径就不适用。
收益:
- 减少嵌套深度,每个属性独立一行
- 提高单行可读性——一行内就能看到属性的完整路径
- 截断安全——删除任意一行点路径属性,其余行仍然合法
局限:对于需要同时设置同一对象多个属性的场景,点路径会导致路径前缀重复(如 draw_text.color: / draw_text.text_style.font_size:)。Splash 用合并运算符 +: 来处理这种情况,但那属于高级用法。
模式三:流式优先设计
问题:传统语法需要完整输入才能解析。JSON 需要匹配所有括号,XML 需要匹配所有闭合标签。在输入不完整时,解析器只能等待或报错。
方案:从 tokenizer 到 parser 的全链路支持增量输入:
- tokenizer 的
tokenize(new_chars)接受字符片段 - parser 的
ParserCheckpoint支持状态快照和回滚 - auto-close 机制让不完整的输入也能产生可渲染的 UI
前提条件:语法中不存在需要"向前看多步"才能确定含义的结构。每个 token 的含义在它被读取时就能确定。
收益:AI 逐 token 输出时,UI 可以逐步渲染。用户看到的不是"等待……等待……完整 UI 出现",而是 UI 逐渐成型的过程。这种体验从根本上改变了 AI 生成 UI 的交互感受——从"等待结果"变为"观看构建"(详见第11章:流式求值,第27章:Canvas 架构剖析)。
本章小结
Splash 的语法不是"简化版 JSON"或"无逗号 QML"。它是一种为三个约束条件同时优化的语言:
| 设计约束 | 关键设计决策 | 实现效果 |
|---|---|---|
| 人类可读性 | 去掉逗号/分号,点路径展平嵌套 | 更少的视觉噪音,每行自解释 |
| 流式解析 | 增量 tokenizer + parser checkpoint + auto-close | 部分输入即可解析和渲染 |
| AI 友好性 | 最小化标点,紧凑表达,组件名即类型 | 比 JSON 少约一半字符,更高的生成成功率 |
这三个约束的交集定义了 Splash 的设计空间。下一章将深入 Splash 的属性系统——draw_bg、draw_text、布局属性的完整用法,以及那些让人踩坑的常见陷阱(详见第7章:属性与容器)。
第7章:属性与容器
为什么这很重要
上一章讲了 Splash 的语法哲学——空格分隔、点路径、花括号嵌套。但"知道语法规则"和"能写出好看的 UI"之间还有一段距离。这段距离就是属性系统和容器组件。
属性系统决定了你如何控制 UI 的外观:颜色、字体、间距、圆角、阴影。容器组件决定了你如何组织 UI 的结构:水平排列、垂直堆叠、滚动区域。两者结合,就是 Splash 中"从空白到完整界面"的全部工具集。
本章以 Canvas 的 token-dashboard 应用为贯穿示例,系统讲解三类属性(视觉、布局、行为)和容器组件家族。读完本章,你应该能够仅凭 Splash 代码构建出一个有层次、有样式的完整界面。
flowchart TD
A[Splash 属性] --> B[视觉属性]
A --> C[布局属性]
A --> D[行为属性]
B --> B1["draw_bg (背景)"]
B --> B2["draw_text (文字)"]
C --> C1["width/height (尺寸)"]
C --> C2["flow/spacing (排列)"]
C --> C3["padding/margin (间距)"]
C --> C4["align (对齐)"]
D --> D1["visible, cursor"]
D --> D2["new_batch"]
属性系统总览
三类属性
Splash 的属性可以分为三类,每类的命名模式和用途不同:
视觉属性——以 draw_bg 和 draw_text 开头,控制组件的渲染外观:
draw_bg.color: #x161628 // 背景颜色
draw_bg.radius: 8. // 圆角半径
draw_text.color: #xeeeeff // 文字颜色
draw_text.text_style.font_size: 20 // 字号
来源:tools/canvas/examples/token-dashboard.splash:5,12
视觉属性的命名规律:draw_bg 前缀控制背景渲染(颜色、圆角、边框、阴影),draw_text 前缀控制文字渲染(颜色、字体、字号)。所有视觉属性都通过点路径设置,不需要嵌套。
布局属性——控制组件的尺寸和子组件的排列方式:
width: Fill height: Fit // 尺寸
flow: Down spacing: 20 // 垂直排列,间距 20px
padding: Inset{left: 32. right: 32. top: 24. bottom: 24.} // 内边距
align: Align{y: 0.5} // 垂直居中
来源:tools/canvas/examples/token-dashboard.splash:1,4
行为属性——控制组件的交互和渲染行为:
visible: true // 是否可见
new_batch: true // 开启新渲染批次
cursor: MouseCursor.Hand // 鼠标指针样式
clip_x: true // 水平裁剪
来源:splash.md:452-460
dot-path 的工作机制
在第6章我们已经见过点路径语法(详见第6章:Splash 语法设计哲学,规则四)。现在深入理解它的工作方式。
当你写 draw_bg.color: #x161628 时,Splash 做了什么?这等价于:
draw_bg +: { color: #x161628 }
+: 是合并运算符——它不是替换整个 draw_bg,而是在现有的 draw_bg 对象上修改 color 字段。这意味着你可以分多行设置同一个子对象的不同属性,而不会互相覆盖:
draw_bg.color: #x161628 // 设置颜色
draw_bg.radius: 8. // 设置圆角(不影响颜色)
draw_bg.border_color: #x333355 // 设置边框色(不影响上面两个)
每一行都只修改点路径指向的那个字段,其他字段保持不变。这就是为什么点路径在 Splash 中如此安全——你可以放心地逐行添加或删除属性,不用担心副作用。
警告:draw_bg: vs draw_bg +:
// ✅ 合并——只修改 color,保留 border_radius、shader 等
draw_bg +: { color: #xf00 }
// ❌ 替换——丢失 ALL 其他 draw_bg 属性(shader、radius 等全部消失)
draw_bg: { color: #xf00 }
+: 是合并,: 是替换。在修改 draw_bg 或 draw_text 的子属性时,始终使用 +:。使用 : 会把整个绘制对象替换为你提供的值,丢失 Widget 默认的 shader、圆角、边框等属性——通常导致组件外观完全错乱。点路径语法(draw_bg.color:)内部等价于 +:,所以点路径是安全的。
容器组件:View 家族
为什么有这么多 View?
Splash 有超过 15 种容器组件,全部继承自 ViewBase。初看让人困惑:为什么不能只用一个 View 加不同的属性?
原因是性能。每种容器使用不同的 GPU shader。View 不画任何背景(零 GPU 开销);SolidView 用最简单的纯色 shader;RoundedView 需要计算圆角的 SDF(Signed Distance Field)。把这些能力分开,可以让每个组件只承担它需要的 GPU 计算量。
常用容器速查
| 容器 | 背景 | 形状 | 使用场景 |
|---|---|---|---|
View | 无 | — | 纯布局容器,不需要背景 |
SolidView | 纯色 | 矩形 | 页面背景、分隔区域 |
RoundedView | 纯色 | 圆角矩形 | 卡片、按钮容器、标签 |
RoundedShadowView | 纯色+阴影 | 圆角矩形 | 悬浮卡片、弹窗 |
CircleView | 纯色 | 圆形 | 头像、状态指示器 |
GradientXView | 水平渐变 | 矩形 | 装饰背景 |
GradientYView | 垂直渐变 | 矩形 | 装饰背景 |
来源:splash.md:414-430
三种最常用的容器
View——不可见的布局容器
View{width: Fill height: Fit flow: Right spacing: 16
Label{text: "Left"}
Filler{}
Label{text: "Right"}
}
View 不绘制任何背景,GPU 开销为零。它是纯粹的布局工具——用来排列子组件。当你只需要"把几个东西横着排"或"把几个东西竖着排"时,用 View。
上面的例子展示了一个经典的 Splash 布局技巧:用 Filler{} 将左右两个元素推到两端。Filler 是一个宽度为 Fill 的空组件,它占满中间的所有可用空间,把后面的元素推到最右边。这比 CSS 的 justify-content: space-between 更直观——你能在代码中"看到"那个占位的空间。
View 的另一个常见用途是作为"间距块"——不需要任何属性,只用固定高度制造垂直间距:
View{height: 20} // 20px 的空白间距
token-dashboard 中多次使用这个技巧来分隔不同区域。
SolidView——纯色背景容器
SolidView{width: Fill height: Fit draw_bg.color: #x0c0c18
flow: Down padding: 20
Label{text: "Content on dark background"
draw_text.color: #xeeeeff}
}
来源:tools/canvas/examples/token-dashboard.splash:1(简化)
SolidView 已经默认设置了 show_bg: true,你只需要通过 draw_bg.color 设置颜色。它使用最简单的矩形 shader,没有圆角,没有阴影。
RoundedView——圆角卡片容器
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: "Input Tokens"
draw_text.color: #x888899
draw_text.text_style.font_size: 10}
Label{text: "48.5M"
draw_text.color: #xcc66ff
draw_text.text_style.font_size: 28}
}
来源:tools/canvas/examples/token-dashboard.splash:12-15(格式化)
RoundedView 是最常用的"有外观"容器。通过 draw_bg.radius 设置圆角半径,通过 draw_bg.color 设置背景色。token-dashboard 中的每个卡片都是一个 RoundedView。
滚动容器
当内容超出视区时,使用滚动容器:
ScrollYView{width: Fill height: Fill
flow: Down spacing: 8
// 很多子组件...
}
| 容器 | 滚动方向 |
|---|---|
ScrollXView | 水平 |
ScrollYView | 垂直 |
ScrollXYView | 双向 |
来源:splash.md:432-437
视觉属性详解
draw_bg:背景属性
draw_bg 是所有带背景容器的视觉属性根。以下是常用属性:
// 基础
draw_bg.color: #x334455 // 背景色(所有带背景容器)
// 圆角(RoundedView 系列)
draw_bg.radius: 8. // 圆角半径
// 边框(RectView、RoundedView 等)
draw_bg.border_size: 1. // 边框宽度
draw_bg.border_color: #x888888 // 边框颜色
// 阴影(RoundedShadowView、RectShadowView)
draw_bg.shadow_radius: 10. // 阴影模糊半径
draw_bg.shadow_color: #x0007 // 阴影颜色
draw_bg.shadow_offset: vec2(0 3) // 阴影偏移方向
// 渐变(GradientXView、GradientYView,或任何带背景的容器)
draw_bg.color: #x334455 // 渐变起始色
draw_bg.color_2: vec4(0.2 0.1 0.3 1.0) // 渐变结束色
来源:splash.md:499-514
属性与容器的匹配关系是一个重要的概念:不是所有属性在所有容器上都有效。draw_bg.radius 在 SolidView 上无效(它没有圆角 shader),shadow_radius 在非 Shadow 容器上无效。如果你在错误的容器上设置了这些属性,Splash 不会报错——属性会被静默忽略,这是初学者困惑的常见来源。
下表总结了哪些属性在哪些容器上有效:
| 属性 | View | SolidView | RoundedView | ShadowView | CircleView |
|---|---|---|---|---|---|
draw_bg.color | — | 有效 | 有效 | 有效 | 有效 |
draw_bg.radius | — | — | 有效 | 有效 | — |
draw_bg.border_size | — | — | 有效 | 有效 | — |
draw_bg.shadow_radius | — | — | — | 有效 | — |
draw_bg.color_2 | — | — | — | — | — |
(GradientXView/GradientYView 支持 color 和 color_2 渐变)
颜色格式
#f00 // RGB 短格式
#ff0000 // RGB 完整格式
#ff0000ff // RGBA
#x1e1e2e // 含 e 的十六进制用 #x 前缀
#0000 // 完全透明
vec4(1.0 0.0 0.0 1.0) // RGBA 向量
来源:splash.md:352-360
draw_text:文字属性
draw_text 控制文本的外观:
draw_text.color: #xffffff // 文字颜色
draw_text.text_style.font_size: 14 // 字号
draw_text.text_style: theme.font_bold{} // 使用粗体
draw_text.text_style: theme.font_code{} // 使用等宽字体
来源:splash.md:560-571
可用字体:
| 值 | 说明 |
|---|---|
theme.font_regular | 常规体(默认) |
theme.font_bold | 粗体 |
theme.font_italic | 斜体 |
theme.font_bold_italic | 粗斜体 |
theme.font_code | 等宽代码体 |
theme.font_icons | 图标字体 |
重要:默认文字颜色是白色(#fff)。 在浅色背景上,你必须显式设置 draw_text.color 为深色,否则文字不可见(白字白底)。
布局属性详解
尺寸:width 和 height
每个组件都有 width 和 height,值是 Size 枚举:
width: Fill // 填满可用空间(默认值)
width: Fit // 收缩到内容大小
width: 200 // 固定 200px
width: Fill{min: 100 max: 500} // 填满,但限制在 100-500px
height: Fit{max: Abs(300)} // 收缩,但最大 300px
来源:splash.md:362-371
Fill:组件尽可能大,占满父容器给它的空间。width 和 height 的默认值都是 Fill。当父容器是 flow: Right 时,多个 width: Fill 的子组件会平分可用宽度。
Fit:组件尽可能小,刚好容纳子组件。当你写 width: Fit 时,组件的宽度由它最宽的子组件决定。大多数容器需要显式设置 height: Fit——这是本章最重要的陷阱之一(后面会详细讲)。
Fixed(数字):固定像素大小。width: 200 表示固定 200px,不随父容器缩放。
带约束的 Fill/Fit:可以给 Fill 和 Fit 加上最小/最大限制:
width: Fill{min: 100 max: 500} // 填满,但宽度限制在 100-500px
height: Fit{max: Abs(300)} // 收缩到内容,但最多 300px
这在响应式布局中很有用——比如一个卡片的宽度填满父容器,但不超过 500px。
理解 Fill vs Fit 是 Splash 布局的核心。一个简单的心智模型:
Fill= "给我多少空间我占多少"(向外扩张)Fit= "我的内容有多大我就多大"(向内收缩)
token-dashboard 的卡片区域完美展示了这两者的配合:外层 View{flow: Right} 包含四个 RoundedView{width: Fill}——四个 Fill 平分水平空间,每个卡片占 25% 宽度。每个卡片内部 height: Fit——高度由内容(标题+数值两行文字)决定。
排列方向:flow
flow 决定子组件的排列方向:
flow: Right // 默认,从左到右
flow: Down // 从上到下
flow: Overlay // 堆叠(后面的覆盖前面的)
flow: Flow.Right{wrap: true} // 从左到右,自动换行
flow: Flow.Down{wrap: true} // 从上到下,自动换列
来源:splash.md:375-382
token-dashboard 用 flow: Down 做页面的垂直布局,用 flow: Right 做卡片的水平排列:
// 页面级:垂直排列各区块
SolidView{... flow: Down spacing: 20
// 标题栏:水平排列标题和副标题
View{... flow: Right align: Align{y: 0.5}
Label{text: "March 2025 Token Usage" ...}
Filler{}
Label{text: "Claude Code" ...}
}
// 卡片区:水平排列四张卡片
View{... flow: Right spacing: 16
RoundedView{...}
RoundedView{...}
RoundedView{...}
RoundedView{...}
}
}
来源:tools/canvas/examples/token-dashboard.splash:1-28(结构简化)
间距:spacing, padding, margin
spacing: 16 // 子组件之间的间距(16px)
padding: 20 // 组件内部的统一内边距
padding: Inset{top: 24. bottom: 24. left: 32. right: 32.} // 四边独立内边距
margin: Inset{top: 8. bottom: 8.} // 外边距
来源:splash.md:384-391
- spacing:子组件之间的间距,类似 CSS 的
gap。只作用于相邻子组件之间,第一个子组件前面和最后一个子组件后面没有间距 - padding:组件边界到内容区域的距离。裸数字表示四边统一,
Inset{}表示四边独立 - margin:组件自身到外部的距离。用法和 padding 相同
padding 的两种写法在实践中的选择:
// 四边统一:简洁
padding: 20
// 上下和左右不同:用 Inset
padding: Inset{top: 24. bottom: 24. left: 32. right: 32.}
// 只设置某些方向(其他方向默认 0)
padding: Inset{left: 16. right: 16.}
token-dashboard 的设计展示了一个常见的间距策略:页面级使用较大的 padding(32px 左右)给整体留白,卡片级使用较小的 padding(16-20px)给内容留白,spacing 控制同层级元素之间的间距。这种"层级递减"的间距策略让界面有清晰的视觉层次。
对齐:align
align: Center // 居中(水平+垂直)
align: HCenter // 仅水平居中
align: VCenter // 仅垂直居中
align: Align{x: 1.0 y: 0.0} // 右上角
align: Align{x: 0.0 y: 1.0} // 左下角
来源:splash.md:394-401
Align{x: 0.5 y: 0.5} 等价于 Center。x 值 0.0 是左,1.0 是右;y 值 0.0 是上,1.0 是下。
token-dashboard 中柱状图底部对齐的技巧:
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: 38 draw_bg.color: #x7733cc draw_bg.radius: 2.}
Label{text: "01" draw_text.color: #x444455 draw_text.text_style.font_size: 7}
}
// ...更多柱子
}
来源:tools/canvas/examples/token-dashboard.splash:33-37
align: Align{y: 1.0} 让子组件贴到底部。结合 flow: Right,实现了"从底部向上生长的柱状图"效果。
陷阱地图:六个必须知道的坑
陷阱一:忘记 height: Fit
这是 Splash 开发中最常见的问题——没有之一。
// ❌ 错误:容器不可见(0px 高度)
RoundedView{width: Fill draw_bg.color: #x334
Label{text: "Where am I?"}
}
// ✅ 正确:显式设置 height: Fit
RoundedView{width: Fill height: Fit draw_bg.color: #x334
Label{text: "Here I am!"}
}
为什么? height 的默认值是 Fill(填满父容器)。但如果父容器的高度是 Fit(收缩到内容),子容器说"我要填满你",而父容器说"你有多大我就多大"——循环依赖,结果是 0px。
什么情况下可以不写 height: Fit?
- 父容器有固定高度:
View{height: 300 View{height: Fill ...}}— 子容器填满 300px,合法 - 根窗口:Window 本身有屏幕高度,直接子组件用
height: Fill是安全的 - 滚动容器的直接子组件:
ScrollYView{height: Fill ...}— 滚动容器自身应该 Fill
除了这三种情况,其他所有容器都应该写 height: Fit。当你看到一个 UI 组件"消失"了(存在于代码中但不可见),第一个检查的就是 height: Fit 是否遗漏。
规则:在 Splash 中,每个 View/SolidView/RoundedView 都显式写 height: Fit,除非你确定父容器有固定高度或 height: Fill。
陷阱二:根容器使用固定宽度
// ❌ 错误:根容器固定宽度,无法适配不同屏幕
RoundedView{width: 400 height: Fit draw_bg.color: #x334
Label{text: "Narrow sliver or clipped"}
}
// ✅ 正确:根容器用 width: Fill
RoundedView{width: Fill height: Fit draw_bg.color: #x334
Label{text: "Adapts to available width"}
}
根容器(最外层组件)必须使用 width: Fill。固定宽度(如 width: 400)不会适应可用空间——如果父容器窄于 400px,内容被裁剪;如果代码有解析错误,整个布局可能坍缩到近零宽度。固定宽度只用于内部元素(图标、头像等)。
陷阱三:颜色中的 e 导致解析错误
// ❌ 错误:1e1 被解析为科学计数法
draw_bg.color: #1e1e2e
// ✅ 正确:使用 #x 前缀
draw_bg.color: #x1e1e2e
当十六进制颜色值包含字母 e 时,tokenizer 会将 1e1 解析为浮点数 10.0。这不是 bug——这是 tokenizer 保持简单性的设计选择(详见第6章:Splash 语法设计哲学,规则六)。
规则:所有颜色值统一使用 #x 前缀。
陷阱三:浮点数漏写尾部点号
// ❌ 错误:8 被解析为整数,某些属性期望浮点数
draw_bg.radius: 8
// ✅ 正确:尾部点号表示浮点数
draw_bg.radius: 8.
有些属性(如 draw_bg.radius、shadow_radius)期望浮点数类型。传入整数可能导致类型不匹配而被忽略。养成给所有"物理量"属性(半径、偏移等)加尾部点号的习惯。
陷阱四:draw_bg.radius 传入 Inset
// ❌ 错误:border_radius 不接受 Inset
draw_bg.radius: Inset{top: 0 bottom: 16 left: 0 right: 0}
// ✅ 正确:border_radius 是单一 f32 值
draw_bg.radius: 16.
来源:splash.md:178-185
draw_bg.radius 是一个统一的圆角值,四个角相同。Splash 不支持单独设置每个角的半径(如果需要,使用 RoundedAllView 配合 vec4)。传入 Inset 会导致静默的解析错误,可能破坏整个布局。
陷阱五:在 View 上设置 draw_bg.color
// ❌ 错误:View 没有背景 shader,颜色不生效
View{width: Fill height: Fit draw_bg.color: #x334
Label{text: "No background visible"}
}
// ✅ 正确:使用 SolidView 或 RoundedView
SolidView{width: Fill height: Fit draw_bg.color: #x334
Label{text: "Background visible!"}
}
View 默认没有背景——它是一个不可见的布局容器。如果你尝试 View{show_bg: true ...},会看到一个丑陋的绿色测试背景——这是 View 的默认 debug 颜色,不适合生产使用。需要背景色时,使用 SolidView(矩形)或 RoundedView(圆角),它们已经正确配置了背景 shader。
陷阱六:文字被背景遮盖(new_batch 问题)
// ❌ 错误:文字可能被同一批次的其他背景遮盖
RoundedView{draw_bg.color: #x334 height: Fit
Label{text: "Might be invisible!"}
}
// ✅ 正确:new_batch: true 确保背景先绘制
RoundedView{draw_bg.color: #x334 height: Fit new_batch: true
Label{text: "Always visible!"}
}
来源:splash.md:466-478
为什么会发生? Makepad 的渲染器为了性能,会将使用相同 shader 的组件合并到同一个 GPU 绘制调用(draw batch)中。比如页面上所有的 Label 文字会被合并到同一个文字绘制批次。如果你有 Label{} RoundedView{ Label{} },两个 Label 的文字会被合并到同一个 draw call 中,这个 call 可能在 RoundedView 的背景之前执行——结果是第二个 Label 的文字被 RoundedView 的背景遮住。
new_batch: true 强制当前容器开启新的渲染批次,打断这种跨容器的 shader 合并。容器内部的绘制从新批次开始,确保背景先画、文字后画。
这个问题在 hover 动画中尤为致命。 如果一个列表项有 hover 效果(鼠标悬停时背景从透明变为半透明),没有 new_batch: true 时:
// ❌ hover 时文字消失!
View{show_bg: true height: Fit
draw_bg.color: #0000 // 透明→hover 时变为不透明
Label{text: "I vanish on hover!" draw_text.color: #xfff}
}
// ✅ 加上 new_batch 保证文字始终在背景之上
View{show_bg: true height: Fit new_batch: true
draw_bg.color: #0000
Label{text: "I stay visible!" draw_text.color: #xfff}
}
来源:splash.md:472-476
hover 前背景透明所以看不出问题;hover 时背景变为不透明,突然盖住了文字——这被 splash.md 列为"排名第一的错误"。
经验法则:任何带背景(show_bg: true 或使用 SolidView/RoundedView)且包含文字的容器,都加 new_batch: true。
实战解读:token-dashboard 的属性结构
让我们把本章学到的知识应用到 token-dashboard 的一个完整片段上。下面是仪表板中"模型分布"区域的代码:
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: 12
Label{text: "Model Distribution"
draw_text.color: #xaaaacc draw_text.text_style.font_size: 12}
View{width: Fill height: 24 flow: Right
RoundedView{width: 496 height: Fill draw_bg.color: #x7733cc draw_bg.radius: 4.}
RoundedView{width: 224 height: Fill draw_bg.color: #x3366aa draw_bg.radius: 4.}
RoundedView{width: 80 height: Fill draw_bg.color: #x33aa66 draw_bg.radius: 4.}
}
View{width: Fill height: Fit flow: Right spacing: 24
View{width: Fit height: Fit flow: Right spacing: 6 align: Align{y: 0.5}
RoundedView{width: 10 height: 10 draw_bg.color: #xcc66ff draw_bg.radius: 2.}
Label{text: "Opus 4 — 62%" draw_text.color: #xcc66ff draw_text.text_style.font_size: 11}
}
// ... Sonnet 4 和 Haiku 4 类似
}
}
来源:tools/canvas/examples/token-dashboard.splash:118-139(简化)
逐层分析这段代码中的属性:
外层 RoundedView(卡片容器):
width: Fill height: Fit— 宽度填满父容器,高度由内容决定draw_bg.color: #x161628 draw_bg.radius: 8.— 深色背景 + 8px 圆角padding: Inset{...}— 内边距,上下 16px,左右 20pxflow: Down spacing: 12— 子组件垂直排列,间距 12px
水平条形图(View{height: 24 flow: Right}):
height: 24— 固定高度 24px(不是 Fit,因为条形图需要固定高度)flow: Right— 三个条形从左到右排列- 三个
RoundedView{width: 496/224/80}— 固定宽度表示比例(62%/28%/10%) height: Fill— 填满父容器的 24px 高度(这里 Fill 是安全的,因为父容器有固定高度)
图例行(View{flow: Right spacing: 24}):
- 每个图例项是
View{width: Fit flow: Right spacing: 6 align: Align{y: 0.5}} width: Fit— 每个图例项收缩到内容宽度align: Align{y: 0.5}— 色块和文字垂直居中对齐- 色块用
RoundedView{width: 10 height: 10 draw_bg.radius: 2.}— 小圆角方块
这个片段综合运用了本章的几乎所有概念:Fill/Fit/Fixed 尺寸混用、flow: Down 嵌套 flow: Right、RoundedView 做卡片和色块、padding/spacing 控制间距、align 做垂直居中。
模式提炼
模式一:卡片模式
token-dashboard 中反复出现的模式——带圆角背景的内容卡片:
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 new_batch: true
// 标题(小字灰色)
Label{text: "Title" draw_text.color: #x888899 draw_text.text_style.font_size: 10}
// 数值(大字彩色)
Label{text: "48.5M" draw_text.color: #xcc66ff draw_text.text_style.font_size: 28}
}
要素:RoundedView + draw_bg.radius + padding + new_batch: true + flow: Down。
模式二:标题栏模式
水平排列标题和右侧信息,使用 Filler 将两者推到两端:
View{width: Fill height: Fit flow: Right align: Align{y: 0.5}
Label{text: "Main Title" draw_text.color: #xeeeeff draw_text.text_style.font_size: 20}
Filler{}
Label{text: "Subtitle" draw_text.color: #x666688 draw_text.text_style.font_size: 12}
}
来源:tools/canvas/examples/token-dashboard.splash:4-8(简化)
要素:flow: Right + Filler{} + align: Align{y: 0.5}(垂直居中)。
模式三:属性检查清单
每次写一个新的容器组件时,按这个顺序检查:
height: Fit— 是否设置了?(最常见的遗漏)width: Fill— 根容器是否填满宽度?flow: Down或Right— 子组件排列方向正确吗?draw_bg.color— 是否用了正确的容器类型(不是View)?new_batch: true— 有背景+文字时是否设置了?#x前缀 — 颜色值中是否有e?
本章小结
| 属性类别 | 关键属性 | 典型用法 |
|---|---|---|
| 视觉:背景 | draw_bg.color, .border_radius, .shadow_radius | 设置容器外观 |
| 视觉:文字 | draw_text.color, .text_style.font_size | 设置文字样式 |
| 布局:尺寸 | width, height (Fill/Fit/数字) | 控制组件大小 |
| 布局:排列 | flow, spacing, align | 控制子组件位置 |
| 布局:间距 | padding, margin (数字或 Inset) | 控制内外边距 |
| 行为 | new_batch, visible, cursor | 控制渲染和交互 |
容器选择指南:
| 需要 | 选择 |
|---|---|
| 纯布局,无背景 | View |
| 纯色矩形背景 | SolidView |
| 圆角卡片 | RoundedView |
| 带阴影的卡片 | RoundedShadowView |
| 可滚动的内容区 | ScrollYView |
下一章将讲解 Splash 的模板系统——如何用 let 定义可复用组件,用 := 命名子组件实现实例级覆写(详见第8章:模板与组合)。
第8章:模板与组合
为什么这很重要
回顾上一章的 token-dashboard:四张统计卡片的代码几乎一模一样——每张都是 RoundedView + padding + 两个 Label。如果要修改卡片的圆角半径,你需要改四个地方。如果要加第五张卡片,你需要复制粘贴整块代码。
这就是模板要解决的问题。
Splash 用 let 定义模板,用 := 命名子组件,用实例化语法覆写特定属性。这套机制让你可以"定义一次,使用多次"。但 Splash 的模板不只是代码复用工具——它是 AI 与人类协作的接口:AI 负责生成模板结构和默认值,人类负责覆写每个实例的具体内容。
本章从最简单的模板开始,逐步递进到嵌套模板和多层覆写,最后展示这套机制如何服务于 AI 生成 UI 的场景。
flowchart LR
A["let 定义模板"] --> B["WidgetName{...}"]
B --> C["实例 1"]
B --> D["实例 2"]
B --> E["实例 3"]
C --> F["覆写 title.text"]
D --> G["覆写 title.text + body.text"]
E --> H["覆写 title.text + 样式"]
style A fill:#51cf66,color:#111
第一层:let 定义模板
最简单的模板
let 用于定义一个可复用的 widget 模板。语法很直接:
let MyHeader = Label{
draw_text.color: #xffffff
draw_text.text_style.font_size: 16
}
来源:splash.md:254-257
这定义了一个叫 MyHeader 的模板——本质上是一个"预配置的 Label",文字颜色白色、字号 16。使用时直接写模板名加花括号:
MyHeader{text: "Dashboard Title"}
MyHeader{text: "Settings"}
MyHeader{text: "About"}
每次使用 MyHeader{} 都会创建一个新的 Label 实例,继承模板中定义的所有属性(白色、16号字),同时可以覆写 text 等其他属性。
理解 let 的本质:它不是运行时变量——它是一个编译期的名称绑定。let MyHeader = Label{...} 告诉 Splash "以后凡是遇到 MyHeader,就用这个预配置的 Label 来实例化"。这和 CSS 的 class 有相似之处(定义一组样式,多次应用),但 Splash 的模板更强大——它不仅能定义样式,还能定义子组件的完整结构。
模板实例化时,你可以覆写模板中定义的属性,也可以添加模板中没有的属性。覆写的优先级高于模板默认值——就像 CSS 中内联样式覆盖 class 样式一样:
// 模板定义了 draw_text.color: #xffffff(白色)
// 实例覆写为绿色
MyHeader{text: "Success" draw_text.color: #x66ffaa}
关键规则:let 必须在使用之前定义。 Splash 是从上到下解析的,如果你先使用 MyHeader{} 再定义 let MyHeader = ...,会得到"未知组件"错误。这和大多数编程语言中的变量声明类似。
带子组件的模板
更常见的场景是模板内部包含多个子组件。这里以 splash.md 中的 InfoCard 为例:
let InfoCard = RoundedView{
width: Fill height: Fit
padding: 15 flow: Down spacing: 8
draw_bg.color: #x334
draw_bg.radius: 8.
title := Label{text: "default"
draw_text.color: #xfff
draw_text.text_style.font_size: 16}
body := Label{text: ""
draw_text.color: #xaaa}
}
改编自:splash.md:260-267(原名 MyCard,改为 InfoCard;统一使用 #x 颜色前缀)
注意两个 Label 前面的 title := 和 body :=。这就是下一节要讲的命名机制。现在先看使用方式:
View{flow: Down height: Fit spacing: 12 padding: 20
InfoCard{title.text: "First Card" body.text: "Content here"}
InfoCard{title.text: "Second Card" body.text: "More content"}
}
改编自:splash.md:273-275
两行代码创建了两张卡片,每张有不同的标题和正文。模板中定义的样式(背景色、圆角、字体)被所有实例共享,只有 title.text 和 body.text 是每个实例独有的。
第二层::= 命名机制
为什么需要 :=?
在上面的 InfoCard 模板中,我们写了 title := Label{...} 而不是 title: Label{...}。这一个符号的差异决定了子组件是否可以被外部覆写。
:= 声明一个命名的、可寻址的子组件。 只有用 := 声明的子组件才能在模板实例化时被覆写:
// 模板定义
let Card = RoundedView{
title := Label{text: "default"} // ✅ 命名子组件,可被覆写
Label{text: "footer"} // 匿名子组件,无法从外部修改
}
// 使用
Card{title.text: "New Title"} // ✅ title 可以覆写
// Card 的 footer Label 永远显示 "footer",无法从实例修改
这是 Splash 模板系统最核心的概念::= 是模板的"接口声明"——它定义了模板的哪些部分是可定制的,哪些是固定的。
覆写语法:dot-path 到命名子组件
覆写的语法和属性的点路径一致——用 . 连接子组件名和属性名:
InfoCard{
title.text: "Revenue Report" // 覆写 title 的文字
title.draw_text.color: #x66ffaa // 覆写 title 的颜色
body.text: "Q1 2026 financial summary" // 覆写 body 的文字
body.draw_text.text_style.font_size: 12 // 覆写 body 的字号
}
你可以覆写命名子组件的任何属性——不仅是 text,还包括颜色、字号、间距等所有属性。覆写只影响当前实例,不影响模板定义和其他实例。
:= vs : 的区别
这是初学者最容易犯的错误之一。对比以下两种写法:
// ❌ 用冒号:label 是静态属性,不可寻址
let BadCard = RoundedView{
label: Label{text: "default"}
}
BadCard{label.text: "new text"} // 静默失败!文字仍是 "default"
// ✅ 用冒号等号:label 是命名子组件,可寻址
let GoodCard = RoundedView{
label := Label{text: "default"}
}
GoodCard{label.text: "new text"} // 成功!文字变为 "new text"
基于 splash.md:8-17 的规则构造
用 : 声明的 label 被解析为一个静态属性赋值(把一个 Label 赋给名为 label 的属性),而不是一个可寻址的命名子组件。覆写 label.text 时,Splash 找不到这个命名子组件,覆写静默失败——文字保持默认值,但不会报任何错误。
这是 Splash 中最隐蔽的 bug 来源之一。当你发现模板实例化后文字"不见了"或"不变"时,第一个检查的就是:模板中的子组件是否用了 := 而不是 :。
第三层:嵌套模板与路径解析
嵌套命名容器
当模板的结构变复杂——命名子组件被放在另一个容器内部时,覆写路径需要包含完整的命名链:
let TodoItem = View{
width: Fill height: Fit
padding: Inset{top: 8 bottom: 8 left: 12 right: 12}
flow: Right spacing: 8
align: Align{y: 0.5}
check := CheckBox{text: ""}
label := Label{text: "task"
draw_text.color: #xddd
draw_text.text_style.font_size: 11}
Filler{}
tag := Label{text: ""
draw_text.color: #x888
draw_text.text_style.font_size: 9}
}
View{flow: Down height: Fit spacing: 4
TodoItem{label.text: "Walk the dog" tag.text: "personal"}
TodoItem{label.text: "Fix login bug" tag.text: "urgent"}
TodoItem{label.text: "Buy groceries" tag.text: "errands"}
}
来源:splash.md:285-301
TodoItem 有三个命名子组件:check、label、tag。每个实例可以独立覆写任意一个。注意 Filler{} 是匿名的——它不需要被覆写,所以不用 :=。
匿名容器中的命名子组件:不可达陷阱
这是模板系统中最容易踩的坑: 如果一个命名子组件被嵌套在一个匿名 View{} 中,覆写路径无法到达它:
// ❌ label 被嵌套在匿名 View 中,从外部不可达
let BadItem = View{
View{ // 匿名容器——没有 :=
flow: Down
label := Label{text: "default"}
}
}
BadItem{label.text: "new text"} // 静默失败!
// ✅ 给中间容器也加上 :=
let GoodItem = View{
texts := View{ // 命名容器
flow: Down
label := Label{text: "default"}
}
}
GoodItem{texts.label.text: "new text"} // 成功!
来源:splash.md:306-317
规则:从模板根到目标子组件的路径上,每一层容器都必须有 := 名称。 覆写路径就是这些名称用 . 连接起来的完整链。
这在设计模板时需要预先规划——哪些中间容器将来可能需要被"穿透"来覆写内部子组件?这些容器都要用 := 命名。
陷阱速查
前面各节已经详细讲解了模板的常见问题。这里汇总为快速诊断表:
| 症状 | 可能原因 | 诊断方法 |
|---|---|---|
| 覆写后文字"不变"或"消失" | 子组件用了 : 而非 := | 检查模板中对应子组件的声明符号 |
| 深层子组件覆写不生效 | 路径上有匿名容器 | 检查路径上每层容器是否都有 := 名称 |
| "未知组件"错误 | let 在使用位置之后 | 把 let 移到使用位置的上方 |
| 所有实例显示相同内容 | 覆写语法错误(可能缺少 .) | 检查 Name{id.prop: value} 格式 |
陷阱详解:在 let 定义之前使用模板
// ❌ 先使用后定义——MyCard 未知
View{
MyCard{title.text: "Oops"}
}
let MyCard = RoundedView{
title := Label{text: "default"}
}
// ✅ 先定义后使用
let MyCard = RoundedView{
title := Label{text: "default"}
}
View{
MyCard{title.text: "Works!"}
}
来源:splash.md:246
症状:组件名显示为未知。
诊断:确保 let 定义在使用位置的上方。
实战重构:用模板改造 token-dashboard
token-dashboard 的四张统计卡片是完美的模板化候选——结构相同,只有标题、数值、颜色不同。
重构前:四段重复代码
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: "Input Tokens" draw_text.color: #x888899
draw_text.text_style.font_size: 10}
Label{text: "48.5M" draw_text.color: #xcc66ff
draw_text.text_style.font_size: 28}
}
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: "Output Tokens" draw_text.color: #x888899
draw_text.text_style.font_size: 10}
Label{text: "12.3M" draw_text.color: #x66aaff
draw_text.text_style.font_size: 28}
}
// ... 还有两张类似的卡片
}
来源:tools/canvas/examples/token-dashboard.splash:11-28(简化)
四张卡片的共同点:RoundedView + 同样的背景色、圆角、padding、flow、spacing。不同点:标题文字、数值文字、数值颜色。
重构后:一个模板 + 四次实例化
let StatCard = 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 new_batch: true
title := Label{text: "Metric"
draw_text.color: #x888899
draw_text.text_style.font_size: 10}
value := Label{text: "0"
draw_text.color: #xcc66ff
draw_text.text_style.font_size: 28}
}
View{width: Fill height: Fit flow: Right spacing: 16
StatCard{title.text: "Input Tokens" value.text: "48.5M"
value.draw_text.color: #xcc66ff}
StatCard{title.text: "Output Tokens" value.text: "12.3M"
value.draw_text.color: #x66aaff}
StatCard{title.text: "Estimated Cost" value.text: "$186.50"
value.draw_text.color: #x66ffaa}
StatCard{title.text: "Active Days" value.text: "20 / 31"
value.draw_text.color: #xffaa66}
}
重构的效果:
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 代码行数 | ~32 行 | ~20 行 |
| 修改圆角半径 | 改 4 处 | 改 1 处 |
| 添加第5张卡片 | 复制粘贴 8 行 | 1 行 |
| 改共享样式 | 逐一修改 | 改模板定义 |
模板让"结构"和"数据"分离。let StatCard 定义了卡片的结构和默认样式,每个实例只提供差异化的数据(标题、数值、颜色)。
这种分离带来的一个隐含好处是一致性保障。在重构前,如果设计师要求把标题文字从 10 号改成 11 号,你需要在四个地方各改一次——很容易漏改一个。重构后,只需要修改 StatCard 模板定义中的一行,所有实例自动更新。
注意重构后版本加了 new_batch: true(详见第7章:属性与容器,陷阱六)。原版没有这个属性,可能存在文字被背景遮盖的风险。模板化的另一个好处是:修复一个 bug 就修复了所有实例。
AI 协作视角:模板是 AI 与人类的接口
Splash 的模板机制天然适合 AI-human 协作。这种协作模式分为两个阶段:
阶段一:AI 生成模板
AI 根据用户的自然语言描述,生成完整的模板定义。模板包含结构(哪些子组件、如何排列)和默认样式(颜色、字号、间距)。
阶段二:人类覆写实例
人类不需要理解模板内部的全部实现细节——只需要知道模板暴露了哪些命名子组件(:=),然后在实例化时覆写具体的文字和颜色。
sequenceDiagram
participant U as 用户
participant AI as AI Agent
participant S as Splash
U->>AI: "做一个统计卡片,标题小字灰色,数值大字彩色"
AI->>S: let StatCard = RoundedView{<br/> title := Label{...}<br/> value := Label{...}<br/>}
Note over S: 模板定义(AI 生成)
U->>S: StatCard{title.text: "Revenue"<br/> value.text: "$1.2M"}
Note over S: 实例覆写(人类调整)
U->>AI: "给数值加个单位标签"
AI->>S: let StatCard = RoundedView{<br/> title := Label{...}<br/> value := Label{...}<br/> unit := Label{text: "" ...}<br/>}
Note over S: 模板升级:新增 unit := 接口
Note over U: 已有实例无需修改<br/>(title/value 路径不变,unit 是新增的)
为什么 Splash 的模板比其他框架更适合这种协作?因为 := 命名机制天然创造了一个"接口层"。在 React 中,组件的可定制性通过 props 定义——需要写 TypeScript 类型声明、解构参数、处理默认值。在 Splash 中,:= 就是全部——写一个名字,这个子组件就变成了可覆写的接口点。AI 不需要思考"这个参数应该是什么类型",只需要给子组件一个名字。
这个工作流的关键特性:
1. 模板是 AI 的"输出格式"
AI 生成完整的 let ... = Widget{...} 定义。因为 Splash 的语法简洁(详见第6章),AI 生成模板的成功率很高——不需要处理复杂的括号匹配或类型标注。
2. := 是 AI 与人类之间的"接口合约"
AI 用 := 标记哪些子组件是"可定制的"。人类只需要关心这些接口点,不需要理解模板内部的布局逻辑。这和面向对象编程中"公共接口 vs 私有实现"的概念类似。
3. 模板升级遵循"接口不变"原则
当用户要求修改模板结构时(比如加一个新字段),AI 可以重新生成模板定义。只要保持已有的 := 命名不变、不改变已有子组件的嵌套层级,所有已有的实例代码都不需要修改。新增功能通过新增 := 接口实现。
但要注意:如果 AI 将已有的 := 子组件移到新的命名容器内部(比如把 title := 移到 header := View{title := ...} 里面),覆写路径就会从 title.text 变为 header.title.text——所有已有实例都会失效。这就是前面"路径必须连续"规则的实际后果。好的模板升级策略是:只新增,不重组已有的命名层级。
4. 实例代码是人类的"数据层"
StatCard{title.text: "Revenue" value.text: "$1.2M" value.draw_text.color: #x66ffaa}
这一行是人类的工作——填入业务数据和品牌颜色。它简短、直观、不需要任何编程知识。非技术用户也能修改实例代码来更新仪表板数据。
模式提炼
模式一:模板接口设计
问题:模板需要在"可定制性"和"封装性"之间平衡。太多 := 让模板难以维护,太少让使用者无法调整。
方案:只对"每个实例都不同的内容"使用 :=。样式属性(颜色、字号)在模板中固定,文字内容和关键样式(如强调色)暴露为 := 接口。
检查清单:
- 文字内容(text)→ 用
:= - 每个实例不同的颜色 → 通过
value.draw_text.color覆写 - 所有实例共享的样式 → 在模板中固定,不暴露
模式二:递进式模板构建
问题:一开始不确定模板需要多复杂。
方案:从最简单的模板开始(只有一两个 :=),随着需求增长逐步添加命名子组件。不要一开始就设计"万能模板"。
步骤:
- 先写 2-3 个具体实例(不用模板)
- 找出重复的结构
- 提取为
let模板,把差异部分标记为:= - 用模板替换原来的重复代码
这正是我们在"实战重构"中做的事情——先看到了 token-dashboard 的四段重复代码,再提取为 StatCard 模板。
模式三:AI 安全的模板结构
问题:AI 生成的模板在升级时可能破坏已有实例。
方案:遵循"接口不变"原则——模板的 := 命名是"接口合约"。修改模板内部结构时,保持所有已有的 := 名称和路径不变。新增功能用新的 := 名称,不修改已有的名称。
本章小结
| 概念 | 语法 | 作用 |
|---|---|---|
| 模板定义 | let Name = Widget{...} | 定义可复用的组件结构 |
| 模板实例化 | Name{...} | 创建模板的实例 |
| 命名子组件 | id := Widget{...} | 声明可从外部覆写的子组件 |
| 实例覆写 | Name{id.prop: value} | 修改实例中命名子组件的属性 |
| 嵌套路径 | Name{outer.inner.prop: value} | 穿透多层命名容器覆写 |
三个关键规则:
:=是接口——只有:=声明的子组件才能被覆写- 路径必须连续——从模板根到目标子组件,每层容器都要有
:=名称 - 先定义后使用——
let必须在使用位置的上方
下一章将讲解 Splash 的事件系统——on_click、on_return 等回调如何让 UI 具有交互能力(详见第9章:事件与交互)。
第9章:事件与交互
为什么这很重要
前面的章节已经多次使用 on_click: ||{...} 让按钮响应点击。但事件系统远不止按钮点击——TextInput 的回车提交、Slider 的值变化、View 的渲染回调、应用的启动事件,都是 Splash 事件系统的一部分。
本章系统讲解 Splash 中所有可用的事件类型、闭包语法和事件处理模式。读完本章,你能够让 UI 中的任何组件响应用户操作,并理解事件从触发到回调执行的完整链路。
注意:本章聚焦于 Splash 侧的事件(on_click、on_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 的闭包语法——两个竖线表示"没有参数",花括号内是执行体。闭包可以访问外部作用域的变量(如 state、ui),这就是闭包的"捕获"能力。
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 的一个属性,和 text、draw_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_return 和 on_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_change 和 on_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_render 和 on_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 是最常见的有参数用法;另外 TextInput 的 on_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)
}
}
支持的方法:GET、POST、PUT、DELETE、HEAD、PATCH、OPTIONS。
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 的属性——先修改状态,然后让一个统一的函数(refresh 或 on_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 |
核心规则:
- 所有事件回调最终都要更新 UI——通过
set_text()或render() - 闭包可以访问外层变量——
state、ui、全局函数 - 在当前纯 Splash 里,条件替换整块子树通常用
on_render完成——通用脚本侧set_visible()不是稳定的通用 API - 在
Splash{}/ Canvas 环境里,可用fn tick()获得约 1 秒周期回调
下一章将讲解 Splash 的状态机和动画系统——mod.state、Animator、hover/pressed 效果(详见第10章:状态与动画)。
第10章:状态与动画
为什么这很重要
到目前为止,我们构建的 UI 都是"静态"的——按钮按下去没有视觉反馈,页面切换没有过渡效果,列表项没有 hover 高亮。在现代 UI 中,动画不是装饰——它是反馈。当用户鼠标悬停在按钮上,按钮颜色变化告诉用户"这个东西是可点击的"。当页面切换有过渡动画,用户知道"我从这里到了那里"。
Makepad 2.0 的动画系统叫 Animator——一个基于状态机的属性动画引擎。它不是 CSS transitions 那种"从 A 过渡到 B"的简单模型,而是一个完整的多状态、多轨道、多缓动的动画系统。
本章讲解 Animator 的 Splash 语法和使用模式。以 Canvas 的 music-player 应用为案例,从最简单的 hover 效果开始,逐步构建到完整的状态驱动 UI。
flowchart LR
A["Animator 状态机"] --> B["hover 组"]
A --> C["active 组"]
A --> D["focus 组"]
B --> B1["off → on: Forward 0.15s"]
B --> B2["on → off: Forward 0.15s"]
C --> C1["off → on: Snap"]
style A fill:#51cf66,color:#111
Animator 基础:状态组和过渡
核心概念
Animator 是一个状态机——它管理一组命名的状态,以及状态之间的过渡方式。每个状态定义了一组目标属性值,过渡定义了从当前值到目标值的插值方式。
Animator 的结构:
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}}
}
}
}
来源:splash.md:1374-1387
三个层级:
- 状态组(
hover):独立的动画轨道。多个组可以同时运行互不干扰 - 状态(
off、on):组内的具体状态。每个状态有过渡方式和目标值 - 过渡(
Forward {duration: 0.15}):从一个状态到另一个状态时的插值方式
三种过渡类型
| 过渡 | 效果 | 语法 | 适用场景 |
|---|---|---|---|
| Forward | 从当前值平滑过渡到目标值 | Forward {duration: 0.15} | hover 效果、渐变、淡入淡出 |
| Snap | 立即跳到目标值,无过渡 | Snap | 点击反馈、开关切换 |
| Loop | 循环播放动画 | Loop {duration: 1.0} | 加载动画、呼吸灯、脉冲 |
来源:splash.md:1393-1402
from 块定义"从哪个状态来时用什么过渡"。all 是通配符,表示从任何状态来都用这个过渡:
from: {all: Forward {duration: 0.2}} // 从任何状态来都用 0.2 秒过渡
from: {all: Snap} // 从任何状态来都立即跳转
from: {
all: Forward {duration: 0.1} // 默认 0.1 秒
down: Forward {duration: 0.01} // 从 down 状态来时更快
}
来源:splash.md:1396-1401
apply 块:动画目标
apply 定义状态对应的属性目标值。结构镜像 Widget 的属性树:
apply: {
draw_bg: {hover: 1.0} // 动画 draw_bg.hover 到 1.0
draw_text: {hover: 1.0} // 同时动画 draw_text.hover 到 1.0
}
来源:splash.md:1404-1412
Animator 动画的目标是 instance() 类型的 shader 变量——它们是每个 Widget 实例独有的浮点值(详见第7章:属性与容器)。hover、down、focus、active 是最常用的 instance 变量,由 Animator 自动驱动。
实战一:hover 效果
最常见的动画是 hover 效果——鼠标悬停时背景变化。这需要三步:
第一步:设置支持 hover 的 shader
View{
width: Fill height: Fit
cursor: MouseCursor.Hand // 鼠标变成手型
show_bg: true // 启用背景绘制
new_batch: true // 防止文字被遮盖(详见第7章)
draw_bg +: {
color: uniform(#0000) // 默认透明
color_hover: uniform(#xfff2) // hover 时半透明白
hover: instance(0.0) // 动画变量,0→1
pixel: fn(){
return Pal.premul(self.color.mix(self.color_hover, self.hover))
}
}
第二步:配置 Animator
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{text: "Hoverable item" draw_text.color: #xfff}
}
改编自:splash.md:1341-1369
这三步的完整代码构成了一个"hover 响应容器"——鼠标移入时背景在 0.15 秒内从透明过渡到半透明白色,移出时再过渡回去。
工作原理:
- 鼠标进入时,Makepad 自动将 hover 组切换到
on状态 - Animator 开始将
draw_bg.hover从 0.0 插值到 1.0(0.15 秒,Forward) - shader 的
pixel函数用self.hover做颜色混合——hover=0时显示color(透明),hover=1时显示color_hover(半透明白) - 鼠标移出时,hover 组切换回
off,hover从 1.0 插值回 0.0
重要限制:不是所有 Widget 都支持 Animator。
| 支持 Animator | 不支持 Animator |
|---|---|
| View, SolidView, RoundedView | Label, H1-H4, P, TextBox |
| Button, ButtonFlat, ButtonFlatter | Image, Icon, Slider |
| CheckBox, Toggle, RadioButton | Markdown, Html, DropDown |
| TextInput, ScrollView | Splitter, Hr, Filler |
来源:splash.md:1337-1339
如果你给 Label 添加 animator,定义会被静默忽略——不报错,但也没效果。要让 Label 有 hover 效果,把它包在支持 Animator 的 View 中(如上面的示例)。
实战二:按钮状态(hover + pressed)
按钮需要同时响应 hover 和 pressed 两个状态组。Makepad 内置的 Button 已经有这些动画,但你也可以自定义:
Button{text: "Custom Button"
draw_bg +: {
color: uniform(#x336)
color_hover: uniform(#x449)
color_down: uniform(#x225)
}
}
改编自:splash.md:628-638
Button 内置了 hover 和 down 两个 Animator 组——你只需要设置颜色的 uniform 值,Animator 自动处理状态切换和过渡动画。
对于 pomodoro 中的按钮,颜色是直接通过 draw_bg.color 设置的(不是 uniform()),所以它们没有 hover 动画——点击有效,但没有视觉反馈。如果你想给 pomodoro 的按钮加 hover 效果,需要改为 uniform() + Animator 配置。
实战三:music-player 的状态控制
music-player 展示了一种不同的"状态"模式——不是 Animator 驱动的视觉状态,而是应用逻辑状态(播放/暂停)。它使用 Canvas 约定的 fn on_audio() 回调和状态变量来驱动 UI:
let player = { track: 0 }
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") }
}
来源:tools/canvas/examples/music-player.splash:28-33
这里的"状态"不是 Animator 状态——它是应用数据层面的状态(当前播放的曲目、是否正在播放)。_playing、_pos、_dur 是 Canvas 音频系统注入的全局变量(详见第30章:音频可视化案例)。
music-player 展示了两种"状态"的共存:
| 状态类型 | 管理者 | 示例 | UI 效果 |
|---|---|---|---|
| 视觉状态 | Animator | hover, pressed, focus | 颜色过渡、缩放动画 |
| 应用状态 | Splash 变量 | playing, track, volume | 文字更新、按钮切换 |
两种状态是独立的——Animator 管理"看起来怎样",Splash 变量管理"数据是什么"。它们通过不同的机制更新 UI:Animator 通过 shader instance 变量驱动颜色插值,Splash 变量通过 set_text() 和 on_render 更新内容。
高级特性
snap():立即跳转
在 apply 中用 snap() 包裹的值会立即跳到目标,不做插值:
apply: {
draw_bg: {down: snap(1.0), hover: 1.0} // down 立即跳,hover 平滑过渡
}
来源:splash.md:1430-1436
适用场景:按钮按下时需要立即变色(不要等 0.15 秒),但松开时可以平滑恢复。
timeline():关键帧动画
timeline() 让你定义多个时间点的值,Animator 在时间点之间插值:
apply: {
draw_bg: {anim_time: timeline(0.0 0.0 0.5 1.0 1.0 0.0)}
}
来源:splash.md:1438-1444
这创建了一个"0 → 1 → 0"的三角波动画——0% 时值为 0,50% 时值为 1,100% 时值回到 0。结合 Loop 过渡类型,可以实现呼吸灯效果。
多状态组并行
一个 Widget 可以有多个独立的 Animator 组:
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}}}
}
focus: {
default: @off
off: AnimatorState{from: {all: Forward {duration: 0.1}} apply: {draw_bg: {focus: 0.0}}}
on: AnimatorState{from: {all: Forward {duration: 0.1}} apply: {draw_bg: {focus: 1.0}}}
}
}
hover 组和 focus 组同时运行——鼠标悬停时 hover=1.0,键盘聚焦时 focus=1.0,两者互不干扰。shader 可以同时使用两个变量来计算最终颜色。
模式提炼
模式一:hover 响应容器
View{show_bg: true cursor: MouseCursor.Hand new_batch: true
draw_bg +: {
color: uniform(#0000)
color_hover: uniform(#xfff2)
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}}}
}
}
// 内容
}
何时用:列表项、卡片、任何需要 hover 反馈的容器。
要点:show_bg: true + new_batch: true + cursor 三者缺一不可。
模式二:应用状态 vs 视觉状态分离
| 层面 | 管理方式 | 更新机制 |
|---|---|---|
| 应用数据 | let state = {...} | set_text() / on_render |
| 视觉效果 | Animator | shader instance 变量自动插值 |
不要试图用 Animator 管理应用数据(如"当前页面"),也不要用 Splash 变量手动实现 hover 动画。两套系统各有分工。
模式三:过渡类型选择
| 场景 | 过渡类型 | duration |
|---|---|---|
| hover 效果 | Forward | 0.1-0.2s |
| 按钮按下 | Snap(或 Forward 0.01s) | 即时 |
| 展开/折叠 | Forward | 0.2-0.4s |
| 加载动画 | Loop | 1.0-2.0s |
本章小结
| 概念 | 作用 | 语法 |
|---|---|---|
| Animator | 管理视觉状态过渡 | animator: Animator{...} |
| 状态组 | 独立的动画轨道 | hover: {...} / focus: {...} |
| AnimatorState | 单个状态的目标值和过渡 | AnimatorState{from: {...} apply: {...}} |
| Forward | 平滑过渡 | Forward {duration: 0.15} |
| Snap | 立即跳转 | Snap |
| Loop | 循环动画 | Loop {duration: 1.0} |
| instance() | Animator 驱动的 shader 变量 | hover: instance(0.0) |
| uniform() | 不被 Animator 驱动的固定值 | color: uniform(#x336) |
下一章是 Part II 的高潮——流式求值,揭示 AI 逐 token 输出时 UI 如何逐步成型的技术机制(详见第11章:流式求值)。
第11章:流式求值
为什么这很重要
当 AI Agent 生成一段 500 行的 Splash UI 代码时,用户需要等到全部 500 行输出完毕才能看到 UI 吗?
在大多数框架中,答案是"是"——JSON 需要所有括号匹配,JSX 需要完整编译,QML 需要完整解析。但在 Makepad 2.0 中,答案是"不需要"。
流式求值(Streaming Evaluation)是 Splash 的核心能力之一——AI 输出的每一段代码片段都能立即被解析、编译和渲染。用户看到的不是"等待...等待...完整 UI 出现",而是UI 逐渐成型的过程:先出现容器背景,再填入文字,再加上按钮……
这不是一个附加功能——它是 Splash 语法设计(详见第6章)的直接结果,也是 Canvas Agent-to-App 管线(详见第27章)的技术基础。本章揭示这个能力的完整实现机制。
sequenceDiagram
participant AI as AI Agent
participant C as Canvas
participant T as Tokenizer
participant P as Parser
participant VM as VM
participant S as 屏幕
AI->>C: SplashStreamBegin
AI->>C: "SolidView{width: Fill"
C->>T: tokenize(new_chars)
T->>P: parse_streaming()
P->>VM: 自动关闭未完成结构
VM->>S: 渲染部分 UI
AI->>C: " height: Fit Label{"
C->>T: tokenize(new_chars)
T->>P: 恢复 checkpoint + 继续解析
VM->>S: 更新 UI
AI->>C: "text: \"Hello\"}}"
AI->>C: SplashStreamEnd
C->>T: tokenize(new_chars)
P->>VM: 最终完整结构
VM->>S: 渲染最终 UI
批量 vs 流式:两种求值模式
Splash VM 提供两种求值方式:
批量求值(eval)
#![allow(unused)] fn main() { pub fn eval(&mut self, script_mod: ScriptMod) -> ScriptValue }
来源:platform/script/src/vm.rs:982
接收完整的 Splash 代码,一次性 tokenize → parse → execute。这是 script_mod! 和 POST /splash 的默认方式。
流式求值(eval_with_append_source)
#![allow(unused)] fn main() { /// Evaluate script incrementally by appending new source to an existing body. /// /// Pass the full growing source code string each time. On first call, creates /// the body and tokenizes/parses everything. On subsequent calls, computes the /// delta (new chars since last call), restores the parser checkpoint (removing /// auto-close opcodes), tokenizes only the new chars, continues parsing, then /// auto-closes again for execution. Always re-executes from opcode 0. pub fn eval_with_append_source( &mut self, script_mod: ScriptMod, code: &str, source: ScriptObject, ) -> ScriptValue }
来源:platform/script/src/vm.rs:1039-1051
函数文档本身就是流式求值的完整说明。关键点:
- 增量 tokenize:每次调用只 tokenize 新增的字符片段
- checkpoint 恢复:恢复上一次的 parser 状态,撤销之前的自动关闭
- 自动关闭:为不完整的代码补上缺失的
},让 VM 可以执行 - 完全重新执行:每次从 opcode 0 开始执行,渲染完整的 UI
用户体验的差异
| 批量求值 | 流式求值 | |
|---|---|---|
| AI 输出第 1% | 用户看到空白 | 用户看到容器背景 |
| AI 输出第 50% | 用户仍看到空白 | 用户看到半完成的 UI |
| AI 输出第 100% | 完整 UI 突然出现 | UI 最后的细节补全 |
| 用户感知 | "AI 在思考" | "UI 在构建" |
| 总等待时间 | 相同 | 相同 |
| 感知等待时间 | 长(空白期焦虑) | 短(持续有进展) |
感知等待时间的差异是心理学级别的——总时间一样,但流式模式让用户全程有东西看,焦虑感大幅降低。
流式求值的三阶段协议
Canvas 通过 WebSocket 或 HTTP 使用流式求值,协议分三个阶段:
#![allow(unused)] fn main() { pub enum CanvasCommand { SplashStreamBegin, // 阶段 1:开始 SplashStreamAppend { code: String }, // 阶段 2:追加(多次) SplashStreamEnd, // 阶段 3:结束 } }
来源:tools/canvas/src/ws/types.rs:9-13
阶段 1:Begin — 清空当前 UI,准备接收新代码。Canvas 重置内部的代码缓冲区。
阶段 2:Append(多次调用)— 每次追加一段代码片段。Canvas 将新片段拼接到已有代码后面,然后调用 eval_with_append_source 进行增量求值。每次 Append 后用户都能看到当前代码对应的 UI。
阶段 3:End — 标记代码输出完成。当前 Canvas 实现主要在 End 时收尾并记录最终代码;真正的增量求值发生在每次 Append。
对应的 HTTP 端点:
| 端点 | 对应阶段 |
|---|---|
POST /splash/stream(空 body) | Begin |
POST /splash/stream + body | Append |
POST /splash/end | End |
深入源码:增量求值的实现
eval_with_append_source 的核心逻辑可以分为四步:
第一步:计算增量
#![allow(unused)] fn main() { let prev_len = body.source_len; let content_changed = prev_len > 0 && (code.len() < prev_len || code[..prev_len] != body.tokenizer.original[..prev_len]); if content_changed { // 内容完全变化——重置,从头 tokenize body.tokenizer.clear(); body.parser = ScriptParser::default(); body.checkpoint = None; body.source_len = code.len(); body.tokenizer.tokenize(code, &mut self.bx.heap); } else if code.len() >= prev_len { body.source_len = code.len(); let new_chars = &code[prev_len..]; if !new_chars.is_empty() { body.tokenizer.tokenize(new_chars, &mut self.bx.heap); } } }
来源:platform/script/src/vm.rs:1101-1121
VM 比较新代码和之前的代码。如果新代码是之前代码的扩展(前缀相同,只是尾部有新内容),只 tokenize 新增部分。如果内容完全变化(比如用户完全替换了代码),重置一切从头开始。
这个检查确保了两种场景都能正确处理:AI 逐段追加(增量)和用户完全替换代码(重置)。
第二步:恢复 checkpoint 并解析
#![allow(unused)] fn main() { if let Some(cp) = body.checkpoint.take() { body.parser.restore_checkpoint(cp); } let cp = body.parser.parse_streaming( &body.tokenizer, &existing_mod.file, (existing_mod.line, existing_mod.column), &existing_mod.values, unfinished, ); body.checkpoint = Some(cp); }
来源:platform/script/src/vm.rs:1097-1136
关键操作是 restore_checkpoint → parse_streaming → 保存新 checkpoint。
为什么需要 checkpoint? 上一次求值时,parser 为了让不完整的代码能执行,会自动插入缺失的 }(auto-close)。这些自动关闭的 opcode 在下一次追加时需要被撤销——因为代码还没真正结束。restore_checkpoint 就是做这个撤销操作的。
然后 parse_streaming 从当前位置继续解析新的 token,再次自动关闭,保存新的 checkpoint。
第三步:静默执行
#![allow(unused)] fn main() { self.bx.silence_errors = true; let result = self.run_root(body_id); self.bx.silence_errors = false; }
来源:platform/script/src/vm.rs:1142-1144
silence_errors = true 是流式求值的关键策略——不完整的代码必然会产生运行时错误(比如引用了还没定义的变量),这些错误在代码未完成时是无意义的。静默模式下 VM 继续执行能执行的部分,跳过出错的部分,尽量渲染出部分 UI。
第四步:渲染
VM 执行后产生的 Widget 树被渲染到屏幕上。用户看到当前代码段对应的(不完整但可见的)UI。
技术边界:什么能流式,什么不能
支持流式渲染的结构
| 结构 | 流式行为 | 原因 |
|---|---|---|
Widget 声明 View{...} | 逐步显示 | auto-close 补上 } 后即可渲染 |
属性赋值 text: "hello" | 值到达后立即生效 | token 完整即可求值 |
| 嵌套 Widget | 外层先出现,内层后填入 | 外层的 auto-close 在内层到达时被撤销 |
let 模板定义 | 定义完成后可用 | 模板是编译期结构 |
不支持增量的结构
| 结构 | 行为 | 原因 |
|---|---|---|
字符串字面量 "hel | 等待关闭引号 | tokenizer 保持在 String 状态,发射 StringUnfinished |
fn 函数定义 | 函数体需要完整 | 不完整的函数体无法编译为可执行的 opcode |
条件表达式 if x { | 需要完整的 if/else | 不完整的条件无法确定执行路径 |
对于 AI 生成场景,影响最大的是字符串。当 AI 输出 Label{text: "Hello Wor 时,"Hello Wor 是一个未完成的字符串——tokenizer 无法确定何时结束。VM 会使用 intern_unfinished_string 将当前已有的部分作为临时值,让 Label 显示"Hello Wor"。当后续的 ld"} 到达时,字符串被正确关闭为"Hello World"。
流式求值在 Canvas 中的应用
Canvas 的 SplashStreamAppend 命令直接调用 eval_with_append_source。一个典型的流式渲染过程:
时间线:
──────────────────────────────────────────
t=0ms AI 开始输出
→ SplashStreamBegin
→ Canvas 清空画布
t=50ms AI: "SolidView{width: Fill height: Fit draw_bg.color: #x1a1a2e"
→ Append → auto-close → 渲染深色背景
t=120ms AI: " flow: Down padding: 20\nLabel{text: \"Title\""
→ Append → auto-close → 背景 + "Title" 文字出现
t=200ms AI: " draw_text.color: #xfff draw_text.text_style.font_size: 24}"
→ Append → Label 样式更新(白色大字)
t=350ms AI: "\nButton{text: \"Click me\" draw_bg.color: #x51cf66}"
→ Append → 绿色按钮出现
t=400ms AI: "\n}"
→ SplashStreamEnd → 最终完整 UI
用户在 50ms 就看到了背景,120ms 看到标题,350ms 看到按钮。整个过程 400ms——但体验上几乎没有"等待"的感觉,因为全程都有视觉进展。
对比批量模式:用户在前 399ms 看到空白画布,第 400ms 完整 UI 突然出现。体验上感觉"AI 在思考了 400ms"。
这种差异在更复杂的 UI(如 token-dashboard 的 148 行代码)中更加明显。流式模式下,用户在前几百毫秒就看到了仪表板的框架结构,接下来的 1-2 秒逐渐填入卡片、图表、数据。批量模式下则是 2 秒的空白之后整个仪表板一次性出现。
为什么其他框架难以做到
流式求值不只是"技术实现"——它需要从语法设计到 VM 架构的全链路支持:
| 层级 | Splash 的设计 | 其他框架的障碍 |
|---|---|---|
| 语法 | 无需分隔符,token 自描述 | JSON 需要逗号;XML 需要关闭标签 |
| Tokenizer | 增量输入(tokenize(new_chars)) | 大多数 tokenizer 需要完整输入 |
| Parser | checkpoint + auto-close | 需要完整 AST 才能输出 |
| VM | silence_errors 容忍不完整代码 | 错误中断执行 |
| 渲染 | 每次追加都渲染 | 需要完整 Virtual DOM diff |
这就是为什么第6章说"Splash 的语法设计服务于流式解析"——不是事后添加的能力,而是从第一天就作为设计目标。
连接 Part VI:Canvas 的流式渲染
本章讲解了流式求值的技术机制。在 Part VI 中,这些机制将在更大的系统中发挥作用:
- 第27章将展示 Canvas 如何通过
StdioBridge接收 WS/HTTP 命令,将SplashStreamAppend转化为eval_with_append_source调用 - 第28章将展示 AI Agent 如何决定何时使用批量模式(
POST /splash)和流式模式(SplashStream*) - 第29章将展示自愈循环如何利用流式渲染——AI 修复代码后立即推送,用户实时看到 UI 修复过程
流式求值是连接"Splash 语言"和"AI 生成 UI"的关键桥梁。语言设计确保了代码可以增量解析(详见第6章),VM 实现确保了不完整的代码也能产生可见的 UI,Canvas 架构确保了这一切可以通过网络远程驱动(详见第27-29章)。
模式提炼
模式一:增量求值三步曲
1. 恢复 checkpoint(撤销上次的 auto-close)
2. tokenize 新增字符 → parse_streaming → 新的 auto-close
3. 静默执行 → 渲染部分 UI → 保存新 checkpoint
这是 eval_with_append_source 的核心循环。每次新代码到达时重复这三步。
模式二:auto-close 容错策略
不完整的代码通过自动关闭获得可执行性:
- 未关闭的
{→ parser 自动补上} - 未完成的字符串 → tokenizer 临时发射部分字符串
- 未定义的变量引用 → VM 静默跳过(
silence_errors)
代价是部分 UI 可能暂时不正确,但随着更多代码到达,UI 逐步趋于正确。这是一个"进展优于等待"的设计哲学。
模式三:流式 vs 批量的选择
| 场景 | 推荐模式 |
|---|---|
| AI 实时生成 UI | 流式(SplashStream*) |
| 加载已保存的应用 | 批量(POST /splash) |
| 用户编辑代码 | 批量(每次保存后完整求值) |
| 热重载 | 批量(文件变化触发完整重新求值) |
本章小结
| 概念 | 说明 |
|---|---|
| 批量求值 | eval() — 完整代码一次性处理 |
| 流式求值 | eval_with_append_source() — 增量追加 + 增量渲染 |
| 三阶段协议 | StreamBegin → StreamAppend × N → StreamEnd |
| checkpoint | Parser 状态快照,支持撤销 auto-close |
| auto-close | 自动补全未关闭的结构,让不完整代码可执行 |
| silence_errors | 静默跳过不完整代码导致的错误 |
核心要点:
- 流式求值是语法设计的结果——Splash 的无分隔符、花括号嵌套设计使 auto-close 成为可能
- 用户体验是"观看构建"而非"等待结果"——相同的总时间,不同的感知
- 这为 AI 生成 UI 提供了更短的反馈链路——Canvas 的 Agent-to-App 管线的技术基础
Part II(Splash 语言篇)到此完成。下一步进入 Part III(Widget 体系篇),系统讲解 Makepad 的布局引擎、组件库和自定义 Widget 机制。
第12章:布局引擎 Turtle
为什么这很重要
前面的章节中,我们反复使用 flow: Down、width: Fill、align: Center 等布局属性,但从未解释它们的底层机制。当你的布局"不对"——某个组件没有出现在预期位置、尺寸不对、对齐偏移——你需要理解布局引擎的工作方式才能诊断问题。
Makepad 的布局引擎叫 Turtle(海龟)。这个名字来自 Logo 语言的"海龟图形"隐喻——一只虚拟的海龟在画布上行走,每经过一个 Widget 就前进相应的距离。布局就是海龟的行走路径。
Turtle 不是 CSS Flexbox 的复制品。它是一个单遍布局算法——从上到下、从左到右扫描 Widget 树,一次遍历完成所有布局计算。没有回溯、没有多轮迭代。这使得 Turtle 的性能非常好,但也意味着某些 CSS 中常见的布局模式(如"子元素等高")需要不同的实现方式。
flowchart TD
A["Turtle 开始行走"] --> B{"flow 方向?"}
B -->|Right| C["→ 从左到右放置子组件"]
B -->|Down| D["↓ 从上到下放置子组件"]
B -->|Overlay| E["⊕ 所有子组件叠加"]
C --> F["遇到 Fill → 延迟计算"]
D --> F
F --> G["所有 Fit 子组件放置完毕"]
G --> H["回填 Fill 子组件的实际尺寸"]
style A fill:#51cf66,color:#111
Turtle 的行走规则
Walk 结构
每个组件有一个 Walk——描述它在布局中"占多大空间":
#![allow(unused)] fn main() { pub struct Walk { pub abs_pos: Option<Vec2d>, // 绝对定位(忽略 Turtle 流) pub margin: Inset, // 外边距 pub width: Size, // 宽度 pub height: Size, // 高度 } }
来源:draw/src/turtle.rs:49-60(简化)
Size 枚举:三种尺寸模式
#![allow(unused)] fn main() { pub enum Size { Fill, // 填满剩余空间 Fit, // 收缩到内容 Fixed(f64), // 固定像素值 } }
在 Splash 中:
width: Fill // Size::Fill — 默认值
width: Fit // Size::Fit
width: 200 // Size::Fixed(200.0)
width: Fill{min: 100 max: 500} // 带约束的 Fill
来源:splash.md:362-371
Flow 枚举:四种排列方向
flow: Right // 从左到右(默认)
flow: Down // 从上到下
flow: Overlay // 堆叠
flow: Flow.Right{wrap: true} // 自动换行
来源:splash.md:375-382
Turtle 根据 flow 方向前进:
- Right:每放一个子组件,Turtle 向右移动
子组件宽度 + spacing - Down:每放一个子组件,Turtle 向下移动
子组件高度 + spacing - Overlay:所有子组件放在同一位置,Turtle 不移动
- Wrap:到达边界时换行/换列
Fill 的延迟计算
Fill 是布局中最复杂的部分。当 Turtle 遇到 width: Fill 的子组件时,它不知道该给它多少空间——因为后面可能还有其他子组件。
解决方案:延迟计算。
- Turtle 先跳过所有
Fill子组件,只放置Fit和Fixed子组件 - 所有非 Fill 子组件放置完毕后,计算剩余空间
- 将剩余空间按权重分配给
Fill子组件
父容器宽度: 400px
子组件 A: width: 100 (Fixed) → 占 100px
子组件 B: width: Fill → 待定
子组件 C: width: Fit → 测量后为 60px
spacing: 10px × 2 = 20px
剩余 = 400 - 100 - 60 - 20 = 220px
子组件 B 宽度 = 220px
多个 Fill 子组件平分剩余空间:
父容器宽度: 400px
子组件 A: width: Fill → 待定
子组件 B: width: Fill → 待定
spacing: 10px
剩余 = 400 - 10 = 390px
A 和 B 各得 195px
这就是 token-dashboard 中四张卡片等宽的原因——四个 RoundedView{width: Fill} 在 flow: Right 的父容器中平分可用空间。
Align:对齐机制
align 控制子组件在父容器剩余空间中的位置:
align: Center // x:0.5 y:0.5 → 居中
align: Align{x: 0.0 y: 0.0} // 左上角
align: Align{x: 1.0 y: 1.0} // 右下角
align: Align{x: 0.5 y: 0.0} // 顶部水平居中
来源:splash.md:394-401
Align 的工作方式:Turtle 先按 flow 方向放置所有子组件,计算子组件占用的总面积。然后将剩余空间按 align 比例分配到子组件组的前面和后面。
父容器: 400×200
子组件总占用: 200×50
剩余: 200×150
align: Center (x:0.5 y:0.5)
→ 子组件组偏移 (200×0.5, 150×0.5) = (100, 75) 像素
→ 子组件从 (100, 75) 开始放置
token-dashboard 中柱状图的底部对齐就用了 align: Align{y: 1.0}——把子组件推到父容器底部(详见第7章)。
Padding、Margin、Spacing 的区别
flowchart TD
subgraph Container["父容器"]
subgraph Padding["padding 区域"]
subgraph Child1["子组件 A(含 margin)"]
A["内容"]
end
S["← spacing →"]
subgraph Child2["子组件 B(含 margin)"]
B["内容"]
end
end
end
| 属性 | 作用范围 | 影响 |
|---|---|---|
padding | 父容器 → 内容区 | 缩小 Turtle 的可用空间 |
margin | 子组件 → 外围 | 在子组件周围添加空白 |
spacing | 子组件之间 | 相邻子组件的间隔(不影响首尾) |
padding 是容器级的——它缩小 Turtle 行走的"画布"。margin 是组件级的——它在组件自身周围添加空白。spacing 是子组件间的——它只在两个相邻子组件之间生效。
常见布局问题诊断
问题:组件不可见(0px 高度)
原因:子组件 height: Fill(默认值),但父容器 height: Fit。Fill-in-Fit 循环依赖导致 0px。
修复:给子组件加 height: Fit(详见第7章:陷阱一)。
问题:Fill 子组件没有占满空间
原因:父容器自身也是 width: Fit。Fit 容器的宽度由子组件决定,Fill 子组件在 Fit 容器中没有"剩余空间"可填。
修复:确保 Fill 子组件的父容器有明确的宽度(Fixed 或父级的 Fill)。
问题:Filler{} 和 width: Fill 并存导致 50/50 分割
原因:Filler{} 本身就是 width: Fill。当它和另一个 Fill 子组件并存时,两者平分空间。
修复:不要同时使用 Filler{} 和 width: Fill 的兄弟组件。给内容组件用 width: Fill,它自然会推开 Fit 的兄弟组件。
来源:splash.md:740-757
问题:文字被截断
原因:父容器有 clip_x: true(默认值)且宽度不足。
修复:给文字容器足够的宽度,或使用 clip_x: false 允许溢出。
模式提炼
模式一:Holy Grail 布局
View{width: Fill height: Fill flow: Down
// Header
SolidView{width: Fill height: 60 draw_bg.color: #x222}
// Body(填满中间)
View{width: Fill height: Fill flow: Right
// Sidebar
SolidView{width: 200 height: Fill draw_bg.color: #x333}
// Content(填满剩余)
View{width: Fill height: Fill}
}
// Footer
SolidView{width: Fill height: 40 draw_bg.color: #x222}
}
Header 和 Footer 是固定高度,Body 用 height: Fill 占满中间。Body 内部 Sidebar 固定宽度,Content 用 width: Fill。
模式二:等分布局
View{width: Fill height: Fit flow: Right spacing: 8
RoundedView{width: Fill height: Fit ...} // 1/3
RoundedView{width: Fill height: Fit ...} // 1/3
RoundedView{width: Fill height: Fit ...} // 1/3
}
N 个 width: Fill 子组件在 flow: Right 中自动等分。
模式三:底部对齐(柱状图)
View{width: Fill height: 200 flow: Right align: Align{y: 1.0}
View{width: Fill height: Fit flow: Down align: Center
RoundedView{width: 14 height: 80 draw_bg.color: #x7733cc}
Label{text: "Mon"}
}
// 更多柱子...
}
align: Align{y: 1.0} 把子组件推到底部。每根柱子是 flow: Down 容器,柱体在上,标签在下。
本章小结
| 概念 | 说明 |
|---|---|
| Turtle | 单遍布局算法,沿 flow 方向行走 |
| Walk | 每个组件的尺寸描述(width + height + margin) |
| Size | Fill(填满)/ Fit(收缩)/ Fixed(固定) |
| Flow | Right / Down / Overlay / Wrap |
| Fill 延迟计算 | 先放 Fit/Fixed,再分配剩余空间给 Fill |
| Align | 在剩余空间中按比例偏移子组件组 |
| padding / margin / spacing | 容器内边距 / 组件外边距 / 组件间距 |
下一章将深入 Makepad 的文本世界——Label、TextInput、Markdown、Html 组件和文本渲染机制(详见第13章:文本世界)。
第13章:文本世界
为什么这很重要
文本是 UI 的基础——几乎每个界面都包含标签、标题、输入框、富文本。Makepad 2.0 提供了从简单标签到完整 Markdown 渲染的文本组件家族。理解这些组件的能力边界和使用模式,是构建信息密集型界面的关键。
flowchart TD
A["文本组件"] --> B["Label — 单行/多行静态文本"]
A --> C["TextInput — 可编辑输入"]
A --> D["Markdown — 富文本渲染"]
A --> E["Html — HTML 子集渲染"]
B --> B1["H1-H4 — 标题变体"]
B --> B2["P / TextBox — 段落"]
Label:静态文本
Label 是最基础的文本组件——显示一段不可编辑的文字。
Label{text: "Hello World"}
Label{text: "Styled Text"
draw_text.color: #xff6b6b
draw_text.text_style.font_size: 18
draw_text.text_style: theme.font_bold{}}
来源:splash.md:524-530
Label 的关键属性
| 属性 | 用途 | 默认值 |
|---|---|---|
text | 显示的文字 | "" |
draw_text.color | 文字颜色 | #fff(白色) |
draw_text.text_style.font_size | 字号 | theme.font_size_p |
draw_text.text_style | 字体族 | theme.font_regular |
width / height | 尺寸 | 默认 Fill / Fill |
align | 文字对齐 | 左上 |
重要限制:Label 不支持 animator 和 cursor。要做可 hover/可点击的文字,把 Label 包在 View 中(详见第10章:hover 响应容器模式)。
标题变体
H1{text: "Page Title"} // 最大
H2{text: "Section"} // 次大
H3{text: "Subsection"} // 中等
H4{text: "Minor heading"} // 较小
来源:splash.md:554-558
段落变体
P{text: "Paragraph text, typically with smaller font and wider width."}
Pbold{text: "Bold paragraph"}
TextBox{text: "Full-width text container"}
来源:splash.md:546-549
运行时更新文字
在 Splash 脚本中通过 := 命名后用 set_text() 更新:
counter_label := Label{text: "0"}
// 在 on_click 或 fn 中:
ui.counter_label.set_text("" + state.counter)
set_text() 是最高效的 UI 更新方式——只修改文字内容,不重建 Widget(详见第9章:set_text vs on_render 对比)。
TextInput:可编辑输入
TextInput 允许用户输入文字。它支持占位文字、密码模式、只读模式和数字限制:
TextInput{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}
TextInputFlat{width: Fill height: Fit empty_text: "Flat style"}
来源:splash.md:575-580
TextInput 事件
| 事件 | 触发时机 | Splash API | Rust API |
|---|---|---|---|
| 内容变化 | 用户编辑文本 | on_change: |text|{...} | .changed(actions) |
| 回车提交 | 用户按 Enter | on_return: ||{...} / |text|{...} | .returned(actions) |
| 读取文字 | 任意时刻 | ui.input.text() | .text() |
| 设置文字 | 任意时刻 | ui.input.set_text("") | .set_text(cx, "") |
on_return 是 TextInput 最重要的事件——用户按回车时触发,常用于搜索框和表单提交(详见第9章)。Rust 侧的 .returned(actions) 还会一并返回 KeyModifiers,便于区分普通回车和带修饰键的提交。
on_change 则适合做实时校验、搜索建议和即时预览。脚本侧会直接收到当前文本;Rust 侧用 .changed(actions) 取得更新后的字符串。
TextInput 样式
TextInput 有自己的绘制层:draw_bg(背景)、draw_text(文字)、draw_selection(选中高亮)、draw_cursor(光标)。
Markdown 和 Html
Makepad 内置了 Markdown 和 Html 渲染组件,可以直接在 Splash 中使用:
Markdown{
width: Fill height: Fit
selectable: true
body: "# Title\n\nParagraph with **bold** and *italic*"
}
Html{
width: Fill height: Fit
body: "<h3>HTML Title</h3><p>Content with <b>bold</b></p>"
}
来源:splash.md:599-607
这两个组件都基于 TextFlow——Makepad 的富文本排版引擎。TextFlow 处理段落折行、内联样式、链接高亮等。
可用字体
| 字体 | Splash 名 | 用途 |
|---|---|---|
| 常规 | theme.font_regular | 正文默认 |
| 粗体 | theme.font_bold | 标题、强调 |
| 斜体 | theme.font_italic | 引用、注释 |
| 粗斜体 | theme.font_bold_italic | 双重强调 |
| 等宽 | theme.font_code | 代码 |
| 图标 | theme.font_icons | UI 图标 |
来源:splash.md:570
模式提炼
模式:文字对比色
问题:默认文字颜色是白色(#fff),在浅色背景上不可见。
方案:始终显式设置 draw_text.color。深色背景用浅色文字,浅色背景用深色文字。
// 深色背景 + 浅色文字
SolidView{draw_bg.color: #x1a1a2e
Label{text: "Visible" draw_text.color: #xeeeeff}}
// 浅色背景 + 深色文字
SolidView{draw_bg.color: #xf5f5f5 new_batch: true
Label{text: "Visible" draw_text.color: #x222222}}
本章小结
| 组件 | 用途 | 可编辑 | 支持 Animator |
|---|---|---|---|
| Label | 静态文字 | 否 | 否 |
| H1-H4 | 标题 | 否 | 否 |
| P / TextBox | 段落 | 否 | 否 |
| TextInput | 输入框 | 是 | 是 |
| Markdown | Markdown 渲染 | 选择 | 否 |
| Html | HTML 渲染 | 选择 | 否 |
下一章讲解交互组件——Button、CheckBox、Slider、DropDown(详见第14章:交互组件)。
第14章:交互组件
为什么这很重要
文本组件显示信息,交互组件收集输入。Button、CheckBox、Slider、DropDown——这些是用户与应用对话的工具。本章系统讲解 Makepad 的交互组件家族及其在 Splash 中的使用方式。
flowchart TD
A["交互组件"] --> B["Button — 按钮"]
A --> C["CheckBox / Toggle — 开关"]
A --> D["RadioButton — 单选"]
A --> E["Slider — 滑块"]
A --> F["DropDown — 下拉选择"]
Button:按钮
Makepad 提供三种按钮样式:
Button{text: "Standard"} // 标准(有立体边框)
ButtonFlat{text: "Flat"} // 扁平(无边框)
ButtonFlatter{text: "Minimal"} // 极简(透明背景)
来源:splash.md:615-617
自定义按钮样式
ButtonFlat{text: "Custom"
draw_bg +: {
color: uniform(#x336)
color_hover: uniform(#x449)
color_down: uniform(#x225)
}
draw_text +: {color: #xfff}
}
来源:splash.md:628-638
color_hover 和 color_down 是 Animator 驱动的变体——鼠标悬停时过渡到 hover 色,按下时过渡到 down 色(详见第10章)。
按钮事件
| 模式 | Splash | Rust |
|---|---|---|
| 点击 | on_click: ||{...} | button.clicked(actions) |
| 按下 | on_press: ||{...} | button.pressed(actions) |
| 程序触发 | ui.btn.on_click() | — |
| 程序触发按下 | ui.btn.on_press() | — |
如果你需要“手指 / 鼠标一按下就反馈”,用 on_press;如果你需要“按下后松开且仍在按钮区域内才算完成”,用 on_click。
带图标的按钮:
Button{text: "Save"
icon_walk: Walk{width: 16 height: 16}
draw_icon.color: #xfff
draw_icon.svg: crate_resource("self://path/to/icon.svg")
}
来源:splash.md:620-625
CheckBox 和 Toggle:开关
CheckBox{text: "Enable notifications"}
CheckBoxFlat{text: "Flat style"}
Toggle{text: "Dark mode"}
ToggleFlat{text: "Flat toggle"}
来源:splash.md:655-659
CheckBox 和 Toggle 功能相同——一个有/无状态的开关。区别是视觉样式:CheckBox 是方块打勾,Toggle 是滑动开关。
事件:Rust 用 changed,Splash 用 on_click
CheckBox{text: "Enable notifications"
on_click: |checked|{
if checked { ui.status_label.set_text("ON") }
else { ui.status_label.set_text("OFF") }
}
}
脚本侧没有单独的 on_change 名称;当前实现是在切换时触发 on_click,并把新的布尔状态作为参数传入。
#![allow(unused)] fn main() { // Rust 侧 if let Some(checked) = item.check_box(cx, ids!(check)).changed(actions) { // checked: bool } }
来源:examples/todo/src/main.rs:315
Rust 侧如果已经在 handle_actions 里处理交互,changed(actions) 仍然是最直接的入口(详见第5章 Todo 示例)。
RadioButton:单选
RadioButton{text: "Option A"}
RadioButtonFlat{text: "Option A"}
来源:splash.md:670-673
RadioButton 用法和 CheckBox 类似,但交互语义不同:它点击后只会从 off 切到 on,不会再次点击取消。Rust 侧单个按钮通常用 .clicked(actions) 检测;如果是一组单选项,则用 RadioButtonSet::selected(cx, actions) 得到被选中的索引。
Slider:滑块
Slider{width: Fill text: "Volume" min: 0. max: 100. default: 50.}
SliderMinimal{text: "Value" min: 0. max: 1. step: 0.01 precision: 2}
来源:splash.md:680-681
Slider 属性
| 属性 | 说明 | 默认 |
|---|---|---|
min | 最小值 | 0.0 |
max | 最大值 | 1.0 |
step | 步进 | 连续 |
default | 初始值 | 0.0 |
precision | 显示精度(小数位) | 自动 |
Slider 事件
Slider{text: "Brightness" min: 0. max: 100.
on_change: |val|{
state.brightness = val
ui.brightness_label.set_text("" + val)
}
}
on_change: |val|{...} 是 Splash 中最常见的有参数闭包事件之一(详见第9章)。TextInput 的 on_change / on_return 在运行时也会传入当前文本。
DropDown:下拉选择
DropDown{labels: ["Small" "Medium" "Large"]}
DropDownFlat{labels: ["Option A" "Option B" "Option C"]}
来源:splash.md:687-688
DropDown 的 labels 是一个字符串数组。选择变化目前主要通过 Rust 侧处理,常见入口是 .changed(actions);当前实现也提供了 .selected(actions) 这个等价别名。
模式提炼
模式:表单布局
View{width: Fill height: Fit flow: Down spacing: 12 padding: 20
View{width: Fill height: Fit flow: Right spacing: 8 align: Align{y: 0.5}
Label{text: "Name:" width: 80 draw_text.color: #x888}
input_name := TextInput{width: Fill height: Fit empty_text: "Enter name"}
}
View{width: Fill height: Fit flow: Right spacing: 8 align: Align{y: 0.5}
Label{text: "Volume:" width: 80 draw_text.color: #x888}
Slider{width: Fill text: "" min: 0. max: 100. default: 50.}
}
View{width: Fill height: Fit flow: Right spacing: 8
Filler{}
Button{text: "Submit" draw_bg.color: #x51cf66}
}
}
要素:flow: Down 垂直排列表单行,每行 flow: Right 水平排列标签+控件,Filler{} 将提交按钮推到右侧。
本章小结
| 组件 | 输入类型 | Splash 事件 | Rust 事件 |
|---|---|---|---|
| Button | 点击 | on_click | .clicked() |
| CheckBox | 开/关 | on_click | .changed() |
| Toggle | 开/关 | on_click | .changed() |
| RadioButton | 选择 | — | .clicked() / RadioButtonSet::selected() |
| Slider | 连续值 | on_change | .changed() |
| DropDown | 列表选择 | — | .changed() / .selected() |
下一章讲解列表组件——PortalList 虚拟化列表和 FlatList(详见第15章:列表与虚拟化)。
第15章:列表与虚拟化
为什么这很重要
第5章的 Todo 应用已经使用了 PortalList——Makepad 的虚拟化列表组件。当时我们把它当作黑盒使用。本章深入讲解 PortalList 的工作原理、配置选项和性能特性。
对于任何需要显示"可变长度数据"的应用——聊天记录、文件列表、搜索结果——PortalList 是核心组件。理解它的虚拟化机制,就能理解 Makepad 如何高效渲染上万条数据。
flowchart LR
A["数据: Vec<Item>"] -->|"set_item_range(0, N)"| B["PortalList"]
B -->|"next_visible_item()"| C["只创建可见区域的 Widget"]
C --> D["Item 0"]
C --> E["Item 1"]
C --> F["..."]
C --> G["Item 9"]
H["Item 10-999"] -->|"不创建"| I["节省内存和 GPU"]
style I fill:#ff6b6b,color:#fff
PortalList 基础
Splash 定义
list := PortalList{
width: Fill height: Fill
flow: Down
scroll_bar: ScrollBar{}
Item := CachedView{
// 行模板
RoundedView{width: Fill height: Fit ...}
}
Empty := CachedView{
// 空状态模板
View{align: Center Label{text: "No items"}}
}
}
改编自:examples/todo/src/main.rs:100-108
PortalList 的核心概念:
- 模板定义:
Item :=和Empty :=定义两种行模板 - 虚拟化:只创建可见区域内的行
- CachedView:每行被缓存为 GPU 纹理,滚动时直接使用缓存
- ScrollBar:内置滚动条支持
Rust 渲染循环
#![allow(unused)] fn main() { // 在 Widget::draw_walk 中 let todos = TODOS.read().unwrap(); list.set_item_range(cx, 0, todos.len()); while let Some(item_id) = list.next_visible_item(cx) { let item = list.item(cx, item_id, id!(Item)); let todo = &todos[item_id]; item.label(cx, ids!(label)).set_text(cx, &todo.text); item.draw_all_unscoped(cx); } }
来源:examples/todo/src/main.rs:223-235(简化)
三步:
set_item_range(0, N)告诉列表有 N 项数据next_visible_item()返回下一个需要渲染的可见项 IDlist.item(cx, item_id, id!(Item))获取(或创建)该项的 Widget 实例
PortalList 只对可见区域内的项调用 next_visible_item。如果列表有 10000 项但只有 20 项可见,只创建 20 个 Widget 实例。
事件处理
#![allow(unused)] fn main() { for (item_id, item) in list.items_with_actions(actions) { if item.check_box(cx, ids!(check)).changed(actions) { ... } if item.button(cx, ids!(delete)).clicked(actions) { ... } } }
来源:examples/todo/src/main.rs:314-319
items_with_actions 只遍历产生了用户操作的项,返回 (index, widget) 对。不需要遍历所有 10000 项——只处理有事件的那几项。
PortalList 配置
| 属性 | 说明 | 默认 |
|---|---|---|
flow | 列表方向 | Down |
scroll_bar | 滚动条配置 | 无 |
drag_scrolling | 触摸拖动滚动 | false |
auto_tail | 新项添加时自动滚动到底部 | false |
capture_overload | 捕获滚动溢出事件 | false |
selectable | 行是否可选中 | false |
来源:splash.md:790-791
auto_tail: true 特别适合聊天应用——新消息到达时列表自动滚到最新消息。
FlatList vs PortalList
| 特性 | PortalList | FlatList |
|---|---|---|
| 虚拟化 | 是(只渲染可见项) | 否(渲染所有项) |
| 性能 | O(可见项) | O(全部项) |
| 适用规模 | 无限 | < 100 项 |
| 模板 | Item := 声明 | 直接放子组件 |
| CachedView | 支持 | 不需要 |
| 滚动 | 内置 ScrollBar | 需外层 ScrollView |
FlatList 是简单的线性容器——所有子组件都同时存在。适合项数固定且少的场景(如设置页面的选项列表)。PortalList 是虚拟化的——动态创建和回收 Widget。适合数据驱动的长列表。
模式提炼
模式:数据-列表-事件三角
Vec<Data> ←→ PortalList ←→ MatchEvent
↑ | |
└────── redraw(cx) ←───────┘
数据在 Rust 中,列表在 Splash 中,事件在 Rust 中处理。修改数据后调用 redraw() 刷新列表。这是 Makepad 列表应用的标准架构(详见第5章 Todo 完整示例)。
本章小结
| 概念 | 说明 |
|---|---|
| PortalList | 虚拟化列表,只渲染可见项 |
| CachedView | 将行渲染为 GPU 纹理缓存 |
set_item_range | 声明数据范围 |
next_visible_item | 获取下一个需要渲染的项 |
items_with_actions | 获取有用户操作的项 |
| FlatList | 非虚拟化简单列表 |
下一章讲解高级容器——Dock、Modal、PageFlip、ExpandablePanel(详见第16章:高级容器)。
第16章:高级容器
为什么这很重要
前面的章节覆盖了基础容器(View、SolidView、RoundedView)和列表容器(PortalList)。但构建复杂应用还需要更高级的布局组件——标签页面板、模态弹窗、可折叠区域、分割面板。本章讲解 Makepad 的高级容器组件。
flowchart TD
A["高级容器"] --> B["Dock — 可拖拽面板系统"]
A --> C["Modal — 模态弹窗"]
A --> D["PageFlip — 页面切换"]
A --> E["ExpandablePanel — 可展开面板"]
A --> F["FoldHeader — 折叠区"]
A --> G["Splitter — 分割面板"]
A --> H["GlassPanel — 毛玻璃面板"]
Dock:可拖拽面板
Dock 是 Makepad Studio 使用的核心组件——一个可拖拽、可分割、可合并的面板系统。适合 IDE、仪表板、多窗口应用。
Dock 的配置比较复杂,通常在 Rust 侧用代码构建面板树。本书不深入 Dock 的完整 API——它是 Part V 架构篇的内容(详见第22章:事件与 Action 系统中的 Dock 事件处理)。
Modal:模态弹窗
Modal 在内容之上叠加一个半透明遮罩层和弹窗内容:
modal := Modal{
content: RoundedView{
width: 300 height: Fit
draw_bg.color: #x2a2a4e
draw_bg.radius: 12.
padding: 20 flow: Down spacing: 12 new_batch: true
Label{text: "Confirm Delete?" draw_text.color: #xfff draw_text.text_style.font_size: 16}
Label{text: "This action cannot be undone." draw_text.color: #x888}
View{flow: Right spacing: 8 height: Fit
Filler{}
Button{text: "Cancel"}
Button{text: "Delete" draw_bg.color: #xff4444}
}
}
}
Modal 的显示/隐藏通常在 Rust 侧通过 modal.open(cx) 和 modal.close(cx) 控制。
PageFlip:页面切换
PageFlip 在多个"页面"之间切换,同一时刻只显示一个页面:
page_flip := PageFlip{
active_page: page_home
page_home := View{
Label{text: "Home Page"}
}
page_settings := View{
Label{text: "Settings Page"}
}
}
在 Rust 侧切换页面:page_flip.set_active_page(cx, ids!(page_settings))。在 Splash 中可以用 on_render 条件渲染实现类似效果(详见第9章)。
ExpandablePanel:可展开面板
ExpandablePanel{
width: Fill height: Fit
header: View{height: 40 align: Align{y: 0.5}
Label{text: "Details" draw_text.color: #xfff}
}
body: View{height: Fit flow: Down padding: 12
Label{text: "Expanded content here"}
}
}
ExpandablePanel 内置展开/折叠动画(Forward 过渡)。
FoldHeader:折叠区
FoldHeader 和 ExpandablePanel 类似,但更轻量——通常用在设置页面的分组中:
FoldHeader{
header: View{height: Fit flow: Right align: Align{y: 0.5} spacing: 8
FoldButton{}
Label{text: "Advanced Settings"}
}
body: View{height: Fit flow: Down padding: Inset{left: 23} spacing: 8
CheckBox{text: "Enable debug mode"}
CheckBox{text: "Show FPS counter"}
}
}
来源:splash.md:774-785
FoldButton 是一个小三角图标,点击时旋转(通过 Animator)。
Splitter:分割面板
Splitter 将区域分为两个可调大小的部分:
Splitter{
axis: SplitterAxis.Horizontal
align: SplitterAlign.FromA(250.)
a := View{Label{text: "Left Panel"}}
b := View{Label{text: "Right Panel"}}
}
改编自:splash.md:762-768
| 属性 | 说明 |
|---|---|
axis | Horizontal(左右分割)/ Vertical(上下分割) |
align | FromA(px) / FromB(px) / Weighted(0.5) |
a / b | 两个子面板(用 := 命名) |
GlassPanel:毛玻璃面板
GlassPanel 创建一个带模糊效果的半透明面板,常用于 overlay 式的导航面板:
GlassPanel{
width: 300 height: Fill
// 内容
}
GlassPanel 的模糊效果是 GPU shader 级别的——在它后面的内容会被实时模糊。
模式提炼
模式:容器选择决策树
需要模态遮罩? → Modal
需要多页切换? → PageFlip 或 on_render 条件渲染
需要折叠展开? → FoldHeader(轻量)或 ExpandablePanel(带动画)
需要可调分割? → Splitter
需要可拖拽面板? → Dock
需要毛玻璃效果? → GlassPanel
对于简单的"显示/隐藏"需求,优先考虑 on_render 条件渲染(详见第9章)而非 Modal 或 PageFlip——条件渲染在纯 Splash 中就能工作,不需要 Rust 侧控制。
本章小结
| 容器 | 用途 | 复杂度 |
|---|---|---|
| Modal | 模态弹窗 | 中 |
| PageFlip | 页面切换 | 低 |
| ExpandablePanel | 展开/折叠 | 低 |
| FoldHeader | 轻量折叠 | 低 |
| Splitter | 可调分割 | 中 |
| GlassPanel | 毛玻璃面板 | 低 |
| Dock | 可拖拽面板系统 | 高 |
下一章讲解如何创建自定义 Widget——#[derive(Script, ScriptHook, Widget)]、Widget trait 和 Widget 生命周期(详见第17章:自定义 Widget)。
第17章:自定义 Widget
为什么这很重要
到目前为止,我们使用的都是 Makepad 内置的 Widget——View、Label、Button、PortalList。但实际应用中你总会遇到内置组件无法满足的需求:自定义图表、特殊交互模式、业务专属的 UI 组件。
Makepad 2.0 的 Widget 系统是可扩展的——你可以用 Rust 创建新的 Widget,然后在 Splash 中像内置组件一样使用它。第5章的 TodoList 就是一个自定义 Widget 的实例。
本章讲解创建自定义 Widget 的完整流程。
flowchart TD
A["#[derive(Script, ScriptHook, Widget)]"] --> B["实现 Widget trait"]
B --> C["draw_walk() — 渲染逻辑"]
B --> D["handle_event() — 事件处理"]
A --> E["在 script_mod! 中注册"]
E --> F["Splash 中使用 MyWidget{}"]
Widget trait 的核心方法
每个自定义 Widget 都需要实现 Widget trait 的两个核心方法:
#![allow(unused)] fn main() { impl Widget for TodoList { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // 渲染逻辑:在这里绘制 Widget 的视觉内容 } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { // 事件处理:在这里响应鼠标、键盘等事件 } } }
来源:examples/todo/src/main.rs:210-246(简化)
draw_walk:渲染
draw_walk 在每帧被调用(如果 Widget 需要重绘)。它接收:
cx: &mut Cx2d——2D 绘图上下文scope: &mut Scope——作用域数据(可传递父组件的数据)walk: Walk——布局信息(位置和尺寸)
返回 DrawStep::done() 表示渲染完成。
handle_event:事件
handle_event 在每个事件循环迭代中被调用。通常转发给内部的 View:
#![allow(unused)] fn main() { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); } }
创建自定义 Widget 的步骤
步骤一:定义 Rust 结构体
#![allow(unused)] fn main() { #[derive(Script, ScriptHook, Widget)] struct MyChart { #[deref] view: View, // 自定义字段 #[rust] data: Vec<f64>, } }
#[derive(Script, ScriptHook, Widget)]——让结构体既能作为 Widget 工作,也能被 Splash 运行时注册和驱动#[deref] view: View——委托基础的布局和绘制给内部的 View#[rust]——标记非 Splash 字段(不会出现在 Splash DSL 中)
步骤二:实现 Widget trait
#![allow(unused)] fn main() { impl Widget for MyChart { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // 使用 self.data 绘制图表 while let Some(step) = self.view.draw_walk(cx, scope, walk).step() { // 自定义渲染逻辑 } DrawStep::done() } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); } } }
步骤三:在 Splash 中注册
mod.widgets.MyChartBase = #(MyChart::register_widget(vm))
mod.widgets.MyChart = set_type_default() do mod.widgets.MyChartBase{
width: Fill height: 200
}
#(MyChart::register_widget(vm)) 告诉 Splash VM 有一个叫 MyChart 的新 Widget 类型。
步骤四:在 Splash 中使用
chart := mod.widgets.MyChart{
width: Fill height: 300
}
注册后,自定义 Widget 和内置 Widget 使用方式完全相同——支持属性设置、:= 命名、模板定义。
实例:TodoList 的 Widget 结构
回顾第5章的 TodoList——它是一个完整的自定义 Widget 实例:
#![allow(unused)] fn main() { #[derive(Script, ScriptHook, Widget)] struct TodoList { #[deref] view: View, } }
来源:examples/todo/src/main.rs:204-208
TodoList 的 draw_walk 从 TODOS 数据源读取数据,遍历 PortalList 的可见项,为每一项设置文字和样式(详见第5章的完整分析)。
它在 Splash 中被注册和使用:
mod.widgets.TodoListBase = #(TodoList::register_widget(vm))
mod.widgets.TodoList = set_type_default() do mod.widgets.TodoListBase{
list := PortalList{...}
}
// 使用
todo_list := mod.widgets.TodoList{}
来源:examples/todo/src/main.rs:96-109,174
Scope:父子组件通信
Scope 是 Makepad 中父组件向子组件传递数据的机制:
#![allow(unused)] fn main() { // 父组件传递数据 let scope = Scope::with_data(&mut my_data); child.draw_walk(cx, &mut scope, walk); // 子组件接收数据 fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { if let Some(data) = scope.data.get::<MyData>() { // 使用父组件传递的数据 } } }
来源:platform/script/src/apply.rs:41-47
Scope 是类型擦除的——通过 Any trait 实现。子组件需要知道父组件传递的具体类型才能 downcast_ref。
模式提炼
模式:Deref 委托
#![allow(unused)] fn main() { #[derive(Script, ScriptHook, Widget)] struct MyWidget { #[deref] view: View, } }
几乎所有自定义 Widget 都使用 #[deref] view: View 模式——将基础的布局、绘制、事件处理委托给内部的 View。你只需要在 draw_walk 和 handle_event 中添加自定义逻辑。
模式:数据驱动渲染
#![allow(unused)] fn main() { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { let data = DATA.read().unwrap(); // 读取全局数据 while let Some(step) = self.view.draw_walk(cx, scope, walk).step() { // 用 data 驱动渲染 } DrawStep::done() } }
Widget 在 draw_walk 中读取数据并渲染。数据修改后调用 redraw(cx) 触发重绘。这和第4章的"状态-更新-渲染"循环是相同的模式,只是发生在 Widget 内部。
本章小结
| 概念 | 说明 |
|---|---|
#[derive(Script, ScriptHook, Widget)] | 让 Rust 结构体既成为 Widget,又能被 Splash 注册与驱动 |
draw_walk() | Widget 的渲染入口 |
handle_event() | Widget 的事件入口 |
#[deref] view: View | 委托基础功能给内部 View |
register_widget(vm) | 在 Splash VM 中注册自定义 Widget |
Scope | 父子 Widget 间的数据传递 |
Part III(Widget 体系篇)到此完成。下一步进入 Part IV(渲染与 Shader 篇),讲解 Makepad 的 GPU 渲染管线和 Sdf2d Shader 编程。
第18章:Draw 管线
为什么这很重要
前面的章节讲解了如何使用 Widget 构建 UI,但 Widget 是如何被渲染到屏幕上的?Makepad 的 Draw 管线(Draw Pipeline)是连接 Widget 声明与 GPU 渲染之间的桥梁。理解这条管线,你才能写出高性能的自定义 Widget(详见第17章),理解 shader 的执行上下文(详见第19章)。
flowchart TB
A["Widget.draw_walk()"] --> B["Cx2d 上下文"]
B --> C["DrawQuad / DrawText 绘制原语"]
C --> D["DrawList2d 绘制列表"]
D --> E["DrawPass 绘制遍"]
E --> F["GPU 提交 → 屏幕"]
核心架构:三层上下文
Makepad 的绘制系统由三层嵌套的上下文对象组成,每层通过 Deref 向下委托:
flowchart LR
Cx2d -->|"Deref"| CxDraw -->|"Deref"| Cx
Cx 是全局状态容器,管理窗口、事件循环、全局资源。CxDraw 在每次 DrawEvent 时创建:
#![allow(unused)] fn main() { pub struct CxDraw<'a> { pub cx: &'a mut Cx, pub draw_event: &'a DrawEvent, pub(crate) pass_stack: Vec<PassStackItem>, // 渲染遍层级 pub draw_list_stack: Vec<DrawListId>, // 绘制列表层级 pub fonts: Rc<RefCell<Fonts>>, // 字体资源 } }
来源:draw/src/cx_draw.rs:23-31
CxDraw 在 Drop 时自动提交字体纹理更新——如果纹理未准备好,则触发全量重绘。
Cx2d 是 Widget 在 draw_walk 中直接使用的上下文,负责 2D 布局(Turtle,详见第12章)和绘制调用分组:
#![allow(unused)] fn main() { pub struct Cx2d<'a, 'b> { pub cx: &'b mut CxDraw<'a>, pub(crate) turtles: Vec<Turtle>, // 布局栈 pub(crate) draw_call_parent_stack: Vec<u64>, // 合批分组 } }
来源:draw/src/cx_2d.rs:12-24(简化)
绘制原语:DrawQuad 与 DrawText
所有 2D 可视元素最终都是四边形(Quad)。DrawQuad 是最基本的绘制原语:
mod.draw.DrawQuad = mod.std.set_type_default() do #(DrawQuad::script_shader(vm)){
vertex_pos: vertex_position(vec4f)
fb0: fragment_output(0, vec4f)
draw_call: uniform_buffer(draw.DrawCallUniforms)
draw_pass: uniform_buffer(draw.DrawPassUniforms)
draw_list: uniform_buffer(draw.DrawListUniforms)
geom: vertex_buffer(geom.QuadVertex, geom.QuadGeom)
vertex: fn() {
self.vertex_pos = self.clip_and_transform_vertex(self.rect_pos, self.rect_size)
}
fragment: fn(){
self.fb0 = depth_clip(self.world, self.pixel(), self.depth_clip)
}
pixel: fn(){ #0000 }
}
来源:draw/src/shader/draw_quad.rs:3-68
DrawColor 继承自 DrawQuad,只覆盖 pixel 函数输出纯色:
mod.draw.DrawColor = { ..mod.draw.DrawQuad pixel: fn(){ return vec4(self.color.rgb*self.color.a, self.color.a); } }
来源:draw/src/shader/draw_quad.rs:70-76
DrawText 处理字形渲染——每个字形是一个四边形,使用 SDF 纹理实现高质量缩放。它持有 grayscale_texture、color_texture、msdf_texture 三种字体纹理。
来源:draw/src/shader/draw_text.rs:30-78
DrawList:绘制列表
DrawList2d 是绘制命令的有序集合。每个 View{} 创建一个 DrawList:
flowchart TD
V["View (DrawList2d)"] --> DI1["DrawItem: 背景 DrawQuad"]
V --> DI2["DrawItem: 文本 DrawText"]
V --> DI3["DrawItem: 子 View (sub-list)"]
DI3 --> V2["子 View (DrawList2d)"]
核心操作:begin_always/begin_maybe 开始记录、end 结束记录、redraw 标记需重绘。
Cx2d::will_redraw 通过比较 Turtle 位置判断是否需要重绘——位置未变则跳过,这是避免全量重绘的关键。
来源:draw/src/draw_list_2d.rs:14-28,draw/src/cx_2d.rs:114-120
DrawPass 与合批
DrawPass 是一次完整的 GPU 渲染提交,持有根绘制列表、DPI 缩放、相机矩阵。
为减少 GPU draw call 数量,Makepad 通过 draw_call_parent_stack 将连续的同类型绘制原语合批:
#![allow(unused)] fn main() { pub fn push_draw_call_parent(&mut self) { let id = self.draw_call_parent_next; self.draw_call_parent_next = self.draw_call_parent_next.wrapping_add(1).max(2); self.draw_call_parent_stack.push(id); } }
来源:draw/src/cx_2d.rs:57-61
同一分组内、相同 shader 的连续绘制实例会被合并为一个 ManyInstances 批次——实例数据打包到同一 vertex buffer,GPU 一次绘制。背景和内容在不同 lane 中,避免 z-order 混乱。
完整绘制流程
sequenceDiagram
participant App as 应用
participant CxDraw as CxDraw
participant Cx2d as Cx2d
participant DL as DrawList2d
participant GPU as GPU
App->>CxDraw: DrawEvent 触发,创建 CxDraw
CxDraw->>Cx2d: Cx2d::new()
Cx2d->>DL: begin_always()
Note over Cx2d,DL: Widget.draw_walk() 执行
Cx2d->>DL: DrawQuad/DrawText 实例写入
DL->>Cx2d: end()
CxDraw->>GPU: 提交所有 DrawPass
GPU-->>App: 帧缓冲 → 屏幕
- DrawEvent 触发——平台层请求重绘
- CxDraw/Cx2d 创建——初始化字体纹理、Turtle 布局栈
- Widget 树遍历——每个 Widget 的
draw_walk写入绘制原语 - 合批优化——同类绘制实例合并
- GPU 提交——每个 DrawPass 提交到平台 GPU API(Metal/D3D11/WebGL)
- 字体纹理更新——CxDraw drop 时提交字体图集变更
模式提炼
模式:三层 Deref 链
Cx2d → CxDraw → Cx
在 draw_walk 中拿到 Cx2d,就可以直接调用 Cx 上的任何方法——无需手动穿透层级。
模式:pixel 函数覆盖
mod.draw.MyDraw = { ..mod.draw.DrawQuad pixel: fn(){ ... } }
所有 2D 渲染的核心模式——vertex 逻辑共享,pixel 逻辑自定义。
模式:脏区检测避免全量重绘
DrawList 通过 dirty_check_rect 比较位置变化,结合 begin_maybe 的条件执行,大部分静态 UI 在每帧中被跳过。
本章小结
| 概念 | 说明 |
|---|---|
Cx2d | 2D 绘制上下文,Widget 直接使用 |
CxDraw | 绘制事件上下文,管理 pass 和字体 |
DrawQuad | 最基本的四边形绘制原语 |
DrawText | 文本绘制原语,使用 SDF 字体纹理 |
DrawList2d | 绘制命令的有序列表 |
DrawPass | 一次 GPU 渲染提交 |
ManyInstances | 实例化合批,减少 draw call |
理解了 Draw 管线后,下一章将深入 pixel 函数内部,讲解 Makepad 强大的 Sdf2d Shader 编程系统。
第19章:Sdf2d Shader 编程
为什么这很重要
上一章讲解了 Draw 管线,管线的最后一步是 pixel 函数。Makepad 提供了 Sdf2d(Signed Distance Field 2D)API,让你在 GPU 上用类似绘图 API 的方式描述形状。RoundedView 的圆角、Button 的按下效果、Slider 的轨道——所有内置 Widget 的视觉外观都由 Sdf2d 实现。
flowchart LR
A["pixel: fn()"] --> B["Sdf2d::viewport()"]
B --> C["形状原语<br>circle / box / rect"]
C --> D["布尔运算<br>union / intersect / subtract"]
D --> E["着色操作<br>fill / stroke / glow"]
E --> F["vec4 颜色输出"]
Shader 结构与变量类型
DrawQuad 的 fragment 调用 pixel(),pixel() 返回 vec4 颜色。Shader 中有四种变量类型:
| 类型 | 声明 | 说明 |
|---|---|---|
| instance | instance(default) | 每个实例不同(rect_pos、color),Splash 可直接设置 |
| uniform | uniform(type) | 同一 draw call 共享,系统自动填充 |
| varying | varying(type) | vertex → fragment 插值传递 |
| texture | texture_2d(type) | GPU 纹理采样 |
Sdf2d API
Sdf2d 通过距离场描述形状——对每个像素计算"到最近形状边界的有符号距离",据此决定填充或描边。
初始化与基本流程
pixel: fn(){
let sdf = Sdf2d::viewport(self.pos * self.rect_size);
sdf.circle(50., 50., 30.); // 定义形状
sdf.fill(#f80); // 着色
return sdf.result; // 输出
}
形状原语
来源:draw/src/shader/sdf.rs:150-500
| 函数 | 说明 |
|---|---|
sdf.circle(x, y, r) | 圆形 |
sdf.box(x, y, w, h, radius) | 圆角矩形(最常用) |
sdf.box_all(x, y, w, h, r_lt, r_rt, r_rb, r_lb) | 独立四角圆角 |
sdf.rect(x, y, w, h) | 无圆角矩形 |
sdf.hexagon(x, y, r) | 六边形 |
sdf.hline(y, h) | 水平线 |
sdf.arc_round_caps(x, y, r, start, end, thickness) | 圆端弧线 |
sdf.arc_flat_caps(x, y, r, start, end, thickness) | 平端弧线 |
路径绘制:sdf.move_to(x, y) → sdf.line_to(x, y) → sdf.close_path()
着色操作
| 函数 | 说明 |
|---|---|
sdf.fill(color) | 填充并清除形状 |
sdf.fill_keep(color) | 填充但保留形状(可继续 stroke) |
sdf.stroke(color, width) | 描边并清除形状 |
sdf.stroke_keep(color, width) | 描边但保留形状 |
sdf.glow(color, width) | 外发光效果 |
来源:draw/src/shader/sdf.rs:215-274
布尔运算
sdf.circle(50., 50., 30.);
sdf.circle(70., 50., 30.);
sdf.union(); // 并集
sdf.fill(#f00);
| 运算 | 效果 |
|---|---|
union() | 并集——形状合并 |
intersect() | 交集——只保留重叠区域 |
subtract() | 差集——从旧形状中减去新形状 |
gloop(k) | 平滑并集——圆滑过渡 |
blend(k) | 混合——在两个形状间插值(适合动画) |
来源:draw/src/shader/sdf.rs:276-300
变换
sdf.translate(x, y) // 平移
sdf.rotate(angle, cx, cy) // 绕点旋转
sdf.scale(factor, cx, cy) // 绕点缩放
辅助工具
Pal(调色板):Pal.premul(color) 预乘 alpha、Pal.hsv2rgb(hsva) 色彩空间转换、Pal.iq0..iq7(t) 程序化渐变色。
GaussShadow:GaussShadow.rounded_box_shadow(lower, upper, point, sigma, corner) 和 box_shadow 实现 GPU 高斯模糊阴影。
来源:draw/src/shader/sdf.rs:11-148
完整示例:RoundedView 的 draw_bg
draw_bg: {
pixel: fn(){
let sdf = Sdf2d::viewport(self.pos * self.rect_size);
sdf.box(
self.border_width,
self.border_width,
self.rect_size.x - self.border_width * 2.0,
self.rect_size.y - self.border_width * 2.0,
max(1.0, self.border_radius)
);
sdf.fill_keep(self.color);
if self.border_width > 0.0 {
sdf.stroke(self.border_color, self.border_width);
}
return sdf.result;
}
}
每个属性(border_width、border_radius、color)都是 instance 变量,可在 Splash 中设置或通过 Animator 动画化(详见第10章)。
模式提炼
模式:viewport + shape + fill 三步法
let sdf = Sdf2d::viewport(self.pos * self.rect_size);
sdf.circle(x, y, r); // 1. 定义形状
sdf.fill(color); // 2. 着色
return sdf.result; // 3. 输出
几乎所有 Makepad shader 都遵循这个三步模式。多个形状按顺序叠加——后绘制的在上面。
模式:fill_keep + stroke 组合
sdf.box(x, y, w, h, r);
sdf.fill_keep(bg_color); // 填充但不清除形状
sdf.stroke(border_color, border_width);// 同一形状上描边
fill_keep 保留距离场,后续 stroke 可在同一形状上描边。用 fill(不带 keep)则会重置距离场。
模式:instance 变量驱动外观
Button{ draw_bg: { color: #f00, border_radius: 8.0 } }
Shader 中的 instance 变量可从 Splash DSL、Rust 代码和 Animator 三个入口驱动,比传统 CSS 更灵活。
本章小结
| 概念 | 说明 |
|---|---|
Sdf2d::viewport | 初始化距离场,传入像素坐标 |
circle / box / rect | 基础形状原语 |
fill / stroke / glow | 着色操作 |
union / intersect / subtract | 布尔运算组合形状 |
blend(k) | 两形状间插值,适合动画 |
Pal / GaussShadow | 调色板和阴影辅助工具 |
instance 变量 | 可从 DSL/Rust/Animator 设置的 shader 参数 |
SDF shader 解决了"形状怎么画"。下一章讲解矢量图形(Vector Widget),用三角化方式绘制复杂的 SVG 路径和动画。
第20章:矢量图形
为什么这很重要
第19章的 Sdf2d 适合 UI 控件的简单形状。但当你需要绘制图标、插画、数据可视化——SVG 路径有数十个控制点,用 SDF 描述代价太高。Makepad 的 Vector Widget 将 SVG 风格的矢量图形声明式地写在 Splash 中,引擎将路径三角化(tessellation)后交给 GPU 渲染。
flowchart TD
A["Splash 声明<br>Vector{ Path{...} }"] --> B["SVG 解析"]
B --> C["三角化 tessellate"]
C --> D["DrawVector shader → GPU"]
D --> E["屏幕输出"]
基本用法
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}
Line{x1: 10 y1: 150 x2: 190 y2: 150 stroke: #fff stroke_width: 2}
}
来源:splash.md Vector Widget 章节
Vector{} 属性:width/height 控制布局尺寸,viewbox: vec4(min_x min_y w h) 定义内部坐标系。
形状元素
| 形状 | 关键属性 |
|---|---|
Rect | x, y, w, h, rx, ry |
Circle | cx, cy, r |
Ellipse | cx, cy, rx, ry |
Line | x1, y1, x2, y2 |
Polyline / Polygon | points: "x,y x,y ..." |
Path | d: "M10 80 Q50 10 100 80" |
通用样式
| 属性 | 说明 |
|---|---|
fill | 颜色 / Gradient / false(不填充) |
stroke / stroke_width | 描边色和宽度 |
opacity / fill_opacity / stroke_opacity | 透明度 |
stroke_linecap | "butt" / "round" / "square" |
stroke_linejoin | "miter" / "round" / "bevel" |
transform | 变换(见下文) |
filter | 滤镜引用 |
Path:SVG 路径
Path{d: "M 10 80 Q 52 10 95 80 T 180 80" fill: #f0f stroke: #fff stroke_width: 1.5}
常用命令:M(移动)、L(直线)、H/V(水平/垂直线)、Q(二次贝塞尔)、C(三次贝塞尔)、A(弧线)、Z(闭合)。路径由 parse_path_data 解析后传入三角化引擎。
来源:draw/src/svg/mod.rs re-export 的 makepad_svg::path_data::parse_path_data
Group 与变换
Vector{width: 200 height: 200 viewbox: vec4(0 0 200 200)
Group{opacity: 0.7 transform: Rotate{deg: 15}
Rect{x: 20 y: 20 w: 60 h: 60 fill: #f00}
Circle{cx: 130 cy: 50 r: 30 fill: #0f0}
}
}
变换类型:Translate{x: y:}、Rotate{deg:}、Scale{x: y:}。组合变换用数组:
transform: [Translate{x: 36.8 y: 11.4} Scale{x: 7.6 y: 7.6}]
来源:draw/src/svg/render.rs:28-41
Gradient:渐变
线性渐变:
let my_grad = Gradient{x1: 0 y1: 0 x2: 1 y2: 1
Stop{offset: 0 color: #ff0000}
Stop{offset: 0.5 color: #00ff00}
Stop{offset: 1 color: #0000ff}
}
Vector{... Rect{x: 0 y: 0 w: 200 h: 100 fill: my_grad} }
径向渐变:
let radial = RadGradient{cx: 0.5 cy: 0.5 r: 0.5
Stop{offset: 0 color: #fff} Stop{offset: 1 color: #000}
}
渐变在 GPU 端通过纹理行实现——DrawVector 的 gradient_texture 纹理在渲染前由 add_gradient_row 填充。
来源:draw/src/shader/draw_vector.rs:18,draw/src/svg/render.rs:54-56
Filter 与 Tween
DropShadow 滤镜:
let shadow = Filter{ DropShadow{dx: 2 dy: 4 blur: 6 color: #000 opacity: 0.5} }
Vector{... Rect{... filter: shadow} }
阴影通过 DrawVector shader 中的 erf7/blur_step 高斯近似实现(详见第19章 GaussShadow)。
Tween 属性动画——动画化几乎任何数值/颜色属性:
Path{d: Tween{dur: 2.0 loop_: true
values: ["M 10 80 Q 50 10 100 80" "M 10 80 Q 50 150 100 80"]
} fill: #f0f}
Circle{cx: 50 cy: 50 r: 30
fill: Tween{dur: 1.5 loop_: true from: #ff0000 to: #0000ff}}
Tween 属性:from/to(起止值)、values(关键帧数组)、dur(秒)、begin(延迟)、loop_(循环)、calc(插值方式)。动画由 render_svg 的 time 参数驱动。
来源:splash.md Tween 章节,draw/src/svg/render.rs:19-27
完整示例:应用图标
let glass_bg = Gradient{x1: 0 y1: 0 x2: 1 y2: 1
Stop{offset: 0 color: #x556677 opacity: 0.45}
Stop{offset: 1 color: #x334455 opacity: 0.35}
}
let brain_grad = Gradient{x1: 0.5 y1: 0 x2: 0.5 y2: 1
Stop{offset: 0 color: #x77ccff}
Stop{offset: 0.75 color: #x8866dd}
Stop{offset: 1 color: #x9944cc}
}
let icon_shadow = Filter{DropShadow{dx: 0 dy: 4 blur: 6 color: #x000000 opacity: 0.5}}
Vector{width: 256 height: 256 viewbox: vec4(0 0 256 256)
Rect{x: 16 y: 16 w: 224 h: 224 rx: 44 ry: 44 fill: glass_bg filter: icon_shadow}
Group{transform: [Translate{x: 36.8 y: 11.4} Scale{x: 7.6 y: 7.6}]
Path{d: "M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8"
fill: false stroke: brain_grad stroke_width: 0.35
stroke_linecap: "round" stroke_linejoin: "round"}
}
}
来源:splash.md Complete Example 章节
渲染架构
render_svg 递归遍历 SvgDocument 树,对每个节点调用三角化并写入 DrawVector 顶点缓冲区:
#![allow(unused)] fn main() { for node in nodes { match node { SvgNode::Path(path) => render_path(dv, path, defs, parent_xf, time, grad_map), SvgNode::Rect(rect) => render_rect(dv, rect, defs, parent_xf, time, grad_map), SvgNode::Circle(circ) => render_circle(dv, circ, defs, parent_xf, time, grad_map), // ... } } }
来源:draw/src/svg/render.rs:62-80
Sdf2d 与 Vector 对比
| 维度 | Sdf2d(第19章) | Vector(本章) |
|---|---|---|
| 渲染方式 | 距离场,逐像素计算 | 三角化,多边形光栅化 |
| 适用场景 | 简单形状、UI 控件 | 复杂路径、图标、插画 |
| 动画 | instance + Animator | Tween 属性动画 |
| 渐变 | 需手动 shader | 内置 Gradient / RadGradient |
模式提炼
模式:let 变量预定义资源
let my_grad = Gradient{...}
let my_filter = Filter{...}
Vector{... Rect{fill: my_grad filter: my_filter} }
渐变和滤镜用 let 预定义后被多个形状共享,避免重复。
模式:Group 组合变换
Group{transform: [Translate{x: 36.8 y: 11.4} Scale{x: 7.6 y: 7.6}]
Path{d: "M..."} Path{d: "M..."}
}
多个路径需统一缩放或定位时,用 Group 包裹而非逐个修改坐标。
模式:Tween 驱动属性动画
Tween 可附加在 fill、stroke、d、opacity 等属性上,引擎每帧自动插值。这与第10章的 Animator 是不同的动画系统——Animator 驱动 Widget 状态,Tween 驱动矢量图形属性。
本章小结
| 概念 | 说明 |
|---|---|
Vector{} | 矢量图形根容器 |
Path{d: "..."} | SVG 路径数据 |
Gradient / RadGradient | 线性/径向渐变 |
Filter{DropShadow{...}} | 阴影滤镜 |
Group{transform: ...} | 分组 + 共享变换 |
Tween{dur: from: to:} | 属性动画 |
DrawVector | 矢量图形的 GPU shader |
Vector 和 Sdf2d 共同构成 Makepad 的 2D 渲染能力。下一章进入第三维度——3D 场景渲染。
第21章:3D 场景
为什么这很重要
前三章讲解了 Makepad 的 2D 渲染体系。Makepad 同样具备 3D 渲染能力,虽然在 UI 应用中使用频率较低,但在产品展示、数据可视化、游戏原型等场景中有用。如果你的项目不涉及 3D,可以跳过本章。
flowchart TD
A["SceneState3D<br>相机 + 投影"] --> B["Cx3d 上下文"]
B --> C["DrawCube / DrawPbr / DrawText3d"]
C --> D["GPU 渲染"]
核心架构
Cx3d:3D 绘制上下文
与 2D 的 Cx2d 对应(详见第18章),Cx3d 通过相同的 Deref 链委托到 CxDraw 和 Cx:
#![allow(unused)] fn main() { pub struct Cx3d<'a, 'b> { pub cx: &'b mut CxDraw<'a>, scene_3d: Cx3dState, } }
来源:draw/src/cx_3d.rs:11-14
SceneState3D:场景状态
#![allow(unused)] fn main() { pub struct SceneState3D { pub time: f64, // 场景时间(动画用) pub camera_pos: Vec3f, // 相机位置 pub view: Mat4f, // 视图矩阵(世界 → 相机) pub projection: Mat4f, // 投影矩阵(相机 → 裁剪) pub viewport_rect: Rect, // 视口矩形 } }
来源:draw/src/scene_3d.rs:7-13
SceneScope3D:场景作用域
渲染过程中追踪世界变换和绘制调用锚点:
#![allow(unused)] fn main() { pub struct SceneScope3D { pub scene: SceneState3D, pub world_transform: Mat4f, pub draw_call_anchors: Vec<SceneDrawCallAnchor>, } }
来源:draw/src/scene_3d.rs:24-28
3D 场景生命周期
sequenceDiagram
participant W as Widget
participant Cx3d as Cx3d
participant GPU as GPU
W->>Cx3d: begin_scene_3d(state)
W->>Cx3d: set_scene_world_transform_3d(mat)
W->>Cx3d: DrawCube / DrawPbr 绘制
W->>Cx3d: end_scene_3d()
Cx3d->>GPU: 提交绘制命令
begin_scene_3d设置相机和投影set_scene_world_transform_3d设置模型矩阵(返回旧矩阵用于恢复)- 调用 DrawCube/DrawPbr 等绘制原语
end_scene_3d结束场景
来源:draw/src/cx_3d.rs:44-67
3D 绘制原语
DrawCube:立方体
基本的 Lambert 光照立方体:
mod.draw.DrawCube = {
backface_culling: true
geom: vertex_buffer(geom.CubeVertex, geom.CubeGeom)
vertex: fn() {
let pos = self.get_size() * self.geom.geom_pos + self.get_pos();
let model_view = self.draw_list.view_transform * self.transform;
let normal = normalize((model_view * vec4(self.geom.geom_normal, 0.0)).xyz);
self.world = model_view * vec4(pos, 1.0);
let dp = max(dot(normal, normalize(self.light_dir)), 0.0);
self.lit_color = self.get_color(dp);
self.vertex_pos = self.draw_pass.camera_projection
* (self.draw_pass.camera_view * self.world);
}
pixel: fn() { return self.lit_color; }
}
来源:draw/src/shader/draw_cube.rs:10-60(简化)
光照模型:环境光 28% + 漫反射 72%,通过法线与 light_dir 点积计算。
DrawPbr:PBR 材质
基于物理的渲染,支持完整纹理集:
#![allow(unused)] fn main() { pub struct DrawPbrMaterialState { pub base_color_factor: Vec4f, pub metallic_factor: f32, pub roughness_factor: f32, pub emissive_factor: Vec3f, pub textures: DrawPbrTextureSet, // base_color, metallic_roughness, normal, occlusion, emissive, env } }
来源:draw/src/shader/draw_pbr.rs:26-34
支持的基础网格:Cube、Sphere、Capsule、Surface、RoundedCube,通过 PbrPrimitiveMeshKey 参数化生成。
DrawText3d
3D 空间中的文本渲染,使用与 2D DrawText 相同的 SDF 字体技术。
3D 拾取:DrawCallAnchor
为支持 3D 空间中的鼠标交互,每个绘制调用可注册锚点:
#![allow(unused)] fn main() { pub struct SceneDrawCallAnchor { pub area: Area, pub draw_list_id: Option<DrawListId>, pub draw_item_id: Option<usize>, pub world_pos: Vec3f, } }
来源:draw/src/scene_3d.rs:15-21
2D 事件系统(详见第22章)通过锚点将鼠标事件映射到 3D 对象,避免昂贵的射线-三角形相交测试。
2D 与 3D 的关系
flowchart TD
CxDraw["CxDraw"] --> Cx2d["Cx2d<br>2D: DrawQuad, DrawText, DrawVector"]
CxDraw --> Cx3d["Cx3d<br>3D: DrawCube, DrawPbr, DrawText3d"]
Cx2d --> Pass["DrawPass → GPU"]
Cx3d --> Pass
Cx2d 和 Cx3d 从同一个 CxDraw 派生,绘制命令汇入同一 DrawPass。2D 和 3D 内容可以在同一 pass 中共存——例如 3D 场景上叠加 2D UI。
| 维度 | 2D(Cx2d) | 3D(Cx3d) |
|---|---|---|
| 布局 | Turtle 引擎 | 手动矩阵变换 |
| 光照 | 无(SDF 模拟) | Lambert / PBR |
| DSL 支持 | 完整 Splash 集成 | 主要 Rust 代码 |
模式提炼
模式:Cx2d / Cx3d ��行架构
Makepad 将 2D 和 3D 分为并行上下文,共享底层 CxDraw/Cx,但各自维护独立状态。
模式:保存/恢复变换矩阵
#![allow(unused)] fn main() { let previous = cx3d.set_scene_world_transform_3d(new_transform); // ... 绘制子节点 ... cx3d.set_scene_world_transform_3d(previous.unwrap()); }
经典的 save/restore 模式,支持层级化场景图的递归构建。
模式:锚点注册实现 3D 拾取
3D 对象通过 SceneDrawCallAnchor(世界坐标 + Area)让 2D 事件系统可以定位它们,避免实时射线相交测试。
本章小结
| 概念 | 说明 |
|---|---|
Cx3d | 3D 绘制上下文,与 Cx2d 并行 |
SceneState3D | 相机、视图/投影矩阵 |
DrawCube | 立方体,Lambert 光照 |
DrawPbr | PBR 材质,完整纹理集 |
SceneDrawCallAnchor | 3D 拾取锚点 |
Part IV(渲染与 Shader 篇)到此完成。从 Draw 管线到 SDF Shader,从矢量图形到 3D 场景,全面覆盖了 Makepad 的渲染体系。下一章进入 Part V(底层机制篇),深入事件与 Action 的内部实现。
第22章:事件与 Action 系统
Makepad 的事件分发管线与 Action 通信机制是整个框架的神经系统。 本章深入 Rust 层面的实现,与第9章所述的 Splash 脚本事件形成互补。
22.1 Event 枚举全景
Makepad 将所有平台输入统一为一个 Event 枚举,定义于 platform/src/event/event.rs。
该枚举涵盖六大类事件:
| 类别 | 典型变体 | 说明 |
|---|---|---|
| 生命周期 | Startup, Shutdown, Foreground, Background, Resume, Pause | 应用全局状态转换 |
| 窗口管理 | WindowGotFocus, WindowClosed, WindowGeomChange | 窗口级别事件 |
| 指针输入 | MouseDown, MouseMove, MouseUp, TouchUpdate, Scroll | 原始输入,需经 Hit 转换 |
| 键盘/文本 | KeyDown, KeyUp, TextInput, TextCopy | 键盘与 IME 输入 |
| 媒体/网络 | AudioDevices, NetworkResponses, VideoPlaybackPrepared | 异步资源事件 |
| 框架内部 | Draw, NextFrame, Timer, Signal, Actions | 渲染循环与内部通信 |
#![allow(unused)] fn main() { // platform/src/event/event.rs (核心结构,约 240 行) pub enum Event { Startup, Shutdown, Foreground, Background, Resume, Pause, Draw(DrawEvent), MouseDown(MouseDownEvent), MouseMove(MouseMoveEvent), MouseUp(MouseUpEvent), TouchUpdate(TouchUpdateEvent), KeyDown(KeyEvent), Actions(ActionsBuf), // ... 共 60+ 变体 } }
关键设计:每个输入事件携带 Cell<Area> 类型的 handled 字段,允许 Widget 通过
内部可变性标记"已处理",阻止事件继续冒泡。
22.2 从原始事件到 Hit:命中测试管线
开发者不应直接匹配 MouseDown 等原始事件。Makepad 提供 Event::hits() 方法,
将原始坐标与 Widget 的绘制区域(Area)做命中测试,返回高层 Hit 枚举:
#![allow(unused)] fn main() { // platform/src/event/event.rs pub enum Hit { KeyFocus(KeyFocusEvent), KeyFocusLost(KeyFocusEvent), KeyDown(KeyEvent), KeyUp(KeyEvent), FingerScroll(FingerScrollEvent), FingerDown(FingerDownEvent), FingerMove(FingerMoveEvent), FingerHoverIn(FingerHoverEvent), FingerHoverOver(FingerHoverEvent), FingerHoverOut(FingerHoverEvent), FingerUp(FingerUpEvent), FingerLongPress(FingerLongPressEvent), Nothing, } }
flowchart TD
OS["OS 原始事件<br/>MouseDown / TouchUpdate"] --> EV["Event 枚举"]
EV --> HT["Event::hits(cx, area)"]
HT --> |命中| HIT["Hit::FingerDown / FingerMove / ..."]
HT --> |未命中| NOTHING["Hit::Nothing"]
HIT --> WH["Widget::handle_event()"]
WH --> ACT["cx.action(MyAction)"]
ACT --> PARENT["父 Widget handle_actions()"]
hits() 内部从 Cx 的绘制列表中查找 Area 对应的矩形区域,并与事件坐标比较。
这使得命中测试与渲染顺序天然一致,无需额外的布局查询。
22.3 Action 系统:Widget 间通信
Action 是 Makepad 的"向上通信"机制,与事件的"向下分发"方向相反。
ActionTrait 与类型擦除
#![allow(unused)] fn main() { // platform/src/action.rs pub trait ActionTrait: 'static { fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result; fn ref_cast_type_id(&self) -> TypeId; } pub type Action = Box<dyn ActionTrait>; pub type ActionsBuf = Vec<Action>; pub type Actions = [Action]; }
ActionTrait 的巧妙之处:它为所有 'static + Debug 类型自动实现(blanket impl),
因此任何满足条件的枚举都能直接作为 Action 使用,无需手动 impl。
发送与接收
| 方法 | 调用位置 | 线程安全 | 说明 |
|---|---|---|---|
cx.action(val) | Widget 内部 | 否(主线程) | 同步入队 |
Cx::post_action(val) | 后台线程 | 是(Send) | 通过 mpsc::Sender + SignalToUI |
cx.capture_actions(f) | 父 Widget | 否 | 捕获子树产生的 Action |
cx.extend_actions(buf) | 父 Widget | 否 | 重新注入被捕获的 Action |
#![allow(unused)] fn main() { // platform/src/action.rs pub fn post_action(action: impl ActionTrait + Send) { ACTION_SENDER_GLOBAL .lock().unwrap() .as_mut().unwrap() .send(Box::new(action)).unwrap(); SignalToUI::set_action_signal(); } }
post_action 通过全局 Mutex<Option<Sender<ActionSend>>> 实现跨线程发送,
配合 SignalToUI 唤醒主线程的事件循环。
Action 向下转型
#![allow(unused)] fn main() { // 使用 ActionCast trait 安全转型 impl<T: ActionTrait + Default + Clone> ActionCast<T> for Box<dyn ActionTrait> { fn cast(&self) -> T { if let Some(item) = (*self).downcast_ref::<T>() { item.clone() } else { T::default() // 类型不匹配时返回默认值 } } } }
这种"不匹配返回默认值"的设计避免了 Option 层层嵌套,使 match 分支更简洁。
22.4 MatchEvent:事件分发的便捷 Trait
MatchEvent trait(draw/src/match_event.rs)将巨大的 Event 枚举解构为
独立的回调方法:
#![allow(unused)] fn main() { // draw/src/match_event.rs pub trait MatchEvent { 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_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); } } fn handle_network_responses(&mut self, cx: &mut Cx, e: &NetworkResponsesEvent) { // 自动分发为 handle_http_response / handle_http_stream 等 } // ... 20+ 生命周期/输入/媒体回调方法 } }
开发者只需 impl MatchEvent for App,覆盖感兴趣的方法即可,
其余均为空默认实现。handle_network_responses 还会自动将网络事件细分为
handle_http_response、handle_http_stream 等更具体的回调。
22.5 事件分发完整流程
sequenceDiagram
participant OS as 平台层 (macOS/Win/Linux)
participant CX as Cx 事件循环
participant APP as App::handle_event
participant WDG as Widget::handle_event
participant ACT as Action 队列
OS->>CX: MouseDown / KeyDown / Signal
CX->>CX: 合并 post_action 队列
CX->>APP: event: &Event
APP->>WDG: 向下分发 (widget.handle_event)
WDG->>WDG: event.hits(cx, self.area)
WDG-->>ACT: cx.action(ButtonClicked)
CX->>CX: 收集 new_actions
CX->>APP: Event::Actions(actions_buf)
APP->>APP: MatchEvent::handle_actions
整个流程分两个阶段:
- 事件阶段:OS 事件向下分发到 Widget 树,Widget 通过
hits()判断是否命中 - Action 阶段:Widget 产生的 Action 被收集后,以
Event::Actions形式再次分发
22.6 map_actions 与 capture_actions
Cx 提供两个高级 API 用于 Action 流的拦截与变换:
#![allow(unused)] fn main() { // platform/src/action.rs - 捕获子树 Action,阻止传播到外部 pub fn capture_actions<F>(&mut self, f: F) -> ActionsBuf { let mut actions = Vec::new(); std::mem::swap(&mut self.new_actions, &mut actions); f(self); std::mem::swap(&mut self.new_actions, &mut actions); actions } // 对子树产生的 Action 做变换后重新注入 pub fn map_actions<F, G, R>(&mut self, f: F, g: G) -> R { let start = self.new_actions.len(); let r = f(self); let end = self.new_actions.len(); if start != end { let buf = self.new_actions.drain(start..end).collect(); let buf = g(self, buf); self.new_actions.extend(buf); } r } }
典型用例:Modal 组件捕获内部 Action,根据对话框状态决定是否释放到外层。
22.7 与 Splash 事件系统的关系
详见第9章关于 Splash on_click、on_render 等脚本事件的说明。
Rust 层的 Event/Action 系统与 Splash 脚本事件通过 ScriptHook 桥接:
| Rust 层 | Splash 层 | 桥接方式 |
|---|---|---|
Event::MouseDown -> Hit::FingerDown | on_click 回调 | ScriptHook 在 handle_event 中触发脚本求值 |
cx.action() | mod.state 更新 | 脚本状态变更后触发 redraw() |
ui.view.render() 脚本调用 | on_render 回调 | View::script_call(render) 异步执行脚本并以 reload 方式应用结果 |
模式提炼
| 模式 | 描述 | 源码位置 |
|---|---|---|
| 类型擦除 Action | Box<dyn ActionTrait> + blanket impl,零样板代码 | platform/src/action.rs |
| Hit 命中测试 | 将坐标空间匹配委托给渲染系统,避免布局重查 | platform/src/event/event.rs |
| Cell 标记消费 | Cell<Area> / Cell<bool> 实现无 &mut 的事件消费标记 | platform/src/event/finger.rs |
| capture/extend | mem::swap 实现零拷贝 Action 流截获 | platform/src/action.rs |
| 跨线程 post_action | Mutex<Sender> + SignalToUI 唤醒主线程 | platform/src/action.rs |
本章小结
Makepad 的事件系统遵循"向下分发、向上反馈"的单向数据流:
Event枚举统一了 60+ 种平台事件,通过hits()转化为 Widget 可处理的HitActionTrait利用 Rust 的 blanket impl 实现零样板的类型擦除通信MatchEventtrait 将庞大的事件枚举分解为独立回调,降低认知负担capture_actions/map_actions提供了 Action 流的拦截与变换能力- 跨线程通信通过
post_action+SignalToUI安全桥接到主线程
详见第26章了解各平台如何将 OS 原始事件注入此管线。
第23章:Splash VM 内幕
Splash 是 Makepad 2.0 的运行时脚本语言,其 VM 架构决定了热重载与流式求值的性能上限。 本章深入字节码、操作码、解析器与执行模型,比第6章的语言概览更底层。
23.1 VM 总体架构
Splash VM 是一个基于栈的字节码虚拟机,核心组件分布在 platform/script/src/:
| 文件 | 行数 | 职责 |
|---|---|---|
vm.rs | ~1250 | VM 主体:ScriptVm、ScriptBody、执行循环 |
parser.rs | ~4265 | 递归下降解析器,源码直接发射字节码 |
opcode.rs | ~407 | 操作码定义与编码格式 |
opcodes_*.rs | - | 各类操作码的执行实现(ops/vars/calls/control/loops/assign) |
tokenizer.rs | - | 词法分析器 |
value.rs | - | 值表示(NaN boxing) |
flowchart LR
SRC["Splash 源码"] --> TOK["Tokenizer<br/>词法分析"]
TOK --> PAR["Parser<br/>递归下降"]
PAR --> BC["字节码<br/>(Opcode, OpcodeArgs)"]
BC --> VM["VM 执行循环<br/>栈机器"]
VM --> HEAP["ScriptHeap<br/>堆内存"]
VM --> NAT["Native<br/>Rust 绑定"]
23.2 操作码设计
每条指令由 (Opcode, OpcodeArgs) 二元组构成。Opcode 为单字节(最多 256 条),
OpcodeArgs 为 32 位,高 4 位编码参数类型:
OpcodeArgs 布局:
31 30 29 28 | 27 ........... 0
NIL POP -- T | value (28 bit)
| 位域 | 含义 |
|---|---|
| bit 31 | NEED_NIL_FLAG - 需要 nil 检查 |
| bit 30 | POP_TO_ME_FLAG - 弹栈至 me |
| bit 29-28 | TYPE_MASK - 参数类型:00=None, 01=Nil, 10=Number |
| bit 27-0 | 无符号数值(跳转偏移、变量索引等) |
约 130 条操作码分为:算术逻辑(0-24)、赋值(25-65,覆盖 3 种目标 x 12 种运算符)、 对象/数组构造(66-72)、函数调用(73-83)、控制流与循环(84-113)、变量访问(86-98)。
逻辑运算使用专用短路操作码:LOGIC_AND_TEST(若 falsy 跳过)、
LOGIC_OR_TEST(若 truthy 跳过)、NIL_OR_TEST(?? 运算符)。
23.3 解析器:无 AST 的直接发射
parser.rs 是 Splash 最核心的文件。其设计跳过 AST 构建,
通过约 30 种状态驱动的递归下降直接发射字节码:
#![allow(unused)] fn main() { // platform/script/src/parser.rs enum State { BeginStmt { last_was_sep: bool }, BeginExpr { required: bool }, EndExpr, IfTest { index: u32 }, ForIdent { idents: u32, index: u32 }, Loop { index: u32 }, While { index: u32 }, // ... 约 30 种状态 } }
三重优势:零 AST 分配(减少 GC 压力)、支持流式求值(边解析边执行)、
增量编译(ParserCheckpoint 保存状态,新代码追加时从断点继续)。
23.4 ScriptVm 核心结构
#![allow(unused)] fn main() { // platform/script/src/vm.rs pub struct ScriptVm { pub bx: Box<ScriptVmInner>, // 堆分配以减少栈帧大小 } // ScriptVmInner 包含:heap, threads, code, builtins, native }
每个编译单元对应一个 ScriptBody,包含 tokenizer、parser、scope 对象和 checkpoint。
ScriptSource 类型里同时定义了 Mod 与 Streaming 变体;但当前 eval_with_append_source() 这条流式 UI 主路径实际是在复用 ScriptSource::Mod 对应的 body,并在其上做增量 tokenize / parse。
23.5 执行线程与值表示
协作式多线程(ScriptThreads),每个线程维护:操作数栈(stack)、
词法作用域链(scopes)、方法接收者栈(mes)、循环状态(loops)、
错误处理(trap)。
Splash 使用 NaN boxing 将所有值编码为 64 位 f64。数值运算零拷贝, 对象引用编码在 NaN 有效载荷中,栈操作仅为 64 位 push/pop。 详见第24章。
23.6 函数调用协议
调用流程:CALL_ARGS 压栈参数并创建 ScriptMe::Call 帧 -> CALL_EXEC 跳转到目标函数 ->
函数体执行 -> RETURN 弹出作用域,返回值留在栈顶。
Native 函数通过 ScriptNative 注册:
#![allow(unused)] fn main() { native.add_method(heap, gc, id_lut!(set_static), script_args!(value = NIL), |vm, args| { let value = script_value!(vm, args.value); vm.bx.heap.set_static(value); value }, ); }
VM 将参数从栈中取出打包为 ScriptArgs,调用 Rust 闭包,返回值推回栈顶。
23.7 流式求值
flowchart TD
LLM["LLM 输出 token 流"] --> APP["追加到 effective_code"]
APP --> CK{"有 checkpoint?"}
CK --> |是| RESTORE["恢复 Parser 状态"]
CK --> |否| INIT["初始化 Parser"]
RESTORE --> PARSE["继续解析新增部分"]
INIT --> PARSE
PARSE --> EMIT["发射新字节码"]
EMIT --> EXEC["立即执行"]
EXEC --> RENDER["UI 增量更新 + 保存 checkpoint"]
RENDER --> |等待新 token| LLM
checkpoint 保存的是 parser 侧的恢复点,用于撤销上一次 auto-close 并继续解析;tokenizer 的增量位置则通过 source_len 和已缓存 token 序列管理。
新代码段从上次停止处继续解析,无需重解析整个文件。也是第11章的深入主题。
23.8 与 Shader 编译的分岔
当解析器遇到 FN_BODY_TYPED(带完整类型标注的函数),执行路径从 VM 字节码
切换到 GPU 着色语言生成,翻译为目标语言字符串片段而非操作码。详见第25章。
模式提炼
| 模式 | 描述 | 源码位置 |
|---|---|---|
| 无 AST 直接发射 | 解析器直接输出字节码,省去中间表示 | parser.rs |
| NaN Boxing | 64 位值编码,数值/指针共用一个 word | value.rs |
| 检查点恢复 | ParserCheckpoint 支持断点续解析 | vm.rs, parser.rs |
| 操作码参数编码 | 32 位高 4 位类型标记 + 低 28 位数值 | opcode.rs |
| 短路操作码 | LOGIC_AND_TEST/LOGIC_OR_TEST 合并测试与跳转 | opcode.rs |
| 双路径函数 | FN_BODY_DYN 走 VM,FN_BODY_TYPED 走 Shader 编译 | parser.rs |
本章小结
Splash VM 的设计哲学是"为流式求值而生":
- 单字节
Opcode+ 32 位OpcodeArgs的紧凑指令格式,约 130 条操作码覆盖完整语义 - 递归下降解析器通过约 30 种状态驱动,直接发射字节码,跳过 AST 构建
- 基于栈的执行模型配合 NaN boxing 值表示,单次指令分发仅需 64 位操作
ParserCheckpoint使 LLM 流式输出可以逐段解析执行,UI 实时更新- 函数调用区分动态(VM 执行)和静态(Shader 编译)两条路径
详见第24章了解 GC 与堆内存管理,第25章了解 Shader 编译路径。
第24章:GC 与内存管理
Splash VM 的垃圾回收器是整个脚本引擎的基石。 本章深入堆管理、GC 根、标记-清除算法及六种堆类型的设计。
24.1 ScriptHeap:六种堆的统一管理
Splash 的堆由六种独立的 GenVec 组成,每种管理一类值(platform/script/src/heap.rs):
#![allow(unused)] fn main() { pub struct ScriptHeap { pub modules: ScriptObject, // 全局模块根 pub(crate) objects: GenVec<ScriptObjectData>, // 对象堆 pub(crate) arrays: GenVec<ScriptArrayData>, // 数组堆 pub(crate) strings: GenVec<Option<ScriptStringData>>, // 字符串堆 pub(crate) pods: GenVec<ScriptPodData>, // Pod 类型堆 (vec2/mat4 等) pub(crate) handles: GenVec<Option<ScriptHandleData>>, // 外部句柄堆 pub(crate) regexes: GenVec<Option<ScriptRegexData>>, // 正则表达式堆 // 每种堆对应的空闲列表 pub(crate) objects_free: Vec<ScriptObject>, // ... arrays_free, strings_free, pods_free, handles_free, regexes_free // GC 根 pub(crate) root_objects: Rc<RefCell<HashMap<ScriptObject, usize>>>, pub(crate) root_arrays: Rc<RefCell<HashMap<ScriptArray, usize>>>, pub(crate) root_handles: Rc<RefCell<HashMap<ScriptHandle, usize>>>, pub(crate) string_intern: HashMap<ScriptRcString, ScriptString>, // 字符串驻留 pub(crate) mark_vec: Vec<ScriptGcMark>, // GC 标记工作列表 } }
flowchart TB
HEAP["ScriptHeap"]
HEAP --> OBJ["objects<br/>GenVec"]
HEAP --> ARR["arrays<br/>GenVec"]
HEAP --> STR["strings<br/>GenVec + 驻留表"]
HEAP --> POD["pods<br/>GenVec<br/>(vec2, mat4, color)"]
HEAP --> HDL["handles<br/>GenVec<br/>(Rust 外部对象)"]
HEAP --> REX["regexes<br/>GenVec"]
HEAP --> ROOT["GC 根<br/>root_objects / root_arrays / root_handles"]
分六种堆的理由:类型安全(每种引用只能索引对应堆)、GC 效率(按类型扫描)、 空间局部性(同类连续存储)、独立空闲列表。
24.2 GenVec:带代际检查的向量
GenVec(gen_index.rs)是一个带代际号的向量。每次 free_slot 递增槽位代际,
使旧引用的索引操作会因代际不匹配而 panic,从而检测 use-after-free。
24.3 对象标记系统
每个堆对象有一个 Tag,编码多种状态位:
| 标记位 | 含义 |
|---|---|
alloced | 已分配(区分空槽与活跃对象) |
marked | GC 标记阶段已标记 |
static | 永久对象,不参与 GC |
reffed | 被 ScriptObjectRef 持有 |
frozen | 不可变对象 |
Static 对象通过 heap.set_static(value) 递归标记整个对象图为永久存在,
GC 时直接跳过。适合全局模块和内建原型。
24.4 GC 根
标记阶段的起点包括:
| 根类型 | 说明 |
|---|---|
root_objects/arrays/handles | Rust 侧持有的 ScriptObjectRef(引用计数管理) |
| 线程栈 | 操作数栈、作用域栈、me 栈、循环源、trap 值 |
| 代码体 | ScriptBody 中的 scope/me 对象 |
| 类型系统 | type_check 原型、type_defaults、pod_types 默认值 |
| 词法字符串 | tokenizer 中的字符串字面量 |
| native 类型表 | ScriptNative 注册的类型对象 |
ScriptObjectRef 采用引用计数式根管理:创建时计数 +1 并插入 root_objects,
drop 时计数 -1,归零移除。
24.5 标记阶段
标记使用工作列表(mark_vec)驱动的迭代式遍历,避免递归栈溢出:
#![allow(unused)] fn main() { // platform/script/src/gc.rs pub fn mark(&mut self, threads: &ScriptThreads, code: &ScriptCode) { self.mark_vec.clear(); // 1. 收集所有根到 mark_vec(type_check、root_objects、线程栈...) // 2. 工作列表循环 let mut i = 0; while i < self.mark_vec.len() { let mark = self.mark_vec[i]; self.mark_inner(mark); // 追加新可达对象到 mark_vec i += 1; } } }
mark_inner 对 Object 标记 proto 链、map 键值、vec 键值;对 Array 标记每个元素。
通过 mark_value_fields! 宏的分裂借用技巧解决同时遍历和修改不同堆的借用冲突。
24.6 清除阶段
遍历每种堆的全部槽位,回收未标记对象:
#![allow(unused)] fn main() { pub fn sweep(&mut self, log_stats: bool) { for i in 1..self.objects.len() { let obj = &mut self.objects.get_at_mut(i); if obj.tag.is_static() { obj.tag.clear_mark(); continue; } if !obj.tag.is_marked() && obj.tag.is_alloced() { obj.clear(); self.objects.free_slot(i as u32); // 递增代际 let new_gen = self.objects.generation(i); self.objects_free.push(ScriptObject::new(i as u32, new_gen)); } else { obj.tag.clear_mark(); } } // 对 arrays, strings, pods, handles, regexes 执行相同逻辑 } }
特殊处理:字符串回收时从驻留表移除,并将 String 堆内存缓存到 strings_reuse
供复用;Handle 回收时调用 gc() 回调通知 Rust 侧释放外部资源。
flowchart LR
subgraph 标记阶段
ROOT["收集 GC 根"] --> MARK["工作列表遍历<br/>mark_inner()"]
MARK --> |追加可达对象| MARK
end
subgraph 清除阶段
SWEEP["逐堆扫描 6 种堆"] --> FREE["未标记? 释放 + 入空闲列表"]
end
标记阶段 --> 清除阶段
24.7 GC 触发与统计
ScriptHeapGcLast 记录上轮各堆存活量。某种堆的分配量超过上次存活量 2 倍时
自动触发 GC,或通过脚本 gc.run() 手动触发。
清除后打印统计:GC: 120us obj(S:45 A:123 R:67) arr(...) str(...) ...
(S=static、A=alive、R=removed)。
24.8 脚本 API
通过 mod_gc.rs 暴露给 Splash:
gc.set_static(my_object) // 标记为永久对象
gc.run() // 手动触发 GC
gc.run_status() // 触发 GC 并打印统计
gc.dump_tag(my_object) // 调试:打印对象 tag 信息
GC 在事件循环安全点触发(所有线程栈状态稳定),避免增量 GC 的复杂性。 详见第23章了解线程栈如何作为 GC 根参与标记。
模式提炼
| 模式 | 描述 | 源码位置 |
|---|---|---|
| 分类堆 | 六种值类型各自独立的 GenVec 堆 | heap.rs |
| 代际检查 | GenVec 通过 generation 检测悬空引用 | gen_index.rs |
| 三态标记 | alloced/marked/static 三态系统 | gc.rs |
| 工作列表遍历 | 迭代式 mark_vec 替代递归 | gc.rs |
| 分裂借用宏 | mark_value_fields! 解决多堆借用冲突 | gc.rs |
| 字符串驻留复用 | 回收时缓存 String 堆内存到 strings_reuse | gc.rs |
| 引用计数根 | ScriptObjectRef 的 Rc-based 根管理 | heap.rs |
本章小结
Splash 的 GC 系统围绕"分类堆 + 标记-清除"展开:
- 六种独立
GenVec堆,每种有独立空闲列表与回收逻辑 GenVec代际检查在索引时检测悬空引用,提供额外安全保障- 标记阶段从多种根出发,工作列表迭代遍历,
mark_value_fields!宏解决借用冲突 - 清除按堆类型扫描,字符串有驻留表清理,Handle 有回调通知
- Static 标记为永久存在,GC 跳过;在事件循环安全点触发
详见第23章了解 VM 值表示与堆交互,第25章了解 Shader 编译中的类型堆使用。
第25章:Shader 编译器
Makepad 的 Shader 编译器将 Splash 中的类型化函数直接编译为 Metal、HLSL、GLSL 和 WGSL 四种 GPU 着色语言。本章深入编译管线、后端架构与 IO 系统。
25.1 设计哲学:一次书写,四端运行
传统 GPU 框架要求用目标着色语言编写 Shader,或使用 SPIR-V 等中间表示。 Makepad 选择了不同路径:用 Splash 语言编写 Shader,编译器生成四种目标代码。
Shader 代码与 UI 逻辑共存于 script_mod! 中,享受类型系统和热重载。
flowchart TB
SPLASH["Splash 类型化函数<br/>fn pixel(self) -> vec4 { ... }"]
SPLASH --> PARSE["Parser (FN_BODY_TYPED)"]
PARSE --> SO["ShaderOutput 中间表示"]
SO --> METAL["Metal 后端<br/>shader_metal.rs ~600 行"]
SO --> HLSL["HLSL 后端<br/>shader_hlsl.rs ~591 行"]
SO --> GLSL["GLSL 后端<br/>shader_glsl.rs ~835 行"]
SO --> WGSL["WGSL 后端<br/>shader_wgsl.rs ~1118 行"]
25.2 编译入口
详见第23章,解析器区分 FN_BODY_DYN(VM 字节码)和 FN_BODY_TYPED(Shader 路径)。
后者不生成操作码,而是将表达式翻译为目标语言字符串片段。
Shader 编译使用独立的 ShaderMe 栈追踪上下文:
#![allow(unused)] fn main() { // platform/script/src/shader.rs pub enum ShaderMe { FnBody { ret: Option<ScriptPodType>, escaped: bool, stack_depth: usize }, LoopBody { stack_depth: usize }, ForLoop { var_id: LiveId, stack_depth: usize }, IfBody { phi: Option<String>, // SSA phi 变量(分支汇合点) phi_type: Option<ShaderType>, has_return: bool, if_branch_returned: bool, // ... }, LogicOp { op: &'static str, first_operand: String, first_type: ShaderType }, BuiltinCall { name: LiveId }, } }
注意 IfBody 的 phi 字段:Shader 不支持动态返回值,编译器在 if-else 汇合点
生成 SSA 风格的 phi 变量。
25.3 ShaderBackend 与 IO 映射
#![allow(unused)] fn main() { // platform/script/src/shader_backend.rs pub enum ShaderBackend { Metal, Wgsl, Hlsl, Glsl, Rust } }
每种后端将 IO 类型映射为不同的变量前缀:
| IO 类型 | Metal | HLSL | GLSL | WGSL |
|---|---|---|---|---|
RUST_INSTANCE | _io.i-> | 输入结构 | attribute | storage buffer |
DYN_UNIFORM | _io.u-> | cbuffer | uniform buffer | uniform |
VARYING | _iov.v-> | 语义 | varying | struct 字段 |
TEXTURE_2D | _io. | texture | sampler2D | texture_2d |
VERTEX_POSITION | _iov.v->_position | SV_Position | gl_Position | @builtin(position) |
25.4 四大后端
Metal (macOS / iOS)
shader_metal.rs 生成 Metal IO 结构体(Io { constant IoUniform *u; thread IoInstance *i; })
和辅助函数(如矩阵求逆 _mp_inverse,MSL 标准库不提供)。
HLSL (Windows / D3D11)
shader_hlsl.rs 需要区分整型/浮点输入格式,矩阵按 D3D 输入布局分块
(mat3 -> 3xvec3, mat4 -> 4xvec4)。
GLSL (Linux / Android / WebGL)
shader_glsl.rs 将 varying 变量打包为 vec4 数组以减少插值器使用,
收集 geometry/instance/varying 字段后计算打包偏移。
WGSL (WebGPU)
shader_wgsl.rs 是最复杂的后端(~1118 行),WGSL 要求显式 binding 编号:
#![allow(unused)] fn main() { pub struct WgslDrawShaderSource { pub wgsl: String, pub dyn_uniform_binding: u32, pub texture_binding_base: u32, pub sampler_binding_base: u32, pub geometry_slots: usize, pub instance_slots: usize, } }
25.5 类型系统与安全输出
Shader 编译依赖 Splash 的 Pod 类型系统(详见第24章):
| Splash 类型 | GLSL | Metal | HLSL | WGSL |
|---|---|---|---|---|
vec2 | vec2 | float2 | float2 | vec2<f32> |
vec4 | vec4 | float4 | float4 | vec4<f32> |
mat4 | mat4 | float4x4 | float4x4 | mat4x4<f32> |
u32 | uint | uint | uint | u32 |
浮点常量输出有安全处理,防止极大/极小值破坏 Shader 解析器:
#![allow(unused)] fn main() { fn write_shader_float(out: &mut String, v: f64) { let abs_v = v.abs(); if abs_v != 0.0 && (abs_v >= 1e15 || abs_v < 1e-6) { write!(out, "{:e}", v).ok(); // 科学计数法 } else { write!(out, "{}", v).ok(); // 确保有小数点:1 → 1.0(避免 "1f" 这样的无效 Metal/GLSL) if !out[start..].contains('.') { out.push_str(".0"); } } } }
25.6 纹理与采样器
支持 9 种纹理类型(1D/2D/3D/Cube 及其数组变体 + 深度纹理), 每种后端有对应的类型名称映射:
#![allow(unused)] fn main() { pub struct ShaderSampler { pub filter: SamplerFilter, // Nearest / Linear pub address: SamplerAddress, // Repeat / ClampToEdge / ClampToZero / MirroredRepeat pub coord: SamplerCoord, // Normalized / Pixel pub is_video: bool, } }
25.7 编译流程总结
sequenceDiagram
participant S as Splash 源码
participant P as Parser
participant SC as ShaderFnCompiler
participant B as Backend (Metal/HLSL/GLSL/WGSL)
S->>P: fn pixel(self) -> vec4 { ... }
P->>P: 识别 FN_BODY_TYPED
P->>SC: 逐语句翻译 + 类型推导
SC->>SC: 累积到 ShaderOutput
SC->>B: 根据平台选择后端
B->>B: 生成 IO 结构 + 辅助函数 + 主函数体
B-->>S: 完整 Shader 源码字符串
模式提炼
| 模式 | 描述 | 源码位置 |
|---|---|---|
| 统一源语言 | Splash 函数可编译为 VM 字节码或 GPU 着色器 | shader.rs |
| 后端枚举 | 5 种后端通过模式匹配分发 IO 映射 | shader_backend.rs |
| IO 前缀抽象 | ShaderIoPrefix 统一不同后端的变量访问 | shader_backend.rs |
| Phi 变量 | if-else 分支汇合使用 SSA 风格 phi 节点 | shader.rs |
| 安全浮点输出 | write_shader_float 防止极值破坏解析 | shader.rs |
| Varying 打包 | GLSL 将多个 varying 打包为 vec4 数组 | shader_glsl.rs |
本章小结
Makepad 的 Shader 编译器实现了"一次书写,四端运行":
FN_BODY_TYPED在解析阶段分流到 Shader 路径,直接翻译为目标着色语言ShaderOutput作为中间表示收集 IO 声明、采样器和函数体代码- 四种后端(Metal/HLSL/GLSL/WGSL)各自实现 IO 结构生成、类型映射和辅助函数注入
- 编译器依赖 Splash 的 Pod 类型堆(详见第24章),类型映射通过后端枚举分发
详见第19章了解 SDF Shader 的上层 API,第26章了解各平台如何加载编译后的 Shader。
第26章:跨平台层
Makepad 在五大平台上实现原生渲染。本章剖析平台抽象层的架构: 每个平台如何接入事件循环、选择 GPU 后端、并将 OS 事件转换为统一
Event。
26.1 平台目录结构
跨平台代码组织在 platform/src/os/ 下,采用"共享核心 + 平台特化"模式:
platform/src/os/
mod.rs # cfg 条件编译选择平台
cx_native.rs # 平台无关的 Cx 扩展
cx_shared.rs # 共享工具函数
apple/ # macOS + iOS + tvOS 共享
metal.rs # Metal GPU 后端
apple_sys.rs # 系统 API 绑定
macos/ ios/ tvos/ # 平台特化子目录
windows/ # Windows 平台
d3d11.rs # D3D11 GPU 后端
win32_app.rs # Win32 应用框架
win32_event.rs # 事件转换
linux/ # Linux + Android + OpenHarmony
opengl.rs # OpenGL 后端
vulkan.rs # Vulkan 后端
x11/ wayland/ # 窗口系统
android/ # Android 特化
open_harmony/ # OpenHarmony 特化
web/ # WASM 平台
web.rs web.js # 主桥接
web_gl.rs web_gl.js # WebGL 后端
from_wasm.rs # JS → Rust 消息
to_wasm.rs # Rust → JS 消息
headless/ # 无头模式(CI/测试)
26.2 平台与 GPU 后端对应关系
flowchart TB
subgraph 平台
MAC["macOS"] --> MTL
IOS["iOS / tvOS"] --> MTL
WIN["Windows"] --> D3D
LNX["Linux"] --> GL
LNX --> VK
AND["Android"] --> GL
WEB["Web (WASM)"] --> WGL
end
subgraph GPU后端
MTL["Metal<br/>apple/metal.rs"]
D3D["D3D11<br/>windows/d3d11.rs"]
GL["OpenGL<br/>linux/opengl.rs"]
VK["Vulkan<br/>linux/vulkan.rs"]
WGL["WebGL<br/>web/web_gl.rs"]
end
subgraph Shader语言
MTL --> S1["MSL"]
D3D --> S2["HLSL"]
GL --> S3["GLSL"]
WGL --> S3
end
| 平台 | GPU 后端 | Shader 语言 | 窗口系统 |
|---|---|---|---|
| macOS | Metal | MSL | NSWindow |
| iOS / tvOS | Metal | MSL | UIWindow |
| Windows | D3D11 | HLSL | Win32 HWND |
| Linux | OpenGL / Vulkan | GLSL | X11 / Wayland / Direct |
| Android | OpenGL ES | GLSL | NativeActivity |
| Web | WebGL | GLSL | Canvas |
详见第25章了解 Shader 编译器如何为每种后端生成代码。
26.3 Apple 平台
Apple 三平台共享 apple/metal.rs(Metal 渲染)和 apple_sys.rs(Objective-C 绑定),
通过 macos/、ios/、tvos/ 子目录特化窗口管理和生命周期。
Metal 后端负责:创建 MTLDevice/MTLCommandQueue、管理纹理图集、
编译 MSL Shader、执行绘制命令。
事件路径:NSEvent (mouseDown:) -> MouseDownEvent { abs, button, modifiers } -> Event::MouseDown。iOS 使用 UITouch -> TouchUpdateEvent 路径。
平台特有模块:apple_media.rs(AVFoundation)、apple_webview.rs(WKWebView)、
core_midi.rs、av_capture.rs(摄像头)、apple_game_input.rs(手柄)。
26.4 Windows 平台
d3d11.rs 封装 D3D11 API(设备、交换链、HLSL 编译、常量缓冲区)。
win32_app.rs 实现消息泵,win32_event.rs 做事件转换:
WM_LBUTTONDOWN → Event::MouseDown
WM_MOUSEMOVE → Event::MouseMove
WM_KEYDOWN → Event::KeyDown
WM_SIZE → Event::WindowGeomChange
angle.rs 提供 ANGLE OpenGL ES 兼容层,用于替代 D3D11 的场景。
26.5 Linux 平台
最复杂的平台,需支持多种窗口系统和 GPU 后端:
- 窗口系统:
x11/(传统)、wayland/(现代)、direct/(无窗口系统/嵌入式) - GPU 后端:OpenGL(默认)、Vulkan(可选,通过
vulkan.rs+vulkan_naga.rs) - Android 子平台:
android/目录,NativeActivity 生命周期 + EGL 上下文, 与 Linux 共享 OpenGL 后端 - OpenXR 支持:
openxr.rs/openxr_input.rs/openxr_opengl.rs/openxr_vulkan.rs提供 VR/AR 渲染
26.6 Web 平台
通过 Rust -> WASM 编译 + JS 胶水代码实现双向通信:
Rust (to_wasm.rs) → 序列化消息 → JS (web.js) → DOM API / WebGL
JS (from_wasm.rs) ← 序列化消息 ← Rust (web.rs) ← 事件回调
核心文件:web.js(事件监听/窗口管理)、web_gl.js(WebGL API 调用)、
web_audio.rs、web_midi.rs、web_socket.rs、web_network.rs(Fetch API)。
26.7 事件统一层
各平台将 OS 原始事件转换为统一 Event 枚举(详见第22章):
| Makepad Event | macOS | Windows | Android | Web |
|---|---|---|---|---|
Startup | didFinishLaunching | WM_CREATE | onCreate | DOMContentLoaded |
Foreground | didBecomeActive | WM_ACTIVATEAPP | onStart | visibilitychange |
Background | willResignActive | WM_ACTIVATEAPP(0) | onStop | visibilitychange |
Resume | (同 Foreground) | WM_SETFOCUS | onResume | focus |
Pause | (同 Background) | WM_KILLFOCUS | onPause | blur |
Shutdown | willTerminate | WM_DESTROY | onDestroy | beforeunload |
26.8 无头模式与设计取舍
headless/ 提供无窗口运行模式(CI 测试、基准测试),跳过 GPU 初始化但执行完整事件循环。
支持 --no-draw 禁用绘制、--draws=N 限制帧数。
关键设计取舍:
| 决策 | 选择 | 理由 |
|---|---|---|
| 窗口系统 | 自建,不用 winit | 更好控制事件循环和渲染集成 |
| FFI 方式 | 直接 sys 绑定 | 减少间接层,更灵活 |
| Shader 策略 | 源码级翻译 | 比 SPIR-V 更可调试,无工具链依赖 |
| GPU 后端数量 | 4+1 | 覆盖所有主流平台的最优 API |
模式提炼
| 模式 | 描述 | 源码位置 |
|---|---|---|
| cfg 条件编译 | mod.rs 按 target_os 选择平台实现 | platform/src/os/mod.rs |
| 共享核心 | Apple 三平台共享 metal.rs 等核心文件 | platform/src/os/apple/ |
| 消息桥接 | Web 通过 to_wasm/from_wasm 双向序列化 | platform/src/os/web/ |
| 后端枚举分发 | ShaderBackend + GpuBackend 运行时选择 | shader_backend.rs |
| 统一事件入口 | 所有平台转换为同一 Event 枚举 | platform/src/event/event.rs |
| 自建窗口管理 | 不依赖 winit 等第三方库 | 各平台 app/window 文件 |
本章小结
Makepad 的跨平台架构通过"共享核心 + 平台特化"实现五大平台支持:
- Apple(macOS/iOS/tvOS)共享 Metal 后端和 Objective-C 桥接
- Windows 使用 D3D11 + Win32 API
- Linux 支持 X11/Wayland/Direct 三种窗口系统和 OpenGL/Vulkan 两种 GPU 后端, Android/OpenHarmony 作为子平台共享 OpenGL
- Web 通过 WASM + JS 胶水代码桥接 DOM/WebGL
- 所有平台转换为统一
Event(详见第22章),Shader 按后端生成(详见第25章) - 自建窗口管理和 FFI 绑定,保持对事件循环和渲染集成的完全控制
第27章:Canvas 架构剖析
为什么这很重要
前面 26 章构建了 Makepad 2.0 的完整技术基础——Splash 语言、Widget 体系、渲染引擎、事件系统。现在是把这些知识汇聚起来的时候。Canvas 是 Makepad 2.0 "AI-Native" 设计理念的完整实现——一个通过 WebSocket/HTTP 接收 Splash 代码并实时渲染的原生应用画布。
Canvas 的核心定义:它是一个纯渲染器,不是状态容器。 AI Agent 负责生成 Splash 代码,Canvas 负责渲染和回传事件。所有业务逻辑(状态管理、交互响应、定时器)都在 Splash 代码中——不在 Canvas 的 Rust 代码中。
flowchart LR
A["AI Agent<br/>(Claude Code)"] -->|"WS/HTTP<br/>发送 Splash"| B["Canvas<br/>(渲染器)"]
B -->|"WS<br/>回传事件"| A
B --> C["屏幕<br/>(原生渲染)"]
style B fill:#51cf66,color:#111
三线程架构
Canvas 的内部架构是三个线程的协作:
flowchart TD
subgraph T1["TCP Listener (tokio)"]
L1["接受 WS/HTTP 连接"]
L2["解析命令"]
L3["入队 CanvasCommand"]
end
subgraph T2["Makepad UI (主线程)"]
U1["handle_signal()"]
U2["出队 CanvasCommand"]
U3["Splash.set_text() / stream_begin() / stream_append()"]
U4["渲染 Widget 树"]
U5["路由按钮事件"]
end
subgraph T3["Audio Device"]
A1["PCM 采样"]
A2["FFT 频谱分析"]
A3["SignalToUI 通知"]
end
L3 -->|"SignalToUI"| U1
A3 -->|"SignalToUI"| U1
U5 -->|"send_event()"| L1
style T1 fill:#2a2a4e,color:#fff
style T2 fill:#1a3a2e,color:#fff
style T3 fill:#3a2a1e,color:#fff
来源:tools/canvas/src/app.rs, tools/canvas/src/ws/stdio_bridge.rs
线程 1(TCP Listener):tokio 异步运行时,监听 127.0.0.1:{random_port}。接受 WebSocket 和 HTTP 请求,解析为 CanvasCommand,入队到共享的 Mutex<VecDeque<CanvasCommand>>。端口号写入 /tmp/makepad-canvas.port 供外部程序发现。
线程 2(UI 主线程):Makepad 的事件循环。通过 SignalToUI 被线程 1 唤醒,从队列中取出命令,调用 Splash Widget 的 set_text() 或流式求值函数渲染 UI。同时处理用户交互(按钮点击),将事件名称通过 send_event() 广播给 WS 客户端。
线程 3(Audio Device):系统音频输出回调。每 ~4 个音频回调(约 15Hz)执行 FFT 频谱分析,将 16 波段数据写入 AudioPlaybackState(详见第30章)。
三个线程之间的同步:
Arc<Mutex<VecDeque>>存储命令队列Arc<AtomicBool/U64>存储音频状态(无锁)SignalToUI从工作线程唤醒 UI 线程(零分配唤醒机制)
核心数据结构
CanvasCommand:命令协议
#![allow(unused)] fn main() { pub enum CanvasCommand { SplashRender { code: String }, // 替换整个面板 SplashStreamBegin, // 开始流式渲染 SplashStreamAppend { code: String }, // 追加代码片段 SplashStreamEnd, // 结束流式渲染 SplashEval { code: String }, // 求值 Splash 表达式 AudioPlay { url: String }, // 播放音频 AudioPause, AudioStop, AudioToggle, SaveApp { name: String }, // 保存当前应用 ConnectionState { connected: bool }, // 连接状态变化 } }
来源:tools/canvas/src/ws/types.rs:1-33
最重要的四个命令是 SplashRender(批量渲染)和 SplashStreamBegin/Append/End(流式渲染)。它们对应第11章讲解的两种求值模式。
双协议支持
Canvas 同时支持 WebSocket 和 HTTP:
| HTTP 端点 | 对应命令 |
|---|---|
POST /splash body=code | SplashRender |
POST /splash/stream 空 body | SplashStreamBegin |
POST /splash/stream body=chunk | SplashStreamAppend |
POST /splash/end | SplashStreamEnd |
POST /clear | 清空画布 |
POST /audio/play body=url | AudioPlay |
GET /event | 轮询按钮事件 |
GET /ping | 健康检查 |
来源:tools/canvas/CLAUDE.md
WebSocket 使用 JSON 消息:{"splash": "..."}, {"audio": {"play": "url"}}, 接收 {"event": "click", "widget": "name"}。
为什么需要双协议? WS 适合持久连接和双向通信(AI Agent 的首选),HTTP 适合一次性调试(curl -X POST 快速测试)。
事件路由:uid_map
当用户点击 Canvas 中的按钮时,Canvas 需要知道"这是哪个按钮"——然后把按钮名称发送给 AI Agent。
Canvas 通过 uid_map 实现这个映射:
Widget 树渲染
→ 提取所有 := 命名的 Widget
→ 建立 WidgetUid → widget_name 映射
→ 用户点击时,从 ButtonAction 获取 WidgetUid
→ 查 uid_map 得到 widget_name
→ 通过 WS/HTTP 发送 {"event": "click", "widget": "play_btn"}
某些 widget 名称有特殊处理:audio_toggle、play_btn、audio_stop 直接触发 Canvas 内置的音频控制,不发送到 Agent。
设计决策与权衡
为什么用 Mutex<VecDeque> 而不是 channel?
Makepad 的 SignalToUI 是一个轻量级唤醒机制——它只通知 UI 线程"有新东西",不传递数据。数据通过共享的 Mutex<VecDeque> 传递。这比 mpsc::channel 更适合 Makepad 的事件循环模型——UI 线程在 handle_signal 中一次性取出所有命令,而不是逐条轮询 channel。
为什么每次 SplashRender 都替换整个根 Widget 树?
POST /splash 会让 Splash widget 用新代码重新求值,并替换其当前根 View。这看起来"浪费",但实际上:
- Splash 的 Widget 创建和替换路径相对直接,中小型树的成本通常可控
- 这避免了"增量更新"的复杂性——不需要 diff 算法
- AI 每次输出的是完整的 Splash 代码,不是 diff
重要:不要在循环中调用 POST /splash。每次 POST 都会替换根 Widget 树、重新注册 uid_map,并触发一次全量重绘。若周期性反复这样做,布局和重绘开销会持续放大。正确做法是 POST 一次,让 Canvas 中 Splash widget 提供的 fn tick() 和 on_click 驱动后续更新。
为什么只能同时渲染一个应用?
Canvas 的设计是"单画布"——同一时刻只有一个 Splash 应用在运行。这是有意的简化:
- 避免多个应用争夺 GPU 资源
- 简化事件路由(不需要区分事件来自哪个应用)
- 历史应用保存在侧边栏,可以随时切换回来
模式提炼
模式一:渲染器-而非-状态容器
Canvas 的 Rust 代码不包含任何业务逻辑。所有逻辑在 Splash 代码中:
- 状态 →
let state = {...} - 交互 →
on_click: ||{...} - 定时器 → Canvas 中的
fn tick() - UI 更新 →
refresh()/on_render
这种设计让 AI Agent 可以完全控制应用行为——只需要发送不同的 Splash 代码,不需要修改 Canvas 的 Rust 代码。
模式二:Signal-Queue-Process
工作线程产生数据 → 入队 + SignalToUI → UI 线程出队处理
这是 Makepad 中跨线程通信的标准模式。不使用 async/await,不使用 channel。SignalToUI 是最轻量的唤醒机制。
本章小结
| 组件 | 文件 | 职责 |
|---|---|---|
App | app.rs | 主 Widget,处理 Signal、渲染 Splash、路由事件 |
StdioBridge | ws/stdio_bridge.rs | TCP 监听,WS/HTTP 解析,命令入队 |
CanvasCommand | ws/types.rs | 命令协议枚举(11 个变体) |
AudioPlaybackState | audio.rs | 音频状态(原子操作,无锁) |
核心要点:Canvas 是纯渲染器,三线程架构(TCP/UI/Audio),双协议(WS+HTTP),单应用画布。
下一章讲解完整的 Agent-to-App 管线——AI 如何生成 Splash、推送到 Canvas、接收事件(详见第28章:Agent-to-App 管线)。
第28章:Agent-to-App 管线
为什么这很重要
第27章剖析了 Canvas 的内部架构。本章站在 AI Agent 的视角,讲解完整的 Agent-to-App 管线:用户用自然语言描述需求 → AI 生成 Splash 代码 → 推送到 Canvas → 用户看到原生应用 → 用户交互 → 事件回传给 Agent。
这条管线是全书 AI 叙事线的最终实现——从第1章的设计哲学,到第6章的语法设计,到第11章的流式求值,最终在这里汇聚成一个端到端的工作系统。
sequenceDiagram
participant U as 用户
participant A as AI Agent
participant C as Canvas
participant S as 屏幕
U->>A: "做一个番茄钟计时器"
A->>A: 理解需求,生成 Splash 代码
A->>C: POST /splash (Splash 代码)
C->>S: 渲染原生 UI
U->>S: 点击 "Start" 按钮
S->>C: ButtonAction(start_btn)
C->>A: {"event":"click","widget":"start_btn"}
A->>A: 决定下一步操作
管线的五个阶段
阶段一:Canvas 发现
AI Agent 首先需要找到运行中的 Canvas。Canvas 启动时将端口号写入 /tmp/makepad-canvas.port:
PORT=$(cat /tmp/makepad-canvas.port)
curl -s "http://127.0.0.1:$PORT/ping" # {"ok":true}
阶段二:Splash 代码生成
Agent 根据用户的自然语言描述生成 Splash 代码。关键约束:
- 遵守 Splash 语法——属性间通常省略逗号、
height: Fit、#x颜色前缀(详见第6-8章) - 包含完整应用逻辑——状态、事件处理,以及在 Canvas 中按需使用
fn tick()、fn on_audio()等环境约定 - 谨慎依赖外部资源——当前已经有 HTTP 请求和图片资源加载通道,但 AI 生成示例最好优先使用本地文本、矢量和稳定资源,避免外链失败导致演示不可复现
阶段三:推送到 Canvas
批量推送:curl -X POST "http://127.0.0.1:$PORT/splash" --data-binary @app.splash
流式推送(AI 实时生成时):SplashStreamBegin → Append × N → End(详见第11章)
阶段四:事件回传
Canvas 通过 WS 回传按钮事件:{"event": "click", "widget": "start_btn"}
阶段五:迭代
Agent 可以修改代码并重新推送。同名应用原地更新。
四种 Canvas 应用案例
| 应用 | 类型 | 关键特性 | 来源 |
|---|---|---|---|
| pomodoro | 计时器 | Canvas fn tick() + 6 按钮 | examples/pomodoro.splash |
| token-dashboard | 仪表板 | 纯展示 + 模板复用 | examples/token-dashboard.splash |
| music-player | 播放器 | fn on_audio() + 频谱 | examples/music-player.splash |
| claude-monitor | 监控 | 定时刷新 + 多面板 | examples/claude-monitor.splash |
来源:tools/canvas/examples/
这四个界面都以 .splash 文件交付,不需要为每个示例单独编写 Rust 组件;但其中 music-player 这类案例仍依赖 Canvas 宿主提供的音频控制与状态注入,所以不能简单理解为“完全脱离 Rust”。
为什么是 Splash 而不是 HTML
| 维度 | Splash + Canvas | HTML + WebView |
|---|---|---|
| 渲染 | GPU 原生 | DOM |
| 流式渲染 | 原生支持 | 需要 SSR |
| 代码量 | ~50 行 | ~200 行 |
| 跨平台一致性 | 像素级 | 浏览器差异 |
| 启动链路 | 更短 | 通常更长 |
模式提炼
模式一:POST 一次,Splash 内部驱动
不要循环 POST。POST 一次后,on_click、Canvas 中可选的 fn tick(),以及必要时的宿主 / 网络回调会驱动后续交互。
模式二:Agent 只在需要时介入
大多数用户交互由 Splash on_click 处理。Agent 主要在需要宿主侧能力时介入,例如文件操作、本地系统资源、音频播放控制或其他 Canvas 扩展;网络请求本身很多时候已经可以直接在 Splash 中完成。
本章小结
| 阶段 | 操作 | 协议 |
|---|---|---|
| 发现 | /tmp/makepad-canvas.port | 文件 |
| 生成 | AI → Splash 代码 | — |
| 推送 | POST /splash / WS | HTTP/WS |
| 事件 | {"event":"click","widget":"name"} | WS |
| 迭代 | 修改并重推 | 同上 |
下一章讲解自愈循环——AI 如何通过截图检测渲染问题并自动修复(详见第29章:自愈循环与流式渲染)。
第29章:自愈循环与流式渲染
为什么这很重要
Agent-to-App 管线有一个致命弱点:AI 生成的代码不一定正确。漏写 height: Fit 就让 UI 空白,错误颜色值让文字不可见。自愈循环解决这个问题——推送代码后截图检查,发现问题就修复并重推。最多 3 次迭代。
flowchart TD
A["AI 生成 Splash"] --> B["推送到 Canvas"]
B --> C["截图"]
C --> D{"UI 正确?"}
D -->|是| E["完成"]
D -->|否| F["分析问题"]
F --> G["修复 Splash"]
G --> B
G -.->|"最多 3 次"| D
style E fill:#51cf66,color:#111
style F fill:#ff6b6b,color:#fff
三次迭代模式
迭代 1(结构修复):空白屏幕 → 添加 height: Fit,修改容器类型
迭代 2(视觉修复):内容可见但样式问题 → 修复 draw_text.color、添加 new_batch: true
迭代 3(微调):布局基本正确 → 调整 spacing、padding、alignment
Top 5 常见渲染问题
| # | 症状 | 原因 | 修复 |
|---|---|---|---|
| 1 | 整个 UI 空白 | 缺少 height: Fit | 添加到每个容器 |
| 2 | 文字不可见 | draw_text.color 与背景同色 | 设置对比色 |
| 3 | 背景不显示 | 在 View 上设 draw_bg.color | 改用 SolidView/RoundedView |
| 4 | 文字被遮盖 | 缺少 new_batch: true | 添加(详见第7章) |
| 5 | 颜色解析错误 | #1e1e2e 中 e 被当指数 | 使用 #x 前缀 |
流式渲染的实际应用
AI 生成代码时,LLM 输出是逐 token 的。Agent 可以将每段新输出通过 SplashStreamAppend 发送:
LLM tokens 1-10: "SolidView{width: Fill"
→ StreamAppend → Canvas 显示背景
LLM tokens 11-25: " height: Fit\nLabel{text: \"Hello\""
→ StreamAppend → 背景 + "Hello" 出现
LLM tokens 26-40: "}\nButton{text: \"Click\"}"
→ StreamEnd → 完整 UI
用户在 AI "思考"时就看到 UI 逐步成型。总时间不变,但感知等待大幅降低(详见第11章:批量 vs 流式对比)。
截图分析策略
| 策略 | 检测 | 复杂度 |
|---|---|---|
| 空白检测 | 大部分像素同色 → UI 未渲染 | 低 |
| 元素计数 | 代码有 4 卡片但只见 1 个 | 中 |
| 文字可见性 | 有 Label 但截图无文字 | 中 |
| 布局合理性 | 按钮位置、列表间距 | 高 |
这些策略不需要精确——Agent 只需判断"大致正确"。3 次迭代的代价很低。
局限
| 能修复(渲染层) | 不能修复(逻辑层) |
|---|---|
| height: Fit 遗漏 | 计时器逻辑错误 |
| 颜色值错误 | 状态管理 bug |
| 容器类型选错 | 事件处理逻辑 |
逻辑错误需要用户反馈,不是截图能发现的。
模式提炼
预防优于修复
遵循 Splash 规则(第6-8章)生成的代码,通常第一次就能正确渲染。Canvas 的 skills/app/SKILL.md 将规则编码为 Agent 的 system prompt:
- 每个容器
height: Fit - 颜色用
#x前缀 - 有背景+文字加
new_batch: true - 用
SolidView/RoundedView不用View做背景
本章小结
| 概念 | 说明 |
|---|---|
| 自愈循环 | 推送→截图→检测→修复→重推(最多 3 次) |
| 流式渲染 | AI 逐 token 推送,UI 逐步成型 |
| 预防策略 | 遵循 Splash 规则,减少修复需求 |
Part VI(AI-Native 篇)核心三章完成。接下来是 ch30-32 和 Part IV-V。
第30章:音频可视化案例
为什么这很重要
前两章介绍了 Canvas 架构(详见第27章)和 Agent-to-App 管线(详见第28章)。本章用一个完整的音频可视化案例,展示 Makepad 2.0 如何将音频解码、FFT 频谱分析、GPU 着色器渲染和 Splash 脚本整合起来。这里的音频能力属于 Canvas 宿主提供的共享服务:Splash 可以通过约定命名和 on_audio() 消费播放状态,但真正的音频加载与播放启动仍由 Canvas 命令 / HTTP 端点触发。
flowchart LR
subgraph 音频线程
D["Symphonia 解码"] --> P["PCM 播放"]
P --> F["FFT 2048点"]
F --> B["16 频段"]
end
subgraph UI线程
B -->|"Mutex<br/>~15Hz"| V["Visualizer Widget"]
V --> S["GPU Shader 渲染"]
end
subgraph Splash
SC["on_audio 回调"] --> L["更新 Label"]
end
B -->|"SignalToUI"| SC
style D fill:#51cf66,color:#111
style S fill:#845ef7,color:#fff
音频管线总览
Canvas 的音频管线分为四层:
| 层 | 文件 | 职责 |
|---|---|---|
| 状态单例 | audio.rs | AudioPlaybackState — 全局共享播放状态 |
| 解码器 | audio.rs | Symphonia 解码 MP3/AAC,输出交错 PCM |
| 频谱分析 | spectrum.rs | Hann 窗 + rustfft,输出 16 个对数频段 |
| 可视化 | visualizer.rs | DrawVisualizer + GPU shader,逐帧渲染 |
AudioPlaybackState:无锁状态共享
音频回调运行在实时线程,不能用常规 Mutex 阻塞。Canvas 采用原子变量 + 最小锁范围的设计:
#![allow(unused)] fn main() { pub struct AudioPlaybackState { pub is_playing: AtomicBool, // 原子布尔,零开销 pub amplitude: F64Atomic, // 自定义原子 f64(AtomicU64 + to_bits) pub position_secs: F64Atomic, pub play_cursor: AtomicUsize, // 播放位置(帧索引) pub samples: Mutex<Vec<f32>>, // PCM 数据,仅加载时写入 pub spectrum: Mutex<[f32; 16]>, // FFT 频段,try_lock 非阻塞 } }
spectrum 使用 try_lock——UI 线程正在读取时,音频线程跳过本次写入,不阻塞。状态通过 OnceLock<Arc<AudioPlaybackState>> 实现全局单例。
Symphonia 解码
decode_audio_bytes 使用 Symphonia 库解码 MP3/AAC:Probe 探测格式 -> 获取 Track 参数 -> 循环 next_packet() + decode() -> SampleBuffer 拷贝到 Vec<f32>。解码后通过 load_samples 写入状态单例。Canvas 还实现了两级缓存(内存 HashMap + 磁盘),避免重复下载。
16 频段 FFT 频谱分析
SpectrumAnalyzer(spectrum.rs)对 2048 点采样做 FFT,输出 16 个频段。三个关键步骤:
- Hann 窗:
w(i) = 0.5 * (1 - cos(2PI * i / (N-1))),减少频谱泄漏 - 对数频段映射:
lo = (band/16)^2 * nyquist,低频段占更多 bin,符合听觉特性 - 非对称平滑:上升快(70% 新值),下降慢(15% 新值),让节拍清晰、衰减流畅
#![allow(unused)] fn main() { self.bands[band] = if normalized > prev { prev * 0.3 + normalized * 0.7 } else { prev * 0.85 + normalized * 0.15 }; }
Visualizer Widget 与 GPU Shader
DrawVisualizer 将 16 个频段值(b0~b15)、time、amplitude 作为 shader instance 变量传入 GPU。Visualizer widget 通过 NextFrame 机制逐帧从 AudioPlaybackState 读取数据并触发重绘(详见第10章动画系统)。
内置可视化效果
Canvas 预定义了两种可视化 widget,都通过 script_mod! 在 Splash DSL 中注册。
SpectrumBars:柱状频谱
在像素着色器中,根据 UV 坐标确定当前像素属于哪个频段,再与频段值比较决定是否绘制:
band_idx = floor(uv.x / (1/16)) // 16 列
bar_h = bands[band_idx] * 0.85 // 柱高
in_bar = step(1-uv.y, bar_h) // 像素在柱内?
颜色使用余弦彩虹:0.5 + 0.5 * cos(2PI * (hue + offset)),hue 随 x 位置变化。顶部有指数衰减的辉光效果 exp(-|distance| * 12)。
SpectrumCircular:圆环频谱
将频段映射到极坐标角度,频段值控制圆环半径:
angle = atan2(p.y, p.x)
norm_angle = (angle + PI) / 2PI
band_idx = floor(norm_angle * 16)
ring_radius = 0.3 + bands[band_idx] * 0.35
内圈有振幅驱动的辉光效果:exp(-radius * 3) * amplitude * 0.4。
music-player.splash 案例分析
tools/canvas/examples/music-player.splash 是一个播放器界面示例,展示了 Splash 脚本如何与 Canvas 音频服务协作:
let player = { track: 0 }
let songs = [
{name: "Ambient Flow" artist: "SoundHelix"}
{name: "Electronic Pulse" artist: "SoundHelix"}
]
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") }
}
关键设计模式
- 约定命名触发宿主控制:按钮命名为
play_btn和audio_stop时,Canvas 的handle_actions会把点击路由到AudioPlaybackState::toggle/stop。 on_audio回调:Canvas 以约 10Hz 频率将播放状态(_pos、_dur、_playing)注入 Splash VM 全局变量,并调用on_audio函数更新 UI。- 示例里的 Prev/Next 只更新界面元数据:
music-player.splash中这两个按钮只修改player.track和标签文字;真正加载不同音频 URL 的是外部驱动脚本 /POST /audio/play,不是这两个on_click本身。
sequenceDiagram
participant A as 音频线程
participant S as AudioPlaybackState
participant C as Canvas App
participant V as Splash VM
A->>S: 更新 amplitude, spectrum, position
A->>C: SignalToUI.set()
C->>S: 读取 position, duration, playing
C->>V: 注入 _pos, _dur, _playing
C->>V: 调用 on_audio()
V->>V: ui.time_cur.set_text(...)
扩展:自定义可视化着色器
由于 SpectrumBars 和 SpectrumCircular 都通过 script_mod! 注册,你可以在 Splash 脚本中用 draw_bg +: 语法覆盖像素着色器(详见第19章 SDF 着色器):
SpectrumBars{
width: Fill height: 200
draw_bg +: {
pixel: fn() {
// 你的自定义着色器
let uv = self.pos
let energy = self.b0 + self.b1 + self.b2 + self.b3
return vec4(energy, uv.y * energy, 0.2, 1.0)
}
}
}
所有 self.b0 ~ self.b15、self.time、self.amplitude 变量在着色器中都可用。
本章小结
- Canvas 音频管线由四层组成:状态单例、Symphonia 解码、FFT 频谱分析、GPU 可视化
AudioPlaybackState使用原子变量和try_lock实现音频线程与 UI 线程的无阻塞通信SpectrumAnalyzer对 2048 点 FFT 做 Hann 窗、对数频段映射和非对称平滑SpectrumBars和SpectrumCircular在 GPU shader 中实时渲染 16 频段数据- Splash 脚本通过约定命名(
play_btn、audio_stop)和on_audio回调消费 Canvas 音频状态 - 真正的音频加载 / 播放启动由 Canvas
AudioPlay命令或POST /audio/play触发,示例里的 Prev/Next 主要负责更新界面上的曲目信息 - 可视化着色器可通过
draw_bg +:在 Splash 中自由扩展(详见第19章)
第31章:构建你的 AI 渲染器
为什么这很重要
第27章剖析了 Canvas 架构,第28章展示了 Agent-to-App 管线。本章带你从零构建一个精简版 Canvas——接受 WS 连接、渲染 Splash 代码、回传按钮事件。完成后你将拥有一个可被任何 AI Agent 驱动的原生 UI 渲染器。
flowchart LR
A["AI Agent / 脚本"] -->|"WS: {splash: code}"| B["你的渲染器"]
B -->|"WS: {event: click}"| A
B --> C["原生窗口"]
style B fill:#845ef7,color:#fff
第一步:应用骨架
依赖:makepad-widgets、tokio(rt-multi-thread + net)、tokio-tungstenite、serde_json。
flowchart TD
subgraph Rust应用
A["App (MatchEvent)"] --> B["Splash Widget"]
A --> C["WS 服务线程"]
C -->|"RenderCommand"| Q["Mutex<VecDeque>"]
Q -->|"SignalToUI"| A
A -->|"Splash.set_text"| B
end
subgraph 外部
D["WS 客户端"] <-->|"JSON"| C
end
核心数据结构——命令队列连接 WS 线程和 UI 线程:
#![allow(unused)] fn main() { pub enum RenderCommand { SetSplash(String), Clear } pub struct CommandQueue { pub commands: Mutex<VecDeque<RenderCommand>>, pub signal: SignalToUI, } }
UI 定义使用 script_mod!,只需一个 Window + Splash widget:
#![allow(unused)] fn main() { script_mod! { use mod.prelude.widgets.* startup() do #(App::script_component(vm)){ ui: Root{ Window{ body +: { View{width: Fill height: Fill splash_panel := Splash{width: Fill height: Fit} }} }} } } }
第二步:WebSocket 服务
在独立线程中启动 tokio runtime,监听 WS 连接,解析 JSON 后入队:
#![allow(unused)] fn main() { pub fn start_ws_server(queue: Arc<CommandQueue>, port: u16) { std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await.unwrap(); while let Ok((stream, _)) = listener.accept().await { let q = queue.clone(); tokio::spawn(async move { let ws = accept_async(stream).await.unwrap(); let (_sink, mut source) = ws.split(); while let Some(Ok(msg)) = source.next().await { if let Ok(text) = msg.to_text() { if let Ok(json) = serde_json::from_str::<Value>(text) { if let Some(code) = json.get("splash").and_then(|v| v.as_str()) { q.commands.lock().unwrap() .push_back(RenderCommand::SetSplash(code.into())); q.signal.set(); // 唤醒 UI 线程 } } } } }); } }); }); } }
SignalToUI::set() 触发 UI 线程下一次事件循环中的 handle_signal,比 channel 更轻量。
第三步:渲染 Splash 代码
UI 线程在 handle_signal 中消费命令,把收到的 Splash 文本设置到 Splash widget:
#![allow(unused)] fn main() { impl MatchEvent for App { fn handle_startup(&mut self, cx: &mut Cx) { let queue = Arc::new(CommandQueue { /* ... */ }); start_ws_server(queue.clone(), 9867); self.queue = Some(queue); } fn handle_signal(&mut self, cx: &mut Cx) { for cmd in self.drain_commands() { if let RenderCommand::SetSplash(code) = cmd { let splash = self.ui.widget(cx, ids!(splash_panel)); splash.set_text(cx, &code); } } } } }
这里的核心 API 是 Splash::set_text() / WidgetRef::set_text():接收 Splash 代码字符串,内部求值后替换当前 Splash 的根 View。
第四步:回传按钮事件
在 handle_actions 中捕获 ButtonAction::Clicked,通过 UID 映射找到 widget 名称,广播给所有 WS 客户端:
#![allow(unused)] fn main() { fn handle_actions(&mut self, _cx: &mut Cx, actions: &Actions) { for action in actions { if let Some(wa) = action.as_widget_action() { if wa.action.downcast_ref::<ButtonAction>() .is_some_and(|a| matches!(a, ButtonAction::Clicked(_))) { let name = self.uid_map.get(&wa.widget_uid) .cloned().unwrap_or_default(); let json = format!(r#"{{"event":"click","widget":"{}"}}"#, name); // 广播给所有 WS 客户端 self.broadcast_event(&json); } } } } }
WS 双向通信:accept 后拆分 sink/source,写入端注册 mpsc::UnboundedSender,UI 线程通过 sender 推送事件,断开时移除。
sequenceDiagram
participant C as WS 客户端
participant R as WS 读取任务
participant W as WS 写入任务
participant U as UI 线程
C->>R: {"splash": "Button{...}"}
R->>U: RenderCommand::SetSplash
U->>U: set_text + 求值
Note over U: 用户点击按钮
U->>W: event_json via channel
W->>C: {"event":"click","widget":"btn"}
测试
# 发送带按钮的 UI
echo '{"splash":"View{flow:Down padding:20 btn:=Button{text:\"Click\"}}"}' \
| websocat ws://127.0.0.1:9867
# 持久连接:发送并接收事件
websocat ws://127.0.0.1:9867
> {"splash":"btn := Button{text:\"Ping\" draw_bg.color:#x44aa66}"}
< {"event":"click","widget":"btn"}
精简版约 200 行 Rust,Canvas 完整实现约 800 行。核心循环相同:收 Splash -> set_text / 求值 -> 渲染 -> 捕获事件 -> 回传。进阶方向:流式渲染(详见第11章)、状态注入、截图自愈(详见第29章)、音频服务(详见第30章)。
本章小结
- 最小 AI 渲染器四个组件:Makepad App、Splash Widget、WS 服务、事件回传
SignalToUI是跨线程唤醒 UI 线程的轻量机制Splash.set_text()接收 Splash 代码字符串,并在内部求值后替换当前内容- 事件回传通过
handle_actions捕获点击,UID 映射到名称后发送 JSON - 完整 Canvas 在此基础上增加流式渲染、音频(详见第30章)、自愈循环(详见第29章)
- 此模式不限于 AI,任何外部程序都可通过 WS 驱动 Makepad 渲染原生 UI
第32章:Makepad 的未来
为什么这很重要
前 31 章覆盖了 Makepad 2.0 的技术全貌——从 Splash 语言到 GPU 渲染,从 Widget 体系到 AI 渲染管线。本章梳理 Makepad 的发展方向和生态规划,帮助读者判断哪些技术投入值得持续关注。
从 1.x 到 2.0:已经发生的变化
timeline
title Makepad 演进时间线
2025-11 : 第一个 Splash 脚本运行
2026-01 : 脚本引擎重构 + Widget 重构 + 渲染器重构
2026-02-12 : Makepad 2.0 正式发布
2026-02-26 : Counter 和 Todo 示例应用
2026-03 : Canvas 音频管线 + Agent-to-App 管线
2026-04 : 自愈循环 + 流式渲染稳定
2.0 的核心转变:
| 维度 | 1.x | 2.0 |
|---|---|---|
| UI 定义 | live_design! 编译期宏 | script_mod! 运行时求值 |
| 修改生效 | 重新编译 | 热重载,无需重新编译 |
| AI 友好度 | 低(需理解 Rust 宏语法) | 高(Splash 是独立脚本语言) |
| 动态性 | 有限 | 完整(运行时生成/替换 widget tree) |
路线图:短期(2026 Q2-Q3)
Splash 语言增强
Splash 目前覆盖了 UI 构建的核心场景,但仍有明确的扩展方向:
- 模块系统:支持
import语句,允许 Splash 脚本引用其他.splash文件。当前所有代码必须在单个脚本中,限制了大型应用的组织能力。 - 类型提示:可选的类型注解(
fn add(a: int, b: int) -> int),改善 AI 生成代码的准确性和错误诊断。 - 异步支持:Splash 目前已有 callback 风格的异步能力(如
net.http_request),但还没有统一的await语法。未来可补齐更直接的异步表达方式。
Widget 生态
- 图表 Widget:折线图、柱状图、饼图——Dashboard 类应用的基础组件
- 富文本编辑器:基于现有
CodeView扩展,支持格式化文本输入 - 数据表格:支持排序、筛选的高性能表格,基于
PortalList虚拟化(详见第15章)
平台覆盖
- WASM 性能优化:减少 Splash VM 在浏览器中的 GC 压力
- Android/iOS 稳定性:解决移动端音频延迟、触摸事件精度等已知问题(详见第26章跨平台)
路线图:中期(2026 Q4 - 2027)
AI 集成深化
Canvas(详见第27章)证明了"AI 生成代码 -> 运行时渲染"的模式可行。下一步是让这个循环更紧密:
flowchart TD
A["当前:AI 生成完整 Splash"] --> B["下一步:AI 增量修改"]
B --> C["目标:AI 理解 Widget 语义"]
A1["POST 完整代码"] --> A
B1["PATCH 局部更新"] --> B
C1["意图级指令<br/>如: 把按钮改成红色"] --> C
style A fill:#4dabf7,color:#fff
style B fill:#845ef7,color:#fff
style C fill:#51cf66,color:#111
具体方向:
- 增量更新协议:目前 Canvas 侧的大粒度更新仍以整段 Splash 替换为主,缺少协议级的局部 patch。计划支持针对特定 widget 的局部更新(
script_patch),减少整树替换开销。 - 语义级 API:让 AI 发送高层意图("在列表末尾加一行")而非底层代码,由 Canvas 端的 Splash 解释器翻译为具体操作。
- 多模态输入:将截图分析能力(详见第29章自愈循环)标准化为 API,任何 AI Agent 都可以调用。
编辑器集成
- LSP for Splash:为 Splash 语言提供 Language Server Protocol 支持——代码补全、错误诊断、跳转定义
- 实时预览:在 VS Code / Zed 等编辑器中嵌入 Splash 预览面板
路线图:长期愿景
运行时 UI 求值作为行业模式
Makepad 2.0 的核心创新不是某个具体技术,而是一个架构模式:将 UI 定义从编译期搬到运行时,用独立脚本语言描述,通过流式求值直接驱动渲染。
这个模式不依赖 Makepad 特有的技术。理论上,其他 UI 框架也可以采用类似设计:
| 框架 | 当前 UI 定义 | 运行时求值方案 |
|---|---|---|
| Makepad | Splash (script_mod!) | 已实现 |
| Flutter | Dart 编译 | Dart VM + hot reload(部分实现) |
| SwiftUI | Swift 编译 | Swift Playground(受限) |
| Compose | Kotlin 编译 | 尚无运行时方案 |
| Web | HTML/JS | 天然运行时(但非原生) |
Makepad 的独特优势在于:Splash 的流式求值是为 AI 生成场景设计的。LLM 逐 token 输出时,UI 可以边生成边渲染,不需要等待完整代码。这是编译型方案做不到的。
社区与生态
- Splash 脚本市场:可复用的 Splash 组件和模板共享平台
- Canvas 插件体系:允许第三方扩展 Canvas 的通用能力;音频能力可以看作这类宿主扩展的一个早期例子(详见第30章)
- 跨框架 Splash:将 Splash 语言规范独立出来,允许其他渲染后端实现 Splash 解释器
已知挑战
技术路线图之外,Makepad 面临的现实挑战:
生态规模
Makepad 的 Widget 生态仍处于早期。与 Flutter(数万个 pub.dev 包)或 React(npm 生态)相比,可用组件数量有限。短期内核心团队需要持续补充基础 Widget。
学习曲线
Makepad 2.0 的技术栈跨度大:Rust + Splash + GPU Shader + 事件系统。对于只熟悉 Web 前端或 Flutter 的开发者,入门需要时间。本书的目标之一就是降低这个门槛。
Splash 语言成熟度
Splash 作为新语言,调试工具、错误信息、文档覆盖都在完善中。运行时错误目前以 eprintln! 输出到 stderr,缺少结构化的错误报告机制。
对开发者的建议
- 现在开始用 Splash 构建原型:运行时求值的优势在原型阶段最明显——修改即见效,无需编译等待。
- 复杂逻辑留在 Rust 端:Splash 适合 UI 布局和简单交互,性能敏感或复杂业务逻辑应在 Rust 中实现,通过
ScriptHook桥接(详见第17章自定义 Widget)。 - 关注 Canvas 模式:即使你不用 Canvas 本身,"WS + Splash + 事件回传"的架构模式可以应用于任何需要动态 UI 的场景(详见第31章)。
- 参与社区:Makepad 仓库的 issue 和 discussion 是获取最新进展的最佳渠道。
本章小结
- Makepad 2.0 的核心转变是从编译期 UI 定义到运行时求值,Splash 语言是这一转变的载体
- 短期路线图聚焦 Splash 语言增强(模块、类型提示)和 Widget 生态扩充
- 中期方向是 AI 集成深化——增量更新、语义级 API、标准化截图分析
- 长期愿景是将运行时 UI 求值推广为行业模式,不限于 Makepad 生态
- 已知挑战包括生态规模、学习曲线和 Splash 语言成熟度
- 开发者现在可以用 Splash 快速原型,用 Canvas 模式(详见第27章至第31章)探索 AI 驱动的 UI 开发
附录A:Splash 语法速查
基本语法
key: value // 属性赋值(空格或逗号分隔)
key: Type{ prop1: val1 prop2: val2 } // 嵌套对象(逗号可选)
key +: { prop: val } // 合并运算符
draw_bg.color: #xf00 // 点路径简写
my_btn := Button{ text: "Click" } // 命名子组件(:=)
Label{ text: "hello" } // 匿名子组件
let MyCard = RoundedView{...} // 模板定义
MyCard{title.text: "New"} // 模板实例化+覆写
颜色
| 格式 | 示例 | 说明 |
|---|---|---|
| RGB 短 | #f00 | 红色 |
| RGB | #ff0000 | 红色 |
| RGBA | #ff0000ff | 红色不透明 |
| 含 e | #x1e1e2e | 必须用 #x |
| 透明 | #0000 | 完全透明 |
| 向量 | vec4(1.0 0.0 0.0 1.0) | RGBA |
尺寸 / 布局 / 对齐
| 属性 | 值 | 说明 |
|---|---|---|
width | Fill / Fit / 200 | 默认 Fill |
height | Fill / Fit / 200 | 必须设 Fit |
flow | Right / Down / Overlay | 排列方向 |
spacing | 10 | 子组件间距 |
padding | 15 / Inset{top: 5 left: 10} | 内边距 |
align | Center / Align{x: 0.5 y: 0.5} | 对齐 |
事件
| 事件 | 语法 | 触发 |
|---|---|---|
| 点击 | on_click: ||{...} | Button 点击 |
| 回车 | on_return: ||{...} / |text|{...} | TextInput 回车 |
| 值变 | on_change: |val|{...} | Slider / TextInput 值变化 |
| 渲染 | on_render: ||{...} | render() 调用 |
| 启动 | on_startup: ||{...} | 应用启动 |
| 定时 | fn tick() {...} | Splash{} / Canvas 中约 1 秒周期调用 |
Widget API
ui.name.set_text("text") // 更新文字
ui.name.text() // 读取文字
ui.view.render() // 触发 on_render
ui.btn.on_click() // 程序化点击
网络请求
let req = net.HttpRequest{
url: "https://api.example.com/data"
method: net.HttpMethod.GET
}
net.http_request(req) do net.HttpEvents{
on_response: |res|{ let data = res.body.parse_json() }
on_error: |e|{ /* 错误处理 */ }
}
方法:GET / POST / PUT / DELETE / HEAD / PATCH
流式:is_streaming: true + on_stream / on_complete
HTML 解析:html_string.parse_html().query("css selector")
八条必记规则
height: Fit— 每个容器必须设置width: Fill— 根容器必须用 Fill(不用固定宽度)#x前缀 — 所有颜色统一使用new_batch: true— 有背景+文字时必须加:=命名 — 需要覆写的子组件用:=SolidView/RoundedView— 需要背景色时用(不用View)- 尾部点号 — 浮点数写
8.不写8 draw_bg +:— 修改子属性用+:(不用:,会替换全部)
附录B:Widget 属性参考
容器
| Widget | 背景 | 形状 | 用途 |
|---|---|---|---|
View | 无 | — | 纯布局 |
SolidView | 纯色 | 矩形 | 页面背景 |
RoundedView | 纯色 | 圆角 | 卡片 |
RoundedShadowView | 色+阴影 | 圆角 | 悬浮卡片 |
CircleView | 纯色 | 圆 | 头像/指示器 |
GradientXView / GradientYView | 渐变 | 矩形 | 装饰 |
CachedView | 纹理缓存 | — | 性能优化 |
ScrollYView / ScrollXView | — | — | 滚动区域 |
容器属性
| 属性 | 值 | 默认 |
|---|---|---|
width | Fill / Fit / 数字 | Fill |
height | Fill / Fit / 数字 | Fill |
flow | Right / Down / Overlay | Right |
spacing | 数字 | 0 |
padding | 数字 / Inset{} | 0 |
align | Center / Align{x: y:} | TopLeft |
show_bg | true / false | false (View) / true (SolidView等) |
new_batch | true / false | false |
visible | true / false | true |
cursor | MouseCursor.Hand 等 | 默认指针 |
draw_bg 属性
| 属性 | 类型 | 适用容器 |
|---|---|---|
draw_bg.color | 颜色 | SolidView, RoundedView 等 |
draw_bg.radius | f32 | RoundedView 系列 |
draw_bg.border_size | f32 | RoundedView, RectView |
draw_bg.border_color | 颜色 | RoundedView, RectView |
draw_bg.shadow_radius | f32 | ShadowView 系列 |
draw_bg.shadow_color | 颜色 | ShadowView 系列 |
draw_bg.color_2 | vec4 | GradientView 系列 |
draw_text 属性
| 属性 | 说明 | 默认 |
|---|---|---|
draw_text.color | 文字颜色 | #fff |
draw_text.text_style.font_size | 字号 | 11 |
draw_text.text_style | 字体 | theme.font_regular |
可用字体:theme.font_regular, theme.font_bold, theme.font_italic, theme.font_bold_italic, theme.font_code, theme.font_icons
文本组件
| Widget | 可编辑 | Animator | 备注 |
|---|---|---|---|
Label | 否 | 否 | 不支持 cursor |
H1-H4 | 否 | 否 | 标题变体 |
TextInput | 是 | 是 | 支持 on_return / on_change |
Markdown | 选择 | 否 | body 属性 |
交互组件
| Widget | 事件 | 样式变体 |
|---|---|---|
Button | on_click / on_press / .clicked() / .pressed() | ButtonFlat, ButtonFlatter |
CheckBox | on_click / .changed() | CheckBoxFlat |
Toggle | on_click / .changed() | ToggleFlat |
RadioButton | .clicked() / RadioButtonSet::selected() | RadioButtonFlat |
Slider | on_change / .changed() | SliderMinimal |
DropDown | .changed() / .selected() | DropDownFlat |
列表
| Widget | 虚拟化 | 适用规模 |
|---|---|---|
PortalList | 是 | 无限 |
FlatList | 否 | < 100 |
高级容器
| Widget | 用途 |
|---|---|
Modal | 模态弹窗 |
PageFlip | 页面切换 |
FoldHeader | 折叠区域 |
Splitter | 分割面板 |
Dock | 可拖拽面板 |
附录C:Shader 内置函数
Sdf2d 形状
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.circle(cx, cy, radius) // 圆
sdf.rect(x, y, w, h) // 矩形
sdf.box(x, y, w, h, radius) // 圆角矩形
sdf.hexagon(cx, cy, radius) // 六边形
sdf.move_to(x, y) // 路径起点
sdf.line_to(x, y) // 路径直线
sdf.arc(cx, cy, r, start, end) // 圆弧
Sdf2d 操作
sdf.fill(color) // 填充
sdf.fill_premul(color) // 预乘填充
sdf.stroke(color, width) // 描边
sdf.glow(color, size) // 发光
sdf.union() // 合并
sdf.intersect() // 相交
sdf.subtract() // 减去
sdf.blend(amount) // 混合
sdf.translate(x, y) // 平移
sdf.rotate(angle, cx, cy) // 旋转
sdf.scale(factor, cx, cy) // 缩放
sdf.result // 最终输出
颜色工具
Pal.premul(color) // 预乘 alpha
#f00.mix(#0f0, 0.5) // 颜色混合
color.mix(other, self.hover) // 动态混合
vec4(r, g, b, a) // 构造颜色
GaussShadow
GaussShadow.box_shadow(self.pos, offset, spread, radius, color)
变量类型
| 类型 | 声明 | Animator | 说明 |
|---|---|---|---|
uniform() | color: uniform(#x334) | 不可驱动 | 全实例共享 |
instance() | hover: instance(0.0) | 可驱动 | 每实例独有 |
内置 self 变量
| 变量 | 类型 | 说明 |
|---|---|---|
self.pos | vec2 | 归一化坐标 (0-1) |
self.rect_size | vec2 | Widget 像素尺寸 |
self.rect_pos | vec2 | Widget 屏幕位置 |
pixel fn 模板
draw_bg +: {
color: uniform(#x334)
hover: instance(0.0)
pixel: fn(){
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.box(1. 1. self.rect_size.x - 2. self.rect_size.y - 2. 4.)
sdf.fill(self.color.mix(self.color_hover, self.hover))
return sdf.result
}
}
附录D:从 1.x 迁移到 2.0
核心变化
| 维度 | Makepad 1.x | Makepad 2.0 |
|---|---|---|
| UI 宏 | live_design!{...} | script_mod!{...} |
| UI 语言 | 声明式数据格式 | Splash 脚本语言 |
| 求值方式 | 宏提取 + 运行时解析 | 完整 VM 执行 |
| 动态能力 | 静态属性声明 | 变量、函数、闭包、条件渲染 |
| Widget 语法 | <Widget> 尖括号 | Widget{} 花括号 |
| 属性赋值 | Key = Value | Key: value |
| 主题引用 | (THEME_COLOR) | theme.color |
| 生命周期 | LiveHook | ScriptHook |
| 属性覆写 | apply_over | script_eval! |
语法对照
Widget 声明
// 1.x
<View> {
width: Fill, height: Fill
<Label> { text: "Hello" }
}
// 2.0
View{
width: Fill height: Fill
Label{text: "Hello"}
}
模板定义
// 1.x
MyCard = <RoundedView> {
draw_bg: { color: #334 }
title = <Label> { text: "default" }
}
// 2.0
let MyCard = RoundedView{
draw_bg.color: #x334
title := Label{text: "default"}
}
事件处理
#![allow(unused)] fn main() { // 1.x impl LiveHook for App { fn after_apply(&mut self, cx: &mut Cx, ...) { ... } } impl MatchEvent for App { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { if self.ui.button(id!(my_btn)).clicked(actions) { self.ui.label(id!(my_label)).apply_over(cx, live!{ text: "clicked!" }); } } } // 2.0 impl MatchEvent for App { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { if self.ui.button(cx, ids!(my_btn)).clicked(actions) { script_eval!(cx, { ui.my_label.set_text("clicked!") }); } } } }
关键差异
| 1.x | 2.0 | 说明 |
|---|---|---|
id!(name) | ids!(name) | Widget 查找 |
apply_over + live!{} | script_eval!{} | 运行时修改 UI |
LiveHook trait | ScriptHook derive | 生命周期钩子 |
#[live] 属性 | #[live] 属性 | 不变 |
| 逗号分隔属性 | 空格分隔(逗号仍可用) | 逗号被当作空白,新代码推荐省略 |
<Widget> 尖括号 | Widget{} 花括号 | 统一为花括号 |
迁移步骤
- 替换宏:
live_design!→script_mod! - 替换语法:
<Widget>→Widget{},属性间通常省略逗号,=→: - 替换命名:
name =→name := - 替换主题:
(THEME_COLOR)→theme.color - 替换事件:
apply_over+live!→script_eval! - 替换查找:
id!(name)→ids!(name) - 添加脚本能力:利用
on_click、on_render,以及在 Canvas /Splash{}环境中可用的fn tick()等能力
附录E:AI Prompt 模式
本附录总结了引导 AI Agent 生成正确 Splash 代码的 prompt 模式。这些模式来自 Canvas 项目的实践经验。
基础 Prompt 结构
你是一个 Makepad Splash 代码生成器。
规则:
1. 每个容器必须有 height: Fit
2. 颜色统一使用 #x 前缀
3. 有背景+文字的容器加 new_batch: true
4. 需要背景用 SolidView/RoundedView,不用 View
5. 浮点数用尾部点号(8. 不是 8)
6. 不要使用不存在的属性名
7. 输出纯 Splash 代码,无解释
用户需求:{描述}
常见应用类型的 Prompt
计时器/工具
创建一个 {工具名} 应用。
- 状态定义用 let state = {...}
- Canvas 定时器用 fn tick()
- 交互用 on_click: ||{...}
- UI 更新用 refresh() + set_text()
数据仪表板
创建一个数据仪表板,展示以下指标:{指标列表}
- 使用 let 定义卡片模板(RoundedView + Label × 2)
- 模板中标题小字灰色,数值大字彩色
- 卡片水平排列(flow: Right)
- 每个卡片覆写标题、数值、颜色
列表/表单
创建一个 {应用名},包含输入和列表。
- TextInput + Button 做输入区
- on_return 触发添加逻辑
- 列表项用 let 定义模板
- on_render 中用 while 循环生成列表项
自愈 Prompt
当截图检测到问题时:
当前 Splash 代码渲染出现问题:{问题描述}
请修复以下代码:
{当前代码}
常见修复:
- 空白 → 添加 height: Fit
- 文字不可见 → 检查 draw_text.color 对比度
- 背景不显示 → View 改为 SolidView/RoundedView
- 文字被遮盖 → 添加 new_batch: true
模板覆写 Prompt
基于以下模板,创建 {N} 个实例:
{模板定义}
每个实例的差异:
- 实例 1: title="...", value="...", color=...
- 实例 2: ...
使用 TemplateName{field.text: "value"} 语法覆写。
AI 友好的代码结构
AI 生成的 Splash 代码应遵循这个结构:
// 1. 状态定义
let state = { ... }
// 2. 辅助函数
fn refresh() { ... }
fn tick() { ... } // Canvas / Splash widget 中如需定时器
// 3. 模板定义(如需复用)
let CardTemplate = RoundedView{ ... }
// 4. UI 树
SolidView{width: Fill height: Fit draw_bg.color: #x... flow: Down
// 标题区
// 内容区(使用模板实例)
// 操作区(按钮 + on_click)
}
顺序重要:状态 → 函数 → 模板 → UI。let 必须在使用前定义(详见第8章)。
流式生成的优化
AI 生成 Splash 时,输出顺序影响流式渲染效果:
- 先输出容器结构(用户看到布局框架)
- 再输出文字内容(用户看到信息)
- 最后输出样式细节(颜色、字号、圆角)
这样用户在前几百毫秒就能看到有意义的 UI 结构,而不是等到所有细节都输出完才看到东西(详见第11章:流式求值,第29章:自愈循环)。